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:
nvrl committed 2026-10-09 12:34:38 +02:00
commit 2a86830e72
30 files changed
+4010 -2611

No files matched your search

Generated
+18 -18
View File
@@ -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
View File
@@ -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" }
+53 -16
View File
@@ -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,19 +3463,38 @@ fn cmd_diagnose() -> Result<()> {
|kind| format!("{mountpoint} ({kind})"),
),
);
report.finding(
if online {
DiagnoseLevel::Ok
} else {
DiagnoseLevel::Warn
},
" network",
if online {
"online"
} else {
"offline; cached data remains available"
},
);
// The keyring check above read the blob, which still holds the
// tokens the daemon could not replace.
if tokens_unsaved {
report.finding(
DiagnoseLevel::Warn,
" session tokens",
"the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`",
);
}
// A refused session reads as offline too, but no connection will
// bring it back, and the keyring check above cannot tell.
if session_expired {
report.finding(
DiagnoseLevel::Fail,
" session",
"Proton refused it (expired or revoked) — run `pdfs login`",
);
} else {
report.finding(
if online {
DiagnoseLevel::Ok
} else {
DiagnoseLevel::Warn
},
" network",
if online {
"online"
} else {
"offline; cached data remains available"
},
);
}
let pending = pending_uploads + pending_changes;
report.finding(
if pending == 0 {
+522 -38
View File
@@ -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));
}
}
+10
View File
@@ -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 },
+27 -16
View File
@@ -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),
+30
View File
@@ -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]
+28
View File
@@ -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.
+127 -98
View File
@@ -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,100 +107,102 @@ pub fn forbid_dumps() {
}
}
/// Store `secret`, replacing any earlier one.
pub fn store(secret: &[u8]) -> io::Result<()> {
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
// describes `secret`, which outlives the call.
let id = unsafe {
libc::syscall(
libc::SYS_add_key,
KEY_TYPE.as_ptr(),
DESCRIPTION.as_ptr(),
secret.as_ptr(),
secret.len(),
KEY_SPEC_USER_KEYRING,
)
};
if id < 0 {
return last_error();
}
// SAFETY: plain integer arguments.
let rc = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_SETPERM,
id,
PERM_OWNER_AND_POSSESSOR,
)
};
if rc < 0 {
// A key whose permissions are unknown is not one to leave behind.
let err = io::Error::last_os_error();
let _ = clear();
return Err(err);
}
Ok(())
}
/// Read the stored secret, or `None` when there is none.
pub fn load() -> io::Result<Option<Zeroizing<Vec<u8>>>> {
let Some(id) = find()? else {
return Ok(None);
};
// Size first, then read; retry if the key was replaced in between.
for _ in 0..3 {
// SAFETY: a null buffer asks the kernel only for the payload length.
let len = unsafe {
impl Key {
/// Store `secret`, replacing any earlier one.
pub fn store(&self, secret: &[u8]) -> io::Result<()> {
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
// describes `secret`, which outlives the call.
let id = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_READ,
id,
std::ptr::null_mut::<u8>(),
0usize,
libc::SYS_add_key,
KEY_TYPE.as_ptr(),
self.description.as_ptr(),
secret.as_ptr(),
secret.len(),
KEY_SPEC_USER_KEYRING,
)
};
if len < 0 {
if id < 0 {
return last_error();
}
// SAFETY: plain integer arguments.
let rc = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_SETPERM,
id,
PERM_OWNER_AND_POSSESSOR,
)
};
if rc < 0 {
// A key whose permissions are unknown is not one to leave behind.
let err = io::Error::last_os_error();
let _ = self.clear();
return Err(err);
}
Ok(())
}
/// Read the stored secret, or `None` when there is none.
pub fn load(&self) -> io::Result<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.
for _ in 0..3 {
// SAFETY: a null buffer asks the kernel only for the payload length.
let len = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_READ,
id,
std::ptr::null_mut::<u8>(),
0usize,
)
};
if len < 0 {
return match io::Error::last_os_error().raw_os_error() {
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
_ => last_error(),
};
}
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
// SAFETY: `buf` is `len` writable bytes.
let read = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_READ,
id,
buf.as_mut_ptr(),
buf.len(),
)
};
if read < 0 {
return last_error();
}
if read as usize <= buf.len() {
buf.truncate(read as usize);
return Ok(Some(buf));
}
}
Err(io::Error::other("the key changed while it was being read"))
}
/// Remove the stored secret. Succeeds when there is none.
pub fn clear(&self) -> io::Result<()> {
let Some(id) = find(self.description)? else {
return Ok(());
};
// SAFETY: plain integer arguments.
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
if rc < 0 {
return match io::Error::last_os_error().raw_os_error() {
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
_ => last_error(),
};
}
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
// SAFETY: `buf` is `len` writable bytes.
let read = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_READ,
id,
buf.as_mut_ptr(),
buf.len(),
)
};
if read < 0 {
return last_error();
}
if read as usize <= buf.len() {
buf.truncate(read as usize);
return Ok(Some(buf));
}
Ok(())
}
Err(io::Error::other("the key changed while it was being read"))
}
/// Remove the stored secret. Succeeds when there is none.
pub fn clear() -> io::Result<()> {
let Some(id) = find()? else {
return Ok(());
};
// SAFETY: plain integer arguments.
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
if rc < 0 {
return match io::Error::last_os_error().raw_os_error() {
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
_ => last_error(),
};
}
Ok(())
}
#[cfg(test)]
@@ -187,18 +213,21 @@ mod tests {
// so one test covers the whole round trip and skips when it is unavailable.
#[test]
fn stored_secret_round_trips_and_clears() {
if store(b"probe").is_err() {
if PASSPHRASES.store(b"probe").is_err() {
eprintln!("kernel keyring unavailable; skipping");
return;
}
let saved = load().unwrap().expect("stored secret is readable");
let saved = PASSPHRASES
.load()
.unwrap()
.expect("stored secret is readable");
assert_eq!(saved.as_slice(), b"probe");
store(b"replaced").unwrap();
assert_eq!(load().unwrap().unwrap().as_slice(), b"replaced");
PASSPHRASES.store(b"replaced").unwrap();
assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced");
clear().unwrap();
assert!(load().unwrap().is_none());
clear().unwrap();
PASSPHRASES.clear().unwrap();
assert!(PASSPHRASES.load().unwrap().is_none());
PASSPHRASES.clear().unwrap();
}
}
+11
View File
@@ -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;
+2
View File
@@ -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,
}
+47 -19
View File
@@ -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,
+5 -1
View File
@@ -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,
};
+65 -3
View File
@@ -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());
+52 -5
View File
@@ -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");
+7
View File
@@ -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");
+28 -5
View File
@@ -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: &gtk4::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: &gtk4::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: &gtk4::Button, add_folder: &gtk
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.
+45 -1
View File
@@ -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
+67 -7
View File
@@ -45,6 +45,8 @@ enum Phase {
Offline,
/// Changes keep failing; the user has to look.
Attention,
/// Mounted, but Proton refused the session: only signing in again helps.
SessionExpired,
/// Signed in, but no mount daemon is running.
Disconnected,
SignedOut,
@@ -60,6 +62,7 @@ impl Phase {
Phase::Paused => "media-playback-pause-symbolic",
Phase::Offline => "network-offline-symbolic",
Phase::Attention => "dialog-warning-symbolic",
Phase::SessionExpired => "dialog-password-symbolic",
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
}
}
@@ -234,6 +237,8 @@ fn status_state(
pending_changes,
failing_ops,
paused,
session_expired,
tokens_unsaved,
..
} = status
else {
@@ -241,14 +246,25 @@ fn status_state(
};
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
let phase = phase_of(
paused,
failing_ops,
online,
queued.is_some() || busy(items, jobs),
);
let phase = if *session_expired {
Phase::SessionExpired
} else if *tokens_unsaved {
Phase::Attention
} else {
phase_of(
paused,
failing_ops,
online,
queued.is_some() || busy(items, jobs),
)
};
DriveState {
line: match (online, queued) {
// Outranks everything: it is why nothing else moves, and the one
// thing that fixes it is the user's to do.
_ if *session_expired => gettext("Session expired — sign in again"),
// Sync goes on meanwhile, but a reboot ends it until the next login.
_ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"),
_ if paused => gettext("Sync paused"),
_ if failing_ops > 0 => ngettext_f(
"{n} change needs attention",
@@ -365,7 +381,7 @@ impl Tray for DriveTray {
fn status(&self) -> Status {
match self.state.phase {
Phase::Attention => Status::NeedsAttention,
Phase::Attention | Phase::SessionExpired => Status::NeedsAttention,
_ => Status::Active,
}
}
@@ -423,6 +439,9 @@ impl Tray for DriveTray {
// Nothing to mount without a session; the app's sign-in page is the
// way in.
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
Phase::SessionExpired => items.push(item(&gettext("Sign In Again…"), |_| {
open_manager(&["--page", "login"])
})),
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
}
@@ -606,6 +625,47 @@ mod tests {
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
}
fn status(extra: &str) -> Response {
serde_json::from_str(&format!(
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true{extra}}}}}"#
))
.unwrap()
}
/// Issue #35: a refused session read as "Offline — 3 uploads queued" for
/// good, and nothing said that signing in again was the way out.
#[test]
fn a_refused_session_outranks_everything_else() {
let state = status_state(
&status(r#","session_expired":true"#),
&[],
&[],
Path::new("/m"),
);
assert_eq!(state.phase, Phase::SessionExpired);
assert_eq!(state.line, "Session expired — sign in again");
}
/// B61: the keyring refusing the refreshed tokens was only in the log.
#[test]
fn a_sign_in_the_keyring_refused_needs_attention() {
let state = status_state(
&status(r#","tokens_unsaved":true"#),
&[],
&[],
Path::new("/m"),
);
assert_eq!(state.phase, Phase::Attention);
assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
}
#[test]
fn a_daemon_that_does_not_say_has_a_live_session() {
let state = status_state(&status(""), &[], &[], Path::new("/m"));
assert_eq!(state.phase, Phase::Paused);
}
fn job(title: &str, background: bool) -> JobItem {
JobItem {
title: title.to_string(),
+1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+301 -264
View File
File diff suppressed because it is too large. Load diff
+301 -264
View File
File diff suppressed because it is too large. Load diff
+301 -264
View File
File diff suppressed because it is too large. Load diff
+301 -264
View File
File diff suppressed because it is too large. Load diff
+301 -264
View File
File diff suppressed because it is too large. Load diff
+305 -268
View File
File diff suppressed because it is too large. Load diff
+301 -264
View File
File diff suppressed because it is too large. Load diff
+301 -264
View File
File diff suppressed because it is too large. Load diff