Compare commits

...
Author SHA1 Message Date
Claude 3e0db8cf46 fix(fuse): fill an incomplete queued revision's gaps on a write open
A partial write whose base was not in the content cache queued an
incomplete revision, and every write open over it was refused with EIO
until the drain uploaded it. CI's one-client-lan simulation hit this on
seed 1: a create over a file whose partial edit had not drained yet.

The write open now copies the blob and fills its gaps from the remote
base with Core::fill_gaps, as the drain does, and fails only when that
fill fails (B204).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuyxByBpK3fSWtyPkugeHz
2026-10-09 11:36:46 +00:00
nvrl 2a86830e72 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.
2026-10-09 12:34:38 +02:00
nvrl 43031ecf99 chore(deps) updated sdk to 0.8.2 2026-10-09 12:34:02 +02:00
nvrl 2b6c37f543 fix(fuse,core,cli,gui): say when the keyring refuses the refreshed sign-in
A rotation the keyring refused was retried and kept in the kernel
keyring, but only the log said so, and a reboot before the keyring took
it still signed the user out.

`Response::Status` carries `tokens_unsaved` while one waits. 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. New strings are translated in every catalog
(B61).
2026-10-09 12:22:02 +02:00
nvrl 3458e2e72d fix(core): keep refused token rotations in the kernel keyring
A rotation the Secret Service refused lived only in the daemon's
memory, so a crash or SIGKILL before the retry landed lost the only live
refresh token.

Keep it in the kernel keyring as well until it is written. The next
resume, by the daemon or `pdfs unlock`, puts it back in when the stored
blob still holds the refresh token it spent, and writes it; one left
over from an older login is dropped. `kernelkey` now holds named keys,
the passphrases and the unsaved tokens (B61).
2026-10-09 12:14:25 +02:00
nvrl 0ac5928138 fix(fuse,core,cli): resume a session another process refreshed
`pdfs unlock` resumes the daemon's session and may refresh it. The
daemon then holds a spent refresh token, and since B202 a refusal of it
read as an expired session although the keyring held a live one.

Before calling a refused session expired, compare the keyring with what
this process last stored. When it holds a newer rotation or another
login, end the mount with `MountOutcome::ResumeStored` and mount again
from the keyring. A rotation another process already wrote counts as
this session's, so the daemon keeps writing its later ones (B203).
2026-10-09 12:08:17 +02:00
nvrl beb3b2e4e4 fix(core): retry rotated tokens the keyring refused
A failed keyring write after a refresh was only a warning, and the one
live refresh token then lived in memory only (B61). The sink now keeps
the rotation as unsaved and retries it 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. The refusal is logged as an error naming `pdfs login`.

