mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Merge branch 'fix/issue-35-dead-session'
Issue #35: a refused session showed as offline forever, the drain workers spun while offline, and refreshed tokens could be lost or overwritten (B200-B203, B61). Needs proton-sdk 0.8.2.
This commit is contained in:
commit
2a86830e72
30 files changed
+3914
-2515
No files matched your search
Generated
+18
-18
@@ -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]]
|
||||
|
||||
+2
-2
@@ -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" }
|
||||
|
||||
@@ -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<PathBuf>) -> 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<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
},
|
||||
);
|
||||
|
||||
// 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,6 +3463,24 @@ fn cmd_diagnose() -> Result<()> {
|
||||
|kind| format!("{mountpoint} ({kind})"),
|
||||
),
|
||||
);
|
||||
// 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
|
||||
@@ -3458,6 +3494,7 @@ fn cmd_diagnose() -> Result<()> {
|
||||
"offline; cached data remains available"
|
||||
},
|
||||
);
|
||||
}
|
||||
let pending = pending_uploads + pending_changes;
|
||||
report.finding(
|
||||
if pending == 0 {
|
||||
|
||||
+522
-38
@@ -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<Option<(KeyPassphrases, Ke
|
||||
if let Some(found) = stored.key_passphrases.as_ref().and_then(decode) {
|
||||
return Ok(Some((found, KeyState::Stored)));
|
||||
}
|
||||
match kernelkey::load() {
|
||||
match kernelkey::PASSPHRASES.load() {
|
||||
Ok(Some(secret)) => 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<ProtonApiSession> {
|
||||
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<Tokens> {
|
||||
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::<UnsavedRotation>(&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<BTreeMap<String, Arc<TokenSink>>> =
|
||||
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<SinkState>,
|
||||
/// 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<Tokens>,
|
||||
/// 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<Self>, 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<Self>) {
|
||||
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<TokenSink> {
|
||||
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::<keyring_core::mock::Cred>()
|
||||
.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));
|
||||
}
|
||||
}
|
||||
@@ -1959,6 +1959,16 @@ pub enum Response {
|
||||
/// resumed, or not paused.
|
||||
#[serde(default)]
|
||||
paused_until: Option<i64>,
|
||||
/// 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 },
|
||||
|
||||
@@ -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~<lid>` 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<Option<i64>> {
|
||||
let conn = self.read();
|
||||
let ts: Option<i64> = 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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<T>() -> io::Result<T> {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
|
||||
fn find() -> io::Result<Option<libc::c_long>> {
|
||||
fn find(description: &[u8]) -> io::Result<Option<libc::c_long>> {
|
||||
// SAFETY: both strings are NUL-terminated and outlive the call.
|
||||
let id = unsafe {
|
||||
libc::syscall(
|
||||
@@ -57,7 +81,7 @@ fn find() -> io::Result<Option<libc::c_long>> {
|
||||
KEYCTL_SEARCH,
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
KEY_TYPE.as_ptr(),
|
||||
DESCRIPTION.as_ptr(),
|
||||
description.as_ptr(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
@@ -83,15 +107,16 @@ pub fn forbid_dumps() {
|
||||
}
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// Store `secret`, replacing any earlier one.
|
||||
pub fn store(secret: &[u8]) -> io::Result<()> {
|
||||
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_add_key,
|
||||
KEY_TYPE.as_ptr(),
|
||||
DESCRIPTION.as_ptr(),
|
||||
self.description.as_ptr(),
|
||||
secret.as_ptr(),
|
||||
secret.len(),
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
@@ -112,15 +137,15 @@ pub fn store(secret: &[u8]) -> io::Result<()> {
|
||||
if rc < 0 {
|
||||
// A key whose permissions are unknown is not one to leave behind.
|
||||
let err = io::Error::last_os_error();
|
||||
let _ = clear();
|
||||
let _ = self.clear();
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the stored secret, or `None` when there is none.
|
||||
pub fn load() -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
let Some(id) = find()? else {
|
||||
pub fn load(&self) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
let Some(id) = find(self.description)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Size first, then read; retry if the key was replaced in between.
|
||||
@@ -164,8 +189,8 @@ pub fn load() -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
}
|
||||
|
||||
/// Remove the stored secret. Succeeds when there is none.
|
||||
pub fn clear() -> io::Result<()> {
|
||||
let Some(id) = find()? else {
|
||||
pub fn clear(&self) -> io::Result<()> {
|
||||
let Some(id) = find(self.description)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// SAFETY: plain integer arguments.
|
||||
@@ -178,6 +203,7 @@ pub fn clear() -> io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<Option<SyncIssue>> {
|
||||
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<i64>, 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<NodeUid>)> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<std::collections::HashSet<u64>>,
|
||||
/// 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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Ui>, 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");
|
||||
|
||||
@@ -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<bool>,
|
||||
/// 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<Ui>, 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<Ui>) {
|
||||
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.
|
||||
|
||||
@@ -836,6 +836,13 @@ pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
||||
.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>) {
|
||||
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<Ui>, status: Response) {
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
@@ -1231,7 +1240,11 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, 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<Ui>) {
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<Ui>, 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<Ui>, 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
|
||||
|
||||
@@ -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(
|
||||
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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
+150
-2
@@ -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)
|
||||
|
||||
+301
-264
File diff suppressed because it is too large.
Load diff
+305
-268
File diff suppressed because it is too large.
Load diff
+301
-264
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user