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,135 @@
|
|
|
1
|
+
"""Read, without changing anything, what the session bus tells of its Secret Service.
|
|
2
|
+
|
|
3
|
+
Four questions only, always in this order, and the read stops at the first answer that decides: the bus daemon is
|
|
4
|
+
asked whether anyone owns the Secret Service name, then which process owns it, then the service which object its
|
|
5
|
+
``default`` alias names, then that object whether it is locked. The owning process is turned into a program name
|
|
6
|
+
through /proc, which tells which keyring answers. Asking the bus daemon first, and refusing D-Bus activation on every
|
|
7
|
+
message, keeps a Secret Service that does not run from being started by this read; nothing is created, aliased or
|
|
8
|
+
unlocked, and no secret is ever read. Each call is bounded, and the read blocks, so it belongs on a worker thread only.
|
|
9
|
+
|
|
10
|
+
Only Linux reads, through a table indexed by the system; the connection is received through a factory, so the tests
|
|
11
|
+
read a private bus and only ``read_session_keyring_state`` reaches the user's session bus.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from PySide6.QtDBus import QDBus, QDBusConnection, QDBusMessage, QDBusObjectPath, QDBusVariant
|
|
20
|
+
|
|
21
|
+
from gitpane.account.keyring_state import NO_DEFAULT_COLLECTION_PATH, UNKNOWN_KEYRING_READING, KeyringReading, SecretServiceAnswers, keyring_reading
|
|
22
|
+
from gitpane.config import KEYRING_READ_TIMEOUT_MS, LINUX_PLATFORM
|
|
23
|
+
from gitpane.system.process_program import program_name_of_process
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Callable, Sequence
|
|
27
|
+
|
|
28
|
+
ConnectionFactory = Callable[[], QDBusConnection]
|
|
29
|
+
|
|
30
|
+
SECRET_SERVICE_NAME = "org.freedesktop.secrets"
|
|
31
|
+
SECRET_SERVICE_PATH = "/org/freedesktop/secrets"
|
|
32
|
+
SECRET_SERVICE_INTERFACE = "org.freedesktop.Secret.Service"
|
|
33
|
+
SECRET_COLLECTION_INTERFACE = "org.freedesktop.Secret.Collection"
|
|
34
|
+
DBUS_SERVICE_NAME = "org.freedesktop.DBus"
|
|
35
|
+
DBUS_PATH = "/org/freedesktop/DBus"
|
|
36
|
+
DBUS_INTERFACE = "org.freedesktop.DBus"
|
|
37
|
+
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
|
|
38
|
+
NAME_HAS_OWNER_METHOD = "NameHasOwner"
|
|
39
|
+
GET_CONNECTION_UNIX_PROCESS_ID_METHOD = "GetConnectionUnixProcessID"
|
|
40
|
+
READ_ALIAS_METHOD = "ReadAlias"
|
|
41
|
+
GET_PROPERTY_METHOD = "Get"
|
|
42
|
+
DEFAULT_ALIAS = "default"
|
|
43
|
+
LOCKED_PROPERTY = "Locked"
|
|
44
|
+
# The only methods this read ever calls: each one reads, none changes the keyring or returns a secret.
|
|
45
|
+
READ_ONLY_METHOD_NAME_SET = frozenset({NAME_HAS_OWNER_METHOD, GET_CONNECTION_UNIX_PROCESS_ID_METHOD, READ_ALIAS_METHOD, GET_PROPERTY_METHOD})
|
|
46
|
+
# Each of the four replies carries exactly one value.
|
|
47
|
+
REPLY_ARGUMENT_COUNT = 1
|
|
48
|
+
REPLY_VALUE_INDEX = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_only_call(connection: QDBusConnection, service: str, path: str, interface: str, method: str, arguments: Sequence[object], timeout_ms: int) -> QDBusMessage:
|
|
52
|
+
"""Send one method call that may not start its service, wait at most ``timeout_ms`` for the reply, and return it.
|
|
53
|
+
|
|
54
|
+
D-Bus activation is refused on the message, so a Secret Service that is installed but not running is never
|
|
55
|
+
started by GitPane. A reply that does not come in time comes back as an error message.
|
|
56
|
+
"""
|
|
57
|
+
message = QDBusMessage.createMethodCall(service, path, interface, method)
|
|
58
|
+
message.setArguments(list(arguments))
|
|
59
|
+
message.setAutoStartService(False)
|
|
60
|
+
return connection.call(message, QDBus.CallMode.Block, timeout_ms)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def single_reply_value(reply: QDBusMessage) -> object:
|
|
64
|
+
"""Return the one value a successful reply carries, or None for an error or a reply of another shape."""
|
|
65
|
+
if reply.type() != QDBusMessage.MessageType.ReplyMessage:
|
|
66
|
+
return None
|
|
67
|
+
values = reply.arguments()
|
|
68
|
+
if len(values) != REPLY_ARGUMENT_COUNT:
|
|
69
|
+
return None
|
|
70
|
+
return values[REPLY_VALUE_INDEX]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_secret_service_answers(connection: QDBusConnection, timeout_ms: int, read_program_name: Callable[[int], str] = program_name_of_process) -> SecretServiceAnswers:
|
|
74
|
+
"""Ask the four questions in order and keep the answers, stopping at the first one that decides.
|
|
75
|
+
|
|
76
|
+
No owner stops before anything else is asked. The owner's process number is asked of the bus daemon and passed to
|
|
77
|
+
``read_program_name``; a number that cannot be read leaves the program name empty and the read goes on, since the
|
|
78
|
+
state of the collection does not depend on it. An alias that names no collection, or an empty one, stops before
|
|
79
|
+
the property read. Each answer is kept only when it has the type the question expects (a boolean, an integer, an
|
|
80
|
+
object path, a variant), and the aliased path is passed on exactly as the service wrote it.
|
|
81
|
+
"""
|
|
82
|
+
owner = single_reply_value(read_only_call(connection, DBUS_SERVICE_NAME, DBUS_PATH, DBUS_INTERFACE, NAME_HAS_OWNER_METHOD, (SECRET_SERVICE_NAME,), timeout_ms))
|
|
83
|
+
if type(owner) is not bool:
|
|
84
|
+
return SecretServiceAnswers()
|
|
85
|
+
if not owner:
|
|
86
|
+
return SecretServiceAnswers(owner_present=False)
|
|
87
|
+
pid = single_reply_value(read_only_call(connection, DBUS_SERVICE_NAME, DBUS_PATH, DBUS_INTERFACE, GET_CONNECTION_UNIX_PROCESS_ID_METHOD, (SECRET_SERVICE_NAME,), timeout_ms))
|
|
88
|
+
program = read_program_name(pid) if type(pid) is int else ""
|
|
89
|
+
alias = single_reply_value(read_only_call(connection, SECRET_SERVICE_NAME, SECRET_SERVICE_PATH, SECRET_SERVICE_INTERFACE, READ_ALIAS_METHOD, (DEFAULT_ALIAS,), timeout_ms))
|
|
90
|
+
if type(alias) is not QDBusObjectPath:
|
|
91
|
+
return SecretServiceAnswers(owner_present=True, owner_program=program)
|
|
92
|
+
alias_path = alias.path()
|
|
93
|
+
if not alias_path or alias_path == NO_DEFAULT_COLLECTION_PATH:
|
|
94
|
+
return SecretServiceAnswers(owner_present=True, alias_path=alias_path, owner_program=program)
|
|
95
|
+
locked_reply = read_only_call(connection, SECRET_SERVICE_NAME, alias_path, PROPERTIES_INTERFACE, GET_PROPERTY_METHOD, (SECRET_COLLECTION_INTERFACE, LOCKED_PROPERTY), timeout_ms)
|
|
96
|
+
if locked_reply.type() == QDBusMessage.MessageType.ErrorMessage:
|
|
97
|
+
return SecretServiceAnswers(owner_present=True, alias_path=alias_path, locked_error_name=locked_reply.errorName(), owner_program=program)
|
|
98
|
+
locked = single_reply_value(locked_reply)
|
|
99
|
+
if type(locked) is not QDBusVariant:
|
|
100
|
+
return SecretServiceAnswers(owner_present=True, alias_path=alias_path, owner_program=program)
|
|
101
|
+
return SecretServiceAnswers(owner_present=True, alias_path=alias_path, locked=locked.variant(), owner_program=program)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_linux_keyring_state(connect: ConnectionFactory, timeout_ms: int, read_program_name: Callable[[int], str] = program_name_of_process) -> KeyringReading:
|
|
105
|
+
"""Read the state of the default collection and its owning program on the bus ``connect`` opens; a bus that cannot be reached is UNKNOWN, with no call."""
|
|
106
|
+
connection = connect()
|
|
107
|
+
if not connection.isConnected():
|
|
108
|
+
return UNKNOWN_KEYRING_READING
|
|
109
|
+
return keyring_reading(read_secret_service_answers(connection, timeout_ms, read_program_name))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# The systems whose keyring GitPane can read without changing it. Any other system reads as UNKNOWN without opening a
|
|
113
|
+
# connection: macOS and Windows keep their credentials elsewhere, and GitPane says nothing it does not know.
|
|
114
|
+
KEYRING_READ_BY_PLATFORM = {
|
|
115
|
+
LINUX_PLATFORM: read_linux_keyring_state,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_keyring_state(platform: str, connect: ConnectionFactory, timeout_ms: int = KEYRING_READ_TIMEOUT_MS) -> KeyringReading:
|
|
120
|
+
"""Read the state of the default keyring collection on ``platform``, and the program that holds it, through the bus ``connect`` opens.
|
|
121
|
+
|
|
122
|
+
A system missing from the table gives UNKNOWN and never calls ``connect``.
|
|
123
|
+
"""
|
|
124
|
+
read = KEYRING_READ_BY_PLATFORM.get(platform)
|
|
125
|
+
if read is None:
|
|
126
|
+
return UNKNOWN_KEYRING_READING
|
|
127
|
+
return read(connect, timeout_ms)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_session_keyring_state() -> KeyringReading:
|
|
131
|
+
"""Read the state of the default keyring collection of the running session, and the program that holds it.
|
|
132
|
+
|
|
133
|
+
Blocks for up to four times the read bound when the service does not answer, so it runs only on a worker thread.
|
|
134
|
+
"""
|
|
135
|
+
return read_keyring_state(sys.platform, QDBusConnection.sessionBus)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Classify the answers of a read-only Secret Service read, without any D-Bus.
|
|
2
|
+
|
|
3
|
+
The read asks four questions in turn: whether anyone owns the Secret Service name on the bus, which process owns it,
|
|
4
|
+
which object the ``default`` alias names, and whether that object is locked. The answers are classified here, so every
|
|
5
|
+
case can be checked from any system. An alias that names an object absent from the bus, the case where the GNOME
|
|
6
|
+
keyring cannot store anything, is told apart from an alias that names no collection at all, where the credential
|
|
7
|
+
manager's library asks the desktop to create one, and both from a state GitPane could not read; any answer of an
|
|
8
|
+
unexpected shape stays unknown, which gives no sentence rather than a wrong one.
|
|
9
|
+
|
|
10
|
+
The program that owns the name says which keyring answers: KDE Wallet, the GNOME keyring, KeePassXC or another one. It
|
|
11
|
+
only chooses the words said to the user, except for one answer already seen with the GNOME keyring, which stops a
|
|
12
|
+
sign-in before anything is sent; the same answer from any other program lets the sign-in go.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class KeyringState(Enum):
|
|
22
|
+
"""What a read that changes nothing can tell of the Secret Service's default collection."""
|
|
23
|
+
|
|
24
|
+
AVAILABLE = "available"
|
|
25
|
+
"""The default collection is published and unlocked."""
|
|
26
|
+
LOCKED = "locked"
|
|
27
|
+
"""The default collection is published but locked: the desktop asks for its password before anything is stored."""
|
|
28
|
+
UNPUBLISHED = "unpublished"
|
|
29
|
+
"""The alias names an object absent from the bus, the defect of the very first session of a new system or user: nothing can be stored."""
|
|
30
|
+
NO_DEFAULT_COLLECTION = "no_default_collection"
|
|
31
|
+
"""The alias names no collection: the credential manager's library asks the desktop to create one when it stores, so nothing is stopped."""
|
|
32
|
+
NO_SERVICE = "no_service"
|
|
33
|
+
"""Nobody owns the Secret Service name on the bus, as on a desktop without the GNOME keyring."""
|
|
34
|
+
UNKNOWN = "unknown"
|
|
35
|
+
"""No bus, no answer in time, an error or an answer of an unexpected shape, or a system without this read."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# What the ``default`` alias reads when no default collection is assigned.
|
|
39
|
+
NO_DEFAULT_COLLECTION_PATH = "/"
|
|
40
|
+
# The errors a property read on the aliased object can answer when that object is absent from the bus. It is the set on
|
|
41
|
+
# which libsecret concludes that the collection does not exist: the GNOME keyring answers UnknownMethod, a QtDBus
|
|
42
|
+
# service UnknownObject, and the Secret Service specification names NoSuchObject.
|
|
43
|
+
UNPUBLISHED_ERROR_NAME_SET = frozenset({
|
|
44
|
+
"org.freedesktop.DBus.Error.UnknownMethod",
|
|
45
|
+
"org.freedesktop.DBus.Error.UnknownObject",
|
|
46
|
+
"org.freedesktop.Secret.Error.NoSuchObject",
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class SecretServiceAnswers:
|
|
52
|
+
"""The answers of the reads, each None when that read gave no usable answer or was not made."""
|
|
53
|
+
|
|
54
|
+
owner_present: bool | None = None
|
|
55
|
+
"""Whether someone owns the Secret Service name, as the bus daemon answers it."""
|
|
56
|
+
alias_path: str | None = None
|
|
57
|
+
"""The object path the ``default`` alias names, exactly as the service wrote it."""
|
|
58
|
+
locked: object = None
|
|
59
|
+
"""The value of the ``Locked`` property of that object; only a boolean is a usable answer."""
|
|
60
|
+
locked_error_name: str = ""
|
|
61
|
+
"""The D-Bus error name the ``Locked`` read answered, empty when it answered a value or was not made."""
|
|
62
|
+
owner_program: str = ""
|
|
63
|
+
"""The name of the program that owns the Secret Service name, read in /proc; empty when it could not be read."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def keyring_state(answers: SecretServiceAnswers) -> KeyringState:
|
|
67
|
+
"""Classify the answers of a read-only Secret Service read into the state of its default collection.
|
|
68
|
+
|
|
69
|
+
Each step decides only on an answer of the expected type: the owner on a boolean, the alias on a non-empty path
|
|
70
|
+
compared character for character with ``/``, the lock on a boolean or on one of the errors of an absent object.
|
|
71
|
+
Anything else is UNKNOWN, so GitPane never says more than the bus told it.
|
|
72
|
+
"""
|
|
73
|
+
if type(answers.owner_present) is not bool:
|
|
74
|
+
return KeyringState.UNKNOWN
|
|
75
|
+
if not answers.owner_present:
|
|
76
|
+
return KeyringState.NO_SERVICE
|
|
77
|
+
if not answers.alias_path:
|
|
78
|
+
return KeyringState.UNKNOWN
|
|
79
|
+
if answers.alias_path == NO_DEFAULT_COLLECTION_PATH:
|
|
80
|
+
return KeyringState.NO_DEFAULT_COLLECTION
|
|
81
|
+
if answers.locked_error_name:
|
|
82
|
+
if answers.locked_error_name in UNPUBLISHED_ERROR_NAME_SET:
|
|
83
|
+
return KeyringState.UNPUBLISHED
|
|
84
|
+
return KeyringState.UNKNOWN
|
|
85
|
+
if type(answers.locked) is not bool:
|
|
86
|
+
return KeyringState.UNKNOWN
|
|
87
|
+
if answers.locked:
|
|
88
|
+
return KeyringState.LOCKED
|
|
89
|
+
return KeyringState.AVAILABLE
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class KeyringProvider(Enum):
|
|
93
|
+
"""Which keyring answers as the Secret Service, told by the name of the program that owns the name on the bus."""
|
|
94
|
+
|
|
95
|
+
GNOME_KEYRING = "gnome_keyring"
|
|
96
|
+
"""The GNOME keyring, ``gnome-keyring-daemon``."""
|
|
97
|
+
KDE_WALLET = "kde_wallet"
|
|
98
|
+
"""KDE Wallet, through ``ksecretd`` or an older ``kwalletd``."""
|
|
99
|
+
KEEPASSXC = "keepassxc"
|
|
100
|
+
"""KeePassXC with its Secret Service integration turned on."""
|
|
101
|
+
OTHER = "other"
|
|
102
|
+
"""A program GitPane does not know; the sentences name it as read."""
|
|
103
|
+
UNREADABLE = "unreadable"
|
|
104
|
+
"""The program could not be read, or nobody owns the name; the sentences name no program."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# The keyring each known program name stands for. The key is the program name exactly as /proc gives it, compared as
|
|
108
|
+
# is: the base name of the executable, or the command name of a process whose executable link is refused.
|
|
109
|
+
KEYRING_PROVIDER_BY_PROGRAM = {
|
|
110
|
+
"ksecretd": KeyringProvider.KDE_WALLET,
|
|
111
|
+
"kwalletd6": KeyringProvider.KDE_WALLET,
|
|
112
|
+
"kwalletd5": KeyringProvider.KDE_WALLET,
|
|
113
|
+
"gnome-keyring-daemon": KeyringProvider.GNOME_KEYRING,
|
|
114
|
+
# The daemon's name as /proc/<pid>/comm gives it, cut to 15 characters: read when the kernel refuses its executable
|
|
115
|
+
# link, which happens where the daemon is started with a file capability. Debian 13 installs it without any, so its
|
|
116
|
+
# link reads and this name is not needed there; the refusal itself was seen for kwin_wayland and its cap_sys_nice.
|
|
117
|
+
"gnome-keyring-d": KeyringProvider.GNOME_KEYRING,
|
|
118
|
+
"keepassxc": KeyringProvider.KEEPASSXC,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def keyring_provider(program: str) -> KeyringProvider:
|
|
123
|
+
"""Return the keyring a program name stands for: UNREADABLE for an empty name, OTHER for a name the table does not know."""
|
|
124
|
+
if not program:
|
|
125
|
+
return KeyringProvider.UNREADABLE
|
|
126
|
+
return KEYRING_PROVIDER_BY_PROGRAM.get(program, KeyringProvider.OTHER)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class KeyringReading:
|
|
131
|
+
"""What a read of the Secret Service tells: the state of its default collection and which program answered."""
|
|
132
|
+
|
|
133
|
+
state: KeyringState
|
|
134
|
+
"""The state of the default collection."""
|
|
135
|
+
provider: KeyringProvider
|
|
136
|
+
"""The keyring the owning program stands for."""
|
|
137
|
+
program: str
|
|
138
|
+
"""The name of the owning program as read, empty when it could not be read."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def keyring_reading(answers: SecretServiceAnswers) -> KeyringReading:
|
|
142
|
+
"""Classify the answers into the state of the default collection and the keyring that answered."""
|
|
143
|
+
return KeyringReading(keyring_state(answers), keyring_provider(answers.owner_program), answers.owner_program)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# What a read that could not be made, or failed, tells: nothing, so no sentence and no stop.
|
|
147
|
+
UNKNOWN_KEYRING_READING = KeyringReading(KeyringState.UNKNOWN, KeyringProvider.UNREADABLE, "")
|
|
148
|
+
|
|
149
|
+
# The readings that stop a sign-in before anything is sent. Only an answer already seen with this owner stops it: the
|
|
150
|
+
# GNOME keyring's unpublished collection of the very first session, where nothing can be stored. The same answer from
|
|
151
|
+
# another program, or from a program that could not be read, lets the exchange go, so the credential manager's own
|
|
152
|
+
# output tells what happened instead of a guess.
|
|
153
|
+
SIGN_IN_STOPPING_READING_SET = frozenset({(KeyringProvider.GNOME_KEYRING, KeyringState.UNPUBLISHED)})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def keyring_stops_sign_in(reading: KeyringReading) -> bool:
|
|
157
|
+
"""Tell whether this reading stops a sign-in before anything is sent to the credential manager."""
|
|
158
|
+
return (reading.provider, reading.state) in SIGN_IN_STOPPING_READING_SET
|
gitpane/account/ssh.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Give Git network commands a verified OpenSSH client with a private prompt channel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
13
|
+
|
|
14
|
+
from gitpane.account.ssh_prompt import SshPromptBridge
|
|
15
|
+
from gitpane.config import GIT_OPEN_TIMEOUT_MS, SYSTEM_ROOT_ENVIRONMENT_NAME, WINDOWS_PLATFORM
|
|
16
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, NETWORK_PROFILE, ProcessRequest, ProcessResult, ProcessRunner, ProcessStatus
|
|
17
|
+
from gitpane.system.network_folder import network_directory
|
|
18
|
+
from gitpane.tool.git_executable import GIT_BINARY_DIR_BASENAME, GIT_SHELL_DIR_BASENAME, PATH_ENVIRONMENT_NAME, git_root_dirs
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
|
|
23
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
24
|
+
from gitpane.operation.repository_operation import RepositoryOperationService
|
|
25
|
+
from gitpane.preference.preference_store import PreferenceStore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
SSH_UNAVAILABLE_TEXT = "SSH client is unavailable. Choose an executable in Settings and verify it."
|
|
29
|
+
# OpenSSH option that turns every question (host key, passphrase, password) into a failure: a background read must never show one.
|
|
30
|
+
SSH_BATCH_MODE_OPTION = "-o BatchMode=yes"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SshConfiguration:
|
|
35
|
+
"""Keep an exact executable and optional Windows utility directory per launch."""
|
|
36
|
+
|
|
37
|
+
ssh_filename: str = ""
|
|
38
|
+
utility_dir: str = ""
|
|
39
|
+
variant: str = "ssh"
|
|
40
|
+
|
|
41
|
+
def validate(self, *, windows: bool | None = None, optional: bool = False) -> None:
|
|
42
|
+
"""Reject shell expressions and unavailable tools before launching native SSH."""
|
|
43
|
+
windows = os.name == "nt" if windows is None else windows
|
|
44
|
+
if not self.ssh_filename:
|
|
45
|
+
if not optional:
|
|
46
|
+
raise ValueError(SSH_UNAVAILABLE_TEXT)
|
|
47
|
+
elif not os.path.isabs(self.ssh_filename) or not os.path.isfile(self.ssh_filename):
|
|
48
|
+
raise ValueError("SSH executable was not found. Choose an existing absolute filename in Settings.")
|
|
49
|
+
elif not os.access(self.ssh_filename, os.X_OK) or (windows and os.path.splitext(self.ssh_filename)[1].lower() != ".exe"):
|
|
50
|
+
raise ValueError("SSH client is not an executable file. Choose an OpenSSH executable in Settings.")
|
|
51
|
+
if self.variant != "ssh":
|
|
52
|
+
raise ValueError("Only verified OpenSSH clients are supported.")
|
|
53
|
+
if windows and self.utility_dir and (not os.path.isabs(self.utility_dir) or not os.path.isdir(self.utility_dir)):
|
|
54
|
+
raise ValueError("Git utilities directory (Windows) must be an existing absolute directory.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
SSH_WINDOWS_BASENAME = "ssh.exe"
|
|
58
|
+
SSH_BASENAME = "ssh"
|
|
59
|
+
# Where Windows installs its own OpenSSH client, below the Windows folder.
|
|
60
|
+
WINDOWS_OPENSSH_DIR_PARTS = ("System32", "OpenSSH")
|
|
61
|
+
GIT_FOR_WINDOWS_SOURCE_TEXT = "Git for Windows"
|
|
62
|
+
WINDOWS_OPENSSH_SOURCE_TEXT = "Windows OpenSSH"
|
|
63
|
+
PATH_SOURCE_TEXT = "PATH"
|
|
64
|
+
SAVED_SOURCE_TEXT = "Application settings"
|
|
65
|
+
DETECTED_SOURCE_PREFIX = "Detected: "
|
|
66
|
+
NO_SSH_SOURCE_TEXT = "No SSH client found"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SshDetection:
|
|
71
|
+
"""Name the OpenSSH client found on this machine and where it was found; both are empty when none was found."""
|
|
72
|
+
|
|
73
|
+
filename: str = ""
|
|
74
|
+
source: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def absolute_executable_available(filename: str) -> bool:
|
|
78
|
+
"""Tell whether a candidate is an existing executable file given by an absolute name.
|
|
79
|
+
|
|
80
|
+
A relative name is refused because it would depend on the folder GitPane happens to run in.
|
|
81
|
+
"""
|
|
82
|
+
return os.path.isabs(filename) and os.path.isfile(filename) and os.access(filename, os.X_OK)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def no_ssh_detection() -> SshDetection:
|
|
86
|
+
"""Report that no client was found, for a service that is given no way to search."""
|
|
87
|
+
return SshDetection()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def detect_ssh_client(git_filename: str, platform: str, environment_by_name: Mapping[str, str],
|
|
91
|
+
available: Callable[[str], bool] = absolute_executable_available, network: Callable[[str], bool] = network_directory) -> SshDetection:
|
|
92
|
+
"""Find the OpenSSH client used while none is saved: the one of the Git in use, then Windows OpenSSH, then the PATH.
|
|
93
|
+
|
|
94
|
+
On Windows, the ssh.exe of Git for Windows sits in usr/bin below the installation root, one or two folders above
|
|
95
|
+
git.exe. Linux and macOS only look at the PATH. Nothing is started and nothing is saved.
|
|
96
|
+
|
|
97
|
+
The search runs on the window's thread the first time an operation needs the client, so on Windows a PATH entry on
|
|
98
|
+
a network share is skipped: a disconnected share would freeze the window. A client kept on a share can still be
|
|
99
|
+
chosen in Settings.
|
|
100
|
+
"""
|
|
101
|
+
candidates: list[tuple[str, str]] = []
|
|
102
|
+
windows = platform == WINDOWS_PLATFORM
|
|
103
|
+
if windows:
|
|
104
|
+
for root_dir in git_root_dirs(git_filename):
|
|
105
|
+
candidates.append((os.path.join(root_dir, GIT_SHELL_DIR_BASENAME, GIT_BINARY_DIR_BASENAME, SSH_WINDOWS_BASENAME), GIT_FOR_WINDOWS_SOURCE_TEXT))
|
|
106
|
+
system_root_dir = environment_by_name.get(SYSTEM_ROOT_ENVIRONMENT_NAME, "")
|
|
107
|
+
if system_root_dir:
|
|
108
|
+
candidates.append((os.path.join(system_root_dir, *WINDOWS_OPENSSH_DIR_PARTS, SSH_WINDOWS_BASENAME), WINDOWS_OPENSSH_SOURCE_TEXT))
|
|
109
|
+
# The PATH is read here instead of with shutil.which, which on Windows looks in the current folder first: a
|
|
110
|
+
# client dropped there, or in a relative PATH entry, must never be launched for every network operation.
|
|
111
|
+
for path_dir in environment_by_name.get(PATH_ENVIRONMENT_NAME, "").split(os.pathsep):
|
|
112
|
+
if os.path.isabs(path_dir) and not (windows and network(path_dir)):
|
|
113
|
+
candidates.append((os.path.join(path_dir, SSH_WINDOWS_BASENAME if windows else SSH_BASENAME), PATH_SOURCE_TEXT))
|
|
114
|
+
for filename, source in candidates:
|
|
115
|
+
if available(filename):
|
|
116
|
+
return SshDetection(os.path.abspath(filename), source)
|
|
117
|
+
return SshDetection()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class SshClientFinder:
|
|
121
|
+
"""Find the OpenSSH client used while none is saved, once per Git executable for this launch.
|
|
122
|
+
|
|
123
|
+
The search only looks at files, but operations ask for it every time they start; keeping the answer per Git
|
|
124
|
+
executable means a Git changed in Settings is searched again. The answer is never saved in the preferences.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, current_git_filename: Callable[[], str], platform: str, environment_by_name: Mapping[str, str], *, log_line: Callable[[str], None]) -> None:
|
|
128
|
+
"""Take the Git in use, read at each search, and the line writer of the --log file, which ignores lines when there is none."""
|
|
129
|
+
self.current_git_filename = current_git_filename
|
|
130
|
+
self.platform = platform
|
|
131
|
+
self.environment_by_name = environment_by_name
|
|
132
|
+
self.log_line = log_line
|
|
133
|
+
# The OpenSSH client found on this machine, per Git executable, used while none is saved.
|
|
134
|
+
self.ssh_detection_by_git_filename: dict[str, SshDetection] = {}
|
|
135
|
+
|
|
136
|
+
def find(self) -> SshDetection:
|
|
137
|
+
"""Answer the client of the Git in use, searching the machine only the first time this Git asks."""
|
|
138
|
+
git_filename = self.current_git_filename()
|
|
139
|
+
detection = self.ssh_detection_by_git_filename.get(git_filename)
|
|
140
|
+
if detection is not None:
|
|
141
|
+
return detection
|
|
142
|
+
detection = detect_ssh_client(git_filename, self.platform, self.environment_by_name)
|
|
143
|
+
self.ssh_detection_by_git_filename[git_filename] = detection
|
|
144
|
+
self.log_line("SSH client detected: " + (detection.filename + " (" + detection.source + ")" if detection.filename else "none"))
|
|
145
|
+
return detection
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def effective_source(saved: SshConfiguration, detection: SshDetection) -> str:
|
|
149
|
+
"""Say where the client used by operations comes from: the saved setting, a detection, or nowhere."""
|
|
150
|
+
if saved.ssh_filename:
|
|
151
|
+
return SAVED_SOURCE_TEXT
|
|
152
|
+
if detection.filename:
|
|
153
|
+
return DETECTED_SOURCE_PREFIX + detection.source
|
|
154
|
+
return NO_SSH_SOURCE_TEXT
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class SshVerification:
|
|
159
|
+
"""Report the captured candidate and native version without publishing a draft."""
|
|
160
|
+
|
|
161
|
+
configuration: SshConfiguration
|
|
162
|
+
version: str = ""
|
|
163
|
+
cause: str = ""
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def succeeded(self) -> bool:
|
|
167
|
+
"""Require an identified OpenSSH version before a candidate can be saved."""
|
|
168
|
+
return bool(self.version) and not self.cause
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SshService(QObject):
|
|
172
|
+
"""Verify tools asynchronously and provide isolated network environment values."""
|
|
173
|
+
|
|
174
|
+
question_requested = Signal(object)
|
|
175
|
+
question_closed = Signal(str)
|
|
176
|
+
|
|
177
|
+
def __init__(self, preference: PreferenceStore, runner: ProcessRunner, parent: QObject | None = None, *, repository_operation: RepositoryOperationService | None = None,
|
|
178
|
+
detect_client: Callable[[], SshDetection] = no_ssh_detection) -> None:
|
|
179
|
+
"""Reuse the application process owner so verification drains during shutdown.
|
|
180
|
+
|
|
181
|
+
detect_client names the client to use while none is saved; the application gives one that searches the machine.
|
|
182
|
+
"""
|
|
183
|
+
# Retain the runner in Python without making it an implicit Qt owner: releasing
|
|
184
|
+
# its last reference during deferred service deletion must not delete this child again.
|
|
185
|
+
super().__init__(parent)
|
|
186
|
+
self.preference = preference
|
|
187
|
+
self.runner = runner
|
|
188
|
+
self.repository_operation = repository_operation
|
|
189
|
+
self.detect_client = detect_client
|
|
190
|
+
self.bridge_by_id: dict[str, SshPromptBridge] = {}
|
|
191
|
+
self.verification_by_id: dict[str, tuple[SshConfiguration, Callable[[SshVerification], None], bytearray]] = {}
|
|
192
|
+
runner.stdout_chunk.connect(self._stdout)
|
|
193
|
+
runner.finished.connect(self._finished)
|
|
194
|
+
runner.stop_requested.connect(self._stopping)
|
|
195
|
+
|
|
196
|
+
def prompt(self, request: ProcessRequest, *, context: RepositoryContext | None = None, generation: int = 0,
|
|
197
|
+
is_current: Callable[[], bool] | None = None) -> ProcessRequest:
|
|
198
|
+
"""Attach a private prompt owner to a captured network command before it is started."""
|
|
199
|
+
if request.profile != NETWORK_PROFILE or not request.operation_id or request.request_id in self.bridge_by_id:
|
|
200
|
+
raise ValueError("SSH prompting requires a unique supervised network operation")
|
|
201
|
+
if context is not None:
|
|
202
|
+
owner = self.repository_operation.active_by_id.get(request.operation_id) if self.repository_operation is not None else None
|
|
203
|
+
if owner is None or owner.context != context or owner.generation != generation or owner.session_id != request.session_id:
|
|
204
|
+
raise ValueError("SSH operation does not own the captured repository session")
|
|
205
|
+
try:
|
|
206
|
+
bridge = SshPromptBridge(request, self.runner, is_current or (lambda: True), self)
|
|
207
|
+
except OSError as error:
|
|
208
|
+
raise OSError("SSH prompt helper could not be prepared. Check temporary-directory permissions or repair the GitPane installation.") from error
|
|
209
|
+
self.bridge_by_id[request.request_id] = bridge
|
|
210
|
+
bridge.question_requested.connect(self.question_requested)
|
|
211
|
+
bridge.dismissed.connect(lambda: self.question_closed.emit(request.operation_id))
|
|
212
|
+
environment_by_name = dict(request.environment_by_name)
|
|
213
|
+
environment_by_name.update(bridge.environment())
|
|
214
|
+
return replace(request, environment_by_name=environment_by_name)
|
|
215
|
+
|
|
216
|
+
def close(self) -> None:
|
|
217
|
+
"""Refuse questions while retaining each bridge until its process has drained."""
|
|
218
|
+
for request_id, bridge in tuple(self.bridge_by_id.items()):
|
|
219
|
+
bridge.refuse()
|
|
220
|
+
if request_id in self.runner.active_by_id or any(request.request_id == request_id for request in self.runner.pending_requests):
|
|
221
|
+
self.runner.cancel(request_id)
|
|
222
|
+
else:
|
|
223
|
+
self.bridge_by_id.pop(request_id, None)
|
|
224
|
+
bridge.close()
|
|
225
|
+
|
|
226
|
+
def _stopping(self, request: ProcessRequest, cause: str) -> None:
|
|
227
|
+
"""Dismiss pending questions when their captured command begins stopping."""
|
|
228
|
+
bridge = self.bridge_by_id.get(request.request_id)
|
|
229
|
+
if bridge is not None:
|
|
230
|
+
bridge.refuse()
|
|
231
|
+
|
|
232
|
+
def saved_configuration(self) -> SshConfiguration:
|
|
233
|
+
"""Read only accepted preferences: what the Settings form shows and what its Save may write back."""
|
|
234
|
+
self.preference.refresh()
|
|
235
|
+
return SshConfiguration(str(self.preference.value_by_key["ssh_filename"]), str(self.preference.value_by_key["git_utility_dir"]))
|
|
236
|
+
|
|
237
|
+
def configuration(self) -> SshConfiguration:
|
|
238
|
+
"""Give the tools a new operation captures: the saved client, or the detected one while none is saved.
|
|
239
|
+
|
|
240
|
+
The detected client is never written to the preferences, so a later installation is found again.
|
|
241
|
+
"""
|
|
242
|
+
saved = self.saved_configuration()
|
|
243
|
+
if saved.ssh_filename:
|
|
244
|
+
return saved
|
|
245
|
+
return replace(saved, ssh_filename=self.detection().filename)
|
|
246
|
+
|
|
247
|
+
def detection(self) -> SshDetection:
|
|
248
|
+
"""Report the client found on this machine, whether or not one is saved."""
|
|
249
|
+
return self.detect_client()
|
|
250
|
+
|
|
251
|
+
def verify(self, configuration: SshConfiguration, complete: Callable[[SshVerification], None]) -> str:
|
|
252
|
+
"""Identify the selected executable with a bounded native version request."""
|
|
253
|
+
request_id = uuid.uuid4().hex
|
|
254
|
+
try:
|
|
255
|
+
configuration.validate()
|
|
256
|
+
except ValueError as error:
|
|
257
|
+
QTimer.singleShot(0, self, lambda cause=str(error): complete(SshVerification(configuration, cause=cause)))
|
|
258
|
+
return request_id
|
|
259
|
+
self.verification_by_id[request_id] = (configuration, complete, bytearray())
|
|
260
|
+
self.runner.start(ProcessRequest(request_id, configuration.ssh_filename, ["-V"], os.path.dirname(configuration.ssh_filename), GIT_OPEN_TIMEOUT_MS,
|
|
261
|
+
operation="Verify SSH client", profile=LOCAL_WRITE_PROFILE, operation_id=request_id))
|
|
262
|
+
return request_id
|
|
263
|
+
|
|
264
|
+
def process_environment(self, configuration: SshConfiguration | None = None, *, parent_path: str | None = None, windows: bool | None = None) -> dict[str, str]:
|
|
265
|
+
"""Preserve native Host, identity and agent settings without editing the parent PATH."""
|
|
266
|
+
configuration = configuration or self.configuration()
|
|
267
|
+
configuration.validate(windows=windows)
|
|
268
|
+
environment_by_name = {"GIT_SSH": configuration.ssh_filename, "GIT_SSH_VARIANT": configuration.variant}
|
|
269
|
+
windows = os.name == "nt" if windows is None else windows
|
|
270
|
+
if windows and configuration.utility_dir:
|
|
271
|
+
environment_by_name[PATH_ENVIRONMENT_NAME] = os.pathsep.join((configuration.utility_dir, os.environ.get(PATH_ENVIRONMENT_NAME, "") if parent_path is None else parent_path))
|
|
272
|
+
return environment_by_name
|
|
273
|
+
|
|
274
|
+
def command_configuration(self, configuration: SshConfiguration | None = None, *, batch_mode: bool = False) -> tuple[list[str], dict[str, str]]:
|
|
275
|
+
"""Keep a repository's core.sshCommand from silently replacing the selected executable.
|
|
276
|
+
|
|
277
|
+
With ``batch_mode`` OpenSSH never asks anything: an unknown host key or a key protected by a passphrase makes
|
|
278
|
+
the command fail instead. Work the user did not start explicitly, such as reading branches while a URL is typed,
|
|
279
|
+
uses it.
|
|
280
|
+
"""
|
|
281
|
+
configuration = configuration or self.configuration()
|
|
282
|
+
environment_by_name = self.process_environment(configuration)
|
|
283
|
+
# Git's core.sshCommand precedes GIT_SSH. Quote only the verified filename, never user-supplied command text.
|
|
284
|
+
ssh_command = shlex.quote(configuration.ssh_filename.replace("\\", "/") if os.name == "nt" else configuration.ssh_filename)
|
|
285
|
+
if batch_mode:
|
|
286
|
+
ssh_command += " " + SSH_BATCH_MODE_OPTION
|
|
287
|
+
arguments = ["-c", "core.sshCommand=" + ssh_command]
|
|
288
|
+
return arguments, environment_by_name
|
|
289
|
+
|
|
290
|
+
def _stdout(self, request: ProcessRequest, data: bytes) -> None:
|
|
291
|
+
"""Collect version output only for the matching executable verification."""
|
|
292
|
+
pending = self.verification_by_id.get(request.request_id)
|
|
293
|
+
if pending is not None:
|
|
294
|
+
_, _, output = pending
|
|
295
|
+
output.extend(data)
|
|
296
|
+
|
|
297
|
+
def _finished(self, result: ProcessResult) -> None:
|
|
298
|
+
"""Release drained prompt resources and publish the captured tool verification."""
|
|
299
|
+
bridge = self.bridge_by_id.pop(result.request.request_id, None)
|
|
300
|
+
if bridge is not None:
|
|
301
|
+
bridge.close()
|
|
302
|
+
pending = self.verification_by_id.pop(result.request.request_id, None)
|
|
303
|
+
if pending is None:
|
|
304
|
+
return
|
|
305
|
+
configuration, complete, output = pending
|
|
306
|
+
version = (bytes(output) + result.stderr).decode("utf-8", "replace").strip()
|
|
307
|
+
if result.status != ProcessStatus.SUCCESS:
|
|
308
|
+
complete(SshVerification(configuration, cause=result.cause or "SSH verification failed. Choose another executable in Settings."))
|
|
309
|
+
elif not version.startswith("OpenSSH_"):
|
|
310
|
+
complete(SshVerification(configuration, cause="This executable did not identify itself as OpenSSH. Choose an OpenSSH client in Settings."))
|
|
311
|
+
else:
|
|
312
|
+
complete(SshVerification(configuration, version=version))
|