gitpane 1.0.0__py3-none-any.whl

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 (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. gitpane-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,216 @@
1
+ """Exchange one bounded SSH prompt over an authenticated, same-user local socket."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import io
7
+ import json
8
+ import os
9
+ import platform
10
+ import shlex
11
+ import socket
12
+ import struct
13
+ import sys
14
+ import zipfile
15
+ from ctypes import wintypes
16
+ from typing import TYPE_CHECKING
17
+
18
+ from PySide6.QtCore import QCoreApplication, QElapsedTimer
19
+ from PySide6.QtNetwork import QLocalSocket
20
+
21
+ from gitpane.config import (
22
+ LINUX_PLATFORM,
23
+ MACOS_PLATFORM,
24
+ MODULE_OPTION,
25
+ SSH_IPC_CONNECT_TIMEOUT_MS,
26
+ SSH_IPC_LIMIT_BYTES,
27
+ SSH_PROMPT_LIMIT_BYTES,
28
+ SSH_PROMPT_TIMEOUT_MS,
29
+ SSH_RESPONSE_LIMIT_BYTES,
30
+ WINDOWS_PLATFORM,
31
+ application_module_interpreter_options,
32
+ )
33
+ from gitpane.process.process_group import WINDOWLESS_INTERPRETER_BASENAME
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Callable
37
+
38
+ # struct ucred of <sys/socket.h>: process id, user id, group id.
39
+ LINUX_PEER_CREDENTIAL_FORMAT = "3i"
40
+ LINUX_PEER_CREDENTIAL_PROCESS_ID_INDEX = 0
41
+ LINUX_PEER_CREDENTIAL_USER_ID_INDEX = 1
42
+ # Workaround: Python exposes neither SOL_LOCAL nor LOCAL_PEERPID on macOS, only LOCAL_PEERCRED. Their values come
43
+ # from <sys/un.h>, where Apple has kept them unchanged since LOCAL_PEERPID appeared.
44
+ MACOS_LOCAL_SOCKET_LEVEL = 0
45
+ MACOS_LOCAL_PEER_PROCESS_ID_OPTION = 2
46
+ # pid_t, a C int.
47
+ MACOS_PEER_PROCESS_ID_FORMAT = "i"
48
+ MACOS_PEER_PROCESS_ID_INDEX = 0
49
+ # struct xucred of <sys/ucred.h>: version, user id, group count, then the NGROUPS (16) group ids.
50
+ MACOS_PEER_CREDENTIAL_FORMAT = "IIh16I"
51
+ MACOS_PEER_CREDENTIAL_USER_ID_INDEX = 1
52
+ OTHER_USER_PEER_MESSAGE = "SSH prompt peer belongs to another user"
53
+
54
+
55
+ def _windows_peer_process_id(descriptor: int, server: bool) -> int:
56
+ """Ask Windows which process holds the other end of the named pipe: the server for a client, the client for a server."""
57
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
58
+ function = kernel.GetNamedPipeServerProcessId if server else kernel.GetNamedPipeClientProcessId
59
+ function.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.ULONG)]
60
+ function.restype = wintypes.BOOL
61
+ process_id = wintypes.ULONG()
62
+ if not function(descriptor, ctypes.byref(process_id)):
63
+ raise ctypes.WinError(ctypes.get_last_error())
64
+ return process_id.value
65
+
66
+
67
+ def _refuse_other_user(user_id: int) -> None:
68
+ """Refuse a peer run by another user: its process id alone would not prove it belongs to this session."""
69
+ if user_id != os.getuid():
70
+ raise OSError(OTHER_USER_PEER_MESSAGE)
71
+
72
+
73
+ def _linux_peer_process_id(descriptor: int, server: bool) -> int:
74
+ """Read the peer process and user Linux records on a connected local socket (SO_PEERCRED)."""
75
+ with socket.socket(fileno=os.dup(descriptor)) as native_socket:
76
+ credentials = native_socket.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize(LINUX_PEER_CREDENTIAL_FORMAT))
77
+ credential_fields = struct.unpack(LINUX_PEER_CREDENTIAL_FORMAT, credentials)
78
+ _refuse_other_user(credential_fields[LINUX_PEER_CREDENTIAL_USER_ID_INDEX])
79
+ return credential_fields[LINUX_PEER_CREDENTIAL_PROCESS_ID_INDEX]
80
+
81
+
82
+ def _macos_peer_process_id(descriptor: int, server: bool) -> int:
83
+ """Read the peer process (LOCAL_PEERPID) and user (LOCAL_PEERCRED) macOS records on a connected local socket."""
84
+ credential_option = getattr(socket, "LOCAL_PEERCRED", None)
85
+ if credential_option is None:
86
+ raise OSError("This Python does not expose LOCAL_PEERCRED, so the local socket peer cannot be identified")
87
+ with socket.socket(fileno=os.dup(descriptor)) as native_socket:
88
+ process_id_data = native_socket.getsockopt(MACOS_LOCAL_SOCKET_LEVEL, MACOS_LOCAL_PEER_PROCESS_ID_OPTION, struct.calcsize(MACOS_PEER_PROCESS_ID_FORMAT))
89
+ credentials = native_socket.getsockopt(MACOS_LOCAL_SOCKET_LEVEL, credential_option, struct.calcsize(MACOS_PEER_CREDENTIAL_FORMAT))
90
+ _refuse_other_user(struct.unpack(MACOS_PEER_CREDENTIAL_FORMAT, credentials)[MACOS_PEER_CREDENTIAL_USER_ID_INDEX])
91
+ return struct.unpack(MACOS_PEER_PROCESS_ID_FORMAT, process_id_data)[MACOS_PEER_PROCESS_ID_INDEX]
92
+
93
+
94
+ PEER_PROCESS_ID_READER_BY_PLATFORM: dict[str, Callable[[int, bool], int]] = {
95
+ WINDOWS_PLATFORM: _windows_peer_process_id,
96
+ LINUX_PLATFORM: _linux_peer_process_id,
97
+ MACOS_PLATFORM: _macos_peer_process_id,
98
+ }
99
+
100
+
101
+ def peer_process_id_reader(platform: str) -> Callable[[int, bool], int]:
102
+ """Choose how ``platform`` names the process at the other end of a local socket.
103
+
104
+ A system with no known reading raises OSError, which every socket owner treats as a refused peer, so a peer is
105
+ never accepted on a system where the operating system cannot prove who it is.
106
+ """
107
+ platform_key = LINUX_PLATFORM if platform.startswith(LINUX_PLATFORM) else platform
108
+ reader = PEER_PROCESS_ID_READER_BY_PLATFORM.get(platform_key)
109
+ if reader is None:
110
+ raise OSError(f"Cannot identify a local socket peer on {platform}")
111
+ return reader
112
+
113
+
114
+ def peer_process_id(connection: QLocalSocket, *, server: bool = False, platform: str = sys.platform) -> int:
115
+ """Read the OS peer identity so a guessed socket name cannot impersonate an operation."""
116
+ return peer_process_id_reader(platform)(int(connection.socketDescriptor()), server)
117
+
118
+
119
+ def askpass_module_name() -> str:
120
+ """Answer the module name the SSH helper launcher passes to -m and the Windows archive imports.
121
+
122
+ It is read from the module as the import system loaded it, so a move that rewrites the import cannot leave a stale
123
+ name in the launcher, and it stays right when this module runs itself through -m, where __name__ is __main__.
124
+ """
125
+ if __spec__ is None:
126
+ raise OSError("The SSH helper module was not loaded by the import system, so its name is unknown.")
127
+ return __spec__.name
128
+
129
+
130
+ def askpass_bootstrap_text() -> str:
131
+ """Answer the Python text the Windows SSH helper archive runs: import the helper's entry point and exit with its status."""
132
+ return "from " + askpass_module_name() + " import " + main.__name__ + "\nraise SystemExit(" + main.__name__ + "())\n"
133
+
134
+
135
+ def create_launcher(directory: str) -> str:
136
+ """Create an owned windowless launcher that imports only the installed application.
137
+
138
+ The launcher starts the Python of the installation without the Python variables the interpreter reads itself
139
+ (PYTHONPATH, PYTHONHOME...) or the folder it starts from on the import path, so only the installed GitPane, wherever
140
+ pip put it, can be imported. The site module still reads PYTHONUSERBASE, which moves the user's site-packages
141
+ folder; that folder is left out altogether when the running GitPane runs without it.
142
+ """
143
+ bootstrap = askpass_bootstrap_text()
144
+ interpreter_options = application_module_interpreter_options(bool(sys.flags.no_user_site))
145
+ filename = os.path.join(directory, "askpass.exe" if os.name == "nt" else "askpass")
146
+ if os.name == "nt":
147
+ # OpenSSH calls an executable directly, so a batch file cannot safely forward arbitrary prompts.
148
+ # The bundled distlib launcher starts the Python named on its shebang line, with the options written there.
149
+ from importlib.resources import files
150
+ launcher_basename = "w64-arm.exe" if platform.machine().lower() == "arm64" else "w64.exe" if struct.calcsize("P") == 8 else "w32.exe"
151
+ launcher = files("gitpane").joinpath("vendor", launcher_basename).read_bytes()
152
+ python_filename = os.path.join(os.path.dirname(sys.executable), WINDOWLESS_INTERPRETER_BASENAME)
153
+ archive = io.BytesIO()
154
+ with zipfile.ZipFile(archive, "w") as content:
155
+ content.writestr("__main__.py", bootstrap)
156
+ shebang = '#!"' + python_filename + '" ' + " ".join(interpreter_options) + "\n"
157
+ data = launcher + shebang.encode("utf-8") + archive.getvalue()
158
+ else:
159
+ start_command = shlex.join([sys.executable, *interpreter_options, MODULE_OPTION, askpass_module_name()])
160
+ data = ("#!/bin/sh\nexec " + start_command + ' "$@"\n').encode("utf-8")
161
+ with open(filename, "xb") as stream:
162
+ stream.write(data)
163
+ os.chmod(filename, 0o700)
164
+ return filename
165
+
166
+
167
+ def main() -> int:
168
+ """Return one private answer to OpenSSH stdout, refusing malformed or expired exchanges."""
169
+ application = QCoreApplication.instance() or QCoreApplication(sys.argv)
170
+ prompt_index = 1
171
+ if len(sys.argv) != prompt_index + 1:
172
+ return 1
173
+ prompt = sys.argv[prompt_index]
174
+ if len(prompt.encode("utf-8")) > SSH_PROMPT_LIMIT_BYTES or "\0" in prompt:
175
+ return 1
176
+ connection = QLocalSocket()
177
+ try:
178
+ connection.connectToServer(os.environ["GITPANE_SSH_PIPE"])
179
+ if not connection.waitForConnected(SSH_IPC_CONNECT_TIMEOUT_MS):
180
+ return 1
181
+ if peer_process_id(connection, server=True) != int(os.environ["GITPANE_SSH_OWNER_PID"]):
182
+ return 1
183
+ request_by_key = {"nonce": os.environ["GITPANE_SSH_NONCE"], "operation": os.environ["GITPANE_SSH_OPERATION"],
184
+ "prompt": prompt, "hint": os.environ.get("SSH_ASKPASS_PROMPT", "")}
185
+ connection.write(json.dumps(request_by_key).encode("utf-8") + b"\n")
186
+ if not connection.waitForBytesWritten(SSH_IPC_CONNECT_TIMEOUT_MS):
187
+ return 1
188
+ deadline = QElapsedTimer()
189
+ deadline.start()
190
+ response = bytearray()
191
+ while deadline.elapsed() < SSH_PROMPT_TIMEOUT_MS:
192
+ response.extend(bytes(connection.readAll()))
193
+ if len(response) > SSH_IPC_LIMIT_BYTES:
194
+ return 1
195
+ if b"\n" in response:
196
+ response_by_key = json.loads(response)
197
+ answer = response_by_key.get("answer")
198
+ if not isinstance(answer, str) or len(answer.encode("utf-8")) > SSH_RESPONSE_LIMIT_BYTES or any(character in answer for character in "\r\n\0"):
199
+ return 1
200
+ if sys.stdout is None:
201
+ return 1
202
+ sys.stdout.buffer.write(answer.encode("utf-8") + b"\n")
203
+ sys.stdout.buffer.flush()
204
+ return 0
205
+ if not connection.waitForReadyRead(max(1, SSH_PROMPT_TIMEOUT_MS - deadline.elapsed())):
206
+ return 1
207
+ return 1
208
+ except (OSError, ValueError, KeyError, TypeError):
209
+ return 1
210
+ finally:
211
+ connection.abort()
212
+ del application
213
+
214
+
215
+ if __name__ == "__main__":
216
+ raise SystemExit(main())
@@ -0,0 +1,222 @@
1
+ """Own bounded SSH prompt sockets until their supervised operation has drained."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import json
7
+ import os
8
+ import secrets
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from PySide6.QtCore import QObject, QTimer, Signal
14
+ from PySide6.QtNetwork import QLocalServer, QLocalSocket
15
+
16
+ from gitpane.account.ssh_askpass import create_launcher, peer_process_id
17
+ from gitpane.config import (
18
+ SSH_IPC_CONNECT_TIMEOUT_MS,
19
+ SSH_IPC_LIMIT_BYTES,
20
+ SSH_NONCE_BYTES,
21
+ SSH_PENDING_CONNECTION_LIMIT,
22
+ SSH_PROMPT_COUNT_LIMIT,
23
+ SSH_PROMPT_LIMIT_BYTES,
24
+ SSH_PROMPT_TIMEOUT_MS,
25
+ SSH_RESPONSE_LIMIT_BYTES,
26
+ )
27
+ from gitpane.process.process_runner import ProcessStatus
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Callable
31
+
32
+ from gitpane.process.process_runner import ProcessRequest, ProcessRunner
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SshQuestion:
37
+ """Expose prompt text and a private reply callback without carrying credentials in signals."""
38
+
39
+ operation_id: str
40
+ description: str
41
+ text: str
42
+ confirmation: bool
43
+ answer: Callable[[str | None], None]
44
+
45
+
46
+ class SshPromptBridge(QObject):
47
+ """Authenticate native descendants before opening asynchronous credential questions."""
48
+
49
+ question_requested = Signal(object)
50
+ dismissed = Signal()
51
+
52
+ def __init__(self, request: ProcessRequest, runner: ProcessRunner, is_current: Callable[[], bool], parent: QObject | None = None,
53
+ *, timeout_ms: int = SSH_PROMPT_TIMEOUT_MS) -> None:
54
+ """Create a same-user named pipe or filesystem socket for exactly one operation."""
55
+ super().__init__(parent)
56
+ self.request = request
57
+ self.runner = runner
58
+ self.is_current = is_current
59
+ self.timeout_ms = timeout_ms
60
+ self.nonce = secrets.token_hex(SSH_NONCE_BYTES)
61
+ self.closed = False
62
+ self.refused = False
63
+ self.prompt_count = 0
64
+ self.connection_by_socket: dict[QLocalSocket, bytearray] = {}
65
+ self.timer_by_socket: dict[QLocalSocket, QTimer] = {}
66
+ self.pending_socket: QLocalSocket | None = None
67
+ self.directory = tempfile.TemporaryDirectory(prefix="gitpane-ssh-")
68
+ self.server = QLocalServer(self)
69
+ self.server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption)
70
+ self.server.setMaxPendingConnections(SSH_PENDING_CONNECTION_LIMIT)
71
+ pipe_name = "gitpane-ssh-" + secrets.token_hex(SSH_NONCE_BYTES) if os.name == "nt" else os.path.join(self.directory.name, "socket")
72
+ try:
73
+ self.launcher_filename = create_launcher(self.directory.name)
74
+ if not self.server.listen(pipe_name):
75
+ raise OSError("Could not open private SSH prompt channel: " + self.server.errorString())
76
+ except Exception:
77
+ self.directory.cleanup()
78
+ raise
79
+ self.server.newConnection.connect(self._accept)
80
+ self.timer = QTimer(self)
81
+ self.timer.setSingleShot(True)
82
+ self.timer.timeout.connect(self._expire_prompt)
83
+
84
+ def environment(self) -> dict[str, str]:
85
+ """Supply only this operation's capability and exact helper executable to its child."""
86
+ return {"SSH_ASKPASS": self.launcher_filename, "GIT_ASKPASS": self.launcher_filename, "SSH_ASKPASS_REQUIRE": "force", "DISPLAY": "gitpane:0",
87
+ "GITPANE_SSH_PIPE": self.server.fullServerName(), "GITPANE_SSH_NONCE": self.nonce,
88
+ "GITPANE_SSH_OPERATION": self.request.operation_id, "GITPANE_SSH_OWNER_PID": str(os.getpid())}
89
+
90
+ def refuse(self) -> None:
91
+ """Refuse pending and future questions without releasing process ownership early."""
92
+ self.refused = True
93
+ self.timer.stop()
94
+ self.pending_socket = None
95
+ for connection in tuple(self.connection_by_socket):
96
+ connection.abort()
97
+ self.dismissed.emit()
98
+
99
+ def close(self) -> None:
100
+ """Remove only owned IPC resources after the runner reports complete drainage."""
101
+ if self.closed:
102
+ return
103
+ self.closed = True
104
+ self.refuse()
105
+ self.server.close()
106
+ self.server.newConnection.disconnect()
107
+ self.timer.timeout.disconnect()
108
+ self.nonce = ""
109
+ self.directory.cleanup()
110
+ # Callbacks retain the service; releasing its last reference during Qt teardown
111
+ # must not let the parent delete this bridge again while its destructor is active.
112
+ self.setParent(None)
113
+ self.deleteLater()
114
+
115
+ def _accept(self) -> None:
116
+ """Bound peers and authenticate their kernel-reported process identity first."""
117
+ while self.server.hasPendingConnections():
118
+ connection = self.server.nextPendingConnection()
119
+ connection.setReadBufferSize(SSH_IPC_LIMIT_BYTES + 1)
120
+ try:
121
+ active = self.runner.active_by_id.get(self.request.request_id)
122
+ if (self.closed or self.refused or not self.is_current() or active is None or active.status != ProcessStatus.RUNNING
123
+ or active.request.operation_id != self.request.operation_id or active.group is None
124
+ or len(self.connection_by_socket) >= SSH_PENDING_CONNECTION_LIMIT
125
+ or not active.group.contains_process(peer_process_id(connection))):
126
+ raise ValueError("Unrecognized SSH prompt peer")
127
+ except (OSError, ValueError, AttributeError):
128
+ connection.abort()
129
+ connection.deleteLater()
130
+ continue
131
+ self.connection_by_socket[connection] = bytearray()
132
+ connection.readyRead.connect(lambda current=connection: self._read(current))
133
+ connection.disconnected.connect(lambda current=connection: self._disconnected(current))
134
+ timer = QTimer(connection)
135
+ timer.setSingleShot(True)
136
+ timer.timeout.connect(lambda current=connection: self._expire_handshake(current))
137
+ self.timer_by_socket[connection] = timer
138
+ timer.start(SSH_IPC_CONNECT_TIMEOUT_MS)
139
+ self._read(connection)
140
+
141
+ def _expire_handshake(self, connection: QLocalSocket) -> None:
142
+ """Disconnect peers that do not provide their authenticated question in time."""
143
+ if connection in self.connection_by_socket and connection is not self.pending_socket:
144
+ connection.abort()
145
+
146
+ def _expire_prompt(self) -> None:
147
+ """Stop an unanswered operation while preserving its bridge through process drainage."""
148
+ self.refuse()
149
+ self.runner.cancel(self.request.request_id)
150
+
151
+ def _disconnected(self, connection: QLocalSocket) -> None:
152
+ """Release a departed peer and dismiss any question waiting on its socket."""
153
+ self.connection_by_socket.pop(connection, None)
154
+ timer = self.timer_by_socket.pop(connection, None)
155
+ if timer is not None:
156
+ timer.stop()
157
+ timer.timeout.disconnect()
158
+ connection.readyRead.disconnect()
159
+ connection.disconnected.disconnect()
160
+ if self.pending_socket is connection:
161
+ self.pending_socket = None
162
+ self.timer.stop()
163
+ self.dismissed.emit()
164
+ connection.deleteLater()
165
+
166
+ def _read(self, connection: QLocalSocket) -> None:
167
+ """Validate one framed prompt, nonce and operation before it reaches any widget."""
168
+ content = self.connection_by_socket.get(connection)
169
+ if content is None:
170
+ return
171
+ content.extend(bytes(connection.readAll()))
172
+ if len(content) > SSH_IPC_LIMIT_BYTES or connection is self.pending_socket:
173
+ connection.abort()
174
+ return
175
+ if b"\n" not in content:
176
+ return
177
+ try:
178
+ data_by_key = json.loads(content)
179
+ if not isinstance(data_by_key, dict):
180
+ raise TypeError("Invalid SSH question")
181
+ nonce = data_by_key.get("nonce")
182
+ prompt = data_by_key.get("prompt")
183
+ hint = data_by_key.get("hint", "")
184
+ if (not isinstance(nonce, str) or not hmac.compare_digest(nonce, self.nonce) or data_by_key.get("operation") != self.request.operation_id
185
+ or not isinstance(prompt, str) or not prompt or len(prompt.encode("utf-8")) > SSH_PROMPT_LIMIT_BYTES or "\0" in prompt
186
+ or hint not in {"", "confirm", "none"} or self.pending_socket is not None or self.prompt_count >= SSH_PROMPT_COUNT_LIMIT
187
+ or self.refused or not self.is_current()):
188
+ raise ValueError("Invalid SSH question")
189
+ if "REMOTE HOST IDENTIFICATION HAS CHANGED" in prompt.upper():
190
+ raise ValueError("Changed host keys require native SSH configuration repair")
191
+ except (ValueError, UnicodeError, TypeError):
192
+ connection.abort()
193
+ return
194
+ self.prompt_count += 1
195
+ self.timer_by_socket[connection].stop()
196
+ self.pending_socket = connection
197
+ confirmation = hint == "confirm" or "are you sure you want to continue connecting" in prompt.lower()
198
+ question = SshQuestion(self.request.operation_id, self.request.operation, prompt, confirmation, lambda answer: self._answer(connection, confirmation, answer))
199
+ self.timer.start(self.timeout_ms)
200
+ self.question_requested.emit(question)
201
+
202
+ def _answer(self, connection: QLocalSocket, confirmation: bool, answer: str | None) -> None:
203
+ """Write a bounded answer only to the still-current authenticated requesting process."""
204
+ if self.closed or self.refused or connection is not self.pending_socket:
205
+ return
206
+ if not self.is_current() or answer is None:
207
+ self.refuse()
208
+ self.runner.cancel(self.request.request_id)
209
+ return
210
+ if not isinstance(answer, str) or len(answer.encode("utf-8")) > SSH_RESPONSE_LIMIT_BYTES or any(character in answer for character in "\r\n\0"):
211
+ self.refuse()
212
+ return
213
+ if confirmation and answer not in {"yes", "no"}:
214
+ self.refuse()
215
+ return
216
+ if not confirmation:
217
+ self.runner.protect_secret(self.request.request_id, answer)
218
+ self.timer.stop()
219
+ self.pending_socket = None
220
+ connection.write(json.dumps({"answer": answer}).encode("utf-8") + b"\n")
221
+ connection.disconnectFromServer()
222
+ self.dismissed.emit()