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.
Files changed (181) hide show
  1. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/MANIFEST.in +1 -0
  2. {railmux-0.4.0.dev25/src/railmux.egg-info → railmux-0.4.0.dev27}/PKG-INFO +1 -1
  3. railmux-0.4.0.dev27/src/railmux/__init__.py +1 -0
  4. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_install_log.py +18 -0
  5. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_msys2.py +207 -14
  6. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_ui_transition.py +2 -2
  7. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27/src/railmux.egg-info}/PKG-INFO +1 -1
  8. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/SOURCES.txt +2 -1
  9. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_install_log.py +25 -0
  10. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_msys2.py +179 -15
  11. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_ui_transition.py +3 -3
  12. railmux-0.4.0.dev27/tools/windows_archive_smoke.py +67 -0
  13. railmux-0.4.0.dev25/src/railmux/__init__.py +0 -1
  14. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/LICENSE +0 -0
  15. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/README.md +0 -0
  16. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/pyproject.toml +0 -0
  17. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/setup.cfg +0 -0
  18. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/__main__.py +0 -0
  19. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/atomic_file.py +0 -0
  20. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/background_index.py +0 -0
  21. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/cli.py +0 -0
  22. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/codex_index.py +0 -0
  23. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/config.py +0 -0
  24. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/config_cli.py +0 -0
  25. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/diagnostics.py +0 -0
  26. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/discovery.py +0 -0
  27. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/display_transport.py +0 -0
  28. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/entrypoint.py +0 -0
  29. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_client.py +0 -0
  30. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_history.py +0 -0
  31. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_input.py +0 -0
  32. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_protocol.py +0 -0
  33. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fast_display_server.py +0 -0
  34. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/favorites.py +0 -0
  35. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/fuzzy.py +0 -0
  36. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/help_workspace.py +0 -0
  37. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/launcher.py +0 -0
  38. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/legacy_sessions.py +0 -0
  39. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/local_clipboard.py +0 -0
  40. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/local_open.py +0 -0
  41. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/models.py +0 -0
  42. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/modes.py +0 -0
  43. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/mouse_manager.py +0 -0
  44. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/orphan_marker.py +0 -0
  45. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/pane_surface.py +0 -0
  46. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/path_codec.py +0 -0
  47. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/provider_paths.py +0 -0
  48. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/remote_config.py +0 -0
  49. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/renames.py +0 -0
  50. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/restart_state.py +0 -0
  51. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/runtime_config.py +0 -0
  52. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/scroll_agent.py +0 -0
  53. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/scroll_manager.py +0 -0
  54. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/selection_isolation.py +0 -0
  55. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/self_update.py +0 -0
  56. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/session_cache.py +0 -0
  57. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/session_index.py +0 -0
  58. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/session_lease.py +0 -0
  59. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/setting_contracts.py +0 -0
  60. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/settings.py +0 -0
  61. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ssh_args.py +0 -0
  62. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ssh_compat.py +0 -0
  63. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ssh_display_diagnostics.py +0 -0
  64. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ssh_doctor.py +0 -0
  65. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/system_deps.py +0 -0
  66. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/terminal_status.py +0 -0
  67. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tmux_binding_manager.py +0 -0
  68. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tmux_ctl.py +0 -0
  69. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tmux_health.py +0 -0
  70. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tmux_server.py +0 -0
  71. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/tool_panes.py +0 -0
  72. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/transcript.py +0 -0
  73. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/__init__.py +0 -0
  74. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/_widgets.py +0 -0
  75. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/app.py +0 -0
  76. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/keymap.py +0 -0
  77. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/modals.py +0 -0
  78. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/projects_pane.py +0 -0
  79. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/running_pane.py +0 -0
  80. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/sessions_pane.py +0 -0
  81. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/sidebar.py +0 -0
  82. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/statusbar.py +0 -0
  83. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/ui/workspace.py +0 -0
  84. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_attach_relay.py +0 -0
  85. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_bootstrap.py +0 -0
  86. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_pacman.py +0 -0
  87. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux/windows_tmux_lifecycle.py +0 -0
  88. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/dependency_links.txt +0 -0
  89. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/entry_points.txt +0 -0
  90. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/requires.txt +0 -0
  91. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/src/railmux.egg-info/top_level.txt +0 -0
  92. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_agent_workspace.py +0 -0
  93. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_atomic_file.py +0 -0
  94. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_background_index.py +0 -0
  95. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_cli.py +0 -0
  96. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_codex_delete.py +0 -0
  97. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_codex_index.py +0 -0
  98. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_codex_lineage.py +0 -0
  99. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_codex_transcript.py +0 -0
  100. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_config.py +0 -0
  101. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_config_cli.py +0 -0
  102. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_denested_transport_benchmark.py +0 -0
  103. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_diagnostics.py +0 -0
  104. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_discovery.py +0 -0
  105. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_display_transport.py +0 -0
  106. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_fast_full_window.py +0 -0
  107. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_focus_highlight.py +0 -0
  108. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_fullscreen_binding.py +0 -0
  109. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_fuzzy.py +0 -0
  110. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_help_agent.py +0 -0
  111. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_help_workspace.py +0 -0
  112. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_history_cleanup.py +0 -0
  113. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_keymap.py +0 -0
  114. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_launcher.py +0 -0
  115. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_layout_profile.py +0 -0
  116. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_legacy_sessions.py +0 -0
  117. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_local_clipboard.py +0 -0
  118. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_local_open.py +0 -0
  119. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_modals.py +0 -0
  120. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_mode_selection.py +0 -0
  121. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_modes.py +0 -0
  122. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_mouse_manager.py +0 -0
  123. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_new_project.py +0 -0
  124. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_options.py +0 -0
  125. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_orphan_recovery.py +0 -0
  126. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_pane_surface.py +0 -0
  127. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_path_codec.py +0 -0
  128. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_projects_pane.py +0 -0
  129. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_provider_paths.py +0 -0
  130. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_release_notes.py +0 -0
  131. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_release_workflow.py +0 -0
  132. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_remote_config.py +0 -0
  133. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_renames.py +0 -0
  134. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_restart_state.py +0 -0
  135. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_running_pane.py +0 -0
  136. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_running_sort.py +0 -0
  137. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_runtime_config.py +0 -0
  138. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_scroll_agent.py +0 -0
  139. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_scroll_manager.py +0 -0
  140. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_selection_isolation.py +0 -0
  141. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_self_update.py +0 -0
  142. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_session_cache.py +0 -0
  143. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_session_index.py +0 -0
  144. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_session_lease.py +0 -0
  145. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_sessions_pane.py +0 -0
  146. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_setting_contracts.py +0 -0
  147. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_settings.py +0 -0
  148. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_sidebar.py +0 -0
  149. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_soft_quit.py +0 -0
  150. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_args.py +0 -0
  151. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_compat.py +0 -0
  152. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_display_benchmark.py +0 -0
  153. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_display_diagnostics.py +0 -0
  154. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_ssh_doctor.py +0 -0
  155. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_status.py +0 -0
  156. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_statusbar.py +0 -0
  157. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_system_deps.py +0 -0
  158. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_terminal_size.py +0 -0
  159. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_terminal_status.py +0 -0
  160. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_termux_local_touch.py +0 -0
  161. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_binding_manager.py +0 -0
  162. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_ctl.py +0 -0
  163. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_health.py +0 -0
  164. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_integration.py +0 -0
  165. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_server.py +0 -0
  166. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tmux_status.py +0 -0
  167. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_tool_panes.py +0 -0
  168. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_transcript.py +0 -0
  169. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_utf8_state.py +0 -0
  170. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_web_demo_recorder.py +0 -0
  171. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_wheel_smoke.py +0 -0
  172. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_widgets.py +0 -0
  173. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_attach_relay.py +0 -0
  174. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_bootstrap.py +0 -0
  175. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_pacman.py +0 -0
  176. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_tmux_lifecycle.py +0 -0
  177. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_windows_wrapper_contract.py +0 -0
  178. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tests/test_yolo_prompt.py +0 -0
  179. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tools/denested_transport_benchmark.py +0 -0
  180. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tools/release_notes.py +0 -0
  181. {railmux-0.4.0.dev25 → railmux-0.4.0.dev27}/tools/wheel_smoke.py +0 -0
@@ -1,3 +1,4 @@
1
1
  include tools/denested_transport_benchmark.py
2
2
  include tools/release_notes.py
3
3
  include tools/wheel_smoke.py
4
+ include tools/windows_archive_smoke.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev25
3
+ Version: 0.4.0.dev27
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -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('-', '')}.sfx.exe"
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 = 52_820_994
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": MSYS2_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 path.is_symlink() or path.stat().st_size != MSYS2_ARCHIVE_SIZE:
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") != MSYS2_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
- _run_checked(
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 MSYS2_ARCHIVE_SHA256
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") != MSYS2_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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev25
3
+ Version: 0.4.0.dev27
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -174,4 +174,5 @@ tests/test_windows_wrapper_contract.py
174
174
  tests/test_yolo_prompt.py
175
175
  tools/denested_transport_benchmark.py
176
176
  tools/release_notes.py
177
- tools/wheel_smoke.py
177
+ tools/wheel_smoke.py
178
+ tools/windows_archive_smoke.py
@@ -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": MSYS2_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 MSYS2_ARCHIVE_SIZE == 52_820_994
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"