railmux 0.4.0.dev25__tar.gz → 0.4.0.dev26__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 (180) hide show
  1. {railmux-0.4.0.dev25/src/railmux.egg-info → railmux-0.4.0.dev26}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev26/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/windows_install_log.py +18 -0
  4. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/windows_msys2.py +190 -14
  5. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/windows_ui_transition.py +2 -2
  6. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26/src/railmux.egg-info}/PKG-INFO +1 -1
  7. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_windows_msys2.py +151 -15
  8. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_windows_ui_transition.py +3 -3
  9. railmux-0.4.0.dev25/src/railmux/__init__.py +0 -1
  10. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/LICENSE +0 -0
  11. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/MANIFEST.in +0 -0
  12. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/README.md +0 -0
  13. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/pyproject.toml +0 -0
  14. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/setup.cfg +0 -0
  15. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/__main__.py +0 -0
  16. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/atomic_file.py +0 -0
  17. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/background_index.py +0 -0
  18. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/cli.py +0 -0
  19. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/codex_index.py +0 -0
  20. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/config.py +0 -0
  21. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/config_cli.py +0 -0
  22. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/diagnostics.py +0 -0
  23. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/discovery.py +0 -0
  24. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/display_transport.py +0 -0
  25. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/entrypoint.py +0 -0
  26. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/fast_display_client.py +0 -0
  27. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/fast_display_history.py +0 -0
  28. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/fast_display_input.py +0 -0
  29. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/fast_display_protocol.py +0 -0
  30. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/fast_display_server.py +0 -0
  31. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/favorites.py +0 -0
  32. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/fuzzy.py +0 -0
  33. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/help_workspace.py +0 -0
  34. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/launcher.py +0 -0
  35. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/legacy_sessions.py +0 -0
  36. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/local_clipboard.py +0 -0
  37. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/local_open.py +0 -0
  38. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/models.py +0 -0
  39. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/modes.py +0 -0
  40. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/mouse_manager.py +0 -0
  41. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/orphan_marker.py +0 -0
  42. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/pane_surface.py +0 -0
  43. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/path_codec.py +0 -0
  44. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/provider_paths.py +0 -0
  45. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/remote_config.py +0 -0
  46. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/renames.py +0 -0
  47. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/restart_state.py +0 -0
  48. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/runtime_config.py +0 -0
  49. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/scroll_agent.py +0 -0
  50. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/scroll_manager.py +0 -0
  51. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/selection_isolation.py +0 -0
  52. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/self_update.py +0 -0
  53. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/session_cache.py +0 -0
  54. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/session_index.py +0 -0
  55. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/session_lease.py +0 -0
  56. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/setting_contracts.py +0 -0
  57. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/settings.py +0 -0
  58. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ssh_args.py +0 -0
  59. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ssh_compat.py +0 -0
  60. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ssh_display_diagnostics.py +0 -0
  61. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ssh_doctor.py +0 -0
  62. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/system_deps.py +0 -0
  63. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/terminal_status.py +0 -0
  64. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/tmux_binding_manager.py +0 -0
  65. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/tmux_ctl.py +0 -0
  66. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/tmux_health.py +0 -0
  67. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/tmux_server.py +0 -0
  68. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/tool_panes.py +0 -0
  69. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/transcript.py +0 -0
  70. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/__init__.py +0 -0
  71. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/_widgets.py +0 -0
  72. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/app.py +0 -0
  73. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/keymap.py +0 -0
  74. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/modals.py +0 -0
  75. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/projects_pane.py +0 -0
  76. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/running_pane.py +0 -0
  77. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/sessions_pane.py +0 -0
  78. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/sidebar.py +0 -0
  79. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/statusbar.py +0 -0
  80. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/ui/workspace.py +0 -0
  81. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/windows_attach_relay.py +0 -0
  82. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/windows_bootstrap.py +0 -0
  83. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/windows_pacman.py +0 -0
  84. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux/windows_tmux_lifecycle.py +0 -0
  85. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux.egg-info/SOURCES.txt +0 -0
  86. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux.egg-info/dependency_links.txt +0 -0
  87. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux.egg-info/entry_points.txt +0 -0
  88. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux.egg-info/requires.txt +0 -0
  89. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/src/railmux.egg-info/top_level.txt +0 -0
  90. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_agent_workspace.py +0 -0
  91. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_atomic_file.py +0 -0
  92. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_background_index.py +0 -0
  93. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_cli.py +0 -0
  94. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_codex_delete.py +0 -0
  95. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_codex_index.py +0 -0
  96. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_codex_lineage.py +0 -0
  97. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_codex_transcript.py +0 -0
  98. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_config.py +0 -0
  99. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_config_cli.py +0 -0
  100. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_denested_transport_benchmark.py +0 -0
  101. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_diagnostics.py +0 -0
  102. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_discovery.py +0 -0
  103. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_display_transport.py +0 -0
  104. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_fast_full_window.py +0 -0
  105. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_focus_highlight.py +0 -0
  106. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_fullscreen_binding.py +0 -0
  107. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_fuzzy.py +0 -0
  108. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_help_agent.py +0 -0
  109. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_help_workspace.py +0 -0
  110. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_history_cleanup.py +0 -0
  111. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_keymap.py +0 -0
  112. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_launcher.py +0 -0
  113. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_layout_profile.py +0 -0
  114. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_legacy_sessions.py +0 -0
  115. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_local_clipboard.py +0 -0
  116. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_local_open.py +0 -0
  117. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_modals.py +0 -0
  118. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_mode_selection.py +0 -0
  119. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_modes.py +0 -0
  120. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_mouse_manager.py +0 -0
  121. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_new_project.py +0 -0
  122. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_options.py +0 -0
  123. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_orphan_recovery.py +0 -0
  124. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_pane_surface.py +0 -0
  125. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_path_codec.py +0 -0
  126. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_projects_pane.py +0 -0
  127. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_provider_paths.py +0 -0
  128. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_release_notes.py +0 -0
  129. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_release_workflow.py +0 -0
  130. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_remote_config.py +0 -0
  131. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_renames.py +0 -0
  132. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_restart_state.py +0 -0
  133. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_running_pane.py +0 -0
  134. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_running_sort.py +0 -0
  135. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_runtime_config.py +0 -0
  136. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_scroll_agent.py +0 -0
  137. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_scroll_manager.py +0 -0
  138. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_selection_isolation.py +0 -0
  139. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_self_update.py +0 -0
  140. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_session_cache.py +0 -0
  141. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_session_index.py +0 -0
  142. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_session_lease.py +0 -0
  143. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_sessions_pane.py +0 -0
  144. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_setting_contracts.py +0 -0
  145. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_settings.py +0 -0
  146. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_sidebar.py +0 -0
  147. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_soft_quit.py +0 -0
  148. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_ssh_args.py +0 -0
  149. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_ssh_compat.py +0 -0
  150. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_ssh_display_benchmark.py +0 -0
  151. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_ssh_display_diagnostics.py +0 -0
  152. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_ssh_doctor.py +0 -0
  153. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_status.py +0 -0
  154. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_statusbar.py +0 -0
  155. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_system_deps.py +0 -0
  156. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_terminal_size.py +0 -0
  157. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_terminal_status.py +0 -0
  158. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_termux_local_touch.py +0 -0
  159. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_tmux_binding_manager.py +0 -0
  160. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_tmux_ctl.py +0 -0
  161. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_tmux_health.py +0 -0
  162. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_tmux_integration.py +0 -0
  163. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_tmux_server.py +0 -0
  164. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_tmux_status.py +0 -0
  165. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_tool_panes.py +0 -0
  166. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_transcript.py +0 -0
  167. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_utf8_state.py +0 -0
  168. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_web_demo_recorder.py +0 -0
  169. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_wheel_smoke.py +0 -0
  170. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_widgets.py +0 -0
  171. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_windows_attach_relay.py +0 -0
  172. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_windows_bootstrap.py +0 -0
  173. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_windows_install_log.py +0 -0
  174. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_windows_pacman.py +0 -0
  175. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_windows_tmux_lifecycle.py +0 -0
  176. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_windows_wrapper_contract.py +0 -0
  177. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tests/test_yolo_prompt.py +0 -0
  178. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tools/denested_transport_benchmark.py +0 -0
  179. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tools/release_notes.py +0 -0
  180. {railmux-0.4.0.dev25 → railmux-0.4.0.dev26}/tools/wheel_smoke.py +0 -0