docs/BUGS.md B61.
2026-10-09 11:29:31 +02:00
nvrl bf43476e88 fix(fuse,core,cli,gui): show a refused session as expired, not offline
A refresh token Proton refused, or a deleted or disabled account, was
treated as a lost connection: the mount came up "offline", the probe
retried forever and logged only at debug, and nothing told the user that
only signing in again helps (issue #35).

session_revoked() names those errors. The root fetch, probe, drain,
event poll, event seed and photos root now set session_expired, log one
warning naming `pdfs login`, and stop retrying. Cached files stay
readable and changes stay queued.

Response::Status carries session_expired (serde default false). The
tray shows "Session expired — sign in again" with a Sign In Again…
item that opens the app's sign-in page; the app's status and Sync card
say the same with a button. `pdfs status` and `pdfs diagnose` report it.
New strings are translated in every catalog.

docs/BUGS.md B202.
2026-10-09 11:27:09 +02:00
nvrl 6363125fec fix(core,cli): write rotated tokens only into their own session's blob
Proton refresh tokens are single-use. store_tokens replaced the tokens of
whatever blob was stored, so after `pdfs login` restarted the daemon, the
old daemon's shutdown persist wrote its spent tokens into the new
session's blob, and the new daemon failed with InvalidRefreshToken
(issue #35). `pdfs unlock` and the legacy migration had the same shape.

Each session now writes through a TokenSink that only touches a blob
with its session id that still holds the refresh token it last left
there. Shutdown only flushes a rotation the keyring refused. unlock and
migrate_legacy re-read the blob after verifying.

docs/BUGS.md B201.
2026-10-09 11:08:40 +02:00
nvrl 51d114a8da fix(fuse,core): idle drain workers no longer spin while offline
Offline or paused, a drain worker claims nothing and then slept until the
earliest queued op fell due. Every queued op was already due and nothing
attempted it, so the wait was zero and all 16 workers spun on the queue
for the whole outage (issue #35: 17 min of CPU in 2 min 16 s).

A worker that claims nothing now sleeps the full idle poll; a reconnect
or resume wakes it. earliest_due_at also uses the claim's exclusions, so
an op held behind a claimed op on the same node no longer counts as due.

docs/BUGS.md B200.
2026-10-09 11:05:12 +02:00
Nils Pukropp 31be27f30e chore(aur): update packages to 3.2.0 2026-10-09 01:14:52 +00:00
39 changed files with 4028 additions and 2548 deletions

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" }
+40 -3
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,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
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.
+53 -24
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,15 +107,16 @@ pub fn forbid_dumps() {
}
}
/// Store `secret`, replacing any earlier one.
pub fn store(secret: &[u8]) -> io::Result<()> {
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_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 {
/// 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.
@@ -161,11 +186,11 @@ pub fn load() -> io::Result<Option<Zeroizing<Vec<u8>>>> {
}
}
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 {
/// 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.
@@ -177,6 +202,7 @@ pub fn clear() -> io::Result<()> {
};
}
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,
};
+25 -6
View File
@@ -1267,8 +1267,12 @@ impl ProtonFs {
//
// Complete pending blobs are the common case and can be copied without
// any network access. An incomplete blob still has gaps referring to
// the remote base; stacking another write on it cannot be represented
// safely by WriteHandle, so fail the open rather than risk corruption.
// the remote base, and stacking another write on it cannot be
// represented by WriteHandle, so the copy's gaps are filled from that
// base first, as the drain fills them before an upload. Only when that
// fails (offline, or the base is gone) does the open fail: refusing
// every such open failed a create over a file whose partial edit had
// not drained yet (`docs/BUGS.md` B204).
//
// Reading that blob and installing the handle cannot be done under one
// lock — the copy is the size of the file — so the base is re-checked at
@@ -1302,10 +1306,6 @@ impl ProtonFs {
}
}
let pending_base = self.core.pending.lock().get(&uid).cloned();
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
error!(%uid, "refusing write over incomplete queued revision");
break 'install Err(Errno::EIO);
}
let (file, path) = match self.core.cache.create_scratch() {
Ok(x) => x,
Err(e) => {
@@ -1325,6 +1325,25 @@ impl ProtonFs {
"copy queued revision into write scratch failed");
break 'install Err(Errno::EIO);
}
if !pending.meta.complete {
let mut authored = Intervals::default();
for &(s, e) in &pending.meta.authored {
authored.add(s, e);
}
if let Err(error) = self.core.fill_gaps(
&uid,
&file,
pending.meta.len,
pending.meta.base_mtime,
pending.meta.base_size,
&authored,
) {
let _ = std::fs::remove_file(&path);
error!(%uid, ?error, "cannot complete the queued revision a write open is based on");
break 'install Err(error);
}
debug!(%uid, "write open completed an incomplete queued revision");
}
initial_written.add(0, pending.meta.len);
}
// Sampled *before* the state lock, never under it: no site in
+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");
+30
View File
@@ -451,6 +451,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_partial_write_over_bytes_never_read_takes_another_write_at_once() {
// Nothing here ever read the file, so the release of a partial write
// had no cached base to fill its gaps from and queued an incomplete
// revision. The next write open on it failed with EIO (B204).
let drive = FakeDrive::new();
drive.device().write("f.txt", b"0123456789");
let dir = scratch("partial-write-unread-base");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
write_at(&path, 2, b"ab").unwrap();
write_at(&path, 6, b"cd").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"01ab45cd89")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() {
+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
+63 -3
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(
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",
+178 -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,11 @@ a live account since.
| Entry | Title |
|---|---|
| [B204](#b204--a-write-over-a-partial-edit-whose-base-was-never-read-fails-with-eio) | A write over a partial edit whose base was never read fails with `EIO` |
| [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 +193,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 +307,157 @@ a live account since.
Newest first.
### B204 — A write over a partial edit whose base was never read fails with `EIO`
**Status:** Fixed, not verified live
**Found:** 2026-10-09, by CI on 2a86830 (`one_client_on_a_good_link`, seed 1, which did not replay
locally). A partial write to `x/f.txt`, a file renamed twice before, was renamed over `z/d`. A
create over `z/d` a moment later failed with `EIO`, and the daemon logged "refusing write over
incomplete queued revision".
**Where:** `ProtonFs::serve_open` in `crates/pdfs-fuse/src/filesystem.rs`
**Repro:** Write at an offset into a file this client never read, then open it for writing again
before the drain uploads the first write.
**Cause.** The release of a partial write fills its gaps from the content cache only. When the
cache does not hold the base, the queued revision stays incomplete until the drain fills it from
Drive, and a write open over an incomplete revision was refused. B123, B157, B169 and B170 each
closed one way the cache could miss the base. Any other miss, such as a file never read here,
still failed the next write open for the length of the drain debounce.
**Fix.** A write open over an incomplete queued revision copies the blob and fills its gaps from
the remote base with `Core::fill_gaps`, as the drain does before an upload. The open fails only
when that fill fails, for example offline.
**Test:** `a_partial_write_over_bytes_never_read_takes_another_write_at_once` in `sim/daemon.rs`.
Without the fix the second write fails with `EIO`.
---
### 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 +4979,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 +4991,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)
+4
View File
@@ -12,6 +12,10 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
### Fixed
- **Writing to a file again soon after a partial write could fail with an I/O error.** When the
first write changed only part of a file this client had not read, a second write, or saving
over the file, failed until the first one reached Drive. It now fetches the rest of the file
and goes ahead.
- **A CAPTCHA could not be solved on hardened systems.** When the verification page cannot open in
the app, as on secureblue, the app now offers it in your browser and continues the sign-in
once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing.
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 3.1.2
pkgver = 3.2.0
pkgrel = 1
url = https://proton-drive.narl.io
arch = x86_64
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.1.2
provides = proton-drive-linux=3.1.2
provides = proton-drive-for-linux=3.2.0
provides = proton-drive-linux=3.2.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git
options = !strip
options = !debug
source = proton-drive-linux-3.1.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.1.2/proton-drive-linux-3.1.2-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.1.2.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.1.2.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/proton-drive.service
source = LICENSE-3.1.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/LICENSE
sha256sums = 71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7
source = proton-drive-linux-3.2.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.2.0/proton-drive-linux-3.2.0-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.2.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.2.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.2.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.2.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/proton-drive.service
source = LICENSE-3.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/LICENSE
sha256sums = 3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin
pkgver=3.1.2
pkgver=3.2.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64')
@@ -34,7 +34,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7'
sha256sums=('3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 3.1.2.r0.g07c9b67
pkgver = 3.2.0.r0.g4c498d5
pkgrel = 1
url = https://proton-drive.narl.io
arch = x86_64
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.1.2.r0.g07c9b67
provides = proton-drive-linux=3.1.2.r0.g07c9b67
provides = proton-drive-for-linux=3.2.0.r0.g4c498d5
provides = proton-drive-linux=3.2.0.r0.g4c498d5
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git
pkgver=3.1.2.r0.g07c9b67
pkgver=3.2.0.r0.g4c498d5
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 3.1.2
pkgver = 3.2.0
pkgrel = 1
url = https://proton-drive.narl.io
arch = x86_64
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=3.1.2
provides = proton-drive-linux=3.2.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git
options = !lto
source = proton-drive-for-linux-3.1.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.1.2.tar.gz
sha256sums = 260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1
source = proton-drive-for-linux-3.2.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.2.0.tar.gz
sha256sums = a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8
pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux
pkgver=3.1.2
pkgver=3.2.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64')
@@ -24,7 +24,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
options=('!lto')
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1')
sha256sums=('a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8')
_srcname="proton-drive-linux-$pkgver"
+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