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,216 @@
|
|
|
1
|
+
"""Exchange one bounded SSH prompt over an authenticated, same-user local socket."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import shlex
|
|
11
|
+
import socket
|
|
12
|
+
import struct
|
|
13
|
+
import sys
|
|
14
|
+
import zipfile
|
|
15
|
+
from ctypes import wintypes
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from PySide6.QtCore import QCoreApplication, QElapsedTimer
|
|
19
|
+
from PySide6.QtNetwork import QLocalSocket
|
|
20
|
+
|
|
21
|
+
from gitpane.config import (
|
|
22
|
+
LINUX_PLATFORM,
|
|
23
|
+
MACOS_PLATFORM,
|
|
24
|
+
MODULE_OPTION,
|
|
25
|
+
SSH_IPC_CONNECT_TIMEOUT_MS,
|
|
26
|
+
SSH_IPC_LIMIT_BYTES,
|
|
27
|
+
SSH_PROMPT_LIMIT_BYTES,
|
|
28
|
+
SSH_PROMPT_TIMEOUT_MS,
|
|
29
|
+
SSH_RESPONSE_LIMIT_BYTES,
|
|
30
|
+
WINDOWS_PLATFORM,
|
|
31
|
+
application_module_interpreter_options,
|
|
32
|
+
)
|
|
33
|
+
from gitpane.process.process_group import WINDOWLESS_INTERPRETER_BASENAME
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
|
|
38
|
+
# struct ucred of <sys/socket.h>: process id, user id, group id.
|
|
39
|
+
LINUX_PEER_CREDENTIAL_FORMAT = "3i"
|
|
40
|
+
LINUX_PEER_CREDENTIAL_PROCESS_ID_INDEX = 0
|
|
41
|
+
LINUX_PEER_CREDENTIAL_USER_ID_INDEX = 1
|
|
42
|
+
# Workaround: Python exposes neither SOL_LOCAL nor LOCAL_PEERPID on macOS, only LOCAL_PEERCRED. Their values come
|
|
43
|
+
# from <sys/un.h>, where Apple has kept them unchanged since LOCAL_PEERPID appeared.
|
|
44
|
+
MACOS_LOCAL_SOCKET_LEVEL = 0
|
|
45
|
+
MACOS_LOCAL_PEER_PROCESS_ID_OPTION = 2
|
|
46
|
+
# pid_t, a C int.
|
|
47
|
+
MACOS_PEER_PROCESS_ID_FORMAT = "i"
|
|
48
|
+
MACOS_PEER_PROCESS_ID_INDEX = 0
|
|
49
|
+
# struct xucred of <sys/ucred.h>: version, user id, group count, then the NGROUPS (16) group ids.
|
|
50
|
+
MACOS_PEER_CREDENTIAL_FORMAT = "IIh16I"
|
|
51
|
+
MACOS_PEER_CREDENTIAL_USER_ID_INDEX = 1
|
|
52
|
+
OTHER_USER_PEER_MESSAGE = "SSH prompt peer belongs to another user"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _windows_peer_process_id(descriptor: int, server: bool) -> int:
|
|
56
|
+
"""Ask Windows which process holds the other end of the named pipe: the server for a client, the client for a server."""
|
|
57
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
58
|
+
function = kernel.GetNamedPipeServerProcessId if server else kernel.GetNamedPipeClientProcessId
|
|
59
|
+
function.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.ULONG)]
|
|
60
|
+
function.restype = wintypes.BOOL
|
|
61
|
+
process_id = wintypes.ULONG()
|
|
62
|
+
if not function(descriptor, ctypes.byref(process_id)):
|
|
63
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
64
|
+
return process_id.value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _refuse_other_user(user_id: int) -> None:
|
|
68
|
+
"""Refuse a peer run by another user: its process id alone would not prove it belongs to this session."""
|
|
69
|
+
if user_id != os.getuid():
|
|
70
|
+
raise OSError(OTHER_USER_PEER_MESSAGE)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _linux_peer_process_id(descriptor: int, server: bool) -> int:
|
|
74
|
+
"""Read the peer process and user Linux records on a connected local socket (SO_PEERCRED)."""
|
|
75
|
+
with socket.socket(fileno=os.dup(descriptor)) as native_socket:
|
|
76
|
+
credentials = native_socket.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize(LINUX_PEER_CREDENTIAL_FORMAT))
|
|
77
|
+
credential_fields = struct.unpack(LINUX_PEER_CREDENTIAL_FORMAT, credentials)
|
|
78
|
+
_refuse_other_user(credential_fields[LINUX_PEER_CREDENTIAL_USER_ID_INDEX])
|
|
79
|
+
return credential_fields[LINUX_PEER_CREDENTIAL_PROCESS_ID_INDEX]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _macos_peer_process_id(descriptor: int, server: bool) -> int:
|
|
83
|
+
"""Read the peer process (LOCAL_PEERPID) and user (LOCAL_PEERCRED) macOS records on a connected local socket."""
|
|
84
|
+
credential_option = getattr(socket, "LOCAL_PEERCRED", None)
|
|
85
|
+
if credential_option is None:
|
|
86
|
+
raise OSError("This Python does not expose LOCAL_PEERCRED, so the local socket peer cannot be identified")
|
|
87
|
+
with socket.socket(fileno=os.dup(descriptor)) as native_socket:
|
|
88
|
+
process_id_data = native_socket.getsockopt(MACOS_LOCAL_SOCKET_LEVEL, MACOS_LOCAL_PEER_PROCESS_ID_OPTION, struct.calcsize(MACOS_PEER_PROCESS_ID_FORMAT))
|
|
89
|
+
credentials = native_socket.getsockopt(MACOS_LOCAL_SOCKET_LEVEL, credential_option, struct.calcsize(MACOS_PEER_CREDENTIAL_FORMAT))
|
|
90
|
+
_refuse_other_user(struct.unpack(MACOS_PEER_CREDENTIAL_FORMAT, credentials)[MACOS_PEER_CREDENTIAL_USER_ID_INDEX])
|
|
91
|
+
return struct.unpack(MACOS_PEER_PROCESS_ID_FORMAT, process_id_data)[MACOS_PEER_PROCESS_ID_INDEX]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
PEER_PROCESS_ID_READER_BY_PLATFORM: dict[str, Callable[[int, bool], int]] = {
|
|
95
|
+
WINDOWS_PLATFORM: _windows_peer_process_id,
|
|
96
|
+
LINUX_PLATFORM: _linux_peer_process_id,
|
|
97
|
+
MACOS_PLATFORM: _macos_peer_process_id,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def peer_process_id_reader(platform: str) -> Callable[[int, bool], int]:
|
|
102
|
+
"""Choose how ``platform`` names the process at the other end of a local socket.
|
|
103
|
+
|
|
104
|
+
A system with no known reading raises OSError, which every socket owner treats as a refused peer, so a peer is
|
|
105
|
+
never accepted on a system where the operating system cannot prove who it is.
|
|
106
|
+
"""
|
|
107
|
+
platform_key = LINUX_PLATFORM if platform.startswith(LINUX_PLATFORM) else platform
|
|
108
|
+
reader = PEER_PROCESS_ID_READER_BY_PLATFORM.get(platform_key)
|
|
109
|
+
if reader is None:
|
|
110
|
+
raise OSError(f"Cannot identify a local socket peer on {platform}")
|
|
111
|
+
return reader
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def peer_process_id(connection: QLocalSocket, *, server: bool = False, platform: str = sys.platform) -> int:
|
|
115
|
+
"""Read the OS peer identity so a guessed socket name cannot impersonate an operation."""
|
|
116
|
+
return peer_process_id_reader(platform)(int(connection.socketDescriptor()), server)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def askpass_module_name() -> str:
|
|
120
|
+
"""Answer the module name the SSH helper launcher passes to -m and the Windows archive imports.
|
|
121
|
+
|
|
122
|
+
It is read from the module as the import system loaded it, so a move that rewrites the import cannot leave a stale
|
|
123
|
+
name in the launcher, and it stays right when this module runs itself through -m, where __name__ is __main__.
|
|
124
|
+
"""
|
|
125
|
+
if __spec__ is None:
|
|
126
|
+
raise OSError("The SSH helper module was not loaded by the import system, so its name is unknown.")
|
|
127
|
+
return __spec__.name
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def askpass_bootstrap_text() -> str:
|
|
131
|
+
"""Answer the Python text the Windows SSH helper archive runs: import the helper's entry point and exit with its status."""
|
|
132
|
+
return "from " + askpass_module_name() + " import " + main.__name__ + "\nraise SystemExit(" + main.__name__ + "())\n"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def create_launcher(directory: str) -> str:
|
|
136
|
+
"""Create an owned windowless launcher that imports only the installed application.
|
|
137
|
+
|
|
138
|
+
The launcher starts the Python of the installation without the Python variables the interpreter reads itself
|
|
139
|
+
(PYTHONPATH, PYTHONHOME...) or the folder it starts from on the import path, so only the installed GitPane, wherever
|
|
140
|
+
pip put it, can be imported. The site module still reads PYTHONUSERBASE, which moves the user's site-packages
|
|
141
|
+
folder; that folder is left out altogether when the running GitPane runs without it.
|
|
142
|
+
"""
|
|
143
|
+
bootstrap = askpass_bootstrap_text()
|
|
144
|
+
interpreter_options = application_module_interpreter_options(bool(sys.flags.no_user_site))
|
|
145
|
+
filename = os.path.join(directory, "askpass.exe" if os.name == "nt" else "askpass")
|
|
146
|
+
if os.name == "nt":
|
|
147
|
+
# OpenSSH calls an executable directly, so a batch file cannot safely forward arbitrary prompts.
|
|
148
|
+
# The bundled distlib launcher starts the Python named on its shebang line, with the options written there.
|
|
149
|
+
from importlib.resources import files
|
|
150
|
+
launcher_basename = "w64-arm.exe" if platform.machine().lower() == "arm64" else "w64.exe" if struct.calcsize("P") == 8 else "w32.exe"
|
|
151
|
+
launcher = files("gitpane").joinpath("vendor", launcher_basename).read_bytes()
|
|
152
|
+
python_filename = os.path.join(os.path.dirname(sys.executable), WINDOWLESS_INTERPRETER_BASENAME)
|
|
153
|
+
archive = io.BytesIO()
|
|
154
|
+
with zipfile.ZipFile(archive, "w") as content:
|
|
155
|
+
content.writestr("__main__.py", bootstrap)
|
|
156
|
+
shebang = '#!"' + python_filename + '" ' + " ".join(interpreter_options) + "\n"
|
|
157
|
+
data = launcher + shebang.encode("utf-8") + archive.getvalue()
|
|
158
|
+
else:
|
|
159
|
+
start_command = shlex.join([sys.executable, *interpreter_options, MODULE_OPTION, askpass_module_name()])
|
|
160
|
+
data = ("#!/bin/sh\nexec " + start_command + ' "$@"\n').encode("utf-8")
|
|
161
|
+
with open(filename, "xb") as stream:
|
|
162
|
+
stream.write(data)
|
|
163
|
+
os.chmod(filename, 0o700)
|
|
164
|
+
return filename
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main() -> int:
|
|
168
|
+
"""Return one private answer to OpenSSH stdout, refusing malformed or expired exchanges."""
|
|
169
|
+
application = QCoreApplication.instance() or QCoreApplication(sys.argv)
|
|
170
|
+
prompt_index = 1
|
|
171
|
+
if len(sys.argv) != prompt_index + 1:
|
|
172
|
+
return 1
|
|
173
|
+
prompt = sys.argv[prompt_index]
|
|
174
|
+
if len(prompt.encode("utf-8")) > SSH_PROMPT_LIMIT_BYTES or "\0" in prompt:
|
|
175
|
+
return 1
|
|
176
|
+
connection = QLocalSocket()
|
|
177
|
+
try:
|
|
178
|
+
connection.connectToServer(os.environ["GITPANE_SSH_PIPE"])
|
|
179
|
+
if not connection.waitForConnected(SSH_IPC_CONNECT_TIMEOUT_MS):
|
|
180
|
+
return 1
|
|
181
|
+
if peer_process_id(connection, server=True) != int(os.environ["GITPANE_SSH_OWNER_PID"]):
|
|
182
|
+
return 1
|
|
183
|
+
request_by_key = {"nonce": os.environ["GITPANE_SSH_NONCE"], "operation": os.environ["GITPANE_SSH_OPERATION"],
|
|
184
|
+
"prompt": prompt, "hint": os.environ.get("SSH_ASKPASS_PROMPT", "")}
|
|
185
|
+
connection.write(json.dumps(request_by_key).encode("utf-8") + b"\n")
|
|
186
|
+
if not connection.waitForBytesWritten(SSH_IPC_CONNECT_TIMEOUT_MS):
|
|
187
|
+
return 1
|
|
188
|
+
deadline = QElapsedTimer()
|
|
189
|
+
deadline.start()
|
|
190
|
+
response = bytearray()
|
|
191
|
+
while deadline.elapsed() < SSH_PROMPT_TIMEOUT_MS:
|
|
192
|
+
response.extend(bytes(connection.readAll()))
|
|
193
|
+
if len(response) > SSH_IPC_LIMIT_BYTES:
|
|
194
|
+
return 1
|
|
195
|
+
if b"\n" in response:
|
|
196
|
+
response_by_key = json.loads(response)
|
|
197
|
+
answer = response_by_key.get("answer")
|
|
198
|
+
if not isinstance(answer, str) or len(answer.encode("utf-8")) > SSH_RESPONSE_LIMIT_BYTES or any(character in answer for character in "\r\n\0"):
|
|
199
|
+
return 1
|
|
200
|
+
if sys.stdout is None:
|
|
201
|
+
return 1
|
|
202
|
+
sys.stdout.buffer.write(answer.encode("utf-8") + b"\n")
|
|
203
|
+
sys.stdout.buffer.flush()
|
|
204
|
+
return 0
|
|
205
|
+
if not connection.waitForReadyRead(max(1, SSH_PROMPT_TIMEOUT_MS - deadline.elapsed())):
|
|
206
|
+
return 1
|
|
207
|
+
return 1
|
|
208
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
209
|
+
return 1
|
|
210
|
+
finally:
|
|
211
|
+
connection.abort()
|
|
212
|
+
del application
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Own bounded SSH prompt sockets until their supervised operation has drained."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
14
|
+
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
|
15
|
+
|
|
16
|
+
from gitpane.account.ssh_askpass import create_launcher, peer_process_id
|
|
17
|
+
from gitpane.config import (
|
|
18
|
+
SSH_IPC_CONNECT_TIMEOUT_MS,
|
|
19
|
+
SSH_IPC_LIMIT_BYTES,
|
|
20
|
+
SSH_NONCE_BYTES,
|
|
21
|
+
SSH_PENDING_CONNECTION_LIMIT,
|
|
22
|
+
SSH_PROMPT_COUNT_LIMIT,
|
|
23
|
+
SSH_PROMPT_LIMIT_BYTES,
|
|
24
|
+
SSH_PROMPT_TIMEOUT_MS,
|
|
25
|
+
SSH_RESPONSE_LIMIT_BYTES,
|
|
26
|
+
)
|
|
27
|
+
from gitpane.process.process_runner import ProcessStatus
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
|
|
32
|
+
from gitpane.process.process_runner import ProcessRequest, ProcessRunner
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SshQuestion:
|
|
37
|
+
"""Expose prompt text and a private reply callback without carrying credentials in signals."""
|
|
38
|
+
|
|
39
|
+
operation_id: str
|
|
40
|
+
description: str
|
|
41
|
+
text: str
|
|
42
|
+
confirmation: bool
|
|
43
|
+
answer: Callable[[str | None], None]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SshPromptBridge(QObject):
|
|
47
|
+
"""Authenticate native descendants before opening asynchronous credential questions."""
|
|
48
|
+
|
|
49
|
+
question_requested = Signal(object)
|
|
50
|
+
dismissed = Signal()
|
|
51
|
+
|
|
52
|
+
def __init__(self, request: ProcessRequest, runner: ProcessRunner, is_current: Callable[[], bool], parent: QObject | None = None,
|
|
53
|
+
*, timeout_ms: int = SSH_PROMPT_TIMEOUT_MS) -> None:
|
|
54
|
+
"""Create a same-user named pipe or filesystem socket for exactly one operation."""
|
|
55
|
+
super().__init__(parent)
|
|
56
|
+
self.request = request
|
|
57
|
+
self.runner = runner
|
|
58
|
+
self.is_current = is_current
|
|
59
|
+
self.timeout_ms = timeout_ms
|
|
60
|
+
self.nonce = secrets.token_hex(SSH_NONCE_BYTES)
|
|
61
|
+
self.closed = False
|
|
62
|
+
self.refused = False
|
|
63
|
+
self.prompt_count = 0
|
|
64
|
+
self.connection_by_socket: dict[QLocalSocket, bytearray] = {}
|
|
65
|
+
self.timer_by_socket: dict[QLocalSocket, QTimer] = {}
|
|
66
|
+
self.pending_socket: QLocalSocket | None = None
|
|
67
|
+
self.directory = tempfile.TemporaryDirectory(prefix="gitpane-ssh-")
|
|
68
|
+
self.server = QLocalServer(self)
|
|
69
|
+
self.server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption)
|
|
70
|
+
self.server.setMaxPendingConnections(SSH_PENDING_CONNECTION_LIMIT)
|
|
71
|
+
pipe_name = "gitpane-ssh-" + secrets.token_hex(SSH_NONCE_BYTES) if os.name == "nt" else os.path.join(self.directory.name, "socket")
|
|
72
|
+
try:
|
|
73
|
+
self.launcher_filename = create_launcher(self.directory.name)
|
|
74
|
+
if not self.server.listen(pipe_name):
|
|
75
|
+
raise OSError("Could not open private SSH prompt channel: " + self.server.errorString())
|
|
76
|
+
except Exception:
|
|
77
|
+
self.directory.cleanup()
|
|
78
|
+
raise
|
|
79
|
+
self.server.newConnection.connect(self._accept)
|
|
80
|
+
self.timer = QTimer(self)
|
|
81
|
+
self.timer.setSingleShot(True)
|
|
82
|
+
self.timer.timeout.connect(self._expire_prompt)
|
|
83
|
+
|
|
84
|
+
def environment(self) -> dict[str, str]:
|
|
85
|
+
"""Supply only this operation's capability and exact helper executable to its child."""
|
|
86
|
+
return {"SSH_ASKPASS": self.launcher_filename, "GIT_ASKPASS": self.launcher_filename, "SSH_ASKPASS_REQUIRE": "force", "DISPLAY": "gitpane:0",
|
|
87
|
+
"GITPANE_SSH_PIPE": self.server.fullServerName(), "GITPANE_SSH_NONCE": self.nonce,
|
|
88
|
+
"GITPANE_SSH_OPERATION": self.request.operation_id, "GITPANE_SSH_OWNER_PID": str(os.getpid())}
|
|
89
|
+
|
|
90
|
+
def refuse(self) -> None:
|
|
91
|
+
"""Refuse pending and future questions without releasing process ownership early."""
|
|
92
|
+
self.refused = True
|
|
93
|
+
self.timer.stop()
|
|
94
|
+
self.pending_socket = None
|
|
95
|
+
for connection in tuple(self.connection_by_socket):
|
|
96
|
+
connection.abort()
|
|
97
|
+
self.dismissed.emit()
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
"""Remove only owned IPC resources after the runner reports complete drainage."""
|
|
101
|
+
if self.closed:
|
|
102
|
+
return
|
|
103
|
+
self.closed = True
|
|
104
|
+
self.refuse()
|
|
105
|
+
self.server.close()
|
|
106
|
+
self.server.newConnection.disconnect()
|
|
107
|
+
self.timer.timeout.disconnect()
|
|
108
|
+
self.nonce = ""
|
|
109
|
+
self.directory.cleanup()
|
|
110
|
+
# Callbacks retain the service; releasing its last reference during Qt teardown
|
|
111
|
+
# must not let the parent delete this bridge again while its destructor is active.
|
|
112
|
+
self.setParent(None)
|
|
113
|
+
self.deleteLater()
|
|
114
|
+
|
|
115
|
+
def _accept(self) -> None:
|
|
116
|
+
"""Bound peers and authenticate their kernel-reported process identity first."""
|
|
117
|
+
while self.server.hasPendingConnections():
|
|
118
|
+
connection = self.server.nextPendingConnection()
|
|
119
|
+
connection.setReadBufferSize(SSH_IPC_LIMIT_BYTES + 1)
|
|
120
|
+
try:
|
|
121
|
+
active = self.runner.active_by_id.get(self.request.request_id)
|
|
122
|
+
if (self.closed or self.refused or not self.is_current() or active is None or active.status != ProcessStatus.RUNNING
|
|
123
|
+
or active.request.operation_id != self.request.operation_id or active.group is None
|
|
124
|
+
or len(self.connection_by_socket) >= SSH_PENDING_CONNECTION_LIMIT
|
|
125
|
+
or not active.group.contains_process(peer_process_id(connection))):
|
|
126
|
+
raise ValueError("Unrecognized SSH prompt peer")
|
|
127
|
+
except (OSError, ValueError, AttributeError):
|
|
128
|
+
connection.abort()
|
|
129
|
+
connection.deleteLater()
|
|
130
|
+
continue
|
|
131
|
+
self.connection_by_socket[connection] = bytearray()
|
|
132
|
+
connection.readyRead.connect(lambda current=connection: self._read(current))
|
|
133
|
+
connection.disconnected.connect(lambda current=connection: self._disconnected(current))
|
|
134
|
+
timer = QTimer(connection)
|
|
135
|
+
timer.setSingleShot(True)
|
|
136
|
+
timer.timeout.connect(lambda current=connection: self._expire_handshake(current))
|
|
137
|
+
self.timer_by_socket[connection] = timer
|
|
138
|
+
timer.start(SSH_IPC_CONNECT_TIMEOUT_MS)
|
|
139
|
+
self._read(connection)
|
|
140
|
+
|
|
141
|
+
def _expire_handshake(self, connection: QLocalSocket) -> None:
|
|
142
|
+
"""Disconnect peers that do not provide their authenticated question in time."""
|
|
143
|
+
if connection in self.connection_by_socket and connection is not self.pending_socket:
|
|
144
|
+
connection.abort()
|
|
145
|
+
|
|
146
|
+
def _expire_prompt(self) -> None:
|
|
147
|
+
"""Stop an unanswered operation while preserving its bridge through process drainage."""
|
|
148
|
+
self.refuse()
|
|
149
|
+
self.runner.cancel(self.request.request_id)
|
|
150
|
+
|
|
151
|
+
def _disconnected(self, connection: QLocalSocket) -> None:
|
|
152
|
+
"""Release a departed peer and dismiss any question waiting on its socket."""
|
|
153
|
+
self.connection_by_socket.pop(connection, None)
|
|
154
|
+
timer = self.timer_by_socket.pop(connection, None)
|
|
155
|
+
if timer is not None:
|
|
156
|
+
timer.stop()
|
|
157
|
+
timer.timeout.disconnect()
|
|
158
|
+
connection.readyRead.disconnect()
|
|
159
|
+
connection.disconnected.disconnect()
|
|
160
|
+
if self.pending_socket is connection:
|
|
161
|
+
self.pending_socket = None
|
|
162
|
+
self.timer.stop()
|
|
163
|
+
self.dismissed.emit()
|
|
164
|
+
connection.deleteLater()
|
|
165
|
+
|
|
166
|
+
def _read(self, connection: QLocalSocket) -> None:
|
|
167
|
+
"""Validate one framed prompt, nonce and operation before it reaches any widget."""
|
|
168
|
+
content = self.connection_by_socket.get(connection)
|
|
169
|
+
if content is None:
|
|
170
|
+
return
|
|
171
|
+
content.extend(bytes(connection.readAll()))
|
|
172
|
+
if len(content) > SSH_IPC_LIMIT_BYTES or connection is self.pending_socket:
|
|
173
|
+
connection.abort()
|
|
174
|
+
return
|
|
175
|
+
if b"\n" not in content:
|
|
176
|
+
return
|
|
177
|
+
try:
|
|
178
|
+
data_by_key = json.loads(content)
|
|
179
|
+
if not isinstance(data_by_key, dict):
|
|
180
|
+
raise TypeError("Invalid SSH question")
|
|
181
|
+
nonce = data_by_key.get("nonce")
|
|
182
|
+
prompt = data_by_key.get("prompt")
|
|
183
|
+
hint = data_by_key.get("hint", "")
|
|
184
|
+
if (not isinstance(nonce, str) or not hmac.compare_digest(nonce, self.nonce) or data_by_key.get("operation") != self.request.operation_id
|
|
185
|
+
or not isinstance(prompt, str) or not prompt or len(prompt.encode("utf-8")) > SSH_PROMPT_LIMIT_BYTES or "\0" in prompt
|
|
186
|
+
or hint not in {"", "confirm", "none"} or self.pending_socket is not None or self.prompt_count >= SSH_PROMPT_COUNT_LIMIT
|
|
187
|
+
or self.refused or not self.is_current()):
|
|
188
|
+
raise ValueError("Invalid SSH question")
|
|
189
|
+
if "REMOTE HOST IDENTIFICATION HAS CHANGED" in prompt.upper():
|
|
190
|
+
raise ValueError("Changed host keys require native SSH configuration repair")
|
|
191
|
+
except (ValueError, UnicodeError, TypeError):
|
|
192
|
+
connection.abort()
|
|
193
|
+
return
|
|
194
|
+
self.prompt_count += 1
|
|
195
|
+
self.timer_by_socket[connection].stop()
|
|
196
|
+
self.pending_socket = connection
|
|
197
|
+
confirmation = hint == "confirm" or "are you sure you want to continue connecting" in prompt.lower()
|
|
198
|
+
question = SshQuestion(self.request.operation_id, self.request.operation, prompt, confirmation, lambda answer: self._answer(connection, confirmation, answer))
|
|
199
|
+
self.timer.start(self.timeout_ms)
|
|
200
|
+
self.question_requested.emit(question)
|
|
201
|
+
|
|
202
|
+
def _answer(self, connection: QLocalSocket, confirmation: bool, answer: str | None) -> None:
|
|
203
|
+
"""Write a bounded answer only to the still-current authenticated requesting process."""
|
|
204
|
+
if self.closed or self.refused or connection is not self.pending_socket:
|
|
205
|
+
return
|
|
206
|
+
if not self.is_current() or answer is None:
|
|
207
|
+
self.refuse()
|
|
208
|
+
self.runner.cancel(self.request.request_id)
|
|
209
|
+
return
|
|
210
|
+
if not isinstance(answer, str) or len(answer.encode("utf-8")) > SSH_RESPONSE_LIMIT_BYTES or any(character in answer for character in "\r\n\0"):
|
|
211
|
+
self.refuse()
|
|
212
|
+
return
|
|
213
|
+
if confirmation and answer not in {"yes", "no"}:
|
|
214
|
+
self.refuse()
|
|
215
|
+
return
|
|
216
|
+
if not confirmation:
|
|
217
|
+
self.runner.protect_secret(self.request.request_id, answer)
|
|
218
|
+
self.timer.stop()
|
|
219
|
+
self.pending_socket = None
|
|
220
|
+
connection.write(json.dumps({"answer": answer}).encode("utf-8") + b"\n")
|
|
221
|
+
connection.disconnectFromServer()
|
|
222
|
+
self.dismissed.emit()
|