Compare commits

...
7 Commits
Author SHA1 Message Date
nvrl 470948c191 chore(version) bumped to 1.10.2 2026-09-16 23:05:09 +02:00
nvrl b095b9f6b0 fix(core,fuse): never stat a foreign mount from the local-file indexer
The "This computer" indexer walks $HOME, and `default_excludes` only kept it
out of the Drive mountpoint and our own state/cache dirs. A home directory
that also holds third-party FUSE mounts (sshfs, rclone) got walked into.

`scan` does ask for `same_file_system(true)`, which looks sufficient and is
not: `ignore` implements it as `is_same_file_system` -> `device_num(path)` ->
`path.metadata()`, run after the directory is queued. Learning that a mount is
foreign requires stat'ing it first, and against a mount whose server is gone
that stat never returns.

The consequence is worse than a stalled scan. The blocked thread sits in
uninterruptible FUSE wait, so it ignores SIGKILL: the daemon can never exit,
systemd's stop times out, and the unreapable process keeps the cache.db lock
the next start needs, turning one dead mount into a permanent restart loop.
No userspace recovery exists once a thread is in that state, so the fix has to
be to never enter it.

Exclude every mountpoint strictly below the scan root, read by path from
/proc/self/mounts (octal-unescaped, so a path with a space still matches).
`filter_entry` runs before the device stat, so the walker never touches them.
Re-read per scan rather than cached at startup: a mount that appeared since
the daemon started is the likeliest to be half-alive.

This does not change what is indexed — a nested mount is a different device,
so it was already going to be skipped. Only the stat in front of that decision
goes away.

Refs: docs/BUGS.md B90
2026-09-16 23:05:06 +02:00
nvrl f4d6580cd3 chore(version) bumped to 1.10.1 2026-09-15 15:13:22 +02:00
nvrl b2f62f70e1 chore(deps,core,gui): upgrade dependencies to latest
- keyring 3 -> keyring-core 1 + dbus-secret-service-keyring-store 1
  (crypto-rust). Same libdbus Secret Service backend; with no target the
  store matches items keyring 3 wrote, so stored sessions survive.
- ksni 0.2 -> 0.3 via the blocking API, with assume_sni_available so a
  tray that starts before the SNI watcher waits instead of failing. Drops
  the clap 2 / atty chain (Dependabot atty advisory).
- gtk4 0.11, libadwaita 0.9, webkit6 0.6, rusqlite 0.40, fuser 0.18,
  directories 6, sha1/sha2 0.11, base64 0.23.
2026-09-15 15:07:00 +02:00
nvrl dcb2ab7fac chore(version) bumped to 1.10.0 2026-09-15 13:59:24 +02:00
nvrl eb56f4a1e0 feat(fuse,core,gui,cli): one-request move+rename drain, show who shared an item
- A queued rename that also changes folder now lands as one `move-multiple`
  request carrying the target name instead of rename-then-move, so there is
  no half-applied state and no second call for stale name requirements to
  hit (B46 follow-up). A destination name collision moves under a conflict
  name in one step; same-folder renames still use the rename endpoint.
- Shared with me shows the inviter and invite date (GUI subtitle, `pdfs
  shared-with-me`), with a warning when the invitation signature does not
  verify against the claimed inviter's keys. `DirEntry` gains `shared_by`,
  `shared_at` and `shared_by_unverified`, all serde-defaulted.
- Bump `proton-sdk` / `proton-drive-rs` to 0.6.3 (anonymous-node moves,
  `TrashTime` trashed detection).
2026-09-15 13:59:13 +02:00
nvrl c28803b037 chore(changelog) updated changelog for 1.9.2 and 1.9.3 2026-09-08 14:58:48 +02:00
25 changed files with 1114 additions and 870 deletions

No files matched your search

