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,836 @@
1
+ """Perform credential exchanges in an isolated child owned by the process supervisor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import http.client
7
+ import json
8
+ import os
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from dataclasses import asdict
17
+ from typing import TYPE_CHECKING
18
+ from urllib.parse import quote, urlsplit, urlunsplit
19
+
20
+ from gitpane.account.authentication import (
21
+ ACCOUNT_NAMESPACE_PREFIX,
22
+ BITBUCKET_OAUTH_GIT_USERNAME,
23
+ BROWSER_AUTH_MODE_BY_SETTING,
24
+ BROWSER_GIT_USERNAME_SET,
25
+ BROWSER_SIGN_IN_UNAVAILABLE_TEXT,
26
+ BROWSER_TIMEOUT_TEXT,
27
+ CHECK_COPY_ERASED_KEY,
28
+ CHECK_FAILED_TEXT,
29
+ CHECK_NAMESPACE_SUFFIX,
30
+ GCM_DISABLED_VALUE,
31
+ GENERIC_PROVIDER,
32
+ GIT_EXEC_PATH_KEY,
33
+ GIT_MISSING_TEXT,
34
+ GITHUB_PROVIDER,
35
+ GITLAB_OAUTH_GIT_USERNAME,
36
+ GITLAB_PROVIDER,
37
+ GITPANE_REFUSAL_KEY,
38
+ MANAGED_CREDENTIAL_PROVIDER,
39
+ MANAGED_CREDENTIAL_STORE,
40
+ PROVIDER_HOST_BY_NAME,
41
+ SECRET_LOOKUP_FIELDS,
42
+ TOKEN_PAGE_PATH_BY_PROVIDER,
43
+ AccountSelection,
44
+ AuthenticationAction,
45
+ SecretPresence,
46
+ account_namespace,
47
+ credential_configuration,
48
+ endpoint_origin,
49
+ generic_account,
50
+ isolated_http_arguments,
51
+ token_page_url,
52
+ validate_text,
53
+ )
54
+ from gitpane.account.credential_manager import GIT_EXEC_PATH_ENVIRONMENT_NAME
55
+ from gitpane.config import (
56
+ APPLICATION_NAME,
57
+ AUTHENTICATION_TIMEOUT_MS,
58
+ BROWSER_SIGN_IN_TIMEOUT_MS,
59
+ GCM_ENVIRONMENT_NAME_BY_SETTING,
60
+ GCM_ENVIRONMENT_PREFIX,
61
+ GIT_LOCAL_ENVIRONMENT_NAMES,
62
+ GIT_READ_TIMEOUT_MS,
63
+ MILLISECONDS_PER_SECOND,
64
+ PROCESS_CHUNK_BYTES,
65
+ PROCESS_TREE_STOP_TIMEOUT_MS,
66
+ READ_SIZE_SENTINEL_BYTES,
67
+ SERVER_PROBE_TIMEOUT_MS,
68
+ TRACE_ENVIRONMENT_PREFIXES,
69
+ TRACE_UNIT_LIMIT_BYTES,
70
+ )
71
+ from gitpane.operation.diagnostic import SensitiveTextFilter
72
+ from gitpane.operation.operation_status import OperationStatus
73
+ from gitpane.process.process_group import JobBreakawayRefusedError, ReleasedChildJob
74
+ from gitpane.transport.git_refusal import RefusalKind, credential_store_unavailable, git_output_lines, refusal_kind
75
+
76
+ if TYPE_CHECKING:
77
+ from email.message import Message
78
+
79
+ # A rejection and a removal both erase the selected account's secret, and nothing else.
80
+ ERASE_ACTION_SET = frozenset({AuthenticationAction.REJECT, AuthenticationAction.REMOVE})
81
+ BROWSER_NAMESPACE = "gitpane-browser"
82
+ # Every Git Credential Manager setting is a Git configuration key under this section.
83
+ CREDENTIAL_SETTING_PREFIX = "credential."
84
+ # A browser sign-in stores nothing itself: only the token whose identity was verified afterwards is stored.
85
+ BROWSER_CREDENTIAL_STORE = "none"
86
+ # The process group ID that asks for a new group led by the started process itself.
87
+ NEW_PROCESS_GROUP_ID = 0
88
+ # Names of the variables that could make the account test ask anyone else for a secret, or trace what it sends.
89
+ CHECK_REMOVED_ENVIRONMENT_NAME_SET = frozenset({"GIT_ASKPASS", "SSH_ASKPASS", "GIT_CURL_VERBOSE", "GIT_SSL_KEYLOGFILE"})
90
+ # The account test sets these itself: its own helper settings, and where repository discovery must stop.
91
+ CHECK_KEPT_ENVIRONMENT_NAME_SET = frozenset({"GIT_CONFIG_COUNT", "GIT_CEILING_DIRECTORIES"})
92
+ CHECK_TIMEOUT_CAUSE = "Testing the sign-in with {url} did not finish within {seconds} seconds. Nothing was saved."
93
+ WINDOWS_DEFAULT_ROOT_DIR = "C:\\Windows"
94
+ USER_AGENT = APPLICATION_NAME
95
+ # A server is recognised from its host name only when that name has at least this many labels (github.corp.example),
96
+ # so a bare github.example tells nothing.
97
+ PROVIDER_HOST_MINIMUM_LABEL_COUNT = 3
98
+ FIRST_LABEL_INDEX = 0
99
+ # Headers by which a server tells which software it runs, read on its answer to one HEAD request.
100
+ GITHUB_REQUEST_ID_HEADER = "X-GitHub-Request-Id"
101
+ GITLAB_FEATURE_CATEGORY_HEADER = "X-Gitlab-Feature-Category"
102
+ AUTHENTICATE_HEADER = "WWW-Authenticate"
103
+ GITLAB_REALM_TEXT = 'realm="gitlab"'
104
+ # Git Credential Manager's setting that names the OAuth application of a GitLab server: without one, a self-hosted
105
+ # GitLab cannot offer browser sign-in.
106
+ GITLAB_OAUTH_CLIENT_KEY = "credential.gitLabDevClientId"
107
+
108
+
109
+ class BrowserTimeoutError(Exception):
110
+ """The browser did not answer a browser sign-in within the wait; its helper was stopped before anything was stored."""
111
+
112
+
113
+ class AccountMismatchError(ValueError):
114
+ """GitPane refused the account a sign-in returned, because it is not the account asked for; the message is GitPane's own sentence."""
115
+
116
+
117
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
118
+ """Keep authentication headers on the explicitly selected API host."""
119
+
120
+ def redirect_request(self, request: urllib.request.Request, response: object, code: int, message: str, headers: object, new_url: str) -> None:
121
+ """Refuse API redirects so credentials cannot reach an unapproved destination."""
122
+ return
123
+
124
+
125
+ def helper_environment(data_by_key: dict[str, object], namespace: str, *, browser: bool, trace: bool) -> dict[str, str]:
126
+ """Return the environment that makes the helper work in one namespace with this exchange's own settings only.
127
+
128
+ Inherited helper settings, askpass programs and traces are dropped. GitPane's own settings are given both as Git
129
+ configuration and as Git Credential Manager's variables, which win over any configuration of the user, so the
130
+ user's settings can never choose another store, namespace, provider or prompt mode for GitPane's accounts; the
131
+ settings GitPane does not depend on stay the user's. ``trace`` turns on the helper's diagnostics, which never
132
+ include secrets; without it nothing is traced at all. The helper started directly gets the folder of
133
+ the programs of the Git it was found for, so it finds Git even when Git is not on the PATH; an inherited folder,
134
+ which may belong to another Git, is never passed on.
135
+ """
136
+ environment_by_name = dict(os.environ)
137
+ for name in tuple(environment_by_name):
138
+ if name.startswith((GCM_ENVIRONMENT_PREFIX, *TRACE_ENVIRONMENT_PREFIXES, "GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")) or name in {"GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT", "GIT_ASKPASS", "SSH_ASKPASS"}:
139
+ environment_by_name.pop(name)
140
+ environment_by_name.pop(GIT_EXEC_PATH_ENVIRONMENT_NAME, None)
141
+ exec_path = str(data_by_key.get(GIT_EXEC_PATH_KEY) or "")
142
+ if exec_path:
143
+ environment_by_name[GIT_EXEC_PATH_ENVIRONMENT_NAME] = exec_path
144
+ origin = str(data_by_key["origin"])
145
+ provider = str(data_by_key["provider"]).lower() if browser else MANAGED_CREDENTIAL_PROVIDER
146
+ store = BROWSER_CREDENTIAL_STORE if browser else MANAGED_CREDENTIAL_STORE
147
+ namespace = namespace or BROWSER_NAMESPACE
148
+ setting_pairs = [("credential.helper", ""), ("credential.provider", provider), (f"credential.{origin}.provider", provider),
149
+ ("credential.interactive", "true" if browser else GCM_DISABLED_VALUE), ("credential.credentialStore", store), (f"credential.{origin}.credentialStore", store),
150
+ ("credential.namespace", namespace), (f"credential.{origin}.namespace", namespace),
151
+ ("credential.trace", "true" if trace else "false"), ("credential.traceSecrets", GCM_DISABLED_VALUE), ("credential.useHttpPath", "false")]
152
+ for setting, mode in BROWSER_AUTH_MODE_BY_SETTING.items():
153
+ setting_pairs.append((CREDENTIAL_SETTING_PREFIX + setting, mode))
154
+ environment_by_name["GIT_CONFIG_COUNT"] = str(len(setting_pairs))
155
+ for index, (key, value) in enumerate(setting_pairs):
156
+ environment_by_name[f"GIT_CONFIG_KEY_{index}"] = key
157
+ environment_by_name[f"GIT_CONFIG_VALUE_{index}"] = value
158
+ if trace:
159
+ environment_by_name["GCM_TRACE"] = "1"
160
+ # The same settings again as Git Credential Manager's own variables, which it reads before any Git configuration:
161
+ # a setting the user scoped to this address would otherwise win over the unscoped one above. Settings scoped to the
162
+ # address here, and settings outside the table, give no variable.
163
+ for key, value in setting_pairs:
164
+ setting = key.removeprefix(CREDENTIAL_SETTING_PREFIX)
165
+ if setting in GCM_ENVIRONMENT_NAME_BY_SETTING:
166
+ environment_by_name[GCM_ENVIRONMENT_NAME_BY_SETTING[setting]] = value
167
+ return environment_by_name
168
+
169
+
170
+ def credential_request_content(credential_by_key: dict[str, str]) -> bytes:
171
+ """Write the credential request the helper reads on its standard input: one key=value line each, then an empty line."""
172
+ content = ""
173
+ for key, value in credential_by_key.items():
174
+ validate_text(value, allow_empty=True)
175
+ content += key + "=" + value + "\n"
176
+ content += "\n"
177
+ return content.encode("utf-8")
178
+
179
+
180
+ def helper_response(data_by_key: dict[str, object], credential_by_key: dict[str, str], return_code: int | None, stdout: bytes, stderr: bytes) -> dict[str, str]:
181
+ """Read the helper's answer, writing its diagnostics without any secret, and refuse the answer of a failed helper."""
182
+ response_by_key: dict[str, str] = {}
183
+ for line in stdout.decode("utf-8", errors="strict").splitlines():
184
+ key, separator, value = line.partition("=")
185
+ if separator:
186
+ response_by_key[key] = value
187
+ filter = SensitiveTextFilter({"token": str(data_by_key.get("token", "")), "password": response_by_key.get("password", credential_by_key.get("password", ""))})
188
+ diagnostic = filter.redact(stderr.decode("utf-8", errors="replace"))
189
+ if diagnostic:
190
+ print(diagnostic, file=sys.stderr, flush=True)
191
+ if return_code != 0:
192
+ raise ValueError(diagnostic or "The credential helper failed without an error message")
193
+ return response_by_key
194
+
195
+
196
+ def helper_exchange(data_by_key: dict[str, object], action: str, credential_by_key: dict[str, str], *, namespace: str = "") -> dict[str, str]:
197
+ """Call only the captured helper with private stdin and command-scoped configuration."""
198
+ environment_by_name = helper_environment(data_by_key, namespace, browser=False, trace=True)
199
+ result = subprocess.run([*data_by_key["helper_commands"], action], input=credential_request_content(credential_by_key), capture_output=True, env=environment_by_name,
200
+ timeout=AUTHENTICATION_TIMEOUT_MS / MILLISECONDS_PER_SECOND, check=False)
201
+ return helper_response(data_by_key, credential_by_key, result.returncode, result.stdout, result.stderr)
202
+
203
+
204
+ def lookup_environment(data_by_key: dict[str, object], namespace: str) -> dict[str, str]:
205
+ """Return the environment of a check of one stored secret: the account's own namespace, no prompt, no trace and no network.
206
+
207
+ Nothing is traced, prompts are off both through Git's setting and through Git Credential Manager's own variable, and
208
+ Git is told it may not ask on the terminal either.
209
+ """
210
+ environment_by_name = helper_environment(data_by_key, namespace, browser=False, trace=False)
211
+ environment_by_name[GCM_ENVIRONMENT_NAME_BY_SETTING["interactive"]] = GCM_DISABLED_VALUE
212
+ # Without this, Git Credential Manager asks the server whether it accepts integrated Windows authentication as soon
213
+ # as the secret is missing: a network request this check must never make.
214
+ environment_by_name[GCM_ENVIRONMENT_NAME_BY_SETTING["allowWindowsAuth"]] = GCM_DISABLED_VALUE
215
+ environment_by_name["GIT_TERMINAL_PROMPT"] = "0"
216
+ return environment_by_name
217
+
218
+
219
+ def lookup_timeout_ms(data_by_key: dict[str, object]) -> int:
220
+ """Return how long the whole check of the stored secrets may take, refusing anything but a positive number of milliseconds."""
221
+ value = data_by_key.get("lookup_timeout_ms")
222
+ if type(value) is not int or value <= 0:
223
+ raise ValueError("The secret check time limit must be a positive number of milliseconds")
224
+ return value
225
+
226
+
227
+ def lookup_account(value: object) -> dict[str, str]:
228
+ """Read one account of a check of the stored secrets, refusing anything but the expected nonsecret text fields.
229
+
230
+ The namespace must be one of GitPane's own, so the check can never be pointed at another program's secrets.
231
+ """
232
+ if not isinstance(value, dict) or set(value) != set(SECRET_LOOKUP_FIELDS):
233
+ raise ValueError("The secret check received an invalid account")
234
+ account_by_key = {}
235
+ for name in SECRET_LOOKUP_FIELDS:
236
+ account_by_key[name] = validate_text(value[name])
237
+ if not account_by_key["namespace"].startswith(ACCOUNT_NAMESPACE_PREFIX):
238
+ raise ValueError("The secret check received a namespace that is not GitPane's")
239
+ return account_by_key
240
+
241
+
242
+ def lookup_status(git_username: str, return_code: int | None, stdout: bytes, stderr: bytes) -> SecretPresence:
243
+ """Classify the helper's answer to the check of one stored secret: present, missing or unknown.
244
+
245
+ Only the secret of this account's own Git username counts as present. An answer without any secret, or Git
246
+ Credential Manager saying it may not prompt (which it only needs when nothing is stored), means the secret is gone.
247
+ Any other failure tells nothing. A credential store that cannot be reached (no desktop session, no session bus, a
248
+ locked keyring or a cancelled unlock) says nothing about the secret either, whatever else the message holds; Git
249
+ Credential Manager wraps its messages at about 80 columns, so they are read with their lines merged. The message
250
+ itself is written to the diagnostics as Git Credential Manager wrote it. The secret is never kept nor written anywhere.
251
+ """
252
+ response_by_key: dict[str, str] = {}
253
+ for line in stdout.decode("utf-8", errors="replace").splitlines():
254
+ key, separator, value = line.partition("=")
255
+ if separator:
256
+ response_by_key[key] = value
257
+ password = response_by_key.pop("password", "")
258
+ diagnostic = SensitiveTextFilter({"password": password}).redact(stderr.decode("utf-8", errors="replace")).strip()
259
+ if diagnostic:
260
+ print(diagnostic, file=sys.stderr, flush=True)
261
+ if return_code != 0:
262
+ merged = " ".join(diagnostic.split())
263
+ if credential_store_unavailable(merged):
264
+ return SecretPresence.UNKNOWN
265
+ return SecretPresence.MISSING if refusal_kind(merged) is RefusalKind.CREDENTIALS_MISSING else SecretPresence.UNKNOWN
266
+ if not password:
267
+ return SecretPresence.MISSING
268
+ if response_by_key.get("username") != git_username:
269
+ return SecretPresence.UNKNOWN
270
+ return SecretPresence.PRESENT
271
+
272
+
273
+ def lookup_secret(data_by_key: dict[str, object], account_by_key: dict[str, str], timeout_ms: int) -> SecretPresence:
274
+ """Ask the captured helper, with ``get`` only, whether this account's secret is still stored, within ``timeout_ms``.
275
+
276
+ ``account_by_key`` holds the fields of ``lookup_account``. Nothing is ever stored or erased. A helper that cannot
277
+ start, or does not answer in time, gives an unknown answer; one that runs too long is stopped with every process it started.
278
+ """
279
+ parsed = urlsplit(account_by_key["origin"])
280
+ credential_by_key = {"protocol": parsed.scheme, "host": parsed.netloc, "username": account_by_key["git_username"]}
281
+ request_by_key = {"origin": account_by_key["origin"], "provider": MANAGED_CREDENTIAL_PROVIDER, GIT_EXEC_PATH_KEY: data_by_key.get(GIT_EXEC_PATH_KEY, "")}
282
+ environment_by_name = lookup_environment(request_by_key, account_by_key["namespace"])
283
+ try:
284
+ process = subprocess.Popen([*data_by_key["helper_commands"], "get"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment_by_name,
285
+ process_group=own_process_group())
286
+ except OSError as error:
287
+ print("The credential helper could not start: " + str(error), file=sys.stderr, flush=True)
288
+ return SecretPresence.UNKNOWN
289
+ try:
290
+ stdout, stderr = process.communicate(credential_request_content(credential_by_key), timeout=timeout_ms / MILLISECONDS_PER_SECOND)
291
+ except subprocess.TimeoutExpired:
292
+ stop_process_tree(process)
293
+ try:
294
+ process.communicate(timeout=PROCESS_TREE_STOP_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
295
+ except subprocess.TimeoutExpired:
296
+ pass
297
+ return SecretPresence.UNKNOWN
298
+ return lookup_status(account_by_key["git_username"], process.returncode, stdout, stderr)
299
+
300
+
301
+ def run_lookup(data_by_key: dict[str, object]) -> dict[str, object]:
302
+ """Check every requested account's stored secret, one after the other, within one time limit for them all.
303
+
304
+ Only the namespaces are published, sorted into present, missing and unknown; accounts not reached before the time
305
+ limit are unknown. Each classification is also written to the diagnostics, by account label and address.
306
+ """
307
+ deadline = time.monotonic() + lookup_timeout_ms(data_by_key) / MILLISECONDS_PER_SECOND
308
+ namespaces_by_presence: dict[SecretPresence, list[str]] = {}
309
+ for presence in SecretPresence:
310
+ namespaces_by_presence[presence] = []
311
+ accounts = []
312
+ for value in data_by_key["accounts"]:
313
+ accounts.append(lookup_account(value))
314
+ for account_by_key in accounts:
315
+ remaining_ms = int((deadline - time.monotonic()) * MILLISECONDS_PER_SECOND)
316
+ presence = lookup_secret(data_by_key, account_by_key, remaining_ms) if remaining_ms > 0 else SecretPresence.UNKNOWN
317
+ print("Stored secret of " + account_by_key["label"] + " (" + urlsplit(account_by_key["origin"]).netloc + "): " + presence.value, file=sys.stderr, flush=True)
318
+ namespaces_by_presence[presence].append(account_by_key["namespace"])
319
+ result_by_key: dict[str, object] = {"status": OperationStatus.SUCCESS.value}
320
+ for presence, namespaces in namespaces_by_presence.items():
321
+ result_by_key[presence.value] = namespaces
322
+ return result_by_key
323
+
324
+
325
+ def browser_timeout_ms(data_by_key: dict[str, object]) -> int:
326
+ """Return how long the browser may take to answer, refusing anything but a positive number of milliseconds."""
327
+ value = data_by_key.get("browser_timeout_ms", BROWSER_SIGN_IN_TIMEOUT_MS)
328
+ if type(value) is not int or value <= 0:
329
+ raise ValueError("The browser wait must be a positive number of milliseconds")
330
+ return value
331
+
332
+
333
+ def browser_helper_exchange(data_by_key: dict[str, object], credential_by_key: dict[str, str]) -> dict[str, str]:
334
+ """Ask the helper for a browser sign-in and wait for the browser's answer at most ``browser_timeout_ms``.
335
+
336
+ Nothing is stored by this step: only a token whose identity is checked afterwards is stored, so stopping the helper
337
+ at the end of the wait leaves nothing behind. The helper is stopped at the end of the wait, and with this worker
338
+ when the worker is stopped or GitPane dies, by the guard of the system: a private job on Windows, the kernel's
339
+ parent-death signal on Linux, a small watcher that is the helper's parent on macOS and hands its output and exit
340
+ code on unchanged. The browser the helper opened is the user's own program and is never stopped. When a job that
341
+ holds GitPane keeps the helper, the helper is stopped before it runs and the sign-in fails with
342
+ ``BROWSER_SIGN_IN_UNAVAILABLE_TEXT``: the browser would otherwise be closed with that job.
343
+ """
344
+ environment_by_name = helper_environment(data_by_key, "", browser=True, trace=True)
345
+ content = credential_request_content(credential_by_key)
346
+ wait_ms = browser_timeout_ms(data_by_key)
347
+ job = ReleasedChildJob()
348
+ try:
349
+ try:
350
+ process = job.start([*data_by_key["helper_commands"], "get"], environment_by_name)
351
+ except JobBreakawayRefusedError:
352
+ raise OSError(BROWSER_SIGN_IN_UNAVAILABLE_TEXT) from None
353
+ try:
354
+ stdout, stderr = process.communicate(content, timeout=wait_ms / MILLISECONDS_PER_SECOND)
355
+ except subprocess.TimeoutExpired:
356
+ # Workaround: stopping the helper can fail, for instance when it ended just as the wait did. Releasing the job
357
+ # below stops it anyway, and the user must be told that the browser did not answer, not why the stop failed.
358
+ try:
359
+ job.stop()
360
+ except OSError:
361
+ pass
362
+ try:
363
+ process.communicate(timeout=PROCESS_TREE_STOP_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
364
+ except subprocess.TimeoutExpired:
365
+ pass
366
+ raise BrowserTimeoutError(BROWSER_TIMEOUT_TEXT) from None
367
+ finally:
368
+ job.close()
369
+ return helper_response(data_by_key, credential_by_key, process.returncode, stdout, stderr)
370
+
371
+
372
+ def read_response_prefix(response: object, limit_bytes: int) -> bytes:
373
+ """Read an HTTP answer in chunks until it ends, keeping at most ``limit_bytes`` of it.
374
+
375
+ The length the server announces is not trusted: it neither sizes a buffer nor says when the answer is complete.
376
+ A short chunk is not the end either; only an empty read is.
377
+ """
378
+ content = bytearray()
379
+ while len(content) < limit_bytes:
380
+ chunk = response.read(min(PROCESS_CHUNK_BYTES, limit_bytes - len(content)))
381
+ if not chunk:
382
+ break
383
+ content.extend(chunk)
384
+ return bytes(content)
385
+
386
+
387
+ def controlled_origin(data_by_key: dict[str, object]) -> str:
388
+ """Return the loopback address that isolated tests put in place of every server, or an empty text in normal use.
389
+
390
+ Only plain HTTP on 127.0.0.1 is accepted, so a request can never redirect a real secret to another host this way.
391
+ """
392
+ validation_origin = data_by_key.get("validation_origin")
393
+ if not validation_origin:
394
+ return ""
395
+ controlled = urlsplit(str(validation_origin))
396
+ if controlled.scheme != "http" or controlled.hostname != "127.0.0.1":
397
+ raise ValueError("Controlled authentication requires an explicit loopback endpoint")
398
+ return str(validation_origin)
399
+
400
+
401
+ def validate_identity(data_by_key: dict[str, object], password: str, *, browser: bool) -> AccountSelection:
402
+ """Read the provider's authenticated-user endpoint before accepting or storing a secret."""
403
+ provider = str(data_by_key["provider"])
404
+ origin = endpoint_origin(str(data_by_key["origin"]))
405
+ parsed = urlsplit(origin)
406
+ if provider == "GitHub":
407
+ api_url = "https://api.github.com/user" if parsed.hostname == "github.com" else origin + "/api/v3/user"
408
+ elif provider == "GitLab":
409
+ api_url = origin + ("/oauth/token/info" if browser else "/api/v4/user")
410
+ else:
411
+ if parsed.hostname != "bitbucket.org":
412
+ raise ValueError("Bitbucket Cloud sign-in requires https://bitbucket.org")
413
+ api_url = "https://api.bitbucket.org/2.0/user"
414
+ validation_origin = controlled_origin(data_by_key)
415
+ if validation_origin:
416
+ api_url = validation_origin + urlsplit(api_url).path
417
+ else:
418
+ parsed_api = urlsplit(api_url)
419
+ hostname = parsed_api.hostname.encode("idna").decode("ascii")
420
+ if ":" in hostname:
421
+ hostname = "[" + hostname + "]"
422
+ authority = hostname + (":" + str(parsed_api.port) if parsed_api.port else "")
423
+ api_url = urlunsplit((parsed_api.scheme, authority, quote(parsed_api.path, safe="/%"), "", ""))
424
+ authorization = "Bearer " + password
425
+ if provider == "Bitbucket" and not browser:
426
+ api_email = validate_text(data_by_key.get("api_email"))
427
+ authorization = "Basic " + base64.b64encode((api_email + ":" + password).encode("utf-8")).decode("ascii")
428
+ filter = SensitiveTextFilter({"password": password, "authorization": authorization})
429
+ request = urllib.request.Request(api_url, headers={"Authorization": authorization, "Accept": "application/json", "User-Agent": USER_AGENT})
430
+ opener = urllib.request.build_opener(NoRedirect())
431
+ try:
432
+ with opener.open(request, timeout=GIT_READ_TIMEOUT_MS / MILLISECONDS_PER_SECOND) as response:
433
+ body = read_response_prefix(response, TRACE_UNIT_LIMIT_BYTES + READ_SIZE_SENTINEL_BYTES)
434
+ if len(body) > TRACE_UNIT_LIMIT_BYTES:
435
+ raise ValueError("The authentication response exceeded the supported size")
436
+ response_by_key = json.loads(body)
437
+ if not isinstance(response_by_key, dict):
438
+ raise TypeError("The provider returned an invalid account response")
439
+ print(filter.redact(f"GET {api_url}\nHTTP {response.status}\n" + body.decode("utf-8")), file=sys.stderr, flush=True)
440
+ except urllib.error.HTTPError as error:
441
+ body = read_response_prefix(error, TRACE_UNIT_LIMIT_BYTES).decode("utf-8", errors="replace")
442
+ scope_help = ""
443
+ if error.code == 403:
444
+ scope_help = "Account identity could not be verified; Git access may still be valid. "
445
+ scope_help += "GitLab personal tokens need read_user (or api) in addition to repository access.\n" if provider == "GitLab" else "Grant access to the current account profile, then try again.\n"
446
+ raise ValueError(filter.redact(f"{scope_help}GET {api_url}\nHTTP {error.code}\n{body}")) from None
447
+ if provider == "GitHub":
448
+ account = validate_text(response_by_key.get("login"))
449
+ identity = str(positive_account_id(response_by_key.get("id")))
450
+ git_username = account
451
+ elif provider == "GitLab":
452
+ identity = str(positive_account_id(response_by_key.get("resource_owner_id") if browser else response_by_key.get("id")))
453
+ account = identity
454
+ git_username = GITLAB_OAUTH_GIT_USERNAME if browser else validate_text(response_by_key.get("username"))
455
+ else:
456
+ account = validate_text(response_by_key.get("uuid") or response_by_key.get("account_id"))
457
+ identity = str(data_by_key.get("api_email")) if not browser else account
458
+ git_username = BITBUCKET_OAUTH_GIT_USERNAME if browser else "x-bitbucket-api-token-auth"
459
+ label = "GitLab account " + identity if provider == "GitLab" and browser else validate_text(response_by_key.get("nickname") or response_by_key.get("login") or response_by_key.get("username") or account)
460
+ hint = str(data_by_key.get("account_hint", ""))
461
+ if hint and hint not in (account, label):
462
+ raise ValueError("The provider returned a different account. Choose that account explicitly or sign in again.")
463
+ namespace = account_namespace(provider, origin, account)
464
+ if any(password in value for value in (account, git_username, identity, label)):
465
+ raise ValueError("The provider returned credential data in an account identity field")
466
+ return AccountSelection(provider, origin, account, git_username, identity, label, namespace)
467
+
468
+
469
+ def positive_account_id(value: object) -> int:
470
+ """Require the provider's actual numeric identity rather than inventing one for a null response."""
471
+ if type(value) is not int or value <= 0:
472
+ raise ValueError("The provider returned no valid account identity")
473
+ return value
474
+
475
+
476
+ def run_exchange(data_by_key: dict[str, object]) -> dict[str, object]:
477
+ """Separate rejected authentication from successful identity with failed secure storage."""
478
+ action = AuthenticationAction(data_by_key["action"])
479
+ if action == AuthenticationAction.LOOKUP:
480
+ return run_lookup(data_by_key)
481
+ origin = endpoint_origin(str(data_by_key["origin"]))
482
+ credential_by_key = {"protocol": "https", "host": urlsplit(origin).netloc}
483
+ password = str(data_by_key.get("token", ""))
484
+ selection = AccountSelection(**data_by_key["selection"]) if data_by_key.get("selection") else None
485
+ if data_by_key["provider"] == GENERIC_PROVIDER:
486
+ return run_generic_exchange(data_by_key, credential_by_key, selection)
487
+ if action == AuthenticationAction.GET or action in ERASE_ACTION_SET:
488
+ credential_by_key["username"] = selection.git_username
489
+ response_by_key = helper_exchange(data_by_key, "erase" if action in ERASE_ACTION_SET else "get", credential_by_key, namespace=selection.namespace)
490
+ if action in ERASE_ACTION_SET:
491
+ return {"status": OperationStatus.SUCCESS.value, "stored": False, "authenticated": False}
492
+ password = response_by_key.get("password", "")
493
+ if not password or response_by_key.get("username") != selection.git_username:
494
+ raise ValueError("The selected account is absent from the secure store. Sign in again.")
495
+ data_by_key["account_hint"] = selection.account
496
+ data_by_key["api_email"] = selection.api_identity
497
+ browser = not password
498
+ if browser:
499
+ hint = str(data_by_key.get("account_hint", ""))
500
+ if hint:
501
+ credential_by_key["username"] = hint
502
+ response_by_key = browser_helper_exchange(data_by_key, credential_by_key)
503
+ password = response_by_key.get("password", "")
504
+ if not password:
505
+ raise ValueError("Browser sign-in returned no credential. Sign in again or use a token.")
506
+ if action == AuthenticationAction.GET:
507
+ browser = selection.git_username in BROWSER_GIT_USERNAME_SET
508
+ validated = validate_identity(data_by_key, password, browser=browser)
509
+ if selection is not None and validated.account != selection.account:
510
+ if action == AuthenticationAction.APPROVE:
511
+ raise AccountMismatchError(f"You signed in as {validated.label}, but Reconnect was for {selection.label}. Nothing was changed.")
512
+ raise AccountMismatchError("The stored credential belongs to a different account. Sign in again.")
513
+ if action == AuthenticationAction.GET:
514
+ return {"status": OperationStatus.SUCCESS.value, "selection": asdict(selection), "authenticated": True, "stored": True}
515
+ return store_and_check(data_by_key, credential_by_key, validated, password, authenticated=True)
516
+
517
+
518
+ def store_and_check(data_by_key: dict[str, object], credential_by_key: dict[str, str], validated: AccountSelection, password: str, *, authenticated: bool) -> dict[str, object]:
519
+ """Store the secret in the account's namespace, then read it back, so an account is only listed once the secure store really holds it.
520
+
521
+ A store that refuses or returns something else gives a partial result: the account is not listed. ``authenticated``
522
+ tells whether the server accepted the identity before this step.
523
+ """
524
+ credential_by_key = dict(credential_by_key)
525
+ credential_by_key["username"] = validated.git_username
526
+ credential_by_key["password"] = password
527
+ try:
528
+ helper_exchange(data_by_key, "store", credential_by_key, namespace=validated.namespace)
529
+ lookup_by_key = dict(credential_by_key)
530
+ lookup_by_key.pop("password")
531
+ observed_by_key = helper_exchange(data_by_key, "get", lookup_by_key, namespace=validated.namespace)
532
+ if observed_by_key.get("username") != validated.git_username or observed_by_key.get("password") != password:
533
+ raise ValueError("The secure store did not return the approved credential. Configure the system credential store, then sign in again.")
534
+ except (OSError, ValueError, subprocess.TimeoutExpired) as error:
535
+ safe_error = SensitiveTextFilter({"password": password}).redact(str(error))
536
+ return {"status": OperationStatus.PARTIAL.value, "selection": asdict(validated), "authenticated": authenticated, "stored": False, "error": safe_error}
537
+ return {"status": OperationStatus.SUCCESS.value, "selection": asdict(validated), "authenticated": authenticated, "stored": True}
538
+
539
+
540
+ def check_copy_credential(data_by_key: dict[str, object], selection: AccountSelection) -> dict[str, str]:
541
+ """Describe the temporary copy of a secret under test the way Git asks the helper for it: protocol, host and username."""
542
+ parsed = urlsplit(controlled_origin(data_by_key) or selection.origin)
543
+ return {"protocol": parsed.scheme, "host": parsed.netloc, "username": selection.git_username}
544
+
545
+
546
+ def check_arguments(data_by_key: dict[str, object], validated: AccountSelection, check_namespace: str) -> tuple[list[str], str]:
547
+ """Return the Git arguments that list the repository to test with the typed username, and the URL Git contacts.
548
+
549
+ Git may only ask GitPane's helper for the secret, in the test namespace: every inherited helper and askpass program
550
+ is disabled and inherited HTTP headers are cleared. The secret itself is never part of the arguments.
551
+ """
552
+ check_url = str(data_by_key["check_url"])
553
+ command_url = validated.command_url(check_url)
554
+ scope_origin = validated.origin
555
+ loopback_origin = controlled_origin(data_by_key)
556
+ if loopback_origin:
557
+ loopback = urlsplit(loopback_origin)
558
+ command_url = urlunsplit((loopback.scheme, quote(validated.git_username, safe="") + "@" + loopback.netloc, urlsplit(check_url).path, "", ""))
559
+ scope_origin = loopback_origin
560
+ parsed_command = urlsplit(command_url)
561
+ plain_url = urlunsplit((parsed_command.scheme, parsed_command.netloc.rsplit("@", 1)[-1], parsed_command.path, "", ""))
562
+ arguments = [*credential_configuration(scope_origin, validated.git_username, check_namespace, tuple(data_by_key["helper_commands"])), "-c", "core.askPass=",
563
+ *isolated_http_arguments(plain_url), "ls-remote", "--heads", "--", command_url]
564
+ return arguments, command_url
565
+
566
+
567
+ def check_environment(data_by_key: dict[str, object], check_namespace: str, neutral_dir: str) -> dict[str, str]:
568
+ """Return the environment of the Git test: the helper's own settings, no prompt, no trace and no repository to find.
569
+
570
+ Git starts in ``neutral_dir``, an empty folder, and may not look for a repository above it, so no repository
571
+ configuration is read and nothing of any repository is written.
572
+ """
573
+ environment_by_name = helper_environment(data_by_key, check_namespace, browser=False, trace=False)
574
+ for name in tuple(environment_by_name):
575
+ upper_name = name.upper()
576
+ repository_name = upper_name in GIT_LOCAL_ENVIRONMENT_NAMES and upper_name not in CHECK_KEPT_ENVIRONMENT_NAME_SET
577
+ if repository_name or upper_name in CHECK_REMOVED_ENVIRONMENT_NAME_SET or upper_name.startswith(TRACE_ENVIRONMENT_PREFIXES):
578
+ environment_by_name.pop(name)
579
+ environment_by_name["GIT_TERMINAL_PROMPT"] = "0"
580
+ environment_by_name["LC_ALL"] = "C"
581
+ environment_by_name["GIT_CEILING_DIRECTORIES"] = os.path.dirname(neutral_dir)
582
+ return environment_by_name
583
+
584
+
585
+ def own_process_group() -> int | None:
586
+ """Answer the ``process_group`` option that starts a helper tree in a process group of its own, which ``stop_process_tree`` stops as a whole.
587
+
588
+ The group stays inside this worker's session (a new session would leave it): when the worker itself is stopped, on
589
+ Cancel or when GitPane closes, the supervisor on Linux stops every group of that session with it, as the Windows
590
+ job does. Windows has no POSIX process groups; there the job holds the whole tree.
591
+ """
592
+ return NEW_PROCESS_GROUP_ID if os.name != "nt" else None
593
+
594
+
595
+ def stop_process_tree(process: subprocess.Popen) -> None:
596
+ """Stop a process and every process it started, so no helper started by Git can still store the secret afterwards."""
597
+ try:
598
+ if os.name == "nt":
599
+ taskkill_filename = os.path.join(os.environ.get("SystemRoot", WINDOWS_DEFAULT_ROOT_DIR), "System32", "taskkill.exe")
600
+ subprocess.run([taskkill_filename, "/F", "/T", "/PID", str(process.pid)], capture_output=True, timeout=PROCESS_TREE_STOP_TIMEOUT_MS / MILLISECONDS_PER_SECOND, check=False)
601
+ else:
602
+ os.killpg(process.pid, signal.SIGKILL)
603
+ except (OSError, subprocess.TimeoutExpired):
604
+ pass
605
+ process.kill()
606
+
607
+
608
+ def run_check_git(command: list[str], neutral_dir: str, environment_by_name: dict[str, str], timeout_ms: int) -> tuple[int | None, str]:
609
+ """Run the Git test and return its exit code, or None when it ran out of time, with its standard error.
610
+
611
+ Standard input is empty, so nothing can be typed into it; Git's own list of branches is not needed and is discarded.
612
+ """
613
+ process = subprocess.Popen(command, cwd=neutral_dir, env=environment_by_name, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
614
+ process_group=own_process_group())
615
+ try:
616
+ _stdout, stderr = process.communicate(timeout=timeout_ms / MILLISECONDS_PER_SECOND)
617
+ except subprocess.TimeoutExpired:
618
+ stop_process_tree(process)
619
+ try:
620
+ _stdout, stderr = process.communicate(timeout=PROCESS_TREE_STOP_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
621
+ except subprocess.TimeoutExpired:
622
+ stderr = b""
623
+ return None, (stderr or b"").decode("utf-8", errors="replace")
624
+ return process.returncode, stderr.decode("utf-8", errors="replace")
625
+
626
+
627
+ def erase_check_copy(data_by_key: dict[str, object], selection: AccountSelection, password: str = "") -> bool:
628
+ """Erase the temporary copy of a secret under test and tell whether the helper did; a failure is written to the diagnostics and changes no result."""
629
+ try:
630
+ helper_exchange(data_by_key, "erase", check_copy_credential(data_by_key, selection), namespace=selection.namespace + CHECK_NAMESPACE_SUFFIX)
631
+ except (OSError, ValueError, subprocess.TimeoutExpired) as error:
632
+ print(SensitiveTextFilter({"password": password}).redact("The temporary copy of the tested secret could not be erased: " + str(error)), file=sys.stderr, flush=True)
633
+ return False
634
+ return True
635
+
636
+
637
+ # Recognising the server after a refused password follows the rules of Git Credential Manager's own detection: host
638
+ # name first, then one HEAD request and its known headers. That detection is not asked for here because it records its
639
+ # answer as credential.<url>.provider in the user's global Git configuration, and GitPane must never write there. The
640
+ # rules are therefore reproduced in Python, reading only.
641
+ def server_provider_from_host(host: str) -> str:
642
+ """Name the provider (GitHub or GitLab) a server's host name gives away, or return an empty text when it tells nothing.
643
+
644
+ The provider's own host counts, and so does any name of at least three labels whose first label is the provider's
645
+ name (github.corp.example, gitlab.company.example). The port is ignored and letters may be in any case.
646
+ """
647
+ try:
648
+ hostname = urlsplit("//" + host).hostname or ""
649
+ except ValueError:
650
+ return ""
651
+ labels = hostname.split(".")
652
+ for provider in TOKEN_PAGE_PATH_BY_PROVIDER:
653
+ if hostname == PROVIDER_HOST_BY_NAME[provider]:
654
+ return provider
655
+ if len(labels) >= PROVIDER_HOST_MINIMUM_LABEL_COUNT and labels[FIRST_LABEL_INDEX] == provider.lower():
656
+ return provider
657
+ return ""
658
+
659
+
660
+ def provider_from_headers(header_by_name: Message) -> str:
661
+ """Name the provider whose known headers appear in a server's answer, or return an empty text."""
662
+ if header_by_name.get(GITHUB_REQUEST_ID_HEADER) is not None:
663
+ return GITHUB_PROVIDER
664
+ if header_by_name.get(GITLAB_FEATURE_CATEGORY_HEADER) is not None:
665
+ return GITLAB_PROVIDER
666
+ for challenge in header_by_name.get_all(AUTHENTICATE_HEADER) or []:
667
+ if GITLAB_REALM_TEXT in challenge.lower():
668
+ return GITLAB_PROVIDER
669
+ return ""
670
+
671
+
672
+ def probe_server_provider(url: str, timeout_ms: int) -> str:
673
+ """Send one HEAD request to the tested repository and name the provider its headers give away, or return an empty text.
674
+
675
+ The request carries no credentials and no cookie, and a redirect is never followed: only the server's own first
676
+ answer is read, error statuses included. A server that does not answer within ``timeout_ms`` tells nothing.
677
+ """
678
+ request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
679
+ opener = urllib.request.build_opener(NoRedirect())
680
+ try:
681
+ with opener.open(request, timeout=timeout_ms / MILLISECONDS_PER_SECOND) as response:
682
+ return provider_from_headers(response.headers)
683
+ except urllib.error.HTTPError as error:
684
+ header_by_name = error.headers
685
+ error.close()
686
+ return provider_from_headers(header_by_name) if header_by_name is not None else ""
687
+ except (OSError, http.client.HTTPException, ValueError):
688
+ return ""
689
+
690
+
691
+ def gitlab_oauth_configured(git_filename: str, url: str, environment_by_name: dict[str, str], cwd: str) -> bool:
692
+ """Tell whether the Git configuration names a GitLab OAuth application for this address, so browser sign-in can work there.
693
+
694
+ Git only reads its configuration here; a missing Git or a failed read counts as no application.
695
+ """
696
+ command = [git_filename, "config", "--get-urlmatch", GITLAB_OAUTH_CLIENT_KEY, url]
697
+ try:
698
+ result = subprocess.run(command, cwd=cwd, env=environment_by_name, stdin=subprocess.DEVNULL, capture_output=True,
699
+ timeout=GIT_READ_TIMEOUT_MS / MILLISECONDS_PER_SECOND, check=False)
700
+ except (OSError, subprocess.TimeoutExpired):
701
+ return False
702
+ return result.returncode == 0 and bool(result.stdout.strip())
703
+
704
+
705
+ def server_guidance(data_by_key: dict[str, object], git_output: str, neutral_dir: str, environment_by_name: dict[str, str]) -> dict[str, str]:
706
+ """After an authentication refusal, say which provider the tested server runs, whether it offers browser sign-in, and where to create a token.
707
+
708
+ Nothing is looked up for any other failure. The host name is enough for most servers; otherwise one HEAD request
709
+ asks the server itself, on the loopback address that isolated tests put in place of every server. A GitHub server
710
+ offers browser sign-in; a GitLab server only when an OAuth application is configured for its address. Return an
711
+ empty mapping when the server is not recognised.
712
+ """
713
+ if refusal_kind(git_output) is None:
714
+ return {}
715
+ check_url = str(data_by_key["check_url"])
716
+ parsed = urlsplit(check_url)
717
+ plain_netloc = parsed.netloc.rsplit("@", 1)[-1]
718
+ provider = server_provider_from_host(plain_netloc)
719
+ if not provider:
720
+ loopback_origin = controlled_origin(data_by_key)
721
+ probe_url = loopback_origin + parsed.path if loopback_origin else urlunsplit((parsed.scheme, plain_netloc, parsed.path, "", ""))
722
+ provider = probe_server_provider(probe_url, SERVER_PROBE_TIMEOUT_MS)
723
+ if not provider:
724
+ return {}
725
+ plain_url = urlunsplit((parsed.scheme, plain_netloc, parsed.path, "", ""))
726
+ browser_provider = provider
727
+ if provider == GITLAB_PROVIDER and not gitlab_oauth_configured(str(data_by_key["git_filename"]), plain_url, environment_by_name, neutral_dir):
728
+ browser_provider = ""
729
+ return {"server_provider": provider, "browser_provider": browser_provider, "token_page_url": token_page_url(provider, plain_url)}
730
+
731
+
732
+ def check_account(data_by_key: dict[str, object], validated: AccountSelection, password: str) -> tuple[dict[str, object] | None, bool]:
733
+ """Test a username + password on a repository of its server with `git ls-remote` before anything is saved for the account.
734
+
735
+ The secret reaches Git only through GitPane's helper, as a temporary copy in a test namespace that is erased
736
+ afterwards whatever happens. Return None when the server accepted, otherwise the failed result to publish: nothing
737
+ stored, a short cause and Git's whole output without any secret; then whether the temporary copy was really erased.
738
+ """
739
+ check_namespace = validated.namespace + CHECK_NAMESPACE_SUFFIX
740
+ arguments, _command_url = check_arguments(data_by_key, validated, check_namespace)
741
+ git_filename = str(data_by_key["git_filename"])
742
+ if not os.path.isabs(git_filename):
743
+ raise ValueError(GIT_MISSING_TEXT)
744
+ check_url = str(data_by_key["check_url"])
745
+ timeout_ms = int(data_by_key["check_timeout_ms"])
746
+ password_filter = SensitiveTextFilter({"password": password, "token": str(data_by_key.get("token", ""))})
747
+ copy_by_key = dict(check_copy_credential(data_by_key, validated))
748
+ copy_by_key["password"] = password
749
+ store_failure_by_key = None
750
+ try:
751
+ try:
752
+ helper_exchange(data_by_key, "store", copy_by_key, namespace=check_namespace)
753
+ except (OSError, ValueError, subprocess.TimeoutExpired) as error:
754
+ store_failure_by_key = {"status": OperationStatus.PARTIAL.value, "selection": asdict(validated), "authenticated": False, "stored": False, "error": password_filter.redact(str(error))}
755
+ else:
756
+ with tempfile.TemporaryDirectory(prefix="gitpane-check-", ignore_cleanup_errors=True) as neutral_dir:
757
+ return_code, stderr_text = run_check_git([git_filename, *arguments], neutral_dir, check_environment(data_by_key, check_namespace, neutral_dir), timeout_ms)
758
+ finally:
759
+ copy_erased = erase_check_copy(data_by_key, validated, password)
760
+ if store_failure_by_key is not None:
761
+ return store_failure_by_key, copy_erased
762
+ if return_code == 0:
763
+ return None, copy_erased
764
+ if return_code is None:
765
+ cause = CHECK_TIMEOUT_CAUSE.format(url=check_url, seconds=timeout_ms // MILLISECONDS_PER_SECOND)
766
+ else:
767
+ cause = CHECK_FAILED_TEXT.format(url=check_url)
768
+ git_output = "\n".join(git_output_lines(password_filter.redact(stderr_text)))
769
+ print(git_output, file=sys.stderr, flush=True)
770
+ check_by_key = {"git_output": git_output, "url": check_url}
771
+ with tempfile.TemporaryDirectory(prefix="gitpane-check-", ignore_cleanup_errors=True) as neutral_dir:
772
+ check_by_key.update(server_guidance(data_by_key, git_output, neutral_dir, check_environment(data_by_key, check_namespace, neutral_dir)))
773
+ return {"status": OperationStatus.FAILED.value, "stored": False, "authenticated": False, "error": cause, "check": check_by_key}, copy_erased
774
+
775
+
776
+ def run_generic_exchange(data_by_key: dict[str, object], credential_by_key: dict[str, str], selection: AccountSelection | None) -> dict[str, object]:
777
+ """Handle a username + password account; no provider API is ever called for it.
778
+
779
+ Adding or reconnecting first tests the typed password with Git on a repository of the server; only an accepted test
780
+ stores it, so a refused Reconnect keeps the secret that was saved before. A Reconnect keeps the account's username,
781
+ so it can never turn one account into another.
782
+ """
783
+ action = AuthenticationAction(data_by_key["action"])
784
+ origin = endpoint_origin(str(data_by_key["origin"]))
785
+ if action != AuthenticationAction.SIGN_IN and (selection is None or selection.provider != GENERIC_PROVIDER or selection.origin != origin):
786
+ raise ValueError("The selected account does not match this request. Nothing was changed.")
787
+ if action == AuthenticationAction.ERASE_CHECK:
788
+ erase_check_copy(data_by_key, selection)
789
+ return {"status": OperationStatus.SUCCESS.value, "stored": False, "authenticated": False}
790
+ if action == AuthenticationAction.GET:
791
+ lookup_by_key = dict(credential_by_key)
792
+ lookup_by_key["username"] = selection.git_username
793
+ response_by_key = helper_exchange(data_by_key, "get", lookup_by_key, namespace=selection.namespace)
794
+ if not response_by_key.get("password") or response_by_key.get("username") != selection.git_username:
795
+ raise ValueError("The selected account is absent from the secure store. Add it again.")
796
+ return {"status": OperationStatus.SUCCESS.value, "selection": asdict(selection), "authenticated": False, "stored": True}
797
+ if action in ERASE_ACTION_SET:
798
+ erase_by_key = dict(credential_by_key)
799
+ erase_by_key["username"] = selection.git_username
800
+ helper_exchange(data_by_key, "erase", erase_by_key, namespace=selection.namespace)
801
+ # A test copy an earlier account test could not erase goes with the account.
802
+ erase_check_copy(data_by_key, selection)
803
+ return {"status": OperationStatus.SUCCESS.value, "stored": False, "authenticated": False}
804
+ username = selection.git_username if action == AuthenticationAction.APPROVE else str(data_by_key.get("username", ""))
805
+ password = str(data_by_key.get("token", ""))
806
+ if not password:
807
+ raise ValueError("Enter a password or token.")
808
+ validated = generic_account(origin, username)
809
+ if action == AuthenticationAction.APPROVE and validated.namespace != selection.namespace:
810
+ raise ValueError("Reconnect must keep the account's username. Nothing was changed.")
811
+ failure_by_key, copy_erased = check_account(data_by_key, validated, password)
812
+ result_by_key = failure_by_key if failure_by_key is not None else store_and_check(data_by_key, credential_by_key, validated, password, authenticated=True)
813
+ result_by_key[CHECK_COPY_ERASED_KEY] = copy_erased
814
+ return result_by_key
815
+
816
+
817
+ def main() -> None:
818
+ """Accept private JSON on stdin and publish only nonsecret results on private stdout."""
819
+ data_by_key: dict[str, object] = {}
820
+ try:
821
+ data_by_key = json.loads(sys.stdin.buffer.read())
822
+ result_by_key = run_exchange(data_by_key)
823
+ except BrowserTimeoutError:
824
+ result_by_key = {"status": OperationStatus.SIGN_IN_TIMEOUT.value, "error": BROWSER_TIMEOUT_TEXT}
825
+ except AccountMismatchError as error:
826
+ safe_error = SensitiveTextFilter({"token": str(data_by_key.get("token", ""))}).redact(str(error))
827
+ result_by_key = {"status": OperationStatus.FAILED.value, "error": safe_error, GITPANE_REFUSAL_KEY: True}
828
+ except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as error:
829
+ safe_error = SensitiveTextFilter({"token": str(data_by_key.get("token", ""))}).redact(str(error))
830
+ result_by_key = {"status": OperationStatus.FAILED.value, "error": safe_error}
831
+ sys.stdout.buffer.write(json.dumps(result_by_key, ensure_ascii=False).encode("utf-8"))
832
+ sys.stdout.buffer.flush()
833
+
834
+
835
+ if __name__ == "__main__":
836
+ main()