diff --git a/README.md b/README.md index 615a3db..57abab0 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,9 @@ Keyring and provides a native GTK control center for the details that matter. - Independent upload/download file-data limits, guided connection tuning and guarded upload-slot, cache-retention, metadata-refresh and restart controls. - Conservative health monitoring with desktop notifications. -- Guided first setup, native account reauthorization, encrypted credentials and - signed rclone updates. +- Guided first setup, contextual same-account reauthorization, guarded account + switching with isolated cache namespaces, encrypted credentials and signed + rclone updates. This is an on-demand filesystem, not a full offline mirror. Reads download data when needed; writes remain protected in the local VFS cache until uploaded. @@ -99,6 +100,24 @@ untouched while the replacement login is verified. A real Proton rate limit is distinguished from rejected credentials and persists a local retry time so no UI or manual service start can create another premature login attempt. +For an intentional migration to another Proton account, open **Preferences → +Account → Change Proton account …**. PDrive refuses while any transfer, queued +upload or Dirty cache file remains, tests the candidate login in isolation, and +mounts it only through a fresh anonymous cache namespace. If activation fails, +the previous encrypted configuration, authentication state and cache namespace +are restored automatically. + + + + + + + + + + +
PDrive Preferences account section with the guarded Change Proton account actionPDrive guarded Proton account-change dialog after a successful safety preflight
A deliberate account action, separate from contextual reauthorizationFull native credentials, explicit confirmation and cache-isolation promise
+ Existing configuration, credentials, cache and state are preserved when the installer is run again. diff --git a/VERSION b/VERSION index 0a1ffad..8bd6ba8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.4 +0.7.5 diff --git a/bin/pdrive-account-switch b/bin/pdrive-account-switch new file mode 100755 index 0000000..f24f044 --- /dev/null +++ b/bin/pdrive-account-switch @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail +umask 077 + +readonly installed_backend="${PDRIVE_ACCOUNT_SWITCH_BACKEND:-${HOME}/.local/libexec/switch-rclone-proton-account}" +source_backend="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../libexec" && pwd)/switch-rclone-proton-account" +readonly source_backend + +usage() { + printf '%s\n' \ + 'Usage: pdrive-account-switch' \ + ' pdrive-account-switch --switch' \ + ' pdrive-account-switch --help' \ + '' \ + 'Guarded migration of the managed /pdrive mount to another Proton account.' \ + '' \ + 'No option and --help show only this action-free guidance.' \ + '--switch starts one interactive, transactional account change.' \ + '' \ + 'Safety workflow:' \ + ' 1. refuses active uploads, downloads, queued writes or Dirty VFS data;' \ + ' 2. reads the candidate username, password and optional 2FA privately;' \ + ' 3. authenticates the candidate in an isolated encrypted configuration;' \ + ' 4. rechecks the preflight before stopping the current mount;' \ + ' 5. assigns a new anonymous cache namespace and activates atomically;' \ + ' 6. restores the previous configuration and namespace if validation fails.' \ + '' \ + 'Previous cache namespaces and encrypted rollback data are retained.' \ + 'Credentials never enter argv, environment variables, logs or diagnostics.' +} + +case "${1:-}" in + '') + (( $# == 0 )) || { usage >&2; exit 2; } + usage + exit 0 + ;; + --switch) + (( $# == 1 )) || { usage >&2; exit 2; } + ;; + -h|--help) + (( $# == 1 )) || { usage >&2; exit 2; } + usage + exit 0 + ;; + *) + printf 'Unknown option: %s (help: pdrive-account-switch --help)\n' "$1" >&2 + exit 2 + ;; +esac + +backend="${installed_backend}" +if [[ ! -x "${backend}" && -x "${source_backend}" ]]; then + backend="${source_backend}" +fi +if [[ ! -x "${backend}" ]]; then + printf 'Account-switch backend is missing: %s\n' "${backend}" >&2 + exit 69 +fi +if [[ ! -t 0 || ! -t 1 ]]; then + printf 'Run this command in a normal interactive terminal.\n' >&2 + exit 64 +fi + +printf '%s\n' \ + 'PDrive Proton account change' \ + '============================' \ + '' \ + 'This changes the remote account mounted at /pdrive. The current account' \ + 'configuration and cache stay separate and are retained for rollback.' \ + 'Pending or active work blocks the change without modifying anything.' \ + '' + +"${backend}" --preflight +printf '\nType CHANGE ACCOUNT to confirm this intentional migration: ' +IFS= read -r confirmation +if [[ "${confirmation}" != 'CHANGE ACCOUNT' ]]; then + printf 'Cancelled; nothing was changed.\n' + exit 0 +fi + +read -r -p 'New Proton username or email address: ' proton_username +read -r -s -p 'New Proton password: ' proton_password +printf '\n' +read -r -s -p 'Repeat password: ' proton_password_confirm +printf '\n' +if [[ -z "${proton_username}" || -z "${proton_password}" \ + || "${proton_password}" != "${proton_password_confirm}" ]]; then + unset proton_password proton_password_confirm + printf 'Username is empty or passwords do not match.\n' >&2 + exit 65 +fi +unset proton_password_confirm +read -r -s -p 'Fresh 6-digit 2FA code (empty when disabled): ' proton_2fa +printf '\n' +if [[ -n "${proton_2fa}" && ! "${proton_2fa}" =~ ^[0-9]{6}$ ]]; then + unset proton_password proton_2fa + printf 'The 2FA code must be empty or exactly six digits.\n' >&2 + exit 65 +fi + +set +e +printf '%s\0' "${proton_username}" "${proton_password}" "${proton_2fa}" \ + | "${backend}" --switch-from-stdin +switch_rc=$? +set -e +unset proton_username proton_password proton_2fa +exit "${switch_rc}" diff --git a/bin/pdrive-draft-recovery b/bin/pdrive-draft-recovery index f40226f..015c132 100755 --- a/bin/pdrive-draft-recovery +++ b/bin/pdrive-draft-recovery @@ -7,8 +7,26 @@ umask 077 readonly draft_config="${HOME}/.config/pdrive-draft-recovery.conf" readonly recovery_config="${HOME}/.config/pdrive-recovery.conf" readonly service_name='rclone-proton-drive.service' -readonly vfs_meta_root="${HOME}/.cache/rclone/vfsMeta" readonly auto_helper="${HOME}/.local/libexec/pdrive-draft-recovery-auto" +source_account_cache_helper="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../libexec" && pwd)/pdrive-account-cache" +readonly source_account_cache_helper +readonly account_cache_helper="${PDRIVE_ACCOUNT_CACHE_HELPER:-${HOME}/.local/libexec/pdrive-account-cache}" + +resolve_cache_dir() { + local helper="${account_cache_helper}" + if [[ ! -x "${helper}" && -x "${source_account_cache_helper}" ]]; then + helper="${source_account_cache_helper}" + fi + if [[ ! -x "${helper}" ]]; then + printf 'Account cache resolver is missing: %s\n' "${helper}" >&2 + return 69 + fi + "${helper}" --path +} + +cache_dir="$(resolve_cache_dir)" +readonly cache_dir +readonly vfs_meta_root="${cache_dir}/vfsMeta" usage() { printf '%s\n' \ diff --git a/bin/pdrive-recovery b/bin/pdrive-recovery index 7bdf354..135b17f 100755 --- a/bin/pdrive-recovery +++ b/bin/pdrive-recovery @@ -6,7 +6,25 @@ umask 077 readonly recovery_config="${HOME}/.config/pdrive-recovery.conf" readonly service_name='rclone-proton-drive.service' -readonly vfs_meta_root="${HOME}/.cache/rclone/vfsMeta" +source_account_cache_helper="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../libexec" && pwd)/pdrive-account-cache" +readonly source_account_cache_helper +readonly account_cache_helper="${PDRIVE_ACCOUNT_CACHE_HELPER:-${HOME}/.local/libexec/pdrive-account-cache}" + +resolve_cache_dir() { + local helper="${account_cache_helper}" + if [[ ! -x "${helper}" && -x "${source_account_cache_helper}" ]]; then + helper="${source_account_cache_helper}" + fi + if [[ ! -x "${helper}" ]]; then + printf 'Account cache resolver is missing: %s\n' "${helper}" >&2 + return 69 + fi + "${helper}" --path +} + +cache_dir="$(resolve_cache_dir)" +readonly cache_dir +readonly vfs_meta_root="${cache_dir}/vfsMeta" usage() { printf '%s\n' \ diff --git a/bin/pdrive-refresh b/bin/pdrive-refresh index 8cea9fd..3979754 100755 --- a/bin/pdrive-refresh +++ b/bin/pdrive-refresh @@ -9,7 +9,25 @@ readonly rc_socket="${HOME}/.local/state/rclone/pdrive-rc.sock" readonly rclone_bin="${HOME}/.local/bin/rclone" readonly watch_bin="${HOME}/.local/bin/pdrive-watch" readonly recovery_config="${HOME}/.config/pdrive-recovery.conf" -readonly vfs_meta_root="${HOME}/.cache/rclone/vfsMeta" +source_account_cache_helper="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../libexec" && pwd)/pdrive-account-cache" +readonly source_account_cache_helper +readonly account_cache_helper="${PDRIVE_ACCOUNT_CACHE_HELPER:-${HOME}/.local/libexec/pdrive-account-cache}" + +resolve_cache_dir() { + local helper="${account_cache_helper}" + if [[ ! -x "${helper}" && -x "${source_account_cache_helper}" ]]; then + helper="${source_account_cache_helper}" + fi + if [[ ! -x "${helper}" ]]; then + printf 'Account cache resolver is missing: %s\n' "${helper}" >&2 + return 69 + fi + "${helper}" --path +} + +cache_dir="$(resolve_cache_dir)" +readonly cache_dir +readonly vfs_meta_root="${cache_dir}/vfsMeta" usage() { printf '%s\n' \ diff --git a/bin/pdrive-state b/bin/pdrive-state index 331d05d..fad33e1 100755 --- a/bin/pdrive-state +++ b/bin/pdrive-state @@ -21,7 +21,7 @@ from typing import Any SCHEMA_VERSION = 1 -TOOL_VERSION = "0.7.4" +TOOL_VERSION = "0.7.5" RECENT_TRANSFER_WINDOW_SECONDS = 24 * 60 * 60 RECENT_TRANSFER_LIMIT = 24 MOUNT_LOG_TAIL_BYTES = 512 * 1024 diff --git a/bin/pdrive-ui b/bin/pdrive-ui index 8d18de3..63f36a3 100755 --- a/bin/pdrive-ui +++ b/bin/pdrive-ui @@ -44,7 +44,7 @@ except (ImportError, ValueError): APP_ID = "io.github.claudiuschuster.PDriveControl" -VERSION = "0.7.4" +VERSION = "0.7.5" REFRESH_INTERVAL_SECONDS = 2 REFRESH_INTERVAL_OPTIONS = (1, 2, 5, 10) GRAPH_WINDOW_SECONDS = 5 * 60 @@ -52,6 +52,7 @@ BANDWIDTH_SLIDER_MAX_RATE = 100.0 BANDWIDTH_SLIDER_UNLIMITED = 110.0 BANDWIDTH_NEAR_PAUSE_MIB = 0.02 SETUP_TIMEOUT_SECONDS = 180 +ACCOUNT_SWITCH_TIMEOUT_SECONDS = 300 AUTOSTART_MARKER = "X-PDrive-Control-Center=true" PROJECT_URL = "https://github.com/oss-singularity/proton-drive-linux" DOCUMENTATION_URL = f"{PROJECT_URL}#readme" @@ -189,6 +190,10 @@ GERMAN_TRANSLATIONS = { "Safely restart service …": "Dienst sicher neu starten …", "Reauthorize Proton account …": "Proton-Konto neu autorisieren …", "Reauthorization is available in the installed application when the account requires it.": "Die neue Autorisierung ist in der installierten Anwendung verfügbar, sobald das Konto sie benötigt.", + "Change Proton account …": "Proton-Konto wechseln …", + "Change Proton account": "Proton-Konto wechseln", + "Account": "Konto", + "Move the managed /pdrive mount to another Proton account. Active transfers and protected pending data block the change.": "Verschiebe den verwalteten /pdrive-Mount zu einem anderen Proton-Konto. Aktive Übertragungen und geschützte ausstehende Daten blockieren den Wechsel.", "Documentation …": "Handbuch …", "Documentation": "Handbuch", "Quick start": "Schnellstart", @@ -557,6 +562,39 @@ GERMAN_TRANSLATIONS = { "Proton did not accept the login. Check the password and retry once with a fresh 2FA code.": "Proton hat die Anmeldung nicht akzeptiert. Prüfe das Passwort und versuche es einmal mit einem frischen 2FA-Code erneut.", "The existing encrypted configuration was not changed.": "Die bestehende verschlüsselte Konfiguration wurde nicht verändert.", "The existing encrypted configuration was updated safely.": "Die bestehende verschlüsselte Konfiguration wurde sicher aktualisiert.", + "Change account securely": "Konto sicher wechseln", + "Move /pdrive to another account": "/pdrive zu einem anderen Konto verschieben", + "The mounted Proton account and remote namespace will change. Existing local cache data stays assigned to the previous account and is never merged.": "Das gemountete Proton-Konto und der entfernte Namensraum ändern sich. Vorhandene lokale Cachedaten bleiben dem bisherigen Konto zugeordnet und werden niemals zusammengeführt.", + "Checking active transfers, queued uploads and protected Dirty cache data …": "Aktive Übertragungen, eingereihte Uploads und geschützte Dirty-Cachedaten werden geprüft …", + "New username or email": "Neuer Benutzername oder E-Mail", + "New password": "Neues Passwort", + "Repeat new password": "Neues Passwort wiederholen", + "Fresh 2FA code (optional)": "Frischer 2FA-Code (optional)", + "Credentials travel through one anonymous pipe. They never enter process arguments, environment variables, logs, diagnostics or screenshots.": "Zugangsdaten laufen durch eine anonyme Pipe. Sie gelangen niemals in Prozessargumente, Umgebungsvariablen, Logs, Diagnosen oder Screenshots.", + "I understand that /pdrive will show the new account and that previous cache data remains separate.": "Ich verstehe, dass /pdrive das neue Konto zeigt und bisherige Cachedaten getrennt bleiben.", + "Confirm the intentional Proton account migration": "Den beabsichtigten Proton-Kontowechsel bestätigen", + "Authenticating the candidate and activating its isolated cache namespace …": "Das neue Konto wird authentifiziert und sein isolierter Cache-Namensraum aktiviert …", + "Ready: no active transfers, queued uploads or Dirty cache data were found.": "Bereit: Es wurden keine aktiven Übertragungen, eingereihten Uploads oder Dirty-Cachedaten gefunden.", + "Account change is blocked while an upload, download or VFS queue item is active.": "Der Kontowechsel ist blockiert, solange ein Upload, Download oder VFS-Warteschlangeneintrag aktiv ist.", + "Account change is blocked while protected Dirty cache data awaits upload.": "Der Kontowechsel ist blockiert, solange geschützte Dirty-Cachedaten auf den Upload warten.", + "The account-switch helper could not be started.": "Der Helfer für den Kontowechsel konnte nicht gestartet werden.", + "Account safety could not be verified. The current account and cache remain unchanged.": "Die Kontosicherheit konnte nicht geprüft werden. Das aktuelle Konto und der Cache bleiben unverändert.", + "Account changes are disabled in demo mode.": "Kontowechsel sind im Demo-Modus deaktiviert.", + "Enter the new Proton username or email address.": "Gib den neuen Proton-Benutzernamen oder die E-Mail-Adresse ein.", + "Confirm the intentional Proton account migration.": "Bestätige den beabsichtigten Proton-Kontowechsel.", + "Proton account changed": "Proton-Konto gewechselt", + "/pdrive is mounted through the new account’s separate cache namespace.": "/pdrive ist über den getrennten Cache-Namensraum des neuen Kontos gemountet.", + "The previous encrypted configuration and cache namespace remain available for rollback.": "Die vorherige verschlüsselte Konfiguration und der Cache-Namensraum bleiben für ein Rollback verfügbar.", + "Previous account restored": "Vorheriges Konto wiederhergestellt", + "The candidate login worked, but its mount failed validation. PDrive restored the previous configuration and cache namespace.": "Die Anmeldung des neuen Kontos funktionierte, aber sein Mount bestand die Validierung nicht. PDrive hat die vorherige Konfiguration und den Cache-Namensraum wiederhergestellt.", + "No cache data was reassigned or removed.": "Keine Cachedaten wurden neu zugeordnet oder entfernt.", + "A transfer or queued upload began during validation. The account change was cancelled safely.": "Während der Validierung begann eine Übertragung oder ein eingereihter Upload. Der Kontowechsel wurde sicher abgebrochen.", + "Dirty cache data appeared during validation. The account change was cancelled safely.": "Während der Validierung erschienen Dirty-Cachedaten. Der Kontowechsel wurde sicher abgebrochen.", + "Proton temporarily rate-limited the candidate login. Wait for the full backoff before retrying.": "Proton hat die Anmeldung des neuen Kontos vorübergehend begrenzt. Warte den vollständigen Backoff ab, bevor du es erneut versuchst.", + "The previous files were restored, but /pdrive still needs diagnostics. Do not delete any cache namespace.": "Die vorherigen Dateien wurden wiederhergestellt, aber /pdrive benötigt weiterhin eine Diagnose. Lösche keinen Cache-Namensraum.", + "The account change timed out. Inspect PDrive before making another login attempt.": "Der Kontowechsel hat das Zeitlimit überschritten. Prüfe PDrive, bevor du einen weiteren Loginversuch startest.", + "Proton did not accept the candidate login. Check the credentials and retry once with a fresh 2FA code.": "Proton hat die Anmeldung des neuen Kontos nicht akzeptiert. Prüfe die Zugangsdaten und versuche es einmal mit einem frischen 2FA-Code erneut.", + "The current account, mount and cache were not changed.": "Das aktuelle Konto, der Mount und der Cache wurden nicht verändert.", "Proton account reauthorization required": "Neue Autorisierung des Proton-Kontos erforderlich", "Open PDrive Control Center and complete one guided reauthorization. Automatic login retries are stopped.": "Öffne das PDrive Control Center und führe einmal die geführte neue Autorisierung durch. Automatische Loginversuche sind gestoppt.", "Proton account reauthorized": "Proton-Konto neu autorisiert", @@ -655,6 +693,16 @@ def reauth_backend_path() -> str: return str(pathlib.Path(__file__).resolve().parent.parent / "libexec/reauth-rclone-proton") +def account_switch_backend_path() -> str: + configured = os.environ.get("PDRIVE_ACCOUNT_SWITCH_BACKEND") + if configured: + return configured + installed = pathlib.Path.home() / ".local/libexec/switch-rclone-proton-account" + if installed.is_file() and os.access(installed, os.X_OK): + return str(installed) + return str(pathlib.Path(__file__).resolve().parent.parent / "libexec/switch-rclone-proton-account") + + def mount_path() -> pathlib.Path: return pathlib.Path(os.environ.get("PDRIVE_MOUNT_DIR", "/pdrive")).expanduser() @@ -690,7 +738,10 @@ def setup_readiness() -> dict[str, Any]: try: rclone_ready = ( subprocess.run( - [resolve_command("PDRIVE_PREREQUISITES_BIN", "pdrive-prerequisites"), "--check"], + [ + resolve_command("PDRIVE_PREREQUISITES_BIN", "pdrive-prerequisites"), + "--check", + ], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -1757,7 +1808,10 @@ class SpeedGraph(Gtk.DrawingArea): window_start = latest - GRAPH_WINDOW_SECONDS return [ ( - max(0.0, min(width, width * (observed - window_start) / GRAPH_WINDOW_SECONDS)), + max( + 0.0, + min(width, width * (observed - window_start) / GRAPH_WINDOW_SECONDS), + ), height - (value / peak) * (height - 10) - 5, ) for observed, value in self.samples @@ -2509,7 +2563,11 @@ class SetupWizard(Gtk.Box): self.bandwidth_unlimited = Gtk.RadioButton.new_with_label_from_widget( self.bandwidth_auto, translate("Unlimited bulk transfers") ) - for radio in (self.bandwidth_auto, self.bandwidth_manual, self.bandwidth_unlimited): + for radio in ( + self.bandwidth_auto, + self.bandwidth_manual, + self.bandwidth_unlimited, + ): set_pointer_on_hover(radio) radio.connect("toggled", self.on_bandwidth_mode_changed) @@ -2848,7 +2906,11 @@ class SetupWizard(Gtk.Box): self.bandwidth_spinner.start() self.bandwidth_continue_button.set_sensitive(False) self.bandwidth_back_button.set_sensitive(False) - for radio in (self.bandwidth_auto, self.bandwidth_manual, self.bandwidth_unlimited): + for radio in ( + self.bandwidth_auto, + self.bandwidth_manual, + self.bandwidth_unlimited, + ): radio.set_sensitive(False) threading.Thread( target=self.bandwidth_worker, @@ -2919,7 +2981,11 @@ class SetupWizard(Gtk.Box): self.bandwidth_progress.hide() self.bandwidth_continue_button.set_sensitive(True) self.bandwidth_back_button.set_sensitive(True) - for radio in (self.bandwidth_auto, self.bandwidth_manual, self.bandwidth_unlimited): + for radio in ( + self.bandwidth_auto, + self.bandwidth_manual, + self.bandwidth_unlimited, + ): radio.set_sensitive(True) context = self.bandwidth_result.get_style_context() @@ -3325,6 +3391,406 @@ class ReauthorizationDialog(Gtk.Dialog): return GLib.SOURCE_REMOVE +class AccountSwitchDialog(Gtk.Dialog): + """Guarded migration to a separately cached Proton account.""" + + def __init__(self, window: "PDriveWindow", demo: bool = False) -> None: + super().__init__( + title=translate("Change Proton account"), + transient_for=None if demo else window, + modal=True, + ) + self.parent_window = window + self.demo = demo + self.busy = False + self.completed = False + self.preflight_running = False + self.preflight_ready = False + self.set_default_size(610, -1) + self.add_buttons( + translate("Cancel"), + Gtk.ResponseType.CANCEL, + translate("Change account securely"), + Gtk.ResponseType.OK, + ) + self.cancel_button = self.get_widget_for_response(Gtk.ResponseType.CANCEL) + self.switch_button = self.get_widget_for_response(Gtk.ResponseType.OK) + add_css(self.switch_button, "setup-primary") + self.set_default_response(Gtk.ResponseType.OK) + self.connect("response", self.on_response) + self.connect("delete-event", self.on_delete) + + content = self.get_content_area() + content.set_spacing(12) + content.set_margin_top(18) + content.set_margin_bottom(18) + content.set_margin_start(18) + content.set_margin_end(18) + + hero = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) + self.hero_icon = Gtk.Image.new_from_icon_name("system-switch-user-symbolic", Gtk.IconSize.DIALOG) + self.hero_icon.set_pixel_size(46) + add_css(self.hero_icon, "warning") + hero.pack_start(self.hero_icon, False, False, 0) + hero_text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self.hero_title = label("Move /pdrive to another account", "status-title") + self.hero_detail = label( + "The mounted Proton account and remote namespace will change. Existing local cache data stays assigned to the previous account and is never merged.", + "status-detail", + ) + self.hero_detail.set_line_wrap(True) + hero_text.pack_start(self.hero_title, False, False, 0) + hero_text.pack_start(self.hero_detail, False, False, 0) + hero.pack_start(hero_text, True, True, 0) + content.pack_start(hero, False, False, 0) + + self.preflight_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=9) + self.preflight_spinner = Gtk.Spinner() + self.preflight_box.pack_start(self.preflight_spinner, False, False, 0) + self.preflight_label = label( + "Checking active transfers, queued uploads and protected Dirty cache data …", + "section-subtitle", + ) + self.preflight_label.set_line_wrap(True) + self.preflight_box.pack_start(self.preflight_label, True, True, 0) + content.pack_start(self.preflight_box, False, False, 0) + + self.form = Gtk.Grid(column_spacing=16, row_spacing=12) + add_css(self.form, "reauth-panel") + self.username_entry = Gtk.Entry() + self.username_entry.set_input_purpose(Gtk.InputPurpose.EMAIL) + self.password_entry = Gtk.Entry() + self.password_confirm_entry = Gtk.Entry() + self.two_factor_entry = Gtk.Entry() + for entry in ( + self.password_entry, + self.password_confirm_entry, + self.two_factor_entry, + ): + entry.set_visibility(False) + entry.set_invisible_char("●") + self.two_factor_entry.set_input_purpose(Gtk.InputPurpose.DIGITS) + self.two_factor_entry.set_max_length(6) + for row_index, (title, entry) in enumerate( + ( + ("New username or email", self.username_entry), + ("New password", self.password_entry), + ("Repeat new password", self.password_confirm_entry), + ("Fresh 2FA code (optional)", self.two_factor_entry), + ) + ): + self.form.attach(label(title), 0, row_index, 1, 1) + entry.set_hexpand(True) + entry.connect("changed", self.update_button_state) + entry.connect("activate", lambda _entry: self.start_account_switch()) + self.form.attach(entry, 1, row_index, 1, 1) + content.pack_start(self.form, False, False, 0) + + credential_note = label( + "Credentials travel through one anonymous pipe. They never enter process arguments, environment variables, logs, diagnostics or screenshots.", + "reauth-note", + ) + credential_note.set_line_wrap(True) + content.pack_start(credential_note, False, False, 0) + + self.confirmation = Gtk.CheckButton( + label=translate( + "I understand that /pdrive will show the new account and that previous cache data remains separate." + ) + ) + self.confirmation.set_tooltip_text(translate("Confirm the intentional Proton account migration")) + self.confirmation.connect("toggled", self.update_button_state) + content.pack_start(self.confirmation, False, False, 0) + + self.show_password = Gtk.CheckButton(label=translate("Show passwords")) + self.show_password.set_tooltip_text(translate("Show passwords")) + self.show_password.connect("toggled", self.on_show_password) + content.pack_start(self.show_password, False, False, 0) + + self.progress = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=9) + self.spinner = Gtk.Spinner() + self.progress.pack_start(self.spinner, False, False, 0) + self.progress_label = label( + "Authenticating the candidate and activating its isolated cache namespace …", + "section-subtitle", + ) + self.progress_label.set_line_wrap(True) + self.progress.pack_start(self.progress_label, True, True, 0) + content.pack_start(self.progress, False, False, 0) + + self.result_detail = label("") + self.result_detail.set_no_show_all(True) + self.result_detail.set_line_wrap(True) + content.pack_start(self.result_detail, False, False, 0) + + self.show_all() + self.progress.hide() + self.result_detail.hide() + self.form.set_sensitive(False) + self.confirmation.set_sensitive(False) + self.show_password.set_sensitive(False) + self.switch_button.set_sensitive(False) + if self.demo: + GLib.idle_add(self.preflight_finished, 0, "") + else: + self.start_preflight() + + def on_delete(self, _dialog: Gtk.Dialog, _event: Gdk.Event) -> bool: + return self.busy or self.preflight_running + + def start_preflight(self) -> None: + self.preflight_running = True + self.cancel_button.set_sensitive(False) + self.preflight_spinner.start() + threading.Thread(target=self.preflight_worker, daemon=True).start() + + def preflight_worker(self) -> None: + returncode = 127 + error_text = "" + try: + completed = subprocess.run( + [account_switch_backend_path(), "--preflight"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=30, + ) + returncode = completed.returncode + error_text = completed.stderr.decode("utf-8", errors="replace")[-4096:] + except subprocess.TimeoutExpired: + returncode = 124 + except OSError: + error_text = "helper-unavailable" + GLib.idle_add(self.preflight_finished, returncode, error_text) + + def preflight_finished(self, returncode: int, error_text: str) -> bool: + self.preflight_running = False + self.preflight_spinner.stop() + self.cancel_button.set_sensitive(True) + if returncode == 0: + self.preflight_ready = True + self.preflight_label.set_text( + translate("Ready: no active transfers, queued uploads or Dirty cache data were found.") + ) + self.form.set_sensitive(True) + self.confirmation.set_sensitive(True) + self.show_password.set_sensitive(True) + add_css(self.preflight_label, "reauth-success") + self.username_entry.grab_focus() + else: + self.preflight_ready = False + if "PDRIVE_ACCOUNT_SWITCH_ERROR=busy" in error_text: + message = "Account change is blocked while an upload, download or VFS queue item is active." + elif "PDRIVE_ACCOUNT_SWITCH_ERROR=dirty-cache" in error_text: + message = "Account change is blocked while protected Dirty cache data awaits upload." + elif error_text == "helper-unavailable": + message = "The account-switch helper could not be started." + else: + message = "Account safety could not be verified. The current account and cache remain unchanged." + self.preflight_label.set_text(translate(message)) + add_css(self.preflight_label, "setup-error") + self.update_button_state() + return GLib.SOURCE_REMOVE + + def on_show_password(self, button: Gtk.CheckButton) -> None: + visible = button.get_active() + self.password_entry.set_visibility(visible) + self.password_confirm_entry.set_visibility(visible) + self.two_factor_entry.set_visibility(visible) + + def credentials_valid(self) -> bool: + two_factor = self.two_factor_entry.get_text().strip() + return ( + bool(self.username_entry.get_text().strip()) + and bool(self.password_entry.get_text()) + and self.password_entry.get_text() == self.password_confirm_entry.get_text() + and (not two_factor or bool(re.fullmatch(r"[0-9]{6}", two_factor))) + ) + + def update_button_state(self, *_args: Any) -> None: + self.switch_button.set_sensitive( + self.completed + or ( + self.preflight_ready + and not self.preflight_running + and not self.busy + and self.confirmation.get_active() + and self.credentials_valid() + ) + ) + + def on_response(self, _dialog: Gtk.Dialog, response: int) -> None: + if self.busy or self.preflight_running: + return + if response in {Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT} or self.completed: + self.destroy() + return + if response == Gtk.ResponseType.OK: + self.start_account_switch() + + def start_account_switch(self) -> None: + if self.busy or self.completed or not self.preflight_ready: + return + if self.demo: + self.show_error("Account changes are disabled in demo mode.") + return + username = self.username_entry.get_text().strip() + password = self.password_entry.get_text() + password_confirm = self.password_confirm_entry.get_text() + two_factor = self.two_factor_entry.get_text().strip() + if not username: + self.show_error("Enter the new Proton username or email address.") + return + if not password or password != password_confirm: + self.show_error("Passwords are empty or do not match.") + return + if two_factor and not re.fullmatch(r"[0-9]{6}", two_factor): + self.show_error("The 2FA code must be empty or exactly six digits.") + return + if not self.confirmation.get_active(): + self.show_error("Confirm the intentional Proton account migration.") + return + + payload = bytearray() + for value in (username, password, two_factor): + payload.extend(value.encode("utf-8")) + payload.append(0) + self.username_entry.set_text("") + self.password_entry.set_text("") + self.password_confirm_entry.set_text("") + self.two_factor_entry.set_text("") + del username, password, password_confirm, two_factor + self.begin_busy_state() + threading.Thread(target=self.account_switch_worker, args=(payload,), daemon=True).start() + + def begin_busy_state(self) -> None: + self.busy = True + self.form.set_sensitive(False) + self.confirmation.set_sensitive(False) + self.show_password.set_sensitive(False) + self.cancel_button.set_sensitive(False) + self.switch_button.set_sensitive(False) + self.result_detail.hide() + self.progress.show_all() + self.spinner.start() + + def account_switch_worker(self, payload: bytearray) -> None: + returncode = 127 + error_text = "" + try: + process = subprocess.Popen( + [account_switch_backend_path(), "--switch-from-stdin"], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + try: + _stdout, stderr = process.communicate(bytes(payload), timeout=ACCOUNT_SWITCH_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + _stdout, stderr = process.communicate() + returncode = 124 + else: + returncode = process.returncode + error_text = stderr.decode("utf-8", errors="replace")[-4096:] + except OSError: + error_text = "helper-unavailable" + finally: + for index in range(len(payload)): + payload[index] = 0 + payload.clear() + GLib.idle_add(self.account_switch_finished, returncode, error_text) + + def show_error(self, message: str) -> None: + self.set_result_style("setup-error") + self.result_detail.set_text(translate(message)) + self.result_detail.show() + + def set_result_style(self, css_class: str) -> None: + context = self.result_detail.get_style_context() + for candidate in ("setup-error", "reauth-success", "reauth-attention"): + context.remove_class(candidate) + context.add_class(css_class) + + def account_switch_finished(self, returncode: int, error_text: str) -> bool: + self.busy = False + self.spinner.stop() + self.progress.hide() + self.cancel_button.set_sensitive(True) + if returncode == 0: + self.completed = True + self.cancel_button.hide() + self.switch_button.set_label(translate("Close")) + self.switch_button.set_sensitive(True) + self.hero_icon.set_from_icon_name("emblem-ok-symbolic", Gtk.IconSize.DIALOG) + add_css(self.hero_icon, "good") + self.hero_title.set_text(translate("Proton account changed")) + self.hero_detail.set_text( + translate("/pdrive is mounted through the new account’s separate cache namespace.") + ) + self.result_detail.set_text( + translate("The previous encrypted configuration and cache namespace remain available for rollback.") + ) + self.set_result_style("reauth-success") + self.result_detail.show() + self.parent_window.request_refresh(force_capacity=True) + return GLib.SOURCE_REMOVE + + if "PDRIVE_ACCOUNT_SWITCH_ERROR=activation-failed-rolled-back" in error_text: + self.completed = True + self.cancel_button.hide() + self.switch_button.set_label(translate("Close")) + self.switch_button.set_sensitive(True) + self.hero_icon.set_from_icon_name("edit-undo-symbolic", Gtk.IconSize.DIALOG) + add_css(self.hero_icon, "warning") + self.hero_title.set_text(translate("Previous account restored")) + self.hero_detail.set_text( + translate( + "The candidate login worked, but its mount failed validation. PDrive restored the previous configuration and cache namespace." + ) + ) + self.result_detail.set_text(translate("No cache data was reassigned or removed.")) + self.set_result_style("reauth-attention") + self.result_detail.show() + self.parent_window.request_refresh(force_capacity=True) + return GLib.SOURCE_REMOVE + + self.form.set_sensitive(self.preflight_ready) + self.confirmation.set_sensitive(self.preflight_ready) + self.show_password.set_sensitive(self.preflight_ready) + if self.preflight_ready: + self.username_entry.grab_focus() + if "PDRIVE_ACCOUNT_SWITCH_ERROR=busy" in error_text: + message = "A transfer or queued upload began during validation. The account change was cancelled safely." + self.preflight_ready = False + elif "PDRIVE_ACCOUNT_SWITCH_ERROR=dirty-cache" in error_text: + message = "Dirty cache data appeared during validation. The account change was cancelled safely." + self.preflight_ready = False + elif "PDRIVE_ACCOUNT_SWITCH_ERROR=rate-limited" in error_text: + message = "Proton temporarily rate-limited the candidate login. Wait for the full backoff before retrying." + elif "PDRIVE_ACCOUNT_SWITCH_ERROR=rollback-failed" in error_text: + message = "The previous files were restored, but /pdrive still needs diagnostics. Do not delete any cache namespace." + self.preflight_ready = False + elif error_text == "helper-unavailable": + message = "The account-switch helper could not be started." + elif returncode == 124: + message = "The account change timed out. Inspect PDrive before making another login attempt." + else: + message = ( + "Proton did not accept the candidate login. Check the credentials and retry once with a fresh 2FA code." + ) + self.form.set_sensitive(self.preflight_ready) + self.confirmation.set_sensitive(self.preflight_ready) + self.show_password.set_sensitive(self.preflight_ready) + self.result_detail.set_text( + translate(message) + " " + translate("The current account, mount and cache were not changed.") + ) + self.set_result_style("setup-error") + self.result_detail.show() + self.update_button_state() + return GLib.SOURCE_REMOVE + + class PDriveWindow(Gtk.ApplicationWindow): def __init__(self, application: Gtk.Application, demo: bool = False) -> None: super().__init__(application=application) @@ -3337,6 +3803,7 @@ class PDriveWindow(Gtk.ApplicationWindow): self.documentation_window: DocumentationWindow | None = None self.about_dialog: Gtk.AboutDialog | None = None self.reauthorization_dialog: ReauthorizationDialog | None = None + self.account_switch_dialog: AccountSwitchDialog | None = None self.setup_wizard: SetupWizard | None = None self.setup_required = not demo and not setup_config_path().is_file() self.dashboard_timer_id = 0 @@ -3544,7 +4011,11 @@ class PDriveWindow(Gtk.ApplicationWindow): ("alarm-symbolic", "Restart cooldown …", self.on_cooldown), ("view-refresh-symbolic", "Refresh metadata …", self.on_metadata_refresh), ("system-reboot-symbolic", "Safely restart service …", self.on_restart), - ("dialog-password-symbolic", "Reauthorize Proton account …", self.on_reauthorize), + ( + "dialog-password-symbolic", + "Reauthorize Proton account …", + self.on_reauthorize, + ), ] for icon_name, text, callback in actions: button = Gtk.Button() @@ -4421,7 +4892,10 @@ class PDriveWindow(Gtk.ApplicationWindow): self.active_card.update(str(active), translate_format("of {slots} upload slots in use", slots=slots)) authentication_status = str(authentication.get("status") or "unknown") reauthorization_required = authentication_status == "reauthorization-required" - authentication_blocked = authentication_status in {"rate-limited", "reauthorization-required"} + authentication_blocked = authentication_status in { + "rate-limited", + "reauthorization-required", + } rate_limited = authentication_status == "rate-limited" if authentication_blocked: queue_detail = translate("Reauthorization needed") @@ -4500,10 +4974,16 @@ class PDriveWindow(Gtk.ApplicationWindow): int(vfs.get("cache_bytes") or 0), ) self.upload_graph_peak.set_text( - translate_format("Peak {speed}", speed=human_rate(max(self.speed_graph.values(), default=0))) + translate_format( + "Peak {speed}", + speed=human_rate(max(self.speed_graph.values(), default=0)), + ) ) self.download_graph_peak.set_text( - translate_format("Peak {speed}", speed=human_rate(max(self.download_graph.values(), default=0))) + translate_format( + "Peak {speed}", + speed=human_rate(max(self.download_graph.values(), default=0)), + ) ) if rate_limited: @@ -4747,7 +5227,10 @@ class PDriveWindow(Gtk.ApplicationWindow): content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) top = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - title = label(translate(str(event.get("title") or "rclone reported an error")), "transfer-name") + title = label( + translate(str(event.get("title") or "rclone reported an error")), + "transfer-name", + ) title.set_line_wrap(True) top.pack_start(title, True, True, 0) display_time = str(event.get("resolved_at") or event.get("timestamp") or "") @@ -4963,7 +5446,12 @@ class PDriveWindow(Gtk.ApplicationWindow): elif recent: text = "Failed" if item.get("error") else "Completed" top.pack_end(label(text, "pill"), False, False, 0) - top.pack_end(self.transfer_direction_badge(str(item.get("direction") or "unknown")), False, False, 0) + top.pack_end( + self.transfer_direction_badge(str(item.get("direction") or "unknown")), + False, + False, + 0, + ) else: if item.get("stage") == "finalizing": speed_text = translate("Finalizing") @@ -5269,6 +5757,21 @@ class PDriveWindow(Gtk.ApplicationWindow): ) dialog.present() + def on_account_switch(self, _button: Gtk.Button | None) -> None: + if self.account_switch_dialog is not None: + self.account_switch_dialog.present() + return + dialog = AccountSwitchDialog(self, demo=self.demo) + self.account_switch_dialog = dialog + dialog.connect( + "destroy", + lambda _dialog: setattr(self, "account_switch_dialog", None), + ) + dialog.present() + application = self.get_application() + if self.demo and isinstance(application, PDriveApplication) and application.demo_dialog == "account-switch": + self.hide() + def on_copy_short_status(self, _button: Gtk.Button) -> None: status = getattr(self, "short_status_text", "") if not status: @@ -5426,7 +5929,11 @@ class PDriveWindow(Gtk.ApplicationWindow): application = self.get_application() if not isinstance(application, PDriveApplication): return - dialog = Gtk.Dialog(title=translate("Preferences"), transient_for=self, modal=True) + dialog = Gtk.Dialog( + title=translate("Preferences"), + transient_for=None if self.demo else self, + modal=True, + ) dialog.add_buttons( translate("Cancel"), Gtk.ResponseType.CANCEL, @@ -5496,6 +6003,27 @@ class PDriveWindow(Gtk.ApplicationWindow): ) language_note.set_line_wrap(True) content.pack_start(language_note, False, False, 0) + account_separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + account_separator.set_margin_top(3) + account_separator.set_margin_bottom(3) + content.pack_start(account_separator, False, False, 0) + content.pack_start(label("Account", "section-title"), False, False, 0) + account_note = label( + "Move the managed /pdrive mount to another Proton account. Active transfers and protected pending data block the change.", + "section-subtitle", + ) + account_note.set_line_wrap(True) + content.pack_start(account_note, False, False, 0) + account_switch_button = Gtk.Button(label=translate("Change Proton account …")) + account_switch_button.set_tooltip_text(translate("Change Proton account …")) + account_switch_button.set_halign(Gtk.Align.START) + add_css(account_switch_button, "reauth-attention") + set_pointer_on_hover(account_switch_button) + account_switch_button.connect( + "clicked", + lambda _button: dialog.response(Gtk.ResponseType.APPLY), + ) + content.pack_start(account_switch_button, False, False, 0) explanation = label( "Session startup uses ~/.config/autostart. A manual launch from the application " "menu still opens the window visibly.", @@ -5537,6 +6065,8 @@ class PDriveWindow(Gtk.ApplicationWindow): notification_combo.connect("changed", update_save_sensitivity) save_button.set_sensitive(False) dialog.show_all() + if self.demo and application.demo_dialog == "preferences-account": + self.hide() response = dialog.run() close_to_tray = close_toggle.get_active() start_in_tray = start_toggle.get_active() @@ -5546,6 +6076,9 @@ class PDriveWindow(Gtk.ApplicationWindow): selected_language = language_combo.get_active_id() or "en" previous_language = str(application.preferences["language"]) dialog.destroy() + if response == Gtk.ResponseType.APPLY: + self.on_account_switch(account_switch_button) + return if response != Gtk.ResponseType.OK: return error = application.update_preferences( @@ -5918,6 +6451,7 @@ class PDriveApplication(Gtk.Application): demo: bool = False, background: bool = False, demo_page: str = "overview", + demo_dialog: str = "", ) -> None: global CURRENT_LANGUAGE non_unique = demo or os.environ.get("PDRIVE_UI_NON_UNIQUE") == "1" @@ -5925,6 +6459,7 @@ class PDriveApplication(Gtk.Application): super().__init__(application_id=APP_ID, flags=flags) self.demo = demo self.demo_page = demo_page + self.demo_dialog = demo_dialog self.background = background and not demo self.preferences = dict(DEFAULT_PREFERENCES) if demo else load_preferences() if demo: @@ -5965,6 +6500,12 @@ class PDriveApplication(Gtk.Application): self.window.show_all() if self.demo: self.window.stack.set_visible_child_name(self.demo_page) + if self.demo_dialog: + self.window.move(520, 60) + if self.demo_dialog == "preferences-account": + GLib.idle_add(self.window.on_preferences, None) + elif self.demo_dialog == "account-switch": + GLib.idle_add(self.window.on_account_switch, None) if background_activation: self.window.hide() else: @@ -6386,6 +6927,12 @@ def argument_parser() -> argparse.ArgumentParser: default="overview", help="select the initial page for --demo", ) + parser.add_argument( + "--demo-dialog", + choices=("preferences-account", "account-switch"), + default="", + help="open one deterministic documentation dialog for --demo", + ) parser.add_argument( "--background", action="store_true", @@ -6407,6 +6954,8 @@ def main() -> int: parser.error("--demo and --background cannot be combined") if not args.demo and args.demo_page != "overview": parser.error("--demo-page requires --demo") + if not args.demo and args.demo_dialog: + parser.error("--demo-dialog requires --demo") if args.check: state_command = PDriveWindow.find_state_command() if not pathlib.Path(state_command).is_file() and not shutil.which(state_command): @@ -6427,6 +6976,7 @@ def main() -> int: demo=args.demo, background=args.background, demo_page=args.demo_page, + demo_dialog=args.demo_dialog, ) try: return application.run([]) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8fa06b3..4ffdda7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -53,6 +53,16 @@ mount. Saved limits are applied only after mount startup so backend option values cannot alter the VFS cache fingerprint and select a different Dirty queue namespace. +Intentional account switching is a separate transaction from reauthorization. +`switch-rclone-proton-account` performs read-only queue, transfer and Dirty-file +preflights before and after isolated candidate authentication. The opaque +`pdrive-account.conf` selector is resolved only by `pdrive-account-cache`; an +absent selector preserves the legacy cache root. Mount, refresh and recovery +helpers must use that resolver so every operation addresses the currently +selected account namespace without scanning another account's cached files as +live state. Whole-installation destructive guards such as uninstall still scan +all retained namespaces. + ## Security model - The rclone configuration is encrypted with a random password stored in the @@ -180,7 +190,7 @@ changed and disable again when the original values are restored. ## CI and release process `make check` covers syntax, ShellCheck, action-free help behavior, setup safety, -transactional reauthentication, terminal-authentication retry suppression, +transactional reauthentication and account switching, terminal-authentication retry suppression, systemd semantics, state fixtures, version consistency, desktop validation and GTK checks when the display stack is available. GitHub Actions also runs Super-Linter for Bash, Python, Markdown, YAML, action security and secret diff --git a/docs/EVERYDAY_USE.md b/docs/EVERYDAY_USE.md index 1f23f90..3d446a6 100644 --- a/docs/EVERYDAY_USE.md +++ b/docs/EVERYDAY_USE.md @@ -74,6 +74,20 @@ Proton Web, the mobile app, the official CLI or another rclone client. If an exceptional external change occurs, wait for an empty queue and use the guarded metadata refresh. +## Change the mounted Proton account + +Routine login repair uses contextual **Reauthorize** and keeps the same account. +For a deliberate account migration, open **Preferences → Account → Change +Proton account …**. The action remains visible because it is an intentional +setting, not an error recovery shortcut. + +Finish every upload and download first. PDrive blocks the change if Active, +Queue or any protected Dirty VFS metadata is nonzero. It tests the new username, +password and optional fresh 2FA code in an isolated encrypted configuration, +rechecks the preflight, and only then stops `/pdrive`. A successful account gets +a new anonymous cache namespace. Previous clean cache and encrypted rollback +data remain separate; PDrive never merges or silently reassigns them. + ## Notifications and issue review Desktop notifications follow **Preferences → Desktop notifications** and the diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 37dd74d..7b281f6 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -325,6 +325,9 @@ a native documentation window with Quick start, Everyday use, Operations, Troubleshooting, Security and License pages. Account reauthorization appears there only while PDrive has detected that the configured account requires it; the same contextual action is shown in the Overview status banner. +Intentional account migration is separate: **Preferences → Account → Change +Proton account …** is always available on a configured installation and opens a +full native login plus explicit migration confirmation. The About dialog reports the installed PDrive Control Center version, project authors, GPL license and canonical GitHub project link. @@ -381,6 +384,18 @@ with `X-PDrive-Control-Center=true`, whose command is `pdrive-ui --background`. The application refuses to overwrite a same-named unmarked file and removes only its own marked autostart file. A manual menu launch remains visible. +The Preferences **Account** section is a guarded action rather than a saved UI +preference. **Change Proton account …** accepts a new Proton username or email, +password, repeated password and an optional fresh six-digit 2FA code. It has no +default credential value and stores none of those inputs in the UI preference +file. Activation occurs only after isolated authentication, a repeated clean +preflight and validated remounting. Its persistent effects are the encrypted +`~/.config/rclone/rclone.conf`, the mode-0600 opaque namespace selector +`~/.config/pdrive-account.conf`, a new owner-only cache root below +`~/.cache/rclone/accounts/`, and an encrypted rollback bundle below +`~/.config/rclone/backups/`. The complete safety and rollback contract is in +**Guarded Proton account switching** below. + #### Operational settings These dialogs delegate to the same strict `pdrive-*` helpers available in a @@ -407,16 +422,17 @@ format, CLI equivalent and refusal conditions: #### One-shot actions -| Action | Protection and result | -| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Header **Refresh** | Re-reads local state immediately and refreshes Proton capacity; it changes no configuration. | -| **Restart cooldown → Reset restart cooldown** | Clears only the current automatic-restart cooldown through `pdrive-watch --clear-cooldown`; the configured duration is unchanged. | -| **Refresh metadata** | Checks uploads, queue and Dirty cache first, then requires terminal confirmation before a controlled restart. | -| **Safely restart service** | Warns that an active upload would be interrupted, requires terminal confirmation and validates the new PID and mount. | -| **Mark issues reviewed** | Advances only the local issue watermark; it does not delete logs, history or unresolved health evidence. | -| **Open Proton Drive web** | Opens the official web client for account-wide settings; it makes no local PDrive change. | -| **Open PDrive folder** | Opens `/pdrive` in the file manager; reads and writes then follow normal mounted-filesystem semantics. | -| Overview banner or conditional menu **Reauthorize Proton account …** | Appears only when PDrive reports `reauthorization-required`; runs one isolated same-account login and replaces the encrypted configuration only after Proton accepts it. | +| Action | Protection and result | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Header **Refresh** | Re-reads local state immediately and refreshes Proton capacity; it changes no configuration. | +| **Restart cooldown → Reset restart cooldown** | Clears only the current automatic-restart cooldown through `pdrive-watch --clear-cooldown`; the configured duration is unchanged. | +| **Refresh metadata** | Checks uploads, queue and Dirty cache first, then requires terminal confirmation before a controlled restart. | +| **Safely restart service** | Warns that an active upload would be interrupted, requires terminal confirmation and validates the new PID and mount. | +| **Mark issues reviewed** | Advances only the local issue watermark; it does not delete logs, history or unresolved health evidence. | +| **Open Proton Drive web** | Opens the official web client for account-wide settings; it makes no local PDrive change. | +| **Open PDrive folder** | Opens `/pdrive` in the file manager; reads and writes then follow normal mounted-filesystem semantics. | +| Overview banner or conditional menu **Reauthorize Proton account …** | Appears only when PDrive reports `reauthorization-required`; runs one isolated same-account login and replaces the encrypted configuration only after Proton accepts it. | +| **Preferences → Account → Change Proton account …** | Always-distinct intentional migration; refuses active or pending work, authenticates in isolation, selects a new cache namespace and rolls back all live selectors if the new mount fails validation. | Metadata refresh and service restart deliberately finish their final safety checks in a terminal so the user sees the exact queue state and confirmation @@ -690,6 +706,76 @@ After HTTP 429, leave the service stopped until the retry time shown by PDrive. If Proton states a longer backoff, that longer interval remains authoritative. Repeated “tests” can extend the block. +## Guarded Proton account switching + +Same-account reauthorization and account switching are intentionally separate. +Reauthorization preserves the existing account identity and VFS namespace; +`pdrive-account-switch --switch` deliberately changes both. The Control Center +access path is **hamburger menu → Preferences → Account → Change Proton account +…**. Its full native form accepts the candidate username or email, password, +repeated password and optional fresh six-digit 2FA code. The explicit checkbox +confirms that `/pdrive` and the Proton remote namespace will change while old +local cache data remains separate. + +The no-option and `--help` terminal forms are action-free: + +```bash +pdrive-account-switch +pdrive-account-switch --switch +``` + +Before presenting terminal credentials, and again immediately after the +bounded candidate login, the backend requires all of these facts: + +- no active upload or download in `core/stats`; +- an empty live `vfs/queue` whenever the managed service is active; +- no Dirty VFS metadata anywhere below the retained legacy or account-specific + cache roots; +- a coherent service/mount/owner-only RC state that can be inspected safely. + +An unavailable queue, malformed metadata, an unexpected mount, a changing +service state or any pending work is a refusal, not permission to guess. The +current service, configuration, authentication state and every cache file stay +untouched. This repeated preflight closes the race in which a transfer begins +while Proton validates the candidate login. + +Credentials cross one anonymous NUL-delimited stdin pipe. They never enter +process arguments, environment variables, persistent logs, state JSON, +fixtures, diagnostics or screenshots. The candidate configuration is encrypted +with the existing GNOME-Keyring-backed rclone configuration password and tested +once with retries bounded and a dedicated temporary cache. Account-specific +mailbox secrets, session tokens and current-account backend credentials are not +copied. The one-time 2FA field is removed before any candidate can become live. +A rejected or rate-limited candidate does not change the same-account +reauthorization guard or its saved cooldown. + +Only after authentication and the second preflight does PDrive stop the managed +service. It saves the final encrypted `rclone.conf`, the prior opaque account +selector and the credential-free authentication state in a mode-0700 rollback +bundle. The candidate receives a random `account-` plus 32-hex namespace; the +identifier contains no username, email, Proton ID or remote path. The selector +is written atomically to mode-0600 `~/.config/pdrive-account.conf`, and +`rclone-proton-mount` resolves it to +`~/.cache/rclone/accounts/`. An installation without the +selector continues using the legacy `~/.cache/rclone` root, so an upgrade never +hides existing Dirty data. + +Success requires the user service to be active, `/pdrive` to be a writable +rclone FUSE mount, the owner-only RC socket PID to match systemd, and `vfs/stats` +to report both data and metadata paths below the new cache root. If any gate +times out, PDrive stops the candidate, atomically restores the previous +encrypted configuration, account selector and authentication state, and—when +it was previously active—validates the old mount again. It never merges, +renames, deletes or reassigns either cache namespace. A rollback whose old mount +also cannot become ready leaves both namespaces intact and directs the user to +`pdrive-doctor`. + +Previous account cache roots and rollback bundles are deliberately retained. +They can contain the only local copy of important data even after a clean +preflight. Do not delete them merely to reclaim space; first identify the exact +namespace, prove it has no Dirty metadata, verify important remote files in the +corresponding account and maintain an independent backup. + ## Advanced draft recovery ```bash diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index 951736b..f6601d9 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -88,6 +88,12 @@ If Proton rate-limits login attempts, wait until the displayed retry time. Do not bypass the cooldown with manual service starts. The encrypted configuration and local upload cache remain untouched while login is paused. +Reauthorization keeps the same account. To intentionally move `/pdrive` to a +different Proton account, first wait for zero Active transfers, an empty Queue +and no pending VFS data, then use **Preferences → Account → Change Proton +account …**. PDrive gives the candidate a separate cache namespace; it never +relabels the previous account's cached files as belonging to the new account. + ## Where to go next - [Everyday use](EVERYDAY_USE.md) explains Nemo, transfers, cache and routine diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 108d190..8ba192b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -343,6 +343,30 @@ credential rejection remain distinct and do not manufacture a rate limit. The mount unit's one-hour restart delay and unlimited start timeout exist to avoid such login hammering. +## Account change is blocked or rolled back + +Use **Reauthorize** only for the already configured account. For an intentional +new account, open **Preferences → Account → Change Proton account …** or run: + +```bash +pdrive-account-switch --switch +``` + +PDrive refuses while any upload, download, VFS queue entry or Dirty metadata +file exists. This is a safety result: wait for real remote completion and retry +the preflight; do not clear the queue, delete metadata or stop a healthy active +transfer to force it through. If the live RC or metadata state cannot be read, +run `pdrive-doctor` and repair observability before changing accounts. + +A rejected candidate login leaves the current mount and same-account +authentication guard unchanged. A message that the **previous account was +restored** means the candidate authenticated but its new mount failed one of the +PID, writable-FUSE, RC or cache-path validation gates. Inspect service +diagnostics before another login attempt. Do not delete either cache namespace +or the encrypted rollback bundle; the old selectors have already been restored +atomically. If both the candidate and restored mount need attention, preserve +all cache roots and use `pdrive-doctor` before any manual service action. + ## HTTP 422: draft or name already exists A normal name conflict and an incomplete server-side upload draft can both diff --git a/docs/assets/pdrive-account-settings.png b/docs/assets/pdrive-account-settings.png new file mode 100644 index 0000000..5711af8 Binary files /dev/null and b/docs/assets/pdrive-account-settings.png differ diff --git a/docs/assets/pdrive-account-switch.png b/docs/assets/pdrive-account-switch.png new file mode 100644 index 0000000..49003b2 Binary files /dev/null and b/docs/assets/pdrive-account-switch.png differ diff --git a/install.sh b/install.sh index c82d18f..c78613d 100755 --- a/install.sh +++ b/install.sh @@ -174,6 +174,12 @@ install -m 0644 \ install -m 0644 \ "${project_dir}/docs/assets/pdrive-auth-cooldown.png" \ "${doc_assets_dir}/pdrive-auth-cooldown.png" +install -m 0644 \ + "${project_dir}/docs/assets/pdrive-account-settings.png" \ + "${doc_assets_dir}/pdrive-account-settings.png" +install -m 0644 \ + "${project_dir}/docs/assets/pdrive-account-switch.png" \ + "${doc_assets_dir}/pdrive-account-switch.png" install -m 0644 \ "${project_dir}/docs/assets/pdrive-setup-wizard.png" \ "${doc_assets_dir}/pdrive-setup-wizard.png" diff --git a/libexec/pdrive-account-cache b/libexec/pdrive-account-cache new file mode 100755 index 0000000..138f53f --- /dev/null +++ b/libexec/pdrive-account-cache @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail + +readonly cache_root="${PDRIVE_CACHE_ROOT:-${HOME}/.cache/rclone}" +readonly account_config="${PDRIVE_ACCOUNT_CONFIG:-${HOME}/.config/pdrive-account.conf}" + +usage() { + printf '%s\n' \ + 'Usage: pdrive-account-cache --path' \ + ' pdrive-account-cache --help' \ + '' \ + 'Internal read-only resolver for the active account-specific VFS cache.' \ + 'An installation without pdrive-account.conf keeps the legacy cache root.' +} + +case "${1:-}" in + --path) + (( $# == 1 )) || { usage >&2; exit 2; } + ;; + -h|--help|'') + (( $# <= 1 )) || { usage >&2; exit 2; } + usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n\n' "$1" >&2 + usage >&2 + exit 2 + ;; +esac + +if [[ ! -e "${account_config}" ]]; then + printf '%s\n' "${cache_root}" + exit 0 +fi +if [[ ! -f "${account_config}" || -L "${account_config}" || ! -r "${account_config}" ]]; then + printf 'Account cache configuration is missing, unreadable or unsafe: %s\n' \ + "${account_config}" >&2 + exit 78 +fi +if [[ "$(stat -c %u -- "${account_config}" 2>/dev/null || true)" != "$(id -u)" ]]; then + printf 'Refusing account cache configuration not owned by the current user: %s\n' \ + "${account_config}" >&2 + exit 78 +fi + +entries="$(awk ' + /^[[:space:]]*($|#)/ { next } + { print } +' "${account_config}")" +if [[ ! "${entries}" =~ ^cache_namespace=(account-[0-9a-f]{32})$ ]]; then + printf 'Invalid account cache configuration: %s\n' "${account_config}" >&2 + exit 78 +fi + +printf '%s/accounts/%s\n' "${cache_root}" "${BASH_REMATCH[1]}" diff --git a/libexec/pdrive-draft-recovery-auto b/libexec/pdrive-draft-recovery-auto index 21fe8ac..e6dd38d 100755 --- a/libexec/pdrive-draft-recovery-auto +++ b/libexec/pdrive-draft-recovery-auto @@ -24,7 +24,38 @@ from typing import Any HOME = pathlib.Path.home() STATE_DIR = pathlib.Path(os.environ.get("PDRIVE_STATE_DIR", HOME / ".local/state/rclone")) CONFIG_DIR = pathlib.Path(os.environ.get("PDRIVE_CONFIG_DIR", HOME / ".config")) -CACHE_DIR = pathlib.Path(os.environ.get("PDRIVE_CACHE_DIR", HOME / ".cache/rclone")) + + +def configured_cache_dir() -> pathlib.Path: + override = os.environ.get("PDRIVE_CACHE_DIR") + if override: + return pathlib.Path(override) + helper = pathlib.Path( + os.environ.get( + "PDRIVE_ACCOUNT_CACHE_HELPER", + HOME / ".local/libexec/pdrive-account-cache", + ) + ) + if not helper.is_file() or not os.access(helper, os.X_OK): + sibling = pathlib.Path(__file__).with_name("pdrive-account-cache") + helper = sibling if sibling.is_file() and os.access(sibling, os.X_OK) else helper + try: + completed = subprocess.run( + [str(helper), "--path"], + check=True, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + raise RuntimeError(f"Could not resolve the active account cache: {error}") from error + path = completed.stdout.strip() + if not path: + raise RuntimeError("The active account cache resolver returned an empty path") + return pathlib.Path(path) + + +CACHE_DIR = configured_cache_dir() RC_SOCKET = pathlib.Path(os.environ.get("PDRIVE_RC_SOCKET", STATE_DIR / "pdrive-rc.sock")) RCLONE_BIN = pathlib.Path(os.environ.get("PDRIVE_RCLONE_BIN", HOME / ".local/bin/rclone")) MOUNT_LOG = pathlib.Path(os.environ.get("PDRIVE_MOUNT_LOG", STATE_DIR / "proton-mount.log")) diff --git a/libexec/rclone-proton-mount b/libexec/rclone-proton-mount index 7172bbc..28862f7 100755 --- a/libexec/rclone-proton-mount +++ b/libexec/rclone-proton-mount @@ -4,7 +4,6 @@ set -euo pipefail readonly mount_dir='/pdrive' -readonly cache_dir="${HOME}/.cache/rclone" readonly state_dir="${HOME}/.local/state/rclone" readonly config_file="${HOME}/.config/rclone/rclone.conf" readonly transfers_config="${HOME}/.config/pdrive-transfers.conf" @@ -14,6 +13,14 @@ readonly recovery_config="${HOME}/.config/pdrive-recovery.conf" readonly draft_recovery_config="${HOME}/.config/pdrive-draft-recovery.conf" readonly rc_socket="${state_dir}/pdrive-rc.sock" readonly rclone_bin="${HOME}/.local/bin/rclone" +readonly account_cache_helper="${PDRIVE_ACCOUNT_CACHE_HELPER:-${HOME}/.local/libexec/pdrive-account-cache}" + +if [[ ! -x "${account_cache_helper}" ]]; then + echo "Account cache resolver is missing: ${account_cache_helper}" >&2 + exit 69 +fi +cache_dir="$("${account_cache_helper}" --path)" +readonly cache_dir mkdir -p -- "${cache_dir}" "${state_dir}" if [[ ! -d "${mount_dir}" || -L "${mount_dir}" ]]; then diff --git a/libexec/reauth-rclone-proton b/libexec/reauth-rclone-proton index e105df7..71bf5d2 100755 --- a/libexec/reauth-rclone-proton +++ b/libexec/reauth-rclone-proton @@ -40,7 +40,7 @@ case "${1:-}" in ;; esac -for command_name in systemctl mountpoint; do +for command_name in flock systemctl mountpoint; do if ! command -v "${command_name}" >/dev/null 2>&1; then printf 'Missing required command: %s\n' "${command_name}" >&2 exit 69 @@ -65,6 +65,14 @@ if [[ "$(stat -c %u -- "${config_file}" 2>/dev/null || true)" != "$(id -u)" ]]; exit 73 fi +mkdir -p -- "${state_dir}" +exec 9> "${state_dir}/pdrive-auth-transaction.lock" +if ! flock -n 9; then + printf '%s\n' 'PDRIVE_REAUTH_ERROR=already-running' >&2 + printf '%s\n' 'Another PDrive authentication transaction is already running.' >&2 + exit 75 +fi + # Preserve only documented, non-session backend options. The decrypted config # stream never reaches the terminal; awk drops passwords and all refresh/client # tokens before the bounded allowlist enters shell memory. @@ -111,11 +119,11 @@ if [[ -n "${two_factor}" && ! "${two_factor}" =~ ^[0-9]{6}$ ]]; then exit 65 fi -mkdir -p -- "${config_file%/*}" "${state_dir}" "${backup_dir}" +mkdir -p -- "${config_file%/*}" "${backup_dir}" temporary_config="$(mktemp "${config_file%/*}/rclone.conf.reauth.XXXXXX")" test_cache="$(mktemp -d "${state_dir}/reauth-cache.XXXXXX")" # Invoked indirectly by the EXIT trap below. -# shellcheck disable=SC2329 +# shellcheck disable=SC2317,SC2329 cleanup() { unset password two_factor obscured_password preserved_options username rm -f -- "${temporary_config:-}" diff --git a/libexec/switch-rclone-proton-account b/libexec/switch-rclone-proton-account new file mode 100755 index 0000000..933face --- /dev/null +++ b/libexec/switch-rclone-proton-account @@ -0,0 +1,510 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail +umask 077 + +readonly rclone_bin="${PDRIVE_RCLONE_BIN:-${HOME}/.local/bin/rclone}" +readonly config_file="${PDRIVE_RCLONE_CONFIG:-${HOME}/.config/rclone/rclone.conf}" +readonly account_config="${PDRIVE_ACCOUNT_CONFIG:-${HOME}/.config/pdrive-account.conf}" +readonly cache_root="${PDRIVE_CACHE_ROOT:-${HOME}/.cache/rclone}" +readonly state_dir="${PDRIVE_STATE_DIR:-${HOME}/.local/state/rclone}" +readonly backup_dir="${PDRIVE_BACKUP_DIR:-${HOME}/.config/rclone/backups}" +readonly mount_dir="${PDRIVE_MOUNT_DIR:-/pdrive}" +readonly rc_socket="${PDRIVE_RC_SOCKET:-${state_dir}/pdrive-rc.sock}" +readonly auth_state_file="${PDRIVE_AUTH_STATE:-${state_dir}/pdrive-auth-state.json}" +readonly auth_guard="${PDRIVE_AUTH_GUARD:-${HOME}/.local/libexec/pdrive-auth-failure-guard}" +readonly installed_cache_helper="${PDRIVE_ACCOUNT_CACHE_HELPER:-${HOME}/.local/libexec/pdrive-account-cache}" +source_cache_helper="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/pdrive-account-cache" +readonly source_cache_helper +readonly service_name='rclone-proton-drive.service' +readonly mount_attempts="${PDRIVE_ACCOUNT_SWITCH_MOUNT_ATTEMPTS:-45}" +readonly rollback_attempts="${PDRIVE_ACCOUNT_SWITCH_ROLLBACK_ATTEMPTS:-30}" +readonly poll_seconds="${PDRIVE_ACCOUNT_SWITCH_POLL_SECONDS:-2}" + +usage() { + printf '%s\n' \ + 'Usage: switch-rclone-proton-account --preflight' \ + ' switch-rclone-proton-account --switch-from-stdin' \ + ' switch-rclone-proton-account --help' \ + '' \ + 'Internal transactional backend for pdrive-account-switch.' \ + '--preflight performs only read-only transfer and cache safety checks.' \ + '--switch-from-stdin accepts username, password and optional 2FA as three' \ + 'NUL-delimited fields. Direct interactive use is intentionally unsupported.' +} + +case "${1:-}" in + --preflight|--switch-from-stdin) + (( $# == 1 )) || { usage >&2; exit 2; } + action="$1" + ;; + -h|--help|'') + (( $# <= 1 )) || { usage >&2; exit 2; } + usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n\n' "$1" >&2 + usage >&2 + exit 2 + ;; +esac + +for command_name in findmnt flock jq mountpoint openssl python3 realpath stat systemctl timeout; do + if ! command -v "${command_name}" >/dev/null 2>&1; then + printf 'Missing required command: %s\n' "${command_name}" >&2 + exit 69 + fi +done +if [[ ! "${mount_attempts}" =~ ^[1-9][0-9]*$ \ + || ! "${rollback_attempts}" =~ ^[1-9][0-9]*$ \ + || ! "${poll_seconds}" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + printf 'Invalid account-switch validation timing.\n' >&2 + exit 2 +fi +if [[ ! -x "${rclone_bin}" ]]; then + printf 'Missing rclone wrapper: %s\n' "${rclone_bin}" >&2 + exit 69 +fi +if [[ ! -x "${auth_guard}" ]]; then + printf 'Missing authentication lifecycle guard: %s\n' "${auth_guard}" >&2 + exit 69 +fi + +cache_helper="${installed_cache_helper}" +if [[ ! -x "${cache_helper}" && -x "${source_cache_helper}" ]]; then + cache_helper="${source_cache_helper}" +fi +if [[ ! -x "${cache_helper}" ]]; then + printf 'Account cache resolver is missing: %s\n' "${cache_helper}" >&2 + exit 69 +fi + +service_properties() { + systemctl --user show "${service_name}" \ + -p ActiveState -p SubState -p MainPID --no-pager 2>/dev/null || true +} + +dirty_file_count() { + [[ -d "${cache_root}" ]] || { printf '0\n'; return 0; } + if ! python3 - "${cache_root}" 2>/dev/null <<'PY' +import json +import os +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +dirty = 0 + + +def walk_error(error: OSError) -> None: + raise error + + +for directory, directory_names, file_names in os.walk(root, followlinks=False, onerror=walk_error): + current = pathlib.Path(directory) + parts = current.relative_to(root).parts + metadata_index = next((index for index, part in enumerate(parts) if part == "vfsMeta"), -1) + relevant = metadata_index >= 0 and len(parts) > metadata_index + 1 and parts[metadata_index + 1].startswith("proton") + for directory_name in list(directory_names): + candidate = current / directory_name + if candidate.is_symlink() and (relevant or directory_name == "vfsMeta"): + raise RuntimeError("unsafe VFS metadata symlink") + if not relevant: + continue + for file_name in file_names: + path = current / file_name + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(descriptor, "r", encoding="utf-8") as stream: + payload = json.load(stream) + if not isinstance(payload, dict) or not isinstance(payload.get("Dirty"), bool): + raise RuntimeError("invalid VFS metadata") + dirty += int(payload["Dirty"]) + +print(dirty) +PY + then + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=preflight-unavailable' >&2 + printf '%s\n' \ + 'Account change refused: local VFS metadata could not be validated safely.' \ + 'The current account, mount and cache remain unchanged.' >&2 + return 75 + fi +} + +preflight() { + local properties active sub pid queue_json stats_json queue_count transfer_count dirty_count + + properties="$(service_properties)" + active="$(awk -F= '$1 == "ActiveState" { print $2; exit }' <<< "${properties}")" + sub="$(awk -F= '$1 == "SubState" { print $2; exit }' <<< "${properties}")" + pid="$(awk -F= '$1 == "MainPID" { print $2; exit }' <<< "${properties}")" + : "${active:=unknown}" "${sub:=unknown}" "${pid:=0}" + + case "${active}/${sub}" in + active/running|active/mounted) ;; + inactive/dead|failed/failed|inactive/failed) ;; + *) + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=preflight-unavailable' >&2 + printf 'Account change refused while the service state is %s/%s.\n' \ + "${active}" "${sub}" >&2 + return 75 + ;; + esac + + if [[ "${active}" == active ]]; then + if [[ ! "${pid}" =~ ^[1-9][0-9]*$ || ! -S "${rc_socket}" ]] \ + || ! queue_json="$(timeout --signal=TERM 10s "${rclone_bin}" rc \ + --unix-socket "${rc_socket}" vfs/queue 2>/dev/null)" \ + || ! stats_json="$(timeout --signal=TERM 10s "${rclone_bin}" rc \ + --unix-socket "${rc_socket}" core/stats 2>/dev/null)" \ + || ! jq -e '.queue | type == "array"' >/dev/null 2>&1 <<< "${queue_json}" \ + || ! jq -e '(.transferring // []) | type == "array"' >/dev/null 2>&1 <<< "${stats_json}"; then + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=preflight-unavailable' >&2 + printf '%s\n' \ + 'Account change refused: the live transfer state could not be validated safely.' \ + 'The current account, mount and cache remain unchanged.' >&2 + return 75 + fi + queue_count="$(jq -r '.queue | length' <<< "${queue_json}")" + transfer_count="$(jq -r '(.transferring // []) | length' <<< "${stats_json}")" + if (( queue_count != 0 || transfer_count != 0 )); then + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=busy' >&2 + printf 'Account change refused: VFS queue %s, active transfers %s.\n' \ + "${queue_count}" "${transfer_count}" >&2 + printf '%s\n' \ + 'Wait for every upload and download to finish. Nothing was changed.' >&2 + return 75 + fi + elif mountpoint -q -- "${mount_dir}"; then + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=preflight-unavailable' >&2 + printf '%s\n' \ + 'Account change refused: /pdrive is mounted outside the expected active service state.' \ + 'The current account, mount and cache remain unchanged.' >&2 + return 75 + fi + + if ! dirty_count="$(dirty_file_count)"; then + return 75 + fi + if (( dirty_count != 0 )); then + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=dirty-cache' >&2 + printf 'Account change refused: %s Dirty VFS cache file(s) remain protected.\n' \ + "${dirty_count}" >&2 + printf '%s\n' \ + 'Wait for pending uploads to finish. No cache data was reassigned or removed.' >&2 + return 75 + fi + + printf '%s\n' \ + 'Preflight passed: no active transfers, queued uploads or Dirty VFS data.' +} + +if [[ "${action}" == --preflight ]]; then + preflight + exit 0 +fi + +if [[ ! -f "${config_file}" || -L "${config_file}" || ! -r "${config_file}" ]]; then + printf 'Existing rclone configuration is missing, unreadable or unsafe: %s\n' \ + "${config_file}" >&2 + exit 73 +fi +if [[ "$(stat -c %u -- "${config_file}" 2>/dev/null || true)" != "$(id -u)" ]]; then + printf 'Refusing a configuration not owned by the current user: %s\n' \ + "${config_file}" >&2 + exit 73 +fi + +mkdir -p -- "${state_dir}" +exec 9> "${state_dir}/pdrive-auth-transaction.lock" +if ! flock -n 9; then + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=already-running' >&2 + printf '%s\n' 'Another PDrive authentication transaction is already running.' >&2 + exit 75 +fi + +if ! IFS= read -r -d '' username \ + || ! IFS= read -r -d '' password \ + || ! IFS= read -r -d '' two_factor; then + printf 'Invalid credential transport from pdrive-account-switch.\n' >&2 + exit 65 +fi +if [[ -z "${username}" || "${username}" == *$'\n'* || "${username}" == *$'\r'* ]]; then + unset password two_factor + printf 'Invalid Proton username or email address.\n' >&2 + exit 65 +fi +if [[ -z "${password}" || "${password}" == *$'\n'* || "${password}" == *$'\r'* ]]; then + unset password two_factor username + printf 'Invalid Proton password.\n' >&2 + exit 65 +fi +if [[ -n "${two_factor}" && ! "${two_factor}" =~ ^[0-9]{6}$ ]]; then + unset password two_factor username + printf 'The 2FA code must be empty or exactly six digits.\n' >&2 + exit 65 +fi + +preflight >/dev/null + +mkdir -p -- "${config_file%/*}" "${account_config%/*}" "${backup_dir}" "${cache_root}/accounts" +temporary_config="$(mktemp "${config_file%/*}/rclone.conf.account-switch.XXXXXX")" +test_cache="$(mktemp -d "${state_dir}/account-switch-login-cache.XXXXXX")" +temporary_account_config='' +transaction_dir='' +login_output='' +# Invoked indirectly by the EXIT trap below. +# shellcheck disable=SC2329 +cleanup() { + unset password two_factor obscured_password username login_output + [[ -z "${temporary_config:-}" ]] || rm -f -- "${temporary_config}" + [[ -z "${temporary_account_config:-}" ]] || rm -f -- "${temporary_account_config}" + [[ -z "${test_cache:-}" ]] || rm -rf -- "${test_cache}" +} +trap cleanup EXIT + +obscured_password="$(printf '%s\n' "${password}" | "${rclone_bin}" obscure -)" +unset password +{ + printf '%s\n' \ + '[proton]' \ + 'type = protondrive' \ + "username = ${username}" \ + "password = ${obscured_password}" \ + 'original_file_size = true' \ + 'enable_caching = false' + if [[ -n "${two_factor}" ]]; then + printf '2fa = %s\n' "${two_factor}" + fi +} > "${temporary_config}" +unset obscured_password username + +"${rclone_bin}" --config="${temporary_config}" config encryption set >/dev/null +"${rclone_bin}" --config="${temporary_config}" config encryption check >/dev/null + +set +e +login_output="$(timeout --signal=TERM 90s "${rclone_bin}" \ + --config="${temporary_config}" \ + --cache-dir="${test_cache}" \ + --low-level-retries=0 \ + --retries=1 \ + --log-level=ERROR \ + lsd proton: --max-depth 1 2>&1 >/dev/null)" +login_rc=$? +set -e + +"${rclone_bin}" --config="${temporary_config}" config update proton 2fa '' --no-output +unset two_factor + +if (( login_rc != 0 )); then + if grep -Eqi '(^|[^0-9])(HTTP[ =]?429|Status=429|Code=429)([^0-9]|$)' <<< "${login_output}"; then + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=rate-limited' >&2 + printf '%s\n' \ + 'The candidate account login was rate-limited. The existing account,' \ + 'mount, cache and authentication lifecycle state remain unchanged.' >&2 + else + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=login-rejected' >&2 + printf '%s\n' \ + 'The candidate account login failed. The existing account, mount and' \ + 'cache remain unchanged.' >&2 + fi + unset login_output + exit "${login_rc}" +fi +unset login_output + +# Transfers can begin during the bounded candidate login. Recheck every safety +# fact immediately before the first live mutation. +preflight >/dev/null + +old_cache_dir="$("${cache_helper}" --path)" +old_properties="$(service_properties)" +old_active="$(awk -F= '$1 == "ActiveState" { print $2; exit }' <<< "${old_properties}")" +new_namespace="account-$(openssl rand -hex 16)" +[[ "${new_namespace}" =~ ^account-[0-9a-f]{32}$ ]] +new_cache_dir="${cache_root}/accounts/${new_namespace}" +if [[ -e "${new_cache_dir}" ]]; then + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=namespace-collision' >&2 + printf '%s\n' 'The new anonymous cache namespace already exists; nothing was changed.' >&2 + exit 73 +fi +install -d -m 0700 -- "${new_cache_dir}" + +timestamp="$(date '+%Y%m%d-%H%M%S')" +transaction_dir="$(mktemp -d "${backup_dir}/account-switch-${timestamp}.XXXXXX")" +chmod 0700 "${transaction_dir}" +install -m 0600 "${config_file}" "${transaction_dir}/rclone.conf" +if [[ -e "${account_config}" ]]; then + install -m 0600 "${account_config}" "${transaction_dir}/pdrive-account.conf" +else + : > "${transaction_dir}/pdrive-account.conf.absent" +fi +if [[ -e "${auth_state_file}" ]]; then + install -m 0600 "${auth_state_file}" "${transaction_dir}/pdrive-auth-state.json" +else + : > "${transaction_dir}/pdrive-auth-state.json.absent" +fi + +temporary_account_config="$(mktemp "${account_config%/*}/pdrive-account.conf.XXXXXX")" +printf '%s\n' \ + '# Managed by PDrive account switching; never source as shell code.' \ + '# The opaque namespace contains no Proton account identifier.' \ + "cache_namespace=${new_namespace}" > "${temporary_account_config}" +chmod 0600 "${temporary_account_config}" "${temporary_config}" + +mount_is_verified() { + local expected_cache="$1" properties active pid findmnt_line options pid_json stats_json + local disk_path meta_path expected_real + + mountpoint -q -- "${mount_dir}" || return 1 + findmnt_line="$(findmnt -rn -M "${mount_dir}" -o FSTYPE,OPTIONS 2>/dev/null)" || return 1 + [[ "${findmnt_line%% *}" =~ ^fuse(\.rclone)?$ ]] || return 1 + options="${findmnt_line#* }" + [[ ",${options}," == *,rw,* ]] || return 1 + properties="$(service_properties)" + active="$(awk -F= '$1 == "ActiveState" { print $2; exit }' <<< "${properties}")" + pid="$(awk -F= '$1 == "MainPID" { print $2; exit }' <<< "${properties}")" + [[ "${active}" == active && "${pid}" =~ ^[1-9][0-9]*$ ]] || return 1 + pid_json="$(timeout --signal=TERM 5s "${rclone_bin}" rc \ + --unix-socket "${rc_socket}" core/pid 2>/dev/null)" || return 1 + [[ "$(jq -r '.pid // 0' <<< "${pid_json}")" == "${pid}" ]] || return 1 + stats_json="$(timeout --signal=TERM 5s "${rclone_bin}" rc \ + --unix-socket "${rc_socket}" vfs/stats 2>/dev/null)" || return 1 + disk_path="$(jq -er '.diskCache.path | select(type == "string")' <<< "${stats_json}" 2>/dev/null)" || return 1 + meta_path="$(jq -er '.diskCache.pathMeta | select(type == "string")' <<< "${stats_json}" 2>/dev/null)" || return 1 + expected_real="$(realpath -m -- "${expected_cache}")" + [[ "$(realpath -m -- "${disk_path}")" == "${expected_real}/"* ]] || return 1 + [[ "$(realpath -m -- "${meta_path}")" == "${expected_real}/"* ]] +} + +restore_previous_transaction() { + local stopped=true files_restored=true mount_restored=true + + systemctl --user stop "${service_name}" || stopped=false + if ! install -m 0600 "${transaction_dir}/rclone.conf" "${config_file}.rollback" \ + || ! mv -f -- "${config_file}.rollback" "${config_file}"; then + files_restored=false + fi + if [[ -f "${transaction_dir}/pdrive-account.conf" ]]; then + if ! install -m 0600 "${transaction_dir}/pdrive-account.conf" "${account_config}.rollback" \ + || ! mv -f -- "${account_config}.rollback" "${account_config}"; then + files_restored=false + fi + elif ! rm -f -- "${account_config}"; then + files_restored=false + fi + if [[ -f "${transaction_dir}/pdrive-auth-state.json" ]]; then + if ! install -m 0600 "${transaction_dir}/pdrive-auth-state.json" "${auth_state_file}.rollback" \ + || ! mv -f -- "${auth_state_file}.rollback" "${auth_state_file}"; then + files_restored=false + fi + elif ! rm -f -- "${auth_state_file}"; then + files_restored=false + fi + systemctl --user reset-failed "${service_name}" || true + + if [[ "${old_active}" == active ]]; then + mount_restored=false + if [[ "${stopped}" == true && "${files_restored}" == true ]] \ + && systemctl --user start --no-block "${service_name}"; then + for ((attempt = 0; attempt < rollback_attempts; attempt += 1)); do + if mount_is_verified "${old_cache_dir}"; then + mount_restored=true + break + fi + sleep "${poll_seconds}" + done + fi + fi + + [[ "${stopped}" == true && "${files_restored}" == true && "${mount_restored}" == true ]] +} + +activation_started=false +activation_ready=true +if systemctl --user stop "${service_name}"; then + activation_started=true +else + activation_ready=false +fi +if [[ "${activation_ready}" == true ]]; then + if mv -f -- "${temporary_config}" "${config_file}"; then + temporary_config='' + else + activation_ready=false + fi +fi +if [[ "${activation_ready}" == true ]]; then + if mv -f -- "${temporary_account_config}" "${account_config}"; then + temporary_account_config='' + else + activation_ready=false + fi +fi +if [[ "${activation_ready}" == true ]] \ + && ! "${auth_guard}" --mark-healthy; then + activation_ready=false +fi +if [[ "${activation_ready}" == true ]] \ + && ! systemctl --user reset-failed "${service_name}"; then + activation_ready=false +fi +if [[ "${activation_ready}" == true ]] \ + && ! systemctl --user start --no-block "${service_name}"; then + activation_ready=false +fi + +if [[ "${activation_ready}" != true ]]; then + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=activation-failed-rolled-back' >&2 + if [[ "${activation_started}" != true ]]; then + printf '%s\n' \ + 'The current service could not be stopped, so no live configuration' \ + 'or cache selector was changed.' >&2 + exit 75 + fi + if restore_previous_transaction; then + printf '%s\n' \ + 'Candidate activation failed before mount validation. The previous' \ + 'configuration, cache namespace and authentication state were restored.' >&2 + exit 75 + fi + printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=rollback-failed' >&2 + printf '%s\n' \ + 'Candidate activation failed and the previous transaction could not be' \ + 'fully restored. Preserve every cache namespace and run pdrive-doctor.' >&2 + exit 74 +fi + +new_mount_ready=false +for ((attempt = 0; attempt < mount_attempts; attempt += 1)); do + if mount_is_verified "${new_cache_dir}"; then + new_mount_ready=true + break + fi + sleep "${poll_seconds}" +done +if [[ "${new_mount_ready}" == true ]]; then + printf '%s\n' \ + 'Proton account change succeeded. /pdrive is mounted through a new,' \ + 'anonymous account-specific cache namespace.' \ + "Encrypted rollback bundle: ${transaction_dir}" + exit 0 +fi + +printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=activation-failed-rolled-back' >&2 +if restore_previous_transaction; then + printf '%s\n' \ + 'The candidate account authenticated, but its managed mount did not pass' \ + 'validation. The previous encrypted configuration, cache namespace and' \ + 'authentication state were restored; no cache data was reassigned.' >&2 + exit 75 +fi + +printf '%s\n' 'PDRIVE_ACCOUNT_SWITCH_ERROR=rollback-failed' >&2 +printf '%s\n' \ + 'The candidate mount failed validation and the previous files were restored,' \ + 'but the previous mount also did not become ready. Run pdrive-doctor; do not' \ + 'delete either cache namespace.' >&2 +exit 74 diff --git a/tests/check.sh b/tests/check.sh index 2923dd4..681d455 100755 --- a/tests/check.sh +++ b/tests/check.sh @@ -132,6 +132,8 @@ manual_assets=( 'docs/assets/pdrive-control-center.png' 'docs/assets/pdrive-transfers.png' 'docs/assets/pdrive-auth-cooldown.png' + 'docs/assets/pdrive-account-settings.png' + 'docs/assets/pdrive-account-switch.png' 'docs/assets/pdrive-setup-wizard.png' ) for manual_path in "${manual_files[@]}" "${manual_assets[@]}"; do @@ -158,6 +160,7 @@ done "${project_dir}/tests/test-prerequisites.sh" "${project_dir}/tests/test-setup.sh" "${project_dir}/tests/test-reauth.sh" +"${project_dir}/tests/test-account-switch.sh" "${project_dir}/tests/test-auth-failure-guard.sh" "${project_dir}/tests/test-setup-wizard-ui.sh" "${project_dir}/tests/test-state.sh" diff --git a/tests/test-account-switch.sh b/tests/test-account-switch.sh new file mode 100755 index 0000000..7578b8b --- /dev/null +++ b/tests/test-account-switch.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail + +project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +test_root="$(mktemp -d /tmp/proton-drive-linux-account-switch.XXXXXX)" +cleanup() { rm -rf -- "${test_root}"; } +trap cleanup EXIT + +fake_bin="${test_root}/bin" +mkdir -p -- "${fake_bin}" + +# Keep fixture expansions literal until the fake command runs. +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "systemctl:%s\n" "$*" >> "${PDRIVE_TEST_EVENT_LOG}"' \ + 'case " $* " in' \ + ' *" show "*)' \ + ' state="$(cat "${PDRIVE_TEST_SERVICE_STATE}")"' \ + ' if [[ "${state}" == active ]]; then' \ + ' printf "ActiveState=active\nSubState=running\nMainPID=4242\n"' \ + ' else' \ + ' printf "ActiveState=inactive\nSubState=dead\nMainPID=0\n"' \ + ' fi' \ + ' ;;' \ + ' *" stop rclone-proton-drive.service "*) printf "inactive\n" > "${PDRIVE_TEST_SERVICE_STATE}" ;;' \ + ' *" start --no-block rclone-proton-drive.service "*) printf "active\n" > "${PDRIVE_TEST_SERVICE_STATE}" ;;' \ + 'esac' \ + 'exit 0' > "${fake_bin}/systemctl" + +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + '[[ "${1:-}" == -q ]] || exit 2' \ + '[[ "$(cat "${PDRIVE_TEST_SERVICE_STATE}")" == active ]] || exit 1' \ + 'if [[ "${PDRIVE_TEST_FAIL_NEW_MOUNT:-}" == 1 && -e "${PDRIVE_ACCOUNT_CONFIG}" ]]; then exit 1; fi' \ + 'exit 0' > "${fake_bin}/mountpoint" + +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [[ "$(cat "${PDRIVE_TEST_SERVICE_STATE}")" != active ]]; then exit 1; fi' \ + 'if [[ "${PDRIVE_TEST_FAIL_NEW_MOUNT:-}" == 1 && -e "${PDRIVE_ACCOUNT_CONFIG}" ]]; then exit 1; fi' \ + 'printf "fuse.rclone rw,nosuid,nodev\n"' > "${fake_bin}/findmnt" + +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "rclone:" >> "${PDRIVE_TEST_ARGV_LOG}"' \ + 'printf " %q" "$@" >> "${PDRIVE_TEST_ARGV_LOG}"' \ + 'printf "\n" >> "${PDRIVE_TEST_ARGV_LOG}"' \ + 'config=""' \ + 'for argument in "$@"; do case "${argument}" in --config=*) config="${argument#--config=}" ;; esac; done' \ + 'case "$*" in' \ + ' *"obscure -"*) IFS= read -r _secret; printf "obscured-candidate\n" ;;' \ + ' *" config encryption set"*|*" config encryption check"*) exit 0 ;;' \ + ' *" config update proton 2fa "*) sed -i "/^2fa = /d" "${config}" ;;' \ + ' *" lsd proton:"*)' \ + ' printf "rclone:login\n" >> "${PDRIVE_TEST_EVENT_LOG}"' \ + ' if [[ "${PDRIVE_TEST_RATE_LIMIT:-}" == 1 ]]; then printf "Status=429\n" >&2; exit 43; fi' \ + ' [[ "${PDRIVE_TEST_LOGIN_FAIL:-}" != 1 ]] || exit 42' \ + ' grep -qFx "password = obscured-candidate" "${config}"' \ + ' ;;' \ + ' *" vfs/queue"*)' \ + ' if [[ "${PDRIVE_TEST_QUEUE:-}" == 1 ]]; then printf "{\"queue\":[{\"name\":\"pending.bin\"}]}\n"; else printf "{\"queue\":[]}\n"; fi' \ + ' ;;' \ + ' *" core/stats"*)' \ + ' if [[ "${PDRIVE_TEST_TRANSFER:-}" == 1 ]]; then printf "{\"transferring\":[{\"name\":\"active.bin\"}]}\n"; else printf "{\"transferring\":[]}\n"; fi' \ + ' ;;' \ + ' *" core/pid"*) printf "{\"pid\":4242}\n" ;;' \ + ' *" vfs/stats"*)' \ + ' cache="${PDRIVE_CACHE_ROOT}"' \ + ' if [[ -r "${PDRIVE_ACCOUNT_CONFIG}" ]]; then namespace="$(cut -d= -f2 "${PDRIVE_ACCOUNT_CONFIG}" | tail -n 1)"; cache="${cache}/accounts/${namespace}"; fi' \ + ' printf "{\"diskCache\":{\"path\":\"%s/vfs/proton\",\"pathMeta\":\"%s/vfsMeta/proton\"}}\n" "${cache}" "${cache}"' \ + ' ;;' \ + ' *) exit 2 ;;' \ + 'esac' > "${fake_bin}/rclone" + +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "auth-guard:%s\n" "$*" >> "${PDRIVE_TEST_EVENT_LOG}"' \ + '[[ "${PDRIVE_TEST_AUTH_GUARD_FAIL:-}" != 1 ]] || exit 44' \ + 'if [[ "${1:-}" == --mark-healthy ]]; then printf "%s\n" "{\"schema_version\":1,\"status\":\"ready\",\"reason\":\"authenticated\",\"restart_suppressed\":false}" > "${PDRIVE_AUTH_STATE}"; fi' \ + 'exit 0' > "${fake_bin}/pdrive-auth-failure-guard" + +chmod 0755 "${fake_bin}"/* + +setup_home() { + local fixture_home="$1" + mkdir -p -- \ + "${fixture_home}/.config/rclone" \ + "${fixture_home}/.local/state/rclone" \ + "${fixture_home}/.cache/rclone" \ + "${fixture_home}/mount" + printf '%s\n' '[proton]' 'type = protondrive' 'old = current-account' \ + > "${fixture_home}/.config/rclone/rclone.conf" + chmod 0600 "${fixture_home}/.config/rclone/rclone.conf" + printf 'active\n' > "${fixture_home}/service-state" + printf '%s\n' \ + '{"schema_version":1,"status":"ready","reason":"authenticated","restart_suppressed":false}' \ + > "${fixture_home}/.local/state/rclone/pdrive-auth-state.json" + python3 - "${fixture_home}/.local/state/rclone/pdrive-rc.sock" <<'PY' +import socket +import sys + +listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +listener.bind(sys.argv[1]) +listener.close() +PY +} + +run_fixture() { + local fixture_home="$1" + shift + HOME="${fixture_home}" \ + PATH="${fake_bin}:/usr/bin:/bin" \ + PDRIVE_RCLONE_BIN="${fake_bin}/rclone" \ + PDRIVE_RCLONE_CONFIG="${fixture_home}/.config/rclone/rclone.conf" \ + PDRIVE_ACCOUNT_CONFIG="${fixture_home}/.config/pdrive-account.conf" \ + PDRIVE_CACHE_ROOT="${fixture_home}/.cache/rclone" \ + PDRIVE_STATE_DIR="${fixture_home}/.local/state/rclone" \ + PDRIVE_BACKUP_DIR="${fixture_home}/.config/rclone/backups" \ + PDRIVE_MOUNT_DIR="${fixture_home}/mount" \ + PDRIVE_RC_SOCKET="${fixture_home}/.local/state/rclone/pdrive-rc.sock" \ + PDRIVE_AUTH_STATE="${fixture_home}/.local/state/rclone/pdrive-auth-state.json" \ + PDRIVE_AUTH_GUARD="${fake_bin}/pdrive-auth-failure-guard" \ + PDRIVE_ACCOUNT_CACHE_HELPER="${project_dir}/libexec/pdrive-account-cache" \ + PDRIVE_ACCOUNT_SWITCH_MOUNT_ATTEMPTS=1 \ + PDRIVE_ACCOUNT_SWITCH_ROLLBACK_ATTEMPTS=1 \ + PDRIVE_ACCOUNT_SWITCH_POLL_SECONDS=0 \ + PDRIVE_TEST_ARGV_LOG="${fixture_home}/argv.log" \ + PDRIVE_TEST_EVENT_LOG="${fixture_home}/events.log" \ + PDRIVE_TEST_SERVICE_STATE="${fixture_home}/service-state" \ + "$@" +} + +resolver_home="${test_root}/resolver-home" +mkdir -p -- "${resolver_home}/.config" +legacy_path="$(HOME="${resolver_home}" "${project_dir}/libexec/pdrive-account-cache" --path)" +[[ "${legacy_path}" == "${resolver_home}/.cache/rclone" ]] +printf '%s\n' 'cache_namespace=account-0123456789abcdef0123456789abcdef' \ + > "${resolver_home}/.config/pdrive-account.conf" +resolved_path="$(HOME="${resolver_home}" "${project_dir}/libexec/pdrive-account-cache" --path)" +[[ "${resolved_path}" == "${resolver_home}/.cache/rclone/accounts/account-0123456789abcdef0123456789abcdef" ]] +printf '%s\n' 'cache_namespace=../../unsafe' > "${resolver_home}/.config/pdrive-account.conf" +if HOME="${resolver_home}" "${project_dir}/libexec/pdrive-account-cache" --path >/dev/null 2>&1; then + printf 'An unsafe account cache namespace was accepted.\n' >&2 + exit 1 +fi + +busy_home="${test_root}/busy-home" +setup_home "${busy_home}" +if PDRIVE_TEST_QUEUE=1 run_fixture "${busy_home}" \ + "${project_dir}/libexec/switch-rclone-proton-account" --preflight \ + > "${busy_home}/stdout" 2> "${busy_home}/stderr"; then + printf 'An account change preflight accepted a queued upload.\n' >&2 + exit 1 +fi +grep -qF 'PDRIVE_ACCOUNT_SWITCH_ERROR=busy' "${busy_home}/stderr" +if grep -qF ' stop ' "${busy_home}/events.log"; then + printf 'A blocked preflight changed the service lifecycle.\n' >&2 + exit 1 +fi + +dirty_home="${test_root}/dirty-home" +setup_home "${dirty_home}" +mkdir -p -- "${dirty_home}/.cache/rclone/vfsMeta/proton/fixture" +printf '%s\n' '{"Dirty":true,"Size":4096}' \ + > "${dirty_home}/.cache/rclone/vfsMeta/proton/fixture/pending.bin" +if run_fixture "${dirty_home}" \ + "${project_dir}/libexec/switch-rclone-proton-account" --preflight \ + > "${dirty_home}/stdout" 2> "${dirty_home}/stderr"; then + printf 'An account change preflight accepted Dirty cache data.\n' >&2 + exit 1 +fi +grep -qF 'PDRIVE_ACCOUNT_SWITCH_ERROR=dirty-cache' "${dirty_home}/stderr" + +test_username="candidate-$RANDOM-$RANDOM" +test_password="password-$RANDOM-$RANDOM-$RANDOM" +test_2fa="$(printf '%06d' "$((RANDOM % 1000000))")" + +failed_home="${test_root}/failed-home" +setup_home "${failed_home}" +failed_before="$(sha256sum "${failed_home}/.config/rclone/rclone.conf")" +if printf '%s\0' "${test_username}" "${test_password}" "${test_2fa}" \ + | PDRIVE_TEST_LOGIN_FAIL=1 run_fixture "${failed_home}" \ + "${project_dir}/libexec/switch-rclone-proton-account" --switch-from-stdin \ + > "${failed_home}/stdout" 2> "${failed_home}/stderr"; then + printf 'A failed candidate login was accepted.\n' >&2 + exit 1 +fi +[[ "$(sha256sum "${failed_home}/.config/rclone/rclone.conf")" == "${failed_before}" ]] +[[ ! -e "${failed_home}/.config/pdrive-account.conf" ]] +if grep -qF 'systemctl:--user stop' "${failed_home}/events.log"; then + printf 'A failed candidate login stopped the current service.\n' >&2 + exit 1 +fi +grep -qF 'PDRIVE_ACCOUNT_SWITCH_ERROR=login-rejected' "${failed_home}/stderr" + +rate_home="${test_root}/rate-home" +setup_home "${rate_home}" +if printf '%s\0' "${test_username}" "${test_password}" "${test_2fa}" \ + | PDRIVE_TEST_RATE_LIMIT=1 run_fixture "${rate_home}" \ + "${project_dir}/libexec/switch-rclone-proton-account" --switch-from-stdin \ + > "${rate_home}/stdout" 2> "${rate_home}/stderr"; then + printf 'A rate-limited candidate login was accepted.\n' >&2 + exit 1 +fi +grep -qF 'PDRIVE_ACCOUNT_SWITCH_ERROR=rate-limited' "${rate_home}/stderr" +if grep -qF 'auth-guard:' "${rate_home}/events.log"; then + printf 'Candidate rate limiting leaked into the same-account authentication guard.\n' >&2 + exit 1 +fi + +success_home="${test_root}/success-home" +setup_home "${success_home}" +if ! printf '%s\0' "${test_username}" "${test_password}" "${test_2fa}" \ + | run_fixture "${success_home}" \ + "${project_dir}/libexec/switch-rclone-proton-account" --switch-from-stdin \ + > "${success_home}/stdout" 2> "${success_home}/stderr"; then + printf 'A valid candidate account switch failed:\n' >&2 + sed -n '1,20p' "${success_home}/stderr" >&2 + exit 1 +fi +grep -qFx "username = ${test_username}" "${success_home}/.config/rclone/rclone.conf" +grep -qFx 'password = obscured-candidate' "${success_home}/.config/rclone/rclone.conf" +if grep -qF "${test_2fa}" "${success_home}/.config/rclone/rclone.conf"; then + printf 'The one-time code remained in the installed configuration.\n' >&2 + exit 1 +fi +namespace="$(awk -F= '$1 == "cache_namespace" { print $2 }' "${success_home}/.config/pdrive-account.conf")" +[[ "${namespace}" =~ ^account-[0-9a-f]{32}$ ]] +[[ -d "${success_home}/.cache/rclone/accounts/${namespace}" ]] +rollback_bundle="$(find "${success_home}/.config/rclone/backups" -mindepth 1 -maxdepth 1 \ + -type d -name 'account-switch-*' -print -quit)" +[[ -n "${rollback_bundle}" && -f "${rollback_bundle}/rclone.conf" ]] +grep -qF 'old = current-account' "${rollback_bundle}/rclone.conf" +login_line="$(grep -nF 'rclone:login' "${success_home}/events.log" | head -n 1 | cut -d: -f1)" +stop_line="$(grep -nF 'systemctl:--user stop rclone-proton-drive.service' \ + "${success_home}/events.log" | cut -d: -f1)" +(( login_line < stop_line )) +grep -qF 'auth-guard:--mark-healthy' "${success_home}/events.log" +if grep -RqsF "${test_password}" \ + "${success_home}/argv.log" "${success_home}/events.log" \ + "${success_home}/stdout" "${success_home}/stderr"; then + printf 'The candidate password escaped into output, argv or lifecycle logs.\n' >&2 + exit 1 +fi +if grep -RqsF "${test_username}" \ + "${success_home}/argv.log" "${success_home}/events.log" \ + "${success_home}/stdout" "${success_home}/stderr"; then + printf 'The candidate account identifier escaped into output, argv or lifecycle logs.\n' >&2 + exit 1 +fi + +rollback_home="${test_root}/rollback-home" +setup_home "${rollback_home}" +rollback_before="$(sha256sum "${rollback_home}/.config/rclone/rclone.conf")" +if printf '%s\0' "${test_username}" "${test_password}" "${test_2fa}" \ + | PDRIVE_TEST_FAIL_NEW_MOUNT=1 run_fixture "${rollback_home}" \ + "${project_dir}/libexec/switch-rclone-proton-account" --switch-from-stdin \ + > "${rollback_home}/stdout" 2> "${rollback_home}/stderr"; then + printf 'A candidate mount validation failure reported success.\n' >&2 + exit 1 +fi +grep -qF 'PDRIVE_ACCOUNT_SWITCH_ERROR=activation-failed-rolled-back' \ + "${rollback_home}/stderr" +[[ "$(sha256sum "${rollback_home}/.config/rclone/rclone.conf")" == "${rollback_before}" ]] +[[ ! -e "${rollback_home}/.config/pdrive-account.conf" ]] +[[ "$(cat "${rollback_home}/service-state")" == active ]] + +activation_home="${test_root}/activation-home" +setup_home "${activation_home}" +activation_before="$(sha256sum "${activation_home}/.config/rclone/rclone.conf")" +if printf '%s\0' "${test_username}" "${test_password}" "${test_2fa}" \ + | PDRIVE_TEST_AUTH_GUARD_FAIL=1 run_fixture "${activation_home}" \ + "${project_dir}/libexec/switch-rclone-proton-account" --switch-from-stdin \ + > "${activation_home}/stdout" 2> "${activation_home}/stderr"; then + printf 'A failed activation guard reported account-switch success.\n' >&2 + exit 1 +fi +grep -qF 'PDRIVE_ACCOUNT_SWITCH_ERROR=activation-failed-rolled-back' \ + "${activation_home}/stderr" +[[ "$(sha256sum "${activation_home}/.config/rclone/rclone.conf")" == "${activation_before}" ]] +[[ ! -e "${activation_home}/.config/pdrive-account.conf" ]] +[[ "$(cat "${activation_home}/service-state")" == active ]] + +printf 'PDrive guarded account-switch checks passed.\n' diff --git a/tests/test-help.sh b/tests/test-help.sh index 65c365e..4590f78 100755 --- a/tests/test-help.sh +++ b/tests/test-help.sh @@ -18,7 +18,7 @@ snapshot() { } before="$(snapshot)" -for helper in pdrive-bwlimit pdrive-cache-age pdrive-doctor pdrive-draft-recovery pdrive-network-tune pdrive-reauth \ +for helper in pdrive-account-switch pdrive-bwlimit pdrive-cache-age pdrive-doctor pdrive-draft-recovery pdrive-network-tune pdrive-reauth \ pdrive-prerequisites pdrive-recovery pdrive-refresh pdrive-setup pdrive-state pdrive-transfers \ pdrive-ui pdrive-watch; do helper_help="$(HOME="${test_home}" "${project_dir}/bin/${helper}" --help 2>&1)" @@ -52,6 +52,8 @@ HOME="${test_home}" bash "${project_dir}/install.sh" --help >/dev/null HOME="${test_home}" bash "${project_dir}/uninstall.sh" --help >/dev/null HOME="${test_home}" bash "${project_dir}/libexec/setup-rclone-proton" >/dev/null HOME="${test_home}" bash "${project_dir}/libexec/reauth-rclone-proton" --help >/dev/null +HOME="${test_home}" bash "${project_dir}/libexec/pdrive-account-cache" --help >/dev/null +HOME="${test_home}" bash "${project_dir}/libexec/switch-rclone-proton-account" --help >/dev/null HOME="${test_home}" "${project_dir}/libexec/pdrive-auth-failure-guard" >/dev/null HOME="${test_home}" "${project_dir}/libexec/pdrive-auth-failure-guard" --help >/dev/null HOME="${test_home}" make -s -C "${project_dir}" help >/dev/null diff --git a/tests/test-ui-preferences.sh b/tests/test-ui-preferences.sh index 808142a..26be121 100755 --- a/tests/test-ui-preferences.sh +++ b/tests/test-ui-preferences.sh @@ -68,6 +68,8 @@ assert module.translate( ).startswith("Fabian Schneider — Quatschkomödie") assert module.translate("Keep running in the tray when the window closes").startswith("Beim Schließen") assert module.translate("Keep live metrics updating while hidden in the tray").startswith("Live-Metriken") +assert module.translate("Change Proton account …") == "Proton-Konto wechseln …" +assert module.translate("Previous account restored") == "Vorheriges Konto wiederhergestellt" module.CURRENT_LANGUAGE = "en" project_root = pathlib.Path(sys.argv[1]).resolve().parent.parent assert module.documentation_path("QUICK_START.md", "docs/QUICK_START.md") == project_root.joinpath( diff --git a/tests/test-ui-widgets.sh b/tests/test-ui-widgets.sh index 8cdbc9f..063e4c3 100755 --- a/tests/test-ui-widgets.sh +++ b/tests/test-ui-widgets.sh @@ -162,6 +162,39 @@ assert documentation_button.get_sensitive() assert button_with_label("Preferences …").get_sensitive() about_button = button_with_label("About …") assert about_button.get_sensitive() + +original_dialog_run = module.Gtk.Dialog.run +preferences_checked = [] + +def inspect_preferences(dialog): + labels = [ + widget.get_text() + for widget in descendants(dialog) + if isinstance(widget, module.Gtk.Label) + ] + buttons = [ + widget + for widget in descendants(dialog) + if isinstance(widget, module.Gtk.Button) + ] + account_button = next( + button + for button in buttons + if button.get_tooltip_text() == "Change Proton account …" + ) + assert "Account" in labels + assert account_button.get_visible() + assert account_button.get_sensitive() + assert account_button.get_halign() == module.Gtk.Align.START + assert account_button.get_events() & module.Gdk.EventMask.ENTER_NOTIFY_MASK + preferences_checked.append(True) + return module.Gtk.ResponseType.CANCEL + +module.Gtk.Dialog.run = inspect_preferences +window.on_preferences(None) +module.Gtk.Dialog.run = original_dialog_run +assert preferences_checked == [True] + menu_buttons[0].set_active(True) while module.Gtk.events_pending(): module.Gtk.main_iteration_do(False) @@ -577,6 +610,57 @@ assert not reauth_dialog.form.get_sensitive() assert reauth_dialog.hero_title.get_text() == "Login temporarily paused" reauth_dialog.destroy() +account_dialog = module.AccountSwitchDialog(window, demo=True) +while module.Gtk.events_pending(): + module.Gtk.main_iteration_do(False) +assert account_dialog.get_title() == "Change Proton account" +assert account_dialog.preflight_ready +assert "no active transfers" in account_dialog.preflight_label.get_text() +assert len( + [ + widget + for widget in descendants(account_dialog.form) + if isinstance(widget, module.Gtk.Entry) + ] +) == 4 +assert not account_dialog.switch_button.get_sensitive() +account_dialog.username_entry.set_text("candidate-user") +account_dialog.password_entry.set_text("generated-test-password") +account_dialog.password_confirm_entry.set_text("generated-test-password") +account_dialog.two_factor_entry.set_text("123") +account_dialog.confirmation.set_active(True) +assert not account_dialog.switch_button.get_sensitive() +account_dialog.two_factor_entry.set_text("123456") +assert account_dialog.switch_button.get_sensitive() +account_dialog.confirmation.set_active(False) +assert not account_dialog.switch_button.get_sensitive() +account_dialog.confirmation.set_active(True) +account_dialog.begin_busy_state() +assert account_dialog.busy +assert not account_dialog.form.get_sensitive() +assert not account_dialog.confirmation.get_sensitive() +assert account_dialog.progress.get_visible() +account_dialog.account_switch_finished( + 75, + "PDRIVE_ACCOUNT_SWITCH_ERROR=activation-failed-rolled-back", +) +assert account_dialog.completed +assert account_dialog.switch_button.get_label() == "Close" +assert account_dialog.hero_title.get_text() == "Previous account restored" +assert "No cache data" in account_dialog.result_detail.get_text() +account_dialog.destroy() + +module.CURRENT_LANGUAGE = "de" +german_account_dialog = module.AccountSwitchDialog(window, demo=True) +while module.Gtk.events_pending(): + module.Gtk.main_iteration_do(False) +assert german_account_dialog.get_title() == "Proton-Konto wechseln" +assert german_account_dialog.hero_title.get_text() == "/pdrive zu einem anderen Konto verschieben" +assert german_account_dialog.switch_button.get_label() == "Konto sicher wechseln" +assert german_account_dialog.confirmation.get_label().startswith("Ich verstehe") +german_account_dialog.destroy() +module.CURRENT_LANGUAGE = "en" + rate_limited_state = copy.deepcopy(auth_state) rate_limited_state["authentication"].update( { diff --git a/uninstall.sh b/uninstall.sh index 06d0e13..fca0cf3 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -60,13 +60,13 @@ if [[ -S "${rc_socket}" && -x "${rclone_bin}" ]] && command -v jq >/dev/null 2>& fi dirty_count=0 -if [[ -d "${HOME}/.cache/rclone/vfsMeta" ]]; then +if [[ -d "${HOME}/.cache/rclone" ]]; then if ! command -v jq >/dev/null 2>&1; then printf 'Cannot safely inspect VFS metadata without jq; uninstall refused.\n' >&2 exit 75 fi - dirty_count="$(find "${HOME}/.cache/rclone/vfsMeta" -mindepth 2 -type f \ - -path '*/proton*/*' -print0 2>/dev/null \ + dirty_count="$(find "${HOME}/.cache/rclone" -type f \ + -path '*/vfsMeta/proton*/*' -print0 2>/dev/null \ | xargs -0 -r jq -r 'select(.Dirty == true) | 1' 2>/dev/null \ | awk '{ count += $1 } END { print count + 0 }')" fi @@ -95,7 +95,7 @@ systemctl --user disable --now \ pdrive-watch.timer pdrive-draft-recovery.timer rclone-proton-drive.service \ rclone-selfupdate.timer proton-drive-update.timer >/dev/null 2>&1 || true -for file_name in pdrive-bwlimit pdrive-cache-age pdrive-doctor pdrive-draft-recovery pdrive-network-tune pdrive-reauth \ +for file_name in pdrive-account-switch pdrive-bwlimit pdrive-cache-age pdrive-doctor pdrive-draft-recovery pdrive-network-tune pdrive-reauth \ pdrive-recovery pdrive-refresh pdrive-setup pdrive-state pdrive-transfers \ pdrive-ui pdrive-watch rclone; do rm -f -- "${bin_dir}/${file_name}" @@ -110,8 +110,9 @@ fi if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "${applications_dir}" >/dev/null 2>&1 || true fi -for file_name in pdrive-draft-recovery-auto proton-drive-update rclone-bin rclone-proton-mount \ - rclone-proton-unmount rclone-selfupdate reauth-rclone-proton setup-rclone-proton; do +for file_name in pdrive-account-cache pdrive-draft-recovery-auto proton-drive-update rclone-bin rclone-proton-mount \ + rclone-proton-unmount rclone-selfupdate reauth-rclone-proton setup-rclone-proton \ + switch-rclone-proton-account; do rm -f -- "${libexec_dir}/${file_name}" done for file_name in pdrive-watch.service pdrive-watch.timer \ @@ -126,6 +127,8 @@ if [[ -d "${doc_dir}" ]]; then "${doc_dir}/docs/assets/pdrive-transfers.png" \ "${doc_dir}/docs/assets/pdrive-history.png" \ "${doc_dir}/docs/assets/pdrive-auth-cooldown.png" \ + "${doc_dir}/docs/assets/pdrive-account-settings.png" \ + "${doc_dir}/docs/assets/pdrive-account-switch.png" \ "${doc_dir}/docs/assets/pdrive-setup-wizard.png" \ "${doc_dir}/share/icons/hicolor/scalable/apps/io.github.claudiuschuster.PDriveControl.svg" rmdir "${doc_dir}/docs/assets" "${doc_dir}/docs" 2>/dev/null || true