Generated
+512 -793
View File
File diff suppressed because it is too large. Load diff
+14 -10
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "1.9.3" version = "1.10.2"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
@@ -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.6.2" } proton-sdk = { version = "0.6.4" }
proton-drive-rs = { version = "0.6.2" } proton-drive-rs = { version = "0.6.4" }
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
@@ -25,7 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.23"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not # Non-poisoning mutexes. A handler that panics while holding a lock must not
@@ -35,24 +35,28 @@ anyhow = "1"
parking_lot = "0.12" parking_lot = "0.12"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5" directories = "6"
ignore = "0.4" ignore = "0.4"
sha2 = "0.10" sha2 = "0.11"
# Google Takeout exports are zip sets; the importer reads their central # Google Takeout exports are zip sets; the importer reads their central
# directories and streams entries out without extracting to disk first. # directories and streams entries out without extracting to disk first.
zip = { version = "8", default-features = false, features = ["deflate"] } zip = { version = "8", default-features = false, features = ["deflate"] }
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an # Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
# import has to speak SHA-1 whatever its age. # import has to speak SHA-1 whatever its age.
sha1 = "0.10" sha1 = "0.11"
# Thumbnail generation for photos the server has none for. Decoders only for the # Thumbnail generation for photos the server has none for. Decoders only for the
# formats a camera or phone actually produces — the rest of the crate's codecs # formats a camera or phone actually produces — the rest of the crate's codecs
# would be dead weight in the daemon. # would be dead weight in the daemon.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.40", features = ["bundled"] }
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } # keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
# with no `target` it matches items v3 wrote, so existing logins survive.
keyring-core = "1"
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
rpassword = "7" rpassword = "7"
async-trait = "0.1" async-trait = "0.1"
futures = "0.3" futures = "0.3"
fuser = "0.17" fuser = "0.18"
libc = "0.2" libc = "0.2"
+71
View File
@@ -1129,6 +1129,9 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
format!(" ({})", e.role) format!(" ({})", e.role)
}; };
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid); println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid);
if let Some(line) = shared_by_line(&e) {
println!("{:>15}{line}", "");
}
} }
} }
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
@@ -1137,6 +1140,39 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
Ok(()) Ok(())
} }
/// Who shared an entry with me, and when, as a line printed under it — `None`
/// when the invitation said nothing (owned content, or a node inside a shared
/// folder rather than its root). An inviter whose signature did not verify is
/// said to be claimed, not stated: the name may be forged.
fn shared_by_line(entry: &pdfs_core::control::DirEntry) -> Option<String> {
if entry.shared_by.is_empty() {
return None;
}
let mut line = format!("shared by {}", entry.shared_by);
if entry.shared_at > 0 {
line.push_str(&format!(" at {}", format_epoch_utc(entry.shared_at)));
}
if entry.shared_by_unverified {
line.push_str(" WARNING: invitation signature did not verify; the sender may be forged");
}
Some(line)
}
/// `YYYY-MM-DD` (UTC) for epoch seconds, without pulling a date crate into the
/// CLI for one column. Civil-from-days per Howard Hinnant's algorithm.
fn format_epoch_utc(secs: i64) -> String {
let z = secs.div_euclid(86_400) + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}")
}
/// Download a file shared with me. The daemon puts the plaintext in its content /// Download a file shared with me. The daemon puts the plaintext in its content
/// cache and replies with that path; with a `dest` we copy it out, so the caller /// cache and replies with that path; with a `dest` we copy it out, so the caller
/// gets a file it owns rather than one the cache may later evict. /// gets a file it owns rather than one the cache may later evict.
@@ -2805,4 +2841,39 @@ mod login_wait_tests {
wait_for_session(|| Ok(()), |_| slept += 1).unwrap(); wait_for_session(|| Ok(()), |_| slept += 1).unwrap();
assert_eq!(slept, 0); assert_eq!(slept, 0);
} }
#[test]
fn epoch_seconds_format_as_utc_calendar_dates() {
assert_eq!(format_epoch_utc(0), "1970-01-01");
assert_eq!(format_epoch_utc(951_782_400), "2000-02-29");
assert_eq!(format_epoch_utc(1_700_000_000), "2023-11-14");
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
}
#[test]
fn a_forged_looking_invitation_is_flagged_not_stated() {
let mut entry = pdfs_core::control::DirEntry {
name: "Budget.ods".into(),
is_dir: false,
size: 1,
modified: 0,
pinned: false,
cached: false,
uid: "vol~link".into(),
path: String::new(),
role: "viewer".into(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
};
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
entry.shared_by = "alice@proton.me".into();
entry.shared_at = 1_700_000_000;
assert_eq!(
shared_by_line(&entry).as_deref(),
Some("shared by alice@proton.me at 2023-11-14")
);
entry.shared_by_unverified = true;
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
}
} }
+2 -1
View File
@@ -23,7 +23,8 @@ sha2.workspace = true
sha1.workspace = true sha1.workspace = true
zip.workspace = true zip.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
keyring.workspace = true keyring-core.workspace = true
dbus-secret-service-keyring-store.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
async-trait.workspace = true async-trait.workspace = true
libc.workspace = true libc.workspace = true
+8 -3
View File
@@ -6,7 +6,7 @@
//! interactive step; the refresh token auto-renews via the HTTP client's 401 //! interactive step; the refresh token auto-renews via the HTTP client's 401
//! path, so no fresh 2FA is required until the refresh token itself expires. //! path, so no fresh 2FA is required until the refresh token itself expires.
use keyring::Entry; use keyring_core::Entry;
use proton_drive_rs::{KeySalt, ProtonDriveClient}; use proton_drive_rs::{KeySalt, ProtonDriveClient};
use proton_sdk::api::{HumanVerification, HumanVerificationCredential}; use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
use proton_sdk::cache::EncryptedCacheRepository; use proton_sdk::cache::EncryptedCacheRepository;
@@ -81,6 +81,11 @@ fn client_config() -> ProtonClientConfiguration {
} }
fn keyring_entry() -> Result<Entry> { fn keyring_entry() -> Result<Entry> {
// Installed lazily rather than once at startup so a session bus that was not
// up yet on the first attempt is retried on the next one.
if keyring_core::get_default_store().is_none() {
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
}
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?) Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
} }
@@ -215,7 +220,7 @@ pub fn load() -> Result<StoredSession> {
let entry = keyring_entry()?; let entry = keyring_entry()?;
match entry.get_password() { match entry.get_password() {
Ok(json) => Ok(serde_json::from_str(&json)?), Ok(json) => Ok(serde_json::from_str(&json)?),
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn), Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
@@ -238,7 +243,7 @@ pub fn logout() -> Result<()> {
} }
} }
match keyring_entry()?.delete_credential() { match keyring_entry()?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
+16
View File
@@ -906,6 +906,22 @@ pub struct DirEntry {
/// report. Defaulted for wire-compat with clients/daemons predating it. /// report. Defaulted for wire-compat with clients/daemons predating it.
#[serde(default)] #[serde(default)]
pub role: String, pub role: String,
/// Who shared this node with me, as the invitation claims it — empty for
/// content I own, for a node reached *inside* a shared folder (only the share
/// root carries the invitation), and when the API did not say. Trust it only
/// as far as [`shared_by_unverified`](Self::shared_by_unverified) allows.
/// Defaulted for wire-compat with clients/daemons predating it.
#[serde(default)]
pub shared_by: String,
/// When it was shared with me, epoch seconds; 0 when unknown.
#[serde(default)]
pub shared_at: i64,
/// The invitation's signature did not check out against `shared_by`'s keys,
/// so the name may be forged. False when it verified *and* when it could not
/// be checked at all (no signature, no keys): only an actual mismatch is a
/// warning worth showing.
#[serde(default)]
pub shared_by_unverified: bool,
} }
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the /// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
+4
View File
@@ -3201,6 +3201,8 @@ fn test_db_has_children_and_trashed_filtering() {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
}; };
db.upsert_node(&parent_node).unwrap(); db.upsert_node(&parent_node).unwrap();
@@ -3227,6 +3229,8 @@ fn test_db_has_children_and_trashed_filtering() {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
}; };
db.upsert_node(&child_node).unwrap(); db.upsert_node(&child_node).unwrap();
+1 -1
View File
@@ -11,7 +11,7 @@ pub enum Error {
Proton(#[from] proton_sdk::ProtonError), Proton(#[from] proton_sdk::ProtonError),
#[error("keyring: {0}")] #[error("keyring: {0}")]
Keyring(#[from] keyring::Error), Keyring(#[from] keyring_core::Error),
#[error("serialization: {0}")] #[error("serialization: {0}")]
Serde(#[from] serde_json::Error), Serde(#[from] serde_json::Error),
+101
View File
@@ -199,6 +199,9 @@ fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
/// The paths a scan should never descend into: the Drive mountpoint (walking it /// The paths a scan should never descend into: the Drive mountpoint (walking it
/// would fault every remote node in through FUSE) plus our own state and cache /// would fault every remote node in through FUSE) plus our own state and cache
/// dirs, which hold blobs no user searches for by name. /// dirs, which hold blobs no user searches for by name.
///
/// Callers should extend this with [`nested_mount_points`] for each scan root —
/// see that function for why `same_file_system(true)` is not enough on its own.
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> { pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
vec![ vec![
mountpoint.to_path_buf(), mountpoint.to_path_buf(),
@@ -207,6 +210,66 @@ pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -
] ]
} }
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
///
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
/// the directory is queued — so the walker has to touch a foreign mount to learn
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
/// the half-dead process keeps the cache.db lock that the next start needs
/// (docs/BUGS.md B90).
///
/// Excluding the mountpoints by path avoids that stat entirely. It does not
/// change what ends up indexed: a nested mount is a different device, so
/// `same_file_system(true)` was already going to skip it.
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
return Vec::new();
};
parse_nested_mount_points(&mounts, root)
}
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
/// be tested without a real `/proc`.
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
mounts
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.map(|field| PathBuf::from(unescape_mount_field(field)))
// Strictly below: `root` itself being a mountpoint must not exclude the
// whole scan.
.filter(|path| path.starts_with(root) && path != root)
.collect()
}
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
/// real path, silently putting a mount with a space in its name back in the walk.
fn unescape_mount_field(field: &str) -> String {
let mut out = String::with_capacity(field.len());
let mut rest = field;
while let Some(at) = rest.find('\\') {
out.push_str(&rest[..at]);
let escape = rest[at + 1..].get(..3);
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
Some(byte) => {
out.push(byte as char);
rest = &rest[at + 4..];
}
// Not an octal escape after all — keep the backslash verbatim.
None => {
out.push('\\');
rest = &rest[at + 1..];
}
}
}
out.push_str(rest);
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -268,4 +331,42 @@ mod tests {
std::fs::remove_dir_all(&tmp).unwrap(); std::fs::remove_dir_all(&tmp).unwrap();
} }
/// A dead network FUSE mount under the scan root must be skipped by *path*,
/// before anything stats it — see `nested_mount_points`. The scan root itself
/// being a mountpoint must not exclude the entire walk.
#[test]
fn nested_mount_points_are_excluded_but_the_root_mount_is_not() {
let mounts = "\
/dev/sda2 /home ext4 rw 0 0
narl@narl.io:/opt /home/narl/remote/narl.io fuse.sshfs rw 0 0
google_drive: /home/narl/remote/gdrive fuse.rclone rw 0 0
tmpfs /run/user/1000 tmpfs rw 0 0
";
let nested = parse_nested_mount_points(mounts, Path::new("/home/narl"));
assert_eq!(
nested,
vec![
PathBuf::from("/home/narl/remote/narl.io"),
PathBuf::from("/home/narl/remote/gdrive"),
]
);
// `/home` is an ancestor, not a descendant: excluding it would empty the
// scan. `/run/user/1000` is unrelated.
assert!(parse_nested_mount_points(mounts, Path::new("/home")).len() == 2);
}
/// `/proc/self/mounts` octal-escapes a space; leaving it escaped would put a
/// mount whose path contains one back into the walk.
#[test]
fn mount_paths_are_unescaped() {
let mounts = "srv:/x /home/narl/My\\040Cloud fuse.sshfs rw 0 0\n";
assert_eq!(
parse_nested_mount_points(mounts, Path::new("/home/narl")),
vec![PathBuf::from("/home/narl/My Cloud")]
);
assert_eq!(unescape_mount_field("/a\\134b"), "/a\\b");
assert_eq!(unescape_mount_field("/plain/path"), "/plain/path");
}
} }
+8 -1
View File
@@ -487,7 +487,12 @@ fn scan_local_once(
} }
}; };
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir()); let mut excludes =
localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
// Resolved per scan rather than once at startup: mounts come and go, and a
// mount that appeared since the daemon started is exactly the one most
// likely to be a half-alive network filesystem (see `nested_mount_points`).
excludes.extend(localindex::nested_mount_points(&home));
indexing.store(true, Ordering::Relaxed); indexing.store(true, Ordering::Relaxed);
let started = Instant::now(); let started = Instant::now();
@@ -541,6 +546,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+90 -42
View File
@@ -29,12 +29,14 @@ use std::time::{Duration, Instant};
use parking_lot::Mutex; use parking_lot::Mutex;
use futures::StreamExt;
use pdfs_core::batch; use pdfs_core::batch;
use pdfs_core::cache::{Baseline, StagedWrite}; use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection}; use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta}; use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
use proton_drive_rs::Node; use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{Node, NodeMoveItem};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use super::state::Intervals; use super::state::Intervals;
@@ -642,61 +644,46 @@ impl Core {
return Ok(()); return Ok(());
} }
}; };
// The name half goes first, so that a collision on the move half below is let landed = if node.parent_uid.as_ref() == Some(&parent) {
// about the name the node will actually land under rather than the one it self.drain_rename_in_place(&uid, &node.name, &name)?
// is about to lose. } else {
let mut landed = node.name.clone(); // Move and rename land as one request (`move-multiple` takes a
if landed != name { // target name), so there is no half-applied state between them and
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) { // no window for the requirements to go stale between two calls —
Ok(()) => landed = name.clone(), // the race B46 queued this op to get away from.
// Someone took the name while we were offline. Renaming to a let target = (node.name != name).then_some(name.as_str());
match self.move_renaming(&uid, &parent, target) {
Ok(()) => name.clone(),
// The destination holds that name already. Landing under a
// *different* name is the non-destructive resolution: it neither // *different* name is the non-destructive resolution: it neither
// clobbers their file nor drops ours, and it is visible. // clobbers their file nor drops ours, and it is visible.
Err(e) if is_already_exists(&e) => { Err(e) if is_already_exists(&e) => {
let alt = conflict_name(&name, now_secs()); let alt = conflict_name(&name, now_secs());
warn!(%uid, name, alt, "rename target name is taken; using a conflict name"); warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
self.rt self.move_renaming(&uid, &parent, Some(&alt))?;
.block_on(self.client.rename_node(&uid, &alt, None))?;
landed = alt.clone();
self.adopt_drained_name(&uid, &alt); self.adopt_drained_name(&uid, &alt);
self.log_activity( self.log_activity(
ActivityKind::Rename, ActivityKind::Rename,
&name, &name,
format!("name was taken remotely; renamed to {alt}"),
false,
);
}
Err(e) => return Err(e.into()),
}
}
if node.parent_uid.as_ref() != Some(&parent) {
match self.rt.block_on(self.client.move_node(&uid, &parent)) {
Ok(()) => {}
// The destination holds a `landed` of its own. Same resolution as
// a name collision, and it has to happen before the move: the API
// has no move-and-rename, so the node is renamed out of the way
// here and moved second.
Err(e) if is_already_exists(&e) => {
let alt = conflict_name(&landed, now_secs());
warn!(%uid, name = %landed, alt, "destination already holds that name; using a conflict name");
self.rt
.block_on(self.client.rename_node(&uid, &alt, None))?;
self.rt.block_on(self.client.move_node(&uid, &parent))?;
self.adopt_drained_name(&uid, &alt);
self.log_activity(
ActivityKind::Rename,
&landed,
format!("destination already had that name; moved as {alt}"), format!("destination already had that name; moved as {alt}"),
false, false,
); );
alt
} }
// The destination folder is gone. Leaving the node in its current // The destination folder is gone. Leaving the node in its current
// parent is the honest outcome: it is not where the user asked for // parent is the honest outcome: it is not where the user asked for
// it, but it exists, it is where it has always been, and the // it, but it exists and it is where it has always been. The name
// rename half above still applied. Retrying could only fail again // the user chose still applies there. Retrying the move could
// — the folder is not coming back — and would wedge the queue. // only fail again — the folder is not coming back — and would
// wedge the queue.
Err(e) if is_gone(&e) => { Err(e) if is_gone(&e) => {
warn!(%uid, name = %landed, %parent, "move destination is gone; leaving the node where it is"); warn!(%uid, name, %parent, "move destination is gone; leaving the node where it is");
let landed = match self.drain_rename_in_place(&uid, &node.name, &name) {
Ok(landed) => landed,
// The node went with it; nothing is left to rename.
Err(e) if is_gone(&e) => node.name.clone(),
Err(e) => return Err(e.into()),
};
self.log_activity( self.log_activity(
ActivityKind::Rename, ActivityKind::Rename,
&landed, &landed,
@@ -704,16 +691,73 @@ impl Core {
.to_string(), .to_string(),
false, false,
); );
landed
} }
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
} }
} };
self.db.delete_op(op.id)?; self.db.delete_op(op.id)?;
self.note_self_change(&uid); self.note_self_change(&uid);
info!(%uid, name = %landed, "pending rename landed"); info!(%uid, name = %landed, "pending rename landed");
Ok(()) Ok(())
} }
/// Rename `uid` without moving it, from `current` to `name`, and return the
/// name it landed under. Someone may have taken the name while we were
/// offline; the node then lands under a conflict name instead.
fn drain_rename_in_place(
&self,
uid: &NodeUid,
current: &str,
name: &str,
) -> Result<String, ProtonError> {
if current == name {
return Ok(current.to_string());
}
match self.rt.block_on(self.client.rename_node(uid, name, None)) {
Ok(()) => Ok(name.to_string()),
Err(e) if is_already_exists(&e) => {
let alt = conflict_name(name, now_secs());
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
self.rt.block_on(self.client.rename_node(uid, &alt, None))?;
self.adopt_drained_name(uid, &alt);
self.log_activity(
ActivityKind::Rename,
name,
format!("name was taken remotely; renamed to {alt}"),
false,
);
Ok(alt)
}
Err(e) => Err(e),
}
}
/// Move `uid` under `parent` in one request, renaming it to `target_name` on
/// the way when one is given.
fn move_renaming(
&self,
uid: &NodeUid,
parent: &NodeUid,
target_name: Option<&str>,
) -> Result<(), ProtonError> {
let item = NodeMoveItem {
uid: uid.clone(),
target_name: target_name.map(str::to_string),
};
self.rt.block_on(async {
let mut outcomes =
std::pin::pin!(self.client.move_nodes_streaming(vec![item], parent.clone()));
match outcomes.next().await {
Some(Ok((_, outcome))) => outcome,
Some(Err(e)) => Err(e),
None => Err(ProtonError::invalid_operation(format!(
"move of {uid} reported no outcome"
))),
}
})
}
/// Apply a queued trash to the remote. /// Apply a queued trash to the remote.
/// ///
/// A node that is already gone is a success, not a failure: the outcome the /// A node that is already gone is a success, not a failure: the outcome the
@@ -1587,6 +1631,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -1879,6 +1925,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+15
View File
@@ -3738,6 +3738,9 @@ impl Core {
// My own tree: a role only means something under a share, and // My own tree: a role only means something under a share, and
// this listing never crosses into one. // this listing never crosses into one.
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}) })
.collect()) .collect())
} }
@@ -4319,6 +4322,9 @@ impl Core {
uid: item.uid, uid: item.uid,
path: String::new(), path: String::new(),
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}) })
.collect()) .collect())
} }
@@ -4994,6 +5000,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
album: None, album: None,
// Nothing signed it: it has never been near the crypto layer. // Nothing signed it: it has never been near the crypto layer.
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -5918,6 +5926,8 @@ mod pending_size_tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -7128,6 +7138,9 @@ mod tests {
share_id, share_id,
membership_id: ShareMembershipId::from("membership"), membership_id: ShareMembershipId::from("membership"),
permissions, permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
}); });
node node
}; };
@@ -7588,6 +7601,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
} }
+16
View File
@@ -15,6 +15,7 @@ use pdfs_core::{CoreError, CoreResult};
use tracing::debug; use tracing::debug;
use crate::QUOTA_TTL; use crate::QUOTA_TTL;
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{MemberRole, NodeKind}; use proton_drive_rs::{MemberRole, NodeKind};
@@ -47,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
.map(role_to_str) .map(role_to_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
let invitation = n.membership.as_ref();
let shared_by = invitation
.and_then(|m| m.inviter_email.clone())
.unwrap_or_default();
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
// Only a signature that is present and wrong is a warning. An invitation
// that could not be checked — unsigned, or no keys for the claimed inviter —
// is not evidence of forgery, and flagging every such share would teach the
// user to ignore the flag.
let shared_by_unverified = invitation
.and_then(|m| m.inviter_verification)
.is_some_and(|status| status == VerificationStatus::Failed);
DirEntry { DirEntry {
name: n.name, name: n.name,
is_dir, is_dir,
@@ -57,6 +70,9 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(), path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
uid: n.uid.to_string(), uid: n.uid.to_string(),
role, role,
shared_by,
shared_at,
shared_by_unverified,
} }
} }
+5
View File
@@ -1120,6 +1120,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -1128,6 +1130,9 @@ mod tests {
share_id: ShareId::from("share"), share_id: ShareId::from("share"),
membership_id: ShareMembershipId::from("membership"), membership_id: ShareMembershipId::from("membership"),
permissions, permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
} }
} }
+2
View File
@@ -368,6 +368,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+2
View File
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+4 -4
View File
@@ -29,9 +29,9 @@ anyhow.workspace = true
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
ksni = "0.2" ksni = { version = "0.3", features = ["blocking"] }
gtk4 = { version = "0.9", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] } adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
async-channel = "2" async-channel = "2"
exif = { version = "0.6", package = "kamadak-exif" } exif = { version = "0.6", package = "kamadak-exif" }
webkit6 = "0.4" webkit6 = "0.6"
+3
View File
@@ -2345,6 +2345,9 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
uid: h.uid.clone(), uid: h.uid.clone(),
path: h.path.clone(), path: h.path.clone(),
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}) })
.collect(); .collect();
entries.sort_by(|a, b| { entries.sort_by(|a, b| {
+48 -4
View File
@@ -309,6 +309,17 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
if let Some(badge) = role_badge(&entry.role) { if let Some(badge) = role_badge(&entry.role) {
row.add_suffix(&badge); row.add_suffix(&badge);
} }
// The "shared by" in the subtitle is what the invitation claims. When its
// signature does not check out against that person's keys, the claim must
// not read as fact — someone may be impersonating a contact to get a file
// opened.
if entry.shared_by_unverified {
let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic");
warning.add_css_class("warning");
warning.set_valign(gtk4::Align::Center);
warning.set_tooltip_text(Some(UNVERIFIED_INVITER_TOOLTIP));
row.add_suffix(&warning);
}
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true)); row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
let ui_act = ui.clone(); let ui_act = ui.clone();
let uid = entry.uid.clone(); let uid = entry.uid.clone();
@@ -329,12 +340,29 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
row row
} }
/// A shared row's subtitle: its size (files only), and where it can be reached /// Tooltip on the warning shown next to a share whose invitation did not verify.
/// in the local mount once the synthetic `Shared with me/` directory has interned const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \
/// it. An unlisted share has no local path yet, and says nothing rather than It may not really be from them.";
/// pointing at a directory that is not there.
/// A shared row's subtitle: who shared it and when (share roots only — that is
/// where the invitation lives), its size (files only), and where it can be
/// reached in the local mount once the synthetic `Shared with me/` directory has
/// interned it. An unlisted share has no local path yet, and says nothing rather
/// than pointing at a directory that is not there.
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String { pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
if !entry.shared_by.is_empty() {
let date = if entry.shared_at > 0 {
format_modified(entry.shared_at)
} else {
String::new()
};
parts.push(if date.is_empty() {
format!("Shared by {}", entry.shared_by)
} else {
format!("Shared by {} on {date}", entry.shared_by)
});
}
if !entry.is_dir { if !entry.is_dir {
parts.push(human_bytes(entry.size)); parts.push(human_bytes(entry.size));
} }
@@ -723,9 +751,25 @@ mod tests {
uid: "vol~link".into(), uid: "vol~link".into(),
path: path.into(), path: path.into(),
role: role.into(), role: role.into(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
} }
} }
#[test]
fn a_share_root_says_who_shared_it() {
let mut root = entry(true, "", "viewer");
root.shared_by = "alice@proton.me".into();
assert_eq!(shared_row_subtitle(&root), "Shared by alice@proton.me");
root.shared_at = 1_700_000_000;
let subtitle = shared_row_subtitle(&root);
assert!(
subtitle.starts_with("Shared by alice@proton.me on "),
"{subtitle}"
);
}
#[test] #[test]
fn a_share_with_no_local_path_says_nothing_about_one() { fn a_share_with_no_local_path_says_nothing_about_one() {
// Before the synthetic directory has been listed, the daemon cannot // Before the synthetic directory has been listed, the daemon cannot
@@ -293,5 +293,8 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
path: item.path.clone(), path: item.path.clone(),
// Shared *by* me: I own it, so there is no role of mine to report. // Shared *by* me: I own it, so there is no role of mine to report.
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
} }
} }
+9 -8
View File
@@ -14,8 +14,9 @@ use std::process::Command;
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use ksni::blocking::TrayMethods;
use ksni::menu::StandardItem; use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService}; use ksni::{MenuItem, Tray};
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
@@ -282,13 +283,13 @@ fn main() -> Result<()> {
state: poll_state(&socket, &default_mountpoint), state: poll_state(&socket, &default_mountpoint),
}; };
let service = TrayService::new(tray); // The tray autostarts with the session and can beat the desktop's SNI
let handle = service.handle(); // watcher onto the bus. Without `assume_sni_available` that race is a hard
std::thread::spawn(move || { // spawn error; with it the item waits and registers once the watcher shows.
if let Err(e) = service.run() { let handle = tray
tracing::error!("tray service stopped: {e:?}"); .assume_sni_available(true)
} .spawn()
}); .context("start tray service")?;
// Poll the daemon forever, pushing each fresh snapshot into the tray so the // Poll the daemon forever, pushing each fresh snapshot into the tray so the
// menu reflects mount/login changes made elsewhere (e.g. via the CLI). // menu reflects mount/login changes made elsewhere (e.g. via the CLI).
+1 -1
View File
@@ -1364,7 +1364,7 @@ fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
if is_dir { if is_dir {
return gio::ThemedIcon::new("folder").upcast(); return gio::ThemedIcon::new("folder").upcast();
} }
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), &[]); let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
gio::functions::content_type_get_icon(&content_type) gio::functions::content_type_get_icon(&content_type)
} }
+83
View File
@@ -12,6 +12,79 @@ Conventions:
--- ---
## B90 — The local-file indexer walks into foreign FUSE mounts, and a dead one makes the daemon unkillable
**Status:** Fixed (unverified) — the walker no longer stats a nested mountpoint. The stuck-daemon
half cannot be fixed in userspace at all; see "Why there is no second line of defence".
**Found:** 2026-09-16, from a user report of `systemctl --user restart` failing in a loop.
**Where:** `crates/pdfs-core/src/localindex.rs` (`default_excludes`, `scan`),
`crates/pdfs-fuse/src/background.rs` (`scan_local_once`).
**Symptom.** Stopping the service times out, SIGKILL does not help, and every subsequent start
fails with `another Proton Drive daemon is already using .../cache.db`:
```
systemd[2196]: proton-drive.service: State 'final-sigterm' timed out. Killing.
systemd[2196]: proton-drive.service: Killing process 2221 (pdfs) with signal SIGKILL.
systemd[2196]: proton-drive.service: Processes still around after final SIGKILL. Entering failed mode.
systemd[2196]: proton-drive.service: Unit process 2221 (pdfs) remains running after unit stopped.
pdfs[73921]: ERROR mount failed; retrying in 5s error="open cache db: another Proton Drive daemon
is already using /home/narl/.local/state/proton-drive-linux/cache.db"
```
**Diagnosis.** The surviving process is a zombie that still has a live thread:
```
$ cat /proc/2221/status | grep -E 'State|Threads'
State: Z (zombie)
Threads: 2
$ cat /proc/2221/task/73231/comm; cat /proc/2221/task/73231/stack
pdfs-localindex
request_wait_answer
```
`pdfs-localindex` is parked in a FUSE request nobody will answer, in uninterruptible `D` state, so
it ignores SIGKILL. The thread group leader exits, the thread does not, the process cannot be
reaped — and it keeps the `cache.db` lock, which is what turns one stuck scan into a permanent
restart loop. The same dead connection also blocked an unrelated `duf` in `fuse_statfs`
(`/sys/fs/fuse/connections/69/waiting` was 2).
**Cause.** `scan_local_once` walks `$HOME`, and `default_excludes` only excluded the Drive
mountpoint plus our own state and cache dirs. The reporter's home also held
`~/remote/narl.io` (sshfs) and `~/remote/gdrive` (rclone) — third-party FUSE mounts the walk
descends into.
`scan` does set `same_file_system(true)`, which looks like it should already cover this. It does
not: in `ignore` 0.4.33 the check is `is_same_file_system` → `device_num(path)` →
`path.metadata()` (`walk.rs:2164`), i.e. **a stat of the mountpoint**, performed in `run_one`
*after* the directory has been queued. To discover that a mount is foreign, the walker first has
to touch it. Against a network FUSE mount whose server is gone, that stat never returns.
**Fix.** `localindex::nested_mount_points` reads `/proc/self/mounts` and returns every mountpoint
strictly below the scan root; `scan_local_once` appends those to the excludes. `filter_entry` runs
before the device stat (`walk.rs:1926`, before `send`), so a path-only rejection keeps the walker
from ever touching the mount. Mountpoint fields are octal-unescaped, because a `\040` left in
place would silently fail to match and put the mount back in the walk. Re-read per scan, not
cached at startup: a mount that appeared since the daemon started is the likeliest to be a
half-alive network filesystem.
This changes nothing about what gets indexed. A nested mount is a different device, so
`same_file_system(true)` was always going to skip it — the exclude only removes the stat that had
to happen first.
**Why there is no second line of defence.** Once a thread is in uninterruptible FUSE wait, no
userspace change rescues it: not a join timeout, not `std::process::exit`, not SIGKILL. The kernel
will not reap the task until the syscall returns, which needs someone to answer the request or
abort the connection (`echo 1 > /sys/fs/fuse/connections/<id>/abort`, root-only). Avoiding entry is
the entire fix. That makes any *new* code path that stats a user-supplied or mount-crossing path
from a daemon thread a repeat of this bug — mirror-folder scanning is the obvious next candidate.
**Recovery for an already-wedged daemon.** As root, abort the connection with waiters
(`grep . /sys/fs/fuse/connections/*/waiting` finds it), which releases the thread, lets the zombie
be reaped and frees the `cache.db` lock. A reboot also clears it.
---
## B89 — The recorded SDK dependency is three minor versions out of date in the working memory ## B89 — The recorded SDK dependency is three minor versions out of date in the working memory
**Status:** Open — documentation only, but it misdirects every reader of the SDK code. **Status:** Open — documentation only, but it misdirects every reader of the SDK code.
@@ -2650,6 +2723,16 @@ collisions without losing the source. Simple rename-only and move-only calls
remain synchronous. The managed live suite subsequently passed the replacement remain synchronous. The managed live suite subsequently passed the replacement
and combined move/rename cases. and combined move/rename cases.
**Follow-up (2026-09-15, unverified):** With SDK 0.6.3 the drain sends the
move and the rename as one `move-multiple` request carrying the target name,
instead of renaming in the source folder and then moving. That removes the
second call the stale requirements could hit, and the SDK retries a remaining
`InvalidRequirements` once with the server's current name hash. A destination
name collision now moves under a conflict name in one step. Same-folder renames
are unchanged. The synchronous FUSE `rename` path (`serve_rename`) still renames
then moves with its bounded retry and has not been converted. Needs a managed
live run of the combined move/rename cases.
--- ---
## B47 — FUSE Accepts Path Components Longer Than NAME_MAX ## B47 — FUSE Accepts Path Components Longer Than NAME_MAX
+95 -1
View File
@@ -11,7 +11,99 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
No schema change. Nothing yet.
## [1.10.2] — 2026-09-16
Fixes a daemon that could not be stopped or restarted. No schema change.
Schema: **28**; no SDK change.
### Fixed
- **A dead network mount in your home directory no longer wedges the daemon (B90).** The
"This computer" file indexer walked `$HOME` and descended into other FUSE mounts — an sshfs
whose server had gone away, an unresponsive rclone. Stat'ing one of those blocks
uninterruptibly, so the indexer thread stopped responding to SIGKILL, the daemon could never
exit, `systemctl --user stop` timed out, and the half-dead process kept the cache database lock
that the next start needed: every restart then failed with "another Proton Drive daemon is
already using cache.db". The indexer now skips mountpoints below your home directory by path,
without touching them. Nothing changes about which files are indexed — those mounts were always
excluded from the results, just not before they had been stat'ed.
## [1.10.1] — 2026-09-15
Dependency upgrade release, clearing both open Dependabot advisories. No schema change.
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.4**.
### Security
- **`lru` IterMut Stacked Borrows advisory.** The SDK now depends on `lru` 0.18.
- **`atty` unaligned read advisory.** The tray moved to `ksni` 0.3, which no longer pulls
`dbus-codegen` → `clap` 2 → `atty`.
### Changed
- **Keyring backend is `keyring-core` + `dbus-secret-service-keyring-store`** (keyring 4's split).
Same libdbus Secret Service as before; stored sessions written by earlier releases are still
found, so no re-login is needed.
- **The tray waits for a late StatusNotifierWatcher** instead of failing to start when it beats
the desktop's tray host onto the session bus.
- Upgraded `gtk4` 0.11, `libadwaita` 0.9, `webkit6` 0.6, `rusqlite` 0.40 (bundled SQLite),
`fuser` 0.18, `directories` 6, `sha1`/`sha2` 0.11, `base64` 0.23.
## [1.10.0] — 2026-09-15
One-request move-and-rename in the drain, and who shared an item with you. No schema change.
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.3**.
### Added
- **Shared with me says who shared an item, and when.** The GUI row subtitle and `pdfs
shared-with-me` show the inviter and invite date for a share root. The invitation's signature is
checked against the claimed inviter's keys; when it does not verify, the GUI shows a warning
and the CLI prints one, since the sender may be forged. `DirEntry` gains `shared_by`,
`shared_at` and `shared_by_unverified` (defaulted, so older front-ends and daemons interoperate).
### Changed
- **A queued move-and-rename lands as one request.** `drain_rename` used to rename in the source
folder and then move, with a rename-out-of-the-way step when the destination held the name. It
now calls `move-multiple` with a target name, so there is no half-applied state between the two
halves and no second call for the encrypted-name requirements to go stale against (B46). A
rename that stays in the same folder still uses the rename endpoint.
### Fixed
- **Moving a node with no signature email no longer fails.** Such nodes (created anonymously, e.g.
through a public link) need the passphrase signed by the mover; SDK 0.6.3 sends that signature.
- **A node trashed through the newer API field is recognised as trashed.** The SDK now reads
`TrashTime` as well as the legacy `Trashed` field, so the drain no longer tries to rename a node
that is already in the trash.
## [1.9.3] — 2026-09-01
The daemon's wait-for-login loop, and the latency that fixing it introduced. No schema change.
Schema: **28**; SDK unchanged at `proton-sdk` / `proton-drive-rs` **0.6.2**.
### Fixed
- **An enabled service with no saved session no longer polls the keyring 28,800 times a day.**
`wait_for_session` slept a flat 3 s between checks, and a check is not just a sleep — `auth::load`
builds a fresh `keyring::Entry`, so every iteration opened a new D-Bus connection to the Secret
Service and negotiated a session on it. The wait now backs off, and "not logged in" is said once
at `info` instead of on every pass, which also stops the journal filling with tens of thousands
of identical lines (`#21`).
- **Signing in wakes the parked daemon instead of leaving the mount up to an interval late.** The
backoff had no wake-up path: a unit started at boot reaches the ceiling within ~90 s, and
`enable --now` is a no-op on an already-active unit, so the GUI login and the tray's Connect left
it sleeping. `service::enable_start` now runs `enable` + `restart`, which interrupts the sleep and
still starts a unit that is down, and `pdfs login` restarts the unit when it is already active —
deliberately without touching `enable`, so a headless setup driving `pdfs daemon` by hand is left
alone. The ceiling also drops from 60 s to 15 s, still an 80% cut in keyring/D-Bus wakeups
(28,800/day → 5,760/day), bounding the worst case for paths that never reach systemd.
### Changed
- The wait is extracted into `wait_for_session()`, taking the load and sleep calls as parameters,
so the schedule is testable without a keyring and without real time passing.
## [1.9.2] — 2026-08-28
Repair for photos a Takeout import filed under the day they were imported, an `fzf` launcher front
end, and a gallery that survives 1,600 photos on one date. No schema change. Schema: **28**; SDK
`proton-sdk` / `proton-drive-rs` **0.6.2**.
### Added ### Added
- **`pdfs redate-photos`: repair photos filed under the day they were imported.** A Takeout whose - **`pdfs redate-photos`: repair photos filed under the day they were imported.** A Takeout whose
@@ -621,6 +713,8 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[1.9.3]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.3
[1.9.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.2
[1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1 [1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1
[1.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.0 [1.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.0
[1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2 [1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=1.9.3 pkgver=1.10.2
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')