railmux 0.4.0.dev25__tar.gz → 0.4.0.dev27__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/MANIFEST.in +1 -0
- {railmux-0.4.0.dev25/src/railmux.egg-info → railmux-0.4.0.dev27}/PKG-INFO +1 -1
- railmux-0.4.0.dev27/src/railmux/__init__.py +1 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_install_log.py +18 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_msys2.py +207 -14
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_ui_transition.py +2 -2
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27/src/railmux.egg-info}/PKG-INFO +1 -1
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/SOURCES.txt +2 -1
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_install_log.py +25 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_msys2.py +179 -15
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_ui_transition.py +3 -3
- railmux-0.4.0.dev27/tools/windows_archive_smoke.py +67 -0
- railmux-0.4.0.dev25/src/railmux/__init__.py +0 -1
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/LICENSE +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/README.md +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/pyproject.toml +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/setup.cfg +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/__main__.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/atomic_file.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/background_index.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/cli.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/codex_index.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/config.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/config_cli.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/diagnostics.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/discovery.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/display_transport.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/entrypoint.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_client.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_history.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_protocol.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_server.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/favorites.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fuzzy.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/help_workspace.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/launcher.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/local_open.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/models.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/modes.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/mouse_manager.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/orphan_marker.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/pane_surface.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/path_codec.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/provider_paths.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/remote_config.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/renames.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/restart_state.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/runtime_config.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/self_update.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/session_cache.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/session_index.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/session_lease.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/settings.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ssh_args.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/system_deps.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/terminal_status.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tmux_ctl.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tmux_health.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tmux_server.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tool_panes.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/transcript.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/app.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/modals.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/projects_pane.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/running_pane.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/sessions_pane.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_attach_relay.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_pacman.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_agent_workspace.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_atomic_file.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_background_index.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_cli.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_codex_delete.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_codex_index.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_codex_lineage.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_codex_transcript.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_config.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_config_cli.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_diagnostics.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_discovery.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_display_transport.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_fast_full_window.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_focus_highlight.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_fullscreen_binding.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_fuzzy.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_help_agent.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_help_workspace.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_history_cleanup.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_keymap.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_launcher.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_layout_profile.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_local_clipboard.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_local_open.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_modals.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_mode_selection.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_modes.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_mouse_manager.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_new_project.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_options.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_orphan_recovery.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_pane_surface.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_path_codec.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_projects_pane.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_provider_paths.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_release_notes.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_release_workflow.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_remote_config.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_renames.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_restart_state.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_running_pane.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_running_sort.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_runtime_config.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_scroll_agent.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_scroll_manager.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_selection_isolation.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_self_update.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_session_cache.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_session_index.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_session_lease.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_sessions_pane.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_setting_contracts.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_settings.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_sidebar.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_soft_quit.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_args.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_compat.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_status.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_statusbar.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_system_deps.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_terminal_size.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_terminal_status.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_ctl.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_health.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_integration.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_server.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_status.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tool_panes.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_transcript.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_utf8_state.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_widgets.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_attach_relay.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_pacman.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_wrapper_contract.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tools/release_notes.py +0 -0
- {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tools/wheel_smoke.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0.dev27"
|
|
@@ -235,6 +235,24 @@ class InstallReporter:
|
|
|
235
235
|
self._consume_output_line(self._pending_output)
|
|
236
236
|
self._pending_output = ""
|
|
237
237
|
|
|
238
|
+
def extraction_progress(self, completed: int, total: int) -> None:
|
|
239
|
+
"""Report bounded in-process archive extraction progress."""
|
|
240
|
+
if total <= 0:
|
|
241
|
+
return
|
|
242
|
+
completed = min(max(completed, 0), total)
|
|
243
|
+
percent = min(100, int(completed * 100 / total))
|
|
244
|
+
self._progress_detail = (
|
|
245
|
+
f"extracting private runtime: {completed}/{total} files "
|
|
246
|
+
f"({percent}%)"
|
|
247
|
+
)
|
|
248
|
+
if percent >= self._last_extraction_percent + 5 or percent == 100:
|
|
249
|
+
self.note(
|
|
250
|
+
f"Extracting private runtime: {completed}/{total} files "
|
|
251
|
+
f"({percent}%)"
|
|
252
|
+
)
|
|
253
|
+
self._last_extraction_percent = percent
|
|
254
|
+
self.heartbeat()
|
|
255
|
+
|
|
238
256
|
def _consume_output_line(self, raw_line: str) -> None:
|
|
239
257
|
line = _redact_line(raw_line)
|
|
240
258
|
self._write_log(f"{line}\n")
|
|
@@ -10,6 +10,7 @@ import concurrent.futures
|
|
|
10
10
|
import hashlib
|
|
11
11
|
import http.client
|
|
12
12
|
import json
|
|
13
|
+
import lzma
|
|
13
14
|
import os
|
|
14
15
|
import re
|
|
15
16
|
import secrets
|
|
@@ -17,6 +18,7 @@ import shutil
|
|
|
17
18
|
import stat
|
|
18
19
|
import subprocess
|
|
19
20
|
import sys
|
|
21
|
+
import tarfile
|
|
20
22
|
import tempfile
|
|
21
23
|
import time
|
|
22
24
|
import urllib.error
|
|
@@ -25,7 +27,7 @@ import urllib.request
|
|
|
25
27
|
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
26
28
|
from contextlib import contextmanager
|
|
27
29
|
from dataclasses import dataclass
|
|
28
|
-
from pathlib import Path
|
|
30
|
+
from pathlib import Path, PurePosixPath
|
|
29
31
|
|
|
30
32
|
from railmux.windows_install_log import (
|
|
31
33
|
InstallReporter,
|
|
@@ -44,7 +46,7 @@ from railmux.terminal_status import STYLE_ACCENT, STYLE_MUTED, styled
|
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
MSYS2_RELEASE = "2026-03-22"
|
|
47
|
-
MSYS2_ARCHIVE_NAME = f"msys2-base-x86_64-{MSYS2_RELEASE.replace('-', '')}.
|
|
49
|
+
MSYS2_ARCHIVE_NAME = f"msys2-base-x86_64-{MSYS2_RELEASE.replace('-', '')}.tar.xz"
|
|
48
50
|
MSYS2_ARCHIVE_SOURCES = (
|
|
49
51
|
(
|
|
50
52
|
"GitHub",
|
|
@@ -65,11 +67,20 @@ MSYS2_ARCHIVE_SOURCES = (
|
|
|
65
67
|
f"https://mirror.nju.edu.cn/msys2/distrib/x86_64/{MSYS2_ARCHIVE_NAME}",
|
|
66
68
|
),
|
|
67
69
|
)
|
|
68
|
-
MSYS2_ARCHIVE_SIZE =
|
|
70
|
+
MSYS2_ARCHIVE_SIZE = 53_466_096
|
|
69
71
|
MSYS2_ARCHIVE_SHA256 = (
|
|
72
|
+
"6b4a986a3ec4f1e40313bdf17903a6f5c854373d4230c40f14c5e35c4bac7fce"
|
|
73
|
+
)
|
|
74
|
+
MSYS2_ARCHIVE_MEMBER_COUNT = 16_485
|
|
75
|
+
MSYS2_ARCHIVE_UNPACKED_SIZE = 289_361_533
|
|
76
|
+
# Schema-1 base-content markers released in dev24/dev25 use the SFX digest as
|
|
77
|
+
# their pinned release-lineage token. Keep accepting and writing that durable
|
|
78
|
+
# value while the bootstrap consumes the equivalent official tar.xz artifact.
|
|
79
|
+
MSYS2_BASE_LINEAGE_SHA256 = (
|
|
70
80
|
"6fe0cc8154132040e034ff4daface2a4163a9d1f6ebaaa1133394bff460bd5cf"
|
|
71
81
|
)
|
|
72
82
|
MSYS2_RUNTIME_ID = f"msys2-{MSYS2_RELEASE}"
|
|
83
|
+
_NATIVE_WINDOWS = os.name == "nt"
|
|
73
84
|
RUNTIME_SCHEMA = 1
|
|
74
85
|
|
|
75
86
|
_RUNTIME_OVERRIDE = "RAILMUX_MSYS2_ROOT"
|
|
@@ -141,7 +152,7 @@ class BaseContentIdentity:
|
|
|
141
152
|
return {
|
|
142
153
|
"schema": 1,
|
|
143
154
|
"runtime": MSYS2_RUNTIME_ID,
|
|
144
|
-
"archive_sha256":
|
|
155
|
+
"archive_sha256": MSYS2_BASE_LINEAGE_SHA256,
|
|
145
156
|
"content_id": self.content_id,
|
|
146
157
|
"package_count": self.package_count,
|
|
147
158
|
"core_packages": dict(self.core_packages),
|
|
@@ -166,6 +177,7 @@ Probe = Callable[..., subprocess.CompletedProcess[bytes]]
|
|
|
166
177
|
Runner = Callable[..., subprocess.CompletedProcess]
|
|
167
178
|
Downloader = Callable[[str, Path, str], None]
|
|
168
179
|
MirrorOptimizer = Callable[[Path], PacmanMirrorDecision]
|
|
180
|
+
Extractor = Callable[..., None]
|
|
169
181
|
|
|
170
182
|
|
|
171
183
|
@dataclass(frozen=True)
|
|
@@ -519,9 +531,20 @@ def _managed_cache(environ: Mapping[str, str]) -> Path | None:
|
|
|
519
531
|
return Path(local_app_data) / "Railmux" / "cache"
|
|
520
532
|
|
|
521
533
|
|
|
534
|
+
def _path_is_link_or_reparse(path: Path) -> bool:
|
|
535
|
+
info = path.lstat()
|
|
536
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
537
|
+
return stat.S_ISLNK(info.st_mode) or bool(
|
|
538
|
+
getattr(info, "st_file_attributes", 0) & reparse_flag
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
522
542
|
def _archive_cache_is_valid(path: Path) -> bool:
|
|
523
543
|
try:
|
|
524
|
-
if
|
|
544
|
+
if (
|
|
545
|
+
_path_is_link_or_reparse(path)
|
|
546
|
+
or path.stat().st_size != MSYS2_ARCHIVE_SIZE
|
|
547
|
+
):
|
|
525
548
|
return False
|
|
526
549
|
digest = hashlib.sha256()
|
|
527
550
|
with path.open("rb") as stream:
|
|
@@ -553,6 +576,176 @@ def _prepare_cached_archive(
|
|
|
553
576
|
return archive, source
|
|
554
577
|
|
|
555
578
|
|
|
579
|
+
def _safe_archive_parts(member: tarfile.TarInfo) -> tuple[str, ...]:
|
|
580
|
+
name = member.name
|
|
581
|
+
raw_parts = name.split("/")
|
|
582
|
+
pure = PurePosixPath(name)
|
|
583
|
+
if (
|
|
584
|
+
not name
|
|
585
|
+
or "\\" in name
|
|
586
|
+
or pure.is_absolute()
|
|
587
|
+
or any(part in {"", ".", ".."} for part in raw_parts)
|
|
588
|
+
or not raw_parts
|
|
589
|
+
or raw_parts[0] != "msys64"
|
|
590
|
+
):
|
|
591
|
+
raise RuntimeInstallError(
|
|
592
|
+
"the verified MSYS2 archive contained an unsafe path"
|
|
593
|
+
)
|
|
594
|
+
return tuple(raw_parts)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _ensure_safe_archive_directory(root: Path, parts: Sequence[str]) -> Path:
|
|
598
|
+
current = root
|
|
599
|
+
for part in parts:
|
|
600
|
+
candidate = current / part
|
|
601
|
+
try:
|
|
602
|
+
if _path_is_link_or_reparse(candidate) or not candidate.is_dir():
|
|
603
|
+
raise RuntimeInstallError(
|
|
604
|
+
"the MSYS2 extraction staging directory became unsafe"
|
|
605
|
+
)
|
|
606
|
+
except FileNotFoundError:
|
|
607
|
+
try:
|
|
608
|
+
candidate.mkdir()
|
|
609
|
+
except OSError as exc:
|
|
610
|
+
raise RuntimeInstallError(
|
|
611
|
+
"could not create the MSYS2 extraction staging directory"
|
|
612
|
+
) from exc
|
|
613
|
+
if _path_is_link_or_reparse(candidate) or not candidate.is_dir():
|
|
614
|
+
raise RuntimeInstallError(
|
|
615
|
+
"the MSYS2 extraction staging directory became unsafe"
|
|
616
|
+
)
|
|
617
|
+
except OSError as exc:
|
|
618
|
+
raise RuntimeInstallError(
|
|
619
|
+
"could not inspect the MSYS2 extraction staging directory"
|
|
620
|
+
) from exc
|
|
621
|
+
current = candidate
|
|
622
|
+
return current
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _apply_archive_mode(path: Path, mode: int) -> None:
|
|
626
|
+
if _NATIVE_WINDOWS:
|
|
627
|
+
# Windows chmod maps a missing owner-write bit to the NTFS read-only
|
|
628
|
+
# attribute; applying tar's POSIX 0444/0555 modes would prevent pacman
|
|
629
|
+
# from replacing those files during the first base update.
|
|
630
|
+
os.chmod(path, stat.S_IWRITE)
|
|
631
|
+
else:
|
|
632
|
+
os.chmod(path, mode)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _extract_msys2_archive(
|
|
636
|
+
archive: Path,
|
|
637
|
+
destination: Path,
|
|
638
|
+
*,
|
|
639
|
+
reporter: InstallReporter,
|
|
640
|
+
) -> None:
|
|
641
|
+
"""Extract the pinned tar.xz without executing downloaded code."""
|
|
642
|
+
reporter.command_started("MSYS2 archive extraction", progress="extract")
|
|
643
|
+
seen: set[tuple[str, ...]] = set()
|
|
644
|
+
directories: list[tuple[Path, int]] = []
|
|
645
|
+
member_count = 0
|
|
646
|
+
unpacked_size = 0
|
|
647
|
+
try:
|
|
648
|
+
if _path_is_link_or_reparse(destination) or not destination.is_dir():
|
|
649
|
+
raise RuntimeInstallError(
|
|
650
|
+
"the MSYS2 extraction staging directory is unsafe"
|
|
651
|
+
)
|
|
652
|
+
with tarfile.open(archive, mode="r|xz") as stream:
|
|
653
|
+
for member in stream:
|
|
654
|
+
member_count += 1
|
|
655
|
+
if member_count > MSYS2_ARCHIVE_MEMBER_COUNT:
|
|
656
|
+
raise RuntimeInstallError(
|
|
657
|
+
"the verified MSYS2 archive member count changed"
|
|
658
|
+
)
|
|
659
|
+
parts = _safe_archive_parts(member)
|
|
660
|
+
if parts in seen:
|
|
661
|
+
raise RuntimeInstallError(
|
|
662
|
+
"the verified MSYS2 archive contained a duplicate path"
|
|
663
|
+
)
|
|
664
|
+
seen.add(parts)
|
|
665
|
+
if not (member.isdir() or member.isreg()):
|
|
666
|
+
raise RuntimeInstallError(
|
|
667
|
+
"the verified MSYS2 archive contained an unsupported link "
|
|
668
|
+
"or special file"
|
|
669
|
+
)
|
|
670
|
+
parent = _ensure_safe_archive_directory(destination, parts[:-1])
|
|
671
|
+
target = parent / parts[-1]
|
|
672
|
+
if member.isdir():
|
|
673
|
+
directory = _ensure_safe_archive_directory(destination, parts)
|
|
674
|
+
directories.append((directory, member.mode & 0o777))
|
|
675
|
+
else:
|
|
676
|
+
if member.size < 0:
|
|
677
|
+
raise RuntimeInstallError(
|
|
678
|
+
"the verified MSYS2 archive contained an invalid file"
|
|
679
|
+
)
|
|
680
|
+
unpacked_size += member.size
|
|
681
|
+
if unpacked_size > MSYS2_ARCHIVE_UNPACKED_SIZE:
|
|
682
|
+
raise RuntimeInstallError(
|
|
683
|
+
"the verified MSYS2 archive expanded beyond its "
|
|
684
|
+
"pinned size"
|
|
685
|
+
)
|
|
686
|
+
try:
|
|
687
|
+
try:
|
|
688
|
+
target.lstat()
|
|
689
|
+
except FileNotFoundError:
|
|
690
|
+
pass
|
|
691
|
+
else:
|
|
692
|
+
raise RuntimeInstallError(
|
|
693
|
+
"the MSYS2 extraction staging path already exists"
|
|
694
|
+
)
|
|
695
|
+
source = stream.extractfile(member)
|
|
696
|
+
if source is None:
|
|
697
|
+
raise RuntimeInstallError(
|
|
698
|
+
"the verified MSYS2 archive file was unreadable"
|
|
699
|
+
)
|
|
700
|
+
remaining = member.size
|
|
701
|
+
with target.open("xb") as output:
|
|
702
|
+
while remaining:
|
|
703
|
+
chunk = source.read(min(1024 * 1024, remaining))
|
|
704
|
+
if not chunk:
|
|
705
|
+
raise RuntimeInstallError(
|
|
706
|
+
"the verified MSYS2 archive ended early"
|
|
707
|
+
)
|
|
708
|
+
output.write(chunk)
|
|
709
|
+
remaining -= len(chunk)
|
|
710
|
+
_apply_archive_mode(target, member.mode & 0o777)
|
|
711
|
+
except RuntimeInstallError:
|
|
712
|
+
raise
|
|
713
|
+
except OSError as exc:
|
|
714
|
+
raise RuntimeInstallError(
|
|
715
|
+
"could not write the MSYS2 extraction staging file"
|
|
716
|
+
) from exc
|
|
717
|
+
reporter.extraction_progress(
|
|
718
|
+
member_count, MSYS2_ARCHIVE_MEMBER_COUNT
|
|
719
|
+
)
|
|
720
|
+
if member_count != MSYS2_ARCHIVE_MEMBER_COUNT:
|
|
721
|
+
raise RuntimeInstallError(
|
|
722
|
+
"the verified MSYS2 archive member count changed"
|
|
723
|
+
)
|
|
724
|
+
if unpacked_size != MSYS2_ARCHIVE_UNPACKED_SIZE:
|
|
725
|
+
raise RuntimeInstallError(
|
|
726
|
+
"the verified MSYS2 archive expanded to an unexpected size"
|
|
727
|
+
)
|
|
728
|
+
for directory, mode in reversed(directories):
|
|
729
|
+
_apply_archive_mode(directory, mode)
|
|
730
|
+
except RuntimeInstallError:
|
|
731
|
+
reporter.note(
|
|
732
|
+
"MSYS2 archive extraction failed; the temporary base was not "
|
|
733
|
+
"published.",
|
|
734
|
+
level="error",
|
|
735
|
+
)
|
|
736
|
+
raise
|
|
737
|
+
except (OSError, lzma.LZMAError, tarfile.TarError, EOFError) as exc:
|
|
738
|
+
reporter.note(
|
|
739
|
+
"MSYS2 archive extraction failed; the temporary base was not "
|
|
740
|
+
"published.",
|
|
741
|
+
level="error",
|
|
742
|
+
)
|
|
743
|
+
raise RuntimeInstallError(
|
|
744
|
+
"the verified MSYS2 tar.xz archive could not be extracted"
|
|
745
|
+
) from exc
|
|
746
|
+
reporter.command_succeeded()
|
|
747
|
+
|
|
748
|
+
|
|
556
749
|
def managed_root(environ: Mapping[str, str]) -> Path | None:
|
|
557
750
|
base = _managed_base(environ)
|
|
558
751
|
if base is None:
|
|
@@ -690,7 +883,7 @@ def _decode_base_content_marker(root: Path) -> BaseContentIdentity | None:
|
|
|
690
883
|
if (
|
|
691
884
|
payload.get("schema") != 1
|
|
692
885
|
or payload.get("runtime") != MSYS2_RUNTIME_ID
|
|
693
|
-
or payload.get("archive_sha256") !=
|
|
886
|
+
or payload.get("archive_sha256") != MSYS2_BASE_LINEAGE_SHA256
|
|
694
887
|
or not isinstance(content_id, str)
|
|
695
888
|
or re.fullmatch(r"[0-9a-f]{64}", content_id) is None
|
|
696
889
|
or not isinstance(package_count, int)
|
|
@@ -1431,6 +1624,12 @@ def _run_checked(
|
|
|
1431
1624
|
if returncode:
|
|
1432
1625
|
if allow_failure:
|
|
1433
1626
|
return returncode
|
|
1627
|
+
if _NATIVE_WINDOWS and returncode & 0xFFFFFFFF == 0xC0000135:
|
|
1628
|
+
reporter.note(
|
|
1629
|
+
"Windows reported STATUS_DLL_NOT_FOUND. Check whether security "
|
|
1630
|
+
"software quarantined a file in the private MSYS2 staging tree.",
|
|
1631
|
+
level="error",
|
|
1632
|
+
)
|
|
1434
1633
|
reporter.command_failed(label, returncode)
|
|
1435
1634
|
raise RuntimeInstallError(f"{label} failed with exit code {returncode}")
|
|
1436
1635
|
reporter.command_succeeded()
|
|
@@ -2022,6 +2221,7 @@ def install_managed_runtime(
|
|
|
2022
2221
|
probe: Probe = _probe,
|
|
2023
2222
|
lock_factory: Callable[[Path], object] = install_lock,
|
|
2024
2223
|
mirror_optimizer: MirrorOptimizer = optimize_pacman_mirror,
|
|
2224
|
+
extractor: Extractor = _extract_msys2_archive,
|
|
2025
2225
|
verbose: bool = False,
|
|
2026
2226
|
reuse_only: bool = False,
|
|
2027
2227
|
) -> Msys2Runtime:
|
|
@@ -2160,14 +2360,7 @@ def install_managed_runtime(
|
|
|
2160
2360
|
reporter.done(source)
|
|
2161
2361
|
|
|
2162
2362
|
reporter.phase(2, 7, "Extracting the private MSYS2 base")
|
|
2163
|
-
|
|
2164
|
-
[str(archive), "-y", f"-o{stage}"],
|
|
2165
|
-
env=environ,
|
|
2166
|
-
reporter=reporter,
|
|
2167
|
-
runner=runner,
|
|
2168
|
-
label="MSYS2 extraction",
|
|
2169
|
-
progress="extract",
|
|
2170
|
-
)
|
|
2363
|
+
extractor(archive, stage, reporter=reporter)
|
|
2171
2364
|
root = stage / "msys64"
|
|
2172
2365
|
if not (root / "usr" / "bin" / "bash.exe").is_file():
|
|
2173
2366
|
raise RuntimeInstallError(
|
|
@@ -23,7 +23,7 @@ from typing import Iterator, Mapping, Sequence
|
|
|
23
23
|
from packaging.version import InvalidVersion, Version
|
|
24
24
|
|
|
25
25
|
from railmux import restart_state, tmux_server
|
|
26
|
-
from railmux.windows_msys2 import
|
|
26
|
+
from railmux.windows_msys2 import MSYS2_BASE_LINEAGE_SHA256
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
CURRENT_APP_OPTION = "@railmux_current_app_v1"
|
|
@@ -136,7 +136,7 @@ def _base_identity(runtime: str) -> str | None:
|
|
|
136
136
|
or not isinstance(content, dict)
|
|
137
137
|
or content.get("schema") != 1
|
|
138
138
|
or content.get("runtime") != runtime
|
|
139
|
-
or content.get("archive_sha256") !=
|
|
139
|
+
or content.get("archive_sha256") != MSYS2_BASE_LINEAGE_SHA256
|
|
140
140
|
or not isinstance(content_id, str)
|
|
141
141
|
or _CONTENT_RE.fullmatch(content_id) is None
|
|
142
142
|
or not isinstance(package_count, int)
|
|
@@ -2,8 +2,12 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import io
|
|
4
4
|
import os
|
|
5
|
+
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from railmux import windows_msys2
|
|
7
11
|
from railmux.windows_install_log import InstallReporter, install_log_path
|
|
8
12
|
from railmux.windows_msys2 import _run_checked
|
|
9
13
|
|
|
@@ -194,6 +198,27 @@ def test_default_command_runner_uses_no_stdin_and_captures_utf8(tmp_path):
|
|
|
194
198
|
assert "captured 中文" in path.read_text(encoding="utf-8")
|
|
195
199
|
|
|
196
200
|
|
|
201
|
+
def test_windows_missing_dll_status_is_explained(tmp_path, monkeypatch):
|
|
202
|
+
stream = io.StringIO()
|
|
203
|
+
path = tmp_path / "install.log"
|
|
204
|
+
monkeypatch.setattr(windows_msys2, "_NATIVE_WINDOWS", True)
|
|
205
|
+
|
|
206
|
+
with InstallReporter(path, verbose=False, stream=stream) as reporter:
|
|
207
|
+
with pytest.raises(windows_msys2.RuntimeInstallError, match="3221225781"):
|
|
208
|
+
_run_checked(
|
|
209
|
+
["fixture.exe"],
|
|
210
|
+
env={},
|
|
211
|
+
reporter=reporter,
|
|
212
|
+
runner=lambda *_args, **_kwargs: subprocess.CompletedProcess(
|
|
213
|
+
[], 0xC0000135
|
|
214
|
+
),
|
|
215
|
+
label="MSYS2 base update",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
assert "STATUS_DLL_NOT_FOUND" in stream.getvalue()
|
|
219
|
+
assert "security software" in path.read_text(encoding="utf-8")
|
|
220
|
+
|
|
221
|
+
|
|
197
222
|
def test_compact_reporter_surfaces_extraction_and_package_progress(tmp_path):
|
|
198
223
|
stream = io.StringIO()
|
|
199
224
|
path = tmp_path / "install-0.4.0.dev8-20260803T000000Z-7.log"
|
|
@@ -3,8 +3,11 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import io
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import re
|
|
8
|
+
import stat
|
|
7
9
|
import subprocess
|
|
10
|
+
import tarfile
|
|
8
11
|
from contextlib import contextmanager
|
|
9
12
|
from pathlib import Path
|
|
10
13
|
from types import SimpleNamespace
|
|
@@ -20,6 +23,7 @@ from railmux.windows_msys2 import (
|
|
|
20
23
|
MSYS2_ARCHIVE_SHA256,
|
|
21
24
|
MSYS2_ARCHIVE_SIZE,
|
|
22
25
|
MSYS2_ARCHIVE_SOURCES,
|
|
26
|
+
MSYS2_BASE_LINEAGE_SHA256,
|
|
23
27
|
MSYS2_RUNTIME_ID,
|
|
24
28
|
Msys2Runtime,
|
|
25
29
|
RuntimeInstallError,
|
|
@@ -102,7 +106,7 @@ def make_runtime(
|
|
|
102
106
|
{
|
|
103
107
|
"schema": 1,
|
|
104
108
|
"runtime": MSYS2_RUNTIME_ID,
|
|
105
|
-
"archive_sha256":
|
|
109
|
+
"archive_sha256": MSYS2_BASE_LINEAGE_SHA256,
|
|
106
110
|
"content_id": _TEST_CONTENT_ID,
|
|
107
111
|
"package_count": 3,
|
|
108
112
|
"core_packages": {
|
|
@@ -156,7 +160,12 @@ def add_marked_app(root: Path, version: str) -> Path:
|
|
|
156
160
|
|
|
157
161
|
|
|
158
162
|
def test_approved_archive_sources_are_https_and_share_one_pinned_artifact():
|
|
159
|
-
assert
|
|
163
|
+
assert MSYS2_ARCHIVE_NAME.endswith(".tar.xz")
|
|
164
|
+
assert not MSYS2_ARCHIVE_NAME.endswith(".sfx.exe")
|
|
165
|
+
assert MSYS2_ARCHIVE_SIZE == 53_466_096
|
|
166
|
+
assert MSYS2_ARCHIVE_SHA256 == (
|
|
167
|
+
"6b4a986a3ec4f1e40313bdf17903a6f5c854373d4230c40f14c5e35c4bac7fce"
|
|
168
|
+
)
|
|
160
169
|
assert len(MSYS2_ARCHIVE_SOURCES) == 4
|
|
161
170
|
assert len({url for _label, url in MSYS2_ARCHIVE_SOURCES}) == 4
|
|
162
171
|
for label, url in MSYS2_ARCHIVE_SOURCES:
|
|
@@ -728,21 +737,22 @@ def test_install_is_staged_and_activated_only_after_exact_probe(tmp_path):
|
|
|
728
737
|
assert destination.name == MSYS2_ARCHIVE_NAME
|
|
729
738
|
destination.write_bytes(b"verified fixture")
|
|
730
739
|
|
|
740
|
+
def extractor(archive, output, *, reporter):
|
|
741
|
+
assert archive.name == MSYS2_ARCHIVE_NAME
|
|
742
|
+
bash = output / "msys64" / "usr" / "bin" / "bash.exe"
|
|
743
|
+
bash.parent.mkdir(parents=True)
|
|
744
|
+
bash.write_bytes(b"fixture")
|
|
745
|
+
config = output / "msys64" / "etc" / "pacman.conf"
|
|
746
|
+
config.parent.mkdir(parents=True, exist_ok=True)
|
|
747
|
+
config.write_text(
|
|
748
|
+
"[options]\nParallelDownloads = 5\n"
|
|
749
|
+
"[mingw64]\nInclude = /etc/pacman.d/mirrorlist.mingw\n"
|
|
750
|
+
"[msys]\nInclude = /etc/pacman.d/mirrorlist.msys\n",
|
|
751
|
+
encoding="utf-8",
|
|
752
|
+
)
|
|
753
|
+
|
|
731
754
|
def runner(argv, *, env, check):
|
|
732
755
|
commands.append(argv)
|
|
733
|
-
if argv[0].endswith(MSYS2_ARCHIVE_NAME):
|
|
734
|
-
output = Path(next(arg[2:] for arg in argv if arg.startswith("-o")))
|
|
735
|
-
bash = output / "msys64" / "usr" / "bin" / "bash.exe"
|
|
736
|
-
bash.parent.mkdir(parents=True)
|
|
737
|
-
bash.write_bytes(b"fixture")
|
|
738
|
-
config = output / "msys64" / "etc" / "pacman.conf"
|
|
739
|
-
config.parent.mkdir(parents=True, exist_ok=True)
|
|
740
|
-
config.write_text(
|
|
741
|
-
"[options]\nParallelDownloads = 5\n"
|
|
742
|
-
"[mingw64]\nInclude = /etc/pacman.d/mirrorlist.mingw\n"
|
|
743
|
-
"[msys]\nInclude = /etc/pacman.d/mirrorlist.msys\n",
|
|
744
|
-
encoding="utf-8",
|
|
745
|
-
)
|
|
746
756
|
return completed(argv)
|
|
747
757
|
|
|
748
758
|
def probe(argv, *, env, timeout):
|
|
@@ -759,6 +769,7 @@ def test_install_is_staged_and_activated_only_after_exact_probe(tmp_path):
|
|
|
759
769
|
environ=environ,
|
|
760
770
|
downloader=downloader,
|
|
761
771
|
runner=runner,
|
|
772
|
+
extractor=extractor,
|
|
762
773
|
probe=probe,
|
|
763
774
|
lock_factory=unlocked,
|
|
764
775
|
mirror_optimizer=lambda _root: PacmanMirrorDecision(
|
|
@@ -790,6 +801,7 @@ def test_install_is_staged_and_activated_only_after_exact_probe(tmp_path):
|
|
|
790
801
|
assert any("--needed tmux python python-pip" in command for command in joined)
|
|
791
802
|
assert any('python -m venv "$1/venv"' in command for command in joined)
|
|
792
803
|
assert any('railmux[ssh]==$2' in command for command in joined)
|
|
804
|
+
assert all(MSYS2_ARCHIVE_NAME not in command for command in joined)
|
|
793
805
|
logs = list((Path(environ["LOCALAPPDATA"]) / "Railmux" / "logs").glob("*.log"))
|
|
794
806
|
assert len(logs) == 1
|
|
795
807
|
log = logs[0].read_text(encoding="utf-8")
|
|
@@ -1253,6 +1265,158 @@ def test_verified_base_archive_cache_avoids_a_second_download(tmp_path, monkeypa
|
|
|
1253
1265
|
assert source == "verified local cache"
|
|
1254
1266
|
|
|
1255
1267
|
|
|
1268
|
+
def _write_test_tar(path: Path, entries: list[tuple[tarfile.TarInfo, bytes]]) -> None:
|
|
1269
|
+
with tarfile.open(path, mode="w:xz") as archive:
|
|
1270
|
+
for member, payload in entries:
|
|
1271
|
+
archive.addfile(member, io.BytesIO(payload) if member.isreg() else None)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _tar_directory(name: str) -> tuple[tarfile.TarInfo, bytes]:
|
|
1275
|
+
member = tarfile.TarInfo(name)
|
|
1276
|
+
member.type = tarfile.DIRTYPE
|
|
1277
|
+
member.mode = 0o755
|
|
1278
|
+
return member, b""
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _tar_file(name: str, payload: bytes) -> tuple[tarfile.TarInfo, bytes]:
|
|
1282
|
+
member = tarfile.TarInfo(name)
|
|
1283
|
+
member.size = len(payload)
|
|
1284
|
+
member.mode = 0o755 if name.endswith(".exe") else 0o644
|
|
1285
|
+
return member, payload
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def test_tar_xz_extraction_is_internal_bounded_and_progress_visible(
|
|
1289
|
+
tmp_path,
|
|
1290
|
+
monkeypatch,
|
|
1291
|
+
):
|
|
1292
|
+
entries = [
|
|
1293
|
+
_tar_directory("msys64"),
|
|
1294
|
+
_tar_directory("msys64/usr"),
|
|
1295
|
+
_tar_file("msys64/usr/bash.exe", b"verified bash"),
|
|
1296
|
+
_tar_file("msys64/usr/read-only-upstream", b"must stay writable"),
|
|
1297
|
+
]
|
|
1298
|
+
entries[-1][0].mode = 0o444
|
|
1299
|
+
archive = tmp_path / MSYS2_ARCHIVE_NAME
|
|
1300
|
+
_write_test_tar(archive, entries)
|
|
1301
|
+
monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_MEMBER_COUNT", 4)
|
|
1302
|
+
monkeypatch.setattr(
|
|
1303
|
+
windows_msys2,
|
|
1304
|
+
"MSYS2_ARCHIVE_UNPACKED_SIZE",
|
|
1305
|
+
len(b"verified bash") + len(b"must stay writable"),
|
|
1306
|
+
)
|
|
1307
|
+
destination = tmp_path / "stage"
|
|
1308
|
+
destination.mkdir()
|
|
1309
|
+
log = tmp_path / "install.log"
|
|
1310
|
+
output = io.StringIO()
|
|
1311
|
+
|
|
1312
|
+
with InstallReporter(log, verbose=False, stream=output) as reporter:
|
|
1313
|
+
windows_msys2._extract_msys2_archive(archive, destination, reporter=reporter)
|
|
1314
|
+
|
|
1315
|
+
assert (destination / "msys64" / "usr" / "bash.exe").read_bytes() == (
|
|
1316
|
+
b"verified bash"
|
|
1317
|
+
)
|
|
1318
|
+
package_owned = destination / "msys64" / "usr" / "read-only-upstream"
|
|
1319
|
+
if os.name == "nt":
|
|
1320
|
+
readonly = getattr(stat, "FILE_ATTRIBUTE_READONLY", 0x1)
|
|
1321
|
+
assert not getattr(package_owned.stat(), "st_file_attributes", 0) & readonly
|
|
1322
|
+
else:
|
|
1323
|
+
assert stat.S_IMODE(package_owned.stat().st_mode) == 0o444
|
|
1324
|
+
rendered = output.getvalue()
|
|
1325
|
+
assert "Extracting private runtime: 1/4 files (25%)" in rendered
|
|
1326
|
+
assert "Extracting private runtime: 4/4 files (100%)" in rendered
|
|
1327
|
+
assert "MSYS2 archive extraction" in log.read_text(encoding="utf-8")
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def test_native_windows_extraction_clears_posix_read_only_modes(
|
|
1331
|
+
tmp_path, monkeypatch,
|
|
1332
|
+
):
|
|
1333
|
+
path = tmp_path / "package-owned"
|
|
1334
|
+
path.write_bytes(b"fixture")
|
|
1335
|
+
applied = []
|
|
1336
|
+
monkeypatch.setattr(windows_msys2, "_NATIVE_WINDOWS", True)
|
|
1337
|
+
monkeypatch.setattr(
|
|
1338
|
+
windows_msys2.os, "chmod", lambda target, mode: applied.append((target, mode))
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
windows_msys2._apply_archive_mode(path, 0o444)
|
|
1342
|
+
|
|
1343
|
+
assert applied == [(path, stat.S_IWRITE)]
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
@pytest.mark.parametrize(
|
|
1347
|
+
("member", "message"),
|
|
1348
|
+
[
|
|
1349
|
+
(_tar_file("../outside", b"bad"), "unsafe path"),
|
|
1350
|
+
(_tar_file("/absolute", b"bad"), "unsafe path"),
|
|
1351
|
+
(_tar_file(r"msys64\..\outside", b"bad"), "unsafe path"),
|
|
1352
|
+
],
|
|
1353
|
+
)
|
|
1354
|
+
def test_tar_xz_extraction_rejects_paths_outside_staging(
|
|
1355
|
+
tmp_path,
|
|
1356
|
+
monkeypatch,
|
|
1357
|
+
member,
|
|
1358
|
+
message,
|
|
1359
|
+
):
|
|
1360
|
+
archive = tmp_path / MSYS2_ARCHIVE_NAME
|
|
1361
|
+
_write_test_tar(archive, [member])
|
|
1362
|
+
monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_MEMBER_COUNT", 1)
|
|
1363
|
+
monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_UNPACKED_SIZE", 3)
|
|
1364
|
+
destination = tmp_path / "stage"
|
|
1365
|
+
destination.mkdir()
|
|
1366
|
+
|
|
1367
|
+
with InstallReporter(
|
|
1368
|
+
tmp_path / "install.log", verbose=False, stream=io.StringIO()
|
|
1369
|
+
) as reporter:
|
|
1370
|
+
with pytest.raises(RuntimeInstallError, match=message):
|
|
1371
|
+
windows_msys2._extract_msys2_archive(
|
|
1372
|
+
archive, destination, reporter=reporter
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
assert not (tmp_path / "outside").exists()
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def test_tar_xz_extraction_rejects_links_and_special_files(tmp_path, monkeypatch):
|
|
1379
|
+
member = tarfile.TarInfo("msys64/link")
|
|
1380
|
+
member.type = tarfile.SYMTYPE
|
|
1381
|
+
member.linkname = "../outside"
|
|
1382
|
+
archive = tmp_path / MSYS2_ARCHIVE_NAME
|
|
1383
|
+
_write_test_tar(archive, [_tar_directory("msys64"), (member, b"")])
|
|
1384
|
+
monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_MEMBER_COUNT", 2)
|
|
1385
|
+
monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_UNPACKED_SIZE", 0)
|
|
1386
|
+
destination = tmp_path / "stage"
|
|
1387
|
+
destination.mkdir()
|
|
1388
|
+
|
|
1389
|
+
with InstallReporter(
|
|
1390
|
+
tmp_path / "install.log", verbose=False, stream=io.StringIO()
|
|
1391
|
+
) as reporter:
|
|
1392
|
+
with pytest.raises(RuntimeInstallError, match="unsupported link"):
|
|
1393
|
+
windows_msys2._extract_msys2_archive(
|
|
1394
|
+
archive, destination, reporter=reporter
|
|
1395
|
+
)
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def test_tar_xz_extraction_rejects_changed_inventory_without_publishing(
|
|
1399
|
+
tmp_path,
|
|
1400
|
+
monkeypatch,
|
|
1401
|
+
):
|
|
1402
|
+
archive = tmp_path / MSYS2_ARCHIVE_NAME
|
|
1403
|
+
_write_test_tar(archive, [_tar_directory("msys64")])
|
|
1404
|
+
monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_MEMBER_COUNT", 2)
|
|
1405
|
+
monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_UNPACKED_SIZE", 0)
|
|
1406
|
+
destination = tmp_path / "stage"
|
|
1407
|
+
destination.mkdir()
|
|
1408
|
+
|
|
1409
|
+
with InstallReporter(
|
|
1410
|
+
tmp_path / "install.log", verbose=False, stream=io.StringIO()
|
|
1411
|
+
) as reporter:
|
|
1412
|
+
with pytest.raises(RuntimeInstallError, match="member count changed"):
|
|
1413
|
+
windows_msys2._extract_msys2_archive(
|
|
1414
|
+
archive, destination, reporter=reporter
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
assert (destination / "msys64").is_dir()
|
|
1418
|
+
|
|
1419
|
+
|
|
1256
1420
|
def test_pacman_network_failure_retries_with_cache_and_relaxed_timeout(tmp_path):
|
|
1257
1421
|
root = tmp_path / "msys64"
|
|
1258
1422
|
bash = root / "usr" / "bin" / "bash.exe"
|