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.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- 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()
|