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,954 @@
|
|
|
1
|
+
"""Prepare one secret-filtered diagnostic for every place that shows, copies or logs it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
from contextlib import ExitStack
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
from urllib.parse import unquote, urlsplit, urlunsplit
|
|
16
|
+
|
|
17
|
+
from gitpane.config import AUTHORIZATION_CODE_FIELD_NAME, DIAGNOSTIC_DECODE_LIMIT, LINE_SEPARATOR, NON_SECRET_ENVIRONMENT_NAME_SET, SENSITIVE_FIELD_NAMES, SENSITIVE_VALUE_MIN_LENGTH, TRACE_UNIT_LIMIT_BYTES
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable, Mapping
|
|
21
|
+
from typing import TextIO
|
|
22
|
+
|
|
23
|
+
SENSITIVE_NAME_PATTERN = re.compile(r"password|passwd|token|secret|authorization|api[_-]?key|credential|cookie|verifier|nonce", re.IGNORECASE)
|
|
24
|
+
URL_USERINFO_PATTERN = re.compile(r"(\b[a-z][a-z0-9+.-]*://)[^/\s@]+@", re.IGNORECASE)
|
|
25
|
+
MASKED_VALUE = "[hidden]"
|
|
26
|
+
# A "credential.username" or "credential.<url>.username" setting given on the command line: the key stays readable, the
|
|
27
|
+
# login after "=" is hidden. Git reads configuration names without regard to case.
|
|
28
|
+
CREDENTIAL_USERNAME_SETTING_PATTERN = re.compile(r"(credential\.(?:.+\.)?username=)(.+)", re.IGNORECASE | re.DOTALL)
|
|
29
|
+
FIELD_PATTERN = "|".join(re.escape(name).replace("_", "[-_]") for name in SENSITIVE_FIELD_NAMES)
|
|
30
|
+
# Words that make the "code" after them the result of a process or a request, never an OAuth authorization code. The
|
|
31
|
+
# word must start a word of its own, but may follow an underscore ("process_exit_code"), and only blanks of the same
|
|
32
|
+
# line, "_" or "-" may separate it from "code": a word that ends one line says nothing about a field on the next one.
|
|
33
|
+
RESULT_CODE_QUALIFIER_PATTERN = r"(?<![A-Za-z0-9])(?:exit|status|error|return)[ \t_-]*"
|
|
34
|
+
# Characters that, written right before a field name, make it the end of a longer name such as "passcode" or "otp_code".
|
|
35
|
+
WORD_CHARACTER_PATTERN = re.compile(r"[A-Za-z0-9_-]")
|
|
36
|
+
FIELD_ASSIGNMENT_PATTERN = re.compile(
|
|
37
|
+
r"(?P<prefix>(?P<qualifier>" + RESULT_CODE_QUALIFIER_PATTERN + r")?[\"']?(?P<name>" + FIELD_PATTERN + r")[\"']?\s*[:=]\s*)"
|
|
38
|
+
r"(?P<value>\[hidden\]|\[REDACTED\]|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|[^\s&;,}\]]+)",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
# A whole number, with the minus sign of a signal exit (-15) or of a Windows status read as signed (-1073741819).
|
|
42
|
+
NUMERIC_CODE_VALUE_PATTERN = re.compile(r"-?(?P<digits>[0-9]+)")
|
|
43
|
+
# Longest number a bare "code" field may show: exit codes 0 to 255, signal exits, HTTP statuses and Git's 128 all fit,
|
|
44
|
+
# while a six-digit one-time code, the numeric authorization code a server can send, stays hidden.
|
|
45
|
+
BARE_CODE_DIGIT_LIMIT = 3
|
|
46
|
+
# Longest number an "exit code", "status code", "error code" or "return code" may show: a Windows exit status is a
|
|
47
|
+
# 32-bit value, up to 4294967295 (10 digits), such as 3221225477 for an access violation.
|
|
48
|
+
QUALIFIED_CODE_DIGIT_LIMIT = 10
|
|
49
|
+
SECRET_HEADER_PATTERN = re.compile(r"((?:proxy-authorization|authorization|set-cookie|cookie)\s*:\s*)([^\r\n]+)", re.IGNORECASE)
|
|
50
|
+
URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s\"'<>]+", re.IGNORECASE)
|
|
51
|
+
ANSI_PATTERN = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
|
|
52
|
+
SENSITIVE_NORMALIZED_NAME_SET = frozenset(name.replace("-", "").replace("_", "") for name in SENSITIVE_FIELD_NAMES)
|
|
53
|
+
CURL_DATA_PATTERN = re.compile(rb"(?:<= Recv|=> Send) data: (.*)")
|
|
54
|
+
CURL_DATA_SIZE_PATTERN = re.compile(rb"(?:<= Recv|=> Send) data, [0-9]+ bytes")
|
|
55
|
+
DIAGNOSTIC_OPERATION_PREFIX = "Operation: "
|
|
56
|
+
DIAGNOSTIC_CAUSE_PREFIX = "Cause: "
|
|
57
|
+
DIAGNOSTIC_STDERR_HEADER = "Standard error:"
|
|
58
|
+
DIAGNOSTIC_TECHNICAL_DETAIL_HEADER = "Technical details:"
|
|
59
|
+
# Start of the one-line description that replaces a binary output; the console leaves it out, the records and --log keep it.
|
|
60
|
+
OPAQUE_BODY_PREFIX = "Opaque body: "
|
|
61
|
+
# A text unit is read as JSON when its first non-blank byte opens an object or an array. On a bytes pattern, \s covers
|
|
62
|
+
# exactly the six ASCII blanks bytes.lstrip() removes, and, like a strip of the rest of the output, it crosses line
|
|
63
|
+
# breaks: a blank line followed by a JSON line is read together with it.
|
|
64
|
+
JSON_UNIT_START_PATTERN = re.compile(rb"\s*[\[{]")
|
|
65
|
+
LEADING_BLANK_PATTERN = re.compile(rb"\s*")
|
|
66
|
+
# JSON tokens, read the way json.loads reads them (NaN and Infinity included). A string cannot hold a raw line break and
|
|
67
|
+
# numbers and literals end at one, so no token straddles the end of a line: each complete line can be scanned on its own.
|
|
68
|
+
JSON_BLANK_PATTERN = re.compile(rb"[ \t\n\r]*")
|
|
69
|
+
JSON_TOKEN_PATTERN = re.compile(
|
|
70
|
+
rb'(?P<punctuation>[\[\]{},:])|(?P<string>"(?:[^"\\\x00-\x1f]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*")'
|
|
71
|
+
rb"|(?P<scalar>-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?|true|false|null|NaN|-?Infinity)"
|
|
72
|
+
)
|
|
73
|
+
JSON_ARRAY_OPENING = b"["
|
|
74
|
+
JSON_OBJECT_OPENING = b"{"
|
|
75
|
+
JSON_OPENING_BY_CLOSING = {b"]": JSON_ARRAY_OPENING, b"}": JSON_OBJECT_OPENING}
|
|
76
|
+
JSON_NAME_SEPARATOR = b":"
|
|
77
|
+
JSON_VALUE_SEPARATOR = b","
|
|
78
|
+
INNERMOST_BRACKET_INDEX = -1
|
|
79
|
+
EXIT_STATUS_RUNNING = "running"
|
|
80
|
+
EXIT_STATUS_NOT_STARTED = "not started"
|
|
81
|
+
# The status of a failure that came after its process had already ended normally: the process is not the cause.
|
|
82
|
+
EXIT_STATUS_ENDED_BEFORE_FAILURE = "ended before the failure"
|
|
83
|
+
TERMINAL_REDRAW_BREAK = "\r"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class MaskedArguments:
|
|
88
|
+
"""A command with its sensitive words replaced, and the raw secrets it replaced.
|
|
89
|
+
|
|
90
|
+
``secret_values`` are values written after a secret-named option, such as a password or a token: every text of the
|
|
91
|
+
command, Git's own error output included, must hide them.
|
|
92
|
+
|
|
93
|
+
The login of a ``credential.<url>.username=<login>`` setting is hidden in ``arguments`` only, as the value of its
|
|
94
|
+
setting, and is not returned: it is no secret, and replacing that word anywhere else would damage the text around
|
|
95
|
+
it. On GitHub a login is also the owner part of that user's repository URLs, often the user's local account name in
|
|
96
|
+
every path, and a short one is part of ordinary words.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
arguments: list[str]
|
|
100
|
+
secret_values: list[str]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mask_sensitive_arguments(arguments: list[str]) -> MaskedArguments:
|
|
104
|
+
"""Return the command with every value written after a secret-named option replaced, plus those raw values.
|
|
105
|
+
|
|
106
|
+
This rule lives here alone: every surface that shows a command — error diagnostics, the request published to
|
|
107
|
+
subscribers, the process log line, the console command line — must hide exactly the same words, and separate
|
|
108
|
+
copies have already drifted apart. A second sensitive option in a row is hidden too, because hiding one word too
|
|
109
|
+
many is the safe reading.
|
|
110
|
+
|
|
111
|
+
The value of every ``credential.<url>.username=<login>`` setting is hidden in the command as well: commands of a
|
|
112
|
+
repository with submodules carry the login of each saved default account in such a setting. Only that value is
|
|
113
|
+
hidden, in the command's own words; the login is not returned, so no filter replaces it elsewhere.
|
|
114
|
+
"""
|
|
115
|
+
masked_arguments: list[str] = []
|
|
116
|
+
secret_values: list[str] = []
|
|
117
|
+
hide_next = False
|
|
118
|
+
for argument in arguments:
|
|
119
|
+
username_setting = CREDENTIAL_USERNAME_SETTING_PATTERN.fullmatch(argument)
|
|
120
|
+
if hide_next:
|
|
121
|
+
masked_arguments.append(MASKED_VALUE)
|
|
122
|
+
secret_values.append(argument)
|
|
123
|
+
elif username_setting is not None:
|
|
124
|
+
masked_arguments.append(username_setting.group(1) + MASKED_VALUE)
|
|
125
|
+
else:
|
|
126
|
+
masked_arguments.append(argument)
|
|
127
|
+
hide_next = argument.startswith("-") and SENSITIVE_NAME_PATTERN.fullmatch(argument.lstrip("-")) is not None
|
|
128
|
+
return MaskedArguments(masked_arguments, secret_values)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SensitiveTextFilter:
|
|
132
|
+
"""Filter complete text units and remember discovered secrets for later echoes."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, environment_by_name: Mapping[str, str] | None = None) -> None:
|
|
135
|
+
"""Accept an explicit environment for isolated tests and credential consumers."""
|
|
136
|
+
self.sensitive_value_set: set[str] = set()
|
|
137
|
+
for name, value in (os.environ if environment_by_name is None else environment_by_name).items():
|
|
138
|
+
if value and name.upper() not in NON_SECRET_ENVIRONMENT_NAME_SET and SENSITIVE_NAME_PATTERN.search(name):
|
|
139
|
+
self.sensitive_value_set.add(value)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def decode(value: str) -> str:
|
|
143
|
+
"""Recognize repeatedly encoded query keys without an unbounded decoding loop."""
|
|
144
|
+
for _ in range(DIAGNOSTIC_DECODE_LIMIT):
|
|
145
|
+
decoded = unquote(value)
|
|
146
|
+
if decoded == value:
|
|
147
|
+
break
|
|
148
|
+
value = decoded
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def normalized_name(cls, name: str) -> str:
|
|
153
|
+
"""Bring a field name to the form the sensitive names are compared in: decoded, lowercase, without - or _."""
|
|
154
|
+
return cls.decode(name).lower().replace("-", "").replace("_", "")
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def is_sensitive_name(cls, name: str) -> bool:
|
|
158
|
+
"""Use one policy for nested JSON fields, query keys and protocol assignments."""
|
|
159
|
+
return cls.normalized_name(name) in SENSITIVE_NORMALIZED_NAME_SET
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def is_readable_code_value(cls, name: str, value: object, qualified: bool = False) -> bool:
|
|
163
|
+
"""Tell whether a "code" field holds a short whole number, an exit, HTTP or Git error code that stays readable.
|
|
164
|
+
|
|
165
|
+
The OAuth authorization code shares that field name, but it is a long string of letters and digits: any value
|
|
166
|
+
that is not a short number, and every value of the other sensitive fields, stays hidden. ``qualified`` says the
|
|
167
|
+
field was written after a word such as "exit" or "status", which allows the longer Windows exit statuses.
|
|
168
|
+
"""
|
|
169
|
+
if cls.normalized_name(name) != AUTHORIZATION_CODE_FIELD_NAME:
|
|
170
|
+
return False
|
|
171
|
+
# JSON true and false are ints in Python, but no exit code: their text is not a number and they stay hidden.
|
|
172
|
+
if isinstance(value, int):
|
|
173
|
+
value = str(value)
|
|
174
|
+
if not isinstance(value, str):
|
|
175
|
+
return False
|
|
176
|
+
number = NUMERIC_CODE_VALUE_PATTERN.fullmatch(value)
|
|
177
|
+
if number is None:
|
|
178
|
+
return False
|
|
179
|
+
digit_limit = QUALIFIED_CODE_DIGIT_LIMIT if qualified else BARE_CODE_DIGIT_LIMIT
|
|
180
|
+
return len(number.group("digits")) <= digit_limit
|
|
181
|
+
|
|
182
|
+
def redact(self, text: str) -> str:
|
|
183
|
+
"""Retain safe response text while hiding structured, encoded and known secrets."""
|
|
184
|
+
text = ANSI_PATTERN.sub("", text).replace("\r\n", "\n").replace("\r", "\n")
|
|
185
|
+
candidate = text.strip()
|
|
186
|
+
if candidate.startswith(("{", "[")):
|
|
187
|
+
try:
|
|
188
|
+
value = json.loads(candidate)
|
|
189
|
+
self._collect_json(value)
|
|
190
|
+
return json.dumps(self._redact_json(value), ensure_ascii=False)
|
|
191
|
+
except (ValueError, RecursionError):
|
|
192
|
+
pass
|
|
193
|
+
text = URL_PATTERN.sub(self._redact_url, text)
|
|
194
|
+
text = SECRET_HEADER_PATTERN.sub(self._redact_header, text)
|
|
195
|
+
text = FIELD_ASSIGNMENT_PATTERN.sub(self._redact_assignment, text)
|
|
196
|
+
return self.hide_known_values(text)
|
|
197
|
+
|
|
198
|
+
def hide_known_values(self, text: str) -> str:
|
|
199
|
+
"""Replace every remembered value in already-parsed text, without learning anything new from it."""
|
|
200
|
+
for value in self.masked_values():
|
|
201
|
+
text = text.replace(value, MASKED_VALUE)
|
|
202
|
+
return text
|
|
203
|
+
|
|
204
|
+
def remember_values(self, values: list[str]) -> None:
|
|
205
|
+
"""Hide these raw values in every later text, such as the words a command hid; an empty value is skipped."""
|
|
206
|
+
for value in values:
|
|
207
|
+
# An empty value would make the replacement insert the masking text between every single character.
|
|
208
|
+
if value:
|
|
209
|
+
self.sensitive_value_set.add(value)
|
|
210
|
+
|
|
211
|
+
def masked_values(self) -> list[str]:
|
|
212
|
+
"""Return the remembered values that are long enough to hide, longest first so a value never splits a longer one.
|
|
213
|
+
|
|
214
|
+
The length rule lives here, where values are used, rather than where they are added: environment variables,
|
|
215
|
+
JSON fields, URLs and callers that add a value directly all end up in the same set, and none of them can bring
|
|
216
|
+
back a one-character value that would hide every occurrence of that character in the output.
|
|
217
|
+
"""
|
|
218
|
+
long_enough_values: list[str] = []
|
|
219
|
+
for value in self.sensitive_value_set:
|
|
220
|
+
if len(value) >= SENSITIVE_VALUE_MIN_LENGTH:
|
|
221
|
+
long_enough_values.append(value)
|
|
222
|
+
return sorted(long_enough_values, key=len, reverse=True)
|
|
223
|
+
|
|
224
|
+
def _remember(self, value: object) -> None:
|
|
225
|
+
"""Collect nested secret values before processing any public echo in the same unit."""
|
|
226
|
+
if isinstance(value, dict):
|
|
227
|
+
for nested in value.values():
|
|
228
|
+
self._remember(nested)
|
|
229
|
+
elif isinstance(value, list):
|
|
230
|
+
for nested in value:
|
|
231
|
+
self._remember(nested)
|
|
232
|
+
elif isinstance(value, str) and value and value not in {MASKED_VALUE, "[REDACTED]"}:
|
|
233
|
+
self.sensitive_value_set.add(value)
|
|
234
|
+
self.sensitive_value_set.add(self.decode(value))
|
|
235
|
+
|
|
236
|
+
def _collect_json(self, value: object) -> None:
|
|
237
|
+
"""Discover secret fields before rendering sibling fields that can echo them."""
|
|
238
|
+
if isinstance(value, dict):
|
|
239
|
+
for name, nested in value.items():
|
|
240
|
+
if self.is_sensitive_name(name) and not self.is_readable_code_value(name, nested):
|
|
241
|
+
self._remember(nested)
|
|
242
|
+
else:
|
|
243
|
+
self._collect_json(nested)
|
|
244
|
+
elif isinstance(value, list):
|
|
245
|
+
for nested in value:
|
|
246
|
+
self._collect_json(nested)
|
|
247
|
+
|
|
248
|
+
def _redact_json(self, value: object) -> object:
|
|
249
|
+
"""Preserve nested JSON structure while masking protected fields and echoed values."""
|
|
250
|
+
if isinstance(value, dict):
|
|
251
|
+
result_by_name: dict[str, object] = {}
|
|
252
|
+
for name, nested in value.items():
|
|
253
|
+
hidden = self.is_sensitive_name(name) and not self.is_readable_code_value(name, nested)
|
|
254
|
+
result_by_name[name] = MASKED_VALUE if hidden else self._redact_json(nested)
|
|
255
|
+
return result_by_name
|
|
256
|
+
if isinstance(value, list):
|
|
257
|
+
results: list[object] = []
|
|
258
|
+
for nested in value:
|
|
259
|
+
results.append(self._redact_json(nested))
|
|
260
|
+
return results
|
|
261
|
+
if isinstance(value, str):
|
|
262
|
+
return self.redact(value)
|
|
263
|
+
return value
|
|
264
|
+
|
|
265
|
+
def _redact_url(self, match: re.Match[str]) -> str:
|
|
266
|
+
"""Keep the destination path while masking userinfo and secret query values.
|
|
267
|
+
|
|
268
|
+
Only a userinfo that carries a password is remembered, to hide it wherever it is echoed. A user name alone, such
|
|
269
|
+
as the account login GitPane writes in the URL of a command, is hidden in its URL only: it is no secret, and the
|
|
270
|
+
same word is often the owner of the user's repositories and the user's own folder name, which must stay readable.
|
|
271
|
+
"""
|
|
272
|
+
value = match.group()
|
|
273
|
+
try:
|
|
274
|
+
parsed = urlsplit(value)
|
|
275
|
+
authority = parsed.netloc
|
|
276
|
+
if "@" in authority:
|
|
277
|
+
userinfo, _, destination = authority.rpartition("@")
|
|
278
|
+
_user, password_separator, password = userinfo.partition(":")
|
|
279
|
+
if password_separator:
|
|
280
|
+
self._remember(userinfo)
|
|
281
|
+
self._remember(password)
|
|
282
|
+
authority = MASKED_VALUE + "@" + destination
|
|
283
|
+
components: list[str] = []
|
|
284
|
+
for component in parsed.query.split("&"):
|
|
285
|
+
name, separator, content = component.partition("=")
|
|
286
|
+
if self.is_sensitive_name(name) and not self.is_readable_code_value(name, self.decode(content)):
|
|
287
|
+
self._remember(content)
|
|
288
|
+
component = name + separator + MASKED_VALUE
|
|
289
|
+
components.append(component)
|
|
290
|
+
return urlunsplit((parsed.scheme, authority, parsed.path, "&".join(components), parsed.fragment))
|
|
291
|
+
except ValueError:
|
|
292
|
+
return URL_USERINFO_PATTERN.sub(r"\1" + MASKED_VALUE + "@", value)
|
|
293
|
+
|
|
294
|
+
def _redact_header(self, match: re.Match[str]) -> str:
|
|
295
|
+
value = match.group(2)
|
|
296
|
+
self._remember(value)
|
|
297
|
+
for part in value.split(";"):
|
|
298
|
+
if "=" in part:
|
|
299
|
+
self._remember(part.partition("=")[-1].strip())
|
|
300
|
+
if value.lower().startswith(("bearer ", "basic ")):
|
|
301
|
+
self._remember(value.partition(" ")[-1])
|
|
302
|
+
return match.group(1) + MASKED_VALUE + ("\ufffd" if "\ufffd" in value else "")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def name_ends_another_word(match: re.Match[str]) -> bool:
|
|
307
|
+
"""Tell whether the field name found is only the end of a longer name, such as "code" in "passcode" or "otp_code".
|
|
308
|
+
|
|
309
|
+
The pattern finds a field name anywhere in a word, which keeps "my_token=..." hidden; but the readable short
|
|
310
|
+
number is allowed only to a "code" written as a word of its own, or right after "exit", "status", "error" or
|
|
311
|
+
"return".
|
|
312
|
+
"""
|
|
313
|
+
name_start = match.start("name")
|
|
314
|
+
if match.group("qualifier") is not None or name_start == 0:
|
|
315
|
+
return False
|
|
316
|
+
return WORD_CHARACTER_PATTERN.fullmatch(match.string[name_start - 1]) is not None
|
|
317
|
+
|
|
318
|
+
def _redact_assignment(self, match: re.Match[str]) -> str:
|
|
319
|
+
"""Mask protected assignments and remember their values for later echoes; a short exit or error code stays as is."""
|
|
320
|
+
value = match.group("value").strip("\"'")
|
|
321
|
+
if not self.name_ends_another_word(match) and self.is_readable_code_value(match.group("name"), value, match.group("qualifier") is not None):
|
|
322
|
+
return match.group()
|
|
323
|
+
self._remember(value)
|
|
324
|
+
return match.group("prefix") + MASKED_VALUE
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class JsonScanStatus(Enum):
|
|
328
|
+
"""Where a JSON unit stands at the end of a scanned line."""
|
|
329
|
+
|
|
330
|
+
UNFINISHED = "unfinished"
|
|
331
|
+
COMPLETE = "complete"
|
|
332
|
+
INVALID = "invalid"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class JsonExpectation(Enum):
|
|
336
|
+
"""The token kinds the JSON grammar accepts next."""
|
|
337
|
+
|
|
338
|
+
VALUE = "value"
|
|
339
|
+
VALUE_OR_CLOSE = "value or close"
|
|
340
|
+
KEY = "key"
|
|
341
|
+
KEY_OR_CLOSE = "key or close"
|
|
342
|
+
COLON = "colon"
|
|
343
|
+
COMMA_OR_CLOSE = "comma or close"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
JSON_VALUE_EXPECTATION_SET = frozenset({JsonExpectation.VALUE, JsonExpectation.VALUE_OR_CLOSE})
|
|
347
|
+
JSON_KEY_EXPECTATION_SET = frozenset({JsonExpectation.KEY, JsonExpectation.KEY_OR_CLOSE})
|
|
348
|
+
JSON_CLOSE_EXPECTATION_SET = frozenset({JsonExpectation.VALUE_OR_CLOSE, JsonExpectation.KEY_OR_CLOSE, JsonExpectation.COMMA_OR_CLOSE})
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class JsonUnitScanner:
|
|
352
|
+
"""Follow one JSON value line by line, so a stream knows at each line end whether to wait for more lines.
|
|
353
|
+
|
|
354
|
+
Re-parsing the whole unit with json.loads at every line cost the unit's size times its line count, and any line
|
|
355
|
+
that merely starts with a bracket, like Git's "[main 1a2b3c4] subject", kept failing and pulled in the rest of the
|
|
356
|
+
output. The scan reads each byte once and rejects such a line on the line where it appears. It only checks the
|
|
357
|
+
grammar; SensitiveTextFilter.redact still parses the finished unit itself.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
def __init__(self) -> None:
|
|
361
|
+
"""Start before the first token of the value."""
|
|
362
|
+
self.open_brackets: list[bytes] = []
|
|
363
|
+
self.expectation = JsonExpectation.VALUE
|
|
364
|
+
self.finished = False
|
|
365
|
+
|
|
366
|
+
def scan_line(self, content: bytearray, position: int, end: int) -> JsonScanStatus:
|
|
367
|
+
"""Read the complete line content[position:end] and tell whether the value ended, can continue, or is not JSON."""
|
|
368
|
+
while True:
|
|
369
|
+
position = JSON_BLANK_PATTERN.match(content, position, end).end()
|
|
370
|
+
if position == end:
|
|
371
|
+
return JsonScanStatus.COMPLETE if self.finished else JsonScanStatus.UNFINISHED
|
|
372
|
+
if self.finished:
|
|
373
|
+
return JsonScanStatus.INVALID
|
|
374
|
+
match = JSON_TOKEN_PATTERN.match(content, position, end)
|
|
375
|
+
if match is None or not self._accept(match):
|
|
376
|
+
return JsonScanStatus.INVALID
|
|
377
|
+
position = match.end()
|
|
378
|
+
|
|
379
|
+
def _accept(self, match: re.Match[bytes]) -> bool:
|
|
380
|
+
"""Apply one token to the grammar state, or return False when the grammar does not allow it here."""
|
|
381
|
+
punctuation = match.group("punctuation")
|
|
382
|
+
if punctuation is None:
|
|
383
|
+
if match.group("string") is not None and self.expectation in JSON_KEY_EXPECTATION_SET:
|
|
384
|
+
self.expectation = JsonExpectation.COLON
|
|
385
|
+
return True
|
|
386
|
+
if self.expectation not in JSON_VALUE_EXPECTATION_SET:
|
|
387
|
+
return False
|
|
388
|
+
self._end_value()
|
|
389
|
+
return True
|
|
390
|
+
if punctuation in (JSON_ARRAY_OPENING, JSON_OBJECT_OPENING):
|
|
391
|
+
if self.expectation not in JSON_VALUE_EXPECTATION_SET:
|
|
392
|
+
return False
|
|
393
|
+
self.open_brackets.append(punctuation)
|
|
394
|
+
self.expectation = JsonExpectation.VALUE_OR_CLOSE if punctuation == JSON_ARRAY_OPENING else JsonExpectation.KEY_OR_CLOSE
|
|
395
|
+
return True
|
|
396
|
+
if punctuation == JSON_NAME_SEPARATOR:
|
|
397
|
+
if self.expectation is not JsonExpectation.COLON:
|
|
398
|
+
return False
|
|
399
|
+
self.expectation = JsonExpectation.VALUE
|
|
400
|
+
return True
|
|
401
|
+
if punctuation == JSON_VALUE_SEPARATOR:
|
|
402
|
+
if self.expectation is not JsonExpectation.COMMA_OR_CLOSE:
|
|
403
|
+
return False
|
|
404
|
+
self.expectation = JsonExpectation.VALUE if self.open_brackets[INNERMOST_BRACKET_INDEX] == JSON_ARRAY_OPENING else JsonExpectation.KEY
|
|
405
|
+
return True
|
|
406
|
+
if not self.open_brackets or self.open_brackets[INNERMOST_BRACKET_INDEX] != JSON_OPENING_BY_CLOSING[punctuation]:
|
|
407
|
+
return False
|
|
408
|
+
if self.expectation not in JSON_CLOSE_EXPECTATION_SET:
|
|
409
|
+
return False
|
|
410
|
+
self.open_brackets.pop()
|
|
411
|
+
self._end_value()
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
def _end_value(self) -> None:
|
|
415
|
+
"""After a complete value, expect a separator or a closing bracket, or mark the whole unit finished."""
|
|
416
|
+
if self.open_brackets:
|
|
417
|
+
self.expectation = JsonExpectation.COMMA_OR_CLOSE
|
|
418
|
+
return
|
|
419
|
+
self.finished = True
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class TextUnitEnd(Enum):
|
|
423
|
+
"""Say whether a filtered unit completes a line or only redraws its current value."""
|
|
424
|
+
|
|
425
|
+
LINE = "line"
|
|
426
|
+
REDRAW = "redraw"
|
|
427
|
+
HIDDEN_TAIL = "hidden tail"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class TextValueSource(Enum):
|
|
431
|
+
"""Distinguish a newly received visible value from an already published preview."""
|
|
432
|
+
|
|
433
|
+
NEW = "new"
|
|
434
|
+
PUBLISHED = "published"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@dataclass(frozen=True)
|
|
438
|
+
class TextUnit:
|
|
439
|
+
"""Keep a complete filtered record separate from the value the console shows."""
|
|
440
|
+
|
|
441
|
+
text: str
|
|
442
|
+
shown_text: str | None
|
|
443
|
+
end: TextUnitEnd = TextUnitEnd.LINE
|
|
444
|
+
source: TextValueSource = TextValueSource.NEW
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class SensitiveTextStream:
|
|
448
|
+
"""Hold incomplete UTF-8 and JSON units until the common filter can protect them."""
|
|
449
|
+
|
|
450
|
+
def __init__(self, text_filter: SensitiveTextFilter | None = None, hides_value: Callable[[str], bool] | None = None) -> None:
|
|
451
|
+
"""Share a filter when several streams can repeat the same operation secret."""
|
|
452
|
+
self.filter = text_filter or SensitiveTextFilter()
|
|
453
|
+
self.hides_value = hides_value
|
|
454
|
+
self.buffer = bytearray()
|
|
455
|
+
self.complete = True
|
|
456
|
+
self.discarded = False
|
|
457
|
+
self.secret_header = False
|
|
458
|
+
# A JSON unit still open at the end of the previous call: it starts at the head of the buffer and its lines are
|
|
459
|
+
# scanned up to json_scan_end, so the next call resumes there instead of reading those lines again.
|
|
460
|
+
self.json_scanner: JsonUnitScanner | None = None
|
|
461
|
+
self.json_scan_end = 0
|
|
462
|
+
self.published_end = 0
|
|
463
|
+
self.redraw_end = 0
|
|
464
|
+
self.redraw_value = ""
|
|
465
|
+
self.redraw_value_start: int | None = None
|
|
466
|
+
|
|
467
|
+
def feed(self, data: bytes, final: bool = False) -> list[str]:
|
|
468
|
+
"""Return complete filtered records without the console-only redraws."""
|
|
469
|
+
texts: list[str] = []
|
|
470
|
+
for unit in self.feed_units(data, final):
|
|
471
|
+
if unit.end != TextUnitEnd.REDRAW:
|
|
472
|
+
texts.append(unit.text)
|
|
473
|
+
return texts
|
|
474
|
+
|
|
475
|
+
def feed_units(self, data: bytes, final: bool = False) -> list[TextUnit]:
|
|
476
|
+
"""Return complete records with safe previews of the currently open line."""
|
|
477
|
+
return self._feed_units(data, final)
|
|
478
|
+
|
|
479
|
+
def feed_pieces(self, data: bytes, final: bool = False) -> list[str]:
|
|
480
|
+
"""Return filtered pieces a downstream trace can read with the original carriage returns.
|
|
481
|
+
|
|
482
|
+
Complete values of an open line are filtered separately so progress can arrive before the newline without
|
|
483
|
+
exposing a partial secret. Protected units stay whole; completed records still use the shared unit scanner.
|
|
484
|
+
"""
|
|
485
|
+
pieces: list[str] = []
|
|
486
|
+
self._feed_units(data, final, pieces)
|
|
487
|
+
return pieces
|
|
488
|
+
|
|
489
|
+
def _feed_units(self, data: bytes, final: bool, pieces: list[str] | None = None) -> list[TextUnit]:
|
|
490
|
+
"""Return only safe complete units; a quota failure withholds the remaining stream.
|
|
491
|
+
|
|
492
|
+
A unit is one line, or, when its first non-blank byte opens JSON, the lines up to the end of that JSON value; an
|
|
493
|
+
unfinished JSON value waits for more output unless the stream ends. A line that cannot continue the value, such
|
|
494
|
+
as Git's "[main 1a2b3c4] subject", ends its unit at once, so it never holds back the lines after it. The buffer
|
|
495
|
+
is walked with an index and cut once per call, and each line is scanned once even when a JSON unit spans several
|
|
496
|
+
calls, so the cost grows with the output instead of with the output times its line count.
|
|
497
|
+
"""
|
|
498
|
+
if self.discarded:
|
|
499
|
+
return []
|
|
500
|
+
if len(self.buffer) + len(data) > TRACE_UNIT_LIMIT_BYTES:
|
|
501
|
+
self.buffer = bytearray()
|
|
502
|
+
self.json_scanner = None
|
|
503
|
+
self.complete = False
|
|
504
|
+
self.discarded = True
|
|
505
|
+
self.published_end = 0
|
|
506
|
+
self.reset_redraw()
|
|
507
|
+
text = f"Trace incomplete: text unit exceeds {TRACE_UNIT_LIMIT_BYTES} bytes; remaining stream withheld."
|
|
508
|
+
if pieces is not None:
|
|
509
|
+
pieces.append(text + "\n")
|
|
510
|
+
return [TextUnit(text, text)]
|
|
511
|
+
content = self.buffer
|
|
512
|
+
content.extend(data)
|
|
513
|
+
units: list[TextUnit] = []
|
|
514
|
+
if b"\n" not in data and not final:
|
|
515
|
+
# No unit can end without a new line break, and every earlier line break in the buffer was already tried.
|
|
516
|
+
if b"\r" in data:
|
|
517
|
+
self._publish_redraw(bytes(content), units, pieces)
|
|
518
|
+
return units
|
|
519
|
+
start = 0
|
|
520
|
+
scanner = self.json_scanner
|
|
521
|
+
scan_end = self.json_scan_end
|
|
522
|
+
# Where the blank run that begins at or after start ends: a run of blank lines is scanned once, not once per line.
|
|
523
|
+
blank_end = -1
|
|
524
|
+
while True:
|
|
525
|
+
line_start = start if scanner is None else scan_end
|
|
526
|
+
newline = content.find(b"\n", line_start)
|
|
527
|
+
if newline < 0:
|
|
528
|
+
break
|
|
529
|
+
end = newline + 1
|
|
530
|
+
if scanner is None:
|
|
531
|
+
if start > blank_end:
|
|
532
|
+
blank_end = LEADING_BLANK_PATTERN.match(content, start).end()
|
|
533
|
+
if not JSON_UNIT_START_PATTERN.match(content, blank_end):
|
|
534
|
+
units.append(self._finish_unit(bytes(content[start:end]), pieces))
|
|
535
|
+
start = end
|
|
536
|
+
continue
|
|
537
|
+
scanner = JsonUnitScanner()
|
|
538
|
+
if scanner.scan_line(content, line_start, end) is JsonScanStatus.UNFINISHED:
|
|
539
|
+
scan_end = end
|
|
540
|
+
continue
|
|
541
|
+
# The value is complete, or this line proves the unit is not JSON after all: either way the unit ends here,
|
|
542
|
+
# kept whole so a secret split across its lines is still filtered together.
|
|
543
|
+
units.append(self._finish_unit(bytes(content[start:end]), pieces))
|
|
544
|
+
scanner = None
|
|
545
|
+
start = end
|
|
546
|
+
del content[:start]
|
|
547
|
+
if final and content:
|
|
548
|
+
units.append(self._finish_unit(bytes(content), pieces))
|
|
549
|
+
content.clear()
|
|
550
|
+
scanner = None
|
|
551
|
+
self.json_scanner = scanner
|
|
552
|
+
self.json_scan_end = scan_end - start if scanner is not None else 0
|
|
553
|
+
if not final and b"\r" in data:
|
|
554
|
+
self._publish_redraw(bytes(content), units, pieces)
|
|
555
|
+
if final:
|
|
556
|
+
self.reset_redraw()
|
|
557
|
+
return units
|
|
558
|
+
|
|
559
|
+
def _allows_redraw(self, content: bytes, *, complete_unit: bool = False) -> bool:
|
|
560
|
+
"""Keep protected units whole before either previewing or forwarding any value."""
|
|
561
|
+
if (not complete_unit and self.json_scanner is not None) or b"\0" in content or JSON_UNIT_START_PATTERN.match(content):
|
|
562
|
+
return False
|
|
563
|
+
return not (self.secret_header and content.startswith((b" ", b"\t")))
|
|
564
|
+
|
|
565
|
+
def _publish_redraw(self, content: bytes, units: list[TextUnit], pieces: list[str] | None) -> None:
|
|
566
|
+
"""Forward every new complete value, or provide just the last console preview."""
|
|
567
|
+
if pieces is None:
|
|
568
|
+
redraw = self.open_line_redraw(content)
|
|
569
|
+
if redraw is not None:
|
|
570
|
+
units.append(redraw)
|
|
571
|
+
return
|
|
572
|
+
if not self._allows_redraw(content):
|
|
573
|
+
return
|
|
574
|
+
end = content.rfind(b"\r") + 1
|
|
575
|
+
if end <= self.published_end:
|
|
576
|
+
return
|
|
577
|
+
try:
|
|
578
|
+
text = content[self.published_end:end].decode("utf-8")
|
|
579
|
+
except UnicodeDecodeError:
|
|
580
|
+
return
|
|
581
|
+
for value in text.removesuffix("\r").split("\r"):
|
|
582
|
+
pieces.append(self.filter.redact(value) + "\r")
|
|
583
|
+
self.published_end = end
|
|
584
|
+
|
|
585
|
+
def _finish_unit(self, content: bytes, pieces: list[str] | None) -> TextUnit:
|
|
586
|
+
"""Finish a shared record while forwarding only values not already published."""
|
|
587
|
+
can_reuse_redraw = self.redraw_value_start is not None and self._allows_redraw(content, complete_unit=True) and b"\n" not in content.removesuffix(b"\n")
|
|
588
|
+
if pieces is None:
|
|
589
|
+
unit = self._prepare_unit(content)
|
|
590
|
+
elif self.published_end:
|
|
591
|
+
# A later binary value cannot retract earlier progress. Filter each remaining value so its opaque
|
|
592
|
+
# description does not discard the readable values after it or repeat the already forwarded prefix.
|
|
593
|
+
secret_header = self.secret_header
|
|
594
|
+
texts: list[str] = []
|
|
595
|
+
for value in content[self.published_end:].removesuffix(b"\n").split(b"\r"):
|
|
596
|
+
texts.append(self._prepare_unit(value).text)
|
|
597
|
+
self.secret_header = secret_header
|
|
598
|
+
pieces.append("\r".join(texts) + "\n")
|
|
599
|
+
unit = self._prepare_unit(content)
|
|
600
|
+
else:
|
|
601
|
+
preserves_redraw = self._allows_redraw(content, complete_unit=True) and b"\n" not in content.removesuffix(b"\n")
|
|
602
|
+
try:
|
|
603
|
+
content.decode("utf-8")
|
|
604
|
+
except UnicodeDecodeError:
|
|
605
|
+
preserves_redraw = False
|
|
606
|
+
unit = self._prepare_unit(content)
|
|
607
|
+
# In a single ordinary line, every newline introduced by the filter came from a carriage return.
|
|
608
|
+
text = unit.text.replace("\n", "\r") if preserves_redraw else unit.text
|
|
609
|
+
pieces.append(text + "\n")
|
|
610
|
+
if can_reuse_redraw:
|
|
611
|
+
end = len(content.removesuffix(b"\n"))
|
|
612
|
+
source = self._learn_redraw(content, end) or TextValueSource.NEW
|
|
613
|
+
unit = TextUnit(unit.text, unit.shown_text, unit.end, source)
|
|
614
|
+
self.reset_redraw()
|
|
615
|
+
self.published_end = 0
|
|
616
|
+
return unit
|
|
617
|
+
|
|
618
|
+
def open_line_redraw(self, open_line: bytes) -> TextUnit | None:
|
|
619
|
+
"""Learn new complete redraw values before choosing the last visible filtered preview.
|
|
620
|
+
|
|
621
|
+
The preview cursor is separate from the buffered record and forwarded pieces, which retain every value.
|
|
622
|
+
A quoted value split by a carriage return is filtered piece by piece, just as it is at a line feed. JSON,
|
|
623
|
+
binary text and folded secret headers must instead stay private until their whole unit can be filtered.
|
|
624
|
+
"""
|
|
625
|
+
if b"\r" not in open_line or not self._allows_redraw(open_line):
|
|
626
|
+
return None
|
|
627
|
+
end = open_line.rfind(b"\r") + 1
|
|
628
|
+
source = self._learn_redraw(open_line, end)
|
|
629
|
+
if source is None:
|
|
630
|
+
return None
|
|
631
|
+
# Another segment or stream may have taught the shared filter a secret contained in the retained value.
|
|
632
|
+
shown = terminal_line(self.filter.redact(self.redraw_value), LINE_SEPARATOR, self.hides_value)
|
|
633
|
+
if not shown:
|
|
634
|
+
return None
|
|
635
|
+
self.redraw_value = shown
|
|
636
|
+
return TextUnit(shown, shown, TextUnitEnd.REDRAW, source)
|
|
637
|
+
|
|
638
|
+
def _learn_redraw(self, content: bytes, end: int) -> TextValueSource | None:
|
|
639
|
+
"""Learn new segments while identifying their visible value by byte position, never by text equality."""
|
|
640
|
+
try:
|
|
641
|
+
text = content[self.redraw_end:end].decode("utf-8")
|
|
642
|
+
except UnicodeDecodeError:
|
|
643
|
+
return None
|
|
644
|
+
previous_start = self.redraw_value_start
|
|
645
|
+
start = self.redraw_end
|
|
646
|
+
for value in text.removesuffix("\r").split("\r"):
|
|
647
|
+
shown = terminal_line(self.filter.redact(value), LINE_SEPARATOR, self.hides_value)
|
|
648
|
+
if shown:
|
|
649
|
+
self.redraw_value = shown
|
|
650
|
+
self.redraw_value_start = start
|
|
651
|
+
start += len(value.encode("utf-8")) + len(TERMINAL_REDRAW_BREAK)
|
|
652
|
+
self.redraw_end = end
|
|
653
|
+
return TextValueSource.NEW if self.redraw_value_start != previous_start else TextValueSource.PUBLISHED
|
|
654
|
+
|
|
655
|
+
def reset_redraw(self) -> None:
|
|
656
|
+
"""Forget the completed line's preview without forgetting operation secrets."""
|
|
657
|
+
self.redraw_end = 0
|
|
658
|
+
self.redraw_value = ""
|
|
659
|
+
self.redraw_value_start = None
|
|
660
|
+
|
|
661
|
+
def _prepare_unit(self, content: bytes) -> TextUnit:
|
|
662
|
+
"""Remove terminal control sequences and folded secret headers before publication."""
|
|
663
|
+
if b"\0" in content:
|
|
664
|
+
text = OPAQUE_BODY_PREFIX + f"type=application/octet-stream; length={len(content)} bytes; result=opaque."
|
|
665
|
+
return TextUnit(text, text)
|
|
666
|
+
valid_utf8 = True
|
|
667
|
+
try:
|
|
668
|
+
text = content.decode("utf-8").rstrip("\n")
|
|
669
|
+
except UnicodeDecodeError:
|
|
670
|
+
valid_utf8 = False
|
|
671
|
+
self.complete = False
|
|
672
|
+
text = "Trace incomplete: invalid UTF-8 text. " + content.decode("utf-8", "replace").rstrip("\n")
|
|
673
|
+
if self.secret_header and text.startswith((" ", "\t")):
|
|
674
|
+
return TextUnit(MASKED_VALUE, MASKED_VALUE)
|
|
675
|
+
self.secret_header = bool(SECRET_HEADER_PATTERN.search(text))
|
|
676
|
+
text = self.filter.redact(text)
|
|
677
|
+
if not valid_utf8 or b"\n" in content.removesuffix(b"\n"):
|
|
678
|
+
return TextUnit(text, text)
|
|
679
|
+
# The shared filter has already turned each carriage return into a line feed, so those breaks are redraws here.
|
|
680
|
+
shown_text = terminal_line(text, LINE_SEPARATOR, self.hides_value)
|
|
681
|
+
last_value = terminal_line(text, LINE_SEPARATOR)
|
|
682
|
+
end = TextUnitEnd.LINE
|
|
683
|
+
if shown_text is not None and last_value and self.hides_value is not None and self.hides_value(last_value):
|
|
684
|
+
end = TextUnitEnd.HIDDEN_TAIL
|
|
685
|
+
return TextUnit(text, shown_text, end)
|
|
686
|
+
|
|
687
|
+
class NetworkTextStream:
|
|
688
|
+
"""Reconstruct curl display fragments before filtering complete HTTP bodies."""
|
|
689
|
+
|
|
690
|
+
def __init__(self, text_filter: SensitiveTextFilter, hides_value: Callable[[str], bool] | None = None) -> None:
|
|
691
|
+
"""Share discovered secrets with ordinary output and final error publication."""
|
|
692
|
+
self.filter = text_filter
|
|
693
|
+
self.stream = SensitiveTextStream(text_filter, hides_value)
|
|
694
|
+
self.buffer = b""
|
|
695
|
+
self.body = bytearray()
|
|
696
|
+
self.complete = True
|
|
697
|
+
self.discarded = False
|
|
698
|
+
|
|
699
|
+
def feed(self, data: bytes, final: bool = False) -> list[tuple[str, str]]:
|
|
700
|
+
"""Return complete filtered network records without console-only redraws."""
|
|
701
|
+
records: list[tuple[str, str]] = []
|
|
702
|
+
for kind, unit in self.feed_units(data, final):
|
|
703
|
+
if unit.end != TextUnitEnd.REDRAW:
|
|
704
|
+
records.append((kind, unit.text))
|
|
705
|
+
return records
|
|
706
|
+
|
|
707
|
+
def feed_units(self, data: bytes, final: bool = False) -> list[tuple[str, TextUnit]]:
|
|
708
|
+
"""Return safe ordinary records and reconstructed bodies, withholding oversized units."""
|
|
709
|
+
if self.discarded:
|
|
710
|
+
return []
|
|
711
|
+
content = self.buffer + data
|
|
712
|
+
self.buffer = b""
|
|
713
|
+
if len(content) > TRACE_UNIT_LIMIT_BYTES:
|
|
714
|
+
return self._discard()
|
|
715
|
+
records: list[tuple[str, TextUnit]] = []
|
|
716
|
+
while b"\n" in content or final and content:
|
|
717
|
+
line, separator, content = content.partition(b"\n")
|
|
718
|
+
match = CURL_DATA_PATTERN.search(line)
|
|
719
|
+
if match is not None:
|
|
720
|
+
self.stream.reset_redraw()
|
|
721
|
+
self.body.extend(match.group(1).rstrip(b"\r"))
|
|
722
|
+
if len(self.body) > TRACE_UNIT_LIMIT_BYTES:
|
|
723
|
+
return records + self._discard()
|
|
724
|
+
continue
|
|
725
|
+
if not CURL_DATA_SIZE_PATTERN.search(line):
|
|
726
|
+
records.extend(self._flush_body())
|
|
727
|
+
# Finish the unterminated last line directly: a discarded intermediate preview was never published.
|
|
728
|
+
for unit in self.stream.feed_units(line + separator, final=final and not separator):
|
|
729
|
+
records.append(("stderr", unit))
|
|
730
|
+
self.stream.reset_redraw()
|
|
731
|
+
self.buffer = content
|
|
732
|
+
if not final and b"\r" in data and not self.body and not CURL_DATA_PATTERN.search(content) and not CURL_DATA_SIZE_PATTERN.search(content):
|
|
733
|
+
redraw = self.stream.open_line_redraw(content)
|
|
734
|
+
if redraw is not None:
|
|
735
|
+
records.append(("stderr", redraw))
|
|
736
|
+
if final:
|
|
737
|
+
records.extend(self._flush_body())
|
|
738
|
+
for unit in self.stream.feed_units(b"", final=True):
|
|
739
|
+
records.append(("stderr", unit))
|
|
740
|
+
self.complete = self.complete and self.stream.complete
|
|
741
|
+
return records
|
|
742
|
+
|
|
743
|
+
def _flush_body(self) -> list[tuple[str, TextUnit]]:
|
|
744
|
+
"""Filter a complete body before any consumer receives its fragmented fields."""
|
|
745
|
+
if not self.body:
|
|
746
|
+
return []
|
|
747
|
+
stream = SensitiveTextStream(self.filter)
|
|
748
|
+
records = []
|
|
749
|
+
for unit in stream.feed_units(bytes(self.body), final=True):
|
|
750
|
+
records.append(("http-body", unit))
|
|
751
|
+
self.body.clear()
|
|
752
|
+
self.complete = self.complete and stream.complete
|
|
753
|
+
return records
|
|
754
|
+
|
|
755
|
+
def _discard(self) -> list[tuple[str, TextUnit]]:
|
|
756
|
+
"""Fail closed when a body or display line exceeds the bounded capture size."""
|
|
757
|
+
self.complete = False
|
|
758
|
+
self.discarded = True
|
|
759
|
+
self.body.clear()
|
|
760
|
+
self.buffer = b""
|
|
761
|
+
self.stream.reset_redraw()
|
|
762
|
+
text = "Trace incomplete: a network text unit exceeded the capture limit; remaining stream withheld."
|
|
763
|
+
return [("stderr", TextUnit(text, text))]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def terminal_line(line: str, redraw_break: str = TERMINAL_REDRAW_BREAK, hides_value: Callable[[str], bool] | None = None) -> str | None:
|
|
767
|
+
"""Keep the last nonempty visible value of a redrawn line without trailing padding.
|
|
768
|
+
|
|
769
|
+
A break at the very end does not redraw anything, so interrupted progress keeps its value. Return None when
|
|
770
|
+
every nonempty value is hidden, or an empty string when the line never held a nonempty value.
|
|
771
|
+
"""
|
|
772
|
+
hidden = False
|
|
773
|
+
for value in reversed(line.split(redraw_break)):
|
|
774
|
+
value = value.rstrip()
|
|
775
|
+
if not value:
|
|
776
|
+
continue
|
|
777
|
+
if hides_value is not None and hides_value(value):
|
|
778
|
+
hidden = True
|
|
779
|
+
continue
|
|
780
|
+
return value
|
|
781
|
+
return None if hidden else ""
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def terminal_text(text: str) -> str:
|
|
785
|
+
"""Return the final visible value of each terminal line without trailing padding.
|
|
786
|
+
|
|
787
|
+
Git redraws its progress lines with carriage returns ("Receiving objects: 10%", then "100%, done."), so only the
|
|
788
|
+
final value of each progress line is worth showing. A Windows line ending (carriage return then line feed) is an
|
|
789
|
+
ordinary line break, not a redraw. A final carriage return leaves the last nonempty value intact.
|
|
790
|
+
"""
|
|
791
|
+
lines: list[str] = []
|
|
792
|
+
for line in text.replace("\r\n", "\n").split("\n"):
|
|
793
|
+
lines.append(terminal_line(line))
|
|
794
|
+
return "\n".join(lines)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@dataclass(frozen=True)
|
|
798
|
+
class PreparedDiagnostic:
|
|
799
|
+
"""Hold already-filtered text so every output exposes the same details.
|
|
800
|
+
|
|
801
|
+
Only the "Arguments:" line of ``detail`` differs from Git's own text: it hides the value of each
|
|
802
|
+
``credential.<url>.username`` setting, while ``stderr``, ``cause`` and the rest of ``detail`` keep the login
|
|
803
|
+
wherever Git wrote it, such as in the URL of a refused repository.
|
|
804
|
+
|
|
805
|
+
``stderr`` is the filtered standard error alone, as a terminal would show it, so a form can show what Git said
|
|
806
|
+
without the command line and the rest of the detail.
|
|
807
|
+
|
|
808
|
+
``return_code`` and ``process_id`` say which process failed and how, when one did: an operation console that
|
|
809
|
+
already ended that process with its own line does not repeat the failure, and one that hid the process gives its
|
|
810
|
+
exit code instead.
|
|
811
|
+
"""
|
|
812
|
+
|
|
813
|
+
operation: str
|
|
814
|
+
cause: str
|
|
815
|
+
detail: str
|
|
816
|
+
masked: bool
|
|
817
|
+
stderr: str = ""
|
|
818
|
+
return_code: int | None = None
|
|
819
|
+
process_id: str = ""
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
@dataclass(frozen=True)
|
|
823
|
+
class Diagnostic:
|
|
824
|
+
"""Describe a failure without recording command stdout or the whole environment.
|
|
825
|
+
|
|
826
|
+
``process_started`` tells that the process was launched and has not exited yet, as for the record written when a
|
|
827
|
+
command starts: without an exit code, its status reads "running" instead of "not started".
|
|
828
|
+
|
|
829
|
+
``technical_detail`` holds facts meant for the details and the --log file only, such as object IDs: they follow
|
|
830
|
+
the cause line in the detail, filtered like the rest, and never enter the cause shown on screen.
|
|
831
|
+
|
|
832
|
+
``process_id`` names the process the failure comes from, when there is one, so an operation console can tell that
|
|
833
|
+
it already showed this failure and must not repeat it.
|
|
834
|
+
|
|
835
|
+
``ended_before_failure`` says that the process named had already ended normally when the failure happened, such
|
|
836
|
+
as the cleanup of the copies a comparison tool used: the status line then does not read as that process's failure
|
|
837
|
+
nor as a process that never started.
|
|
838
|
+
"""
|
|
839
|
+
|
|
840
|
+
operation: str
|
|
841
|
+
repository_dir: str
|
|
842
|
+
program_filename: str
|
|
843
|
+
arguments: list[str]
|
|
844
|
+
cause: str
|
|
845
|
+
stderr: bytes = b""
|
|
846
|
+
return_code: int | None = None
|
|
847
|
+
process_started: bool = False
|
|
848
|
+
technical_detail: str = ""
|
|
849
|
+
process_id: str = ""
|
|
850
|
+
ended_before_failure: bool = False
|
|
851
|
+
|
|
852
|
+
def prepare(self, environment_by_name: Mapping[str, str] | None = None) -> PreparedDiagnostic:
|
|
853
|
+
"""Decode complete byte output before redacting secrets across fragment boundaries."""
|
|
854
|
+
text_filter = SensitiveTextFilter(environment_by_name)
|
|
855
|
+
redact = text_filter.redact
|
|
856
|
+
masked = mask_sensitive_arguments(self.arguments)
|
|
857
|
+
safe_arguments = masked.arguments
|
|
858
|
+
text_filter.remember_values(masked.secret_values)
|
|
859
|
+
safe_stderr = self._normalize_network(self.stderr, text_filter)
|
|
860
|
+
safe_cause = self._normalize_network(self.cause.encode("utf-8", "replace"), text_filter)
|
|
861
|
+
|
|
862
|
+
if self.ended_before_failure:
|
|
863
|
+
return_text = EXIT_STATUS_ENDED_BEFORE_FAILURE
|
|
864
|
+
elif self.return_code is not None:
|
|
865
|
+
return_text = str(self.return_code)
|
|
866
|
+
elif self.process_started:
|
|
867
|
+
return_text = EXIT_STATUS_RUNNING
|
|
868
|
+
else:
|
|
869
|
+
return_text = EXIT_STATUS_NOT_STARTED
|
|
870
|
+
technical_text = ""
|
|
871
|
+
safe_technical_text = ""
|
|
872
|
+
if self.technical_detail:
|
|
873
|
+
technical_text = f"{DIAGNOSTIC_TECHNICAL_DETAIL_HEADER}\n{self.technical_detail}\n"
|
|
874
|
+
safe_technical_text = f"{DIAGNOSTIC_TECHNICAL_DETAIL_HEADER}\n{self._normalize_network(self.technical_detail.encode('utf-8', 'replace'), text_filter)}\n"
|
|
875
|
+
detail = (
|
|
876
|
+
f"{DIAGNOSTIC_OPERATION_PREFIX}{self.operation}\nRepository: {self.repository_dir}\nExecutable: {self.program_filename}\n"
|
|
877
|
+
f"Arguments: {self.arguments!r}\nExit status: {return_text}\n{DIAGNOSTIC_CAUSE_PREFIX}{self.cause}\n{technical_text}"
|
|
878
|
+
f"{DIAGNOSTIC_STDERR_HEADER}\n{self.stderr.decode('utf-8', 'replace')}"
|
|
879
|
+
)
|
|
880
|
+
safe_detail = (
|
|
881
|
+
f"{DIAGNOSTIC_OPERATION_PREFIX}{self.operation}\nRepository: {self.repository_dir}\nExecutable: {self.program_filename}\n"
|
|
882
|
+
f"Arguments: {safe_arguments!r}\nExit status: {return_text}\n{DIAGNOSTIC_CAUSE_PREFIX}{safe_cause}\n{safe_technical_text}{DIAGNOSTIC_STDERR_HEADER}\n{safe_stderr}"
|
|
883
|
+
)
|
|
884
|
+
redacted_detail = redact(safe_detail)
|
|
885
|
+
return PreparedDiagnostic(redact(self.operation), redact(safe_cause), redacted_detail, redacted_detail != detail, redact(terminal_text(safe_stderr)),
|
|
886
|
+
self.return_code, self.process_id)
|
|
887
|
+
|
|
888
|
+
@staticmethod
|
|
889
|
+
def _normalize_network(content: bytes, text_filter: SensitiveTextFilter) -> str:
|
|
890
|
+
"""Reuse live trace parsing when complete diagnostics contain curl body display lines."""
|
|
891
|
+
if not CURL_DATA_PATTERN.search(content):
|
|
892
|
+
return content.decode("utf-8", "replace")
|
|
893
|
+
records = NetworkTextStream(text_filter).feed(content, final=True)
|
|
894
|
+
return "\n".join(text for _kind, text in records)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class DiagnosticLog:
|
|
898
|
+
"""Write UTF-8 diagnostics into a unique session directory only when requested."""
|
|
899
|
+
|
|
900
|
+
def __init__(self, enabled: bool, base_dir: str, now: datetime | None = None) -> None:
|
|
901
|
+
"""Create an exclusive timestamped file without colliding with another launch."""
|
|
902
|
+
self.log_filename: str | None = None
|
|
903
|
+
self.stream: TextIO | None = None
|
|
904
|
+
self.warning = ""
|
|
905
|
+
self.resource = ExitStack()
|
|
906
|
+
# Lines also come from background tasks, such as the search of the credential manager: one writer at a time
|
|
907
|
+
# keeps lines whole. Reentrant, since a write that fails closes the log under the same lock.
|
|
908
|
+
self.lock = threading.RLock()
|
|
909
|
+
if not enabled:
|
|
910
|
+
return
|
|
911
|
+
try:
|
|
912
|
+
session_dir = os.path.join(base_dir, "diagnostic", uuid.uuid4().hex)
|
|
913
|
+
os.makedirs(session_dir, exist_ok=False)
|
|
914
|
+
basename = (now or datetime.now(UTC).astimezone()).strftime("%Y%m%d-%H%M%S_gitpane.log")
|
|
915
|
+
self.log_filename = os.path.join(session_dir, basename)
|
|
916
|
+
# The file context spans the application lifetime and closes with the owning ExitStack.
|
|
917
|
+
self.stream = self.resource.enter_context(open(self.log_filename, "x", encoding="utf-8", newline="\n")) # noqa: SIM115
|
|
918
|
+
except OSError as error:
|
|
919
|
+
self.warning = self._warning(error)
|
|
920
|
+
|
|
921
|
+
def write(self, diagnostic: PreparedDiagnostic) -> None:
|
|
922
|
+
"""Write only the prepared details, making a disk error non-fatal to Git reads."""
|
|
923
|
+
self.write_line(diagnostic.detail)
|
|
924
|
+
|
|
925
|
+
def write_line(self, text: str) -> None:
|
|
926
|
+
"""Write one already-filtered line, for records that are not error diagnostics; a disk error stays non-fatal.
|
|
927
|
+
|
|
928
|
+
Safe from any thread: a background task may write while the window's thread writes process lines.
|
|
929
|
+
"""
|
|
930
|
+
with self.lock:
|
|
931
|
+
if self.stream is None:
|
|
932
|
+
return
|
|
933
|
+
try:
|
|
934
|
+
self.stream.write(text + "\n")
|
|
935
|
+
self.stream.flush()
|
|
936
|
+
except OSError as error:
|
|
937
|
+
self.warning = self._warning(error)
|
|
938
|
+
self.close()
|
|
939
|
+
|
|
940
|
+
def close(self) -> None:
|
|
941
|
+
"""Flush and release this session's handler without touching another log; safe from any thread."""
|
|
942
|
+
with self.lock:
|
|
943
|
+
stream = self.stream
|
|
944
|
+
self.stream = None
|
|
945
|
+
if stream is None:
|
|
946
|
+
return
|
|
947
|
+
try:
|
|
948
|
+
self.resource.close()
|
|
949
|
+
except OSError as error:
|
|
950
|
+
self.warning = self._warning(error)
|
|
951
|
+
|
|
952
|
+
def _warning(self, error: OSError) -> str:
|
|
953
|
+
safe_error = Diagnostic("Log", "", "", [], str(error)).prepare().cause
|
|
954
|
+
return f"Could not write the log : {safe_error}. Error details remain available in the dialogs."
|