diff --git a/Cargo.lock b/Cargo.lock index 1eb4a04..b2b6b76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,7 +166,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1254,7 +1254,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1433,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1896,7 +1896,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps 9.0.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3100,7 +3100,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3778,9 +3778,9 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "014165ed0f2d3847a4b8956ca94a82ba16c82f2d5c7fa139c3541c15150dba21" +checksum = "1e789cab811c5e73e47fd97810b78a513cb254f4dd763b4eb3cad680d6dd31da" dependencies = [ "async-trait", "base64 0.23.1", @@ -3805,9 +3805,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e16bf5592b7947708dd754defeaee7a58e9fa35988a6ea345b5b9a93765fd81" +checksum = "ace260acbf88c26f5bd4aef8c8c9898c322163c56620bdef3b6dd7742bec7619" dependencies = [ "aes-gcm 0.11.1", "async-trait", @@ -3926,7 +3926,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4374,7 +4374,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4431,7 +4431,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4764,7 +4764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4932,10 +4932,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5266,7 +5266,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5590,7 +5590,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7092855..d5259ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"] categories = ["filesystem", "command-line-utilities"] [workspace.dependencies] -proton-sdk = { version = "0.8.1" } -proton-drive-rs = { version = "0.8.1" } +proton-sdk = { version = "0.8.2" } +proton-drive-rs = { version = "0.8.2" } # Internal crates. pdfs-core = { path = "crates/pdfs-core" } diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 7f8d9a9..ccba21b 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2024,9 +2024,12 @@ fn cmd_status() -> Result<()> { staged_oldest_secs, paused, paused_until, + session_expired, + tokens_unsaved, .. }) => { let state = match (online, paused) { + _ if session_expired => ", session expired: run `pdfs login`", (_, true) => ", sync paused", (false, false) => ", offline", (true, false) => "", @@ -2059,6 +2062,11 @@ fn cmd_status() -> Result<()> { failing_error.as_deref().unwrap_or("no error recorded"), ); } + if tokens_unsaved { + println!( + "Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`" + ); + } } Ok(other) => println!("Mount: unexpected response {other:?}"), Err(_) => println!("Mount: not running."), @@ -2191,6 +2199,9 @@ fn cmd_daemon(mountpoint: Option) -> Result<()> { tracing::info!("daemon stopping"); return Ok(()); } + Ok(pdfs_fuse::MountOutcome::ResumeStored) => { + tracing::info!("resuming the session stored in the keyring"); + } Ok(pdfs_fuse::MountOutcome::Unmounted) => { tracing::warn!("mount ended externally; remounting in 2s"); pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2)); @@ -2261,9 +2272,14 @@ fn mount_once(mountpoint: Option) -> Result { }, ); - // Clean unmount check (as a best-effort final backup). - if let Err(e) = rt.block_on(auth::persist(&session)) { - tracing::debug!(error = %e, "no new tokens to persist on shutdown"); + // Last chance for a rotation the keyring refused when it happened. Only + // that: by now the keyring may hold a newer login, the one this stop was + // restarted for, and the tokens of this session would overwrite it. + if let Err(e) = auth::persist(&session) { + tracing::error!( + error = %e, + "could not save the refreshed session tokens; the next start may need `pdfs login`" + ); } // Dropping the runtime waits for every blocking task, however long; a call // still stuck on Drive would hold the stop until systemd kills it (B165). @@ -3424,6 +3440,8 @@ fn cmd_diagnose() -> Result<()> { Ok(CtlResponse::Status { mountpoint, online, + session_expired, + tokens_unsaved, pending_uploads, pending_changes, .. @@ -3445,19 +3463,38 @@ fn cmd_diagnose() -> Result<()> { |kind| format!("{mountpoint} ({kind})"), ), ); - report.finding( - if online { - DiagnoseLevel::Ok - } else { - DiagnoseLevel::Warn - }, - " network", - if online { - "online" - } else { - "offline; cached data remains available" - }, - ); + // The keyring check above read the blob, which still holds the + // tokens the daemon could not replace. + if tokens_unsaved { + report.finding( + DiagnoseLevel::Warn, + " session tokens", + "the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`", + ); + } + // A refused session reads as offline too, but no connection will + // bring it back, and the keyring check above cannot tell. + if session_expired { + report.finding( + DiagnoseLevel::Fail, + " session", + "Proton refused it (expired or revoked) — run `pdfs login`", + ); + } else { + report.finding( + if online { + DiagnoseLevel::Ok + } else { + DiagnoseLevel::Warn + }, + " network", + if online { + "online" + } else { + "offline; cached data remains available" + }, + ); + } let pending = pending_uploads + pending_changes; report.finding( if pending == 0 { diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index a03b66e..796db3c 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -21,6 +21,9 @@ //! such separation to protect and are stored, as they always were. use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; @@ -31,6 +34,7 @@ use proton_sdk::api::{HumanVerification, HumanVerificationCredential}; use proton_sdk::cache::EncryptedCacheRepository; use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::error::ProtonError; +use proton_sdk::http::Tokens; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; use proton_sdk::telemetry::TracingTelemetry; use serde::{Deserialize, Serialize}; @@ -307,8 +311,8 @@ pub async fn login_verified( } }; - register_refresh_handler(&session); let tokens = session.current_tokens().await; + register_refresh_handler(&session, tokens.refresh_token.clone()); let stored = StoredSession { session_id: session.session_id().as_str().to_owned(), username: session.username().to_owned(), @@ -360,11 +364,11 @@ fn keep_passphrases( let outcome = if remember { stored.key_passphrases = Some(encode(passphrases)); // Best-effort: a system without a kernel keyring has nothing to clear. - let _ = kernelkey::clear(); + let _ = kernelkey::PASSPHRASES.clear(); Ok(()) } else { stored.key_passphrases = None; - kernelkey::store(&encode_secret(passphrases)).map_err(|e| { + kernelkey::PASSPHRASES.store(&encode_secret(passphrases)).map_err(|e| { Error::Other(format!( "the kernel keyring is unavailable, so the unlock cannot be kept for this session: {e}" )) @@ -465,7 +469,7 @@ fn find_passphrases(stored: &StoredSession) -> Result Ok(decode_secret(&secret).map(|p| (p, KeyState::Session))), Ok(None) => Ok(None), // No keyring on this system: nothing was ever kept there. @@ -491,17 +495,18 @@ pub async fn unlock(mailbox: &str, remember: bool) -> Result<()> { return Err(Error::ReloginRequired); } let mailbox = Zeroizing::new(mailbox.to_owned()); - let session = ProtonApiSession::resume(client_config(), stored.to_params())?; - register_refresh_handler(&session); + let session = resume_stored(&stored)?; let passphrases = derive_verified(&session, mailbox.as_bytes(), stored.key_salts.clone()).await?; - keep_passphrases(stored, &passphrases, remember) + // Read again: verifying may have rotated the tokens, and the copy read + // above would put the spent refresh token back. + keep_passphrases(load()?, &passphrases, remember) } /// Drop the passphrases held in kernel memory, so the account is locked again /// until [`unlock`]. With `forget`, also those in the keyring blob. pub fn lock(forget: bool) -> Result<()> { - kernelkey::clear()?; + kernelkey::PASSPHRASES.clear()?; if forget { let mut stored = load()?; if stored.key_passphrases.take().is_some() { @@ -529,9 +534,10 @@ pub fn logout() -> Result<()> { let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); } } - if let Err(e) = kernelkey::clear() { + if let Err(e) = kernelkey::PASSPHRASES.clear() { tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed"); } + let _ = kernelkey::UNSAVED_TOKENS.clear(); match keyring_entry()?.delete_credential() { Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), Err(e) => Err(e.into()), @@ -551,8 +557,7 @@ pub fn logout() -> Result<()> { /// a daemon starting offline can still mount from its cache. pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { let stored = load()?; - let session = ProtonApiSession::resume(client_config(), stored.to_params())?; - register_refresh_handler(&session); + let session = resume_stored(&stored)?; let passphrases = match find_passphrases(&stored)? { Some((passphrases, _)) => passphrases, @@ -567,6 +572,48 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { Ok((client, session)) } +/// Resume `stored`, with the rotation the keyring refused last time put in +/// first, if there is one: Proton has spent the stored refresh token then, +/// and the kernel keyring holds the one that replaced it (B61). +fn resume_stored(stored: &StoredSession) -> Result { + let unsaved = recover_unsaved(stored); + let mut resumed = stored.clone(); + if let Some(tokens) = &unsaved { + tracing::info!("resuming with session tokens the keyring refused last time"); + resumed.access_token = tokens.access_token.clone(); + resumed.refresh_token = tokens.refresh_token.clone(); + } + let session = ProtonApiSession::resume(client_config(), resumed.to_params())?; + let sink = register_refresh_handler(&session, stored.refresh_token.clone()); + if let Some(tokens) = unsaved { + let _ = sink.rotated(tokens); + } + Ok(session) +} + +/// The rotation the kernel keyring holds for `stored`, if it belongs there. +/// One that does not is from an older login, or was written since, and is +/// dropped. +fn recover_unsaved(stored: &StoredSession) -> Option { + let kept = match kernelkey::UNSAVED_TOKENS.load() { + Ok(kept) => kept?, + Err(e) => { + tracing::debug!(error = %e, "kernel keyring unavailable; no unsaved tokens to recover"); + return None; + } + }; + match serde_json::from_slice::(&kept) { + Ok(unsaved) if unsaved.belongs_in(stored) => Some(Tokens { + access_token: unsaved.access_token, + refresh_token: unsaved.refresh_token, + }), + _ => { + let _ = kernelkey::UNSAVED_TOKENS.clear(); + None + } + } +} + /// Turn a blob written by 3.0.x — which holds the mailbox password — into one /// that holds passphrases, so the password stops being stored. /// @@ -597,11 +644,14 @@ async fn migrate_legacy( stored.key_salts.clone() }; - let mut migrated = stored.clone(); + let verified = derive_verified(session, legacy.as_bytes(), key_salts.clone()).await; + // Read again: the calls above may have rotated the tokens, and `stored` + // would put the spent refresh token back. + let mut migrated = load()?; migrated.mailbox_password = String::new(); - migrated.key_salts = key_salts.clone(); + migrated.key_salts = key_salts; - match derive_verified(session, legacy.as_bytes(), key_salts).await { + match verified { Ok(passphrases) => { migrated.key_passphrases = Some(encode(&passphrases)); write_stored(&migrated)?; @@ -659,39 +709,309 @@ fn tune(client: ProtonDriveClient, passphrases: &KeyPassphrases) -> ProtonDriveC } } -/// Write the session's current tokens back to the keyring. +/// Write any rotation of the session's tokens that has not reached the keyring +/// yet. A best-effort last chance before the process exits. /// -/// Everything else in the stored blob — salts, passphrases — is preserved, so -/// this never undoes an [`unlock`] or [`lock`] made since the session started. -/// Call whenever the session may have rotated its tokens so a later -/// [`resume_client`] presents a live refresh token. -pub async fn persist(session: &ProtonApiSession) -> Result<()> { - let tokens = session.current_tokens().await; - store_tokens(tokens.access_token, tokens.refresh_token) +/// Rotations are written as they happen (see [`register_refresh_handler`]), so +/// this usually has nothing to do. It never writes the session's current +/// tokens for their own sake: the blob may by now belong to a newer login, or +/// hold a newer rotation, and writing them would put a spent refresh token +/// over a live one. +pub fn persist(session: &ProtonApiSession) -> Result<()> { + let sink = TOKEN_SINKS + .lock() + .get(session.session_id().as_str()) + .cloned(); + match sink { + Some(sink) => sink.flush(), + None => Ok(()), + } } -fn store_tokens(access_token: String, refresh_token: String) -> Result<()> { - let mut stored = load()?; - stored.access_token = access_token; - stored.refresh_token = refresh_token; - write_stored(&stored) +/// Whether the keyring holds a session newer than any this process runs on: +/// another login, or one of this process's sessions rotated by another +/// process (`pdfs unlock` resumes the same login and may refresh it). +/// +/// The refresh token this process holds is spent then, and Proton refuses it +/// as if the session were gone. A daemon asks this before it reports the +/// session expired, and resumes the stored one instead. +pub fn stored_session_is_newer() -> bool { + // Nothing to compare with, and no keyring to wake up for it. + if TOKEN_SINKS.lock().is_empty() { + return false; + } + let Ok(stored) = load() else { + return false; + }; + let sinks = TOKEN_SINKS.lock(); + match sinks.get(&stored.session_id) { + Some(sink) => sink.state.lock().stored_refresh != stored.refresh_token, + None => !sinks.is_empty(), + } +} + +/// Whether a session in this process holds refreshed tokens the keyring has +/// not taken yet. They are retried and kept in the kernel keyring meanwhile, +/// which a reboot clears, so the user is asked to unlock the keyring (B61). +pub fn tokens_unsaved() -> bool { + TOKEN_SINKS + .lock() + .values() + .any(|sink| sink.state.lock().unsaved.is_some()) +} + +/// Where each live session in this process writes its rotated tokens, by +/// session id, for [`persist`] to find. +static TOKEN_SINKS: parking_lot::Mutex>> = + parking_lot::Mutex::new(BTreeMap::new()); + +/// One session's rotated tokens on their way to the keyring. +/// +/// Proton refresh tokens are single-use, so a rotation has to reach the +/// keyring, and it must reach only the blob it belongs to. That is the blob of +/// this session id that still holds the refresh token this session last knew +/// it to hold. Anything else means the blob moved on without this session: a +/// new login, or another session on the same login (`pdfs unlock`) that +/// rotated first. Writing into it would replace a live refresh token with a +/// spent one, and the next start fails with `InvalidRefreshToken` (issue #35). +struct TokenSink { + session_id: String, + state: parking_lot::Mutex, + /// Whether a thread is retrying a write the keyring refused. + retrying: AtomicBool, +} + +/// How long to wait before each new try at a write the keyring refused; the +/// last delay repeats until it succeeds. A locked or restarting keyring +/// usually comes back within seconds, and one that does not must not be asked +/// in a tight loop. +const KEYRING_RETRY: [Duration; 4] = [ + Duration::from_secs(1), + Duration::from_secs(5), + Duration::from_secs(30), + Duration::from_secs(300), +]; + +struct SinkState { + /// The refresh token the blob holds for this session, as far as this + /// session knows: the one it started from, or the last one it wrote. + stored_refresh: String, + /// The latest rotation, until the keyring has taken it. + unsaved: Option, + /// Whether `unsaved` is also in [`kernelkey::UNSAVED_TOKENS`]. + kept: bool, +} + +/// A rotation the keyring refused, as kept in the kernel keyring until it is +/// written: the tokens, and the blob they go into. +#[derive(Serialize, Deserialize)] +struct UnsavedRotation { + session_id: String, + /// The refresh token this rotation spent, which the blob still holds. + spent: String, + access_token: String, + refresh_token: String, +} + +impl UnsavedRotation { + /// Whether `stored` is the blob this rotation was refused for, still + /// holding the refresh token it spent. + fn belongs_in(&self, stored: &StoredSession) -> bool { + self.session_id == stored.session_id && self.spent == stored.refresh_token + } +} + +/// What became of a rotation offered to a stored blob. +#[derive(Debug, PartialEq, Eq)] +enum Rotation { + /// The blob is this session's and took the tokens. + Taken, + /// The blob moved on without this session; it is left alone. + MovedOn, +} + +/// Put `tokens` into `stored` if it is still this session's blob: the same +/// session id, holding the refresh token this session last left in it, or +/// already holding this very rotation (another process wrote it first). +fn take_rotation( + stored: &mut StoredSession, + session_id: &str, + stored_refresh: &str, + tokens: &Tokens, +) -> Rotation { + if stored.session_id != session_id + || (stored.refresh_token != stored_refresh && stored.refresh_token != tokens.refresh_token) + { + return Rotation::MovedOn; + } + stored.access_token = tokens.access_token.clone(); + stored.refresh_token = tokens.refresh_token.clone(); + Rotation::Taken +} + +impl TokenSink { + fn new(session_id: &str, stored_refresh: String) -> Self { + Self { + session_id: session_id.to_owned(), + state: parking_lot::Mutex::new(SinkState { + stored_refresh, + unsaved: None, + kept: false, + }), + retrying: AtomicBool::new(false), + } + } + + /// Take a new rotation and try to write it. If the keyring refuses, keep + /// trying in the background: until then the only live refresh token is in + /// memory, and a stop that cannot write it either loses the session + /// (`docs/BUGS.md` B61). + fn rotated(self: &Arc, tokens: Tokens) -> Result<()> { + self.state.lock().unsaved = Some(tokens); + let written = self.flush(); + if let Err(e) = &written { + tracing::error!( + error = %e, + "the keyring refused the refreshed session tokens; retrying" + ); + self.keep_in_kernel(); + self.retry_in_background(); + } + written + } + + /// Keep the unsaved rotation in the kernel keyring too, so a process that + /// dies before the retry lands does not take the only live refresh token + /// with it. The next resume puts it back (see [`resume_stored`]). + fn keep_in_kernel(&self) { + let mut state = self.state.lock(); + let Some(tokens) = &state.unsaved else { + return; + }; + let unsaved = UnsavedRotation { + session_id: self.session_id.clone(), + spent: state.stored_refresh.clone(), + access_token: tokens.access_token.clone(), + refresh_token: tokens.refresh_token.clone(), + }; + let Ok(json) = serde_json::to_vec(&unsaved).map(Zeroizing::new) else { + return; + }; + match kernelkey::UNSAVED_TOKENS.store(&json) { + Ok(()) => state.kept = true, + Err(e) => tracing::error!( + error = %e, + "the kernel keyring refused them too; if this process stops first, \ + the next start needs `pdfs login`" + ), + } + } + + /// Retry the unsaved rotation on [`KEYRING_RETRY`] until it is written or + /// has nowhere left to go. One thread per sink; a newer rotation simply + /// replaces what it is retrying. + fn retry_in_background(self: &Arc) { + if self.retrying.swap(true, Ordering::SeqCst) { + return; + } + let sink = Arc::clone(self); + let spawned = std::thread::Builder::new() + .name("pdfs-keyring-retry".into()) + .spawn(move || { + loop { + let delays = KEYRING_RETRY + .into_iter() + .chain(std::iter::repeat(KEYRING_RETRY[KEYRING_RETRY.len() - 1])); + for delay in delays { + std::thread::sleep(delay); + match sink.flush() { + Ok(()) => break, + Err(e) => tracing::debug!(error = %e, ?delay, "keyring still refuses"), + } + } + sink.retrying.store(false, Ordering::SeqCst); + // A rotation refused after the last try succeeded found + // this thread still running, and started none of its own. + if sink.state.lock().unsaved.is_none() + || sink.retrying.swap(true, Ordering::SeqCst) + { + return; + } + } + }); + if let Err(e) = spawned { + self.retrying.store(false, Ordering::SeqCst); + tracing::error!(error = %e, "could not start retrying the keyring write"); + } + } + + /// Write the unsaved rotation, if there is one. A blob that moved on, or + /// is gone with a logout, drops it: there is nothing it may still go into. + fn flush(&self) -> Result<()> { + let mut state = self.state.lock(); + let Some(tokens) = state.unsaved.clone() else { + return Ok(()); + }; + let mut stored = match load() { + Ok(stored) => stored, + Err(Error::NotLoggedIn) => { + state.settle(); + return Ok(()); + } + Err(e) => return Err(e), + }; + match take_rotation( + &mut stored, + &self.session_id, + &state.stored_refresh, + &tokens, + ) { + Rotation::Taken => { + write_stored(&stored)?; + state.stored_refresh = tokens.refresh_token; + tracing::info!("successfully auto-persisted refreshed tokens in keyring"); + } + Rotation::MovedOn => { + tracing::info!( + "the stored session has moved on; not writing this session's tokens over it" + ); + } + } + state.settle(); + Ok(()) + } +} + +impl SinkState { + /// The unsaved rotation has gone where it can: drop it, and its copy in + /// the kernel keyring. + fn settle(&mut self) { + self.unsaved = None; + if std::mem::take(&mut self.kept) { + let _ = kernelkey::UNSAVED_TOKENS.clear(); + } + } } /// Persist rotated tokens the moment the session obtains them. /// -/// The handler re-reads the stored blob instead of capturing one, so it cannot -/// write back a stale copy over a later unlock or lock — and cannot resurrect a -/// session that was logged out while the daemon still ran. -fn register_refresh_handler(session: &ProtonApiSession) { +/// `stored_refresh` is the refresh token the keyring holds for this session +/// now. The handler re-reads the stored blob instead of capturing one, so it +/// cannot write back a stale copy over a later unlock or lock, and it writes +/// only into a blob that is still this session's (see [`TokenSink`]), so it +/// cannot resurrect a logged-out session or overwrite a newer login. +fn register_refresh_handler(session: &ProtonApiSession, stored_refresh: String) -> Arc { + let session_id = session.session_id().as_str(); + let sink = Arc::new(TokenSink::new(session_id, stored_refresh)); + TOKEN_SINKS + .lock() + .insert(session_id.to_owned(), Arc::clone(&sink)); + let handler = Arc::clone(&sink); session.http().set_on_tokens_refreshed(move |tokens| { - match store_tokens(tokens.access_token, tokens.refresh_token) { - Ok(()) => tracing::info!("successfully auto-persisted refreshed tokens in keyring"), - Err(Error::NotLoggedIn) => {} - Err(e) => { - tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring") - } - } + // A refusal is logged and retried by the sink itself. + let _ = handler.rotated(tokens); }); + sink } #[cfg(test)] @@ -786,4 +1106,168 @@ mod tests { assert_eq!(origin, KeyState::Stored); assert_eq!(found, passphrases()); } + + fn tokens(access: &str, refresh: &str) -> Tokens { + Tokens { + access_token: access.into(), + refresh_token: refresh.into(), + } + } + + #[test] + fn own_session_tokens_are_written() { + let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap(); + assert_eq!( + take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")), + Rotation::Taken + ); + assert_eq!(stored.access_token, "a2"); + assert_eq!(stored.refresh_token, "r2"); + } + + /// Issue #35: `pdfs login` stores a new session and restarts the daemon, + /// and the old daemon's stop wrote its spent tokens into the new blob. + #[test] + fn tokens_of_another_session_are_not_written() { + let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap(); + assert_eq!( + take_rotation(&mut stored, "old", "r", &tokens("a2", "r2")), + Rotation::MovedOn + ); + assert_eq!(stored.refresh_token, "r"); + } + + /// `pdfs unlock` resumes a second session on the same login. Once it has + /// rotated, the daemon's copy of the refresh token is spent. + #[test] + fn tokens_are_not_written_over_a_newer_rotation_of_the_same_session() { + let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap(); + assert_eq!( + take_rotation(&mut stored, "s", "spent", &tokens("a2", "r2")), + Rotation::MovedOn + ); + assert_eq!(stored.refresh_token, "r"); + } + + /// `pdfs unlock` wrote this very rotation first, from the same session. + #[test] + fn a_rotation_already_in_the_blob_is_taken() { + let mut stored = stored_with("r2"); + assert_eq!( + take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")), + Rotation::Taken + ); + } + + /// The keyring is one store per process, so the tests that use it, + /// through `keyring-core`'s in-memory store, take turns. + fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> { + static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + let turn = KEYRING.lock(); + keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap()); + turn + } + + fn stored_with(refresh: &str) -> StoredSession { + serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap() + } + + #[test] + fn a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login() { + let _turn = mock_keyring(); + write_stored(&stored_with("r")).unwrap(); + + let sink = Arc::new(TokenSink::new("s", "r".into())); + sink.rotated(tokens("a2", "r2")).unwrap(); + sink.rotated(tokens("a3", "r3")).unwrap(); + assert_eq!(load().unwrap().refresh_token, "r3"); + + // A new login replaces the blob; the old session's next rotation, and + // its stop, leave it alone. + let mut login = stored_with("fresh"); + login.session_id = "new".into(); + write_stored(&login).unwrap(); + sink.rotated(tokens("a4", "r4")).unwrap(); + sink.flush().unwrap(); + let now = load().unwrap(); + assert_eq!(now.session_id, "new"); + assert_eq!(now.refresh_token, "fresh"); + } + + /// `pdfs unlock` rotated the daemon's session behind its back: the + /// daemon's refresh token is spent, and the keyring holds the live one. + #[test] + fn a_session_rotated_by_another_process_is_newer_in_the_keyring() { + let _turn = mock_keyring(); + write_stored(&stored_with("r")).unwrap(); + TOKEN_SINKS + .lock() + .insert("s".into(), Arc::new(TokenSink::new("s", "r".into()))); + assert!(!stored_session_is_newer()); + + write_stored(&stored_with("r2")).unwrap(); + assert!(stored_session_is_newer()); + + let mut login = stored_with("fresh"); + login.session_id = "new".into(); + write_stored(&login).unwrap(); + assert!(stored_session_is_newer()); + TOKEN_SINKS.lock().remove("s"); + } + + /// B61: a rotation the keyring refused lived only in memory. + #[test] + fn a_rotation_the_keyring_refused_is_written_once_it_takes_it() { + let _turn = mock_keyring(); + write_stored(&stored_with("r")).unwrap(); + let sink = Arc::new(TokenSink::new("s", "r".into())); + + let entry = keyring_entry().unwrap(); + let cred = entry + .as_any() + .downcast_ref::() + .unwrap(); + cred.set_error(keyring_core::Error::NoStorageAccess( + std::io::Error::other("locked").into(), + )); + assert!(sink.rotated(tokens("a2", "r2")).is_err()); + // Until it is written, a stop does not lose it: the next resume finds + // it in the kernel keyring, where one is offered. + let kept = sink.state.lock().kept; + if kept { + assert_eq!( + recover_unsaved(&stored_with("r")).unwrap().refresh_token, + "r2" + ); + } + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while load().unwrap().refresh_token != "r2" { + assert!( + std::time::Instant::now() < deadline, + "the retry never wrote the rotation" + ); + std::thread::sleep(Duration::from_millis(50)); + } + assert!(sink.state.lock().unsaved.is_none()); + if kept { + assert!(kernelkey::UNSAVED_TOKENS.load().unwrap().is_none()); + } + } + + #[test] + fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() { + let unsaved = UnsavedRotation { + session_id: "s".into(), + spent: "r".into(), + access_token: "a2".into(), + refresh_token: "r2".into(), + }; + assert!(unsaved.belongs_in(&stored_with("r"))); + // Written since, by a retry or another process. + assert!(!unsaved.belongs_in(&stored_with("r2"))); + let mut login = stored_with("r"); + login.session_id = "new".into(); + assert!(!unsaved.belongs_in(&login)); + } } diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index ae0e043..f2eebd4 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -1959,6 +1959,16 @@ pub enum Response { /// resumed, or not paused. #[serde(default)] paused_until: Option, + /// True when Proton refused the session itself (refresh token refused, + /// account deleted or disabled). `online` is false then too, but no + /// connection brings it back: only signing in again does. + #[serde(default)] + session_expired: bool, + /// True while the keyring refuses the session's refreshed tokens. The + /// daemon keeps retrying; until it succeeds, a reboot signs the user + /// out. + #[serde(default)] + tokens_unsaved: bool, }, /// A human-readable success message. Ok { message: String }, diff --git a/crates/pdfs-core/src/db/ops.rs b/crates/pdfs-core/src/db/ops.rs index 25f5e85..9edabc3 100644 --- a/crates/pdfs-core/src/db/ops.rs +++ b/crates/pdfs-core/src/db/ops.rs @@ -252,6 +252,25 @@ const UID_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.li const PARENT_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)"; +/// The `WHERE` terms for a row a drain worker may claim once it falls due: not +/// claimed already, no other claimed op on the same node, and not under a +/// folder that has yet to land. +/// +/// One fragment for [`Db::claim_next_due_op`] and [`Db::earliest_due_at`]. A +/// row the claim skips but the wait counts is due "now" for as long as it is +/// held, and every idle worker spun on it (`docs/BUGS.md` B200). +fn claimable() -> String { + format!( + "claimed_at = 0 + AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~') + AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0) + AND (lid IS NULL OR lid NOT IN ( + SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))", + v = LOCAL_VOLUME, + n = LOCAL_VOLUME.len() + 1, + ) +} + /// The volume id given to a node that exists only on this machine, so far. A /// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~` and /// round-trips through the same `Display`/parse path as any other uid. @@ -1127,15 +1146,9 @@ impl Db { "SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at, attempts, last_error, next_attempt_at FROM pending_op - WHERE next_attempt_at <= ?1 - AND claimed_at = 0 - AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~') - AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0) - AND (lid IS NULL OR lid NOT IN ( - SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL)) + WHERE next_attempt_at <= ?1 AND {claimable} ORDER BY id LIMIT 1", - v = LOCAL_VOLUME, - n = LOCAL_VOLUME.len() + 1, + claimable = claimable(), ))?; stmt.query_row(params![now], |r| { Ok(PendingOp { @@ -1208,19 +1221,17 @@ impl Db { /// debounced or backed-off op becomes eligible rather than waiting the full /// idle-poll interval. /// - /// Claimed ops are excluded: another worker is already on them, so they are - /// not work this one is waiting for, and counting them would have an idle - /// worker spin on a row it cannot have. + /// Only rows [`claim_next_due_op`](Self::claim_next_due_op) could take + /// count. A claimed op, or one held behind a claimed op on the same node, + /// is not work this worker is waiting for, and counting it would have an + /// idle worker spin on a row it cannot have. pub fn earliest_due_at(&self) -> Result> { let conn = self.read(); let ts: Option = conn .query_row( &format!( - "SELECT MIN(next_attempt_at) FROM pending_op \ - WHERE claimed_at = 0 \ - AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')", - v = LOCAL_VOLUME, - n = LOCAL_VOLUME.len() + 1, + "SELECT MIN(next_attempt_at) FROM pending_op WHERE {claimable}", + claimable = claimable(), ), [], |r| r.get(0), diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 6991ba5..1131104 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -6547,6 +6547,36 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() { ); } +/// B200: a rename queued behind a long upload of the same node is due, but no +/// worker may claim it until the upload retires. Counted as due, it had every +/// idle worker wake at once, find nothing, and go round again. +#[test] +fn earliest_due_at_skips_an_op_held_behind_a_claimed_one() { + let db = Db::open_in_memory().unwrap(); + let root = uid("root").to_string(); + let mut revision = bulk_op(0, &root); + revision.parent_uid = None; + db.enqueue_op(&revision).unwrap(); + db.enqueue_op(&PendingOp { + kind: OP_RENAME.to_string(), + parent_uid: Some(root.clone()), + blob_path: None, + meta_json: None, + ..bulk_op(0, &root) + }) + .unwrap(); + + db.claim_next_due_op(10) + .unwrap() + .expect("the revision is due"); + assert!(db.claim_next_due_op(10).unwrap().is_none()); + assert_eq!( + db.earliest_due_at().unwrap(), + None, + "a row no worker can claim is not work an idle worker is waiting for" + ); +} + /// Trashing a photo from the gallery must not leave it behind on an album page: /// the row goes, its album membership goes with it, and nothing else moves. #[test] diff --git a/crates/pdfs-core/src/error.rs b/crates/pdfs-core/src/error.rs index 1d18adb..005c446 100644 --- a/crates/pdfs-core/src/error.rs +++ b/crates/pdfs-core/src/error.rs @@ -278,6 +278,23 @@ fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind { } } +/// Whether `e` says the session itself is gone: its refresh token was refused, +/// or the account was deleted or disabled. +/// +/// Nothing but a new login helps then, and no retry will ever succeed, so a +/// caller must not take it for a lost connection and wait for it to come back +/// (issue #35: a revoked session was shown as "offline" for good). +pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool { + use proton_sdk::api::ResponseCode as C; + match e { + proton_sdk::ProtonError::Api(api) => { + api.is_invalid_refresh_token() + || matches!(api.code, C::AccountDeleted | C::AccountDisabled) + } + _ => false, + } +} + /// The result every request-serving method in the daemon returns. pub type CoreResult = std::result::Result; @@ -309,6 +326,17 @@ mod tests { assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote); } + #[test] + fn only_a_refused_refresh_or_a_closed_account_revokes_the_session() { + assert!(session_revoked(&api(C::InvalidRefreshToken))); + assert!(session_revoked(&api(C::AccountDeleted))); + assert!(session_revoked(&api(C::AccountDisabled))); + // One file the account may not touch says nothing about the session. + assert!(!session_revoked(&api(C::Forbidden))); + assert!(!session_revoked(&api(C::Timeout))); + assert!(!session_revoked(&ProtonError::KeysLocked)); + } + /// The regression this whole pass exists to prevent: before it, every SDK /// failure was hardcoded to `Remote`, so a 403 asked the user to retry a /// request that could never succeed. diff --git a/crates/pdfs-core/src/kernelkey.rs b/crates/pdfs-core/src/kernelkey.rs index a090cb8..d0ea652 100644 --- a/crates/pdfs-core/src/kernelkey.rs +++ b/crates/pdfs-core/src/kernelkey.rs @@ -5,6 +5,8 @@ //! kept in kernel memory — not on disk, not in the Secret Service — and vanish //! when the user's last session ends or the machine reboots. Both the daemon and //! `pdfs unlock` reach the same entry because it hangs off the *user* keyring. +//! It is also where session tokens wait while the Secret Service refuses them +//! ([`UNSAVED_TOKENS`]), so they outlive the process that holds them. //! //! Kernel keys are not paged to swap, are invisible to other users, and are //! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a @@ -20,12 +22,34 @@ use std::io; use zeroize::Zeroizing; -/// Key description. Namespaced so it cannot collide with another program's key. +/// One secret in the user keyring, by its description. Descriptions are +/// namespaced so they cannot collide with another program's keys. +pub struct Key { + description: &'static [u8], +} + +/// The account's key passphrases (see the module docs). #[cfg(not(test))] -const DESCRIPTION: &[u8] = b"pdfs:key-passphrases\0"; +pub const PASSPHRASES: Key = Key { + description: b"pdfs:key-passphrases\0", +}; /// Tests must never touch a real unlock. #[cfg(test)] -const DESCRIPTION: &[u8] = b"pdfs:key-passphrases:test\0"; +pub const PASSPHRASES: Key = Key { + description: b"pdfs:key-passphrases:test\0", +}; + +/// Session tokens the Secret Service refused to store, until it takes them. +/// A refresh token held only in a process's memory dies with the process. +#[cfg(not(test))] +pub const UNSAVED_TOKENS: Key = Key { + description: b"pdfs:unsaved-tokens\0", +}; +#[cfg(test)] +pub const UNSAVED_TOKENS: Key = Key { + description: b"pdfs:unsaved-tokens:test\0", +}; + const KEY_TYPE: &[u8] = b"user\0"; const KEY_SPEC_USER_KEYRING: i32 = -4; @@ -49,7 +73,7 @@ fn last_error() -> io::Result { Err(io::Error::last_os_error()) } -fn find() -> io::Result> { +fn find(description: &[u8]) -> io::Result> { // SAFETY: both strings are NUL-terminated and outlive the call. let id = unsafe { libc::syscall( @@ -57,7 +81,7 @@ fn find() -> io::Result> { KEYCTL_SEARCH, KEY_SPEC_USER_KEYRING, KEY_TYPE.as_ptr(), - DESCRIPTION.as_ptr(), + description.as_ptr(), 0, ) }; @@ -83,100 +107,102 @@ pub fn forbid_dumps() { } } -/// Store `secret`, replacing any earlier one. -pub fn store(secret: &[u8]) -> io::Result<()> { - // SAFETY: strings are NUL-terminated; the payload pointer/length pair - // describes `secret`, which outlives the call. - let id = unsafe { - libc::syscall( - libc::SYS_add_key, - KEY_TYPE.as_ptr(), - DESCRIPTION.as_ptr(), - secret.as_ptr(), - secret.len(), - KEY_SPEC_USER_KEYRING, - ) - }; - if id < 0 { - return last_error(); - } - // SAFETY: plain integer arguments. - let rc = unsafe { - libc::syscall( - libc::SYS_keyctl, - KEYCTL_SETPERM, - id, - PERM_OWNER_AND_POSSESSOR, - ) - }; - if rc < 0 { - // A key whose permissions are unknown is not one to leave behind. - let err = io::Error::last_os_error(); - let _ = clear(); - return Err(err); - } - Ok(()) -} - -/// Read the stored secret, or `None` when there is none. -pub fn load() -> io::Result>>> { - let Some(id) = find()? else { - return Ok(None); - }; - // Size first, then read; retry if the key was replaced in between. - for _ in 0..3 { - // SAFETY: a null buffer asks the kernel only for the payload length. - let len = unsafe { +impl Key { + /// Store `secret`, replacing any earlier one. + pub fn store(&self, secret: &[u8]) -> io::Result<()> { + // SAFETY: strings are NUL-terminated; the payload pointer/length pair + // describes `secret`, which outlives the call. + let id = unsafe { libc::syscall( - libc::SYS_keyctl, - KEYCTL_READ, - id, - std::ptr::null_mut::(), - 0usize, + libc::SYS_add_key, + KEY_TYPE.as_ptr(), + self.description.as_ptr(), + secret.as_ptr(), + secret.len(), + KEY_SPEC_USER_KEYRING, ) }; - if len < 0 { + if id < 0 { + return last_error(); + } + // SAFETY: plain integer arguments. + let rc = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_SETPERM, + id, + PERM_OWNER_AND_POSSESSOR, + ) + }; + if rc < 0 { + // A key whose permissions are unknown is not one to leave behind. + let err = io::Error::last_os_error(); + let _ = self.clear(); + return Err(err); + } + Ok(()) + } + + /// Read the stored secret, or `None` when there is none. + pub fn load(&self) -> io::Result>>> { + let Some(id) = find(self.description)? else { + return Ok(None); + }; + // Size first, then read; retry if the key was replaced in between. + for _ in 0..3 { + // SAFETY: a null buffer asks the kernel only for the payload length. + let len = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_READ, + id, + std::ptr::null_mut::(), + 0usize, + ) + }; + if len < 0 { + return match io::Error::last_os_error().raw_os_error() { + Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None), + _ => last_error(), + }; + } + let mut buf = Zeroizing::new(vec![0u8; len as usize]); + // SAFETY: `buf` is `len` writable bytes. + let read = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_READ, + id, + buf.as_mut_ptr(), + buf.len(), + ) + }; + if read < 0 { + return last_error(); + } + if read as usize <= buf.len() { + buf.truncate(read as usize); + return Ok(Some(buf)); + } + } + Err(io::Error::other("the key changed while it was being read")) + } + + /// Remove the stored secret. Succeeds when there is none. + pub fn clear(&self) -> io::Result<()> { + let Some(id) = find(self.description)? else { + return Ok(()); + }; + // SAFETY: plain integer arguments. + let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) }; + if rc < 0 { return match io::Error::last_os_error().raw_os_error() { - Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None), + Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()), _ => last_error(), }; } - let mut buf = Zeroizing::new(vec![0u8; len as usize]); - // SAFETY: `buf` is `len` writable bytes. - let read = unsafe { - libc::syscall( - libc::SYS_keyctl, - KEYCTL_READ, - id, - buf.as_mut_ptr(), - buf.len(), - ) - }; - if read < 0 { - return last_error(); - } - if read as usize <= buf.len() { - buf.truncate(read as usize); - return Ok(Some(buf)); - } + Ok(()) } - Err(io::Error::other("the key changed while it was being read")) -} - -/// Remove the stored secret. Succeeds when there is none. -pub fn clear() -> io::Result<()> { - let Some(id) = find()? else { - return Ok(()); - }; - // SAFETY: plain integer arguments. - let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) }; - if rc < 0 { - return match io::Error::last_os_error().raw_os_error() { - Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()), - _ => last_error(), - }; - } - Ok(()) } #[cfg(test)] @@ -187,18 +213,21 @@ mod tests { // so one test covers the whole round trip and skips when it is unavailable. #[test] fn stored_secret_round_trips_and_clears() { - if store(b"probe").is_err() { + if PASSPHRASES.store(b"probe").is_err() { eprintln!("kernel keyring unavailable; skipping"); return; } - let saved = load().unwrap().expect("stored secret is readable"); + let saved = PASSPHRASES + .load() + .unwrap() + .expect("stored secret is readable"); assert_eq!(saved.as_slice(), b"probe"); - store(b"replaced").unwrap(); - assert_eq!(load().unwrap().unwrap().as_slice(), b"replaced"); + PASSPHRASES.store(b"replaced").unwrap(); + assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced"); - clear().unwrap(); - assert!(load().unwrap().is_none()); - clear().unwrap(); + PASSPHRASES.clear().unwrap(); + assert!(PASSPHRASES.load().unwrap().is_none()); + PASSPHRASES.clear().unwrap(); } } diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index 7f14d5d..64afdd2 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -2,6 +2,7 @@ use super::*; use crate::shutdown::Shutdown; +use pdfs_core::error::session_revoked; /// Drop `uid` from one mount's tree and tell that mount's kernel session the /// entry is gone. Shared by the trash and delete paths, which differ only in @@ -402,6 +403,12 @@ pub(super) async fn run_event_sync( } break Some(c.clone()); } + // Refused for good: retrying would be refused the same way + // for the life of the daemon. The next login restarts it. + Err(e) if session_revoked(&e) => { + core.mark_session_expired("seed event cursor", &e); + return; + } Err(e) => { warn!(error = %e, ?delay, "seed event cursor failed; retrying"); tokio::time::sleep(delay).await; @@ -502,6 +509,10 @@ pub(super) async fn run_photos_event_sync(core: Core) { debug!("no photos volume; photo event sync not started"); return; } + Err(error) if session_revoked(&error) => { + core.mark_session_expired("read the photos root", &error); + return; + } Err(error) => { warn!(%error, "reading the photos root failed; retrying"); tokio::time::sleep(ONLINE_PROBE_MAX).await; diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index f308493..86646fd 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -334,6 +334,8 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> budget: core.cache.budget(), pins, online: core.online.load(Ordering::Relaxed), + session_expired: core.session_expired(), + tokens_unsaved: pdfs_core::auth::tokens_unsaved(), pending_uploads: queued.uploads.max(0) as u64 + landing, pending_changes: queued.changes.max(0) as u64, } diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 57861ce..5cc7b35 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -347,17 +347,7 @@ impl Core { // its writer has to be noticed. self.sweep_parked_creates(); } - // Paused, nothing due changes that: sleep until a resume - // wakes the worker (or the idle poll notices a timed pause - // ran out), not until the next op falls due — that is - // "now" for everything already queued. - if paused { - self.wait_for_drain_work(); - continue; - } - // A debounced or backed-off op may be waiting: sleep only - // until it becomes due rather than the full idle-poll. - self.wait_for_drain_work_or_due(); + self.wait_for_drain_work_or_due(online && !paused); continue; } }; @@ -732,14 +722,16 @@ impl Core { /// Like [`wait_for_drain_work`](Self::wait_for_drain_work), but first /// checks whether a debounced or backed-off op is due before the idle-poll /// ceiling. A 2-second debounce must not sleep 30 seconds. - fn wait_for_drain_work_or_due(&self) { - let timeout = match self.db.earliest_due_at() { - Ok(Some(ts)) => { - let remaining = (ts - now_millis()).max(0) as u64; - Duration::from_millis(remaining).min(DRAIN_IDLE_POLL) - } - _ => DRAIN_IDLE_POLL, + /// + /// `claiming` is false while the worker claims nothing (offline or + /// paused); see [`idle_wait`]. + fn wait_for_drain_work_or_due(&self, claiming: bool) { + let earliest = if claiming { + self.db.earliest_due_at().ok().flatten() + } else { + None }; + let timeout = idle_wait(claiming, earliest, now_millis()); sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout); } @@ -2678,7 +2670,9 @@ fn proton_error<'e>(e: &'e (dyn std::error::Error + 'static)) -> Option<&'e Prot /// is, or `Some(None)` for a failure that is not a refusal. fn refusal(e: &(dyn std::error::Error + 'static)) -> Option> { let proton = proton_error(e)?; - if is_network_error(proton) { + // A refused session refuses everything, so it says nothing about this + // op either; the mount is offline for it (`Core::lost_link`). + if is_network_error(proton) || pdfs_core::error::session_revoked(proton) { return None; } let ProtonError::Api(api) = proton else { @@ -2833,6 +2827,23 @@ fn sleep_for_drain_work( *woken = false; } +/// How long an idle drain worker sleeps: until the earliest queued op falls +/// due, at most the idle poll. +/// +/// A worker that claims nothing — offline or paused — sleeps the full idle +/// poll whatever is due. Every op already queued is due "now" for it, so +/// sleeping until then was a zero wait, and all the workers spun on the queue +/// for as long as the outage lasted (`docs/BUGS.md` B200). A reconnect or a +/// resume wakes the drain, so nothing waits the full poll for it. +fn idle_wait(claiming: bool, earliest_due: Option, now: i64) -> Duration { + match earliest_due { + Some(ts) if claiming => { + Duration::from_millis((ts - now).max(0) as u64).min(DRAIN_IDLE_POLL) + } + _ => DRAIN_IDLE_POLL, + } +} + fn placed_in(st: &crate::state::State, uid: &NodeUid) -> Option<(String, Option)> { let entry = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))?; Some((entry.node.name.clone(), entry.node.parent_uid.clone())) @@ -2858,6 +2869,23 @@ mod tests { assert!(started.elapsed() < Duration::from_secs(1)); } + #[test] + fn an_offline_worker_waits_the_idle_poll_for_a_past_due_op() { + // The op fell due long ago, and offline nobody attempts it, so it + // stays due: waiting for it was a zero wait, forever (B200). + assert_eq!(idle_wait(false, Some(0), 10_000), DRAIN_IDLE_POLL); + } + + #[test] + fn a_claiming_worker_waits_only_until_the_next_op_falls_due() { + assert_eq!( + idle_wait(true, Some(12_000), 10_000), + Duration::from_secs(2) + ); + assert_eq!(idle_wait(true, Some(0), 10_000), Duration::ZERO); + assert_eq!(idle_wait(true, None, 10_000), DRAIN_IDLE_POLL); + } + fn pending(kind: &str) -> PendingOp { PendingOp { id: 0, diff --git a/crates/pdfs-fuse/src/events.rs b/crates/pdfs-fuse/src/events.rs index 1e6db68..90c7462 100644 --- a/crates/pdfs-fuse/src/events.rs +++ b/crates/pdfs-fuse/src/events.rs @@ -235,6 +235,8 @@ fn status_keys(status: &CtlResponse) -> (String, String) { used, budget, online, + session_expired, + tokens_unsaved, pending_uploads, pending_changes, .. @@ -247,7 +249,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) { ); let key = format!( "{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\ - {used}/{budget}/{online}" + {used}/{budget}/{online}/{session_expired}/{tokens_unsaved}" ); (key, queue) } @@ -325,6 +327,8 @@ mod tests { budget: 0, pins: Vec::new(), online: true, + session_expired: false, + tokens_unsaved: false, pending_uploads: 1, pending_changes: 0, }; diff --git a/crates/pdfs-fuse/src/link.rs b/crates/pdfs-fuse/src/link.rs index 124fc27..e2c4df0 100644 --- a/crates/pdfs-fuse/src/link.rs +++ b/crates/pdfs-fuse/src/link.rs @@ -9,10 +9,11 @@ //! queued path, and the probe thread here flips it back when Proton answers. use std::future::Future; -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use parking_lot::{Condvar, Mutex}; +use pdfs_core::error::session_revoked; use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError}; use tracing::{debug, info, warn}; @@ -54,6 +55,26 @@ pub(crate) struct Link { changed: Condvar, /// Folders listed from the DB alone while offline, to relist once back. pub(crate) stale_listings: Mutex>, + /// Proton refused the session itself ([`session_revoked`]). The mount is + /// offline then, but no probe brings it back: only a new login does, and + /// that restarts the daemon. Read by front-ends through `Response::Status`, + /// so they can ask the user to sign in again rather than say "offline". + pub(crate) session_expired: AtomicBool, + /// Proton refused the session, but only because another process rotated + /// it (`pdfs unlock`): the keyring holds the live tokens. The mount loop + /// ends the mount for the daemon to mount again from the keyring. + pub(crate) resume_stored: AtomicBool, +} + +impl Link { + /// The link of a mount that comes up with Proton refusing its session, or + /// not. + pub(crate) fn starting(session_expired: bool) -> Self { + Self { + session_expired: AtomicBool::new(session_expired), + ..Default::default() + } + } } /// Whether a failed call failed because Proton could not be reached, rather @@ -161,10 +182,20 @@ impl Core { self.rt.block_on(bounded_by(limit, call)) } + /// Whether Proton refused the session itself, so nothing reaches it until + /// the user signs in again. + pub(crate) fn session_expired(&self) -> bool { + self.link.session_expired.load(Ordering::Relaxed) + } + /// Report a failed remote call. Returns whether it failed for want of a - /// network, in which case the mount is now offline and the caller should - /// take its queued path instead of failing. + /// network, or of a session, in which case the mount is now offline and the + /// caller should take its queued path instead of failing. pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool { + if session_revoked(e) { + self.mark_session_expired(what, e); + return true; + } if !is_network_error(e) { return false; } @@ -172,6 +203,29 @@ impl Core { true } + /// Flip the mount offline for good: Proton refused the session, and only a + /// new login brings it back. Changes stay queued for the daemon that login + /// starts. Only the first caller logs it. + /// + /// Unless the keyring holds a newer session than the refused one: then + /// the mount ends, for the daemon to mount again on that one. + pub(crate) fn mark_session_expired(&self, what: &str, error: &dyn std::fmt::Display) { + if self.link.resume_stored.load(Ordering::Relaxed) { + return self.mark_offline(what, error); + } + if pdfs_core::auth::stored_session_is_newer() { + info!(during = what, %error, + "Proton refused a session another process has since refreshed; resuming it"); + self.link.resume_stored.store(true, Ordering::Relaxed); + return self.mark_offline(what, error); + } + if !self.link.session_expired.swap(true, Ordering::Relaxed) { + warn!(during = what, %error, + "Proton refused the session; run `pdfs login` to sign in again"); + } + self.mark_offline(what, error); + } + /// Flip the mount offline and wake the probe. Only the first caller of an /// outage logs it; the rest are the same outage. pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) { @@ -276,6 +330,9 @@ impl Core { /// Probe until Proton answers, then flip the mount online. Returns `false` /// if the daemon is stopping. + /// + /// A refused session is not probed: every probe would be refused the same + /// way, forever (issue #35). The thread waits for the stop instead. fn probe_until_online(&self) -> bool { let started = self.link.lost_at.lock().unwrap_or_else(Instant::now); let mut delay = if started.elapsed() < YOUNG_OUTAGE { @@ -287,6 +344,10 @@ impl Core { if !self.shutdown.sleep(delay) { return false; } + if self.session_expired() { + delay = ONLINE_PROBE_MAX; + continue; + } let probe = self.rt.block_on(async { match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await { Ok(result) => result, @@ -308,6 +369,7 @@ impl Core { self.mark_online(); return true; } + Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e), Err(e) => { debug!(error = %e, ?delay, "online probe failed; still offline"); delay = next_probe_delay(delay, started.elapsed()); diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index a5f163a..1aeccdc 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -12,6 +12,10 @@ pub enum MountOutcome { /// The kernel mount ended on its own (e.g. an external `fusermount -u`). /// The caller may want to remount. Unmounted, + /// Proton refused the session, and the keyring holds a newer one: another + /// process (`pdfs unlock`) refreshed it, or a new login replaced it. We + /// unmounted ourselves; the caller should mount again, which resumes it. + ResumeStored, } /// Whether `path` is a mountpoint whose FUSE connection is dead — the state a @@ -210,30 +214,60 @@ fn fetch_or_recall_root( drive: &dyn DriveApi, rt: &tokio::runtime::Handle, db: &Db, -) -> std::io::Result<(Node, bool)> { +) -> std::io::Result<(Node, Start)> { let err = match rt.block_on(drive.get_my_files_folder()) { Ok(root) => { if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) { warn!(error = %e, "persist root uid failed"); } - return Ok((root, true)); + return Ok((root, Start::Online)); } Err(e) => e, }; + // Still mounted from the cache, so cached files stay readable and changes + // queue for the daemon the next login starts. + let start = if pdfs_core::error::session_revoked(&err) { + Start::SessionExpired + } else { + Start::Offline + }; + if start == Start::SessionExpired && pdfs_core::auth::stored_session_is_newer() { + return Err(std::io::Error::other(format!( + "Proton refused a session another process has since refreshed ({err}); resuming it" + ))); + } let cached = db .state_str(ROOT_UID_KEY) .ok() .flatten() .and_then(|uid| db.node_by_uid(&uid).ok().flatten()); match cached { + Some(root) if start == Start::SessionExpired => { + warn!(error = %err, + "Proton refused the session; mounting from cache until `pdfs login`"); + Ok((root, start)) + } Some(root) => { warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)"); - Ok((root, false)) + Ok((root, start)) } + None if start == Start::SessionExpired => Err(std::io::Error::other(format!( + "Proton refused the session ({err}); run `pdfs login`" + ))), None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))), } } +/// How a mount comes up, from what fetching its root said. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Start { + Online, + /// Proton could not be reached: serve the cache and probe for the link. + Offline, + /// Proton refused the session: serve the cache until a new login. + SessionExpired, +} + /// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the /// caller, so `mount` itself never reads config or the environment. pub struct MountOptions { @@ -393,7 +427,8 @@ pub(crate) fn mount_with( // Start the uptime clock here rather than at the first `pdfs diagnostics`, // so the age it reports is the daemon's own. super::diagnostics::start_clock(); - let (root, online) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?; + let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?; + let online = start == Start::Online; let scope = root.tree_event_scope_id(); db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None) .map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?; @@ -429,7 +464,7 @@ pub(crate) fn mount_with( shared_generation: Arc::new(AtomicU64::new(0)), shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())), online: Arc::new(AtomicBool::new(online)), - link: Arc::new(Default::default()), + link: Arc::new(link::Link::starting(start == Start::SessionExpired)), sync_paused_until: Arc::new(AtomicI64::new(paused_until)), pending: Arc::new(Mutex::new(HashMap::new())), hidden: Arc::new(Mutex::new(HashSet::new())), @@ -719,6 +754,18 @@ pub(crate) fn mount_with( } break MountOutcome::Shutdown; } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + if core.link.resume_stored.load(Ordering::Relaxed) => + { + info!("unmounting to resume the stored session"); + core.mounts.lock().close(); + if let Err(e) = teardown_session(&core.session_live, || { + umount_session_unblocked(bg, main_conn) + }) { + warn!(error = %e, "umount_and_join failed"); + } + break MountOutcome::ResumeStored; + } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { if bg.guard.is_finished() { info!("mount ended externally"); diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 7f8f67e..f80ef8c 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -579,6 +579,7 @@ fn build_window(app: &adw::Application) { detail: locations_widgets.card_detail.clone(), pause: locations_widgets.pause.clone(), paused: Cell::new(false), + sign_in: locations_widgets.card_sign_in.clone(), }, queue: QueueState { group: locations_widgets.queue_group.clone(), @@ -1290,6 +1291,12 @@ fn install_launch_actions(ui: &Rc, app: &adw::Application, window: &adw::App if ui_page.session.borrow().is_none() { return; } + // Signed in, but Proton refused the session (the tray's "Sign In + // Again…"): the login page, with the rest of the app still reachable. + if page == "login" { + show_login(&ui_page); + return; + } // Activity lives on as the Sync page's History view. if page == "activity" { ui_page.locations.views.set_visible_child_name("history"); diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index 7f2b3f9..68e9967 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -46,6 +46,8 @@ pub(crate) struct SyncCard { pub(crate) pause: adw::SplitButton, /// Whether the last status said paused, so the button knows which way to go. pub(crate) paused: Cell, + /// Shown only while Proton refuses the session; opens the sign-in page. + pub(crate) sign_in: gtk4::Button, } /// What a folder row's controls are built from. The subtitle and progress bar @@ -108,6 +110,7 @@ pub(crate) struct LocationsWidgets { pub(crate) card_icon: gtk4::Image, pub(crate) card_title: gtk4::Label, pub(crate) card_detail: gtk4::Label, + pub(crate) card_sign_in: gtk4::Button, pub(crate) pause: adw::SplitButton, pub(crate) queue_group: adw::PreferencesGroup, pub(crate) retry_all: gtk4::Button, @@ -134,7 +137,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc .build(); add_folder.add_css_class("flat"); let transfers_group = build_transfers_group(); - let (card, card_icon, card_title, card_detail) = build_sync_card(); + let (card, card_icon, card_title, card_detail, card_sign_in) = build_sync_card(); let pause = build_pause_button(); let (queue_group, retry_all) = build_queue_group(); let conflicts_group = adw::PreferencesGroup::builder() @@ -253,6 +256,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc card_icon, card_title, card_detail, + card_sign_in, pause, queue_group, retry_all, @@ -290,6 +294,11 @@ pub(crate) fn wire_locations(ui: &Rc, retry: >k4::Button, add_folder: >k let ui_add = ui.clone(); add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add)); wire_pause(ui); + let ui_sign_in = ui.clone(); + ui.locations + .card + .sign_in + .connect_clicked(move |_| show_login(&ui_sign_in)); let ui_retry = ui.clone(); ui.locations .queue @@ -379,9 +388,15 @@ pub(crate) fn paint_sync_banner(ui: &Rc) { state.banner.set_revealed(away && title.is_some()); } -/// The status hero: a large state icon, the state in words, and what it -/// means. -fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) { +/// The status hero: a large state icon, the state in words, what it means, +/// and the way out when there is one only the user can take. +fn build_sync_card() -> ( + gtk4::Box, + gtk4::Image, + gtk4::Label, + gtk4::Label, + gtk4::Button, +) { let icon = gtk4::Image::builder() .icon_name("pdfs-sync-symbolic") .pixel_size(48) @@ -408,9 +423,17 @@ fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) { let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18); card.add_css_class("card"); card.add_css_class("sync-card"); + let sign_in = gtk4::Button::builder() + .label(gettext("Sign In Again")) + .valign(gtk4::Align::Center) + .visible(false) + .build(); + sign_in.add_css_class("pill"); + sign_in.add_css_class("suggested-action"); card.append(&icon); card.append(&text); - (card, icon, title, detail) + card.append(&sign_in); + (card, icon, title, detail, sign_in) } /// Pause/Resume, with timed pauses in its menu. diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index fab7142..19e674e 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -836,6 +836,13 @@ pub(crate) fn wire_retry(ui: &Rc) { .connect_clicked(move |_| restart_service_then(&ui_trash, load_trash)); } +/// Show the sign-in page while a session is still stored, because Proton has +/// refused it. The sidebar stays, so the user can go back to cached files; a +/// login moves on to the files as from any sign-in ([`refresh`]). +pub(crate) fn show_login(ui: &Rc) { + ui.stack.set_visible_child_name("login"); +} + /// Repaint the window from the cached login identity, then ask the daemon for /// its status and transfers. Runs at startup, after a login and after a service /// restart; the daemon's event feed keeps both current in between. The identity @@ -1221,6 +1228,8 @@ pub(crate) fn paint_status(ui: &Rc, status: Response) { failing_error, paused, paused_until, + session_expired, + tokens_unsaved, .. } = status else { @@ -1231,7 +1240,11 @@ pub(crate) fn paint_status(ui: &Rc, status: Response) { // in it: it is why a file that looks saved is not on the remote // yet, and offline is usually the reason it is still queued. let queued = i18n::pending_summary(pending_uploads, pending_changes); - let state = if paused { + let state = if session_expired { + SyncState::SessionExpired { queued } + } else if tokens_unsaved { + SyncState::TokensUnsaved + } else if paused { SyncState::Paused { until: paused_until, queued, @@ -1294,6 +1307,14 @@ pub(crate) fn paint_disconnected(ui: &Rc) { /// What the sidebar's status strip reports, most urgent first. pub(crate) enum SyncState { + /// Proton refused the session: nothing reaches it until the user signs in + /// again. Outranks a pause, which it makes moot. + SessionExpired { + queued: Option, + }, + /// The keyring refuses the session's refreshed tokens. Sync goes on, but + /// a reboot before the keyring takes them signs the user out. + TokensUnsaved, /// The user paused syncing; nothing goes up until it resumes. Outranks /// everything else, because it is the reason for everything else. Paused { @@ -1328,7 +1349,29 @@ pub(crate) enum SyncState { fn paint_sync_status(ui: &Rc, state: SyncState) { let paused = matches!(state, SyncState::Paused { .. }); let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding); + let expired = matches!(state, SyncState::SessionExpired { .. }); let (icon, class, title, detail) = match state { + SyncState::SessionExpired { queued } => ( + "dialog-password-symbolic", + Some("error"), + gettext("Session expired"), + Some(match queued { + // Translators: {queued} says what is waiting, such as "3 uploads + // queued". + Some(queued) => { + gettext_f("{queued} · sign in again to sync", &[("queued", &queued)]) + } + None => gettext("Sign in again to sync"), + }), + ), + SyncState::TokensUnsaved => ( + "dialog-warning-symbolic", + Some("warning"), + gettext("Sign-in not saved"), + Some(gettext( + "Unlock your keyring, or the next reboot signs you out", + )), + ), SyncState::Paused { until, queued } => ( "media-playback-pause-symbolic", Some("warning"), @@ -1399,6 +1442,7 @@ fn paint_sync_status(ui: &Rc, state: SyncState) { if let Some(class) = class { image.add_css_class(class); } + ui.locations.card.sign_in.set_visible(expired); ui.status.status_title.set_label(&title); ui.status.status_detail.set_visible(detail.is_some()); ui.status diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index 7e6cd8b..fd4b53a 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -45,6 +45,8 @@ enum Phase { Offline, /// Changes keep failing; the user has to look. Attention, + /// Mounted, but Proton refused the session: only signing in again helps. + SessionExpired, /// Signed in, but no mount daemon is running. Disconnected, SignedOut, @@ -60,6 +62,7 @@ impl Phase { Phase::Paused => "media-playback-pause-symbolic", Phase::Offline => "network-offline-symbolic", Phase::Attention => "dialog-warning-symbolic", + Phase::SessionExpired => "dialog-password-symbolic", Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic", } } @@ -234,6 +237,8 @@ fn status_state( pending_changes, failing_ops, paused, + session_expired, + tokens_unsaved, .. } = status else { @@ -241,14 +246,25 @@ fn status_state( }; let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused); let queued = i18n::pending_summary(*pending_uploads, *pending_changes); - let phase = phase_of( - paused, - failing_ops, - online, - queued.is_some() || busy(items, jobs), - ); + let phase = if *session_expired { + Phase::SessionExpired + } else if *tokens_unsaved { + Phase::Attention + } else { + phase_of( + paused, + failing_ops, + online, + queued.is_some() || busy(items, jobs), + ) + }; DriveState { line: match (online, queued) { + // Outranks everything: it is why nothing else moves, and the one + // thing that fixes it is the user's to do. + _ if *session_expired => gettext("Session expired — sign in again"), + // Sync goes on meanwhile, but a reboot ends it until the next login. + _ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"), _ if paused => gettext("Sync paused"), _ if failing_ops > 0 => ngettext_f( "{n} change needs attention", @@ -365,7 +381,7 @@ impl Tray for DriveTray { fn status(&self) -> Status { match self.state.phase { - Phase::Attention => Status::NeedsAttention, + Phase::Attention | Phase::SessionExpired => Status::NeedsAttention, _ => Status::Active, } } @@ -423,6 +439,9 @@ impl Tray for DriveTray { // Nothing to mount without a session; the app's sign-in page is the // way in. Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))), + Phase::SessionExpired => items.push(item(&gettext("Sign In Again…"), |_| { + open_manager(&["--page", "login"]) + })), _ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))), } @@ -606,6 +625,47 @@ mod tests { assert_eq!(phase_of(false, 0, true, false), Phase::Synced); } + fn status(extra: &str) -> Response { + serde_json::from_str(&format!( + r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0, + "pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true{extra}}}}}"# + )) + .unwrap() + } + + /// Issue #35: a refused session read as "Offline — 3 uploads queued" for + /// good, and nothing said that signing in again was the way out. + #[test] + fn a_refused_session_outranks_everything_else() { + let state = status_state( + &status(r#","session_expired":true"#), + &[], + &[], + Path::new("/m"), + ); + assert_eq!(state.phase, Phase::SessionExpired); + assert_eq!(state.line, "Session expired — sign in again"); + } + + /// B61: the keyring refusing the refreshed tokens was only in the log. + #[test] + fn a_sign_in_the_keyring_refused_needs_attention() { + let state = status_state( + &status(r#","tokens_unsaved":true"#), + &[], + &[], + Path::new("/m"), + ); + assert_eq!(state.phase, Phase::Attention); + assert_eq!(state.line, "Sign-in not saved — unlock your keyring"); + } + + #[test] + fn a_daemon_that_does_not_say_has_a_live_session() { + let state = status_state(&status(""), &[], &[], Path::new("/m")); + assert_eq!(state.phase, Phase::Paused); + } + fn job(title: &str, background: bool) -> JobItem { JobItem { title: title.to_string(), diff --git a/crates/pdfs-gui/tests/icon_names.rs b/crates/pdfs-gui/tests/icon_names.rs index eb16703..5e46db8 100644 --- a/crates/pdfs-gui/tests/icon_names.rs +++ b/crates/pdfs-gui/tests/icon_names.rs @@ -28,6 +28,7 @@ const STOCK: &[&str] = &[ "checkbox-symbolic", "computer-symbolic", "dialog-error-symbolic", + "dialog-password-symbolic", "dialog-warning-symbolic", "document-edit-symbolic", "document-open-recent-symbolic", diff --git a/docs/BUGS.md b/docs/BUGS.md index 93cca81..10d0c81 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -73,7 +73,6 @@ original wording. | [B67](#b67--fresh-state-restore-can-silently-select-a-new-device-high-22) | Fresh-state restore can silently select a new device (HIGH-22) | An ambiguous device after a loss of local state is not asked about | | [B66](#b66--local-only-pending-data-is-not-protected-at-shutdown-high-21) | Local-only pending data is not protected at shutdown (HIGH-21) | No shutdown protection and no `pdfs sync flush` | | [B65](#b65--release-artifacts-lack-supply-chain-verification-med-11) | Release artifacts lack supply-chain verification (MED-11) | No SBOM, provenance or checksums | -| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) | Keyring write failures are only logged | | [B15](#b15--an-empty-but-listed-folder-and-duplicated-folder-uids) | An empty-but-`listed` folder, and duplicated folder uids | Cause not attributed | | [B10](#b10--glib-critical-when-the-launcher-closes-over-an-in-flight-open) | GLib critical when the launcher closes over an in-flight open | | @@ -101,6 +100,10 @@ a live account since. | Entry | Title | |---|---| +| [B203](#b203--pdfs-unlock-leaves-a-running-daemon-a-spent-refresh-token) | `pdfs unlock` leaves a running daemon a spent refresh token | +| [B202](#b202--a-refused-session-shows-as-offline-forever-and-nothing-says-to-sign-in-again) | A refused session shows as offline forever, and nothing says to sign in again | +| [B201](#b201--after-signing-in-again-the-old-daemon-puts-its-spent-tokens-back) | After signing in again, the old daemon puts its spent tokens back | +| [B200](#b200--offline-with-changes-queued-the-daemon-keeps-several-cores-busy) | Offline with changes queued, the daemon keeps several cores busy | | [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive | | [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored | | [B196](#b196--one-unreadable-folder-stops-the-whole-synced-folder-from-syncing) | One unreadable folder stops the whole synced folder from syncing | @@ -189,6 +192,7 @@ a live account since. | [B81](#b81--prompt-labels-every-drive-hit-my-files-including-device-folders) | Prompt labels every Drive hit "My files", including device folders | | [B80](#b80--29-of-the-account-is-absent-from-the-search-index) | 29% of the account is absent from the search index | | [B62](#b62--debian-artifact-omits-required-service-and-autostart-units-high-19) | Debian artifact omits required service and autostart units (HIGH-19) | +| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) | | [B60](#b60--config-writes-are-non-atomic-and-parse-errors-are-overwritten-high-17) | Config writes are non-atomic and parse errors are overwritten (HIGH-17) | | [B59](#b59--private-state-permission-enforcement-fails-open-high-16) | Private state permission enforcement fails open (HIGH-16) | | [B58](#b58--control-socket-has-unbounded-frames-and-connections-high-15) | Control socket has unbounded frames and connections (HIGH-15) | @@ -302,6 +306,130 @@ a live account since. Newest first. +### B203 — `pdfs unlock` leaves a running daemon a spent refresh token + +**Status:** Fixed, not verified live +**Found:** 2026-10-09, reading the code for issue #35 after the B201 fix +**Where:** `crates/pdfs-core/src/auth.rs`, `stored_session_is_newer`; `crates/pdfs-fuse/src/link.rs`, +`Core::mark_session_expired`; `crates/pdfs-fuse/src/mount.rs`; `crates/pdfs-cli/src/main.rs`, +`cmd_daemon` + +**Repro:** With the daemon running, run `pdfs unlock` while the stored access token has expired. +Unlock resumes the same session, refreshes it and stores the new tokens. The daemon still holds +the old refresh token. If it refreshes before unlock restarts it, Proton refuses the spent token, +and since B202 the mount reports the session expired, although the keyring holds a live one. + +**Cause.** Two processes ran the same session, and only one of them could hold its live refresh +token. B201 stopped the daemon from writing its spent token over the live one, but nothing made the +daemon pick up the live one. + +**Fix.** Before a refused session counts as expired, `auth::stored_session_is_newer` compares the +keyring with what this process last stored. When the keyring holds a newer rotation of the same +session, or another login, the mount ends with `MountOutcome::ResumeStored`, and the daemon mounts +again at once from the keyring. At startup the same check turns the refusal into a mount error, +which the daemon retries. A rotation that another process already wrote into the blob is now +taken as this session's, so the daemon keeps writing its later rotations. + +**Test:** `auth::tests::a_session_rotated_by_another_process_is_newer_in_the_keyring` and +`auth::tests::a_rotation_already_in_the_blob_is_taken`. + +--- + +### B202 — A refused session shows as offline forever, and nothing says to sign in again + +**Status:** Fixed, not verified live +**Found:** 2026-10-09, issue #35: the tray and `pdfs diagnose` said "offline, 3 queued", and +diagnose also said "keyring session: ok", while every call in the log failed with +`InvalidRefreshToken (http 400)`. `pdfs sync now` and restarting the service changed nothing. +**Where:** `crates/pdfs-fuse/src/link.rs`, `Core::lost_link` and `Core::probe_until_online`; +`crates/pdfs-fuse/src/mount.rs`, `fetch_or_recall_root`; `crates/pdfs-fuse/src/background.rs`; +`crates/pdfs-core/src/control.rs`, `Response::Status`; the tray, the app's status, `pdfs status` +and `pdfs diagnose` + +**Repro:** Make the stored refresh token invalid (B201 is one way), then start the daemon. It +mounts from the cache and says offline; the probe retries every 30 s, then every 5 minutes, and +logs the refusal only at debug. A daemon that started online and loses its session later does not +even go offline: its failures show up as EIO and drain backoff. + +**Cause.** Every failure to fetch the root at startup counted as offline, and nothing told a +refused session apart from a lost connection. No status field could carry it, so no front end +could say it. + +**Fix.** `pdfs_core::error::session_revoked` names the errors only a new login fixes: +`InvalidRefreshToken`, `AccountDeleted`, `AccountDisabled`. When the root fetch, the probe, the +drain, an event poll, the event seed or the photos root sees one, the mount goes offline with +`session_expired` set, logs one warning naming `pdfs login`, and stops probing and retrying. +Cached files stay readable and changes stay queued for the daemon the next login starts. +`Response::Status` carries `session_expired`. The tray shows "Session expired — sign in again" +with **Sign In Again…**, which opens the app's sign-in page; the app's status and Sync card say the +same, with a **Sign In Again** button. `pdfs status` and `pdfs diagnose` report it, the latter as +a FAIL. A refused session is not recorded as a sync issue of the op that ran into it. + +**Test:** `error::tests::only_a_refused_refresh_or_a_closed_account_revokes_the_session`, the +tray's `tests::a_refused_session_outranks_everything_else` and +`tests::a_daemon_that_does_not_say_has_a_live_session`. + +--- + +### B201 — After signing in again, the old daemon puts its spent tokens back + +**Status:** Fixed, not verified live +**Found:** 2026-10-09, reading the code for issue #35, where every call failed with +`InvalidRefreshToken` and signing in again did not obviously help. +**Where:** `crates/pdfs-core/src/auth.rs`, `persist`, `register_refresh_handler`, `unlock` and +`migrate_legacy`; `crates/pdfs-cli/src/main.rs`, `cmd_daemon` + +**Repro:** With the daemon running, run `pdfs login`. It stores the new session and restarts the +unit. The old daemon's stop runs `auth::persist`, which wrote its own access and refresh tokens +into the blob, now the new session's. The new daemon starts with a refresh token of another +session, and the first refresh fails with `InvalidRefreshToken`. `pdfs unlock` had the same +shape: its session could rotate the tokens, and the restarted daemon's stop put the spent refresh +token back. `unlock` and `migrate_legacy` could do it themselves, writing a copy of the blob read +before verifying the mailbox password, which may have rotated the tokens. + +**Cause.** Proton refresh tokens are single-use. `store_tokens` loaded whatever blob was stored +and replaced its tokens, without checking that the blob was still this session's. + +**Fix.** Each session writes its rotations through a `TokenSink`. It writes only into a blob with +its session id that still holds the refresh token the session last left there; a blob that moved +on, to a new login or to another session's rotation, is left alone. The stop no longer writes the +session's tokens, only a rotation the keyring refused when it happened. `unlock` and +`migrate_legacy` read the blob again after verifying. A failed final write is an error in the log, +not a debug line claiming there was nothing to save. + +**Test:** `auth::tests::tokens_of_another_session_are_not_written`, +`auth::tests::tokens_are_not_written_over_a_newer_rotation_of_the_same_session`, +`auth::tests::a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login`. + +--- + +### B200 — Offline with changes queued, the daemon keeps several cores busy + +**Status:** Fixed, not verified live +**Found:** 2026-10-09, issue #35: "offline, 3 queued", and systemd counted 17 minutes of CPU in +2 m 16 s of wall time, 10 hours over a longer run. +**Where:** `crates/pdfs-fuse/src/drain.rs`, `Core::run_pending_drain` and `idle_wait`; +`crates/pdfs-core/src/db/ops.rs`, `Db::earliest_due_at` + +**Repro:** Queue a write, then cut the link (`nmcli networking off`). `pidstat -t -p $(pgrep -x +pdfs) 5` shows the drain threads using most of several cores for as long as the outage lasts. + +**Cause.** Offline, a drain worker claims nothing, and then sleeps until the earliest queued op +falls due. Every queued op was due already, and with no attempts nothing moves its +`next_attempt_at`, so the sleep was zero. All 16 workers ran a query on every pass, the primary +one its idle chores as well. The same spin happened online when a due op sat behind a claimed op +on the same node, such as a rename queued behind a long upload: `earliest_due_at` counted it, +`claim_next_due_op` skipped it. + +**Fix.** A worker that claims nothing, offline or paused, sleeps the full idle poll; a reconnect +or a resume wakes it. `earliest_due_at` now uses the claim's own exclusions, one SQL fragment for +both. + +**Test:** `drain::tests::an_offline_worker_waits_the_idle_poll_for_a_past_due_op`, +`db::tests::earliest_due_at_skips_an_op_held_behind_a_claimed_one`. + +--- + ### B199 — A stopped share stays on the Shared by me page for minutes **Status:** Partly fixed. The share itself is still not deleted. @@ -4823,7 +4951,7 @@ daemon/tray/FUSE, upgrade, rollback, and uninstall without deleting user state. ### B61 — Rotated single-use credentials can be lost (HIGH-18) -**Status:** Open — 1.0 authentication/recovery blocker +**Status:** Fixed, not verified live **Found:** 2026-07-22, 1.0 authentication audit **Where:** `crates/pdfs-core/src/auth.rs`, refresh callback @@ -4835,6 +4963,26 @@ keyring retains an invalid single-use refresh token, so restart loses login. notify the user, and define a safe re-login/shutdown path. Test locked/full/ unavailable keyrings, callback races, repeated rotations, death, and restart. +**Fix (2026-10-09, issue #35).** A rotation the keyring refuses is kept as unsaved and retried +in the background after 1 s, 5 s, 30 s and then every 5 minutes, until it is written or the blob +has moved on (B201). The refusal is an error in the log naming `pdfs login`, and the daemon's stop +writes a rotation still unsaved, logging an error if that fails too. The re-login path is B201's: +an old session never writes into a newer login. + +Until it is written, the rotation also waits in the kernel keyring (`kernelkey::UNSAVED_TOKENS`), +so a crash or SIGKILL before a retry lands no longer loses it. The next resume, by the daemon or +`pdfs unlock`, puts it back in when the blob still holds the refresh token it spent, and writes +it. One that belongs to an older login is dropped. The kernel keyring lasts until the user's last +session ends, so a refused write followed by a crash and a logout still loses the rotation. + +While a rotation waits, `Response::Status` carries `tokens_unsaved`. The tray says "Sign-in not +saved — unlock your keyring" and needs attention, the app's status says the same, `pdfs status` +prints a `Keyring` line and `pdfs diagnose` warns. + +**Test:** `auth::tests::a_rotation_the_keyring_refused_is_written_once_it_takes_it` and +`auth::tests::an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for`, and the tray's +`tests::a_sign_in_the_keyring_refused_needs_attention`. + --- ### B60 — Config writes are non-atomic and parse errors are overwritten (HIGH-17) diff --git a/po/de.po b/po/de.po index b26f638..c9e8ce9 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 02:57+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -57,86 +57,86 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:761 +#: crates/pdfs-gui/src/app/main.rs:762 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 #: crates/pdfs-gui/src/app/pages/browser.rs:1217 #: crates/pdfs-gui/src/app/pages/browser.rs:2083 #: crates/pdfs-gui/src/app/pages/browser.rs:2160 -#: crates/pdfs-gui/src/app/pages/locations.rs:1624 +#: crates/pdfs-gui/src/app/pages/locations.rs:1647 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:764 crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:765 crates/pdfs-gui/src/app/main.rs:1396 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:769 +#: crates/pdfs-gui/src/app/main.rs:770 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:774 +#: crates/pdfs-gui/src/app/main.rs:775 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:777 +#: crates/pdfs-gui/src/app/main.rs:778 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:485 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:778 +#: crates/pdfs-gui/src/app/main.rs:779 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:780 +#: crates/pdfs-gui/src/app/main.rs:781 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1096 +#: crates/pdfs-gui/src/app/main.rs:1097 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1115 +#: crates/pdfs-gui/src/app/main.rs:1116 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1116 +#: crates/pdfs-gui/src/app/main.rs:1117 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1117 +#: crates/pdfs-gui/src/app/main.rs:1118 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1118 +#: crates/pdfs-gui/src/app/main.rs:1119 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1119 +#: crates/pdfs-gui/src/app/main.rs:1120 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1206 +#: crates/pdfs-gui/src/app/main.rs:1207 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1214 +#: crates/pdfs-gui/src/app/main.rs:1215 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -148,23 +148,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1219 +#: crates/pdfs-gui/src/app/main.rs:1220 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1223 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1227 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1232 +#: crates/pdfs-gui/src/app/main.rs:1233 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1236 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -189,7 +189,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1245 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -206,7 +206,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -206,7 +206,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,86 +60,86 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:761 +#: crates/pdfs-gui/src/app/main.rs:762 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 #: crates/pdfs-gui/src/app/pages/browser.rs:1217 #: crates/pdfs-gui/src/app/pages/browser.rs:2083 #: crates/pdfs-gui/src/app/pages/browser.rs:2160 -#: crates/pdfs-gui/src/app/pages/locations.rs:1624 +#: crates/pdfs-gui/src/app/pages/locations.rs:1647 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:764 crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:765 crates/pdfs-gui/src/app/main.rs:1396 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:769 +#: crates/pdfs-gui/src/app/main.rs:770 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:774 +#: crates/pdfs-gui/src/app/main.rs:775 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:777 +#: crates/pdfs-gui/src/app/main.rs:778 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:485 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:778 +#: crates/pdfs-gui/src/app/main.rs:779 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:780 +#: crates/pdfs-gui/src/app/main.rs:781 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1096 +#: crates/pdfs-gui/src/app/main.rs:1097 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1115 +#: crates/pdfs-gui/src/app/main.rs:1116 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1116 +#: crates/pdfs-gui/src/app/main.rs:1117 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1117 +#: crates/pdfs-gui/src/app/main.rs:1118 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1118 +#: crates/pdfs-gui/src/app/main.rs:1119 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1119 +#: crates/pdfs-gui/src/app/main.rs:1120 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1206 +#: crates/pdfs-gui/src/app/main.rs:1207 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1214 +#: crates/pdfs-gui/src/app/main.rs:1215 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -147,23 +147,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1219 +#: crates/pdfs-gui/src/app/main.rs:1220 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1223 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1227 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1232 +#: crates/pdfs-gui/src/app/main.rs:1233 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1236 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -176,21 +176,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1245 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -206,7 +206,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the