From 28f04f8b1cff63632d5547185e44ec185d78d14a Mon Sep 17 00:00:00 2001 From: Claudiu Schuster Date: Tue, 1 Sep 2026 22:36:09 +0200 Subject: [PATCH] Prevent reauthentication retry storms --- README.md | 13 ++--- VERSION | 2 +- bin/pdrive-state | 26 +++++++-- bin/pdrive-ui | 89 +++++++++++++++++++++++++++++-- docs/OPERATIONS.md | 27 +++++----- docs/QUICK_START.md | 13 ++--- libexec/pdrive-auth-failure-guard | 28 +++++++--- tests/test-auth-failure-guard.sh | 34 ++++++++++++ tests/test-state.sh | 10 ++++ tests/test-ui-widgets.sh | 23 ++++++++ 10 files changed, 225 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index ae1668e..9e03e75 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,10 @@ when needed; writes remain protected in the local VFS cache until uploaded. The supported target is Linux Mint 22.x with Cinnamon, Nemo and a normal graphical login. Arch Linux and Ubuntu are the first portability targets. The -Arch Linux `0.8.0-1` package candidate has passed its clean Cinnamon/X11 -desktop gate and is attached to the `v0.8.0` GitHub Release for configured -real-world review; it is not yet a generally supported Arch target. See the +Arch Linux `0.8.1-1` package candidate preserves the package baseline whose +`0.8.0-1` build passed its clean Cinnamon/X11 desktop gate. It is attached to +the `v0.8.1` GitHub Release for configured real-world review; Arch is not yet a +generally supported target. See the [distribution portability plan](docs/PORTABILITY.md) and the privacy-safe [real desktop release gates](docs/DESKTOP_GATES.md). @@ -96,12 +97,12 @@ cd proton-drive-linux pdrive-ui ``` -Arch reviewers can download the `proton-drive-linux-0.8.0-1-any.pkg.tar.zst` -asset from the [`v0.8.0` release](https://github.com/oss-singularity/proton-drive-linux/releases/tag/v0.8.0), +Arch reviewers can download the `proton-drive-linux-0.8.1-1-any.pkg.tar.zst` +asset from the [`v0.8.1` release](https://github.com/oss-singularity/proton-drive-linux/releases/tag/v0.8.1), verify the SHA-256 published in its release notes and install it with: ```bash -sudo pacman -U ./proton-drive-linux-0.8.0-1-any.pkg.tar.zst +sudo pacman -U ./proton-drive-linux-0.8.1-1-any.pkg.tar.zst pdrive-ui ``` diff --git a/VERSION b/VERSION index a3df0a6..6f4eebd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.8.1 diff --git a/bin/pdrive-state b/bin/pdrive-state index 8ee33cf..7c01cf3 100755 --- a/bin/pdrive-state +++ b/bin/pdrive-state @@ -21,7 +21,7 @@ from typing import Any SCHEMA_VERSION = 1 -TOOL_VERSION = "0.8.0" +TOOL_VERSION = "0.8.1" RECENT_TRANSFER_WINDOW_SECONDS = 24 * 60 * 60 RECENT_TRANSFER_LIMIT = 24 MOUNT_LOG_TAIL_BYTES = 512 * 1024 @@ -295,14 +295,24 @@ def rc_call( connection.close() -def collect_rc(*, include_vfs: bool) -> tuple[dict[str, dict[str, Any]], dict[str, str]]: +def collect_rc( + *, + include_vfs: bool, + include_backend: bool, +) -> tuple[dict[str, dict[str, Any]], dict[str, str]]: requests = [ ("core/stats", None), ("core/transferred", None), - ("backend/command", {"command": "data-bandwidth", "fs": "proton:"}), ] if include_vfs: requests.extend([("vfs/queue", None), ("vfs/stats", None)]) + # backend/command resolves its fs argument through rclone's backend cache. + # Before the mount has finished registering proton:, every poll can create + # another login-capable Proton backend instance. Keep startup and terminal + # authentication polling strictly local; query the live backend only after + # systemd and the FUSE mount both prove that the managed instance is ready. + if include_backend: + requests.append(("backend/command", {"command": "data-bandwidth", "fs": "proton:"})) payloads: dict[str, dict[str, Any]] = {} errors: dict[str, str] = {} with concurrent.futures.ThreadPoolExecutor(max_workers=len(requests)) as pool: @@ -1345,7 +1355,15 @@ def build_state(include_capacity: bool = False) -> dict[str, Any]: # vfs/* returns an error and writes an ERROR line to rclone's own log while # the RC socket is up but the mount has not registered its VFS yet. Avoid # manufacturing user-visible issues during this normal startup window. - rc_payloads, rc_errors = collect_rc(include_vfs=mount["ready"]) + authentication_blocked = authentication.get("status") in { + "rate-limited", + "reauthorization-required", + } + runtime_ready = bool(service.get("active") and mount["ready"] and not authentication_blocked) + rc_payloads, rc_errors = collect_rc( + include_vfs=mount["ready"], + include_backend=runtime_ready, + ) mount_log = recent_mount_log() stats = rc_payloads.get("core/stats", {}) transferred = rc_payloads.get("core/transferred", {}).get("transferred", []) diff --git a/bin/pdrive-ui b/bin/pdrive-ui index bcad3ef..424ceb4 100755 --- a/bin/pdrive-ui +++ b/bin/pdrive-ui @@ -60,7 +60,7 @@ PLATFORM_ADAPTER = load_platform_adapter() APP_ID = "io.github.claudiuschuster.PDriveControl" -VERSION = "0.8.0" +VERSION = "0.8.1" REFRESH_INTERVAL_SECONDS = 2 REFRESH_INTERVAL_OPTIONS = (1, 2, 5, 10) GRAPH_WINDOW_SECONDS = 5 * 60 @@ -1234,6 +1234,16 @@ window.pdrive-window { padding: 1px 3px; } .status-card { border-radius: 16px; padding: 13px 20px; } +.status-action-banner .status-card { transition: 120ms ease-in-out; } +.status-action-banner:hover .status-card, +.status-action-banner:focus .status-card { + border-color: #ef7084; + box-shadow: 0 2px 8px rgba(0,0,0,0.28); +} +.status-action-banner:active .status-card { + background: #3f1a22; + box-shadow: inset 0 2px 4px rgba(0,0,0,0.32); +} .page.overview-page { padding-top: 18px; } .status-ready { background: #12382f; border: 1px solid #23876e; } .status-working { background: #153746; border: 1px solid #2e91b5; } @@ -4173,7 +4183,68 @@ class PDriveWindow(Gtk.ApplicationWindow): right.pack_start(self.updated_label, False, False, 0) self.status_box.pack_end(right, False, False, 0) self.status_frame = card(self.status_box, "status-card", "status-unknown") - return self.status_frame + self.status_banner = Gtk.EventBox() + self.status_banner.set_visible_window(False) + self.status_banner.set_above_child(True) + self.status_banner.set_can_focus(False) + self.status_banner.add_events( + Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.ENTER_NOTIFY_MASK + | Gdk.EventMask.LEAVE_NOTIFY_MASK + | Gdk.EventMask.FOCUS_CHANGE_MASK + ) + self.status_banner.connect("button-release-event", self.on_status_banner_release) + self.status_banner.connect("key-release-event", self.on_status_banner_key_release) + self.status_banner.connect("enter-notify-event", self.on_status_banner_enter) + self.status_banner.connect("leave-notify-event", self.on_status_banner_leave) + self.status_banner.add(self.status_frame) + self.status_banner_reauthorizes = False + return self.status_banner + + def set_status_banner_reauthorization(self, enabled: bool) -> None: + self.status_banner_reauthorizes = enabled + self.status_banner.set_can_focus(enabled) + self.status_banner.set_tooltip_text(translate("Reauthorize Proton account …") if enabled else None) + context = self.status_banner.get_style_context() + if enabled: + context.add_class("status-action-banner") + else: + context.remove_class("status-action-banner") + event_window = self.status_banner.get_window() + if event_window is not None: + event_window.set_cursor(None) + + def on_status_banner_release(self, _widget: Gtk.Widget, event: Gdk.EventButton) -> bool: + if not self.status_banner_reauthorizes or event.button != 1: + return False + self.on_reauthorize(None) + return True + + def on_status_banner_key_release(self, _widget: Gtk.Widget, event: Gdk.EventKey) -> bool: + if not self.status_banner_reauthorizes: + return False + if event.keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_space): + return False + self.on_reauthorize(None) + return True + + def on_status_banner_enter(self, _widget: Gtk.Widget, _event: Gdk.EventCrossing) -> bool: + if not self.status_banner_reauthorizes: + return False + event_window = self.status_banner.get_window() + if event_window is not None: + display = event_window.get_display() + cursor = Gdk.Cursor.new_from_name(display, "pointer") + if cursor is None: + cursor = Gdk.Cursor.new_for_display(display, Gdk.CursorType.HAND2) + event_window.set_cursor(cursor) + return False + + def on_status_banner_leave(self, _widget: Gtk.Widget, _event: Gdk.EventCrossing) -> bool: + event_window = self.status_banner.get_window() + if event_window is not None: + event_window.set_cursor(None) + return False def graph_with_axes(self, graph: SpeedGraph) -> Gtk.Widget: overlay = Gtk.Overlay() @@ -4984,8 +5055,18 @@ class PDriveWindow(Gtk.ApplicationWindow): else: status_title = STATUS_META.get(live_status, STATUS_META["unknown"])[1] self.update_status(live_status, translate(status_title), live_summary) - self.status_action.set_visible(reauthorization_required) - self.reauthorize_menu_button.set_visible(reauthorization_required) + if reauthorization_required: + # Both controls use no-show-all while inactive. Showing only the + # parent would leave the custom menu row's icon and label hidden. + for control in (self.status_action, self.reauthorize_menu_button): + child = control.get_child() + if child is not None: + child.show_all() + control.show() + else: + self.status_action.hide() + self.reauthorize_menu_button.hide() + self.set_status_banner_reauthorization(reauthorization_required) issues = state.get("issues", {}) application = self.get_application() if ( diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 701c995..f9ba354 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -687,11 +687,12 @@ the current service start, writes mode-0600 `~/.local/state/rclone/pdrive-auth-state.json`, cancels the pending `Restart=on-failure` retry and sends one notification according to the Control Center notification preference. It never records a username, password, TOTP, -API URL or session token. The Overview then shows **Reauthorization required** -with a dedicated button; only while this state is active, the same action is -available under **hamburger menu → Reauthorize Proton account …**. Both actions -disappear again after successful reauthorization. A rate-limit state keeps the -action unavailable until its saved cooldown expires. +API URL or session token. The Overview then shows **Reauthorization required**; +the complete red banner and its dedicated button open the guided action by +pointer or keyboard. Only while this state is active, the same action is +available under **hamburger menu → Reauthorize Proton account …**. These actions +disappear again after successful reauthorization. A rate-limit state keeps them +unavailable until its saved cooldown expires. The native dialog uses the account already present in the encrypted configuration and requests only the current account password and optional fresh @@ -703,13 +704,15 @@ the old configuration and the stopped retry guard intact. A successful login backs up and atomically replaces the encrypted configuration, clears the one-time code, starts `/pdrive` and changes the authentication state to ready. -Only concrete HTTP 429 evidence in the private isolated-login log activates the -rate-limit state; generic login rejection and HTTP 422 do not. PDrive stores a -credential-free `retry_after` timestamp with a conservative one-hour cooldown, -shows it in the Overview, turns the dialog into a Close-only explanation and -blocks the hamburger-menu and direct-service-start bypasses. When the timestamp -expires, the normalized state automatically offers reauthorization again. A -successful isolated login clears either terminal state immediately. +Only concrete HTTP 429 evidence from Proton's authentication endpoint in the +current service-start log or the private isolated-login log activates the +rate-limit state; metadata 429 responses, generic login rejection and HTTP 422 +do not. PDrive stores a credential-free `retry_after` timestamp with a +conservative one-hour cooldown, shows it in the Overview, turns the dialog into +a Close-only explanation and blocks the hamburger-menu and direct-service-start +bypasses. When the timestamp expires, the normalized state automatically offers +reauthorization again. A successful isolated login clears either terminal state +immediately. The equivalent terminal fallback remains: diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index dc0def6..5e35d9e 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -12,9 +12,10 @@ upload. Detailed maintenance belongs in [Operations](OPERATIONS.md), not here. The supported target is Linux Mint 22.x with Cinnamon, Nemo and a graphical login. Arch Linux and Ubuntu are active portability targets. The Arch Linux -`0.8.0-1` candidate has passed its clean Cinnamon/X11 desktop gate and is ready -for configured real-world review, but is not yet a generally supported release -target; see [Distribution portability](PORTABILITY.md). +`0.8.1-1` candidate preserves the package baseline whose `0.8.0-1` build passed +its clean Cinnamon/X11 desktop gate. It is ready for configured real-world +review, but Arch is not yet a generally supported release target; see +[Distribution portability](PORTABILITY.md). ```bash git clone https://github.com/oss-singularity/proton-drive-linux.git @@ -24,12 +25,12 @@ pdrive-ui ``` For the reviewed Arch candidate, download -`proton-drive-linux-0.8.0-1-any.pkg.tar.zst` from the -[`v0.8.0` release](https://github.com/oss-singularity/proton-drive-linux/releases/tag/v0.8.0), +`proton-drive-linux-0.8.1-1-any.pkg.tar.zst` from the +[`v0.8.1` release](https://github.com/oss-singularity/proton-drive-linux/releases/tag/v0.8.1), verify its published SHA-256 and replace the source-install commands above with: ```bash -sudo pacman -U ./proton-drive-linux-0.8.0-1-any.pkg.tar.zst +sudo pacman -U ./proton-drive-linux-0.8.1-1-any.pkg.tar.zst pdrive-ui ``` diff --git a/libexec/pdrive-auth-failure-guard b/libexec/pdrive-auth-failure-guard index 590f4f7..926fe2b 100755 --- a/libexec/pdrive-auth-failure-guard +++ b/libexec/pdrive-auth-failure-guard @@ -147,7 +147,8 @@ notify_reauthorization_required() { } after_service_exit() { - local log_offset current_size new_log terminal_state_active=false + local log_offset current_size new_log current_status='' + local fresh_two_factor=false login_rate_limited=false [[ -r "${attempt_file}" && -f "${mount_log}" ]] || return 0 log_offset="$(awk -F= '$1 == "log_offset" && $2 ~ /^[0-9]+$/ { print $2; exit }' "${attempt_file}")" @@ -157,18 +158,31 @@ after_service_exit() { (( current_size >= log_offset )) || return 0 new_log="$(tail -c "+$((log_offset + 1))" -- "${mount_log}" 2>/dev/null || true)" - if ! grep -Fq \ + if grep -Fq \ "this account requires a 2FA code. Can be provided with --protondrive-2fa=000000" \ <<< "${new_log}"; then + fresh_two_factor=true + fi + # Only an HTTP 429 from Proton's authentication endpoint is a login + # cooldown. Metadata and file-operation 429 responses remain ordinary + # runtime evidence and must not suppress a later healthy service start. + if grep -Eqi \ + '(^|[^0-9])429([^0-9]|$)[^[:cntrl:]]*POST https://[^[:space:]]+/auth(/|[[:space:]:])' \ + <<< "${new_log}"; then + login_rate_limited=true + fi + if [[ "${fresh_two_factor}" == false && "${login_rate_limited}" == false ]]; then return 0 fi - if [[ -r "${auth_state_file}" ]] \ - && grep -Eq '"status": "(rate-limited|reauthorization-required)"' \ - "${auth_state_file}"; then - terminal_state_active=true + if [[ -r "${auth_state_file}" ]] && command -v jq >/dev/null 2>&1; then + current_status="$(jq -r '.status // empty' "${auth_state_file}" 2>/dev/null || true)" fi - if [[ "${terminal_state_active}" == false ]]; then + if [[ "${login_rate_limited}" == true && "${current_status}" != 'rate-limited' ]]; then + mark_rate_limited + elif [[ "${fresh_two_factor}" == true \ + && "${current_status}" != 'rate-limited' \ + && "${current_status}" != 'reauthorization-required' ]]; then write_auth_state 'reauthorization-required' 'two-factor-required' true notify_reauthorization_required fi diff --git a/tests/test-auth-failure-guard.sh b/tests/test-auth-failure-guard.sh index d4749b4..fb60832 100755 --- a/tests/test-auth-failure-guard.sh +++ b/tests/test-auth-failure-guard.sh @@ -105,6 +105,40 @@ grep -qF 'PDrive needs reauthorization' "${events}" run_guard --mark-healthy run_guard --start-allowed +ready_state="$(sha256sum "${state_dir}/pdrive-auth-state.json")" +service_stop_count="$(grep -c '^systemctl:' "${events}")" +run_guard --begin-start +printf '%s\n' \ + "2026/08/27 02:23:17 ERROR : proton drive root link ID 'redacted': 429 GET https://drive-api.proton.me/core/v4/users: Too many requests (Status=429)" \ + >> "${mount_log}" +run_guard --after-service-exit +[[ "$(sha256sum "${state_dir}/pdrive-auth-state.json")" == "${ready_state}" ]] +[[ "$(grep -c '^systemctl:' "${events}")" == "${service_stop_count}" ]] + +rate_limit_started="$(date +%s)" +run_guard --begin-start +printf '%s\n' \ + "2026/08/27 02:23:18 ERROR : proton drive root link ID 'redacted': 429 GET https://drive-api.proton.me/core/v4/users: Too many requests (Status=429)" \ + "2026/08/27 02:23:19 ERROR : rc: backend command: this account requires a 2FA code. Can be provided with --protondrive-2fa=000000" \ + "2026/08/27 02:23:20 CRITICAL: Failed to create file system: 429 POST https://drive-api.proton.me/auth/v4: Too many requests (Code=2011, Status=429)" \ + >> "${mount_log}" +notification_count="$(grep -c '^notify-send:' "${events}")" +run_guard --after-service-exit +jq -e ' + .status == "rate-limited" + and .reason == "login-rate-limited" + and .restart_suppressed == true + and (.retry_after | type == "string") +' "${state_dir}/pdrive-auth-state.json" >/dev/null +retry_epoch="$(date --date="$(jq -r .retry_after "${state_dir}/pdrive-auth-state.json")" +%s)" +(( retry_epoch >= rate_limit_started + 119 )) +(( retry_epoch <= rate_limit_started + 121 )) +[[ "$(grep -c '^notify-send:' "${events}")" == "${notification_count}" ]] +rate_limited_state="$(sha256sum "${state_dir}/pdrive-auth-state.json")" +run_guard --after-service-exit +[[ "$(sha256sum "${state_dir}/pdrive-auth-state.json")" == "${rate_limited_state}" ]] +run_guard --mark-healthy + rate_limit_started="$(date +%s)" run_guard --mark-rate-limited jq -e ' diff --git a/tests/test-state.sh b/tests/test-state.sh index cb78c26..79725a4 100755 --- a/tests/test-state.sh +++ b/tests/test-state.sh @@ -55,6 +55,7 @@ printf '%s\n' \ 'for argument in "$@"; do' \ ' case "${argument}" in core/*|vfs/*|backend/*) endpoint="${argument}" ;; esac' \ 'done' \ + 'if [[ -n "${PDRIVE_TEST_RC_LOG:-}" ]]; then printf "%s\\n" "${endpoint}" >> "${PDRIVE_TEST_RC_LOG}"; fi' \ 'if [[ "${PDRIVE_TEST_NO_VFS:-}" == 1 && "${endpoint}" == vfs/* ]]; then exit 99; fi' \ 'case "${endpoint}" in' \ ' core/stats)' \ @@ -457,6 +458,8 @@ jq -e ' ' "${finalizing_json}" >/dev/null startup_json="${test_root}/startup.json" +startup_rc_log="${test_root}/startup-rc.log" +: > "${startup_rc_log}" HOME="${test_home}" \ PATH="${fake_bin}:/usr/bin:/bin" \ PDRIVE_STATE_DIR="${state_dir}" \ @@ -465,6 +468,7 @@ PDRIVE_MOUNT_DIR="${test_root}/mount" \ PDRIVE_RC_SOCKET="${state_dir}/pdrive-rc.sock" \ PDRIVE_RCLONE_BIN="${fake_bin}/rclone-bin" \ PDRIVE_RC_TRANSPORT=cli \ +PDRIVE_TEST_RC_LOG="${startup_rc_log}" \ PDRIVE_TEST_NO_MOUNT=1 \ PDRIVE_TEST_NO_VFS=1 \ "${project_dir}/bin/pdrive-state" --compact > "${startup_json}" @@ -476,6 +480,12 @@ jq -e ' and .vfs.available == false and ([.health.components[].component | select(startswith("vfs/"))] | length) == 0 ' "${startup_json}" >/dev/null +grep -qFx 'core/stats' "${startup_rc_log}" +grep -qFx 'core/transferred' "${startup_rc_log}" +if grep -qFx 'backend/command' "${startup_rc_log}"; then + printf 'Startup state polling initialized the Proton backend.\n' >&2 + exit 1 +fi cat > "${state_dir}/pdrive-auth-state.json" <<'EOF' { diff --git a/tests/test-ui-widgets.sh b/tests/test-ui-widgets.sh index 978cd5a..d6c2706 100755 --- a/tests/test-ui-widgets.sh +++ b/tests/test-ui-widgets.sh @@ -573,6 +573,27 @@ assert window.status_action.get_visible() assert window.status_action.get_sensitive() assert window.status_action.get_tooltip_text() == "Reauthorize Proton account …" assert window.reauthorize_menu_button.get_visible() +assert all( + widget.get_visible() + for widget in descendants(window.reauthorize_menu_button) + if isinstance(widget, (module.Gtk.Image, module.Gtk.Label)) +) +assert window.status_banner.get_can_focus() +assert window.status_banner.get_tooltip_text() == "Reauthorize Proton account …" +original_on_reauthorize = window.on_reauthorize +banner_activations = [] +window.on_reauthorize = lambda _button: banner_activations.append("activated") +assert window.on_status_banner_release( + window.status_banner, + type("PointerEvent", (), {"button": 1})(), +) +assert banner_activations == ["activated"] +assert window.on_status_banner_key_release( + window.status_banner, + type("KeyEvent", (), {"keyval": module.Gdk.KEY_Return})(), +) +assert banner_activations == ["activated", "activated"] +window.on_reauthorize = original_on_reauthorize assert window.queue_card.value.get_text() == "–" assert window.queue_card.detail.get_text() == "Reauthorization needed" assert "local cache data remains protected" in window.live_summary.get_text() @@ -685,6 +706,8 @@ assert window.status_title.get_text() == "Login temporarily paused" assert "Try again after" in window.status_summary.get_text() assert not window.status_action.get_visible() assert not window.reauthorize_menu_button.get_visible() +assert not window.status_banner.get_can_focus() +assert window.status_banner.get_tooltip_text() is None assert window.queue_card.value.get_text() == "–" window.apply_state(module.demo_state())