@@ -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.dev26
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.dev26"
@@ -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,8 +67,16 @@ 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}"
@@ -141,7 +151,7 @@ class BaseContentIdentity:
141
151
  return {
142
152
  "schema": 1,
143
153
  "runtime": MSYS2_RUNTIME_ID,
144
- "archive_sha256": MSYS2_ARCHIVE_SHA256,
154
+ "archive_sha256": MSYS2_BASE_LINEAGE_SHA256,
145
155
  "content_id": self.content_id,
146
156
  "package_count": self.package_count,
147
157
  "core_packages": dict(self.core_packages),
@@ -166,6 +176,7 @@ Probe = Callable[..., subprocess.CompletedProcess[bytes]]
166
176
  Runner = Callable[..., subprocess.CompletedProcess]
167
177
  Downloader = Callable[[str, Path, str], None]
168
178
  MirrorOptimizer = Callable[[Path], PacmanMirrorDecision]
179
+ Extractor = Callable[..., None]
169
180
 
170
181
 
171
182
  @dataclass(frozen=True)
@@ -519,9 +530,20 @@ def _managed_cache(environ: Mapping[str, str]) -> Path | None:
519
530
  return Path(local_app_data) / "Railmux" / "cache"
520
531
 
521
532
 
533
+ def _path_is_link_or_reparse(path: Path) -> bool:
534
+ info = path.lstat()
535
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
536
+ return stat.S_ISLNK(info.st_mode) or bool(
537
+ getattr(info, "st_file_attributes", 0) & reparse_flag
538
+ )
539
+
540
+
522
541
  def _archive_cache_is_valid(path: Path) -> bool:
523
542
  try:
524
- if path.is_symlink() or path.stat().st_size != MSYS2_ARCHIVE_SIZE:
543
+ if (
544
+ _path_is_link_or_reparse(path)
545
+ or path.stat().st_size != MSYS2_ARCHIVE_SIZE
546
+ ):
525
547
  return False
526
548
  digest = hashlib.sha256()
527
549
  with path.open("rb") as stream:
@@ -553,6 +575,166 @@ def _prepare_cached_archive(
553
575
  return archive, source
554
576
 
555
577
 
578
+ def _safe_archive_parts(member: tarfile.TarInfo) -> tuple[str, ...]:
579
+ name = member.name
580
+ raw_parts = name.split("/")
581
+ pure = PurePosixPath(name)
582
+ if (
583
+ not name
584
+ or "\\" in name
585
+ or pure.is_absolute()
586
+ or any(part in {"", ".", ".."} for part in raw_parts)
587
+ or not raw_parts
588
+ or raw_parts[0] != "msys64"
589
+ ):
590
+ raise RuntimeInstallError(
591
+ "the verified MSYS2 archive contained an unsafe path"
592
+ )
593
+ return tuple(raw_parts)
594
+
595
+
596
+ def _ensure_safe_archive_directory(root: Path, parts: Sequence[str]) -> Path:
597
+ current = root
598
+ for part in parts:
599
+ candidate = current / part
600
+ try:
601
+ if _path_is_link_or_reparse(candidate) or not candidate.is_dir():
602
+ raise RuntimeInstallError(
603
+ "the MSYS2 extraction staging directory became unsafe"
604
+ )
605
+ except FileNotFoundError:
606
+ try:
607
+ candidate.mkdir()
608
+ except OSError as exc:
609
+ raise RuntimeInstallError(
610
+ "could not create the MSYS2 extraction staging directory"
611
+ ) from exc
612
+ if _path_is_link_or_reparse(candidate) or not candidate.is_dir():
613
+ raise RuntimeInstallError(
614
+ "the MSYS2 extraction staging directory became unsafe"
615
+ )
616
+ except OSError as exc:
617
+ raise RuntimeInstallError(
618
+ "could not inspect the MSYS2 extraction staging directory"
619
+ ) from exc
620
+ current = candidate
621
+ return current
622
+
623
+
624
+ def _extract_msys2_archive(
625
+ archive: Path,
626
+ destination: Path,
627
+ *,
628
+ reporter: InstallReporter,
629
+ ) -> None:
630
+ """Extract the pinned tar.xz without executing downloaded code."""
631
+ reporter.command_started("MSYS2 archive extraction", progress="extract")
632
+ seen: set[tuple[str, ...]] = set()
633
+ directories: list[tuple[Path, int]] = []
634
+ member_count = 0
635
+ unpacked_size = 0
636
+ try:
637
+ if _path_is_link_or_reparse(destination) or not destination.is_dir():
638
+ raise RuntimeInstallError(
639
+ "the MSYS2 extraction staging directory is unsafe"
640
+ )
641
+ with tarfile.open(archive, mode="r|xz") as stream:
642
+ for member in stream:
643
+ member_count += 1
644
+ if member_count > MSYS2_ARCHIVE_MEMBER_COUNT:
645
+ raise RuntimeInstallError(
646
+ "the verified MSYS2 archive member count changed"
647
+ )
648
+ parts = _safe_archive_parts(member)
649
+ if parts in seen:
650
+ raise RuntimeInstallError(
651
+ "the verified MSYS2 archive contained a duplicate path"
652
+ )
653
+ seen.add(parts)
654
+ if not (member.isdir() or member.isreg()):
655
+ raise RuntimeInstallError(
656
+ "the verified MSYS2 archive contained an unsupported link "
657
+ "or special file"
658
+ )
659
+ parent = _ensure_safe_archive_directory(destination, parts[:-1])
660
+ target = parent / parts[-1]
661
+ if member.isdir():
662
+ directory = _ensure_safe_archive_directory(destination, parts)
663
+ directories.append((directory, member.mode & 0o777))
664
+ else:
665
+ if member.size < 0:
666
+ raise RuntimeInstallError(
667
+ "the verified MSYS2 archive contained an invalid file"
668
+ )
669
+ unpacked_size += member.size
670
+ if unpacked_size > MSYS2_ARCHIVE_UNPACKED_SIZE:
671
+ raise RuntimeInstallError(
672
+ "the verified MSYS2 archive expanded beyond its "
673
+ "pinned size"
674
+ )
675
+ try:
676
+ try:
677
+ target.lstat()
678
+ except FileNotFoundError:
679
+ pass
680
+ else:
681
+ raise RuntimeInstallError(
682
+ "the MSYS2 extraction staging path already exists"
683
+ )
684
+ source = stream.extractfile(member)
685
+ if source is None:
686
+ raise RuntimeInstallError(
687
+ "the verified MSYS2 archive file was unreadable"
688
+ )
689
+ remaining = member.size
690
+ with target.open("xb") as output:
691
+ while remaining:
692
+ chunk = source.read(min(1024 * 1024, remaining))
693
+ if not chunk:
694
+ raise RuntimeInstallError(
695
+ "the verified MSYS2 archive ended early"
696
+ )
697
+ output.write(chunk)
698
+ remaining -= len(chunk)
699
+ os.chmod(target, member.mode & 0o777)
700
+ except RuntimeInstallError:
701
+ raise
702
+ except OSError as exc:
703
+ raise RuntimeInstallError(
704
+ "could not write the MSYS2 extraction staging file"
705
+ ) from exc
706
+ reporter.extraction_progress(
707
+ member_count, MSYS2_ARCHIVE_MEMBER_COUNT
708
+ )
709
+ if member_count != MSYS2_ARCHIVE_MEMBER_COUNT:
710
+ raise RuntimeInstallError(
711
+ "the verified MSYS2 archive member count changed"
712
+ )
713
+ if unpacked_size != MSYS2_ARCHIVE_UNPACKED_SIZE:
714
+ raise RuntimeInstallError(
715
+ "the verified MSYS2 archive expanded to an unexpected size"
716
+ )
717
+ for directory, mode in reversed(directories):
718
+ os.chmod(directory, mode)
719
+ except RuntimeInstallError:
720
+ reporter.note(
721
+ "MSYS2 archive extraction failed; the temporary base was not "
722
+ "published.",
723
+ level="error",
724
+ )
725
+ raise
726
+ except (OSError, lzma.LZMAError, tarfile.TarError, EOFError) as exc:
727
+ reporter.note(
728
+ "MSYS2 archive extraction failed; the temporary base was not "
729
+ "published.",
730
+ level="error",
731
+ )
732
+ raise RuntimeInstallError(
733
+ "the verified MSYS2 tar.xz archive could not be extracted"
734
+ ) from exc
735
+ reporter.command_succeeded()
736
+
737
+
556
738
  def managed_root(environ: Mapping[str, str]) -> Path | None:
557
739
  base = _managed_base(environ)
558
740
  if base is None:
@@ -690,7 +872,7 @@ def _decode_base_content_marker(root: Path) -> BaseContentIdentity | None:
690
872
  if (
691
873
  payload.get("schema") != 1
692
874
  or payload.get("runtime") != MSYS2_RUNTIME_ID
693
- or payload.get("archive_sha256") != MSYS2_ARCHIVE_SHA256
875
+ or payload.get("archive_sha256") != MSYS2_BASE_LINEAGE_SHA256
694
876
  or not isinstance(content_id, str)
695
877
  or re.fullmatch(r"[0-9a-f]{64}", content_id) is None
696
878
  or not isinstance(package_count, int)
@@ -2022,6 +2204,7 @@ def install_managed_runtime(
2022
2204
  probe: Probe = _probe,
2023
2205
  lock_factory: Callable[[Path], object] = install_lock,
2024
2206
  mirror_optimizer: MirrorOptimizer = optimize_pacman_mirror,
2207
+ extractor: Extractor = _extract_msys2_archive,
2025
2208
  verbose: bool = False,
2026
2209
  reuse_only: bool = False,
2027
2210
  ) -> Msys2Runtime:
@@ -2160,14 +2343,7 @@ def install_managed_runtime(
2160
2343
  reporter.done(source)
2161
2344
 
2162
2345
  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
- )
2346
+ extractor(archive, stage, reporter=reporter)
2171
2347
  root = stage / "msys64"
2172
2348
  if not (root / "usr" / "bin" / "bash.exe").is_file():
2173
2349
  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.dev26
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
@@ -5,6 +5,7 @@ import io
5
5
  import json
6
6
  import re
7
7
  import subprocess
8
+ import tarfile
8
9
  from contextlib import contextmanager
9
10
  from pathlib import Path
10
11
  from types import SimpleNamespace
@@ -20,6 +21,7 @@ from railmux.windows_msys2 import (
20
21
  MSYS2_ARCHIVE_SHA256,
21
22
  MSYS2_ARCHIVE_SIZE,
22
23
  MSYS2_ARCHIVE_SOURCES,
24
+ MSYS2_BASE_LINEAGE_SHA256,
23
25
  MSYS2_RUNTIME_ID,
24
26
  Msys2Runtime,
25
27
  RuntimeInstallError,
@@ -102,7 +104,7 @@ def make_runtime(
102
104
  {
103
105
  "schema": 1,
104
106
  "runtime": MSYS2_RUNTIME_ID,
105
- "archive_sha256": MSYS2_ARCHIVE_SHA256,
107
+ "archive_sha256": MSYS2_BASE_LINEAGE_SHA256,
106
108
  "content_id": _TEST_CONTENT_ID,
107
109
  "package_count": 3,
108
110
  "core_packages": {
@@ -156,7 +158,12 @@ def add_marked_app(root: Path, version: str) -> Path:
156
158
 
157
159
 
158
160
  def test_approved_archive_sources_are_https_and_share_one_pinned_artifact():
159
- assert MSYS2_ARCHIVE_SIZE == 52_820_994
161
+ assert MSYS2_ARCHIVE_NAME.endswith(".tar.xz")
162
+ assert not MSYS2_ARCHIVE_NAME.endswith(".sfx.exe")
163
+ assert MSYS2_ARCHIVE_SIZE == 53_466_096
164
+ assert MSYS2_ARCHIVE_SHA256 == (
165
+ "6b4a986a3ec4f1e40313bdf17903a6f5c854373d4230c40f14c5e35c4bac7fce"
166
+ )
160
167
  assert len(MSYS2_ARCHIVE_SOURCES) == 4
161
168
  assert len({url for _label, url in MSYS2_ARCHIVE_SOURCES}) == 4
162
169
  for label, url in MSYS2_ARCHIVE_SOURCES:
@@ -728,21 +735,22 @@ def test_install_is_staged_and_activated_only_after_exact_probe(tmp_path):
728
735
  assert destination.name == MSYS2_ARCHIVE_NAME
729
736
  destination.write_bytes(b"verified fixture")
730
737
 
738
+ def extractor(archive, output, *, reporter):
739
+ assert archive.name == MSYS2_ARCHIVE_NAME
740
+ bash = output / "msys64" / "usr" / "bin" / "bash.exe"
741
+ bash.parent.mkdir(parents=True)
742
+ bash.write_bytes(b"fixture")
743
+ config = output / "msys64" / "etc" / "pacman.conf"
744
+ config.parent.mkdir(parents=True, exist_ok=True)
745
+ config.write_text(
746
+ "[options]\nParallelDownloads = 5\n"
747
+ "[mingw64]\nInclude = /etc/pacman.d/mirrorlist.mingw\n"
748
+ "[msys]\nInclude = /etc/pacman.d/mirrorlist.msys\n",
749
+ encoding="utf-8",
750
+ )
751
+
731
752
  def runner(argv, *, env, check):
732
753
  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
754
  return completed(argv)
747
755
 
748
756
  def probe(argv, *, env, timeout):
@@ -759,6 +767,7 @@ def test_install_is_staged_and_activated_only_after_exact_probe(tmp_path):
759
767
  environ=environ,
760
768
  downloader=downloader,
761
769
  runner=runner,
770
+ extractor=extractor,
762
771
  probe=probe,
763
772
  lock_factory=unlocked,
764
773
  mirror_optimizer=lambda _root: PacmanMirrorDecision(
@@ -790,6 +799,7 @@ def test_install_is_staged_and_activated_only_after_exact_probe(tmp_path):
790
799
  assert any("--needed tmux python python-pip" in command for command in joined)
791
800
  assert any('python -m venv "$1/venv"' in command for command in joined)
792
801
  assert any('railmux[ssh]==$2' in command for command in joined)
802
+ assert all(MSYS2_ARCHIVE_NAME not in command for command in joined)
793
803
  logs = list((Path(environ["LOCALAPPDATA"]) / "Railmux" / "logs").glob("*.log"))
794
804
  assert len(logs) == 1
795
805
  log = logs[0].read_text(encoding="utf-8")
@@ -1253,6 +1263,132 @@ def test_verified_base_archive_cache_avoids_a_second_download(tmp_path, monkeypa
1253
1263
  assert source == "verified local cache"
1254
1264
 
1255
1265
 
1266
+ def _write_test_tar(path: Path, entries: list[tuple[tarfile.TarInfo, bytes]]) -> None:
1267
+ with tarfile.open(path, mode="w:xz") as archive:
1268
+ for member, payload in entries:
1269
+ archive.addfile(member, io.BytesIO(payload) if member.isreg() else None)
1270
+
1271
+
1272
+ def _tar_directory(name: str) -> tuple[tarfile.TarInfo, bytes]:
1273
+ member = tarfile.TarInfo(name)
1274
+ member.type = tarfile.DIRTYPE
1275
+ member.mode = 0o755
1276
+ return member, b""
1277
+
1278
+
1279
+ def _tar_file(name: str, payload: bytes) -> tuple[tarfile.TarInfo, bytes]:
1280
+ member = tarfile.TarInfo(name)
1281
+ member.size = len(payload)
1282
+ member.mode = 0o755 if name.endswith(".exe") else 0o644
1283
+ return member, payload
1284
+
1285
+
1286
+ def test_tar_xz_extraction_is_internal_bounded_and_progress_visible(
1287
+ tmp_path,
1288
+ monkeypatch,
1289
+ ):
1290
+ entries = [
1291
+ _tar_directory("msys64"),
1292
+ _tar_directory("msys64/usr"),
1293
+ _tar_file("msys64/usr/bash.exe", b"verified bash"),
1294
+ ]
1295
+ archive = tmp_path / MSYS2_ARCHIVE_NAME
1296
+ _write_test_tar(archive, entries)
1297
+ monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_MEMBER_COUNT", 3)
1298
+ monkeypatch.setattr(
1299
+ windows_msys2, "MSYS2_ARCHIVE_UNPACKED_SIZE", len(b"verified bash")
1300
+ )
1301
+ destination = tmp_path / "stage"
1302
+ destination.mkdir()
1303
+ log = tmp_path / "install.log"
1304
+ output = io.StringIO()
1305
+
1306
+ with InstallReporter(log, verbose=False, stream=output) as reporter:
1307
+ windows_msys2._extract_msys2_archive(archive, destination, reporter=reporter)
1308
+
1309
+ assert (destination / "msys64" / "usr" / "bash.exe").read_bytes() == (
1310
+ b"verified bash"
1311
+ )
1312
+ rendered = output.getvalue()
1313
+ assert "Extracting private runtime: 1/3 files (33%)" in rendered
1314
+ assert "Extracting private runtime: 3/3 files (100%)" in rendered
1315
+ assert "MSYS2 archive extraction" in log.read_text(encoding="utf-8")
1316
+
1317
+
1318
+ @pytest.mark.parametrize(
1319
+ ("member", "message"),
1320
+ [
1321
+ (_tar_file("../outside", b"bad"), "unsafe path"),
1322
+ (_tar_file("/absolute", b"bad"), "unsafe path"),
1323
+ (_tar_file(r"msys64\..\outside", b"bad"), "unsafe path"),
1324
+ ],
1325
+ )
1326
+ def test_tar_xz_extraction_rejects_paths_outside_staging(
1327
+ tmp_path,
1328
+ monkeypatch,
1329
+ member,
1330
+ message,
1331
+ ):
1332
+ archive = tmp_path / MSYS2_ARCHIVE_NAME
1333
+ _write_test_tar(archive, [member])
1334
+ monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_MEMBER_COUNT", 1)
1335
+ monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_UNPACKED_SIZE", 3)
1336
+ destination = tmp_path / "stage"
1337
+ destination.mkdir()
1338
+
1339
+ with InstallReporter(
1340
+ tmp_path / "install.log", verbose=False, stream=io.StringIO()
1341
+ ) as reporter:
1342
+ with pytest.raises(RuntimeInstallError, match=message):
1343
+ windows_msys2._extract_msys2_archive(
1344
+ archive, destination, reporter=reporter
1345
+ )
1346
+
1347
+ assert not (tmp_path / "outside").exists()
1348
+
1349
+
1350
+ def test_tar_xz_extraction_rejects_links_and_special_files(tmp_path, monkeypatch):
1351
+ member = tarfile.TarInfo("msys64/link")
1352
+ member.type = tarfile.SYMTYPE
1353
+ member.linkname = "../outside"
1354
+ archive = tmp_path / MSYS2_ARCHIVE_NAME
1355
+ _write_test_tar(archive, [_tar_directory("msys64"), (member, b"")])
1356
+ monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_MEMBER_COUNT", 2)
1357
+ monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_UNPACKED_SIZE", 0)
1358
+ destination = tmp_path / "stage"
1359
+ destination.mkdir()
1360
+
1361
+ with InstallReporter(
1362
+ tmp_path / "install.log", verbose=False, stream=io.StringIO()
1363
+ ) as reporter:
1364
+ with pytest.raises(RuntimeInstallError, match="unsupported link"):
1365
+ windows_msys2._extract_msys2_archive(
1366
+ archive, destination, reporter=reporter
1367
+ )
1368
+
1369
+
1370
+ def test_tar_xz_extraction_rejects_changed_inventory_without_publishing(
1371
+ tmp_path,
1372
+ monkeypatch,
1373
+ ):
1374
+ archive = tmp_path / MSYS2_ARCHIVE_NAME
1375
+ _write_test_tar(archive, [_tar_directory("msys64")])
1376
+ monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_MEMBER_COUNT", 2)
1377
+ monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_UNPACKED_SIZE", 0)
1378
+ destination = tmp_path / "stage"
1379
+ destination.mkdir()
1380
+
1381
+ with InstallReporter(
1382
+ tmp_path / "install.log", verbose=False, stream=io.StringIO()
1383
+ ) as reporter:
1384
+ with pytest.raises(RuntimeInstallError, match="member count changed"):
1385
+ windows_msys2._extract_msys2_archive(
1386
+ archive, destination, reporter=reporter
1387
+ )
1388
+
1389
+ assert (destination / "msys64").is_dir()
1390
+
1391
+
1256
1392
  def test_pacman_network_failure_retries_with_cache_and_relaxed_timeout(tmp_path):
1257
1393
  root = tmp_path / "msys64"
1258
1394
  bash = root / "usr" / "bin" / "bash.exe"
@@ -8,7 +8,7 @@ import pytest
8
8
  import urwid
9
9
 
10
10
  from railmux import tmux_server, windows_ui_transition as transition
11
- from railmux.windows_msys2 import MSYS2_ARCHIVE_SHA256
11
+ from railmux.windows_msys2 import MSYS2_BASE_LINEAGE_SHA256
12
12
  from railmux.ui import app as app_module
13
13
  from railmux.ui.app import App
14
14
 
@@ -46,7 +46,7 @@ def _validated_tree(monkeypatch, tmp_path: Path) -> Path:
46
46
  json.dumps({
47
47
  "schema": 1,
48
48
  "runtime": RUNTIME,
49
- "archive_sha256": MSYS2_ARCHIVE_SHA256,
49
+ "archive_sha256": MSYS2_BASE_LINEAGE_SHA256,
50
50
  "content_id": CONTENT,
51
51
  "package_count": 3,
52
52
  "core_packages": {
@@ -88,7 +88,7 @@ def test_upgrade_exec_requires_exact_content_bound_app(monkeypatch, tmp_path):
88
88
  json.dumps({
89
89
  "schema": 1,
90
90
  "runtime": RUNTIME,
91
- "archive_sha256": MSYS2_ARCHIVE_SHA256,
91
+ "archive_sha256": MSYS2_BASE_LINEAGE_SHA256,
92
92
  "content_id": "c" * 64,
93
93
  "package_count": 3,
94
94
  "core_packages": {
@@ -1 +0,0 @@
1
- __version__ = "0.4.0.dev25"
File without changes
File without changes
File without changes
File without changes