mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e0db8cf46 | ||
|
|
2a86830e72 | ||
|
|
43031ecf99 | ||
|
|
2b6c37f543 | ||
|
|
3458e2e72d | ||
|
|
0ac5928138 | ||
|
|
beb3b2e4e4 | ||
|
|
bf43476e88 | ||
|
|
6363125fec | ||
|
|
51d114a8da | ||
|
|
31be27f30e |
No files matched your search
Generated
+18
-18
@@ -166,7 +166,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1254,7 +1254,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1433,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1896,7 +1896,7 @@ dependencies = [
|
|||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps 9.0.0",
|
"system-deps 9.0.0",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3100,7 +3100,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3778,9 +3778,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "014165ed0f2d3847a4b8956ca94a82ba16c82f2d5c7fa139c3541c15150dba21"
|
checksum = "1e789cab811c5e73e47fd97810b78a513cb254f4dd763b4eb3cad680d6dd31da"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3805,9 +3805,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e16bf5592b7947708dd754defeaee7a58e9fa35988a6ea345b5b9a93765fd81"
|
checksum = "ace260acbf88c26f5bd4aef8c8c9898c322163c56620bdef3b6dd7742bec7619"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3926,7 +3926,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4374,7 +4374,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4431,7 +4431,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4764,7 +4764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4932,10 +4932,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5266,7 +5266,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5590,7 +5590,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+2
-2
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.8.1" }
|
proton-sdk = { version = "0.8.2" }
|
||||||
proton-drive-rs = { version = "0.8.1" }
|
proton-drive-rs = { version = "0.8.2" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
+53
-16
@@ -2024,9 +2024,12 @@ fn cmd_status() -> Result<()> {
|
|||||||
staged_oldest_secs,
|
staged_oldest_secs,
|
||||||
paused,
|
paused,
|
||||||
paused_until,
|
paused_until,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let state = match (online, paused) {
|
let state = match (online, paused) {
|
||||||
|
_ if session_expired => ", session expired: run `pdfs login`",
|
||||||
(_, true) => ", sync paused",
|
(_, true) => ", sync paused",
|
||||||
(false, false) => ", offline",
|
(false, false) => ", offline",
|
||||||
(true, false) => "",
|
(true, false) => "",
|
||||||
@@ -2059,6 +2062,11 @@ fn cmd_status() -> Result<()> {
|
|||||||
failing_error.as_deref().unwrap_or("no error recorded"),
|
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:?}"),
|
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||||
Err(_) => println!("Mount: not running."),
|
Err(_) => println!("Mount: not running."),
|
||||||
@@ -2191,6 +2199,9 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
tracing::info!("daemon stopping");
|
tracing::info!("daemon stopping");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Ok(pdfs_fuse::MountOutcome::ResumeStored) => {
|
||||||
|
tracing::info!("resuming the session stored in the keyring");
|
||||||
|
}
|
||||||
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
||||||
tracing::warn!("mount ended externally; remounting in 2s");
|
tracing::warn!("mount ended externally; remounting in 2s");
|
||||||
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2));
|
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).
|
// Last chance for a rotation the keyring refused when it happened. Only
|
||||||
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
// that: by now the keyring may hold a newer login, the one this stop was
|
||||||
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
// 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
|
// 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).
|
// still stuck on Drive would hold the stop until systemd kills it (B165).
|
||||||
@@ -3424,6 +3440,8 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
Ok(CtlResponse::Status {
|
Ok(CtlResponse::Status {
|
||||||
mountpoint,
|
mountpoint,
|
||||||
online,
|
online,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
pending_uploads,
|
pending_uploads,
|
||||||
pending_changes,
|
pending_changes,
|
||||||
..
|
..
|
||||||
@@ -3445,19 +3463,38 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
|kind| format!("{mountpoint} ({kind})"),
|
|kind| format!("{mountpoint} ({kind})"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
report.finding(
|
// The keyring check above read the blob, which still holds the
|
||||||
if online {
|
// tokens the daemon could not replace.
|
||||||
DiagnoseLevel::Ok
|
if tokens_unsaved {
|
||||||
} else {
|
report.finding(
|
||||||
DiagnoseLevel::Warn
|
DiagnoseLevel::Warn,
|
||||||
},
|
" session tokens",
|
||||||
" network",
|
"the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`",
|
||||||
if online {
|
);
|
||||||
"online"
|
}
|
||||||
} else {
|
// A refused session reads as offline too, but no connection will
|
||||||
"offline; cached data remains available"
|
// bring it back, and the keyring check above cannot tell.
|
||||||
},
|
if session_expired {
|
||||||
);
|
report.finding(
|
||||||
|
DiagnoseLevel::Fail,
|
||||||
|
" session",
|
||||||
|
"Proton refused it (expired or revoked) — run `pdfs login`",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
report.finding(
|
||||||
|
if online {
|
||||||
|
DiagnoseLevel::Ok
|
||||||
|
} else {
|
||||||
|
DiagnoseLevel::Warn
|
||||||
|
},
|
||||||
|
" network",
|
||||||
|
if online {
|
||||||
|
"online"
|
||||||
|
} else {
|
||||||
|
"offline; cached data remains available"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
let pending = pending_uploads + pending_changes;
|
let pending = pending_uploads + pending_changes;
|
||||||
report.finding(
|
report.finding(
|
||||||
if pending == 0 {
|
if pending == 0 {
|
||||||
|
|||||||
+522
-38
@@ -21,6 +21,9 @@
|
|||||||
//! such separation to protect and are stored, as they always were.
|
//! such separation to protect and are stored, as they always were.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
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 as _;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
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::cache::EncryptedCacheRepository;
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
use proton_sdk::error::ProtonError;
|
use proton_sdk::error::ProtonError;
|
||||||
|
use proton_sdk::http::Tokens;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
use proton_sdk::telemetry::TracingTelemetry;
|
use proton_sdk::telemetry::TracingTelemetry;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -307,8 +311,8 @@ pub async fn login_verified(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
register_refresh_handler(&session);
|
|
||||||
let tokens = session.current_tokens().await;
|
let tokens = session.current_tokens().await;
|
||||||
|
register_refresh_handler(&session, tokens.refresh_token.clone());
|
||||||
let stored = StoredSession {
|
let stored = StoredSession {
|
||||||
session_id: session.session_id().as_str().to_owned(),
|
session_id: session.session_id().as_str().to_owned(),
|
||||||
username: session.username().to_owned(),
|
username: session.username().to_owned(),
|
||||||
@@ -360,11 +364,11 @@ fn keep_passphrases(
|
|||||||
let outcome = if remember {
|
let outcome = if remember {
|
||||||
stored.key_passphrases = Some(encode(passphrases));
|
stored.key_passphrases = Some(encode(passphrases));
|
||||||
// Best-effort: a system without a kernel keyring has nothing to clear.
|
// Best-effort: a system without a kernel keyring has nothing to clear.
|
||||||
let _ = kernelkey::clear();
|
let _ = kernelkey::PASSPHRASES.clear();
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
stored.key_passphrases = None;
|
stored.key_passphrases = None;
|
||||||
kernelkey::store(&encode_secret(passphrases)).map_err(|e| {
|
kernelkey::PASSPHRASES.store(&encode_secret(passphrases)).map_err(|e| {
|
||||||
Error::Other(format!(
|
Error::Other(format!(
|
||||||
"the kernel keyring is unavailable, so the unlock cannot be kept for this session: {e}"
|
"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) {
|
if let Some(found) = stored.key_passphrases.as_ref().and_then(decode) {
|
||||||
return Ok(Some((found, KeyState::Stored)));
|
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(Some(secret)) => Ok(decode_secret(&secret).map(|p| (p, KeyState::Session))),
|
||||||
Ok(None) => Ok(None),
|
Ok(None) => Ok(None),
|
||||||
// No keyring on this system: nothing was ever kept there.
|
// 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);
|
return Err(Error::ReloginRequired);
|
||||||
}
|
}
|
||||||
let mailbox = Zeroizing::new(mailbox.to_owned());
|
let mailbox = Zeroizing::new(mailbox.to_owned());
|
||||||
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
let session = resume_stored(&stored)?;
|
||||||
register_refresh_handler(&session);
|
|
||||||
let passphrases =
|
let passphrases =
|
||||||
derive_verified(&session, mailbox.as_bytes(), stored.key_salts.clone()).await?;
|
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
|
/// Drop the passphrases held in kernel memory, so the account is locked again
|
||||||
/// until [`unlock`]. With `forget`, also those in the keyring blob.
|
/// until [`unlock`]. With `forget`, also those in the keyring blob.
|
||||||
pub fn lock(forget: bool) -> Result<()> {
|
pub fn lock(forget: bool) -> Result<()> {
|
||||||
kernelkey::clear()?;
|
kernelkey::PASSPHRASES.clear()?;
|
||||||
if forget {
|
if forget {
|
||||||
let mut stored = load()?;
|
let mut stored = load()?;
|
||||||
if stored.key_passphrases.take().is_some() {
|
if stored.key_passphrases.take().is_some() {
|
||||||
@@ -529,9 +534,10 @@ pub fn logout() -> Result<()> {
|
|||||||
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
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");
|
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
|
||||||
}
|
}
|
||||||
|
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||||
match keyring_entry()?.delete_credential() {
|
match keyring_entry()?.delete_credential() {
|
||||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
@@ -551,8 +557,7 @@ pub fn logout() -> Result<()> {
|
|||||||
/// a daemon starting offline can still mount from its cache.
|
/// a daemon starting offline can still mount from its cache.
|
||||||
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||||
let stored = load()?;
|
let stored = load()?;
|
||||||
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
let session = resume_stored(&stored)?;
|
||||||
register_refresh_handler(&session);
|
|
||||||
|
|
||||||
let passphrases = match find_passphrases(&stored)? {
|
let passphrases = match find_passphrases(&stored)? {
|
||||||
Some((passphrases, _)) => passphrases,
|
Some((passphrases, _)) => passphrases,
|
||||||
@@ -567,6 +572,48 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
|||||||
Ok((client, session))
|
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
|
/// Turn a blob written by 3.0.x — which holds the mailbox password — into one
|
||||||
/// that holds passphrases, so the password stops being stored.
|
/// that holds passphrases, so the password stops being stored.
|
||||||
///
|
///
|
||||||
@@ -597,11 +644,14 @@ async fn migrate_legacy(
|
|||||||
stored.key_salts.clone()
|
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.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) => {
|
Ok(passphrases) => {
|
||||||
migrated.key_passphrases = Some(encode(&passphrases));
|
migrated.key_passphrases = Some(encode(&passphrases));
|
||||||
write_stored(&migrated)?;
|
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
|
/// Rotations are written as they happen (see [`register_refresh_handler`]), so
|
||||||
/// this never undoes an [`unlock`] or [`lock`] made since the session started.
|
/// this usually has nothing to do. It never writes the session's current
|
||||||
/// Call whenever the session may have rotated its tokens so a later
|
/// tokens for their own sake: the blob may by now belong to a newer login, or
|
||||||
/// [`resume_client`] presents a live refresh token.
|
/// hold a newer rotation, and writing them would put a spent refresh token
|
||||||
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
|
/// over a live one.
|
||||||
let tokens = session.current_tokens().await;
|
pub fn persist(session: &ProtonApiSession) -> Result<()> {
|
||||||
store_tokens(tokens.access_token, tokens.refresh_token)
|
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<()> {
|
/// Whether the keyring holds a session newer than any this process runs on:
|
||||||
let mut stored = load()?;
|
/// another login, or one of this process's sessions rotated by another
|
||||||
stored.access_token = access_token;
|
/// process (`pdfs unlock` resumes the same login and may refresh it).
|
||||||
stored.refresh_token = refresh_token;
|
///
|
||||||
write_stored(&stored)
|
/// 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.
|
/// Persist rotated tokens the moment the session obtains them.
|
||||||
///
|
///
|
||||||
/// The handler re-reads the stored blob instead of capturing one, so it cannot
|
/// `stored_refresh` is the refresh token the keyring holds for this session
|
||||||
/// write back a stale copy over a later unlock or lock — and cannot resurrect a
|
/// now. The handler re-reads the stored blob instead of capturing one, so it
|
||||||
/// session that was logged out while the daemon still ran.
|
/// cannot write back a stale copy over a later unlock or lock, and it writes
|
||||||
fn register_refresh_handler(session: &ProtonApiSession) {
|
/// 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| {
|
session.http().set_on_tokens_refreshed(move |tokens| {
|
||||||
match store_tokens(tokens.access_token, tokens.refresh_token) {
|
// A refusal is logged and retried by the sink itself.
|
||||||
Ok(()) => tracing::info!("successfully auto-persisted refreshed tokens in keyring"),
|
let _ = handler.rotated(tokens);
|
||||||
Err(Error::NotLoggedIn) => {}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
sink
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -786,4 +1106,168 @@ mod tests {
|
|||||||
assert_eq!(origin, KeyState::Stored);
|
assert_eq!(origin, KeyState::Stored);
|
||||||
assert_eq!(found, passphrases());
|
assert_eq!(found, passphrases());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tokens(access: &str, refresh: &str) -> Tokens {
|
||||||
|
Tokens {
|
||||||
|
access_token: access.into(),
|
||||||
|
refresh_token: refresh.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn own_session_tokens_are_written() {
|
||||||
|
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")),
|
||||||
|
Rotation::Taken
|
||||||
|
);
|
||||||
|
assert_eq!(stored.access_token, "a2");
|
||||||
|
assert_eq!(stored.refresh_token, "r2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #35: `pdfs login` stores a new session and restarts the daemon,
|
||||||
|
/// and the old daemon's stop wrote its spent tokens into the new blob.
|
||||||
|
#[test]
|
||||||
|
fn tokens_of_another_session_are_not_written() {
|
||||||
|
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
take_rotation(&mut stored, "old", "r", &tokens("a2", "r2")),
|
||||||
|
Rotation::MovedOn
|
||||||
|
);
|
||||||
|
assert_eq!(stored.refresh_token, "r");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pdfs unlock` resumes a second session on the same login. Once it has
|
||||||
|
/// rotated, the daemon's copy of the refresh token is spent.
|
||||||
|
#[test]
|
||||||
|
fn tokens_are_not_written_over_a_newer_rotation_of_the_same_session() {
|
||||||
|
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
take_rotation(&mut stored, "s", "spent", &tokens("a2", "r2")),
|
||||||
|
Rotation::MovedOn
|
||||||
|
);
|
||||||
|
assert_eq!(stored.refresh_token, "r");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pdfs unlock` wrote this very rotation first, from the same session.
|
||||||
|
#[test]
|
||||||
|
fn a_rotation_already_in_the_blob_is_taken() {
|
||||||
|
let mut stored = stored_with("r2");
|
||||||
|
assert_eq!(
|
||||||
|
take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")),
|
||||||
|
Rotation::Taken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keyring is one store per process, so the tests that use it,
|
||||||
|
/// through `keyring-core`'s in-memory store, take turns.
|
||||||
|
fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> {
|
||||||
|
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||||
|
let turn = KEYRING.lock();
|
||||||
|
keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
|
||||||
|
turn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored_with(refresh: &str) -> StoredSession {
|
||||||
|
serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
write_stored(&stored_with("r")).unwrap();
|
||||||
|
|
||||||
|
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||||
|
sink.rotated(tokens("a2", "r2")).unwrap();
|
||||||
|
sink.rotated(tokens("a3", "r3")).unwrap();
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "r3");
|
||||||
|
|
||||||
|
// A new login replaces the blob; the old session's next rotation, and
|
||||||
|
// its stop, leave it alone.
|
||||||
|
let mut login = stored_with("fresh");
|
||||||
|
login.session_id = "new".into();
|
||||||
|
write_stored(&login).unwrap();
|
||||||
|
sink.rotated(tokens("a4", "r4")).unwrap();
|
||||||
|
sink.flush().unwrap();
|
||||||
|
let now = load().unwrap();
|
||||||
|
assert_eq!(now.session_id, "new");
|
||||||
|
assert_eq!(now.refresh_token, "fresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pdfs unlock` rotated the daemon's session behind its back: the
|
||||||
|
/// daemon's refresh token is spent, and the keyring holds the live one.
|
||||||
|
#[test]
|
||||||
|
fn a_session_rotated_by_another_process_is_newer_in_the_keyring() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
write_stored(&stored_with("r")).unwrap();
|
||||||
|
TOKEN_SINKS
|
||||||
|
.lock()
|
||||||
|
.insert("s".into(), Arc::new(TokenSink::new("s", "r".into())));
|
||||||
|
assert!(!stored_session_is_newer());
|
||||||
|
|
||||||
|
write_stored(&stored_with("r2")).unwrap();
|
||||||
|
assert!(stored_session_is_newer());
|
||||||
|
|
||||||
|
let mut login = stored_with("fresh");
|
||||||
|
login.session_id = "new".into();
|
||||||
|
write_stored(&login).unwrap();
|
||||||
|
assert!(stored_session_is_newer());
|
||||||
|
TOKEN_SINKS.lock().remove("s");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B61: a rotation the keyring refused lived only in memory.
|
||||||
|
#[test]
|
||||||
|
fn a_rotation_the_keyring_refused_is_written_once_it_takes_it() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
write_stored(&stored_with("r")).unwrap();
|
||||||
|
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||||
|
|
||||||
|
let entry = keyring_entry().unwrap();
|
||||||
|
let cred = entry
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<keyring_core::mock::Cred>()
|
||||||
|
.unwrap();
|
||||||
|
cred.set_error(keyring_core::Error::NoStorageAccess(
|
||||||
|
std::io::Error::other("locked").into(),
|
||||||
|
));
|
||||||
|
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
||||||
|
// Until it is written, a stop does not lose it: the next resume finds
|
||||||
|
// it in the kernel keyring, where one is offered.
|
||||||
|
let kept = sink.state.lock().kept;
|
||||||
|
if kept {
|
||||||
|
assert_eq!(
|
||||||
|
recover_unsaved(&stored_with("r")).unwrap().refresh_token,
|
||||||
|
"r2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while load().unwrap().refresh_token != "r2" {
|
||||||
|
assert!(
|
||||||
|
std::time::Instant::now() < deadline,
|
||||||
|
"the retry never wrote the rotation"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
assert!(sink.state.lock().unsaved.is_none());
|
||||||
|
if kept {
|
||||||
|
assert!(kernelkey::UNSAVED_TOKENS.load().unwrap().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
||||||
|
let unsaved = UnsavedRotation {
|
||||||
|
session_id: "s".into(),
|
||||||
|
spent: "r".into(),
|
||||||
|
access_token: "a2".into(),
|
||||||
|
refresh_token: "r2".into(),
|
||||||
|
};
|
||||||
|
assert!(unsaved.belongs_in(&stored_with("r")));
|
||||||
|
// Written since, by a retry or another process.
|
||||||
|
assert!(!unsaved.belongs_in(&stored_with("r2")));
|
||||||
|
let mut login = stored_with("r");
|
||||||
|
login.session_id = "new".into();
|
||||||
|
assert!(!unsaved.belongs_in(&login));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1959,6 +1959,16 @@ pub enum Response {
|
|||||||
/// resumed, or not paused.
|
/// resumed, or not paused.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
paused_until: Option<i64>,
|
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.
|
/// A human-readable success message.
|
||||||
Ok { message: String },
|
Ok { message: String },
|
||||||
|
|||||||
@@ -252,6 +252,25 @@ const UID_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.li
|
|||||||
const PARENT_NOW: &str =
|
const PARENT_NOW: &str =
|
||||||
"COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)";
|
"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
|
/// 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
|
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<lid>` and
|
||||||
/// round-trips through the same `Display`/parse path as any other uid.
|
/// 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,
|
"SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at,
|
||||||
attempts, last_error, next_attempt_at
|
attempts, last_error, next_attempt_at
|
||||||
FROM pending_op
|
FROM pending_op
|
||||||
WHERE next_attempt_at <= ?1
|
WHERE next_attempt_at <= ?1 AND {claimable}
|
||||||
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))
|
|
||||||
ORDER BY id LIMIT 1",
|
ORDER BY id LIMIT 1",
|
||||||
v = LOCAL_VOLUME,
|
claimable = claimable(),
|
||||||
n = LOCAL_VOLUME.len() + 1,
|
|
||||||
))?;
|
))?;
|
||||||
stmt.query_row(params![now], |r| {
|
stmt.query_row(params![now], |r| {
|
||||||
Ok(PendingOp {
|
Ok(PendingOp {
|
||||||
@@ -1208,19 +1221,17 @@ impl Db {
|
|||||||
/// debounced or backed-off op becomes eligible rather than waiting the full
|
/// debounced or backed-off op becomes eligible rather than waiting the full
|
||||||
/// idle-poll interval.
|
/// idle-poll interval.
|
||||||
///
|
///
|
||||||
/// Claimed ops are excluded: another worker is already on them, so they are
|
/// Only rows [`claim_next_due_op`](Self::claim_next_due_op) could take
|
||||||
/// not work this one is waiting for, and counting them would have an idle
|
/// count. A claimed op, or one held behind a claimed op on the same node,
|
||||||
/// worker spin on a row it cannot have.
|
/// 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>> {
|
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let ts: Option<i64> = conn
|
let ts: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
&format!(
|
&format!(
|
||||||
"SELECT MIN(next_attempt_at) FROM pending_op \
|
"SELECT MIN(next_attempt_at) FROM pending_op WHERE {claimable}",
|
||||||
WHERE claimed_at = 0 \
|
claimable = claimable(),
|
||||||
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')",
|
|
||||||
v = LOCAL_VOLUME,
|
|
||||||
n = LOCAL_VOLUME.len() + 1,
|
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
|
|||||||
@@ -6547,6 +6547,36 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B200: a rename queued behind a long upload of the same node is due, but no
|
||||||
|
/// worker may claim it until the upload retires. Counted as due, it had every
|
||||||
|
/// idle worker wake at once, find nothing, and go round again.
|
||||||
|
#[test]
|
||||||
|
fn earliest_due_at_skips_an_op_held_behind_a_claimed_one() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let root = uid("root").to_string();
|
||||||
|
let mut revision = bulk_op(0, &root);
|
||||||
|
revision.parent_uid = None;
|
||||||
|
db.enqueue_op(&revision).unwrap();
|
||||||
|
db.enqueue_op(&PendingOp {
|
||||||
|
kind: OP_RENAME.to_string(),
|
||||||
|
parent_uid: Some(root.clone()),
|
||||||
|
blob_path: None,
|
||||||
|
meta_json: None,
|
||||||
|
..bulk_op(0, &root)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
db.claim_next_due_op(10)
|
||||||
|
.unwrap()
|
||||||
|
.expect("the revision is due");
|
||||||
|
assert!(db.claim_next_due_op(10).unwrap().is_none());
|
||||||
|
assert_eq!(
|
||||||
|
db.earliest_due_at().unwrap(),
|
||||||
|
None,
|
||||||
|
"a row no worker can claim is not work an idle worker is waiting for"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Trashing a photo from the gallery must not leave it behind on an album page:
|
/// 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.
|
/// the row goes, its album membership goes with it, and nothing else moves.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -278,6 +278,23 @@ fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `e` says the session itself is gone: its refresh token was refused,
|
||||||
|
/// or the account was deleted or disabled.
|
||||||
|
///
|
||||||
|
/// Nothing but a new login helps then, and no retry will ever succeed, so a
|
||||||
|
/// caller must not take it for a lost connection and wait for it to come back
|
||||||
|
/// (issue #35: a revoked session was shown as "offline" for good).
|
||||||
|
pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool {
|
||||||
|
use proton_sdk::api::ResponseCode as C;
|
||||||
|
match e {
|
||||||
|
proton_sdk::ProtonError::Api(api) => {
|
||||||
|
api.is_invalid_refresh_token()
|
||||||
|
|| matches!(api.code, C::AccountDeleted | C::AccountDisabled)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The result every request-serving method in the daemon returns.
|
/// The result every request-serving method in the daemon returns.
|
||||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||||
|
|
||||||
@@ -309,6 +326,17 @@ mod tests {
|
|||||||
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
|
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
|
/// 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
|
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
|
||||||
/// request that could never succeed.
|
/// request that could never succeed.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
//! kept in kernel memory — not on disk, not in the Secret Service — and vanish
|
//! 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
|
//! 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.
|
//! `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
|
//! 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
|
//! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a
|
||||||
@@ -20,12 +22,34 @@ use std::io;
|
|||||||
|
|
||||||
use zeroize::Zeroizing;
|
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))]
|
#[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.
|
/// Tests must never touch a real unlock.
|
||||||
#[cfg(test)]
|
#[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_TYPE: &[u8] = b"user\0";
|
||||||
|
|
||||||
const KEY_SPEC_USER_KEYRING: i32 = -4;
|
const KEY_SPEC_USER_KEYRING: i32 = -4;
|
||||||
@@ -49,7 +73,7 @@ fn last_error<T>() -> io::Result<T> {
|
|||||||
Err(io::Error::last_os_error())
|
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.
|
// SAFETY: both strings are NUL-terminated and outlive the call.
|
||||||
let id = unsafe {
|
let id = unsafe {
|
||||||
libc::syscall(
|
libc::syscall(
|
||||||
@@ -57,7 +81,7 @@ fn find() -> io::Result<Option<libc::c_long>> {
|
|||||||
KEYCTL_SEARCH,
|
KEYCTL_SEARCH,
|
||||||
KEY_SPEC_USER_KEYRING,
|
KEY_SPEC_USER_KEYRING,
|
||||||
KEY_TYPE.as_ptr(),
|
KEY_TYPE.as_ptr(),
|
||||||
DESCRIPTION.as_ptr(),
|
description.as_ptr(),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -83,100 +107,102 @@ pub fn forbid_dumps() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store `secret`, replacing any earlier one.
|
impl Key {
|
||||||
pub fn store(secret: &[u8]) -> io::Result<()> {
|
/// Store `secret`, replacing any earlier one.
|
||||||
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
|
pub fn store(&self, secret: &[u8]) -> io::Result<()> {
|
||||||
// describes `secret`, which outlives the call.
|
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
|
||||||
let id = unsafe {
|
// describes `secret`, which outlives the call.
|
||||||
libc::syscall(
|
let id = unsafe {
|
||||||
libc::SYS_add_key,
|
|
||||||
KEY_TYPE.as_ptr(),
|
|
||||||
DESCRIPTION.as_ptr(),
|
|
||||||
secret.as_ptr(),
|
|
||||||
secret.len(),
|
|
||||||
KEY_SPEC_USER_KEYRING,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if id < 0 {
|
|
||||||
return last_error();
|
|
||||||
}
|
|
||||||
// SAFETY: plain integer arguments.
|
|
||||||
let rc = unsafe {
|
|
||||||
libc::syscall(
|
|
||||||
libc::SYS_keyctl,
|
|
||||||
KEYCTL_SETPERM,
|
|
||||||
id,
|
|
||||||
PERM_OWNER_AND_POSSESSOR,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if rc < 0 {
|
|
||||||
// A key whose permissions are unknown is not one to leave behind.
|
|
||||||
let err = io::Error::last_os_error();
|
|
||||||
let _ = clear();
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the stored secret, or `None` when there is none.
|
|
||||||
pub fn load() -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
|
||||||
let Some(id) = find()? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
// Size first, then read; retry if the key was replaced in between.
|
|
||||||
for _ in 0..3 {
|
|
||||||
// SAFETY: a null buffer asks the kernel only for the payload length.
|
|
||||||
let len = unsafe {
|
|
||||||
libc::syscall(
|
libc::syscall(
|
||||||
libc::SYS_keyctl,
|
libc::SYS_add_key,
|
||||||
KEYCTL_READ,
|
KEY_TYPE.as_ptr(),
|
||||||
id,
|
self.description.as_ptr(),
|
||||||
std::ptr::null_mut::<u8>(),
|
secret.as_ptr(),
|
||||||
0usize,
|
secret.len(),
|
||||||
|
KEY_SPEC_USER_KEYRING,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if len < 0 {
|
if id < 0 {
|
||||||
|
return last_error();
|
||||||
|
}
|
||||||
|
// SAFETY: plain integer arguments.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_keyctl,
|
||||||
|
KEYCTL_SETPERM,
|
||||||
|
id,
|
||||||
|
PERM_OWNER_AND_POSSESSOR,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc < 0 {
|
||||||
|
// A key whose permissions are unknown is not one to leave behind.
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
let _ = self.clear();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the stored secret, or `None` when there is none.
|
||||||
|
pub fn load(&self) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||||
|
let Some(id) = find(self.description)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
// Size first, then read; retry if the key was replaced in between.
|
||||||
|
for _ in 0..3 {
|
||||||
|
// SAFETY: a null buffer asks the kernel only for the payload length.
|
||||||
|
let len = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_keyctl,
|
||||||
|
KEYCTL_READ,
|
||||||
|
id,
|
||||||
|
std::ptr::null_mut::<u8>(),
|
||||||
|
0usize,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if len < 0 {
|
||||||
|
return match io::Error::last_os_error().raw_os_error() {
|
||||||
|
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||||
|
_ => last_error(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
|
||||||
|
// SAFETY: `buf` is `len` writable bytes.
|
||||||
|
let read = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_keyctl,
|
||||||
|
KEYCTL_READ,
|
||||||
|
id,
|
||||||
|
buf.as_mut_ptr(),
|
||||||
|
buf.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if read < 0 {
|
||||||
|
return last_error();
|
||||||
|
}
|
||||||
|
if read as usize <= buf.len() {
|
||||||
|
buf.truncate(read as usize);
|
||||||
|
return Ok(Some(buf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(io::Error::other("the key changed while it was being read"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the stored secret. Succeeds when there is none.
|
||||||
|
pub fn clear(&self) -> io::Result<()> {
|
||||||
|
let Some(id) = find(self.description)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// SAFETY: plain integer arguments.
|
||||||
|
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
|
||||||
|
if rc < 0 {
|
||||||
return match io::Error::last_os_error().raw_os_error() {
|
return match io::Error::last_os_error().raw_os_error() {
|
||||||
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
|
||||||
_ => last_error(),
|
_ => last_error(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
|
Ok(())
|
||||||
// SAFETY: `buf` is `len` writable bytes.
|
|
||||||
let read = unsafe {
|
|
||||||
libc::syscall(
|
|
||||||
libc::SYS_keyctl,
|
|
||||||
KEYCTL_READ,
|
|
||||||
id,
|
|
||||||
buf.as_mut_ptr(),
|
|
||||||
buf.len(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if read < 0 {
|
|
||||||
return last_error();
|
|
||||||
}
|
|
||||||
if read as usize <= buf.len() {
|
|
||||||
buf.truncate(read as usize);
|
|
||||||
return Ok(Some(buf));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(io::Error::other("the key changed while it was being read"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the stored secret. Succeeds when there is none.
|
|
||||||
pub fn clear() -> io::Result<()> {
|
|
||||||
let Some(id) = find()? else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
// SAFETY: plain integer arguments.
|
|
||||||
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
|
|
||||||
if rc < 0 {
|
|
||||||
return match io::Error::last_os_error().raw_os_error() {
|
|
||||||
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
|
|
||||||
_ => last_error(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -187,18 +213,21 @@ mod tests {
|
|||||||
// so one test covers the whole round trip and skips when it is unavailable.
|
// so one test covers the whole round trip and skips when it is unavailable.
|
||||||
#[test]
|
#[test]
|
||||||
fn stored_secret_round_trips_and_clears() {
|
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");
|
eprintln!("kernel keyring unavailable; skipping");
|
||||||
return;
|
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");
|
assert_eq!(saved.as_slice(), b"probe");
|
||||||
|
|
||||||
store(b"replaced").unwrap();
|
PASSPHRASES.store(b"replaced").unwrap();
|
||||||
assert_eq!(load().unwrap().unwrap().as_slice(), b"replaced");
|
assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced");
|
||||||
|
|
||||||
clear().unwrap();
|
PASSPHRASES.clear().unwrap();
|
||||||
assert!(load().unwrap().is_none());
|
assert!(PASSPHRASES.load().unwrap().is_none());
|
||||||
clear().unwrap();
|
PASSPHRASES.clear().unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::shutdown::Shutdown;
|
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
|
/// 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
|
/// 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());
|
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) => {
|
Err(e) => {
|
||||||
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
|
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
|
||||||
tokio::time::sleep(delay).await;
|
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");
|
debug!("no photos volume; photo event sync not started");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Err(error) if session_revoked(&error) => {
|
||||||
|
core.mark_session_expired("read the photos root", &error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
warn!(%error, "reading the photos root failed; retrying");
|
warn!(%error, "reading the photos root failed; retrying");
|
||||||
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
||||||
|
|||||||
@@ -334,6 +334,8 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) ->
|
|||||||
budget: core.cache.budget(),
|
budget: core.cache.budget(),
|
||||||
pins,
|
pins,
|
||||||
online: core.online.load(Ordering::Relaxed),
|
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_uploads: queued.uploads.max(0) as u64 + landing,
|
||||||
pending_changes: queued.changes.max(0) as u64,
|
pending_changes: queued.changes.max(0) as u64,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,17 +347,7 @@ impl Core {
|
|||||||
// its writer has to be noticed.
|
// its writer has to be noticed.
|
||||||
self.sweep_parked_creates();
|
self.sweep_parked_creates();
|
||||||
}
|
}
|
||||||
// Paused, nothing due changes that: sleep until a resume
|
self.wait_for_drain_work_or_due(online && !paused);
|
||||||
// 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();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -732,14 +722,16 @@ impl Core {
|
|||||||
/// Like [`wait_for_drain_work`](Self::wait_for_drain_work), but first
|
/// 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
|
/// checks whether a debounced or backed-off op is due before the idle-poll
|
||||||
/// ceiling. A 2-second debounce must not sleep 30 seconds.
|
/// 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() {
|
/// `claiming` is false while the worker claims nothing (offline or
|
||||||
Ok(Some(ts)) => {
|
/// paused); see [`idle_wait`].
|
||||||
let remaining = (ts - now_millis()).max(0) as u64;
|
fn wait_for_drain_work_or_due(&self, claiming: bool) {
|
||||||
Duration::from_millis(remaining).min(DRAIN_IDLE_POLL)
|
let earliest = if claiming {
|
||||||
}
|
self.db.earliest_due_at().ok().flatten()
|
||||||
_ => DRAIN_IDLE_POLL,
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
|
let timeout = idle_wait(claiming, earliest, now_millis());
|
||||||
sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout);
|
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.
|
/// is, or `Some(None)` for a failure that is not a refusal.
|
||||||
fn refusal(e: &(dyn std::error::Error + 'static)) -> Option<Option<SyncIssue>> {
|
fn refusal(e: &(dyn std::error::Error + 'static)) -> Option<Option<SyncIssue>> {
|
||||||
let proton = proton_error(e)?;
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let ProtonError::Api(api) = proton else {
|
let ProtonError::Api(api) = proton else {
|
||||||
@@ -2833,6 +2827,23 @@ fn sleep_for_drain_work(
|
|||||||
*woken = false;
|
*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>)> {
|
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))?;
|
let entry = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))?;
|
||||||
Some((entry.node.name.clone(), entry.node.parent_uid.clone()))
|
Some((entry.node.name.clone(), entry.node.parent_uid.clone()))
|
||||||
@@ -2858,6 +2869,23 @@ mod tests {
|
|||||||
assert!(started.elapsed() < Duration::from_secs(1));
|
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 {
|
fn pending(kind: &str) -> PendingOp {
|
||||||
PendingOp {
|
PendingOp {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
|
|||||||
used,
|
used,
|
||||||
budget,
|
budget,
|
||||||
online,
|
online,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
pending_uploads,
|
pending_uploads,
|
||||||
pending_changes,
|
pending_changes,
|
||||||
..
|
..
|
||||||
@@ -247,7 +249,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
|
|||||||
);
|
);
|
||||||
let key = format!(
|
let key = format!(
|
||||||
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||||
{used}/{budget}/{online}"
|
{used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
|
||||||
);
|
);
|
||||||
(key, queue)
|
(key, queue)
|
||||||
}
|
}
|
||||||
@@ -325,6 +327,8 @@ mod tests {
|
|||||||
budget: 0,
|
budget: 0,
|
||||||
pins: Vec::new(),
|
pins: Vec::new(),
|
||||||
online: true,
|
online: true,
|
||||||
|
session_expired: false,
|
||||||
|
tokens_unsaved: false,
|
||||||
pending_uploads: 1,
|
pending_uploads: 1,
|
||||||
pending_changes: 0,
|
pending_changes: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1267,8 +1267,12 @@ impl ProtonFs {
|
|||||||
//
|
//
|
||||||
// Complete pending blobs are the common case and can be copied without
|
// Complete pending blobs are the common case and can be copied without
|
||||||
// any network access. An incomplete blob still has gaps referring to
|
// any network access. An incomplete blob still has gaps referring to
|
||||||
// the remote base; stacking another write on it cannot be represented
|
// the remote base, and stacking another write on it cannot be
|
||||||
// safely by WriteHandle, so fail the open rather than risk corruption.
|
// 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
|
// 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
|
// 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();
|
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() {
|
let (file, path) = match self.core.cache.create_scratch() {
|
||||||
Ok(x) => x,
|
Ok(x) => x,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1325,6 +1325,25 @@ impl ProtonFs {
|
|||||||
"copy queued revision into write scratch failed");
|
"copy queued revision into write scratch failed");
|
||||||
break 'install Err(Errno::EIO);
|
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);
|
initial_written.add(0, pending.meta.len);
|
||||||
}
|
}
|
||||||
// Sampled *before* the state lock, never under it: no site in
|
// Sampled *before* the state lock, never under it: no site in
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
//! queued path, and the probe thread here flips it back when Proton answers.
|
//! queued path, and the probe thread here flips it back when Proton answers.
|
||||||
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use parking_lot::{Condvar, Mutex};
|
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::api::ResponseCode;
|
||||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
@@ -54,6 +55,26 @@ pub(crate) struct Link {
|
|||||||
changed: Condvar,
|
changed: Condvar,
|
||||||
/// Folders listed from the DB alone while offline, to relist once back.
|
/// Folders listed from the DB alone while offline, to relist once back.
|
||||||
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
|
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
|
/// 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))
|
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
|
/// 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
|
/// network, or of a session, in which case the mount is now offline and the
|
||||||
/// take its queued path instead of failing.
|
/// caller should take its queued path instead of failing.
|
||||||
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
|
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) {
|
if !is_network_error(e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -172,6 +203,29 @@ impl Core {
|
|||||||
true
|
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
|
/// Flip the mount offline and wake the probe. Only the first caller of an
|
||||||
/// outage logs it; the rest are the same outage.
|
/// outage logs it; the rest are the same outage.
|
||||||
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
|
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`
|
/// Probe until Proton answers, then flip the mount online. Returns `false`
|
||||||
/// if the daemon is stopping.
|
/// 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 {
|
fn probe_until_online(&self) -> bool {
|
||||||
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
||||||
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
||||||
@@ -287,6 +344,10 @@ impl Core {
|
|||||||
if !self.shutdown.sleep(delay) {
|
if !self.shutdown.sleep(delay) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if self.session_expired() {
|
||||||
|
delay = ONLINE_PROBE_MAX;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let probe = self.rt.block_on(async {
|
let probe = self.rt.block_on(async {
|
||||||
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
|
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
@@ -308,6 +369,7 @@ impl Core {
|
|||||||
self.mark_online();
|
self.mark_online();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
debug!(error = %e, ?delay, "online probe failed; still offline");
|
debug!(error = %e, ?delay, "online probe failed; still offline");
|
||||||
delay = next_probe_delay(delay, started.elapsed());
|
delay = next_probe_delay(delay, started.elapsed());
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ pub enum MountOutcome {
|
|||||||
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
||||||
/// The caller may want to remount.
|
/// The caller may want to remount.
|
||||||
Unmounted,
|
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
|
/// 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,
|
drive: &dyn DriveApi,
|
||||||
rt: &tokio::runtime::Handle,
|
rt: &tokio::runtime::Handle,
|
||||||
db: &Db,
|
db: &Db,
|
||||||
) -> std::io::Result<(Node, bool)> {
|
) -> std::io::Result<(Node, Start)> {
|
||||||
let err = match rt.block_on(drive.get_my_files_folder()) {
|
let err = match rt.block_on(drive.get_my_files_folder()) {
|
||||||
Ok(root) => {
|
Ok(root) => {
|
||||||
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
||||||
warn!(error = %e, "persist root uid failed");
|
warn!(error = %e, "persist root uid failed");
|
||||||
}
|
}
|
||||||
return Ok((root, true));
|
return Ok((root, Start::Online));
|
||||||
}
|
}
|
||||||
Err(e) => e,
|
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
|
let cached = db
|
||||||
.state_str(ROOT_UID_KEY)
|
.state_str(ROOT_UID_KEY)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
|
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
|
||||||
match cached {
|
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) => {
|
Some(root) => {
|
||||||
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
|
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}"))),
|
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
|
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
|
||||||
/// caller, so `mount` itself never reads config or the environment.
|
/// caller, so `mount` itself never reads config or the environment.
|
||||||
pub struct MountOptions {
|
pub struct MountOptions {
|
||||||
@@ -393,7 +427,8 @@ pub(crate) fn mount_with(
|
|||||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
// so the age it reports is the daemon's own.
|
// so the age it reports is the daemon's own.
|
||||||
super::diagnostics::start_clock();
|
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();
|
let scope = root.tree_event_scope_id();
|
||||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
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}")))?;
|
.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_generation: Arc::new(AtomicU64::new(0)),
|
||||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||||
online: Arc::new(AtomicBool::new(online)),
|
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)),
|
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
@@ -719,6 +754,18 @@ pub(crate) fn mount_with(
|
|||||||
}
|
}
|
||||||
break MountOutcome::Shutdown;
|
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) => {
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||||
if bg.guard.is_finished() {
|
if bg.guard.is_finished() {
|
||||||
info!("mount ended externally");
|
info!("mount ended externally");
|
||||||
|
|||||||
@@ -451,6 +451,36 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
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]
|
#[test]
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() {
|
fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() {
|
||||||
|
|||||||
@@ -579,6 +579,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
detail: locations_widgets.card_detail.clone(),
|
detail: locations_widgets.card_detail.clone(),
|
||||||
pause: locations_widgets.pause.clone(),
|
pause: locations_widgets.pause.clone(),
|
||||||
paused: Cell::new(false),
|
paused: Cell::new(false),
|
||||||
|
sign_in: locations_widgets.card_sign_in.clone(),
|
||||||
},
|
},
|
||||||
queue: QueueState {
|
queue: QueueState {
|
||||||
group: locations_widgets.queue_group.clone(),
|
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() {
|
if ui_page.session.borrow().is_none() {
|
||||||
return;
|
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.
|
// Activity lives on as the Sync page's History view.
|
||||||
if page == "activity" {
|
if page == "activity" {
|
||||||
ui_page.locations.views.set_visible_child_name("history");
|
ui_page.locations.views.set_visible_child_name("history");
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ pub(crate) struct SyncCard {
|
|||||||
pub(crate) pause: adw::SplitButton,
|
pub(crate) pause: adw::SplitButton,
|
||||||
/// Whether the last status said paused, so the button knows which way to go.
|
/// Whether the last status said paused, so the button knows which way to go.
|
||||||
pub(crate) paused: Cell<bool>,
|
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
|
/// 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_icon: gtk4::Image,
|
||||||
pub(crate) card_title: gtk4::Label,
|
pub(crate) card_title: gtk4::Label,
|
||||||
pub(crate) card_detail: gtk4::Label,
|
pub(crate) card_detail: gtk4::Label,
|
||||||
|
pub(crate) card_sign_in: gtk4::Button,
|
||||||
pub(crate) pause: adw::SplitButton,
|
pub(crate) pause: adw::SplitButton,
|
||||||
pub(crate) queue_group: adw::PreferencesGroup,
|
pub(crate) queue_group: adw::PreferencesGroup,
|
||||||
pub(crate) retry_all: gtk4::Button,
|
pub(crate) retry_all: gtk4::Button,
|
||||||
@@ -134,7 +137,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
|||||||
.build();
|
.build();
|
||||||
add_folder.add_css_class("flat");
|
add_folder.add_css_class("flat");
|
||||||
let transfers_group = build_transfers_group();
|
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 pause = build_pause_button();
|
||||||
let (queue_group, retry_all) = build_queue_group();
|
let (queue_group, retry_all) = build_queue_group();
|
||||||
let conflicts_group = adw::PreferencesGroup::builder()
|
let conflicts_group = adw::PreferencesGroup::builder()
|
||||||
@@ -253,6 +256,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
|||||||
card_icon,
|
card_icon,
|
||||||
card_title,
|
card_title,
|
||||||
card_detail,
|
card_detail,
|
||||||
|
card_sign_in,
|
||||||
pause,
|
pause,
|
||||||
queue_group,
|
queue_group,
|
||||||
retry_all,
|
retry_all,
|
||||||
@@ -290,6 +294,11 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k
|
|||||||
let ui_add = ui.clone();
|
let ui_add = ui.clone();
|
||||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||||
wire_pause(ui);
|
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();
|
let ui_retry = ui.clone();
|
||||||
ui.locations
|
ui.locations
|
||||||
.queue
|
.queue
|
||||||
@@ -379,9 +388,15 @@ pub(crate) fn paint_sync_banner(ui: &Rc<Ui>) {
|
|||||||
state.banner.set_revealed(away && title.is_some());
|
state.banner.set_revealed(away && title.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The status hero: a large state icon, the state in words, and what it
|
/// The status hero: a large state icon, the state in words, what it means,
|
||||||
/// 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) {
|
fn build_sync_card() -> (
|
||||||
|
gtk4::Box,
|
||||||
|
gtk4::Image,
|
||||||
|
gtk4::Label,
|
||||||
|
gtk4::Label,
|
||||||
|
gtk4::Button,
|
||||||
|
) {
|
||||||
let icon = gtk4::Image::builder()
|
let icon = gtk4::Image::builder()
|
||||||
.icon_name("pdfs-sync-symbolic")
|
.icon_name("pdfs-sync-symbolic")
|
||||||
.pixel_size(48)
|
.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);
|
let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18);
|
||||||
card.add_css_class("card");
|
card.add_css_class("card");
|
||||||
card.add_css_class("sync-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(&icon);
|
||||||
card.append(&text);
|
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.
|
/// Pause/Resume, with timed pauses in its menu.
|
||||||
|
|||||||
@@ -836,6 +836,13 @@ pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
|||||||
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
|
.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
|
/// 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
|
/// 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
|
/// 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,
|
failing_error,
|
||||||
paused,
|
paused,
|
||||||
paused_until,
|
paused_until,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
..
|
..
|
||||||
} = status
|
} = status
|
||||||
else {
|
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
|
// 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.
|
// yet, and offline is usually the reason it is still queued.
|
||||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
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 {
|
SyncState::Paused {
|
||||||
until: paused_until,
|
until: paused_until,
|
||||||
queued,
|
queued,
|
||||||
@@ -1294,6 +1307,14 @@ pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
/// What the sidebar's status strip reports, most urgent first.
|
/// What the sidebar's status strip reports, most urgent first.
|
||||||
pub(crate) enum SyncState {
|
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
|
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||||
/// everything else, because it is the reason for everything else.
|
/// everything else, because it is the reason for everything else.
|
||||||
Paused {
|
Paused {
|
||||||
@@ -1328,7 +1349,29 @@ pub(crate) enum SyncState {
|
|||||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||||
let paused = matches!(state, SyncState::Paused { .. });
|
let paused = matches!(state, SyncState::Paused { .. });
|
||||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
||||||
|
let expired = matches!(state, SyncState::SessionExpired { .. });
|
||||||
let (icon, class, title, detail) = match state {
|
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 } => (
|
SyncState::Paused { until, queued } => (
|
||||||
"media-playback-pause-symbolic",
|
"media-playback-pause-symbolic",
|
||||||
Some("warning"),
|
Some("warning"),
|
||||||
@@ -1399,6 +1442,7 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
if let Some(class) = class {
|
if let Some(class) = class {
|
||||||
image.add_css_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_title.set_label(&title);
|
||||||
ui.status.status_detail.set_visible(detail.is_some());
|
ui.status.status_detail.set_visible(detail.is_some());
|
||||||
ui.status
|
ui.status
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ enum Phase {
|
|||||||
Offline,
|
Offline,
|
||||||
/// Changes keep failing; the user has to look.
|
/// Changes keep failing; the user has to look.
|
||||||
Attention,
|
Attention,
|
||||||
|
/// Mounted, but Proton refused the session: only signing in again helps.
|
||||||
|
SessionExpired,
|
||||||
/// Signed in, but no mount daemon is running.
|
/// Signed in, but no mount daemon is running.
|
||||||
Disconnected,
|
Disconnected,
|
||||||
SignedOut,
|
SignedOut,
|
||||||
@@ -60,6 +62,7 @@ impl Phase {
|
|||||||
Phase::Paused => "media-playback-pause-symbolic",
|
Phase::Paused => "media-playback-pause-symbolic",
|
||||||
Phase::Offline => "network-offline-symbolic",
|
Phase::Offline => "network-offline-symbolic",
|
||||||
Phase::Attention => "dialog-warning-symbolic",
|
Phase::Attention => "dialog-warning-symbolic",
|
||||||
|
Phase::SessionExpired => "dialog-password-symbolic",
|
||||||
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
|
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,6 +237,8 @@ fn status_state(
|
|||||||
pending_changes,
|
pending_changes,
|
||||||
failing_ops,
|
failing_ops,
|
||||||
paused,
|
paused,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
..
|
..
|
||||||
} = status
|
} = status
|
||||||
else {
|
else {
|
||||||
@@ -241,14 +246,25 @@ fn status_state(
|
|||||||
};
|
};
|
||||||
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
||||||
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
||||||
let phase = phase_of(
|
let phase = if *session_expired {
|
||||||
paused,
|
Phase::SessionExpired
|
||||||
failing_ops,
|
} else if *tokens_unsaved {
|
||||||
online,
|
Phase::Attention
|
||||||
queued.is_some() || busy(items, jobs),
|
} else {
|
||||||
);
|
phase_of(
|
||||||
|
paused,
|
||||||
|
failing_ops,
|
||||||
|
online,
|
||||||
|
queued.is_some() || busy(items, jobs),
|
||||||
|
)
|
||||||
|
};
|
||||||
DriveState {
|
DriveState {
|
||||||
line: match (online, queued) {
|
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 paused => gettext("Sync paused"),
|
||||||
_ if failing_ops > 0 => ngettext_f(
|
_ if failing_ops > 0 => ngettext_f(
|
||||||
"{n} change needs attention",
|
"{n} change needs attention",
|
||||||
@@ -365,7 +381,7 @@ impl Tray for DriveTray {
|
|||||||
|
|
||||||
fn status(&self) -> Status {
|
fn status(&self) -> Status {
|
||||||
match self.state.phase {
|
match self.state.phase {
|
||||||
Phase::Attention => Status::NeedsAttention,
|
Phase::Attention | Phase::SessionExpired => Status::NeedsAttention,
|
||||||
_ => Status::Active,
|
_ => Status::Active,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,6 +439,9 @@ impl Tray for DriveTray {
|
|||||||
// Nothing to mount without a session; the app's sign-in page is the
|
// Nothing to mount without a session; the app's sign-in page is the
|
||||||
// way in.
|
// way in.
|
||||||
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
|
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(&[]))),
|
_ => 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);
|
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 {
|
fn job(title: &str, background: bool) -> JobItem {
|
||||||
JobItem {
|
JobItem {
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const STOCK: &[&str] = &[
|
|||||||
"checkbox-symbolic",
|
"checkbox-symbolic",
|
||||||
"computer-symbolic",
|
"computer-symbolic",
|
||||||
"dialog-error-symbolic",
|
"dialog-error-symbolic",
|
||||||
|
"dialog-password-symbolic",
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
"document-edit-symbolic",
|
"document-edit-symbolic",
|
||||||
"document-open-recent-symbolic",
|
"document-open-recent-symbolic",
|
||||||
|
|||||||
+178
-2
@@ -73,7 +73,6 @@ original wording.
|
|||||||
| [B67](#b67--fresh-state-restore-can-silently-select-a-new-device-high-22) | Fresh-state restore can silently select a new device (HIGH-22) | An ambiguous device after a loss of local state is not asked about |
|
| [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` |
|
| [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 |
|
| [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 |
|
| [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 | |
|
| [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 |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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) |
|
| [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) |
|
| [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) |
|
| [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) |
|
| [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.
|
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
|
### B199 — A stopped share stays on the Shared by me page for minutes
|
||||||
|
|
||||||
**Status:** Partly fixed. The share itself is still not deleted.
|
**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)
|
### 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
|
**Found:** 2026-07-22, 1.0 authentication audit
|
||||||
**Where:** `crates/pdfs-core/src/auth.rs`, refresh callback
|
**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/
|
notify the user, and define a safe re-login/shutdown path. Test locked/full/
|
||||||
unavailable keyrings, callback races, repeated rotations, death, and restart.
|
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)
|
### B60 — Config writes are non-atomic and parse errors are overwritten (HIGH-17)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- **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
|
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.
|
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
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
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
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=3.1.2
|
provides = proton-drive-for-linux=3.2.0
|
||||||
provides = proton-drive-linux=3.1.2
|
provides = proton-drive-linux=3.2.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
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 = 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.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-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.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-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.1.2.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.svg
|
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.1.2.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/proton-drive.service
|
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.1.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/LICENSE
|
source = LICENSE-3.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/LICENSE
|
||||||
sha256sums = 71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7
|
sha256sums = 3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=3.1.2
|
pkgver=3.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
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"
|
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7'
|
sha256sums=('3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (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
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
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-for-linux=3.2.0.r0.g4c498d5
|
||||||
provides = proton-drive-linux=3.1.2.r0.g07c9b67
|
provides = proton-drive-linux=3.2.0.r0.g4c498d5
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=3.1.2.r0.g07c9b67
|
pkgver=3.2.0.r0.g4c498d5
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
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
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
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
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
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
|
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 = 260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1
|
sha256sums = a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=3.1.2
|
pkgver=3.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
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.
|
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||||
options=('!lto')
|
options=('!lto')
|
||||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1')
|
sha256sums=('a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
+301
-264
File diff suppressed because it is too large.
Load diff
+305
-268
File diff suppressed because it is too large.
Load diff
+301
-264
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user