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,1811 @@
|
|
|
1
|
+
"""Select confirmed HTTPS accounts without persisting credentials in application settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
import uuid
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from dataclasses import asdict, dataclass, field, fields, replace
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import TYPE_CHECKING, Self
|
|
16
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
17
|
+
|
|
18
|
+
from PySide6.QtCore import QObject, Qt, QTimer, Signal, Slot
|
|
19
|
+
|
|
20
|
+
from gitpane.account.credential_manager import CredentialManagerProblem, CredentialManagerRefusal
|
|
21
|
+
from gitpane.config import (
|
|
22
|
+
ACCOUNT_CHECK_TIMEOUT_MS,
|
|
23
|
+
ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY,
|
|
24
|
+
AUTHENTICATION_CONFIG_FILE_LIMIT,
|
|
25
|
+
AUTHENTICATION_CONFIG_LIMIT_BYTES,
|
|
26
|
+
AUTHENTICATION_IDLE_TIMEOUT_MS,
|
|
27
|
+
AUTHENTICATION_PREFERENCE_KEY,
|
|
28
|
+
AUTHENTICATION_TIMEOUT_MS,
|
|
29
|
+
BROWSER_SIGN_IN_STOP_GRACE_MS,
|
|
30
|
+
BROWSER_SIGN_IN_TIMEOUT_MS,
|
|
31
|
+
CREDENTIAL_MANAGER_PLATFORM_BY_NAME,
|
|
32
|
+
GCM_ENVIRONMENT_NAME_BY_SETTING,
|
|
33
|
+
GIT_DEFAULT_TEMPLATE_DIR,
|
|
34
|
+
HTTPS_PREFIX,
|
|
35
|
+
SECRET_ERASE_TIMEOUT_MS,
|
|
36
|
+
SECRET_LOOKUP_STOP_GRACE_MS,
|
|
37
|
+
SECRET_LOOKUP_TIMEOUT_MS,
|
|
38
|
+
application_module_interpreter_options,
|
|
39
|
+
)
|
|
40
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
41
|
+
from gitpane.operation.operation_status import OperationStatus, operation_status
|
|
42
|
+
from gitpane.process.process_group import NativeProcessGroup, console_interpreter_filename
|
|
43
|
+
from gitpane.process.process_runner import CREDENTIAL_PROFILE, ProcessRequest, ProcessRunner, ProcessStatus
|
|
44
|
+
from gitpane.system.path_identity import canonical_path
|
|
45
|
+
from gitpane.transport.refusal_notice import NO_ACCOUNT_CONFIGURED_TEXT, TransferAccount
|
|
46
|
+
from gitpane.worktree.command_sequence import GitCommand
|
|
47
|
+
from gitpane.worktree.worktree_snapshot import read_validated_size
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Callable, Generator
|
|
51
|
+
from concurrent.futures import Future
|
|
52
|
+
|
|
53
|
+
from gitpane.account.credential_manager import CredentialManagerDetection, CredentialManagerLocator
|
|
54
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
55
|
+
from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
|
|
56
|
+
from gitpane.preference.preference_store import PreferenceStore
|
|
57
|
+
from gitpane.process.process_runner import ProcessResult
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
GITHUB_PROVIDER = "GitHub"
|
|
61
|
+
GITLAB_PROVIDER = "GitLab"
|
|
62
|
+
BITBUCKET_PROVIDER = "Bitbucket"
|
|
63
|
+
PROVIDER_HOST_BY_NAME = {GITHUB_PROVIDER: "github.com", GITLAB_PROVIDER: "gitlab.com", BITBUCKET_PROVIDER: "bitbucket.org"}
|
|
64
|
+
# The providers a self-hosted server can be recognised as after it refused a password, each with the path, on that
|
|
65
|
+
# server, of the page where its users create a personal access token.
|
|
66
|
+
TOKEN_PAGE_PATH_BY_PROVIDER = {GITHUB_PROVIDER: "/settings/tokens", GITLAB_PROVIDER: "/-/user_settings/personal_access_tokens"}
|
|
67
|
+
# A generic account is a username with a password or token, valid for any HTTPS server (a self-hosted GitLab with an LDAP
|
|
68
|
+
# login, for example). Its identity is the username the user typed. Before it is saved, Git lists a repository of that
|
|
69
|
+
# server with the typed username and password; a refused test saves nothing.
|
|
70
|
+
GENERIC_PROVIDER = "Generic"
|
|
71
|
+
# GitHub, gitlab.com and Bitbucket Cloud refuse the account password over Git HTTPS, so a new username + password account
|
|
72
|
+
# is refused there before anything is stored; the address is put in front of this text.
|
|
73
|
+
CLOUD_PASSWORD_REFUSAL_TEXT = " does not accept account passwords for Git. Sign in with your browser or use a personal access token."
|
|
74
|
+
ACCOUNT_PROVIDER_SET = frozenset({*PROVIDER_HOST_BY_NAME, GENERIC_PROVIDER})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SignInMethod(Enum):
|
|
78
|
+
"""How an account signs in, with the text the Type column of Settings shows for it.
|
|
79
|
+
|
|
80
|
+
The value is the word recorded in the preferences file when an account is added or reconnected. An account saved
|
|
81
|
+
before that record existed is classified from its own fields; only an old GitHub account cannot be, since its browser
|
|
82
|
+
and token sign-ins give the same account fields, so it is shown as either and that method is never recorded.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
BROWSER = ("browser", "Browser (OAuth)")
|
|
86
|
+
TOKEN = ("token", "Token")
|
|
87
|
+
PASSWORD = ("password", "Username + password")
|
|
88
|
+
BROWSER_OR_TOKEN = ("browser-or-token", "Browser or token")
|
|
89
|
+
|
|
90
|
+
def __new__(cls, value: str, type_label: str) -> Self:
|
|
91
|
+
"""Keep the recorded word as the value and the Type column text beside it."""
|
|
92
|
+
member = object.__new__(cls)
|
|
93
|
+
member._value_ = value
|
|
94
|
+
member.type_label = type_label
|
|
95
|
+
return member
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# The methods recorded when an account is added or reconnected: Browser or token is only ever deduced, never recorded.
|
|
99
|
+
RECORDED_SIGN_IN_METHOD_SET = frozenset((SignInMethod.BROWSER, SignInMethod.TOKEN, SignInMethod.PASSWORD))
|
|
100
|
+
# Git usernames that GitLab and Bitbucket require with a token obtained by browser (OAuth) sign-in.
|
|
101
|
+
GITLAB_OAUTH_GIT_USERNAME = "oauth2"
|
|
102
|
+
BITBUCKET_OAUTH_GIT_USERNAME = "x-token-auth"
|
|
103
|
+
BROWSER_GIT_USERNAME_SET = frozenset({GITLAB_OAUTH_GIT_USERNAME, BITBUCKET_OAUTH_GIT_USERNAME})
|
|
104
|
+
# Optional key of the saved account data: the name the user gave each service address (for example "GitLab at work").
|
|
105
|
+
# Data saved without it stays valid and is never rewritten just to add it.
|
|
106
|
+
SERVICE_NAME_KEY = "service_name"
|
|
107
|
+
ACCOUNT_DATA_KEY_SET = frozenset({"accounts", "global", "repository"})
|
|
108
|
+
RECURSIVE_AUTHENTICATION_KEY_PATTERN = r"^(credential\..*helper|http\..*extraheader)$"
|
|
109
|
+
RECURSIVE_CONFIG_PATH_PATTERN = r"^(include(if\..*)?\.path|init\.templatedir)$"
|
|
110
|
+
# One search of the credential manager at a time for the account service: the search already waits for any other
|
|
111
|
+
# search, and an account exchange never needs two of them.
|
|
112
|
+
CREDENTIAL_HELPER_WORKER_COUNT = 1
|
|
113
|
+
# Key of an exchange's private request that names the folder of Git's programs, given to every direct start of the
|
|
114
|
+
# credential manager so it finds Git even when Git is not on the PATH.
|
|
115
|
+
GIT_EXEC_PATH_KEY = "git_exec_path"
|
|
116
|
+
# Composing with this empty helper list builds a preview: every inherited helper is disabled and no helper command is
|
|
117
|
+
# named, so a captured destination can be compared without searching the credential manager.
|
|
118
|
+
PREVIEW_HELPER_COMMANDS: tuple[str, ...] = ()
|
|
119
|
+
# Written to --log, in front of the refusal sentence, when a recursive operation goes on without any credential helper.
|
|
120
|
+
CREDENTIAL_MANAGER_SKIPPED_LOG_TEXT = "Credential manager refused, continuing without a credential helper: "
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def helper_after_refusal(refusal: ValueError, *, operation_uses_account: bool, log: DiagnosticLog | None) -> tuple[str, ...]:
|
|
124
|
+
"""Decide what a recursive operation (clone, submodule initialization, Retry submodules) does when the credential manager is refused.
|
|
125
|
+
|
|
126
|
+
The search only happens when one of the operation's destinations has an account, often only a service default kept
|
|
127
|
+
for submodules on another service. The refusal stops the operation only when the operation itself uses an account:
|
|
128
|
+
a clone URL with an account, the HTTPS ``origin`` with an account of the repository that declares the submodule, or
|
|
129
|
+
an account chosen for the submodule before Retry submodules. Otherwise, with a parent reached over SSH,
|
|
130
|
+
``file://``, a local folder or HTTPS without an account, the operation is never blocked by the credential manager:
|
|
131
|
+
it goes on with the preview, which gives Git no helper at all, and the refusal is written to --log. A submodule
|
|
132
|
+
that needed the account then fails on Git's own refusal.
|
|
133
|
+
"""
|
|
134
|
+
if operation_uses_account:
|
|
135
|
+
raise refusal
|
|
136
|
+
if log is not None:
|
|
137
|
+
log.write_line(CREDENTIAL_MANAGER_SKIPPED_LOG_TEXT + str(refusal))
|
|
138
|
+
return PREVIEW_HELPER_COMMANDS
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class CredentialHelper:
|
|
143
|
+
"""Name the credential helper one operation uses and the folder of the programs of the Git it belongs to.
|
|
144
|
+
|
|
145
|
+
``exec_path`` is given to every direct start of the helper, so it finds Git even when Git is not on the PATH; it
|
|
146
|
+
stays empty for a helper injected by tests.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
commands: tuple[str, ...]
|
|
150
|
+
exec_path: str = ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Where Git Credential Manager keeps the secrets of GitPane's accounts: the system's protected store, never a plaintext file.
|
|
154
|
+
# It is chosen per system, like the search of the credential manager; a system missing from the table gets no store, and
|
|
155
|
+
# the search refuses such a system before anything is started.
|
|
156
|
+
MANAGED_CREDENTIAL_STORE = CREDENTIAL_MANAGER_PLATFORM_BY_NAME[sys.platform].credential_store if sys.platform in CREDENTIAL_MANAGER_PLATFORM_BY_NAME else ""
|
|
157
|
+
# Git Credential Manager's provider for a stored account secret: it returns the secret as stored, without any provider sign-in.
|
|
158
|
+
MANAGED_CREDENTIAL_PROVIDER = "generic"
|
|
159
|
+
# Value of Git Credential Manager's yes/no settings that GitPane turns off: prompts and secrets in its traces.
|
|
160
|
+
GCM_DISABLED_VALUE = "false"
|
|
161
|
+
# The sign-in mode a browser sign-in asks each provider's part of Git Credential Manager for: the browser itself for
|
|
162
|
+
# GitHub and GitLab, OAuth (which opens the browser) for Bitbucket, never a prompt for a password or a token.
|
|
163
|
+
BROWSER_AUTH_MODE_BY_SETTING = {"gitHubAuthModes": "browser", "gitLabAuthModes": "browser", "bitbucketAuthModes": "oauth"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class AuthenticationAction(Enum):
|
|
167
|
+
"""The exchanges the account service asks its worker for, written as their value in the JSON the worker reads."""
|
|
168
|
+
|
|
169
|
+
SIGN_IN = "sign-in"
|
|
170
|
+
"""Add a new account, by the browser, a typed token or a username + password."""
|
|
171
|
+
GET = "get"
|
|
172
|
+
"""Read the stored secret of a listed account and check whom it belongs to."""
|
|
173
|
+
APPROVE = "approve"
|
|
174
|
+
"""Reconnect a listed account: sign in again and replace its stored secret."""
|
|
175
|
+
REJECT = "reject"
|
|
176
|
+
"""Erase the stored secret of one account and nothing else: after an explicit rejection, or when a new account's
|
|
177
|
+
secret was stored for a sign-in whose result nobody can see any more."""
|
|
178
|
+
REMOVE = "remove"
|
|
179
|
+
"""Erase an account's stored secret like a rejection does, then take the account off the durable list."""
|
|
180
|
+
ERASE_CHECK = "erase-check"
|
|
181
|
+
"""Erase only the temporary copy of a secret under test, when its test was stopped before it could erase the copy itself."""
|
|
182
|
+
LOOKUP = "lookup"
|
|
183
|
+
"""Ask the helper, without any prompt and without contacting any server, whether each account's secret is still
|
|
184
|
+
stored: Git and Git Credential Manager erase an account's secret by themselves when a server refuses it."""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Only a new sign-in or a Reconnect stores a fresh secret; checking or erasing an account never adds it to the list.
|
|
188
|
+
REMEMBERED_ACTION_SET = frozenset({AuthenticationAction.SIGN_IN, AuthenticationAction.APPROVE})
|
|
189
|
+
# Every credential namespace of a GitPane account starts with this, so no other program's secrets are ever addressed.
|
|
190
|
+
ACCOUNT_NAMESPACE_PREFIX = "gitpane-"
|
|
191
|
+
# The temporary copy of a secret under test has its own namespace, derived from the account's, so the test never touches
|
|
192
|
+
# the secret already saved for that account. A copy the test could not erase is erased again by the account service,
|
|
193
|
+
# and removing the account erases it too.
|
|
194
|
+
CHECK_NAMESPACE_SUFFIX = "-check"
|
|
195
|
+
# The key of an account test's result that tells whether its worker erased the temporary copy itself; any other answer
|
|
196
|
+
# makes the account service erase the copy again.
|
|
197
|
+
CHECK_COPY_ERASED_KEY = "check_copy_erased"
|
|
198
|
+
# The key of an exchange's result that says its error is GitPane's own sentence, refusing the account the sign-in
|
|
199
|
+
# returned, rather than the output of a program the worker ran.
|
|
200
|
+
GITPANE_REFUSAL_KEY = "gitpane_refusal"
|
|
201
|
+
EMPTY_CHECK_URL_TEXT = "Enter the URL of a repository on {address} to test the sign-in."
|
|
202
|
+
CHECK_URL_FORMAT_TEXT = "Enter the HTTPS URL of a repository on {address}, without a password, to test the sign-in."
|
|
203
|
+
CHECK_URL_ADDRESS_TEXT = "The repository to test must be on {address}."
|
|
204
|
+
GIT_MISSING_TEXT = "Git is not configured. Choose the Git executable in Settings › Tools."
|
|
205
|
+
ACCOUNT_LIST_UNREADABLE_TEXT = ("The saved account list could not be read, so nothing was changed. Another GitPane version may have written it: "
|
|
206
|
+
"close the other GitPane windows, then try again.")
|
|
207
|
+
# Said when Git could not complete the test for another reason than a refusal: repository not found, server unreachable.
|
|
208
|
+
CHECK_FAILED_TEXT = "The sign-in could not be tested with {url}. Nothing was saved."
|
|
209
|
+
# Text of a browser sign-in whose browser did not answer within the wait: the exchange was stopped before
|
|
210
|
+
# anything was stored. The worker writes it with its status and the sign-in window shows it.
|
|
211
|
+
BROWSER_TIMEOUT_TEXT = "The browser did not answer in time. Try again."
|
|
212
|
+
# Said instead of starting a browser sign-in when GitPane runs inside a job that forbids leaving it: the browser would
|
|
213
|
+
# then belong to the same job and be closed with it.
|
|
214
|
+
BROWSER_SIGN_IN_UNAVAILABLE_TEXT = "Browser sign-in is not available here: GitPane runs inside a job that would close the browser with it. Sign in with a token instead."
|
|
215
|
+
# A browser sign-in that ran out of time: stopped by its worker at the end of the wait, or, as a safety net, by the
|
|
216
|
+
# process supervisor when the worker itself no longer answers.
|
|
217
|
+
BROWSER_TIMEOUT_STATUS_SET = frozenset((OperationStatus.SIGN_IN_TIMEOUT, OperationStatus.TIMEOUT))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class SecretPresence(Enum):
|
|
221
|
+
"""How a check of the stored secrets classifies each account, written as their value in the JSON the worker publishes.
|
|
222
|
+
|
|
223
|
+
Only a secret that is clearly gone counts as missing; anything ambiguous (no helper, time limit reached, an unknown
|
|
224
|
+
error) is unknown and never marks an account.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
PRESENT = "present"
|
|
228
|
+
MISSING = "missing"
|
|
229
|
+
UNKNOWN = "unknown"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# The only fields of an account a check of its stored secret needs: where the secret is kept, the address and username
|
|
233
|
+
# the helper is asked about, and the label written to the diagnostics.
|
|
234
|
+
SECRET_LOOKUP_FIELDS = ("namespace", "origin", "git_username", "label")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def validate_configuration_filename(filename: str) -> str:
|
|
238
|
+
"""Refuse configuration paths that could bind to another file in a descendant's cwd."""
|
|
239
|
+
if not os.path.isabs(filename) and os.path.normcase(filename) != os.path.normcase(os.devnull):
|
|
240
|
+
raise ValueError("Recursive authentication requires absolute Git configuration filenames. Update GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, HOME or XDG_CONFIG_HOME and retry.")
|
|
241
|
+
return filename
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def configuration_identity(metadata: os.stat_result) -> tuple[int, int, int, int]:
|
|
245
|
+
"""Answer what proves a configuration file is still the same one: its identity, its size and its modification time."""
|
|
246
|
+
return metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def configuration_signature(filename: str) -> tuple | None:
|
|
250
|
+
"""Detect replacement and content changes without retaining configuration secrets.
|
|
251
|
+
|
|
252
|
+
The opened file must be the one looked at first, before anything is read, and exactly its size is read. The path
|
|
253
|
+
is looked at again after the read: on POSIX another program can move a new file over a path that is open, and the
|
|
254
|
+
open stream would keep describing the old file.
|
|
255
|
+
"""
|
|
256
|
+
try:
|
|
257
|
+
before = os.stat(filename)
|
|
258
|
+
except FileNotFoundError:
|
|
259
|
+
return None
|
|
260
|
+
if not stat.S_ISREG(before.st_mode):
|
|
261
|
+
if canonical_path(filename) != canonical_path(os.devnull):
|
|
262
|
+
raise ValueError("Git authentication configuration must use regular files.")
|
|
263
|
+
# The null device holds nothing, yet macOS moves its modification time each time any program writes to it: its
|
|
264
|
+
# identity alone is its signature, so a write by another process never makes a queued recursive command stale.
|
|
265
|
+
return before.st_dev, before.st_ino
|
|
266
|
+
if before.st_size > AUTHENTICATION_CONFIG_LIMIT_BYTES:
|
|
267
|
+
raise ValueError("Git authentication configuration exceeds the inspection size limit.")
|
|
268
|
+
with open(filename, "rb") as stream:
|
|
269
|
+
opened = os.fstat(stream.fileno())
|
|
270
|
+
if configuration_identity(opened) != configuration_identity(before):
|
|
271
|
+
raise ValueError("Git authentication configuration changed during inspection. Retry the operation.")
|
|
272
|
+
content = read_validated_size(stream, opened.st_size)
|
|
273
|
+
after = os.fstat(stream.fileno())
|
|
274
|
+
try:
|
|
275
|
+
path_after = os.stat(filename)
|
|
276
|
+
except FileNotFoundError:
|
|
277
|
+
path_after = None
|
|
278
|
+
if (len(content) != opened.st_size or configuration_identity(after) != configuration_identity(before)
|
|
279
|
+
or path_after is None or configuration_identity(path_after) != configuration_identity(before)):
|
|
280
|
+
raise ValueError("Git authentication configuration changed during inspection. Retry the operation.")
|
|
281
|
+
return before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, hashlib.sha256(content).digest()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass(frozen=True)
|
|
285
|
+
class RecursiveTransportConfiguration:
|
|
286
|
+
"""Keep cleared key names bound to the configuration files inspected before transport."""
|
|
287
|
+
|
|
288
|
+
keys: tuple[str, ...]
|
|
289
|
+
signature_by_filename: dict[str, tuple | None]
|
|
290
|
+
environment_by_name: dict[str, str | None]
|
|
291
|
+
|
|
292
|
+
def verify(self) -> None:
|
|
293
|
+
"""Refuse stale key discovery before a queued recursive command can contact a server."""
|
|
294
|
+
for name, expected in self.environment_by_name.items():
|
|
295
|
+
if os.environ.get(name) != expected:
|
|
296
|
+
raise ValueError("Git authentication environment changed. Retry the operation.")
|
|
297
|
+
for filename, expected in self.signature_by_filename.items():
|
|
298
|
+
if configuration_signature(filename) != expected:
|
|
299
|
+
raise ValueError("Git authentication configuration changed. Retry the operation.")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def isolated_http_arguments(command_endpoint: str) -> list[str]:
|
|
303
|
+
"""Prevent inherited HTTP headers from authenticating as another selected account."""
|
|
304
|
+
# Git prefers the longest matching URL path and then its username. An exact
|
|
305
|
+
# command-URL reset therefore covers global, included and repository header lists.
|
|
306
|
+
return ["-c", "http.extraHeader=", "-c", "http." + command_endpoint + ".extraHeader="]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def account_namespace(provider: str, origin: str, account: str) -> str:
|
|
310
|
+
"""Bind each managed credential namespace to its full logical account identity."""
|
|
311
|
+
return ACCOUNT_NAMESPACE_PREFIX + hashlib.sha256(json.dumps([provider, origin, account], ensure_ascii=False).encode("utf-8")).hexdigest()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def validate_text(value: object, *, allow_empty: bool = False) -> str:
|
|
315
|
+
"""Reject protocol delimiters without altering exact external account text."""
|
|
316
|
+
if not isinstance(value, str) or not allow_empty and not value or any(ord(character) < 32 or ord(character) == 127 for character in value):
|
|
317
|
+
raise ValueError("Account and host fields must contain text without control characters")
|
|
318
|
+
return value
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def endpoint_origin(endpoint: str) -> str:
|
|
322
|
+
"""Keep the exact HTTPS host and port while excluding repository paths and userinfo."""
|
|
323
|
+
validate_text(endpoint)
|
|
324
|
+
parsed = urlsplit(endpoint)
|
|
325
|
+
if parsed.scheme != "https" or not parsed.hostname or parsed.password is not None or parsed.query or parsed.fragment:
|
|
326
|
+
raise ValueError("Enter an HTTPS URL without a password, query or fragment")
|
|
327
|
+
port = parsed.port
|
|
328
|
+
if port == 0:
|
|
329
|
+
raise ValueError("The HTTPS port must be positive")
|
|
330
|
+
return HTTPS_PREFIX + parsed.netloc.rsplit("@", 1)[-1]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def origin_address(origin: str) -> str:
|
|
334
|
+
"""Return the service address shown to the user: host and port, without the scheme."""
|
|
335
|
+
return endpoint_origin(origin).removeprefix(HTTPS_PREFIX)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def check_repository_url(check_url: str, origin: str) -> str:
|
|
339
|
+
"""Accept the repository used to test a username + password account, and return it without any credentials part.
|
|
340
|
+
|
|
341
|
+
It must be an HTTPS repository URL on exactly the account's address, so the typed secret is only ever sent to the
|
|
342
|
+
server it belongs to; a username written in the URL is dropped, since the test always uses the typed username.
|
|
343
|
+
"""
|
|
344
|
+
validate_text(check_url, allow_empty=True)
|
|
345
|
+
address = origin_address(origin)
|
|
346
|
+
if not check_url:
|
|
347
|
+
raise ValueError(EMPTY_CHECK_URL_TEXT.format(address=address))
|
|
348
|
+
parsed = urlsplit(check_url)
|
|
349
|
+
if parsed.scheme != "https" or not parsed.hostname or parsed.password is not None or parsed.query or parsed.fragment or parsed.path in ("", "/"):
|
|
350
|
+
raise ValueError(CHECK_URL_FORMAT_TEXT.format(address=address))
|
|
351
|
+
if endpoint_origin(check_url) != origin:
|
|
352
|
+
raise ValueError(CHECK_URL_ADDRESS_TEXT.format(address=address))
|
|
353
|
+
return urlunsplit((parsed.scheme, parsed.netloc.rsplit("@", 1)[-1], parsed.path, "", ""))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def helper_command_text(helper_commands: tuple[str, ...], environment_by_name: dict[str, str] | None = None) -> str:
|
|
357
|
+
"""Return the helper setting Git runs for the verified helper command: a quoted shell fragment starting with ``!``.
|
|
358
|
+
|
|
359
|
+
Git runs such a fragment through a shell, so each variable of ``environment_by_name`` is written as a ``NAME=value``
|
|
360
|
+
assignment in front of the command: it is given to that helper process only, never to Git or to another helper.
|
|
361
|
+
"""
|
|
362
|
+
words = []
|
|
363
|
+
for name, value in (environment_by_name or {}).items():
|
|
364
|
+
words.append(name + "=" + shlex.quote(value))
|
|
365
|
+
commands = []
|
|
366
|
+
for command in helper_commands:
|
|
367
|
+
commands.append(command.replace("\\", "/") if os.name == "nt" else command)
|
|
368
|
+
# Git's helper interface is a shell fragment; quote only the captured, verified command.
|
|
369
|
+
words.append(shlex.join(commands))
|
|
370
|
+
return "!" + " ".join(words)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def gcm_setting_value_by_name(namespace: str) -> dict[str, str]:
|
|
374
|
+
"""Return the Git Credential Manager settings of one account's stored secret, keyed by their Git configuration name.
|
|
375
|
+
|
|
376
|
+
The secret is read from GitPane's protected store, in the account's own namespace, without any prompt and without
|
|
377
|
+
tracing secrets; both the ``-c`` settings and the helper's environment are built from this one mapping.
|
|
378
|
+
"""
|
|
379
|
+
return {"provider": MANAGED_CREDENTIAL_PROVIDER, "namespace": namespace, "credentialStore": MANAGED_CREDENTIAL_STORE,
|
|
380
|
+
"interactive": GCM_DISABLED_VALUE, "traceSecrets": GCM_DISABLED_VALUE}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def credential_configuration(origin: str, git_username: str, namespace: str, helper_commands: tuple[str, ...], scope: str | None = None) -> list[str]:
|
|
384
|
+
"""Return the ``-c`` settings that make one Git child use only this helper, username and namespace for one address.
|
|
385
|
+
|
|
386
|
+
Every inherited helper is disabled first, prompts are off and secrets are never traced. With ``scope`` the settings
|
|
387
|
+
apply to that URL only instead of the whole address. An empty ``helper_commands`` names no helper: a preview.
|
|
388
|
+
|
|
389
|
+
Git chooses the helper with the URL it contacts, repository path included, but Git Credential Manager only matches
|
|
390
|
+
its own settings against the host, because Git does not send it the path; a setting scoped to a repository URL would
|
|
391
|
+
be invisible to it and it would look for the secret in its default namespace. The helper is therefore started with
|
|
392
|
+
those settings in its environment, which Git Credential Manager reads first. The same settings stay in the ``-c``
|
|
393
|
+
list, where they describe the destination to anyone comparing two previews.
|
|
394
|
+
"""
|
|
395
|
+
helper_scope = scope or origin
|
|
396
|
+
setting_value_by_name = gcm_setting_value_by_name(namespace)
|
|
397
|
+
helper_command = ""
|
|
398
|
+
if helper_commands:
|
|
399
|
+
environment_by_name = {}
|
|
400
|
+
for name, value in setting_value_by_name.items():
|
|
401
|
+
environment_by_name[GCM_ENVIRONMENT_NAME_BY_SETTING[name]] = value
|
|
402
|
+
helper_command = helper_command_text(helper_commands, environment_by_name)
|
|
403
|
+
interactive = setting_value_by_name["interactive"]
|
|
404
|
+
trace_secrets = setting_value_by_name["traceSecrets"]
|
|
405
|
+
arguments = ["-c", "credential.helper=", "-c", f"credential.{helper_scope}.helper=", "-c", f"credential.{helper_scope}.helper=" + helper_command,
|
|
406
|
+
"-c", f"credential.{origin}.provider=" + setting_value_by_name["provider"], "-c", f"credential.{origin}.username={git_username}",
|
|
407
|
+
"-c", f"credential.{origin}.useHttpPath=false", "-c", f"credential.{origin}.namespace=" + setting_value_by_name["namespace"],
|
|
408
|
+
"-c", "credential.interactive=" + interactive, "-c", f"credential.{origin}.interactive=" + interactive,
|
|
409
|
+
"-c", "credential.traceSecrets=" + trace_secrets, "-c", f"credential.{origin}.traceSecrets=" + trace_secrets,
|
|
410
|
+
"-c", f"credential.{origin}.credentialStore=" + setting_value_by_name["credentialStore"]]
|
|
411
|
+
if scope is None:
|
|
412
|
+
return arguments
|
|
413
|
+
scoped_arguments = []
|
|
414
|
+
for argument in arguments:
|
|
415
|
+
scoped_arguments.append(argument.replace("credential." + origin + ".", "credential." + scope + "."))
|
|
416
|
+
return scoped_arguments
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@dataclass(frozen=True)
|
|
420
|
+
class AccountSelection:
|
|
421
|
+
"""Keep a verified logical account distinct from the username used by Git."""
|
|
422
|
+
|
|
423
|
+
provider: str
|
|
424
|
+
origin: str
|
|
425
|
+
account: str
|
|
426
|
+
git_username: str
|
|
427
|
+
api_identity: str
|
|
428
|
+
label: str
|
|
429
|
+
namespace: str
|
|
430
|
+
|
|
431
|
+
def __post_init__(self) -> None:
|
|
432
|
+
"""Reject edited selectors that could address an unrelated credential-store namespace."""
|
|
433
|
+
for value in asdict(self).values():
|
|
434
|
+
validate_text(value)
|
|
435
|
+
if self.provider not in ACCOUNT_PROVIDER_SET or endpoint_origin(self.origin) != self.origin or self.namespace != account_namespace(self.provider, self.origin, self.account):
|
|
436
|
+
raise ValueError("The account selector is invalid. Sign in again.")
|
|
437
|
+
|
|
438
|
+
def command_url(self, endpoint: str) -> str:
|
|
439
|
+
"""Apply the selected Git username to an ephemeral URL while preserving the displayed URL."""
|
|
440
|
+
if endpoint_origin(endpoint) != self.origin:
|
|
441
|
+
raise ValueError("The account belongs to a different service endpoint")
|
|
442
|
+
parsed = urlsplit(endpoint)
|
|
443
|
+
return urlunsplit((parsed.scheme, quote(self.git_username, safe="") + "@" + parsed.netloc.rsplit("@", 1)[-1], parsed.path, "", ""))
|
|
444
|
+
|
|
445
|
+
def credential_arguments(self, helper_commands: tuple[str, ...], endpoint: str | None = None) -> list[str]:
|
|
446
|
+
"""Override inherited helpers and account hints only for the intended Git child; an empty ``helper_commands`` gives a preview."""
|
|
447
|
+
self.command_url(endpoint or self.origin)
|
|
448
|
+
return credential_configuration(self.origin, self.git_username, self.namespace, helper_commands, endpoint)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def generic_account(origin: str, username: str) -> AccountSelection:
|
|
452
|
+
"""Build the selector of a username + password account: the typed username is its account, Git username, identity and label.
|
|
453
|
+
|
|
454
|
+
Its namespace is derived like any other account's, so two usernames of one server never share a stored secret.
|
|
455
|
+
"""
|
|
456
|
+
validate_text(username)
|
|
457
|
+
origin = endpoint_origin(origin)
|
|
458
|
+
return AccountSelection(GENERIC_PROVIDER, origin, username, username, username, username, account_namespace(GENERIC_PROVIDER, origin, username))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def transfer_account(selection: AccountSelection | None, endpoint: str = "", missing_namespace_set: set[str] | frozenset[str] = frozenset()) -> TransferAccount | None:
|
|
462
|
+
"""Describe the address and account a transfer used, so its failure can name them without carrying any secret.
|
|
463
|
+
|
|
464
|
+
With an account, its own address and label are used, and it needs a reconnect when its namespace is in
|
|
465
|
+
``missing_namespace_set``, the accounts whose secret a check found gone. Without one, an HTTPS ``endpoint`` still
|
|
466
|
+
gives the address (with no account), so the failure can say none is connected there; any other endpoint (SSH,
|
|
467
|
+
local path) gives None.
|
|
468
|
+
"""
|
|
469
|
+
if selection is not None:
|
|
470
|
+
return TransferAccount(urlsplit(endpoint_origin(selection.origin)).netloc, selection.label, selection.namespace in missing_namespace_set)
|
|
471
|
+
try:
|
|
472
|
+
origin = endpoint_origin(endpoint)
|
|
473
|
+
except ValueError:
|
|
474
|
+
return None
|
|
475
|
+
return TransferAccount(urlsplit(origin).netloc)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@dataclass(frozen=True)
|
|
479
|
+
class RecursiveTransportContext:
|
|
480
|
+
"""Bind a confirmed account to one effective Git destination without carrying a secret."""
|
|
481
|
+
|
|
482
|
+
endpoint: str
|
|
483
|
+
origin: str
|
|
484
|
+
selection: AccountSelection | None = None
|
|
485
|
+
repository: RepositoryContext | None = None
|
|
486
|
+
|
|
487
|
+
def __post_init__(self) -> None:
|
|
488
|
+
"""Refuse selectors that refer to another destination before preparing transport."""
|
|
489
|
+
if endpoint_origin(self.endpoint) != self.origin:
|
|
490
|
+
raise ValueError("The recursive destination origin changed. Read the repository again.")
|
|
491
|
+
if self.selection is not None:
|
|
492
|
+
self.selection.command_url(self.endpoint)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
ACCOUNT_FIELD_SET = frozenset(account_field.name for account_field in fields(AccountSelection))
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def validate_service_names(service_name_by_origin: object) -> None:
|
|
499
|
+
"""Accept only non-empty display names keyed by an exact HTTPS service address."""
|
|
500
|
+
if not isinstance(service_name_by_origin, dict):
|
|
501
|
+
raise TypeError("Unreadable service names")
|
|
502
|
+
for origin, service_name in service_name_by_origin.items():
|
|
503
|
+
validate_text(origin)
|
|
504
|
+
validate_text(service_name)
|
|
505
|
+
if endpoint_origin(origin) != origin:
|
|
506
|
+
raise ValueError("Unreadable service address")
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def token_page_url(provider: str, origin: str) -> str:
|
|
510
|
+
"""Return the page of this server where a user of a recognised provider creates a personal access token, or an empty text for any other server."""
|
|
511
|
+
path = TOKEN_PAGE_PATH_BY_PROVIDER.get(provider)
|
|
512
|
+
if path is None:
|
|
513
|
+
return ""
|
|
514
|
+
return endpoint_origin(origin) + path
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def provider_for_browser_origin(origin: str) -> str | None:
|
|
518
|
+
"""Name the provider whose browser sign-in is offered for exactly this address (github.com, gitlab.com or bitbucket.org), else None."""
|
|
519
|
+
for provider, host in PROVIDER_HOST_BY_NAME.items():
|
|
520
|
+
if origin == HTTPS_PREFIX + host:
|
|
521
|
+
return provider
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def recorded_sign_in_method(word: object) -> SignInMethod:
|
|
526
|
+
"""Return the recorded sign-in method a saved word names, raising ValueError for a word that names none or one that is never recorded."""
|
|
527
|
+
try:
|
|
528
|
+
method = SignInMethod(word)
|
|
529
|
+
except ValueError as error:
|
|
530
|
+
raise ValueError("Unreadable account sign-in method") from error
|
|
531
|
+
if method not in RECORDED_SIGN_IN_METHOD_SET:
|
|
532
|
+
raise ValueError("Unreadable account sign-in method")
|
|
533
|
+
return method
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def read_sign_in_method_json(value: object) -> dict[str, SignInMethod]:
|
|
537
|
+
"""Read the saved JSON object mapping GitPane account namespaces to a recorded sign-in method, raising TypeError or ValueError when it is not one."""
|
|
538
|
+
if not isinstance(value, str):
|
|
539
|
+
raise TypeError("Unreadable account sign-in methods")
|
|
540
|
+
word_by_namespace = json.loads(value)
|
|
541
|
+
if not isinstance(word_by_namespace, dict):
|
|
542
|
+
raise TypeError("Unreadable account sign-in methods")
|
|
543
|
+
method_by_namespace = {}
|
|
544
|
+
for namespace, word in word_by_namespace.items():
|
|
545
|
+
validate_text(namespace)
|
|
546
|
+
if not namespace.startswith(ACCOUNT_NAMESPACE_PREFIX):
|
|
547
|
+
raise ValueError("Unreadable account sign-in method")
|
|
548
|
+
method_by_namespace[namespace] = recorded_sign_in_method(word)
|
|
549
|
+
return method_by_namespace
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def validate_sign_in_method_json(value: object) -> str:
|
|
553
|
+
"""Accept only a JSON object mapping GitPane account namespaces to a recorded sign-in method, and keep its text as saved."""
|
|
554
|
+
read_sign_in_method_json(value)
|
|
555
|
+
return value
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def sign_in_method_json(method_by_namespace: dict[str, SignInMethod]) -> str:
|
|
559
|
+
"""Write the sign-in methods as the JSON the preferences file holds: each method as its recorded word, which every system and older GitPane versions read."""
|
|
560
|
+
word_by_namespace = {}
|
|
561
|
+
for namespace, method in method_by_namespace.items():
|
|
562
|
+
word_by_namespace[namespace] = method.value
|
|
563
|
+
return json.dumps(word_by_namespace, ensure_ascii=False)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def exchange_sign_in_method(provider: str, action: AuthenticationAction, token: str) -> SignInMethod:
|
|
567
|
+
"""Name how an add or reconnect exchange signs in: a password for a username + password account, else the browser or a typed token."""
|
|
568
|
+
if provider == GENERIC_PROVIDER:
|
|
569
|
+
return SignInMethod.PASSWORD
|
|
570
|
+
if is_browser_sign_in(action, provider, token):
|
|
571
|
+
return SignInMethod.BROWSER
|
|
572
|
+
return SignInMethod.TOKEN
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def account_sign_in_method(selection: AccountSelection, recorded_method_by_namespace: dict[str, SignInMethod]) -> SignInMethod:
|
|
576
|
+
"""Tell how a listed account signs in: its recorded method, else what its own fields show.
|
|
577
|
+
|
|
578
|
+
A username + password account is always one. GitLab and Bitbucket give a browser sign-in its own Git username, so
|
|
579
|
+
any other of their accounts used a token. An old GitHub account without record can be either.
|
|
580
|
+
"""
|
|
581
|
+
if selection.provider == GENERIC_PROVIDER:
|
|
582
|
+
return SignInMethod.PASSWORD
|
|
583
|
+
recorded_method = recorded_method_by_namespace.get(selection.namespace)
|
|
584
|
+
if recorded_method is not None:
|
|
585
|
+
return recorded_method
|
|
586
|
+
if selection.git_username in BROWSER_GIT_USERNAME_SET:
|
|
587
|
+
return SignInMethod.BROWSER
|
|
588
|
+
if selection.provider in (GITLAB_PROVIDER, BITBUCKET_PROVIDER):
|
|
589
|
+
return SignInMethod.TOKEN
|
|
590
|
+
return SignInMethod.BROWSER_OR_TOKEN
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def validate_selection_json(value: object) -> str:
|
|
594
|
+
"""Allow only nonsecret account records, scope selectors and service names in persisted JSON."""
|
|
595
|
+
if not isinstance(value, str):
|
|
596
|
+
raise TypeError("Unreadable authentication preferences")
|
|
597
|
+
data_by_key = json.loads(value)
|
|
598
|
+
if not isinstance(data_by_key, dict) or set(data_by_key) - {SERVICE_NAME_KEY} != ACCOUNT_DATA_KEY_SET:
|
|
599
|
+
raise ValueError("Unreadable authentication preferences")
|
|
600
|
+
validate_service_names(data_by_key.get(SERVICE_NAME_KEY, {}))
|
|
601
|
+
if not isinstance(data_by_key["accounts"], list):
|
|
602
|
+
raise TypeError("Unreadable account list")
|
|
603
|
+
for account_by_key in data_by_key["accounts"]:
|
|
604
|
+
if not isinstance(account_by_key, dict) or set(account_by_key) != ACCOUNT_FIELD_SET:
|
|
605
|
+
raise ValueError("Authentication preferences may contain only account selectors")
|
|
606
|
+
for text in account_by_key.values():
|
|
607
|
+
validate_text(text)
|
|
608
|
+
if account_by_key["provider"] not in ACCOUNT_PROVIDER_SET or endpoint_origin(account_by_key["origin"]) != account_by_key["origin"]:
|
|
609
|
+
raise ValueError("Unreadable account endpoint")
|
|
610
|
+
AccountSelection(**account_by_key)
|
|
611
|
+
for scope in ("global", "repository"):
|
|
612
|
+
selection_by_key = data_by_key[scope]
|
|
613
|
+
if not isinstance(selection_by_key, dict):
|
|
614
|
+
raise TypeError("Unreadable account scope")
|
|
615
|
+
for key, selector in selection_by_key.items():
|
|
616
|
+
validate_text(key)
|
|
617
|
+
validate_text(selector)
|
|
618
|
+
return value
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
@dataclass(frozen=True)
|
|
622
|
+
class AuthOutcome:
|
|
623
|
+
"""Report verified identity and credential-store success independently.
|
|
624
|
+
|
|
625
|
+
``refused_before_start`` is set when the exchange ended before any worker or helper ran, so nothing was stored or
|
|
626
|
+
erased; a removal result still carries its account then, and the Accounts page says the account was not removed
|
|
627
|
+
without claiming an erase failed. ``credential_manager_refusal`` is set when that early end came from a refused
|
|
628
|
+
credential manager, so the window explaining how to install it can open. ``refused_by_gitpane`` is set when the
|
|
629
|
+
exchange ran but GitPane refused the account it returned, such as a Reconnect signed in as another account: the cause
|
|
630
|
+
is GitPane's own sentence, not a program's output.
|
|
631
|
+
"""
|
|
632
|
+
|
|
633
|
+
operation_id: str
|
|
634
|
+
status: OperationStatus
|
|
635
|
+
selection: AccountSelection | None = None
|
|
636
|
+
authenticated: bool = False
|
|
637
|
+
stored: bool = False
|
|
638
|
+
diagnostic: PreparedDiagnostic | None = None
|
|
639
|
+
renewed: bool = False
|
|
640
|
+
became_default: bool = False
|
|
641
|
+
removal: AccountRemoval | None = None
|
|
642
|
+
check: AccountCheck | None = None
|
|
643
|
+
refused_before_start: bool = False
|
|
644
|
+
credential_manager_refusal: CredentialManagerDetection | None = None
|
|
645
|
+
refused_by_gitpane: bool = False
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def store_was_refused(outcome: AuthOutcome) -> bool:
|
|
649
|
+
"""Tell whether the step that failed is the secure store saving the account, read on the fields of the result only.
|
|
650
|
+
|
|
651
|
+
It is so when nothing was stored after the service accepted the sign-in, or after a username + password account
|
|
652
|
+
passed its Git test. A refusal before any program ran, an account GitPane itself refused, or a failed Git test is
|
|
653
|
+
another step. The output of the credential manager is never read for this: it names the secure store whatever the
|
|
654
|
+
failure is.
|
|
655
|
+
"""
|
|
656
|
+
if outcome.stored or outcome.refused_before_start or outcome.refused_by_gitpane or outcome.check is not None:
|
|
657
|
+
return False
|
|
658
|
+
if outcome.selection is not None and outcome.selection.provider == GENERIC_PROVIDER:
|
|
659
|
+
return True
|
|
660
|
+
return outcome.authenticated
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@dataclass(frozen=True)
|
|
664
|
+
class AccountCheck:
|
|
665
|
+
"""Tell what Git said when the test of a username + password on a repository of its server failed.
|
|
666
|
+
|
|
667
|
+
``git_output`` is Git's standard error as a form shows it, without any secret or URL credentials; ``url`` is the
|
|
668
|
+
tested repository, also without credentials. After an authentication refusal, ``server_provider`` names the
|
|
669
|
+
provider the server was recognised as (GitHub or GitLab), ``browser_provider`` the provider whose browser sign-in
|
|
670
|
+
that server offers, and ``token_page_url`` the page where a personal access token is created there; each is empty
|
|
671
|
+
when unknown.
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
git_output: str
|
|
675
|
+
url: str = ""
|
|
676
|
+
server_provider: str = ""
|
|
677
|
+
browser_provider: str = ""
|
|
678
|
+
token_page_url: str = ""
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def account_check(value: object) -> AccountCheck | None:
|
|
682
|
+
"""Read the nonsecret test result a worker published, refusing anything but text.
|
|
683
|
+
|
|
684
|
+
A recognised provider must be one a server can be recognised as, its browser sign-in can only be that provider's,
|
|
685
|
+
and the token page must be exactly the one of that provider on the tested server, so a worker result can never
|
|
686
|
+
point the user to another address.
|
|
687
|
+
"""
|
|
688
|
+
if value is None:
|
|
689
|
+
return None
|
|
690
|
+
if not isinstance(value, dict) or not isinstance(value.get("git_output"), str):
|
|
691
|
+
raise TypeError("The credential helper returned an invalid test result")
|
|
692
|
+
url = validate_text(value.get("url", ""), allow_empty=True)
|
|
693
|
+
server_provider = validate_text(value.get("server_provider", ""), allow_empty=True)
|
|
694
|
+
browser_provider = validate_text(value.get("browser_provider", ""), allow_empty=True)
|
|
695
|
+
page_url = validate_text(value.get("token_page_url", ""), allow_empty=True)
|
|
696
|
+
if server_provider and (server_provider not in TOKEN_PAGE_PATH_BY_PROVIDER or not url):
|
|
697
|
+
raise ValueError("The credential helper returned an unknown server provider")
|
|
698
|
+
if browser_provider not in ("", server_provider):
|
|
699
|
+
raise ValueError("The credential helper returned an unknown browser sign-in")
|
|
700
|
+
if page_url and page_url != token_page_url(server_provider, url):
|
|
701
|
+
raise ValueError("The credential helper returned a token page on another address")
|
|
702
|
+
return AccountCheck(value["git_output"], url, server_provider, browser_provider, page_url)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
@dataclass(frozen=True)
|
|
706
|
+
class AccountRemoval:
|
|
707
|
+
"""Tell what a removed account leaves to choose again.
|
|
708
|
+
|
|
709
|
+
``default_cleared`` says the account was its address's Global default, now cleared; ``repository_choice_count`` counts
|
|
710
|
+
the repositories that chose it explicitly, whose choices are kept so they ask for another account instead of silently
|
|
711
|
+
using a different one; ``address_has_account`` says another account of the same address is still listed.
|
|
712
|
+
"""
|
|
713
|
+
|
|
714
|
+
default_cleared: bool
|
|
715
|
+
repository_choice_count: int
|
|
716
|
+
address_has_account: bool
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@dataclass(frozen=True)
|
|
720
|
+
class AccountCatalogue:
|
|
721
|
+
"""Hold the nonsecret accounts, the default of each service address and one repository's explicit choices, read together in one read.
|
|
722
|
+
|
|
723
|
+
``needs_reconnect_namespace_set`` names the listed accounts whose secret a check of the system credential store
|
|
724
|
+
found gone: such an account must be reconnected before Git can use it again. ``sign_in_method_by_namespace`` tells
|
|
725
|
+
how each listed account signs in (see ``account_sign_in_method``).
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
accounts: tuple[AccountSelection, ...]
|
|
729
|
+
default_namespace_by_origin: dict[str, str]
|
|
730
|
+
repository_namespace_by_origin: dict[str, str]
|
|
731
|
+
service_name_by_origin: dict[str, str] = field(default_factory=dict)
|
|
732
|
+
needs_reconnect_namespace_set: frozenset[str] = frozenset()
|
|
733
|
+
sign_in_method_by_namespace: dict[str, SignInMethod] = field(default_factory=dict)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
@dataclass(frozen=True)
|
|
737
|
+
class SecretLookup:
|
|
738
|
+
"""Tell, for each account a check of the system credential store covered, whether its secret is still there.
|
|
739
|
+
|
|
740
|
+
It never carries a secret: only the namespaces of the accounts, sorted into present, missing and unknown.
|
|
741
|
+
"""
|
|
742
|
+
|
|
743
|
+
missing_namespace_set: frozenset[str]
|
|
744
|
+
present_namespace_set: frozenset[str]
|
|
745
|
+
unknown_namespace_set: frozenset[str]
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@dataclass(frozen=True)
|
|
749
|
+
class AccountListChange:
|
|
750
|
+
"""Tell whether a remembered account was already listed and whether it became its address's default."""
|
|
751
|
+
|
|
752
|
+
renewed: bool
|
|
753
|
+
became_default: bool
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def check_copy_erased(output: bytes | bytearray) -> bool:
|
|
757
|
+
"""Tell whether an account test's worker reported, in its private result, that it erased the temporary copy of the tested secret.
|
|
758
|
+
|
|
759
|
+
An unreadable result, or one that does not say so, counts as not erased: the copy may still be in the secure store.
|
|
760
|
+
"""
|
|
761
|
+
try:
|
|
762
|
+
value = json.loads(output) if output else None
|
|
763
|
+
except ValueError:
|
|
764
|
+
return False
|
|
765
|
+
return isinstance(value, dict) and value.get(CHECK_COPY_ERASED_KEY) is True
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def authentication_worker_filename() -> str:
|
|
769
|
+
"""Answer the absolute file of the account worker, as imported from the installation; a module that cannot be found or is not valid Python, or a missing file, is a launch error.
|
|
770
|
+
|
|
771
|
+
The account exchange ends with its own failure on a launch error, so a worker that cannot be found, or that Python
|
|
772
|
+
cannot compile, never leaves it waiting.
|
|
773
|
+
"""
|
|
774
|
+
# Imported here, not at the top: the worker imports this module at its top, and only an account exchange needs it.
|
|
775
|
+
try:
|
|
776
|
+
import gitpane.account.authentication_worker
|
|
777
|
+
except (ImportError, SyntaxError) as error:
|
|
778
|
+
raise OSError("The account worker is not available: " + str(error)) from error
|
|
779
|
+
|
|
780
|
+
worker_filename = os.path.abspath(gitpane.account.authentication_worker.__file__)
|
|
781
|
+
if not os.path.isfile(worker_filename):
|
|
782
|
+
raise OSError("The account worker is not available: " + worker_filename)
|
|
783
|
+
return worker_filename
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def account_worker_command(worker_filename: str) -> tuple[str, list[str]]:
|
|
787
|
+
"""Answer the program and arguments that start the account worker of ``worker_filename`` on the Python GitPane runs on.
|
|
788
|
+
|
|
789
|
+
The Python variables the interpreter reads itself and the folders the worker starts from stay out of its import
|
|
790
|
+
path, while the user's site-packages folder, where pip may have installed GitPane, stays in it as long as the
|
|
791
|
+
running GitPane has it, and stays out when the running GitPane runs without it. GitPane and the test that
|
|
792
|
+
launches the worker both take the command from here, so the test runs what GitPane runs. A GitPane started without
|
|
793
|
+
a console runs on the windowless interpreter, so the worker runs on the console interpreter beside it: the worker
|
|
794
|
+
starts the credential manager and Git, which would each open a visible console window under a windowless worker. An
|
|
795
|
+
interpreter that is missing is a launch error: another Python is never searched for.
|
|
796
|
+
"""
|
|
797
|
+
interpreter_filename = console_interpreter_filename(sys.executable)
|
|
798
|
+
if not os.path.isfile(interpreter_filename):
|
|
799
|
+
raise OSError("The account worker interpreter is not available: " + interpreter_filename)
|
|
800
|
+
return interpreter_filename, [*application_module_interpreter_options(bool(sys.flags.no_user_site)), worker_filename]
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def breakaway_process_group() -> NativeProcessGroup:
|
|
804
|
+
"""Create the process group of a browser sign-in worker: a member may leave it only by asking explicitly when it starts a process.
|
|
805
|
+
|
|
806
|
+
Only the worker asks, for the helper of a browser sign-in, which it then holds in a job of its own. Every other
|
|
807
|
+
worker keeps a closed group: Git's shell asks to leave any group that allows it for the programs it starts, so a
|
|
808
|
+
helper run by Git during an account test would otherwise outlive a Cancel or the closing of GitPane.
|
|
809
|
+
"""
|
|
810
|
+
return NativeProcessGroup(breakaway_allowed=True)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def is_browser_sign_in(action: AuthenticationAction, provider: str, token: str) -> bool:
|
|
814
|
+
"""Tell whether an exchange waits for the browser: a new service account or a Reconnect without a typed token."""
|
|
815
|
+
return action in REMEMBERED_ACTION_SET and provider != GENERIC_PROVIDER and not token
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@dataclass
|
|
819
|
+
class _AuthenticationRun:
|
|
820
|
+
"""Retain private output and the captured callback until descendants have stopped."""
|
|
821
|
+
|
|
822
|
+
callback: Callable[[AuthOutcome], None]
|
|
823
|
+
output: bytearray
|
|
824
|
+
is_current: Callable[[], bool] | None = None
|
|
825
|
+
cancelled: bool = False
|
|
826
|
+
action: AuthenticationAction | None = None
|
|
827
|
+
service_name: str = ""
|
|
828
|
+
sign_in_method: SignInMethod | None = None
|
|
829
|
+
selection: AccountSelection | None = None
|
|
830
|
+
check_selection: AccountSelection | None = None
|
|
831
|
+
# A check of the stored secrets: who gets its result, and each account's list generation when it started.
|
|
832
|
+
lookup_callback: Callable[[SecretLookup], None] | None = None
|
|
833
|
+
lookup_generation_by_namespace: dict[str, int] = field(default_factory=dict)
|
|
834
|
+
# An erase the service started itself, for a secret an exchange left behind: it runs on while GitPane closes.
|
|
835
|
+
cleanup: bool = False
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def lookup_request(selection: AccountSelection) -> dict[str, str]:
|
|
839
|
+
"""Describe one account to a check of its stored secret with the nonsecret fields that check needs, and nothing else."""
|
|
840
|
+
account_by_key = asdict(selection)
|
|
841
|
+
request_by_key = {}
|
|
842
|
+
for name in SECRET_LOOKUP_FIELDS:
|
|
843
|
+
request_by_key[name] = account_by_key[name]
|
|
844
|
+
return request_by_key
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def lookup_result(value: object, requested_namespace_set: frozenset[str]) -> SecretLookup:
|
|
848
|
+
"""Read the classification a secret check published, keeping only the accounts it was asked about.
|
|
849
|
+
|
|
850
|
+
An account the answer does not classify, or classifies twice, is unknown, so a malformed answer can never mark an
|
|
851
|
+
account as needing a reconnect.
|
|
852
|
+
"""
|
|
853
|
+
namespace_set_by_presence: dict[SecretPresence, set[str]] = {}
|
|
854
|
+
for presence in SecretPresence:
|
|
855
|
+
namespace_set_by_presence[presence] = set()
|
|
856
|
+
if isinstance(value, dict) and value.get("status") == OperationStatus.SUCCESS.value:
|
|
857
|
+
for presence in SecretPresence:
|
|
858
|
+
namespaces = value.get(presence.value, [])
|
|
859
|
+
if not isinstance(namespaces, list):
|
|
860
|
+
continue
|
|
861
|
+
for namespace in namespaces:
|
|
862
|
+
if isinstance(namespace, str) and namespace in requested_namespace_set:
|
|
863
|
+
namespace_set_by_presence[presence].add(namespace)
|
|
864
|
+
classified_namespace_set: set[str] = set()
|
|
865
|
+
ambiguous_namespace_set: set[str] = set()
|
|
866
|
+
for presence in SecretPresence:
|
|
867
|
+
ambiguous_namespace_set |= classified_namespace_set & namespace_set_by_presence[presence]
|
|
868
|
+
classified_namespace_set |= namespace_set_by_presence[presence]
|
|
869
|
+
missing_namespace_set = namespace_set_by_presence[SecretPresence.MISSING] - ambiguous_namespace_set
|
|
870
|
+
present_namespace_set = namespace_set_by_presence[SecretPresence.PRESENT] - ambiguous_namespace_set
|
|
871
|
+
unknown_namespace_set = requested_namespace_set - missing_namespace_set - present_namespace_set
|
|
872
|
+
return SecretLookup(frozenset(missing_namespace_set), frozenset(present_namespace_set), frozenset(unknown_namespace_set))
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def no_git_filename() -> str:
|
|
876
|
+
"""Answer that no Git is known: the search of the credential manager then reads only the PATH and the fixed locations."""
|
|
877
|
+
return ""
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
class AuthenticationService(QObject):
|
|
881
|
+
"""Share the account exchanges with the Git Credential Manager found for Git, and publish only observed, nonsecret account choices."""
|
|
882
|
+
|
|
883
|
+
idle = Signal()
|
|
884
|
+
# A search of the credential manager ended on the service's own thread: its request id and its finished future.
|
|
885
|
+
credential_helper_resolved = Signal(str, object)
|
|
886
|
+
|
|
887
|
+
def __init__(self, preference: PreferenceStore, parent: QObject | None = None,
|
|
888
|
+
*, credential_manager: CredentialManagerLocator, helper_commands: tuple[str, ...] | None = None, validation_origin: str | None = None, timeout_ms: int = AUTHENTICATION_TIMEOUT_MS,
|
|
889
|
+
check_timeout_ms: int = ACCOUNT_CHECK_TIMEOUT_MS,
|
|
890
|
+
browser_timeout_ms: int = BROWSER_SIGN_IN_TIMEOUT_MS, lookup_timeout_ms: int = SECRET_LOOKUP_TIMEOUT_MS,
|
|
891
|
+
erase_timeout_ms: int = SECRET_ERASE_TIMEOUT_MS,
|
|
892
|
+
current_git_filename: Callable[[], str] = no_git_filename) -> None:
|
|
893
|
+
"""Inject the search of the credential manager, and for isolated tests a helper and a controlled endpoint.
|
|
894
|
+
|
|
895
|
+
``credential_manager`` finds the Git Credential Manager each operation hands to Git and writes its install
|
|
896
|
+
lines; it is always given, since only the composition root knows the session's distribution and processor,
|
|
897
|
+
which those lines depend on. A non-empty ``helper_commands`` is used as it is by every exchange and every
|
|
898
|
+
operation, without any search. ``current_git_filename`` names the Git whose credential manager an account
|
|
899
|
+
exchange uses when the exchange itself names none (checks, removals, erases).
|
|
900
|
+
|
|
901
|
+
``check_timeout_ms`` bounds the Git test of a username + password account before it is saved, and
|
|
902
|
+
``browser_timeout_ms`` the wait for the browser during a browser sign-in; the sign-in window counts that wait down.
|
|
903
|
+
``lookup_timeout_ms`` bounds a whole check of the stored secrets, whatever the number of accounts it covers, and
|
|
904
|
+
``erase_timeout_ms`` the erase of a secret an exchange left behind.
|
|
905
|
+
Only the process group of a browser sign-in worker lets its helper leave, so that the browser it opens is
|
|
906
|
+
never held, nor stopped, with the group; every other worker, the Git test of an account included, stays in a
|
|
907
|
+
closed group that stops all it started.
|
|
908
|
+
|
|
909
|
+
``missing_secret_namespace_set`` holds, in memory only, the accounts whose secret a check found gone; it is
|
|
910
|
+
found again by the next check and never saved. Each account's list generation grows with every successful
|
|
911
|
+
save or removal of that account, so a check started before a Reconnect cannot mark the renewed account.
|
|
912
|
+
"""
|
|
913
|
+
super().__init__(parent)
|
|
914
|
+
self.preference = preference
|
|
915
|
+
self.helper_commands = helper_commands
|
|
916
|
+
self.credential_manager = credential_manager
|
|
917
|
+
self.validation_origin = validation_origin
|
|
918
|
+
self.timeout_ms = timeout_ms
|
|
919
|
+
self.check_timeout_ms = check_timeout_ms
|
|
920
|
+
self.browser_timeout_ms = browser_timeout_ms
|
|
921
|
+
self.lookup_timeout_ms = lookup_timeout_ms
|
|
922
|
+
self.erase_timeout_ms = erase_timeout_ms
|
|
923
|
+
self.missing_secret_namespace_set: set[str] = set()
|
|
924
|
+
self.account_generation_by_namespace: dict[str, int] = {}
|
|
925
|
+
self.runner = ProcessRunner(self, credential_consumer=self._consume)
|
|
926
|
+
self.runner.finished.connect(self._finished)
|
|
927
|
+
self.runner.idle.connect(self.idle)
|
|
928
|
+
self.active_by_id: dict[str, _AuthenticationRun] = {}
|
|
929
|
+
self.outcome_by_id: dict[str, AuthOutcome] = {}
|
|
930
|
+
self.closed = False
|
|
931
|
+
self.current_git_filename = current_git_filename
|
|
932
|
+
# The search may start short processes and wait for them, so it never runs on the window's thread; its result
|
|
933
|
+
# comes back there through a queued signal. Like the file executor of the worktree service, it is never shut
|
|
934
|
+
# down explicitly: a search still running when GitPane closes ends by itself and starts nothing.
|
|
935
|
+
self.helper_executor = ThreadPoolExecutor(max_workers=CREDENTIAL_HELPER_WORKER_COUNT, thread_name_prefix="gitpane-credential-manager")
|
|
936
|
+
# One table for both kinds of answer: the helper an exchange waits for, and the bare detection Settings and the
|
|
937
|
+
# install window ask for. Each request keeps the continuation of its own kind under its own ID.
|
|
938
|
+
self.helper_continuation_by_id: dict[str, Callable[[CredentialHelper | None, Exception | None], None] | Callable[[CredentialManagerDetection | None, Exception | None], None]] = {}
|
|
939
|
+
self.credential_helper_resolved.connect(self._credential_helper_resolved, Qt.ConnectionType.QueuedConnection)
|
|
940
|
+
|
|
941
|
+
def accounts(self, origin: str, provider: str) -> list[AccountSelection]:
|
|
942
|
+
"""List the connected accounts of exactly this provider and service endpoint, as saved in the durable list."""
|
|
943
|
+
accounts = []
|
|
944
|
+
for data_by_key in self._read()["accounts"]:
|
|
945
|
+
selection = AccountSelection(**data_by_key)
|
|
946
|
+
if selection.origin == origin and selection.provider == provider:
|
|
947
|
+
accounts.append(selection)
|
|
948
|
+
return sorted(accounts, key=lambda selection: (selection.label, selection.account))
|
|
949
|
+
|
|
950
|
+
def account_for_endpoint(self, endpoint: str, context: RepositoryContext | None = None) -> AccountSelection | None:
|
|
951
|
+
"""Resolve repository override before service default without substituting a missing account."""
|
|
952
|
+
origin = endpoint_origin(endpoint)
|
|
953
|
+
data_by_key = self._read()
|
|
954
|
+
namespace = data_by_key["repository"].get(self._scope_key(origin, context)) if context is not None else None
|
|
955
|
+
namespace = namespace if namespace is not None else data_by_key["global"].get(origin)
|
|
956
|
+
if namespace is None:
|
|
957
|
+
return None
|
|
958
|
+
for account_by_key in data_by_key["accounts"]:
|
|
959
|
+
if account_by_key["namespace"] == namespace and account_by_key["origin"] == origin:
|
|
960
|
+
return AccountSelection(**account_by_key)
|
|
961
|
+
raise ValueError("The selected account is absent. Sign in again or choose an account explicitly.")
|
|
962
|
+
|
|
963
|
+
def catalogue(self, context: RepositoryContext | None = None) -> AccountCatalogue:
|
|
964
|
+
"""Read every connected account, the service defaults and, with a repository, its explicit choices in one read, without writing."""
|
|
965
|
+
data_by_key = self._read()
|
|
966
|
+
accounts = []
|
|
967
|
+
for account_by_key in data_by_key["accounts"]:
|
|
968
|
+
accounts.append(AccountSelection(**account_by_key))
|
|
969
|
+
accounts.sort(key=lambda selection: (selection.origin, selection.label, selection.account))
|
|
970
|
+
repository_namespace_by_origin: dict[str, str] = {}
|
|
971
|
+
if context is not None:
|
|
972
|
+
repository_dir = canonical_path(context.common_git_dir)
|
|
973
|
+
for scope_key, namespace in data_by_key["repository"].items():
|
|
974
|
+
scope = self._decode_scope_key(scope_key)
|
|
975
|
+
if scope is None:
|
|
976
|
+
continue
|
|
977
|
+
scope_dir, origin = scope
|
|
978
|
+
if scope_dir == repository_dir:
|
|
979
|
+
repository_namespace_by_origin[origin] = namespace
|
|
980
|
+
needs_reconnect_namespace_set = set()
|
|
981
|
+
for selection in accounts:
|
|
982
|
+
if selection.namespace in self.missing_secret_namespace_set:
|
|
983
|
+
needs_reconnect_namespace_set.add(selection.namespace)
|
|
984
|
+
recorded_method_by_namespace = self._read_sign_in_methods()
|
|
985
|
+
sign_in_method_by_namespace = {}
|
|
986
|
+
for selection in accounts:
|
|
987
|
+
sign_in_method_by_namespace[selection.namespace] = account_sign_in_method(selection, recorded_method_by_namespace)
|
|
988
|
+
return AccountCatalogue(tuple(accounts), dict(data_by_key["global"]), repository_namespace_by_origin, dict(data_by_key.get(SERVICE_NAME_KEY, {})),
|
|
989
|
+
frozenset(needs_reconnect_namespace_set), sign_in_method_by_namespace)
|
|
990
|
+
|
|
991
|
+
def remember_account(self, selection: AccountSelection, service_name: str = "", *, sign_in_method: SignInMethod | None = None) -> AccountListChange:
|
|
992
|
+
"""Add or refresh a signed-in account in the durable list; the first new account of an address without default becomes its default.
|
|
993
|
+
|
|
994
|
+
Everything is written at once, so a failed write leaves the stored list unchanged. A default that names an account missing
|
|
995
|
+
from the list still counts as a default and is not replaced here: the user replaces it explicitly. A non-empty
|
|
996
|
+
``service_name`` becomes the name of the account's address in that same write; an empty one keeps the current name.
|
|
997
|
+
A ``sign_in_method`` records how the account signed in, also in that write; None keeps the record.
|
|
998
|
+
"""
|
|
999
|
+
validate_text(service_name, allow_empty=True)
|
|
1000
|
+
if sign_in_method is not None and sign_in_method not in RECORDED_SIGN_IN_METHOD_SET:
|
|
1001
|
+
raise ValueError("Unknown sign-in method")
|
|
1002
|
+
data_by_key = self._read_for_write()
|
|
1003
|
+
renewed = False
|
|
1004
|
+
retained_accounts = []
|
|
1005
|
+
for account_by_key in data_by_key["accounts"]:
|
|
1006
|
+
if account_by_key["namespace"] == selection.namespace:
|
|
1007
|
+
renewed = True
|
|
1008
|
+
continue
|
|
1009
|
+
retained_accounts.append(account_by_key)
|
|
1010
|
+
retained_accounts.append(asdict(selection))
|
|
1011
|
+
data_by_key["accounts"] = retained_accounts
|
|
1012
|
+
became_default = not renewed and selection.origin not in data_by_key["global"]
|
|
1013
|
+
if became_default:
|
|
1014
|
+
data_by_key["global"][selection.origin] = selection.namespace
|
|
1015
|
+
if service_name:
|
|
1016
|
+
data_by_key.setdefault(SERVICE_NAME_KEY, {})[selection.origin] = service_name
|
|
1017
|
+
value_by_key = {AUTHENTICATION_PREFERENCE_KEY: json.dumps(data_by_key, ensure_ascii=False)}
|
|
1018
|
+
if sign_in_method is not None:
|
|
1019
|
+
method_by_namespace = self._read_sign_in_methods(for_write=True)
|
|
1020
|
+
method_by_namespace[selection.namespace] = sign_in_method
|
|
1021
|
+
value_by_key[ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY] = sign_in_method_json(method_by_namespace)
|
|
1022
|
+
self.preference.update(value_by_key)
|
|
1023
|
+
self._account_changed(selection.namespace)
|
|
1024
|
+
return AccountListChange(renewed, became_default)
|
|
1025
|
+
|
|
1026
|
+
def forget_account(self, selection: AccountSelection) -> AccountRemoval:
|
|
1027
|
+
"""Take an account off the durable list, once its stored secret is erased, and clear the Global default that named it.
|
|
1028
|
+
|
|
1029
|
+
Repository choices naming it are kept on purpose: such a repository then asks for another account and its
|
|
1030
|
+
transport refuses, instead of silently using the Global default, which may be someone else; adding the same
|
|
1031
|
+
account again makes those choices valid again. The name of the address goes with its last account. Everything
|
|
1032
|
+
is written at once, and nothing is written when nothing changes, so calling it again is harmless.
|
|
1033
|
+
"""
|
|
1034
|
+
data_by_key = self._read_for_write()
|
|
1035
|
+
changed = False
|
|
1036
|
+
retained_accounts = []
|
|
1037
|
+
for account_by_key in data_by_key["accounts"]:
|
|
1038
|
+
if account_by_key["namespace"] == selection.namespace:
|
|
1039
|
+
changed = True
|
|
1040
|
+
continue
|
|
1041
|
+
retained_accounts.append(account_by_key)
|
|
1042
|
+
data_by_key["accounts"] = retained_accounts
|
|
1043
|
+
default_cleared = data_by_key["global"].get(selection.origin) == selection.namespace
|
|
1044
|
+
if default_cleared:
|
|
1045
|
+
del data_by_key["global"][selection.origin]
|
|
1046
|
+
changed = True
|
|
1047
|
+
repository_choice_count = 0
|
|
1048
|
+
for namespace in data_by_key["repository"].values():
|
|
1049
|
+
if namespace == selection.namespace:
|
|
1050
|
+
repository_choice_count += 1
|
|
1051
|
+
address_has_account = False
|
|
1052
|
+
for account_by_key in retained_accounts:
|
|
1053
|
+
if account_by_key["origin"] == selection.origin:
|
|
1054
|
+
address_has_account = True
|
|
1055
|
+
service_name_by_origin = data_by_key.get(SERVICE_NAME_KEY, {})
|
|
1056
|
+
if not address_has_account and selection.origin in service_name_by_origin:
|
|
1057
|
+
del service_name_by_origin[selection.origin]
|
|
1058
|
+
changed = True
|
|
1059
|
+
value_by_key = {}
|
|
1060
|
+
if changed:
|
|
1061
|
+
value_by_key[AUTHENTICATION_PREFERENCE_KEY] = json.dumps(data_by_key, ensure_ascii=False)
|
|
1062
|
+
method_by_namespace = self._read_sign_in_methods(for_write=True)
|
|
1063
|
+
if method_by_namespace.pop(selection.namespace, None) is not None:
|
|
1064
|
+
value_by_key[ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY] = sign_in_method_json(method_by_namespace)
|
|
1065
|
+
if value_by_key:
|
|
1066
|
+
self.preference.update(value_by_key)
|
|
1067
|
+
self._account_changed(selection.namespace)
|
|
1068
|
+
return AccountRemoval(default_cleared, repository_choice_count, address_has_account)
|
|
1069
|
+
|
|
1070
|
+
def _account_changed(self, namespace: str) -> None:
|
|
1071
|
+
"""Forget that this account needed a reconnect and make any check started before this save or removal stale for it."""
|
|
1072
|
+
self.account_generation_by_namespace[namespace] = self.account_generation_by_namespace.get(namespace, 0) + 1
|
|
1073
|
+
self.missing_secret_namespace_set.discard(namespace)
|
|
1074
|
+
|
|
1075
|
+
def save_global_defaults(self, namespace_by_origin: dict[str, str]) -> None:
|
|
1076
|
+
"""Write only the service defaults the user changed, keeping every other default and all repository choices.
|
|
1077
|
+
|
|
1078
|
+
Every value is checked before anything changes and all are written at once, so a refused value or a failed write
|
|
1079
|
+
leaves the saved data as it was. A default can only be replaced by another account of the same address, never removed.
|
|
1080
|
+
"""
|
|
1081
|
+
data_by_key = self._read_for_write()
|
|
1082
|
+
origins = sorted(namespace_by_origin)
|
|
1083
|
+
for origin in origins:
|
|
1084
|
+
self._require_connected_account(data_by_key, origin, namespace_by_origin[origin])
|
|
1085
|
+
for origin in origins:
|
|
1086
|
+
data_by_key["global"][origin] = namespace_by_origin[origin]
|
|
1087
|
+
self.preference.update({AUTHENTICATION_PREFERENCE_KEY: json.dumps(data_by_key, ensure_ascii=False)})
|
|
1088
|
+
|
|
1089
|
+
def save_repository_choices(self, namespace_by_origin: dict[str, str | None], context: RepositoryContext) -> None:
|
|
1090
|
+
"""Write only this repository's changed rows; None removes the explicit choice so the row follows the global default.
|
|
1091
|
+
|
|
1092
|
+
Choices of other repositories and every default stay as saved. Nothing is written when no row changed.
|
|
1093
|
+
"""
|
|
1094
|
+
if not namespace_by_origin:
|
|
1095
|
+
return
|
|
1096
|
+
data_by_key = self._read_for_write()
|
|
1097
|
+
origins = sorted(namespace_by_origin)
|
|
1098
|
+
for origin in origins:
|
|
1099
|
+
namespace = namespace_by_origin[origin]
|
|
1100
|
+
if namespace is not None:
|
|
1101
|
+
self._require_connected_account(data_by_key, origin, namespace)
|
|
1102
|
+
for origin in origins:
|
|
1103
|
+
namespace = namespace_by_origin[origin]
|
|
1104
|
+
scope_key = self._scope_key(origin, context)
|
|
1105
|
+
if namespace is None:
|
|
1106
|
+
data_by_key["repository"].pop(scope_key, None)
|
|
1107
|
+
else:
|
|
1108
|
+
data_by_key["repository"][scope_key] = namespace
|
|
1109
|
+
self.preference.update({AUTHENTICATION_PREFERENCE_KEY: json.dumps(data_by_key, ensure_ascii=False)})
|
|
1110
|
+
|
|
1111
|
+
def save_selection(self, origin: str, selection: AccountSelection | None, context: RepositoryContext | None = None) -> None:
|
|
1112
|
+
"""Persist one confirmed nonsecret selector, preserving other repository overrides."""
|
|
1113
|
+
endpoint_origin(origin)
|
|
1114
|
+
data_by_key = self._read_for_write()
|
|
1115
|
+
scope = "repository" if context is not None else "global"
|
|
1116
|
+
key = self._scope_key(origin, context) if context is not None else origin
|
|
1117
|
+
if selection is None:
|
|
1118
|
+
data_by_key[scope].pop(key, None)
|
|
1119
|
+
else:
|
|
1120
|
+
known_accounts = self.accounts(origin, selection.provider)
|
|
1121
|
+
if selection not in known_accounts:
|
|
1122
|
+
raise ValueError("Sign in successfully before saving this account")
|
|
1123
|
+
retained_accounts = []
|
|
1124
|
+
for item in data_by_key["accounts"]:
|
|
1125
|
+
if item["namespace"] != selection.namespace:
|
|
1126
|
+
retained_accounts.append(item)
|
|
1127
|
+
data_by_key["accounts"] = retained_accounts
|
|
1128
|
+
data_by_key["accounts"].append(asdict(selection))
|
|
1129
|
+
data_by_key[scope][key] = selection.namespace
|
|
1130
|
+
self.preference.update({AUTHENTICATION_PREFERENCE_KEY: json.dumps(data_by_key, ensure_ascii=False)})
|
|
1131
|
+
|
|
1132
|
+
def sign_in(self, provider: str, endpoint: str, callback: Callable[[AuthOutcome], None], *, token: str = "", account_hint: str = "", api_email: str = "",
|
|
1133
|
+
git_filename: str = "", is_current: Callable[[], bool] | None = None, username: str = "", service_name: str = "", check_url: str = "") -> str:
|
|
1134
|
+
"""Add an account in a supervised private child: a service account's browser or token identity is verified first,
|
|
1135
|
+
a generic account (``username`` with its password or token) only once Git lists the repository ``check_url`` of that
|
|
1136
|
+
server with them, using ``git_filename``.
|
|
1137
|
+
|
|
1138
|
+
A non-empty ``service_name`` becomes the name shown for this address once the account is listed.
|
|
1139
|
+
"""
|
|
1140
|
+
return self._start(AuthenticationAction.SIGN_IN, provider, endpoint, callback, token=token, account_hint=account_hint, api_email=api_email, git_filename=git_filename,
|
|
1141
|
+
is_current=is_current, username=username, service_name=service_name, check_url=check_url)
|
|
1142
|
+
|
|
1143
|
+
def get(self, selection: AccountSelection, callback: Callable[[AuthOutcome], None], *, git_filename: str = "") -> str:
|
|
1144
|
+
"""Check the exact stored account and report expiration without falling back to another."""
|
|
1145
|
+
return self._start(AuthenticationAction.GET, selection.provider, selection.origin, callback, selection=selection, git_filename=git_filename)
|
|
1146
|
+
|
|
1147
|
+
def credential_helper(self, git_filename: str) -> CredentialHelper:
|
|
1148
|
+
"""Return the credential manager found for this Git, or raise CredentialManagerRefusal, a ValueError carrying the sentence and the detection of its refusal.
|
|
1149
|
+
|
|
1150
|
+
Blocking: the search may start short processes, so it is called only from a background task, never from the
|
|
1151
|
+
window's thread. An injected helper is returned as it is, without any search.
|
|
1152
|
+
"""
|
|
1153
|
+
if self.helper_commands:
|
|
1154
|
+
return CredentialHelper(tuple(self.helper_commands))
|
|
1155
|
+
detection = self.credential_manager.locate(git_filename)
|
|
1156
|
+
if detection.problem != CredentialManagerProblem.NONE:
|
|
1157
|
+
raise CredentialManagerRefusal(detection, self.credential_manager.platform)
|
|
1158
|
+
return CredentialHelper((detection.filename,), detection.exec_path)
|
|
1159
|
+
|
|
1160
|
+
def resolve_credential_helper(self, git_filename: str, continuation: Callable[[CredentialHelper | None, Exception | None], None]) -> None:
|
|
1161
|
+
"""Give ``continuation``, on the window's thread, the credential manager found for this Git or the error of its refusal.
|
|
1162
|
+
|
|
1163
|
+
An injected helper answers at once, before this returns. Otherwise the search runs on the service's own thread,
|
|
1164
|
+
since it may start processes and wait for them, and the answer comes back through a queued signal.
|
|
1165
|
+
"""
|
|
1166
|
+
if self.helper_commands:
|
|
1167
|
+
continuation(self.credential_helper(git_filename), None)
|
|
1168
|
+
return
|
|
1169
|
+
self._submit_search(self.credential_helper, git_filename, continuation)
|
|
1170
|
+
|
|
1171
|
+
def locate_credential_manager(self, git_filename: str, continuation: Callable[[CredentialManagerDetection | None, Exception | None], None]) -> None:
|
|
1172
|
+
"""Give ``continuation``, on the window's thread, what the search finds for this Git, success or refusal, or the error that kept it from running.
|
|
1173
|
+
|
|
1174
|
+
An injected helper is ignored, since the question is what the search itself finds. The search runs on the
|
|
1175
|
+
service's own thread, since it may start processes and wait for them, and the answer comes back through the
|
|
1176
|
+
same queued signal as the helper of an exchange.
|
|
1177
|
+
"""
|
|
1178
|
+
self._submit_search(self.credential_manager.locate, git_filename, continuation)
|
|
1179
|
+
|
|
1180
|
+
def _submit_search(self, search: Callable[[str], CredentialHelper | CredentialManagerDetection], git_filename: str,
|
|
1181
|
+
continuation: Callable[[CredentialHelper | None, Exception | None], None] | Callable[[CredentialManagerDetection | None, Exception | None], None]) -> None:
|
|
1182
|
+
"""Run one credential manager search on the service's own thread and give its answer, or the error that kept it from running, to ``continuation`` on the window's thread through the queued signal.
|
|
1183
|
+
|
|
1184
|
+
An executor that no longer accepts work answers on the next turn of the event loop, never before this returns.
|
|
1185
|
+
"""
|
|
1186
|
+
request_id = uuid.uuid4().hex
|
|
1187
|
+
self.helper_continuation_by_id[request_id] = continuation
|
|
1188
|
+
try:
|
|
1189
|
+
future = self.helper_executor.submit(search, git_filename)
|
|
1190
|
+
except RuntimeError as failure:
|
|
1191
|
+
self.helper_continuation_by_id.pop(request_id, None)
|
|
1192
|
+
QTimer.singleShot(0, self, lambda failure=failure: continuation(None, failure))
|
|
1193
|
+
return
|
|
1194
|
+
future.add_done_callback(lambda completed: self.credential_helper_resolved.emit(request_id, completed))
|
|
1195
|
+
|
|
1196
|
+
@Slot(str, object)
|
|
1197
|
+
def _credential_helper_resolved(self, request_id: str, future: Future) -> None:
|
|
1198
|
+
"""Hand a finished search to the exchange, window or Settings line waiting for it, on the window's thread."""
|
|
1199
|
+
continuation = self.helper_continuation_by_id.pop(request_id, None)
|
|
1200
|
+
if continuation is None:
|
|
1201
|
+
return
|
|
1202
|
+
error = future.exception()
|
|
1203
|
+
if error is not None:
|
|
1204
|
+
continuation(None, error)
|
|
1205
|
+
return
|
|
1206
|
+
continuation(future.result(), None)
|
|
1207
|
+
|
|
1208
|
+
def command_configuration(self, endpoint: str, context: RepositoryContext | None = None, *, selection: AccountSelection | None = None,
|
|
1209
|
+
helper_commands: tuple[str, ...]) -> tuple[str, list[str]]:
|
|
1210
|
+
"""Return an ephemeral command URL and helper configuration for one account.
|
|
1211
|
+
|
|
1212
|
+
Every caller names the helper it composes, since nothing is searched here: the preview (``PREVIEW_HELPER_COMMANDS``),
|
|
1213
|
+
which disables every helper and is used to capture and compare a destination, or the credential manager its
|
|
1214
|
+
operation already found in the background.
|
|
1215
|
+
"""
|
|
1216
|
+
if selection is not None and selection not in self.accounts(endpoint_origin(endpoint), selection.provider):
|
|
1217
|
+
raise ValueError("Choose a confirmed account for this service before continuing.")
|
|
1218
|
+
selection = selection or self.account_for_endpoint(endpoint, context)
|
|
1219
|
+
if selection is None:
|
|
1220
|
+
raise ValueError(NO_ACCOUNT_CONFIGURED_TEXT)
|
|
1221
|
+
return selection.command_url(endpoint), selection.credential_arguments(tuple(helper_commands))
|
|
1222
|
+
|
|
1223
|
+
def recursive_configuration(self, destinations: tuple[RecursiveTransportContext, ...], inherited_keys: tuple[str, ...] | RecursiveTransportConfiguration = (), *,
|
|
1224
|
+
helper_commands: tuple[str, ...]) -> list[str]:
|
|
1225
|
+
"""Clear inherited authentication and authorize helpers only for captured destinations.
|
|
1226
|
+
|
|
1227
|
+
Nothing is searched here: a recursive operation finds the credential manager once, in its background task, and
|
|
1228
|
+
passes its ``helper_commands`` for all its destinations, so the Git of every submodule starts that same helper;
|
|
1229
|
+
``PREVIEW_HELPER_COMMANDS`` composes a preview that names no helper.
|
|
1230
|
+
"""
|
|
1231
|
+
if isinstance(inherited_keys, RecursiveTransportConfiguration):
|
|
1232
|
+
inherited_keys.verify()
|
|
1233
|
+
inherited_keys = inherited_keys.keys
|
|
1234
|
+
arguments = ["-c", "credential.helper=", "-c", "http.extraHeader=", "-c", "credential.interactive=" + GCM_DISABLED_VALUE, "-c", "credential.traceSecrets=" + GCM_DISABLED_VALUE]
|
|
1235
|
+
for key in sorted(set(inherited_keys)):
|
|
1236
|
+
validate_text(key)
|
|
1237
|
+
if key.lower().startswith("credential.") and key.lower().endswith(".helper") or key.lower().startswith("http.") and key.lower().endswith(".extraheader"):
|
|
1238
|
+
arguments.extend(["-c", key + "="])
|
|
1239
|
+
for destination in destinations:
|
|
1240
|
+
if destination.selection is None:
|
|
1241
|
+
continue
|
|
1242
|
+
_endpoint, selected_arguments = self.command_configuration(destination.endpoint, destination.repository, selection=destination.selection,
|
|
1243
|
+
helper_commands=helper_commands)
|
|
1244
|
+
for argument in selected_arguments:
|
|
1245
|
+
if argument == "credential.helper=":
|
|
1246
|
+
continue
|
|
1247
|
+
if argument == "-c":
|
|
1248
|
+
continue
|
|
1249
|
+
# Path-scoped selectors prevent another repository on the same host from adopting this account. Only Git
|
|
1250
|
+
# reads them with the path: the helper carries the account's store namespace in its own environment,
|
|
1251
|
+
# since Git Credential Manager ignores a setting scoped to a repository path.
|
|
1252
|
+
argument = argument.replace("credential." + destination.origin + ".", "credential." + destination.endpoint + ".")
|
|
1253
|
+
if argument.startswith("credential.helper="):
|
|
1254
|
+
argument = "credential." + destination.endpoint + ".helper=" + argument.partition("=")[2]
|
|
1255
|
+
arguments.extend(["-c", argument])
|
|
1256
|
+
arguments.extend(isolated_http_arguments(destination.endpoint))
|
|
1257
|
+
return arguments
|
|
1258
|
+
|
|
1259
|
+
def recursive_context(self, endpoint: str | None = None, selection: AccountSelection | None = None) -> tuple[RecursiveTransportContext, ...]:
|
|
1260
|
+
"""Capture each service's default for the whole service, then the parent's own account for its URL only.
|
|
1261
|
+
|
|
1262
|
+
A service default authorizes every repository of that service, the parent's host included, as submodule
|
|
1263
|
+
initialization, Fetch and Push already do: a submodule on the parent's host finds that default. The parent's
|
|
1264
|
+
account comes last because Git applies every matching credential setting in order, so its URL-scoped settings
|
|
1265
|
+
override the host's default for the parent alone, and other repositories of the host never inherit it.
|
|
1266
|
+
"""
|
|
1267
|
+
destinations = []
|
|
1268
|
+
for selected_origin in sorted(self._read()["global"]):
|
|
1269
|
+
selected_account = self.account_for_endpoint(selected_origin)
|
|
1270
|
+
if selected_account is not None:
|
|
1271
|
+
destinations.append(RecursiveTransportContext(selected_origin, selected_origin, selected_account))
|
|
1272
|
+
if endpoint is not None:
|
|
1273
|
+
selection = selection or self.account_for_endpoint(endpoint)
|
|
1274
|
+
destinations.append(RecursiveTransportContext(endpoint, endpoint_origin(endpoint), selection))
|
|
1275
|
+
return tuple(destinations)
|
|
1276
|
+
|
|
1277
|
+
def recursive_inherited_keys(self, repository_dirs: tuple[str, ...]) -> Generator[GitCommand, bytes, RecursiveTransportConfiguration]:
|
|
1278
|
+
"""Inspect conditional includes and templates before future children can activate headers."""
|
|
1279
|
+
key_set: set[str] = set()
|
|
1280
|
+
pending_files: list[tuple[str, str | None, bool]] = []
|
|
1281
|
+
visited_file_set: set[tuple[str, str | None, bool]] = set()
|
|
1282
|
+
signature_by_filename: dict[str, tuple | None] = {}
|
|
1283
|
+
environment_by_name = {}
|
|
1284
|
+
for name in ("GIT_TEMPLATE_DIR", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "HOME", "XDG_CONFIG_HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"):
|
|
1285
|
+
environment_by_name[name] = os.environ.get(name)
|
|
1286
|
+
for name in ("GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"):
|
|
1287
|
+
if environment_by_name[name]:
|
|
1288
|
+
validate_configuration_filename(environment_by_name[name])
|
|
1289
|
+
|
|
1290
|
+
def add_template(directory: str) -> None:
|
|
1291
|
+
"""Inspect copied config paths without changing Git's template selection."""
|
|
1292
|
+
if not directory:
|
|
1293
|
+
return
|
|
1294
|
+
if not os.path.isabs(directory):
|
|
1295
|
+
raise ValueError("Recursive authentication requires an absolute Git template directory. Update GIT_TEMPLATE_DIR or init.templateDir and retry.")
|
|
1296
|
+
for basename in ("config", "config.worktree"):
|
|
1297
|
+
pending_files.append((os.path.join(directory, basename), directory, False))
|
|
1298
|
+
|
|
1299
|
+
if not repository_dirs:
|
|
1300
|
+
return RecursiveTransportConfiguration((), {}, environment_by_name)
|
|
1301
|
+
first_repository_dir = next(iter(repository_dirs))
|
|
1302
|
+
for variable in ("GIT_CONFIG_SYSTEM", "GIT_CONFIG_GLOBAL"):
|
|
1303
|
+
output = yield GitCommand(["-C", first_repository_dir, "var", variable], accepted_codes=(0, 1))
|
|
1304
|
+
for raw_filename in output.splitlines():
|
|
1305
|
+
pending_files.append((validate_configuration_filename(os.fsdecode(raw_filename)), None, True))
|
|
1306
|
+
template_directory = os.environ.get("GIT_TEMPLATE_DIR")
|
|
1307
|
+
if template_directory is not None:
|
|
1308
|
+
add_template(template_directory)
|
|
1309
|
+
else:
|
|
1310
|
+
output = yield GitCommand(["-C", first_repository_dir, "-c", "gitpane.templatedir=" + GIT_DEFAULT_TEMPLATE_DIR,
|
|
1311
|
+
"config", "--path", "--get", "gitpane.templatedir"])
|
|
1312
|
+
add_template(os.fsdecode(output).rstrip("\r\n"))
|
|
1313
|
+
for repository_dir in repository_dirs:
|
|
1314
|
+
output = yield GitCommand(["-C", repository_dir, "config", "--includes", "--null", "--show-scope", "--show-origin", "--name-only", "--list"])
|
|
1315
|
+
fields = iter(output.removesuffix(b"\0").split(b"\0") if output else ())
|
|
1316
|
+
for scope, origin, _key in zip(fields, fields, fields, strict=True):
|
|
1317
|
+
if not origin.startswith(b"file:"):
|
|
1318
|
+
continue
|
|
1319
|
+
filename = os.fsdecode(origin.removeprefix(b"file:"))
|
|
1320
|
+
if not os.path.isabs(filename):
|
|
1321
|
+
filename = os.path.join(repository_dir, filename)
|
|
1322
|
+
pending_files.append((filename, None, scope in (b"system", b"global")))
|
|
1323
|
+
inspected_size = 0
|
|
1324
|
+
while pending_files:
|
|
1325
|
+
filename, template_dir, protected = pending_files.pop()
|
|
1326
|
+
filename = os.path.abspath(filename)
|
|
1327
|
+
if template_dir is not None and os.path.commonpath((canonical_path(template_dir), canonical_path(filename))) != canonical_path(template_dir):
|
|
1328
|
+
raise ValueError("A Git template configuration link escapes its copied directory. Use a regular configuration file and retry.")
|
|
1329
|
+
identity = (canonical_path(filename), canonical_path(template_dir) if template_dir else None, protected)
|
|
1330
|
+
if identity in visited_file_set:
|
|
1331
|
+
continue
|
|
1332
|
+
visited_file_set.add(identity)
|
|
1333
|
+
if len(visited_file_set) > AUTHENTICATION_CONFIG_FILE_LIMIT:
|
|
1334
|
+
raise ValueError("Git authentication configuration exceeds the included-file limit.")
|
|
1335
|
+
signature = configuration_signature(filename)
|
|
1336
|
+
signature_by_filename[filename] = signature
|
|
1337
|
+
if signature is None:
|
|
1338
|
+
continue
|
|
1339
|
+
inspected_size += os.stat(filename).st_size
|
|
1340
|
+
if inspected_size > AUTHENTICATION_CONFIG_LIMIT_BYTES:
|
|
1341
|
+
raise ValueError("Git authentication configuration exceeds the inspection size limit.")
|
|
1342
|
+
output = yield GitCommand(["config", "--file", filename, "--no-includes", "--null", "--name-only", "--get-regexp", RECURSIVE_AUTHENTICATION_KEY_PATTERN], accepted_codes=(0, 1))
|
|
1343
|
+
for raw_key in output.split(b"\0"):
|
|
1344
|
+
if raw_key:
|
|
1345
|
+
key_set.add(validate_text(raw_key.decode("utf-8", "strict")))
|
|
1346
|
+
# Parse every include independently of its condition: the next Git process
|
|
1347
|
+
# may have a different gitdir, branch or remote URL than this inspection.
|
|
1348
|
+
output = yield GitCommand(["config", "--file", filename, "--no-includes", "--null", "--path", "--get-regexp", RECURSIVE_CONFIG_PATH_PATTERN], accepted_codes=(0, 1))
|
|
1349
|
+
for record in output.split(b"\0"):
|
|
1350
|
+
if not record:
|
|
1351
|
+
continue
|
|
1352
|
+
key, raw_path = record.split(b"\n", 1)
|
|
1353
|
+
path = os.fsdecode(raw_path)
|
|
1354
|
+
if key == b"init.templatedir":
|
|
1355
|
+
if protected and template_directory is None:
|
|
1356
|
+
add_template(path)
|
|
1357
|
+
continue
|
|
1358
|
+
if not path:
|
|
1359
|
+
continue
|
|
1360
|
+
included_template_dir = None
|
|
1361
|
+
if not os.path.isabs(path):
|
|
1362
|
+
path = os.path.abspath(os.path.join(os.path.dirname(filename), path))
|
|
1363
|
+
if template_dir is not None:
|
|
1364
|
+
if os.path.commonpath((canonical_path(template_dir), canonical_path(path))) != canonical_path(template_dir):
|
|
1365
|
+
raise ValueError("A relative Git template include escapes its copied directory. Use an absolute include path and retry.")
|
|
1366
|
+
included_template_dir = template_dir
|
|
1367
|
+
pending_files.append((path, included_template_dir, protected))
|
|
1368
|
+
configuration = RecursiveTransportConfiguration(tuple(sorted(key_set)), signature_by_filename, environment_by_name)
|
|
1369
|
+
configuration.verify()
|
|
1370
|
+
return configuration
|
|
1371
|
+
|
|
1372
|
+
def reject(self, selection: AccountSelection, callback: Callable[[AuthOutcome], None], *, git_filename: str = "") -> str:
|
|
1373
|
+
"""Erase only the selected account's private namespace after an explicit rejection."""
|
|
1374
|
+
return self._start(AuthenticationAction.REJECT, selection.provider, selection.origin, callback, selection=selection, git_filename=git_filename)
|
|
1375
|
+
|
|
1376
|
+
def remove_account(self, selection: AccountSelection, callback: Callable[[AuthOutcome], None], *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1377
|
+
"""Erase this account's stored secret, and only its own, then take it off Connected accounts.
|
|
1378
|
+
|
|
1379
|
+
Nothing of any repository is touched: no Git command runs and no repository file is read or written. Once the
|
|
1380
|
+
secret is erased the account is taken off the list even if ``is_current`` no longer accepts the result, so no
|
|
1381
|
+
account stays listed without its secret; if erasing fails the account stays listed and usable.
|
|
1382
|
+
"""
|
|
1383
|
+
return self._start(AuthenticationAction.REMOVE, selection.provider, selection.origin, callback, selection=selection, is_current=is_current)
|
|
1384
|
+
|
|
1385
|
+
def lookup_secrets(self, selections: tuple[AccountSelection, ...] | list[AccountSelection], callback: Callable[[SecretLookup], None], *,
|
|
1386
|
+
is_current: Callable[[], bool] | None = None) -> str:
|
|
1387
|
+
"""Check whether each account's secret is still in the system credential store, and remember the accounts whose secret is gone.
|
|
1388
|
+
|
|
1389
|
+
Git and Git Credential Manager erase an account's secret by themselves when a server refuses it, so an account
|
|
1390
|
+
can stay listed without any secret. The check first finds the credential manager of the current Git, away from
|
|
1391
|
+
the window's thread, then only asks it for each secret, in a worker of its own, never interactively, without
|
|
1392
|
+
contacting any server and without writing or erasing anything; it stops after ``lookup_timeout_ms``. It neither
|
|
1393
|
+
waits for nor blocks a sign-in, a Reconnect or a removal. ``callback`` receives the classification while
|
|
1394
|
+
``is_current`` still accepts it; the remembered accounts change either way. A refused credential manager, or a
|
|
1395
|
+
worker that cannot start, answers through ``callback`` too, every account unknown, and changes no remembered
|
|
1396
|
+
account. Returns the check's operation id, or an empty text when nothing was started (no account, closing).
|
|
1397
|
+
"""
|
|
1398
|
+
requested_namespace_set = set()
|
|
1399
|
+
account_by_key_list: list[dict[str, str]] = []
|
|
1400
|
+
for selection in selections:
|
|
1401
|
+
if selection.namespace in requested_namespace_set:
|
|
1402
|
+
continue
|
|
1403
|
+
requested_namespace_set.add(selection.namespace)
|
|
1404
|
+
account_by_key_list.append(lookup_request(selection))
|
|
1405
|
+
if self.closed or not account_by_key_list:
|
|
1406
|
+
return ""
|
|
1407
|
+
operation_id = uuid.uuid4().hex
|
|
1408
|
+
generation_by_namespace = {}
|
|
1409
|
+
for namespace in requested_namespace_set:
|
|
1410
|
+
generation_by_namespace[namespace] = self.account_generation_by_namespace.get(namespace, 0)
|
|
1411
|
+
data_by_key = {"action": AuthenticationAction.LOOKUP.value, "accounts": account_by_key_list, "lookup_timeout_ms": self.lookup_timeout_ms}
|
|
1412
|
+
self.active_by_id[operation_id] = _AuthenticationRun(lambda _outcome: None, bytearray(), is_current=is_current, action=AuthenticationAction.LOOKUP, lookup_callback=callback,
|
|
1413
|
+
lookup_generation_by_namespace=generation_by_namespace)
|
|
1414
|
+
|
|
1415
|
+
def before_start() -> None:
|
|
1416
|
+
"""A queued check is not worth starting once GitPane is closing."""
|
|
1417
|
+
if self.closed:
|
|
1418
|
+
raise ValueError("Authentication is shutting down")
|
|
1419
|
+
|
|
1420
|
+
def launch(helper: CredentialHelper | None, error: Exception | None) -> None:
|
|
1421
|
+
"""Start the check with the credential manager found, or answer every account unknown on the refusal."""
|
|
1422
|
+
self._launch_exchange(operation_id, data_by_key, helper, error, lambda: self._launch_worker(operation_id, data_by_key, before_start,
|
|
1423
|
+
self.lookup_timeout_ms + SECRET_LOOKUP_STOP_GRACE_MS))
|
|
1424
|
+
|
|
1425
|
+
self.resolve_credential_helper(self.current_git_filename(), launch)
|
|
1426
|
+
return operation_id
|
|
1427
|
+
|
|
1428
|
+
def approve(self, selection: AccountSelection, token: str, callback: Callable[[AuthOutcome], None], *, git_filename: str = "",
|
|
1429
|
+
api_email: str = "", is_current: Callable[[], bool] | None = None, check_url: str = "") -> str:
|
|
1430
|
+
"""Reconnect exactly this account, by browser when the token is empty, and refuse any other account before storing a secret.
|
|
1431
|
+
|
|
1432
|
+
Bitbucket token checks need the account e-mail: an entered one wins, otherwise the one saved with the account is used.
|
|
1433
|
+
A username + password account keeps its old secret unless Git lists the repository ``check_url`` with the new one.
|
|
1434
|
+
``is_current`` lets the requesting form drop a result it can no longer show, so nothing is written for it either.
|
|
1435
|
+
"""
|
|
1436
|
+
bitbucket_email = api_email or selection.api_identity
|
|
1437
|
+
return self._start(AuthenticationAction.APPROVE, selection.provider, selection.origin, callback, token=token, selection=selection,
|
|
1438
|
+
api_email=bitbucket_email if selection.provider == BITBUCKET_PROVIDER else "", git_filename=git_filename, is_current=is_current,
|
|
1439
|
+
check_url=check_url)
|
|
1440
|
+
|
|
1441
|
+
def cancel(self, operation_id: str) -> None:
|
|
1442
|
+
"""Invalidate publication immediately while retaining the process until drainage."""
|
|
1443
|
+
active = self.active_by_id.get(operation_id)
|
|
1444
|
+
if active is not None:
|
|
1445
|
+
active.cancelled = True
|
|
1446
|
+
self.runner.cancel(operation_id)
|
|
1447
|
+
|
|
1448
|
+
def close(self) -> None:
|
|
1449
|
+
"""Stop owned helpers before application exit; no result published afterwards can reach a form.
|
|
1450
|
+
|
|
1451
|
+
A stopped account test still gets its temporary secret copy erased, and a new account's secret stored for a
|
|
1452
|
+
result nobody sees is erased too: the runner stays open until those erases end.
|
|
1453
|
+
"""
|
|
1454
|
+
self.closed = True
|
|
1455
|
+
for operation_id, active in tuple(self.active_by_id.items()):
|
|
1456
|
+
if not active.cleanup:
|
|
1457
|
+
self.cancel(operation_id)
|
|
1458
|
+
if not self._secret_cleanup_pending():
|
|
1459
|
+
self.runner.close()
|
|
1460
|
+
|
|
1461
|
+
def _start(self, action: AuthenticationAction, provider: str, endpoint: str, callback: Callable[[AuthOutcome], None], *, token: str = "", account_hint: str = "", api_email: str = "",
|
|
1462
|
+
selection: AccountSelection | None = None, git_filename: str = "", is_current: Callable[[], bool] | None = None, username: str = "",
|
|
1463
|
+
service_name: str = "", check_url: str = "") -> str:
|
|
1464
|
+
"""Check one credential exchange, find the credential manager for it, then start it; recheck queued work just before it starts.
|
|
1465
|
+
|
|
1466
|
+
Adding or reconnecting a username + password account needs a repository of its server to test the secret on and
|
|
1467
|
+
an absolute Git executable to run the test; both are checked here, before anything starts.
|
|
1468
|
+
An account exchange holds no repository: it only writes GitPane's account list and the system credential store,
|
|
1469
|
+
and only tests a secret against a server address, so no write running on a local repository has any reason to
|
|
1470
|
+
refuse it. The exchange counts as running while its credential manager is searched, away from the window's
|
|
1471
|
+
thread; a refused credential manager ends it with the sentence of the refusal, and nothing is stored.
|
|
1472
|
+
"""
|
|
1473
|
+
operation_id = uuid.uuid4().hex
|
|
1474
|
+
try:
|
|
1475
|
+
if self.closed:
|
|
1476
|
+
raise ValueError("Authentication is shutting down")
|
|
1477
|
+
if self._exchange_running():
|
|
1478
|
+
raise ValueError("Another sign-in is still running or stopping. Wait for it to finish, then try again.")
|
|
1479
|
+
if provider not in ACCOUNT_PROVIDER_SET:
|
|
1480
|
+
raise ValueError("Choose GitHub, GitLab, Bitbucket or a username and password account explicitly")
|
|
1481
|
+
origin = endpoint_origin(endpoint)
|
|
1482
|
+
for value in (token, account_hint, api_email, username, service_name, check_url, git_filename):
|
|
1483
|
+
validate_text(value, allow_empty=True)
|
|
1484
|
+
self._require_account_fields(action, provider, origin, username, token)
|
|
1485
|
+
check_selection = None
|
|
1486
|
+
if provider == GENERIC_PROVIDER and action in REMEMBERED_ACTION_SET:
|
|
1487
|
+
check_url = check_repository_url(check_url, origin)
|
|
1488
|
+
if not os.path.isabs(git_filename):
|
|
1489
|
+
raise ValueError(GIT_MISSING_TEXT)
|
|
1490
|
+
check_selection = selection if selection is not None else generic_account(origin, username)
|
|
1491
|
+
account_hint = account_hint or unquote(urlsplit(endpoint).username or "")
|
|
1492
|
+
validate_text(account_hint, allow_empty=True)
|
|
1493
|
+
except (OSError, ValueError, KeyError) as error:
|
|
1494
|
+
refused_selection = selection if action == AuthenticationAction.REMOVE else None
|
|
1495
|
+
outcome = AuthOutcome(operation_id, OperationStatus.FAILED, selection=refused_selection, diagnostic=Diagnostic("Authentication", "", "", [], str(error)).prepare(),
|
|
1496
|
+
refused_before_start=True)
|
|
1497
|
+
self.outcome_by_id[operation_id] = outcome
|
|
1498
|
+
QTimer.singleShot(0, self, lambda: callback(outcome) if not self.closed and (is_current is None or is_current()) else None)
|
|
1499
|
+
return operation_id
|
|
1500
|
+
data_by_key = {"action": action.value, "provider": provider, "origin": origin, "token": token, "account_hint": account_hint, "api_email": api_email, "username": username,
|
|
1501
|
+
"validation_origin": self.validation_origin, "selection": asdict(selection) if selection else None,
|
|
1502
|
+
"check_url": check_url, "git_filename": git_filename, "check_timeout_ms": self.check_timeout_ms, "browser_timeout_ms": self.browser_timeout_ms}
|
|
1503
|
+
# A browser sign-in is stopped by its worker at the end of the browser wait; the supervisor only steps in
|
|
1504
|
+
# if the worker itself no longer answers, a little later.
|
|
1505
|
+
browser_sign_in = is_browser_sign_in(action, provider, token)
|
|
1506
|
+
run_timeout_ms = self.browser_timeout_ms + BROWSER_SIGN_IN_STOP_GRACE_MS if browser_sign_in else self.timeout_ms
|
|
1507
|
+
sign_in_method = exchange_sign_in_method(provider, action, token) if action in REMEMBERED_ACTION_SET else None
|
|
1508
|
+
self.active_by_id[operation_id] = _AuthenticationRun(callback, bytearray(), is_current=is_current, action=action, service_name=service_name, selection=selection,
|
|
1509
|
+
check_selection=check_selection, sign_in_method=sign_in_method)
|
|
1510
|
+
|
|
1511
|
+
def before_start() -> None:
|
|
1512
|
+
"""A queued helper no longer starts once GitPane is closing or the window that asked for it no longer waits for its result."""
|
|
1513
|
+
if self.closed or is_current is not None and not is_current():
|
|
1514
|
+
raise ValueError("Authentication is no longer authorized")
|
|
1515
|
+
|
|
1516
|
+
def launch(helper: CredentialHelper | None, error: Exception | None) -> None:
|
|
1517
|
+
"""Start the worker with the credential manager found, or end the exchange on the refusal."""
|
|
1518
|
+
self._launch_exchange(operation_id, data_by_key, helper, error, lambda: self._launch_worker(operation_id, data_by_key, before_start, run_timeout_ms,
|
|
1519
|
+
browser_sign_in=browser_sign_in))
|
|
1520
|
+
|
|
1521
|
+
self.resolve_credential_helper(git_filename or self.current_git_filename(), launch)
|
|
1522
|
+
return operation_id
|
|
1523
|
+
|
|
1524
|
+
def _launch_exchange(self, operation_id: str, data_by_key: dict[str, object], helper: CredentialHelper | None, error: Exception | None,
|
|
1525
|
+
start_worker: Callable[[], None]) -> None:
|
|
1526
|
+
"""Start an exchange's worker once its credential manager is known, or end the exchange without any worker.
|
|
1527
|
+
|
|
1528
|
+
An exchange cancelled during the search, or one whose service closed meanwhile, starts nothing; neither does one
|
|
1529
|
+
whose credential manager was refused, whose sentence becomes the exchange's failure. A cleanup erase alone still
|
|
1530
|
+
starts while GitPane closes, so a secret left behind is not kept. The private request learns which helper to
|
|
1531
|
+
start and the folder of Git's programs the helper needs.
|
|
1532
|
+
"""
|
|
1533
|
+
active = self.active_by_id.get(operation_id)
|
|
1534
|
+
if active is None:
|
|
1535
|
+
return
|
|
1536
|
+
if error is None and (active.cancelled or self.closed and not active.cleanup):
|
|
1537
|
+
error = ValueError("Authentication is no longer authorized")
|
|
1538
|
+
if error is not None:
|
|
1539
|
+
refusal = error.detection if isinstance(error, CredentialManagerRefusal) else None
|
|
1540
|
+
self._end_without_worker(operation_id, str(error), credential_manager_refusal=refusal)
|
|
1541
|
+
return
|
|
1542
|
+
data_by_key["helper_commands"] = list(helper.commands)
|
|
1543
|
+
data_by_key[GIT_EXEC_PATH_KEY] = helper.exec_path
|
|
1544
|
+
try:
|
|
1545
|
+
start_worker()
|
|
1546
|
+
except (OSError, ValueError, KeyError) as launch_error:
|
|
1547
|
+
self._end_without_worker(operation_id, str(launch_error))
|
|
1548
|
+
|
|
1549
|
+
def _end_without_worker(self, operation_id: str, cause: str, *, credential_manager_refusal: CredentialManagerDetection | None = None) -> None:
|
|
1550
|
+
"""End an exchange that never got a worker: drop it, publish its failure with ``cause`` when still wanted, and let a closing GitPane go on.
|
|
1551
|
+
|
|
1552
|
+
Nothing ran, so nothing was stored or erased: a removal keeps its account and a check classifies every account
|
|
1553
|
+
as unknown. A removal refused before any worker still carries its account, as every removal result does. No
|
|
1554
|
+
worker means the runner never says it is idle for this exchange, so the service says it here. A
|
|
1555
|
+
``credential_manager_refusal`` is carried by the failure the exchange publishes, so a sign-in or a removal can
|
|
1556
|
+
open the install window; the lookup of stored secrets, which Settings starts by itself, never carries it.
|
|
1557
|
+
"""
|
|
1558
|
+
active = self.active_by_id.pop(operation_id, None)
|
|
1559
|
+
if active is None:
|
|
1560
|
+
return
|
|
1561
|
+
publish = not active.cancelled and not self.closed and (active.is_current is None or active.is_current())
|
|
1562
|
+
if active.action == AuthenticationAction.LOOKUP:
|
|
1563
|
+
lookup = lookup_result(None, frozenset(active.lookup_generation_by_namespace))
|
|
1564
|
+
if publish and active.lookup_callback is not None:
|
|
1565
|
+
QTimer.singleShot(0, self, lambda: active.lookup_callback(lookup) if not self.closed and (active.is_current is None or active.is_current()) else None)
|
|
1566
|
+
elif not active.cleanup:
|
|
1567
|
+
selection = active.selection if active.action == AuthenticationAction.REMOVE else None
|
|
1568
|
+
outcome = AuthOutcome(operation_id, OperationStatus.FAILED, selection=selection, diagnostic=Diagnostic("Authentication", "", "", [], cause).prepare(), refused_before_start=True,
|
|
1569
|
+
credential_manager_refusal=credential_manager_refusal)
|
|
1570
|
+
self.outcome_by_id[operation_id] = outcome
|
|
1571
|
+
if publish:
|
|
1572
|
+
QTimer.singleShot(0, self, lambda: active.callback(outcome) if not self.closed and (active.is_current is None or active.is_current()) else None)
|
|
1573
|
+
if self.closed and not self.runner.closed and not self._secret_cleanup_pending():
|
|
1574
|
+
self.runner.close()
|
|
1575
|
+
if not self.active_by_id:
|
|
1576
|
+
self.idle.emit()
|
|
1577
|
+
|
|
1578
|
+
def _launch_worker(self, operation_id: str, data_by_key: dict[str, object], before_start: Callable[[], None], timeout_ms: int, *, browser_sign_in: bool = False) -> None:
|
|
1579
|
+
"""Start the worker of one exchange on the command ``account_worker_command`` builds, handing it its private request on stdin only; ``timeout_ms`` bounds the whole exchange.
|
|
1580
|
+
|
|
1581
|
+
Only a ``browser_sign_in`` worker gets a process group its helper may leave; any other worker's group is closed.
|
|
1582
|
+
"""
|
|
1583
|
+
worker_filename = authentication_worker_filename()
|
|
1584
|
+
program_filename, arguments = account_worker_command(worker_filename)
|
|
1585
|
+
self.runner.start(ProcessRequest(operation_id, program_filename, arguments, os.path.dirname(worker_filename), timeout_ms,
|
|
1586
|
+
session_id=operation_id, stdin=json.dumps(data_by_key, ensure_ascii=False).encode("utf-8"), profile=CREDENTIAL_PROFILE, operation_id=operation_id,
|
|
1587
|
+
operation="Authentication", idle_timeout_ms=AUTHENTICATION_IDLE_TIMEOUT_MS, before_start=before_start,
|
|
1588
|
+
process_group_factory=breakaway_process_group if browser_sign_in else None))
|
|
1589
|
+
|
|
1590
|
+
def _erase_left_secret(self, selection: AccountSelection, *, account_secret: bool = False) -> None:
|
|
1591
|
+
"""Erase a secret an exchange may have left behind: the temporary copy of an account test, or with ``account_secret`` the secret of a new account nobody listed.
|
|
1592
|
+
|
|
1593
|
+
The account's own secret is only erased for an account missing from the saved list, whose secret nothing else
|
|
1594
|
+
uses; for a username + password account the test copy goes with it. It runs even while GitPane is closing, and
|
|
1595
|
+
nobody waits for its result: a test copy it cannot erase either is erased by the next test of the same account
|
|
1596
|
+
or by the account's removal. Nothing but that account's namespaces is touched. The erase counts as pending while
|
|
1597
|
+
the credential manager of the current Git is searched; a refused credential manager drops it silently.
|
|
1598
|
+
"""
|
|
1599
|
+
operation_id = uuid.uuid4().hex
|
|
1600
|
+
action = AuthenticationAction.REJECT if account_secret else AuthenticationAction.ERASE_CHECK
|
|
1601
|
+
data_by_key = {"action": action.value, "provider": selection.provider, "origin": selection.origin, "token": "", "account_hint": "", "api_email": "",
|
|
1602
|
+
"username": "", "validation_origin": self.validation_origin, "selection": asdict(selection)}
|
|
1603
|
+
self.active_by_id[operation_id] = _AuthenticationRun(lambda _outcome: None, bytearray(), action=action, selection=selection, cleanup=True)
|
|
1604
|
+
|
|
1605
|
+
def launch(helper: CredentialHelper | None, error: Exception | None) -> None:
|
|
1606
|
+
"""Start the erase with the credential manager found, or drop it on the refusal."""
|
|
1607
|
+
self._launch_exchange(operation_id, data_by_key, helper, error, lambda: self._launch_worker(operation_id, data_by_key, lambda: None, self.erase_timeout_ms))
|
|
1608
|
+
|
|
1609
|
+
self.resolve_credential_helper(self.current_git_filename(), launch)
|
|
1610
|
+
|
|
1611
|
+
def _account_listed(self, namespace: str) -> bool:
|
|
1612
|
+
"""Tell whether an account with this namespace is on the saved list; an unreadable list counts as listing it, so no secret is erased on a guess."""
|
|
1613
|
+
try:
|
|
1614
|
+
accounts = self._read()["accounts"]
|
|
1615
|
+
for account_by_key in accounts:
|
|
1616
|
+
if account_by_key["namespace"] == namespace:
|
|
1617
|
+
return True
|
|
1618
|
+
except (ValueError, KeyError, TypeError):
|
|
1619
|
+
return True
|
|
1620
|
+
return False
|
|
1621
|
+
|
|
1622
|
+
def _exchange_running(self) -> bool:
|
|
1623
|
+
"""Tell whether an exchange other than a check of the stored secrets is still running; such a check never blocks a sign-in."""
|
|
1624
|
+
for active in self.active_by_id.values():
|
|
1625
|
+
if active.action != AuthenticationAction.LOOKUP:
|
|
1626
|
+
return True
|
|
1627
|
+
return False
|
|
1628
|
+
|
|
1629
|
+
def _secret_cleanup_pending(self) -> bool:
|
|
1630
|
+
"""Tell whether an account test, or an erase of a secret an exchange left behind, is still running."""
|
|
1631
|
+
for active in self.active_by_id.values():
|
|
1632
|
+
if active.check_selection is not None or active.cleanup:
|
|
1633
|
+
return True
|
|
1634
|
+
return False
|
|
1635
|
+
|
|
1636
|
+
def _consume(self, operation_id: str, content: bytes) -> None:
|
|
1637
|
+
active = self.active_by_id.get(operation_id)
|
|
1638
|
+
if active is not None:
|
|
1639
|
+
active.output.extend(content)
|
|
1640
|
+
|
|
1641
|
+
def _finished(self, result: ProcessResult) -> None:
|
|
1642
|
+
"""Publish a finished exchange and, once its secret is stored, remember the account in the durable list.
|
|
1643
|
+
|
|
1644
|
+
This runs as a Qt slot. An account test whose worker did not finish on its own (cancelled, timed out, crashed),
|
|
1645
|
+
or did not report its temporary secret copy erased, may have left that copy behind, so it is erased next; when
|
|
1646
|
+
that stopped test was adding a new account, the secret it may already have stored for the account is erased
|
|
1647
|
+
with it. The runner of a closing service closes once no such erase is left.
|
|
1648
|
+
"""
|
|
1649
|
+
operation_id = result.request.request_id
|
|
1650
|
+
active = self.active_by_id.pop(operation_id, None)
|
|
1651
|
+
if active is None:
|
|
1652
|
+
return
|
|
1653
|
+
publish = not active.cancelled and not self.closed and (active.is_current is None or active.is_current())
|
|
1654
|
+
check_selection = active.check_selection
|
|
1655
|
+
if check_selection is not None and (result.status != ProcessStatus.SUCCESS or not check_copy_erased(active.output)):
|
|
1656
|
+
stopped_new_account = result.status != ProcessStatus.SUCCESS and active.action == AuthenticationAction.SIGN_IN and not self._account_listed(check_selection.namespace)
|
|
1657
|
+
self._erase_left_secret(check_selection, account_secret=stopped_new_account)
|
|
1658
|
+
if active.action == AuthenticationAction.LOOKUP:
|
|
1659
|
+
self._finish_lookup(active, result, publish)
|
|
1660
|
+
else:
|
|
1661
|
+
self._finish_exchange(active, result, publish)
|
|
1662
|
+
if self.closed and not self.runner.closed and not self._secret_cleanup_pending():
|
|
1663
|
+
self.runner.close()
|
|
1664
|
+
|
|
1665
|
+
def _finish_exchange(self, active: _AuthenticationRun, result: ProcessResult, publish: bool) -> None:
|
|
1666
|
+
"""Read the result of an exchange other than a check of the stored secrets, keep the saved list in step with the secure store, and publish it.
|
|
1667
|
+
|
|
1668
|
+
A failed list write is reported as a partial result instead of escaping, so the requesting form always receives an
|
|
1669
|
+
outcome. A secret stored for a result that can no longer be shown (cancelled, window closed, GitPane closing)
|
|
1670
|
+
still keeps the list in step: an account already listed is refreshed, and a new account's secret is erased, so a
|
|
1671
|
+
cancelled sign-in leaves no secret behind that Connected accounts would not show.
|
|
1672
|
+
"""
|
|
1673
|
+
operation_id = result.request.request_id
|
|
1674
|
+
selection = None
|
|
1675
|
+
try:
|
|
1676
|
+
data_by_key = json.loads(active.output) if active.output else {}
|
|
1677
|
+
selection = AccountSelection(**data_by_key["selection"]) if data_by_key.get("selection") else None
|
|
1678
|
+
diagnostic = Diagnostic("Authentication", "", "", [], data_by_key.get("error") or result.cause, stderr=result.stderr).prepare()
|
|
1679
|
+
status = OperationStatus(data_by_key.get("status", OperationStatus.FAILED.value)) if result.status == ProcessStatus.SUCCESS else operation_status(result.status)
|
|
1680
|
+
outcome = AuthOutcome(operation_id, status, selection, bool(data_by_key.get("authenticated")), bool(data_by_key.get("stored")), diagnostic if status != OperationStatus.SUCCESS else None,
|
|
1681
|
+
check=account_check(data_by_key.get("check")), refused_by_gitpane=data_by_key.get(GITPANE_REFUSAL_KEY) is True)
|
|
1682
|
+
except (ValueError, TypeError, KeyError):
|
|
1683
|
+
selection = None
|
|
1684
|
+
outcome = AuthOutcome(operation_id, OperationStatus.FAILED, diagnostic=Diagnostic("Authentication", "", "", [], "The credential helper returned an invalid response").prepare())
|
|
1685
|
+
finally:
|
|
1686
|
+
active.output.clear()
|
|
1687
|
+
if active.action == AuthenticationAction.REMOVE and active.selection is not None:
|
|
1688
|
+
outcome = self._finish_removal(active.selection, outcome)
|
|
1689
|
+
elif selection is not None and outcome.stored and active.action in REMEMBERED_ACTION_SET and not publish and not self._account_listed(selection.namespace):
|
|
1690
|
+
self._erase_left_secret(selection, account_secret=True)
|
|
1691
|
+
elif selection is not None and outcome.stored and active.action in REMEMBERED_ACTION_SET:
|
|
1692
|
+
try:
|
|
1693
|
+
change = self.remember_account(selection, active.service_name, sign_in_method=active.sign_in_method)
|
|
1694
|
+
outcome = replace(outcome, renewed=change.renewed, became_default=change.became_default)
|
|
1695
|
+
except (OSError, ValueError, TypeError) as error:
|
|
1696
|
+
outcome = AuthOutcome(operation_id, OperationStatus.PARTIAL, selection, authenticated=outcome.authenticated, stored=True, diagnostic=Diagnostic("Authentication", "", "", [], str(error)).prepare())
|
|
1697
|
+
self.outcome_by_id[operation_id] = outcome
|
|
1698
|
+
if publish:
|
|
1699
|
+
active.callback(outcome)
|
|
1700
|
+
|
|
1701
|
+
def _finish_lookup(self, active: _AuthenticationRun, result: ProcessResult, publish: bool) -> None:
|
|
1702
|
+
"""Remember the accounts a finished check found without secret, forget those it found with one, and publish the classification.
|
|
1703
|
+
|
|
1704
|
+
A stopped or failed check changes nothing and classifies every account as unknown. An account saved again or
|
|
1705
|
+
removed after the check started keeps its current state: the missing secret belonged to the old sign-in.
|
|
1706
|
+
"""
|
|
1707
|
+
requested_namespace_set = frozenset(active.lookup_generation_by_namespace)
|
|
1708
|
+
value = None
|
|
1709
|
+
if result.status == ProcessStatus.SUCCESS and not active.cancelled:
|
|
1710
|
+
try:
|
|
1711
|
+
value = json.loads(active.output) if active.output else None
|
|
1712
|
+
except ValueError:
|
|
1713
|
+
value = None
|
|
1714
|
+
active.output.clear()
|
|
1715
|
+
lookup = lookup_result(value, requested_namespace_set)
|
|
1716
|
+
stale_namespace_set = set()
|
|
1717
|
+
for namespace in lookup.missing_namespace_set:
|
|
1718
|
+
if self.account_generation_by_namespace.get(namespace, 0) != active.lookup_generation_by_namespace[namespace]:
|
|
1719
|
+
stale_namespace_set.add(namespace)
|
|
1720
|
+
continue
|
|
1721
|
+
self.missing_secret_namespace_set.add(namespace)
|
|
1722
|
+
for namespace in lookup.present_namespace_set:
|
|
1723
|
+
self.missing_secret_namespace_set.discard(namespace)
|
|
1724
|
+
if stale_namespace_set:
|
|
1725
|
+
lookup = SecretLookup(lookup.missing_namespace_set - stale_namespace_set, lookup.present_namespace_set, lookup.unknown_namespace_set | stale_namespace_set)
|
|
1726
|
+
if publish and active.lookup_callback is not None:
|
|
1727
|
+
active.lookup_callback(lookup)
|
|
1728
|
+
|
|
1729
|
+
def _finish_removal(self, selection: AccountSelection, outcome: AuthOutcome) -> AuthOutcome:
|
|
1730
|
+
"""Take the account off the list once its secret was really erased, whether or not the result can still be shown.
|
|
1731
|
+
|
|
1732
|
+
The person already confirmed the removal, and an account listed without its secret would be worse than a finished
|
|
1733
|
+
removal nobody sees. The removed account is attached to every result, so a failure can name the step that failed.
|
|
1734
|
+
A list that cannot be saved after the erase gives a partial result.
|
|
1735
|
+
"""
|
|
1736
|
+
if outcome.status != OperationStatus.SUCCESS:
|
|
1737
|
+
return replace(outcome, selection=selection)
|
|
1738
|
+
try:
|
|
1739
|
+
removal = self.forget_account(selection)
|
|
1740
|
+
except (OSError, ValueError, TypeError) as error:
|
|
1741
|
+
return AuthOutcome(outcome.operation_id, OperationStatus.PARTIAL, selection, diagnostic=Diagnostic("Remove account", "", "", [], str(error)).prepare())
|
|
1742
|
+
return replace(outcome, selection=selection, removal=removal)
|
|
1743
|
+
|
|
1744
|
+
def _read(self) -> dict[str, object]:
|
|
1745
|
+
"""Read the saved accounts, defaults and repository choices to show or use them."""
|
|
1746
|
+
return json.loads(self.preference.read(AUTHENTICATION_PREFERENCE_KEY))
|
|
1747
|
+
|
|
1748
|
+
def _read_for_write(self) -> dict[str, object]:
|
|
1749
|
+
"""Read the saved account data as stored right now, to build a write on it.
|
|
1750
|
+
|
|
1751
|
+
Every write replaces the whole list, so it must start from what is stored now, never from a copy kept in memory:
|
|
1752
|
+
another window or GitPane process may have removed or added an account meanwhile. When the stored list cannot
|
|
1753
|
+
be read, nothing is written.
|
|
1754
|
+
"""
|
|
1755
|
+
try:
|
|
1756
|
+
return json.loads(self.preference.read_saved(AUTHENTICATION_PREFERENCE_KEY))
|
|
1757
|
+
except ValueError as error:
|
|
1758
|
+
raise ValueError(ACCOUNT_LIST_UNREADABLE_TEXT) from error
|
|
1759
|
+
|
|
1760
|
+
def _read_sign_in_methods(self, *, for_write: bool = False) -> dict[str, SignInMethod]:
|
|
1761
|
+
"""Read the recorded sign-in method of each account, by namespace; ``for_write`` reads it as stored right now, as ``_read_for_write`` does."""
|
|
1762
|
+
if not for_write:
|
|
1763
|
+
return read_sign_in_method_json(self.preference.read(ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY))
|
|
1764
|
+
try:
|
|
1765
|
+
return read_sign_in_method_json(self.preference.read_saved(ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY))
|
|
1766
|
+
except ValueError as error:
|
|
1767
|
+
raise ValueError(ACCOUNT_LIST_UNREADABLE_TEXT) from error
|
|
1768
|
+
|
|
1769
|
+
def _require_account_fields(self, action: AuthenticationAction, provider: str, origin: str, username: str, token: str) -> None:
|
|
1770
|
+
"""Refuse an exchange missing what its account kind needs, before any child starts.
|
|
1771
|
+
|
|
1772
|
+
A generic account is added with a username and a password or token and reconnected with a new password only: its
|
|
1773
|
+
username never changes. A service account keeps the identity its provider verifies, so a typed username is refused.
|
|
1774
|
+
A new generic account is refused on GitHub, gitlab.com and Bitbucket Cloud, whose Git refuses account passwords;
|
|
1775
|
+
one saved there earlier stays usable and can still be reconnected.
|
|
1776
|
+
"""
|
|
1777
|
+
if provider != GENERIC_PROVIDER:
|
|
1778
|
+
if username:
|
|
1779
|
+
raise ValueError("A username is only entered for a username and password account.")
|
|
1780
|
+
return
|
|
1781
|
+
if action == AuthenticationAction.SIGN_IN and provider_for_browser_origin(origin) is not None:
|
|
1782
|
+
raise ValueError(origin_address(origin) + CLOUD_PASSWORD_REFUSAL_TEXT)
|
|
1783
|
+
if action == AuthenticationAction.SIGN_IN and (not username or not token):
|
|
1784
|
+
raise ValueError("Enter a username and a password or token.")
|
|
1785
|
+
if action == AuthenticationAction.APPROVE and not token:
|
|
1786
|
+
raise ValueError("Enter a username and a password or token.")
|
|
1787
|
+
|
|
1788
|
+
def _scope_key(self, origin: str, context: RepositoryContext) -> str:
|
|
1789
|
+
return json.dumps([canonical_path(context.common_git_dir), origin], ensure_ascii=False)
|
|
1790
|
+
|
|
1791
|
+
def _require_connected_account(self, data_by_key: dict[str, object], origin: str, namespace: str | None) -> None:
|
|
1792
|
+
"""Refuse a choice that is empty or names no connected account of exactly this service address."""
|
|
1793
|
+
if namespace and endpoint_origin(origin) == origin:
|
|
1794
|
+
for account_by_key in data_by_key["accounts"]:
|
|
1795
|
+
if account_by_key["namespace"] == namespace and account_by_key["origin"] == origin:
|
|
1796
|
+
return
|
|
1797
|
+
raise ValueError("Choose a connected account for " + origin_address(origin) + " before saving.")
|
|
1798
|
+
|
|
1799
|
+
def _decode_scope_key(self, scope_key: str) -> tuple[str, str] | None:
|
|
1800
|
+
"""Split a repository choice key back into its repository directory and origin; a key not built by _scope_key gives None."""
|
|
1801
|
+
scope_part_count = 2
|
|
1802
|
+
try:
|
|
1803
|
+
scope = json.loads(scope_key)
|
|
1804
|
+
except ValueError:
|
|
1805
|
+
return None
|
|
1806
|
+
if not isinstance(scope, list) or len(scope) != scope_part_count:
|
|
1807
|
+
return None
|
|
1808
|
+
repository_dir, origin = scope
|
|
1809
|
+
if not isinstance(repository_dir, str) or not isinstance(origin, str):
|
|
1810
|
+
return None
|
|
1811
|
+
return repository_dir, origin
|