From 3221e4aa97343de55818ed9eea3e46a6b97e3fd4 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 09:47:08 +0200 Subject: [PATCH 01/44] fix(fuse): mirror sync no longer deletes what Drive or this disk never had Five ways a mirror pass could lose files: - A folder deleted on Drive was removed here with everything in it, including new files and edits whose uploads had just failed for want of a parent, and ignored content such as .git. Such a folder now stays and is created on Drive again (B211). - A node the SDK could not decrypt was missing from the remote walk and so read as deleted on Drive. A short listing or an unreadable size now leaves the affected paths out of the pass, and a pass in which no synced path is left on Drive is refused (B212). - A file Drive deleted was removed here even when it had been edited since the scan. It is now checked again first, and kept (B213). - A folder deleted here was trashed on Drive although another device had just put new files in it. It now stays on Drive (B214). - A decrypted name holding / or .. became a path outside the sync folder, and failed the whole listing in the mount. Such names are left out (B215). --- crates/pdfs-fuse/src/lib.rs | 42 ++++ crates/pdfs-fuse/src/sync.rs | 295 +++++++++++++++++++++++---- crates/pdfs-fuse/src/sync/planner.rs | 221 ++++++++++++++++++++ docs/BUGS.md | 128 ++++++++++++ 4 files changed, 648 insertions(+), 38 deletions(-) diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 30d5aa3..a6935d5 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -6743,7 +6743,27 @@ fn fuse_name(name: &OsStr) -> Result { name.to_str().map(str::to_owned).ok_or(Errno::EILSEQ) } +/// Whether a decrypted remote name can stand as one local path component. +/// +/// Drive checks names only when a client creates or renames a node, and only +/// for length, so a name made by another client, or by a server that should +/// not be trusted, can hold anything. Joined onto a local path, a `/` or a `..` +/// in it walks out of the folder it belongs to, and the mirror engine would +/// then write or delete outside the sync root. In the mount, the kernel fails +/// the whole listing with EIO on a single such name (`docs/BUGS.md` B215). +pub(crate) fn remote_name_is_safe(name: &str) -> bool { + !name.is_empty() + && name != "." + && name != ".." + && name.len() <= 255 + && !name.contains(['/', '\0']) +} + fn node_visible(node: &Node, folder_uid: &NodeUid, hidden: &HashSet) -> bool { + if !remote_name_is_safe(&node.name) { + warn!(uid = %node.uid, "a remote name cannot be a local file name; leaving it out"); + return false; + } !node.trashed && node.uid != *folder_uid && !hidden.contains(&node.uid) } @@ -8654,6 +8674,28 @@ mod tests { )); } + #[test] + fn a_remote_name_that_is_not_one_path_component_is_left_out() { + let folder_uid = NodeUid::new(VolumeId::from("vol"), LinkId::from("folder")); + let none = std::collections::HashSet::new(); + for name in [ + "a/b", + "../../.bashrc", + "..", + ".", + "", + "a\0b", + &"x".repeat(256), + ] { + let node = node_helper("child", "folder", name, false); + assert!(!node_visible(&node, &folder_uid, &none), "{name:?}"); + } + for name in [".hidden", "a..b", "unicodé-文件", &"x".repeat(255)] { + let node = node_helper("child", "folder", name, false); + assert!(node_visible(&node, &folder_uid, &none), "{name:?}"); + } + } + #[test] fn denied_queue_guard_preserves_accepted_bytes_and_pending_count() { use std::cell::Cell; diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index abe74ba..8a2246b 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -59,9 +59,10 @@ pub(crate) use planner::base_name; #[cfg(test)] use planner::conflict_path; use planner::{ - FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline, - guard_local_wipe, join_rel, local_deletions, parent_rel, plan_file, rel_to_path, remote_sig, - tracked_baseline, unchanged_remote_size, + FilePlan, RemoteGaps, Unscanned, classification_order, conflict_path_with_suffix, + filter_baseline, guard_local_wipe, guard_remote_wipe, join_rel, local_deletions, parent_rel, + plan_file, rel_to_path, remote_sig, remote_subtree_changed, tracked_baseline, + unchanged_remote_size, }; /// What a [`Pending`] op did, so the reconcile can update shared state on the /// engine thread (never inside a task). @@ -638,23 +639,32 @@ impl Core { let mut remote: HashMap = HashMap::new(); let mut remote_dirs: HashMap = HashMap::new(); + let mut gaps = RemoteGaps::default(); remote_dirs.insert(String::new(), remote_root.clone()); self.walk_remote( folder_id, remote_root, "", - &mut remote, - &mut remote_dirs, + &mut RemoteWalk { + out: &mut remote, + dirs: &mut remote_dirs, + gaps: &mut gaps, + }, &baseline, )?; + guard_remote_wipe(&tracked_baseline(&baseline, rules, &unscanned), &remote)?; let mut order = classification_order(&local, &remote, &baseline, rules); // A path the scan could not read is no more a local deletion than an // ignored one, and a remote path under it has nowhere to be written. - order.retain(|rel| !unscanned.covers(rel)); + // The same holds for a path the remote walk could not read: missing + // from it, it would read as deleted on Drive (B212). + order.retain(|rel| !unscanned.covers(rel) && !gaps.hides(rel, &remote, &remote_dirs)); + let mut unscanned_report = unscanned.report(); + unscanned_report.extend(gaps.report()); let mut outcome = Outcome { - unscanned: unscanned.report(), + unscanned: unscanned_report, ..Default::default() }; // Folders to delete, collected here and removed deepest-first at the end @@ -814,9 +824,17 @@ impl Core { } }, FilePlan::DeleteLocal => { - if let Err(e) = - removed_locally(std::fs::remove_file(local_root.join(rel_to_path(rel)))) - { + let path = local_root.join(rel_to_path(rel)); + // The plan comes from a scan taken before the remote walk, + // which can take minutes. An edit saved since then is not + // something Drive deleted: keep it, and the next pass + // uploads it as a new file (B213). + if l.is_some_and(|planned| changed_since_scan(&path, planned)) { + info!(rel, "sync: changed since the scan; not deleting it"); + outcome.deferred += 1; + continue; + } + if let Err(e) = removed_locally(std::fs::remove_file(&path)) { warn!(rel, error = %e, "sync: remove local file failed"); self.log_activity( ActivityKind::Trash, @@ -874,28 +892,70 @@ impl Core { &mut outcome, )?; - // Deferred folder deletions, deepest first. - delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count())); - for rel in delete_local_dirs { - if let Err(e) = - removed_locally(std::fs::remove_dir_all(local_root.join(rel_to_path(&rel)))) - { - warn!(rel, error = %e, "sync: remove local folder failed"); - self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false); - outcome.errors += 1; - continue; + // Deferred folder deletions, deepest first. A folder that holds what + // Drive never had stays, and its baseline row goes, so the next pass + // creates it on Drive again and uploads what it holds (B211). + if !delete_local_dirs.is_empty() { + let mut current = self + .db + .sync_entries(folder_id) + .map_err(|e| format!("reload baseline: {e:?}"))?; + for removal in remove_local_dirs(local_root, delete_local_dirs, &mut current) { + match removal { + DirRemoval::Removed(rel) => { + let _ = self.db.sync_entry_remove(folder_id, &rel); + self.log_activity( + ActivityKind::Trash, + base_name(&rel), + "removed locally", + true, + ); + outcome.deleted += 1; + } + DirRemoval::Kept(rel, reason) => { + warn!( + rel, + reason, "sync: deleted on Drive, but holds unsynced data; keeping it" + ); + let _ = self.db.sync_entry_remove(folder_id, &rel); + self.log_activity( + ActivityKind::Trash, + base_name(&rel), + format!("deleted on Proton Drive, but kept here: {reason}"), + false, + ); + } + DirRemoval::Failed(rel, e) => { + warn!(rel, error = %e, "sync: remove local folder failed"); + self.log_activity( + ActivityKind::Trash, + base_name(&rel), + e.to_string(), + false, + ); + outcome.errors += 1; + } + } } - let _ = self.db.sync_entry_remove(folder_id, &rel); - self.log_activity( - ActivityKind::Trash, - base_name(&rel), - "removed locally", - true, - ); - outcome.deleted += 1; } delete_remote_dirs.sort_by_key(|(p, _)| std::cmp::Reverse(p.matches('/').count())); for (rel, uid) in delete_remote_dirs { + // Deleted here, but another device has put something new in it on + // Drive. This pass downloaded that, so the folder is back here + // with it; trashing the folder now would take it away again (B214). + if let Some(reason) = remote_subtree_changed(&rel, &remote, &baseline) { + warn!( + rel, + reason, "sync: deleted here, but changed on Drive; not trashing it" + ); + self.log_activity( + ActivityKind::Trash, + base_name(&rel), + format!("deleted here, but kept on Proton Drive: {reason}"), + false, + ); + continue; + } if let Err(e) = self .rt .block_on(self.drive.trash_nodes(&[uid])) @@ -963,7 +1023,7 @@ impl Core { .block_on(self.drive.enumerate_nodes_light(&uids)) .map_err(|e| format!("resolve nodes: {e}"))?; for node in nodes { - if node.trashed || !node.is_folder() { + if node.trashed || !node.is_folder() || !crate::remote_name_is_safe(&node.name) { continue; } let rel = join_rel(prefix, &node.name); @@ -985,13 +1045,15 @@ impl Core { /// unchanged mtime means the recorded size still stands. /// /// [`enumerate_nodes_light`]: proton_drive_rs::ProtonDriveClient::enumerate_nodes_light + /// + /// What the walk could not read goes to `walk.gaps`, never silently out of + /// the map: a missing path reads as deleted on Drive. fn walk_remote( &self, folder_id: i64, folder: &NodeUid, prefix: &str, - out: &mut HashMap, - dirs: &mut HashMap, + walk: &mut RemoteWalk<'_>, baseline: &HashMap, ) -> Result<(), PassAbort> { // Checked per folder, not per pass: this walk is the long pole on a large @@ -1017,6 +1079,27 @@ impl Core { .rt .block_on(self.drive.enumerate_nodes_light(&uids)) .map_err(|e| format!("resolve nodes: {e}"))?; + // The SDK leaves out a node it cannot decrypt, and says so only in its + // log. Its name went with it, so all this can record is the folder. + if nodes.len() < uids.len() { + warn!( + folder = prefix, + listed = uids.len(), + read = nodes.len(), + "sync: some nodes on Drive cannot be read; leaving them out" + ); + walk.gaps.short_listing(prefix); + } + let nodes: Vec = nodes + .into_iter() + .filter(|node| { + let safe = crate::remote_name_is_safe(&node.name); + if !safe { + warn!(folder = prefix, uid = %node.uid, "sync: a name on Drive cannot be a local file name; leaving it out"); + } + safe + }) + .collect(); // Files whose recorded mtime no longer matches: their size has to be // read for real, so these are the only node keys worth unlocking. @@ -1048,8 +1131,8 @@ impl Core { let rel = join_rel(prefix, &node.name); self.progress_scanned(folder_id, base_name(&rel)); if node.is_folder() { - dirs.insert(rel.clone(), node.uid.clone()); - out.insert( + walk.dirs.insert(rel.clone(), node.uid.clone()); + walk.out.insert( rel.clone(), RemoteItem { uid: node.uid.clone(), @@ -1058,16 +1141,19 @@ impl Core { size: 0, }, ); - self.walk_remote(folder_id, &node.uid, &rel, out, dirs, baseline)?; + self.walk_remote(folder_id, &node.uid, &rel, walk, baseline)?; } else { let size = if stale.contains(&node.uid.to_string()) { match sized.get(&node.uid.to_string()) { Some(size) => *size, // Its size was needed but could not be read (the full // enumeration skipped it as undecryptable). Leave it out - // rather than record a stale size, exactly as a full - // walk would have. - None => continue, + // rather than record a stale size, and say so: missing, + // it would read as deleted on Drive. + None => { + walk.gaps.unread(rel); + continue; + } } } else { match unchanged_remote_size(baseline, &rel, node.modification_time) { @@ -1075,7 +1161,7 @@ impl Core { None => continue, } }; - out.insert( + walk.out.insert( rel, RemoteItem { uid: node.uid, @@ -1613,6 +1699,62 @@ fn keep_racing_local_edit(path: &Path, expected: Option) -> std::io::R preserve_conflict_copy(path, now_secs()).map(|_| ()) } +/// What became of one folder [`remove_local_dirs`] was asked to remove. +enum DirRemoval { + Removed(String), + /// Left in place, with why: it holds something Drive does not have. + Kept(String, String), + Failed(String, std::io::Error), +} + +/// Remove the mirror folders in `dirs`, which Drive deleted, deepest first, +/// except those that hold something Drive never had. +/// +/// The uploads of a new file or an edit in such a folder failed earlier in the +/// pass for want of a parent, and an ignored `.git` is never uploaded at all, +/// so removing the folder would lose them for good (B211). `current` is the +/// baseline as the pass has left it: a row the pass already removed with its +/// file must not read as a deletion not carried up yet, and a subfolder removed +/// here takes its rows with it for the same reason. A kept folder loses its own +/// row, so its parent sees it as new and is kept too. +fn remove_local_dirs( + root: &Path, + mut dirs: Vec, + current: &mut HashMap, +) -> Vec { + dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count())); + dirs.into_iter() + .map(|rel| { + if let Some(reason) = mirror_subtree_unsynced(root, &rel, current) { + current.remove(&rel); + return DirRemoval::Kept(rel, reason); + } + let prefix = format!("{rel}/"); + current.retain(|path, _| *path != rel && !path.starts_with(&prefix)); + match removed_locally(std::fs::remove_dir_all(root.join(rel_to_path(&rel)))) { + Ok(()) => DirRemoval::Removed(rel), + Err(e) => DirRemoval::Failed(rel, e), + } + }) + .collect() +} + +/// What a remote walk fills in as it goes down the tree. +struct RemoteWalk<'a> { + /// Every path it read, by rel path. + out: &'a mut HashMap, + /// Every folder it read, by rel path, to its uid. + dirs: &'a mut HashMap, + /// What it saw but could not read. + gaps: &'a mut RemoteGaps, +} + +/// Whether the file at `path` is no longer what the scan saw as `planned`. +/// A file that is gone has nothing left to lose, so it has not changed. +fn changed_since_scan(path: &Path, planned: &LocalItem) -> bool { + local_sig(path).is_some_and(|now| !now.same_content(&LocalSig::from(planned))) +} + /// Read a local removal as the sync engine has to read it: the *state* it was /// asking for is "this path is gone", so a path that was already gone is a /// success, not a failure. @@ -2892,6 +3034,83 @@ mod tests { (root, baseline) } + /// Drive deleted `dir`. Everything it held that was synced goes with it; + /// a file Drive never had keeps the folder, and the parent with it + /// (`docs/BUGS.md` B211). + #[test] + fn a_folder_deleted_on_drive_keeps_what_drive_never_had() { + let (root, mut baseline) = synced_subtree("b211-keep"); + // The pass already removed the synced files with their rows. + std::fs::remove_file(root.join("dir/a.txt")).unwrap(); + baseline.remove("dir/a.txt"); + std::fs::remove_file(root.join("dir/sub/b.txt")).unwrap(); + baseline.remove("dir/sub/b.txt"); + std::fs::write(root.join("dir/new.txt"), b"never uploaded").unwrap(); + + let removals = remove_local_dirs( + &root, + vec!["dir".to_string(), "dir/sub".to_string()], + &mut baseline, + ); + let shape: Vec<(&str, &str)> = removals + .iter() + .map(|r| match r { + DirRemoval::Removed(rel) => ("removed", rel.as_str()), + DirRemoval::Kept(rel, _) => ("kept", rel.as_str()), + DirRemoval::Failed(rel, _) => ("failed", rel.as_str()), + }) + .collect(); + assert_eq!(shape, [("removed", "dir/sub"), ("kept", "dir")]); + assert_eq!( + std::fs::read(root.join("dir/new.txt")).unwrap(), + b"never uploaded" + ); + assert!(!root.join("dir/sub").exists()); + // Its row is gone, so the next pass sees a new local folder. + assert!(!baseline.contains_key("dir")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_folder_deleted_on_drive_with_only_synced_content_is_removed() { + let (root, mut baseline) = synced_subtree("b211-remove"); + for file in ["dir/a.txt", "dir/sub/b.txt"] { + std::fs::remove_file(root.join(file)).unwrap(); + baseline.remove(file); + } + let removals = remove_local_dirs( + &root, + vec!["dir/sub".to_string(), "dir".to_string()], + &mut baseline, + ); + assert!(removals.iter().all(|r| matches!(r, DirRemoval::Removed(_)))); + assert!(!root.join("dir").exists()); + let _ = std::fs::remove_dir_all(&root); + } + + /// An edit to a file saved after the scan is not a deletion on Drive + /// (`docs/BUGS.md` B213). + #[test] + fn a_file_changed_since_the_scan_is_not_deleted() { + let root = sync_test_dir("b213"); + let path = root.join("notes.txt"); + std::fs::write(&path, b"as scanned").unwrap(); + let sig = local_sig(&path).unwrap(); + let planned = LocalItem { + is_dir: false, + mtime: sig.mtime, + mtime_ns: sig.mtime_ns, + size: sig.size, + open_for_write: false, + }; + assert!(!changed_since_scan(&path, &planned)); + std::fs::write(&path, b"edited after the scan").unwrap(); + assert!(changed_since_scan(&path, &planned)); + std::fs::remove_file(&path).unwrap(); + assert!(!changed_since_scan(&path, &planned)); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn a_fully_synced_mirror_subtree_may_leave() { let (root, baseline) = synced_subtree("move-synced"); diff --git a/crates/pdfs-fuse/src/sync/planner.rs b/crates/pdfs-fuse/src/sync/planner.rs index ce51994..3b7a881 100644 --- a/crates/pdfs-fuse/src/sync/planner.rs +++ b/crates/pdfs-fuse/src/sync/planner.rs @@ -199,6 +199,135 @@ pub(super) fn guard_local_wipe( Ok(()) } +/// Refuse to run a pass whose remote side has vanished in its entirety: the +/// remote twin of [`guard_local_wipe`]. +/// +/// An empty or truncated remote walk reads to the planner as "Drive deleted +/// everything", which a pass answers by deleting the local copies. When not +/// one synced path is left on Drive, a failed listing is far likelier than a +/// deliberate wipe of the whole folder, so the pass stops instead. +pub(super) fn guard_remote_wipe( + baseline: &HashMap, + remote: &HashMap, +) -> Result<(), String> { + if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) { + return Err(format!( + "every one of the {} synced paths is missing on Proton Drive; refusing to \ + delete them here. If they were deleted on purpose, remove the folder from sync.", + baseline.len() + )); + } + Ok(()) +} + +/// What a remote walk saw but could not read: a node the SDK left out because +/// it would not decrypt, or a file whose size could not be read. +/// +/// A path missing from the walk is read as deleted on Drive, so a pass must +/// not classify one it only failed to read. Since a node that failed to decrypt +/// has no name, all a walk can say is that a folder's listing came back short; +/// every path under that folder that the walk did not return is then left out, +/// as [`Unscanned`] leaves out what the local scan could not read. +#[derive(Default)] +pub(super) struct RemoteGaps { + /// Folders whose listing came back with fewer nodes than it named. + short: HashSet, + /// Paths seen, but whose entry could not be read. + unread: HashSet, +} + +impl RemoteGaps { + /// The listing of `folder` named nodes that did not come back. + pub(super) fn short_listing(&mut self, folder: &str) { + self.short.insert(folder.to_string()); + } + + /// `rel` is on Drive, but the walk could not read what it needs of it. + pub(super) fn unread(&mut self, rel: String) { + self.unread.insert(rel); + } + + pub(super) fn is_empty(&self) -> bool { + self.short.is_empty() && self.unread.is_empty() + } + + /// Whether `rel` may be missing from `remote` only because the walk could + /// not read it. A path the walk returned is never hidden. + pub(super) fn hides( + &self, + rel: &str, + remote: &HashMap, + remote_dirs: &HashMap, + ) -> bool { + if self.is_empty() || remote.contains_key(rel) { + return false; + } + if self.unread.contains(rel) { + return true; + } + // The deepest folder the walk did list decides: under a short listing, + // anything not returned may be one of the nodes it could not read. + let mut at = parent_rel(rel); + loop { + if remote_dirs.contains_key(at) { + return self.short.contains(at); + } + if at.is_empty() { + return false; + } + at = parent_rel(at); + } + } + + /// The folders to name to the user, sorted. + pub(super) fn report(&self) -> Vec { + let mut lines: Vec = self + .short + .iter() + .map(|rel| { + let shown = if rel.is_empty() { "the folder" } else { rel }; + format!("{shown} (items on Proton Drive that cannot be read)") + }) + .chain( + self.unread + .iter() + .map(|rel| format!("{rel} (cannot be read on Proton Drive)")), + ) + .collect(); + lines.sort_unstable(); + lines + } +} + +/// Why the remote folder at `rel` must not be trashed although it was deleted +/// here: something under it is new or changed on Drive since the last sync, +/// so it is another device's work that this computer has never had. +/// +/// Trashing the folder would take that work with it, and the next pass would +/// then delete the copy this pass just downloaded. `None` when everything under +/// it is exactly what the baseline recorded. +pub(super) fn remote_subtree_changed( + rel: &str, + remote: &HashMap, + baseline: &HashMap, +) -> Option { + let prefix = format!("{rel}/"); + let mut changed: Vec<&String> = remote + .iter() + .filter(|(path, _)| path.starts_with(&prefix)) + .filter(|(path, item)| match baseline.get(*path) { + None => true, + Some(_) if item.is_dir => false, + Some(base) => remote_sig(base) != Some((item.mtime, item.size)), + }) + .map(|(path, _)| path) + .collect(); + changed.sort_unstable(); + changed + .first() + .map(|path| format!("{path} is new or changed on Proton Drive")) +} + /// The size the baseline recorded for `rel`, if its remote signature's mtime /// still matches `mtime`. pub(super) fn unchanged_remote_size( @@ -530,4 +659,96 @@ mod file_plan_tests { FilePlan::Download ); } + + fn uid(id: &str) -> NodeUid { + NodeUid::new(VolumeId::from("v"), LinkId::from(id)) + } + + fn folder() -> RemoteItem { + RemoteItem { + is_dir: true, + mtime: 0, + size: 0, + ..remote(0, 0) + } + } + + /// An empty or failed remote listing must not read as "Drive deleted + /// everything" and delete the local copies. + #[test] + fn the_remote_wipe_guard_blocks_only_a_total_disappearance() { + let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]); + let none: HashMap = HashMap::new(); + assert!(guard_remote_wipe(&base, &none).is_err()); + let one = HashMap::from([("b".to_string(), remote(1, 1))]); + assert!(guard_remote_wipe(&base, &one).is_ok()); + let empty: HashMap = HashMap::new(); + assert!(guard_remote_wipe(&empty, &none).is_ok()); + } + + /// A node that failed to decrypt has no name, so everything a short + /// listing did not return is left out, and nothing else. + #[test] + fn a_short_remote_listing_hides_what_it_did_not_return() { + let remote = HashMap::from([ + ("docs".to_string(), folder()), + ("docs/a.txt".to_string(), remote(1, 1)), + ("pics".to_string(), folder()), + ]); + let dirs = HashMap::from([ + (String::new(), uid("root")), + ("docs".to_string(), uid("docs")), + ("pics".to_string(), uid("pics")), + ]); + let mut gaps = RemoteGaps::default(); + assert!(!gaps.hides("docs/b.txt", &remote, &dirs)); + gaps.short_listing("docs"); + // Returned paths stand; unreturned ones under the short folder hide, + // and so does anything under a subfolder it failed to return. + assert!(!gaps.hides("docs/a.txt", &remote, &dirs)); + assert!(gaps.hides("docs/b.txt", &remote, &dirs)); + assert!(gaps.hides("docs/sub/deep.txt", &remote, &dirs)); + // A complete sibling listing still reads deletions as deletions. + assert!(!gaps.hides("pics/gone.jpg", &remote, &dirs)); + assert!(!gaps.hides("top.txt", &remote, &dirs)); + gaps.short_listing(""); + assert!(gaps.hides("top.txt", &remote, &dirs)); + } + + #[test] + fn a_file_whose_size_could_not_be_read_is_hidden() { + let remote: HashMap = HashMap::new(); + let dirs = HashMap::from([(String::new(), uid("root"))]); + let mut gaps = RemoteGaps::default(); + gaps.unread("a.txt".to_string()); + assert!(gaps.hides("a.txt", &remote, &dirs)); + assert!(!gaps.hides("b.txt", &remote, &dirs)); + assert_eq!(gaps.report(), ["a.txt (cannot be read on Proton Drive)"]); + } + + /// A folder deleted here holds another device's new work on Drive, so + /// trashing it would lose that work. + #[test] + fn a_remote_folder_with_new_or_changed_files_is_not_trashed() { + let mut base = HashMap::from([ + ("x".to_string(), baseline(0, 0, 0, 0)), + ("x/old.txt".to_string(), baseline(1, 1, 5, 6)), + ]); + let mut remote_tree = HashMap::from([ + ("x".to_string(), folder()), + ("x/old.txt".to_string(), remote(5, 6)), + ("xy/new.txt".to_string(), remote(1, 1)), + ]); + // Unchanged under x; a name-prefixed sibling does not count. + assert_eq!(remote_subtree_changed("x", &remote_tree, &base), None); + + remote_tree.insert("x/old.txt".to_string(), remote(9, 6)); + assert!(remote_subtree_changed("x", &remote_tree, &base).is_some()); + + remote_tree.insert("x/old.txt".to_string(), remote(5, 6)); + remote_tree.insert("x/sub".to_string(), folder()); + assert!(remote_subtree_changed("x", &remote_tree, &base).is_some()); + base.insert("x/sub".to_string(), baseline(0, 0, 0, 0)); + assert_eq!(remote_subtree_changed("x", &remote_tree, &base), None); + } } diff --git a/docs/BUGS.md b/docs/BUGS.md index 7af894c..3794d18 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,11 @@ a live account since. | Entry | Title | |---|---| +| [B215](#b215--a-remote-name-holding--or--becomes-a-path-outside-the-sync-folder) | A remote name holding `/` or `..` becomes a path outside the sync folder | +| [B214](#b214--deleting-a-mirror-folder-here-trashes-another-devices-new-files-in-it) | Deleting a mirror folder here trashes another device's new files in it | +| [B213](#b213--a-file-edited-during-a-mirror-pass-is-deleted-because-drive-deleted-it-earlier) | A file edited during a mirror pass is deleted because Drive deleted it earlier | +| [B212](#b212--a-node-on-drive-that-cannot-be-decrypted-is-deleted-from-the-mirror) | A node on Drive that cannot be decrypted is deleted from the mirror | +| [B211](#b211--a-folder-deleted-on-drive-takes-unsynced-local-files-with-it) | A folder deleted on Drive takes unsynced local files with it | | [B210](#b210--after-a-password-reset-the-mount-retries-missing-key-forever) | After a password reset, the mount retries "missing key" forever | | [B209](#b209--a-blocked-sign-in-says-the-app-version-is-no-longer-supported) | A blocked sign-in says the app version is no longer supported | | [B208](#b208--a-file-landing-while-its-folder-is-pinned-is-kept-but-not-available-offline) | A file landing while its folder is pinned is kept but not available offline | @@ -313,6 +318,129 @@ a live account since. Newest first. +### B215 — A remote name holding `/` or `..` becomes a path outside the sync folder + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.2). +**Where:** `walk_remote` and `walk_remote_dirs` in `crates/pdfs-fuse/src/sync.rs`; `node_visible` +in `crates/pdfs-fuse/src/lib.rs` + +**Repro:** Needs a node whose decrypted name holds `/`, `..` or NUL. Drive checks names only when +a client creates or renames a node, and only for length, so another client or a server that +should not be trusted can make one. Then let a mirror pass or a listing of its folder run. + +**Cause.** The mirror engine joined each decrypted name onto its parent's relative path and +turned that into a local path, so `x/../../.config/autostart/a.desktop` named a file outside the +sync root. The pass then created, replaced or removed it there. In the mount, the kernel refuses a +whole `readdir` with EIO when one name in it is not a single path component. + +**Fix.** `remote_name_is_safe` accepts a name only if it is one path component: not empty, not +`.` or `..`, no `/` or NUL, at most 255 bytes. Both remote walks of the mirror engine and the +listing filter of the mount leave out any other name, with a warning. A name left out cannot be a +local path at all, so leaving it out cannot read as a deletion of anything local. + +**Test:** `tests::a_remote_name_that_is_not_one_path_component_is_left_out`. + +--- + +### B214 — Deleting a mirror folder here trashes another device's new files in it + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.5). +**Where:** `Core::do_reconcile` in `crates/pdfs-fuse/src/sync.rs` + +**Repro:** Delete a synced folder locally while another device adds a file to it on Drive, before +the next pass. + +**Cause.** The pass planned the new file as a download, which recreated the folder locally. At +the end of the same pass it trashed the remote folder, because the folder was in the baseline and +gone locally, and took the new file with it. The next pass then saw the downloaded copy unchanged +and its remote gone, so it deleted that too. The other device's work survived only in the Drive +trash, and nothing said so. + +**Fix.** Before a pass trashes a remote folder, `remote_subtree_changed` looks for anything under +it on Drive that is not in the baseline or whose signature moved. If there is, the folder stays on +Drive with its row, the activity feed says why, and only its unchanged files are trashed, one by +one, as local deletions. + +**Test:** `planner::file_plan_tests::a_remote_folder_with_new_or_changed_files_is_not_trashed`. + +--- + +### B213 — A file edited during a mirror pass is deleted because Drive deleted it earlier + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.6). +**Where:** `Core::do_reconcile`, `FilePlan::DeleteLocal`, in `crates/pdfs-fuse/src/sync.rs` + +**Repro:** Delete a synced file on another device. On this one, edit the file while a pass over a +large folder walks Drive. + +**Cause.** A pass takes its local signatures before the remote walk, which takes minutes on a +large tree, and deleted a file that Drive had deleted without looking at it again. Downloads got +that second look in B39 (`keep_racing_local_edit`); deletions did not, so the edit was lost with +no conflict copy. + +**Fix.** `changed_since_scan` compares the file's signature with the one the scan planned on, right +before the deletion. A changed file stays, and the next pass uploads it as a new file. + +**Test:** `tests::a_file_changed_since_the_scan_is_not_deleted`. + +--- + +### B212 — A node on Drive that cannot be decrypted is deleted from the mirror + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.4). The SDK leaves out nodes it cannot +decrypt, which B112 showed on real accounts and B97 during cancellation. +**Where:** `Core::walk_remote` and `Core::do_reconcile` in `crates/pdfs-fuse/src/sync.rs` + +**Repro:** Make one synced file or folder fail to decrypt, for example with a key the session +cannot unlock, and let a pass run. + +**Cause.** The remote walk kept what the SDK returned and nothing else. A node the SDK left out +was then missing from the remote map, and the planner reads "in the baseline and here, but not on +Drive" as a deletion on Drive. A file was deleted locally; a folder was removed with +`remove_dir_all`. The local side had a guard against a scan that came back empty +(`guard_local_wipe`), the remote side had none. + +**Fix.** +- A listing that returns fewer nodes than it named marks its folder as short. A file whose size + could not be read is marked unread. +- `RemoteGaps::hides` leaves out of the pass every path such a gap could explain, as `Unscanned` + does for the local scan. The folder is reported with the paths that cannot be synced. +- `guard_remote_wipe` refuses a pass in which none of the synced paths is left on Drive. + +**Test:** `planner::file_plan_tests::a_short_remote_listing_hides_what_it_did_not_return`, +`planner::file_plan_tests::a_file_whose_size_could_not_be_read_is_hidden`, +`planner::file_plan_tests::the_remote_wipe_guard_blocks_only_a_total_disappearance`. + +--- + +### B211 — A folder deleted on Drive takes unsynced local files with it + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.1). +**Where:** `Core::do_reconcile` and `remove_local_dirs` in `crates/pdfs-fuse/src/sync.rs` + +**Repro:** In a mirror folder, add a file to a synced subfolder. Before the next pass, delete the +subfolder on another device. + +**Cause.** The pass put the folder on its list of local deletions. The upload of the new file +failed for want of a remote parent and was counted as an error. At the end of the pass, +`remove_dir_all` removed the folder with everything in it: new files, edits, files open for +writing, and ignored content such as `.git`, none of which had ever been on Drive. + +**Fix.** `remove_local_dirs` asks `mirror_subtree_unsynced` about each folder before it removes +it, against the baseline as the pass has left it. A folder that holds anything Drive does not +have stays, and loses its baseline row, so the next pass creates it on Drive again and uploads +what it holds. What was synced in it is still deleted. + +**Test:** `tests::a_folder_deleted_on_drive_keeps_what_drive_never_had`, +`tests::a_folder_deleted_on_drive_with_only_synced_content_is_removed`. + +--- + ### B210 — After a password reset, the mount retries "missing key" forever **Status:** Fixed, not verified live From d6bc921a4bff468ee2508a24bd8a34e53f8f4656 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 09:50:53 +0200 Subject: [PATCH 02/44] fix(core): keep the upload queue when a damaged database is rebuilt A database SQLite reported as corrupt at open was moved aside and replaced by an empty one, on the grounds that everything in it is fetched again. The queue of writes Drive has not received, the mirror baselines and the pins are not, so they were lost. They are now copied out of the damaged file, read only and table by table, into the new one (B216). --- crates/pdfs-core/src/db/mod.rs | 130 +++++++++++++++++++++++++++---- crates/pdfs-core/src/db/tests.rs | 83 ++++++++++++++++++++ docs/BUGS.md | 27 +++++++ 3 files changed, 226 insertions(+), 14 deletions(-) diff --git a/crates/pdfs-core/src/db/mod.rs b/crates/pdfs-core/src/db/mod.rs index 611e0e3..1752e27 100644 --- a/crates/pdfs-core/src/db/mod.rs +++ b/crates/pdfs-core/src/db/mod.rs @@ -216,18 +216,34 @@ impl Db { let conn = match Self::open_configured(path) { Ok(conn) => conn, Err(e) if is_corrupt(&e) => { - // Every byte in here is derived from the account or from the - // content cache directory, so the recovery is to start over — - // loudly, and keeping the damaged file for a post-mortem rather - // than deleting evidence. + // Most of what is in here is derived from the account and is + // fetched again. The queue is not: it holds writes accepted + // here that Drive does not have yet, and the mirror baselines + // and pins are this machine's alone. So the damaged file is + // moved aside, kept as evidence, and those tables are copied + // out of it into the new one as far as SQLite can still read + // them (`docs/BUGS.md` B216). tracing::error!( db = %path.display(), error = %e, - "cache database is corrupt — moving it aside and rebuilding \ - (metadata will be re-fetched; no user data is stored here)" + "cache database is corrupt; moving it aside, rebuilding, and \ + copying the upload queue, sync baselines and pins out of it" ); - quarantine_corrupt_db(path)?; - Self::open_configured(path)? + let damaged = quarantine_corrupt_db(path)?; + let conn = Self::open_configured(path)?; + for (table, result) in salvage(&conn, &damaged) { + match result { + Ok(rows) => { + tracing::warn!(table, rows, "salvaged from the damaged database") + } + Err(error) => tracing::error!( + table, + %error, + "could not salvage from the damaged database" + ), + } + } + conn } Err(e) => return Err(e), }; @@ -428,12 +444,96 @@ fn is_corrupt(e: &Error) -> bool { ) } -/// Move a damaged database (and its WAL sidecars) out of the way so the next -/// open starts from empty. +/// The tables [`salvage`] copies out of a damaged database, in order, each +/// with the rows it keeps. /// -/// Renamed rather than deleted: this is a cache, but it is also the only -/// evidence of what went wrong, and it costs one file the user can remove. -fn quarantine_corrupt_db(path: &Path) -> Result<()> { +/// The queue goes with the node rows it names by local id: a create made here +/// has no other record of its node, and a real node keeps its local id when its +/// folder is listed again. Those rows come back unlisted, so their folders are +/// fetched again rather than trusted. Claims are cleared by [`Db::open`] as +/// after any other crash. +const SALVAGE: &[(&str, &str)] = &[ + ( + "nodes", + "lid IN (SELECT lid FROM old.pending_op WHERE lid IS NOT NULL + UNION SELECT parent_lid FROM old.pending_op WHERE parent_lid IS NOT NULL) + OR uid LIKE 'local~%'", + ), + ("pending_op", "1"), + ("sync_folder", "1"), + ("sync_entry", "1"), + ("pins", "1"), + ("own_sealed_rev", "1"), +]; + +/// Copy what only this machine has out of the damaged database at `damaged` +/// into the fresh one `conn` just created. Returns how each table went. +/// +/// Each table is copied in its own transaction, so a table SQLite cannot read +/// to the end is left out whole rather than half copied, and does not stop +/// the others. Only the columns both files have are copied, which covers a +/// file that broke in the middle of an upgrade. The damaged file is opened read +/// only and left as it was. +fn salvage(conn: &Connection, damaged: &Path) -> Vec<(&'static str, Result)> { + let uri = format!( + "file:{}?mode=ro", + damaged + .to_string_lossy() + .replace('%', "%25") + .replace('?', "%3f") + .replace('#', "%23") + ); + if let Err(e) = conn.execute("ATTACH DATABASE ?1 AS old", [&uri]) { + let e = Error::Db(e); + tracing::error!(error = %e, "could not open the damaged database to salvage it"); + return Vec::new(); + } + let results = SALVAGE + .iter() + .map(|&(table, rows)| (table, salvage_table(conn, table, rows))) + .collect(); + let _ = conn.execute("UPDATE nodes SET listed = 0", []); + if let Err(e) = conn.execute("DETACH DATABASE old", []) { + tracing::warn!(error = %e, "could not detach the damaged database"); + } + results +} + +fn salvage_table(conn: &Connection, table: &str, rows: &str) -> Result { + let columns = |schema: &str| -> Result> { + let mut stmt = conn.prepare(&format!("PRAGMA {schema}.table_info({table})"))?; + let names = stmt + .query_map([], |r| r.get::<_, String>(1))? + .collect::>>()?; + Ok(names) + }; + let new = columns("main")?; + let shared: Vec = columns("old")? + .into_iter() + .filter(|c| new.contains(c)) + .map(|c| format!("\"{c}\"")) + .collect(); + if shared.is_empty() { + return Ok(0); + } + let shared = shared.join(", "); + let tx = conn.unchecked_transaction()?; + let copied = tx.execute( + &format!( + "INSERT OR IGNORE INTO main.{table} ({shared}) SELECT {shared} FROM old.{table} WHERE {rows}" + ), + [], + )?; + tx.commit()?; + Ok(copied) +} + +/// Move a damaged database (and its WAL sidecars) out of the way so the next +/// open starts from empty. Returns where the database itself went. +/// +/// Renamed rather than deleted: it is the only evidence of what went wrong, +/// and [`salvage`] reads what only this machine has back out of it. +fn quarantine_corrupt_db(path: &Path) -> Result { let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) @@ -457,7 +557,9 @@ fn quarantine_corrupt_db(path: &Path) -> Result<()> { Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"), } } - Ok(()) + let mut kept = path.as_os_str().to_owned(); + kept.push(format!(".corrupt-{stamp}")); + Ok(PathBuf::from(kept)) } #[cfg(test)] diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 324378e..8be06bc 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -1283,6 +1283,89 @@ fn a_corrupt_database_is_moved_aside_and_rebuilt() { let _ = std::fs::remove_file(path.with_extension("db.lock")); } +/// A rebuilt database must not lose what only this machine has: writes Drive +/// has not received, the mirror baselines and the pins (`docs/BUGS.md` B216). +#[test] +fn a_rebuild_keeps_the_queue_baselines_and_pins_of_the_damaged_file() { + let id = now_test_id(); + let damaged = + std::env::temp_dir().join(format!("pdfs-salvage-old-{}-{id}.db", std::process::id())); + let fresh = + std::env::temp_dir().join(format!("pdfs-salvage-new-{}-{id}.db", std::process::id())); + + let db = Db::open(&damaged).unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("listed", "root", "listed.txt", 1)) + .unwrap(); + db.upsert_node(&file("queued", "root", "queued.txt", 2)) + .unwrap(); + db.set_listed(&uid("root"), true).unwrap(); + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_REVISION.to_string(), + uid: uid("queued").to_string(), + parent_uid: None, + name: None, + blob_path: Some("/staging/queued".into()), + meta_json: None, + created_at: 1, + attempts: 3, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + let folder_id = db + .sync_folder_add("/home/me/Docs", "vol~docs", "share") + .unwrap(); + db.sync_entry_upsert( + folder_id, + &StoredSyncEntry { + rel_path: "a.txt".into(), + remote_uid: Some("vol~a".into()), + local_mtime: 5, + local_mtime_ns: None, + local_size: 6, + remote_rev: Some("7".into()), + remote_hash: Some("8".into()), + }, + ) + .unwrap(); + db.pin_add("vol~pinned", "/pinned", true).unwrap(); + drop(db); + + let db = Db::open(&fresh).unwrap(); + let results = salvage(&db.conn.lock(), &damaged); + assert!(results.iter().all(|(_, r)| r.is_ok()), "{results:?}"); + + let ops = db.pending_ops().unwrap(); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0].uid, uid("queued").to_string()); + assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/queued")); + // The op's node came with it; the rest of the tree is fetched again. + assert!( + db.node_by_uid(&uid("queued").to_string()) + .unwrap() + .is_some() + ); + assert!( + db.node_by_uid(&uid("listed").to_string()) + .unwrap() + .is_none() + ); + assert_eq!(db.sync_entries(folder_id).unwrap().len(), 1); + assert!(db.is_pinned("vol~pinned").unwrap()); + drop(db); + + for path in [&damaged, &fresh] { + for suffix in ["", "-wal", "-shm"] { + let mut file = path.as_os_str().to_owned(); + file.push(suffix); + let _ = std::fs::remove_file(std::path::PathBuf::from(file)); + } + let _ = std::fs::remove_file(path.with_extension("db.lock")); + } +} + fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry { LocalEntry { path: path.into(), diff --git a/docs/BUGS.md b/docs/BUGS.md index 3794d18..201193b 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B216](#b216--a-damaged-database-is-rebuilt-without-the-writes-still-queued-in-it) | A damaged database is rebuilt without the writes still queued in it | | [B215](#b215--a-remote-name-holding--or--becomes-a-path-outside-the-sync-folder) | A remote name holding `/` or `..` becomes a path outside the sync folder | | [B214](#b214--deleting-a-mirror-folder-here-trashes-another-devices-new-files-in-it) | Deleting a mirror folder here trashes another device's new files in it | | [B213](#b213--a-file-edited-during-a-mirror-pass-is-deleted-because-drive-deleted-it-earlier) | A file edited during a mirror pass is deleted because Drive deleted it earlier | @@ -318,6 +319,32 @@ a live account since. Newest first. +### B216 — A damaged database is rebuilt without the writes still queued in it + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.7). +**Where:** `Db::open`, `salvage` and `quarantine_corrupt_db` in `crates/pdfs-core/src/db/mod.rs` + +**Repro:** Damage `cache.db` so that SQLite reports `SQLITE_CORRUPT` or `SQLITE_NOTADB` at open, +with writes still queued. + +**Cause.** Opening a damaged database moved it aside and started an empty one, logging that +"no user data is stored here". The same file holds the upload queue (`pending_op`), the mirror +baselines (`sync_entry`, `sync_folder`), the pins and the record of revisions this machine sealed. +Every queued create, rename and trash was dropped without a word. The staged blobs lost their ops, +and those of files made here could never be queued again. + +**Fix.** After the rebuild, `salvage` attaches the damaged file read only and copies those tables +back, each in its own transaction and with only the columns both files have. The queue keeps the +node rows it names by local id, so files made here keep their identity; those rows come back +unlisted, so their folders are fetched again. A table SQLite cannot read to the end is left out +and logged as an error. + +**Test:** `db::tests::a_rebuild_keeps_the_queue_baselines_and_pins_of_the_damaged_file`, +`db::tests::a_corrupt_database_is_moved_aside_and_rebuilt`. + +--- + ### B215 — A remote name holding `/` or `..` becomes a path outside the sync folder **Status:** Fixed, not verified live From 0f279c7536c246e6713c090bc9a202ffcb695914 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 09:56:13 +0200 Subject: [PATCH 03/44] fix(core,fuse): compact the search indexes and stop pruning after partial scans FTS5 keeps deleted entries until their segments are merged, which with these indexes is close to never. On one machine local_fts held 317 MiB for 40,000 paths, against 20 MiB rebuilt, and search ran four times slower. The indexes are now compacted once a day after a local scan. A scan that hit the entry cap or lost a batch no longer prunes the paths it never reached, which the next scan used to add back (B217). --- crates/pdfs-core/src/db/local.rs | 71 ++++++++++++++++++++++++++++++ crates/pdfs-core/src/db/tests.rs | 71 ++++++++++++++++++++++++++++++ crates/pdfs-core/src/localindex.rs | 2 +- crates/pdfs-fuse/src/background.rs | 14 ++++-- docs/BUGS.md | 28 ++++++++++++ 5 files changed, 182 insertions(+), 4 deletions(-) diff --git a/crates/pdfs-core/src/db/local.rs b/crates/pdfs-core/src/db/local.rs index f5f4ae2..f15eb22 100644 --- a/crates/pdfs-core/src/db/local.rs +++ b/crates/pdfs-core/src/db/local.rs @@ -111,9 +111,80 @@ impl Db { )?; let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?; tx.commit()?; + drop(conn); + self.compact_search_indexes_if_due(finished_at)?; Ok(count) } + /// Close a scan that did not see everything: it stopped at the entry cap, + /// or a batch failed to write. Nothing is pruned, since a path it missed is + /// not a path that is gone; the next complete scan prunes. Returns the + /// number of indexed entries. + /// + /// Pruning after such a scan dropped every entry it had not reached, and + /// the next scan put them back. That churn is most of what grew + /// `local_fts` to 16 times its size (`docs/BUGS.md` B217). + pub fn local_finish_partial_scan(&self, finished_at: i64) -> Result { + let conn = self.conn.lock(); + conn.execute( + "INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [finished_at.to_string()], + )?; + let count: i64 = conn.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?; + Ok(count) + } + + /// Compact both full-text indexes, at most once a day. + /// + /// FTS5 keeps a deleted entry's data until the segments holding it are + /// merged, and with the few large segments these indexes settle into that + /// is close to never. Every file the local scan sees disappear or appear + /// leaves some behind, and every node row the tree replaces does too: on + /// one machine `local_fts` held 317 MiB for 40,000 paths, against 20 MiB + /// rebuilt, and a search took four times as long (`docs/BUGS.md` B217). + /// + /// `local_fts` is rebuilt from `local_files`, which also drops anything + /// left over, and `nodes_fts`, which has no content table to rebuild from, + /// is merged into one segment. Each runs in its own transaction on the + /// write connection, about half a second on an index that size; once a + /// day keeps the leftovers small without paying that at every scan. + pub fn compact_search_indexes_if_due(&self, now: i64) -> Result<()> { + const EVERY_SECS: i64 = 24 * 60 * 60; + let last: i64 = self + .read() + .query_row( + "SELECT value FROM sync_state WHERE key = 'fts_compacted_at'", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + if now - last < EVERY_SECS { + return Ok(()); + } + let started = std::time::Instant::now(); + { + let conn = self.conn.lock(); + conn.execute("INSERT INTO local_fts(local_fts) VALUES('rebuild')", [])?; + } + { + let conn = self.conn.lock(); + conn.execute("INSERT INTO nodes_fts(nodes_fts) VALUES('optimize')", [])?; + conn.execute( + "INSERT INTO sync_state (key, value) VALUES ('fts_compacted_at', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [now.to_string()], + )?; + } + tracing::info!( + elapsed_ms = started.elapsed().as_millis() as u64, + "compacted the search indexes" + ); + Ok(()) + } + /// When the last local scan completed (epoch seconds), or `None` if the index /// has never been built. The daemon uses this to decide whether a fresh mount /// needs an immediate rescan or can serve the existing index. diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 8be06bc..55a586c 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -1429,6 +1429,77 @@ fn rescanning_an_unchanged_file_keeps_exactly_one_index_entry() { } } +/// A scan that stopped early or lost a batch did not see every file, so it +/// must not prune the ones it missed (`docs/BUGS.md` B217). +#[test] +fn a_partial_local_scan_prunes_nothing() { + let db = Db::open_in_memory().unwrap(); + let g = db.local_begin_scan().unwrap(); + db.local_upsert_batch( + g, + &[ + local("/home/u/a.txt", "a.txt", false), + local("/home/u/b.txt", "b.txt", false), + ], + ) + .unwrap(); + assert_eq!(db.local_finish_scan(g, 1).unwrap(), 2); + + let g = db.local_begin_scan().unwrap(); + db.local_upsert_batch(g, &[local("/home/u/a.txt", "a.txt", false)]) + .unwrap(); + assert_eq!(db.local_finish_partial_scan(2).unwrap(), 2); + assert_eq!(db.search_local("b.txt", 10).unwrap().len(), 1); + assert_eq!(db.local_indexed_at().unwrap(), Some(2)); +} + +/// Deleted entries pile up in an FTS5 index until it is compacted. The daily +/// compaction must leave both indexes whole and searchable, and must not run +/// again the same day (`docs/BUGS.md` B217). +#[test] +fn compacting_the_search_indexes_keeps_them_whole_and_runs_once_a_day() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("f1", "root", "quarterly-report.pdf", 1)) + .unwrap(); + for round in 0..20 { + let g = db.local_begin_scan().unwrap(); + let name = format!("churn-{round}.txt"); + db.local_upsert_batch( + g, + &[ + local("/home/u/keep.txt", "keep.txt", false), + local(&format!("/home/u/{name}"), &name, false), + ], + ) + .unwrap(); + // Far in the past, so no scan compacts on its own. + db.local_finish_scan(g, 0).unwrap(); + } + + let day = 24 * 60 * 60; + db.compact_search_indexes_if_due(day).unwrap(); + { + let conn = db.conn.lock(); + for table in ["local_fts", "nodes_fts"] { + conn.execute( + &format!("INSERT INTO {table}({table}) VALUES('integrity-check')"), + [], + ) + .unwrap(); + } + } + assert_eq!(db.search_local("keep", 10).unwrap().len(), 1); + assert_eq!(db.search_local("churn-19", 10).unwrap().len(), 1); + assert!(db.search_local("churn-3.txt", 10).unwrap().is_empty()); + assert_eq!(db.search("quarterly", 10).unwrap().len(), 1); + let stamp = |db: &Db| db.state_str("fts_compacted_at").unwrap(); + assert_eq!(stamp(&db).as_deref(), Some(day.to_string().as_str())); + + db.compact_search_indexes_if_due(day + 60).unwrap(); + assert_eq!(stamp(&db).as_deref(), Some(day.to_string().as_str())); +} + /// Queries below the trigram minimum still match, via the `LIKE` fallback. #[test] fn local_search_short_query_like_fallback() { diff --git a/crates/pdfs-core/src/localindex.rs b/crates/pdfs-core/src/localindex.rs index 8335ea8..c77996f 100644 --- a/crates/pdfs-core/src/localindex.rs +++ b/crates/pdfs-core/src/localindex.rs @@ -42,7 +42,7 @@ const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"]; /// Hard cap on indexed entries, so a pathological home directory cannot grow the /// database without bound. Reaching it stops the walk early. -const MAX_ENTRIES: usize = 400_000; +pub const MAX_ENTRIES: usize = 400_000; /// Rows handed to the sink at a time. Large enough that the per-transaction cost /// amortises, small enough that the writer lock is released often (FUSE diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index 64afdd2..9026a4c 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -723,16 +723,24 @@ fn scan_local_once( // has seen and stays indeterminate. let job = transfers.begin_background_job("Indexing this computer"); job.detail("Scanning your files"); + let mut failed = false; let walked = localindex::scan(&[home], &excludes, |batch| { if let Err(e) = db.local_upsert_batch(generation, &batch) { warn!(error = %e, "local index: batch write failed"); + failed = true; } }); - // Prune what this scan did not see and rebuild the FTS index over the rest, - // even if some batches failed — a partial index still beats none. + // Prune what this scan did not see, unless it did not see everything: + // a path it never reached, or one whose batch failed to write, is not a + // path that is gone (`docs/BUGS.md` B217). job.detail("Building the search index"); - match db.local_finish_scan(generation, now_secs()) { + let finished = if failed || walked >= localindex::MAX_ENTRIES { + db.local_finish_partial_scan(now_secs()) + } else { + db.local_finish_scan(generation, now_secs()) + }; + match finished { Ok(indexed) => info!( walked, indexed, diff --git a/docs/BUGS.md b/docs/BUGS.md index 201193b..0a470f8 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B217](#b217--the-search-indexes-grow-to-many-times-their-size-and-slow-search-down) | The search indexes grow to many times their size and slow search down | | [B216](#b216--a-damaged-database-is-rebuilt-without-the-writes-still-queued-in-it) | A damaged database is rebuilt without the writes still queued in it | | [B215](#b215--a-remote-name-holding--or--becomes-a-path-outside-the-sync-folder) | A remote name holding `/` or `..` becomes a path outside the sync folder | | [B214](#b214--deleting-a-mirror-folder-here-trashes-another-devices-new-files-in-it) | Deleting a mirror folder here trashes another device's new files in it | @@ -319,6 +320,33 @@ a live account since. Newest first. +### B217 — The search indexes grow to many times their size and slow search down + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.2.1 and 2.2.3). On a live 567 MiB +`cache.db`, `local_fts` held 317 MiB for 40,000 indexed paths. Rebuilt, it was 20 MiB, and a +search candidate query went from 27 ms to 7 ms. `nodes_fts` shrank from 21 to 16 MiB. +**Where:** `Db::local_finish_scan`, `Db::compact_search_indexes_if_due` in +`crates/pdfs-core/src/db/local.rs`; `index_local_files` in `crates/pdfs-fuse/src/background.rs` + +**Repro:** Run the daemon for weeks with a home directory where files come and go, then compare +the size of `local_fts_data` with what a `rebuild` leaves. + +**Cause.** FTS5 keeps a deleted entry's data until the segments holding it are merged, and with +the few large segments these indexes settle into, that is close to never. The client never ran +`optimize` or `rebuild`. The local scan made it worse: a scan that stopped at the entry cap, or +lost a batch, still pruned every path it had not reached, and the next scan put them all back. + +**Fix.** The search indexes are compacted at most once a day, at the end of a local scan: +`local_fts` is rebuilt from `local_files`, and `nodes_fts` is merged into one segment. Each takes +about half a second at that size. A scan that did not see everything closes with +`local_finish_partial_scan`, which prunes nothing. + +**Test:** `db::tests::compacting_the_search_indexes_keeps_them_whole_and_runs_once_a_day`, +`db::tests::a_partial_local_scan_prunes_nothing`. + +--- + ### B216 — A damaged database is rebuilt without the writes still queued in it **Status:** Fixed, not verified live From 2bea1f76720078272a371ad25e2df13aa68f1ea7 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 09:56:25 +0200 Subject: [PATCH 04/44] fix(core): check and count the database on a read connection PRAGMA integrity_check and the row counts behind pdfs cache inspect read the whole file, 34 s on a 567 MiB database, and they held the write connection while they did. Every write in the daemon waited behind them. They now read a WAL snapshot on a pooled read connection (B218). --- crates/pdfs-core/src/db/maintenance.rs | 47 ++++++++++++++++++++++++-- docs/BUGS.md | 22 ++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/pdfs-core/src/db/maintenance.rs b/crates/pdfs-core/src/db/maintenance.rs index 09e2005..8f0f9dc 100644 --- a/crates/pdfs-core/src/db/maintenance.rs +++ b/crates/pdfs-core/src/db/maintenance.rs @@ -91,8 +91,13 @@ impl Db { /// Returns an empty vector when the database is sound; otherwise each entry /// is one problem SQLite found. Read-only and safe against a live daemon, /// though it reads every page, so it is not free on a large file. + /// + /// Runs on a read connection: it reads every page, which took 34 s on a + /// 567 MiB file, and on the write connection every write in the daemon + /// waited that long behind it (`docs/BUGS.md` B218). WAL lets it read a + /// consistent snapshot while the writer goes on. pub fn integrity_check(&self) -> Result> { - let conn = self.conn.lock(); + let conn = self.read(); let mut stmt = conn.prepare("PRAGMA integrity_check")?; let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; let mut problems = Vec::new(); @@ -107,9 +112,11 @@ impl Db { Ok(problems) } - /// Size and row-count snapshot for `pdfs cache inspect`. + /// Size and row-count snapshot for `pdfs cache inspect`. On a read + /// connection, for the reason [`integrity_check`](Self::integrity_check) + /// gives: counting the rows of every table reads most of the file. pub fn stats(&self) -> Result { - let conn = self.conn.lock(); + let conn = self.read(); let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?; let free_pages: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?; let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?; @@ -218,6 +225,40 @@ mod tests { assert!(db.integrity_check().unwrap().is_empty()); } + /// `pdfs diagnose` and `pdfs cache inspect` read the whole file. Behind the + /// write lock they froze every write in the daemon for as long as that + /// took (`docs/BUGS.md` B218). + #[test] + fn the_integrity_check_and_stats_do_not_wait_for_the_writer() { + let path = std::env::temp_dir().join(format!( + "pdfs-check-{}-{}.db", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let db = std::sync::Arc::new(Db::open(&path).unwrap()); + let writer = db.conn.lock(); + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::sync::Arc::clone(&db); + std::thread::spawn(move || { + let checked = reader.integrity_check().map(|p| p.is_empty()); + let counted = reader.stats().map(|s| s.page_count > 0); + let _ = tx.send((checked.ok(), counted.ok())); + }); + let answer = rx.recv_timeout(std::time::Duration::from_secs(10)); + drop(writer); + assert_eq!(answer.ok(), Some((Some(true), Some(true)))); + drop(db); + for suffix in ["", "-wal", "-shm"] { + let mut file = path.as_os_str().to_owned(); + file.push(suffix); + let _ = std::fs::remove_file(std::path::PathBuf::from(file)); + } + let _ = std::fs::remove_file(path.with_extension("db.lock")); + } + #[test] fn stats_report_the_schema_and_the_known_tables() { let db = Db::open_in_memory().unwrap(); diff --git a/docs/BUGS.md b/docs/BUGS.md index 0a470f8..40df4e3 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B218](#b218--pdfs-diagnose-freezes-the-mount-while-it-checks-the-database) | `pdfs diagnose` freezes the mount while it checks the database | | [B217](#b217--the-search-indexes-grow-to-many-times-their-size-and-slow-search-down) | The search indexes grow to many times their size and slow search down | | [B216](#b216--a-damaged-database-is-rebuilt-without-the-writes-still-queued-in-it) | A damaged database is rebuilt without the writes still queued in it | | [B215](#b215--a-remote-name-holding--or--becomes-a-path-outside-the-sync-folder) | A remote name holding `/` or `..` becomes a path outside the sync folder | @@ -320,6 +321,27 @@ a live account since. Newest first. +### B218 — `pdfs diagnose` freezes the mount while it checks the database + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.2.2). `PRAGMA integrity_check` took +33.9 s on a copy of a live 567 MiB `cache.db`. +**Where:** `Db::integrity_check` and `Db::stats` in `crates/pdfs-core/src/db/maintenance.rs` + +**Repro:** Run `pdfs diagnose` or `pdfs cache inspect` on a large database while copying files +into the mount. + +**Cause.** Both read the whole file through the single write connection. Every write in the +daemon took that connection's mutex in the meantime: the write-through of the tree, the drain's +claims, every enqueue. So the mount looked hung for as long as the check ran. + +**Fix.** Both run on a pooled read connection. WAL lets them read a consistent snapshot while the +writer goes on. `VACUUM` still needs the write connection and remains a deliberate command. + +**Test:** `db::maintenance::tests::the_integrity_check_and_stats_do_not_wait_for_the_writer`. + +--- + ### B217 — The search indexes grow to many times their size and slow search down **Status:** Fixed, not verified live From 77dac97afe84ab15607eed3d8903ac4c8b6e5bdc Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 10:02:35 +0200 Subject: [PATCH 05/44] fix(fuse): keep slow remote calls off the dispatch loop and the metadata workers A shared folder's listing is fetched again once it is a minute old, and that fetch ran on the dispatch loop whenever the old listing was still resident, so every request on the mount waited for Drive (B219). Thumbnail downloads, size upgrades and the Shared with me listing had no deadline of their own. On a stalled link each held a mount thread for the SDK's retry budget, and a folder of photos filled the three metadata workers that every cold lookup and rename needs. Offline, an uncached thumbnail is now answered as absent, and these calls give up after 20 or 60 seconds (B220). --- crates/pdfs-fuse/src/lib.rs | 94 ++++++++++++++++++++++++++++++------ crates/pdfs-fuse/src/link.rs | 7 +++ docs/BUGS.md | 48 ++++++++++++++++++ 3 files changed, 133 insertions(+), 16 deletions(-) diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index a6935d5..447cd7e 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -116,6 +116,7 @@ mod upload; mod r#virtual; mod workers; use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync}; +use link::BATCH_CALL_TIMEOUT; pub(crate) use mount::is_stale_mount; pub use mount::{MountOptions, MountOutcome, mount}; use mount::{ @@ -2120,12 +2121,39 @@ impl Core { } } - /// Whether `ino`'s listing is already in memory, i.e. whether - /// [`Core::ensure_children`] would return without touching the network. - /// Lets a handler decide between answering inline and handing off to a - /// worker, at the cost of one uncontended map lookup. + /// Whether [`Core::ensure_children`] would answer for `ino` without + /// touching the network. Lets a handler decide between answering inline + /// and handing off to a worker. + /// + /// A resident listing is not enough on its own. A shared folder's, or the + /// Shared with me root's, is fetched again once it is a minute old, and + /// that fetch ran on the dispatch loop, so every request on the mount + /// waited for Drive to answer (`docs/BUGS.md` B219). fn children_cached(&self, ino: u64) -> bool { - self.state().children.contains_key(&ino) + let (uid, resident) = { + let st = self.state(); + match st.entries.get(&ino) { + Some(e) => (e.uid.clone(), st.children.contains_key(&ino)), + // `list_children` answers ENOENT without the network. + None => return true, + } + }; + if !resident { + return false; + } + let freshness = if is_virtual_uid(&uid) { + Some(SHARED_WITH_ME_SYNCED_MS.to_string()) + } else if !self.is_own_or_virtual(&uid) && !is_local_uid(&uid) { + Some(Self::shared_folder_freshness_key(&uid)) + } else { + None + }; + freshness.is_none_or(|key| { + !listing_needs_refresh( + self.online.load(Ordering::Relaxed), + self.shared_listing_stale(&key), + ) + }) } /// Re-apply the optimistic size of any queued write to `nodes`. @@ -2293,8 +2321,7 @@ impl Core { let generation = self.shared_generation.load(Ordering::SeqCst); let accepted = accepted_share_provenance( - self.rt - .block_on(self.client.enumerate_shared_with_me()) + self.block_on_within(BATCH_CALL_TIMEOUT, self.client.enumerate_shared_with_me()) .map_err(|error| { error!(%error, "enumerating shared roots failed"); Errno::EIO @@ -2303,12 +2330,14 @@ impl Core { let nodes = if accepted.uids.is_empty() { Vec::new() } else { - self.rt - .block_on(self.drive.enumerate_nodes_light(&accepted.uids)) - .map_err(|error| { - error!(%error, "materializing shared roots failed"); - Errno::EIO - })? + self.block_on_within( + BATCH_CALL_TIMEOUT, + self.drive.enumerate_nodes_light(&accepted.uids), + ) + .map_err(|error| { + error!(%error, "materializing shared roots failed"); + Errno::EIO + })? }; let virtual_uid = shared_with_me_uid(); let published = prepare_shared_roots(&accepted, nodes, &virtual_uid); @@ -2879,7 +2908,8 @@ impl Core { let (mut key, mut uids, mut slot) = (key, uids, slot); loop { for chunk in uids.chunks(SIZE_UPGRADE_CHUNK) { - let result = self.rt.block_on(self.drive.enumerate_nodes(chunk)); + let result = + self.block_on_within(BATCH_CALL_TIMEOUT, self.drive.enumerate_nodes(chunk)); self.apply_size_upgrade(key, result); slot.chunk_done(); } @@ -4378,9 +4408,15 @@ impl Core { if self.thumbnail_misses.lock().remote_contains(&key, mtime) { return Ok(None); } + // A file manager asks for one per file in a folder, on the metadata + // workers. Offline, or on a stalled link, each one held a worker for + // the SDK's whole retry budget, and every cold lookup, create and + // rename on the mount queued behind them (`docs/BUGS.md` B220). + if !self.is_online() { + return Ok(None); + } let bytes = self - .rt - .block_on(self.client.download_thumbnail(&uid, ttype)) + .block_on_bounded(self.client.download_thumbnail(&uid, ttype)) .map_err(|e| { warn!(%uid, error = %e, "download thumbnail failed"); Errno::EIO @@ -9201,6 +9237,32 @@ mod tests { assert!(online.contains("self.relist_offline_listings();")); } + #[test] + fn a_stale_shared_listing_is_fetched_off_the_dispatch_loop() { + // A resident listing that `list_children` would fetch again anyway + // must not be answered inline (B219). + let cached = function_source(include_str!("lib.rs"), "fn children_cached("); + assert!(cached.contains("SHARED_WITH_ME_SYNCED_MS")); + assert!(cached.contains("Self::shared_folder_freshness_key(&uid)")); + assert!(cached.contains("listing_needs_refresh(")); + assert!(cached.contains("self.shared_listing_stale(&key)")); + } + + #[test] + fn a_handler_does_not_wait_out_the_sdk_retry_budget() { + // Thumbnails, size upgrades and the share list held mount threads for + // minutes on a stalled link (B220). + let lib = include_str!("lib.rs"); + let thumbnail = function_source(lib, "fn thumbnail("); + assert_before(thumbnail, "if !self.is_online()", "download_thumbnail"); + assert!(thumbnail.contains(".block_on_bounded(self.client.download_thumbnail(")); + let upgrade = function_source(lib, "fn run_size_upgrade("); + assert!(upgrade.contains("block_on_within(BATCH_CALL_TIMEOUT")); + let shared = function_source(lib, "fn ensure_shared_children("); + assert!(shared.contains("block_on_within(BATCH_CALL_TIMEOUT")); + assert!(!shared.contains("self.rt")); + } + #[test] fn a_remote_read_gives_up_at_its_deadline() { // The SDK bounds requests, not reads; a stalled link held one `read` diff --git a/crates/pdfs-fuse/src/link.rs b/crates/pdfs-fuse/src/link.rs index e2c4df0..bd61914 100644 --- a/crates/pdfs-fuse/src/link.rs +++ b/crates/pdfs-fuse/src/link.rs @@ -29,6 +29,13 @@ use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN}; /// keep waiting for; queueing the change is the better answer. pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20); +/// How long a mount thread waits on a remote read that answers a batch: a +/// chunk of file sizes, or the list of shares. Longer than an interactive call, +/// since its answer grows with the batch, but well short of the SDK's own retry +/// budget, which held a worker for minutes on a stalled link +/// (`docs/BUGS.md` B220). +pub(crate) const BATCH_CALL_TIMEOUT: Duration = Duration::from_secs(60); + /// How long one probe may wait for an answer. A probe is a single small request, /// and a probe stuck in the SDK's retry budget would delay noticing the link is /// back by as long as it waits. diff --git a/docs/BUGS.md b/docs/BUGS.md index 40df4e3..9ef8d1d 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,8 @@ a live account since. | Entry | Title | |---|---| +| [B220](#b220--thumbnails-on-a-stalled-link-hold-up-every-other-request) | Thumbnails on a stalled link hold up every other request | +| [B219](#b219--listing-a-shared-folder-freezes-the-whole-mount-on-a-slow-link) | Listing a shared folder freezes the whole mount on a slow link | | [B218](#b218--pdfs-diagnose-freezes-the-mount-while-it-checks-the-database) | `pdfs diagnose` freezes the mount while it checks the database | | [B217](#b217--the-search-indexes-grow-to-many-times-their-size-and-slow-search-down) | The search indexes grow to many times their size and slow search down | | [B216](#b216--a-damaged-database-is-rebuilt-without-the-writes-still-queued-in-it) | A damaged database is rebuilt without the writes still queued in it | @@ -321,6 +323,52 @@ a live account since. Newest first. +### B220 — Thumbnails on a stalled link hold up every other request + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.3 and 2.1.11). +**Where:** `Core::thumbnail`, `Core::run_size_upgrade` and `Core::ensure_shared_children` in +`crates/pdfs-fuse/src/lib.rs` + +**Repro:** Open a folder of photos in a file manager while the link stalls. + +**Cause.** A file manager asks for a thumbnail per file, through `getxattr` on the metadata +workers. The download had no deadline of its own, so on a stalled link each one held a worker for +the SDK's whole retry budget, about two minutes. Only three workers are kept for metadata, so +every cold `lookup`, `create` and `rename` on the mount queued behind them. Offline, every +uncached thumbnail still went to the network. The size upgrade and the Shared with me listing +had the same unbounded calls. + +**Fix.** Offline, a thumbnail that is not cached is answered as absent. Online, its download +gives up after `INTERACTIVE_CALL_TIMEOUT` (20 s). Each chunk of a size upgrade and the two calls +behind the Shared with me listing give up after `BATCH_CALL_TIMEOUT` (60 s). + +**Test:** `tests::a_handler_does_not_wait_out_the_sdk_retry_budget`. + +--- + +### B219 — Listing a shared folder freezes the whole mount on a slow link + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.1). +**Where:** `Core::children_cached` in `crates/pdfs-fuse/src/lib.rs`; `lookup`, `opendir` and +`readdir` in `crates/pdfs-fuse/src/filesystem.rs` + +**Repro:** Open a folder shared with you, wait more than a minute, then `ls` it on a slow link +while another program reads files elsewhere on the mount. + +**Cause.** A handler answered inline whenever the folder's listing was resident, and handed off +to a worker only otherwise. But `list_children` fetches a shared folder again once its listing is +a minute old, and the Shared with me root likewise. That fetch ran on the dispatch loop, the +single thread that reads every request of the mount, so all of them waited for Drive. + +**Fix.** `children_cached` asks the same question `list_children` does: a shared listing that +would be fetched again counts as not cached, so the request goes to a metadata worker. + +**Test:** `tests::a_stale_shared_listing_is_fetched_off_the_dispatch_loop`. + +--- + ### B218 — `pdfs diagnose` freezes the mount while it checks the database **Status:** Fixed, not verified live From 830cfd9faa97973b4ddada50427fbb42f8e4691e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 10:05:58 +0200 Subject: [PATCH 06/44] fix(fuse): allocate a listing's inodes before taking the state lock A cold listing allocated the local id of every new child in a write transaction while it held the global state lock, then marked the folder listed, still under it. Every inline handler of every mount waited on that transaction, and the transaction on the write connection the drain shares. The ids are now allocated before the lock is taken, and the folder is marked listed through the outbox after its children's rows, so a crash in between no longer leaves it recorded as complete and empty (B221). --- crates/pdfs-fuse/src/filesystem.rs | 4 +- crates/pdfs-fuse/src/lib.rs | 10 +-- crates/pdfs-fuse/src/state.rs | 98 +++++++++++++++++++++++++++--- docs/BUGS.md | 30 +++++++++ 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/crates/pdfs-fuse/src/filesystem.rs b/crates/pdfs-fuse/src/filesystem.rs index 9dd60cb..d556171 100644 --- a/crates/pdfs-fuse/src/filesystem.rs +++ b/crates/pdfs-fuse/src/filesystem.rs @@ -1717,9 +1717,7 @@ impl ProtonFs { // empty, which it is, so reads stay offline-clean across a restart too. if local { st.children.insert(ino, Vec::new()); - if let Err(e) = self.core.db.set_listed(&uid, true) { - warn!(%uid, error = %e, "db set_listed(true) failed for local folder"); - } + st.record_listed(uid); } let entry = st.entries.get(&ino).unwrap(); let attr = self.attr(ino, &entry.node, entry.access); diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 447cd7e..229bc66 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -2716,6 +2716,7 @@ impl Core { return Ok(()); } + let preassigned = state::inos_for(&self.db, &filtered_nodes); let mut st = self.state(); // Lost the race? Another thread already populated it. if st.children.contains_key(&ino) { @@ -2737,15 +2738,14 @@ impl Core { }) .map(|n| n.uid.clone()) .collect(); - let inos = st.intern_batch(ino, filtered_nodes); + let inos = st.intern_batch_with_inos(ino, filtered_nodes, preassigned); child_inos.extend(inos); st.children.insert(ino, child_inos); let root_snapshot = primary_root.then(|| RootListingSnapshot::capture(&st, ino).unwrap()); // Record the listing as complete so a later restart (or a trimmed hot - // cache) can rebuild it from the DB without the API. - if let Err(e) = self.db.set_listed(&folder_uid, true) { - warn!(%folder_uid, error = %e, "db set_listed(true) failed"); - } + // cache) can rebuild it from the DB without the API. Written with the + // children's rows when the lock goes, not under it. + st.record_listed(folder_uid.clone()); drop(st); self.flush_access_changes(); if let Some(snapshot) = root_snapshot { diff --git a/crates/pdfs-fuse/src/state.rs b/crates/pdfs-fuse/src/state.rs index 7631662..aaf7996 100644 --- a/crates/pdfs-fuse/src/state.rs +++ b/crates/pdfs-fuse/src/state.rs @@ -212,6 +212,31 @@ pub(crate) fn lid_ino(lid: i64) -> u64 { lid.unsigned_abs() + crate::ROOT_INO } +/// The inodes of the stored nodes among `nodes`, from their local ids, for +/// [`State::intern_batch_with_inos`]. Called before the state lock is taken; +/// a node that already has a row keeps its id. Empty when the database fails, +/// which leaves the allocation to the lock holder, as before. +pub(crate) fn inos_for(db: &Db, nodes: &[Node]) -> HashMap { + let stored: Vec<&Node> = nodes + .iter() + .filter(|node| !crate::r#virtual::is_virtual_uid(&node.uid)) + .collect(); + if stored.is_empty() { + return HashMap::new(); + } + match db.lids_for(&stored) { + Ok(lids) => stored + .iter() + .zip(lids) + .map(|(node, lid)| (node.uid.clone(), lid_ino(lid))) + .collect(), + Err(error) => { + warn!(%error, "no local ids before the listing; allocating them under the lock"); + HashMap::new() + } + } +} + /// The local id of the node at inode `ino`, the inverse of [`lid_ino`]: /// `None` for the root and a fallback inode. pub(crate) fn ino_lid(ino: u64) -> Option { @@ -1074,20 +1099,34 @@ impl State { /// /// This is what keeps `ls` on a large folder quick: one commit for the /// listing, rather than one autocommit — and one fsync — per child. - pub(crate) fn intern_batch(&mut self, parent: u64, mut nodes: Vec) -> Vec { + #[cfg(test)] + pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec) -> Vec { + self.intern_batch_with_inos(parent, nodes, HashMap::new()) + } + + /// [`State::intern_batch`], with inodes for new entries that the caller + /// allocated before it took the lock, from [`inos_for`]. Allocating them + /// is a write transaction, and under the lock every inline handler of + /// every mount waited for it (`docs/BUGS.md` B221). A node missing from + /// `inos` still gets one here. + pub(crate) fn intern_batch_with_inos( + &mut self, + parent: u64, + mut nodes: Vec, + mut inos: HashMap, + ) -> Vec { for node in &mut nodes { self.keep_revision_mtime(node); } self.outbox.push(DbWrite::Upsert(nodes.clone())); let fresh: Vec<&Node> = nodes .iter() - .filter(|node| !self.by_uid.contains_key(&node.uid)) + .filter(|node| !self.by_uid.contains_key(&node.uid) && !inos.contains_key(&node.uid)) .collect(); - let mut inos = if fresh.is_empty() { - HashMap::new() - } else { - self.new_inos(&fresh) - }; + if !fresh.is_empty() { + let allocated = self.new_inos(&fresh); + inos.extend(allocated); + } nodes .into_iter() .map(|node| { @@ -1098,6 +1137,13 @@ impl State { .collect() } + /// Record `uid`'s listing as complete once the lock is released, after the + /// rows of its children: written the other way round, a crash between the + /// two left a folder marked complete with no children to show. + pub(crate) fn record_listed(&mut self, uid: NodeUid) { + self.outbox.push(DbWrite::SetListed(uid, true)); + } + /// Check if a directory inode has any child nodes in memory or in the database. pub(crate) fn has_children(&self, parent: u64) -> bool { // A resident listing is authoritative even when empty. Falling through @@ -1467,6 +1513,44 @@ mod tests { ); } + /// A listing's inodes are allocated before the lock is taken, and are the + /// ones a node would get under it (`docs/BUGS.md` B221). + #[test] + fn inodes_allocated_before_the_lock_are_the_ones_the_node_keeps() { + let (mut st, _dir) = state(); + let db = st.db.clone(); + let listing = vec![ + node("a", "root", "a.txt", false), + node("b", "root", "b.txt", false), + ]; + let early = inos_for(&db, &listing); + assert_eq!(early.len(), 2); + let inos = st.intern_batch_with_inos(0, listing, early.clone()); + assert_eq!(inos, [early[&uid("a")], early[&uid("b")]]); + st.flush_outbox(); + + let mut restarted = State::new(db, HashMap::new()); + assert_eq!( + restarted.intern(0, node("b", "root", "b.txt", false)), + early[&uid("b")] + ); + } + + /// The listing is recorded complete with its children's rows, when the + /// lock goes, never ahead of them. + #[test] + fn a_listing_is_recorded_complete_after_its_children() { + let (mut st, _dir) = state(); + let root = st.intern(0, node("root", "none", "root", true)); + st.flush_outbox(); + st.intern_batch(root, vec![node("a", "root", "a.txt", false)]); + st.record_listed(uid("root")); + assert!(st.db.children_if_listed(&uid("root")).unwrap().is_none()); + st.flush_outbox(); + let kids = st.db.children_if_listed(&uid("root")).unwrap().unwrap(); + assert_eq!(kids.len(), 1); + } + /// Audit A5. Moving a file out of a folder leaves that folder's DB row /// claiming a complete listing, so once the hot cache drops it, remote /// changes under it stop being seen. `relocate` has to clear both sides. diff --git a/docs/BUGS.md b/docs/BUGS.md index 9ef8d1d..da4beaa 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B221](#b221--a-cold-listing-holds-the-global-lock-across-a-database-transaction) | A cold listing holds the global lock across a database transaction | | [B220](#b220--thumbnails-on-a-stalled-link-hold-up-every-other-request) | Thumbnails on a stalled link hold up every other request | | [B219](#b219--listing-a-shared-folder-freezes-the-whole-mount-on-a-slow-link) | Listing a shared folder freezes the whole mount on a slow link | | [B218](#b218--pdfs-diagnose-freezes-the-mount-while-it-checks-the-database) | `pdfs diagnose` freezes the mount while it checks the database | @@ -323,6 +324,35 @@ a live account since. Newest first. +### B221 — A cold listing holds the global lock across a database transaction + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.4). +**Where:** `Core::list_children` in `crates/pdfs-fuse/src/lib.rs`; `State::intern_batch_with_inos`, +`inos_for` and `State::record_listed` in `crates/pdfs-fuse/src/state.rs`; `serve_mkdir` in +`crates/pdfs-fuse/src/filesystem.rs` + +**Repro:** List a large folder for the first time while another program stats files elsewhere in +the mount, and while the drain is busy. + +**Cause.** A new node's inode comes from its row's local id, which `Db::lids_for` allocates in a +write transaction. A cold listing ran that transaction for every new child while it held the +global state lock, and then marked the folder listed, also under the lock. Every inline handler +of every mount waits on that lock, and the transaction itself waits on the single write +connection that the drain and the sync engine also use. That went against the `StateGuard` +outbox, which exists so that the lock is released before the writes are done. Marking the folder +listed before its children's rows were written also meant a crash in between left a folder +recorded as complete with no children. + +**Fix.** `inos_for` allocates the listing's ids before the lock is taken, and +`intern_batch_with_inos` uses them. Marking a folder listed goes through the outbox with +`record_listed`, after the children's rows. + +**Test:** `state::tests::inodes_allocated_before_the_lock_are_the_ones_the_node_keeps`, +`state::tests::a_listing_is_recorded_complete_after_its_children`. + +--- + ### B220 — Thumbnails on a stalled link hold up every other request **Status:** Fixed, not verified live From bca22056b0d24443d293ea03ee093b0cded98007 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 10:32:05 +0200 Subject: [PATCH 07/44] fix(fuse): report write errors at close, and take fsync and fallocate as POSIX does flush answered ok at once and release synced the scratch file, but close(2) only returns what flush answers, so a full disk at close was reported to nobody. flush now syncs a handle with writes on a worker and returns ENOSPC, EDQUOT or EIO; release then finds the pages on disk instead of writing a whole file back on the dispatch loop. fsync on a descriptor opened for reading no longer fails with EBADF (B222). fallocate passed any mode through and tracked only a punched hole or growth: a zeroed range came back from the remote base at commit, and a collapse or insert corrupted the file. Unsupported modes now get EOPNOTSUPP, zeroed ranges count as written, the real errno is returned, and the state lock is not held across the syscall (B224). --- crates/pdfs-fuse/src/filesystem.rs | 250 +++++++++++++++++++++++------ crates/pdfs-fuse/src/sim/daemon.rs | 38 +++++ docs/BUGS.md | 56 +++++++ 3 files changed, 297 insertions(+), 47 deletions(-) diff --git a/crates/pdfs-fuse/src/filesystem.rs b/crates/pdfs-fuse/src/filesystem.rs index d556171..bde3d3b 100644 --- a/crates/pdfs-fuse/src/filesystem.rs +++ b/crates/pdfs-fuse/src/filesystem.rs @@ -556,48 +556,62 @@ impl Filesystem for ProtonFs { return; } let new_end = offset.saturating_add(length); - let keep_size = (mode & 1) != 0; // FALLOC_FL_KEEP_SIZE - - let is_punch_hole = (mode & 0x02) != 0; // FALLOC_FL_PUNCH_HOLE - - let res = { + // Only the modes whose effect on the scratch file this handle can + // record. Anything else went through to the scratch file untracked: + // a zeroed range was refilled from the base at commit, and a collapse + // or insert shifted bytes that `len` and `written` did not follow + // (`docs/BUGS.md` B224). `EOPNOTSUPP` is also what makes glibc's + // `posix_fallocate` fall back to writing zeros. + let Some(kind) = falloc_kind(mode) else { + reply.error(Errno::EOPNOTSUPP); + return; + }; + let file = { + let st = self.core.state(); + match st.handles.get(&_fh.0).and_then(|i| st.active_writes.get(i)) { + Some(aw) => aw.file.clone(), + None => { + reply.error(if st.entries.contains_key(&ino.0) { + Errno::EBADF + } else { + Errno::ENOENT + }); + return; + } + } + }; + // The syscall can take a while on a large range; the state lock is + // not held across it, as `write` does not hold it across its I/O. + let ret = { + use std::os::unix::io::AsRawFd; + unsafe { libc::fallocate(file.as_raw_fd(), mode, offset as i64, length as i64) } + }; + let res = if ret != 0 { + Err(Errno::from(std::io::Error::last_os_error())) + } else { let mut st = self.core.state(); - match st + if let Some(aw) = st .handles .get(&_fh.0) .copied() .and_then(|i| st.active_writes.get_mut(&i)) { - Some(aw) => { - use std::os::unix::io::AsRawFd; - let fd = aw.file.as_raw_fd(); - let ret = unsafe { libc::fallocate(fd, mode, offset as i64, length as i64) }; - if ret == 0 { - if is_punch_hole { - // A punched range reads as zero. Mark it authored so - // queue_revision does not refill it from the remote - // baseline and silently undo the hole at commit. - aw.written.add(offset, new_end.min(aw.len)); - aw.dirty = true; - } else if !keep_size && new_end > aw.len { - aw.written.add(aw.len, new_end); - aw.len = new_end; - aw.dirty = true; - st.set_size(ino.0, new_end); - } - Ok(()) - } else { - Err(Errno::EIO) - } + let len = aw.len; + let (authored, grown) = kind.effect(offset, new_end, len); + if let Some((from, to)) = authored { + // Zeroed or punched bytes read as zero. Marked authored so + // queue_revision does not refill them from the remote + // baseline and silently undo them at commit. + aw.written.add(from, to); + aw.dirty = true; } - None => { - if st.entries.contains_key(&ino.0) { - Err(Errno::EBADF) - } else { - Err(Errno::ENOENT) - } + if let Some(end) = grown { + aw.len = end; + aw.dirty = true; + st.set_size(ino.0, end); } } + Ok(()) }; match res { @@ -606,18 +620,47 @@ impl Filesystem for ProtonFs { } } - /// `close(2)` calls flush before release. The upload is queued in `release` - /// and performed in the background, so there is nothing - /// to push here — the written bytes are already in the scratch file. + /// `close(2)` calls flush before release, and flush is the only one of the + /// two whose answer reaches the program: the kernel drops `release`'s. So + /// the scratch file of a handle with writes on it is synced here, on a + /// worker, and a full disk or a failing one is what `close` returns. + /// + /// Before, flush answered `ok` at once and `release` synced the file, so an + /// `ENOSPC` that delayed allocation held back until then was reported to + /// nobody, and the program took the save as done (`docs/BUGS.md` B222). + /// It also means `release`, which stays on the dispatch loop for ordering, + /// finds the pages already on disk instead of writing back a whole file + /// while every other request on the mount waits. fn flush( &self, _req: &Request, _ino: INodeNo, - _fh: FileHandle, + fh: FileHandle, _lock_owner: LockOwner, reply: ReplyEmpty, ) { - reply.ok(); + let file = { + let st = self.core.state(); + st.handles + .get(&fh.0) + .and_then(|ino| st.active_writes.get(ino)) + .filter(|aw| aw.dirty) + .map(|aw| aw.file.clone()) + }; + let Some(file) = file else { + reply.ok(); + return; + }; + let fs = self.clone(); + self.core + .workers + .run(Lane::Transfer, "flush", move || match file.sync_data() { + Ok(()) => reply.ok(), + Err(e) => { + error!(fh = fh.0, error = %e, "flush of scratch file failed"); + reply.error(fs.scratch_sync_errno(&e)); + } + }); } /// Durability here means the scratch file, not the remote: a caller asking @@ -1022,6 +1065,52 @@ fn build_listing(st: &State, ino: u64) -> DirListing { /// The cookie handed back is the index of the *next* entry, so a resumed call /// continues exactly where the last one stopped — which is only true because /// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43). +/// What an `fallocate` mode does to a write handle, for the modes it can +/// record (`docs/BUGS.md` B224). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FallocKind { + /// Reserve space; past the end it grows the file unless `keep_size`. + Allocate { keep_size: bool }, + /// Deallocate a range inside the file, which then reads as zero. + Punch, + /// Zero a range; past the end it grows the file unless `keep_size`. + Zero { keep_size: bool }, +} + +fn falloc_kind(mode: i32) -> Option { + const KEEP: i32 = libc::FALLOC_FL_KEEP_SIZE; + const PUNCH: i32 = libc::FALLOC_FL_PUNCH_HOLE; + const ZERO: i32 = libc::FALLOC_FL_ZERO_RANGE; + match mode { + 0 => Some(FallocKind::Allocate { keep_size: false }), + KEEP => Some(FallocKind::Allocate { keep_size: true }), + m if m == PUNCH | KEEP => Some(FallocKind::Punch), + ZERO => Some(FallocKind::Zero { keep_size: false }), + m if m == ZERO | KEEP => Some(FallocKind::Zero { keep_size: true }), + _ => None, + } +} + +impl FallocKind { + /// For `[offset, end)` on a file `len` bytes long: the range that now + /// reads as zero and must count as written, and the new length if it grew. + fn effect(self, offset: u64, end: u64, len: u64) -> (Option<(u64, u64)>, Option) { + let grow = |keep_size: bool| (!keep_size && end > len).then_some(end); + let zeroed = |to: u64| (offset < to).then_some((offset, to)); + match self { + // Space past the old end reads as zero, and nothing before it moved. + Self::Allocate { keep_size } => { + (grow(keep_size).map(|end| (len, end)), grow(keep_size)) + } + Self::Punch => (zeroed(end.min(len)), None), + Self::Zero { keep_size } => { + let to = if keep_size { end.min(len) } else { end }; + (zeroed(to), grow(keep_size)) + } + } + } +} + fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) { for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) { let ft = if *is_dir { @@ -1435,6 +1524,20 @@ impl ProtonFs { } /// The body of [`Filesystem::fsync`], run off the dispatch loop. + /// The errno to give a program for a failed sync of a scratch file. + /// Delayed allocation means a full disk usually surfaces at the sync + /// rather than at `write(2)`, and `ENOSPC` is what a program checks for. + fn scratch_sync_errno(&self, e: &std::io::Error) -> Errno { + match e.raw_os_error() { + Some(libc::ENOSPC) => { + self.core.cache.emergency_evict(); + Errno::ENOSPC + } + Some(libc::EDQUOT) => Errno::EDQUOT, + _ => Errno::EIO, + } + } + fn serve_fsync(&self, fh: FileHandle, reply: ReplyEmpty) { // Everything the durability sidecar needs, copied out so no lock is held // across the sync. @@ -1455,22 +1558,18 @@ impl ProtonFs { ) }) }; + // A handle opened for reading has nothing of its own to make durable, + // and POSIX allows `fsync` on it; `EBADF` failed `sync FILE` and + // backup tools that sync what they read (`docs/BUGS.md` B222). let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) = handle else { - reply.error(Errno::EBADF); + reply.ok(); return; }; if let Err(e) = f.sync_all() { error!(fh = fh.0, error = %e, "fsync of scratch file failed"); - // Delayed allocation means a full disk usually surfaces here rather - // than at `write(2)`; `ENOSPC` is what an application checks for. - if e.raw_os_error() == Some(libc::ENOSPC) { - self.core.cache.emergency_evict(); - reply.error(Errno::ENOSPC); - } else { - reply.error(Errno::EIO); - } + reply.error(self.scratch_sync_errno(&e)); return; } // A clean handle has nothing on it that the remote does not already @@ -2335,3 +2434,60 @@ impl ProtonFs { } } } + +#[cfg(test)] +mod falloc_tests { + use super::*; + + #[test] + fn only_the_modes_a_handle_can_record_are_accepted() { + let keep = libc::FALLOC_FL_KEEP_SIZE; + assert_eq!( + falloc_kind(0), + Some(FallocKind::Allocate { keep_size: false }) + ); + assert_eq!( + falloc_kind(keep), + Some(FallocKind::Allocate { keep_size: true }) + ); + assert_eq!( + falloc_kind(libc::FALLOC_FL_PUNCH_HOLE | keep), + Some(FallocKind::Punch) + ); + assert_eq!( + falloc_kind(libc::FALLOC_FL_ZERO_RANGE), + Some(FallocKind::Zero { keep_size: false }) + ); + for mode in [ + libc::FALLOC_FL_PUNCH_HOLE, + libc::FALLOC_FL_COLLAPSE_RANGE, + libc::FALLOC_FL_INSERT_RANGE, + libc::FALLOC_FL_UNSHARE_RANGE, + ] { + assert_eq!(falloc_kind(mode), None, "{mode:#x}"); + } + } + + /// A zeroed range was left unwritten, so the commit filled it back in + /// from the remote base (`docs/BUGS.md` B224). + #[test] + fn a_zeroed_or_punched_range_counts_as_written() { + let zero = FallocKind::Zero { keep_size: false }; + assert_eq!(zero.effect(10, 20, 100), (Some((10, 20)), None)); + assert_eq!(zero.effect(90, 120, 100), (Some((90, 120)), Some(120))); + let zero_keep = FallocKind::Zero { keep_size: true }; + assert_eq!(zero_keep.effect(90, 120, 100), (Some((90, 100)), None)); + assert_eq!( + FallocKind::Punch.effect(90, 120, 100), + (Some((90, 100)), None) + ); + assert_eq!(FallocKind::Punch.effect(120, 130, 100), (None, None)); + let alloc = FallocKind::Allocate { keep_size: false }; + assert_eq!(alloc.effect(0, 50, 100), (None, None)); + assert_eq!(alloc.effect(90, 120, 100), (Some((100, 120)), Some(120))); + assert_eq!( + FallocKind::Allocate { keep_size: true }.effect(90, 120, 100), + (None, None) + ); + } +} diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 7486b4b..600c176 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -1303,6 +1303,44 @@ mod tests { db.unwrap() } + #[test] + #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] + fn close_syncs_a_written_file_and_fsync_accepts_a_read_only_one() { + // `close` is answered by flush, which now syncs the scratch file so a + // failure reaches the program; `fsync` on a descriptor opened for + // reading failed with EBADF (B222). + use std::io::{Read, Write}; + let drive = FakeDrive::new(); + let dir = scratch("flush-fsync"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let path = daemon.mountpoint.join("notes.txt"); + + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(b"written then closed").unwrap(); + drop(file); + let mut again = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + again.write_all(b", twice").unwrap(); + again.flush().unwrap(); + drop(again); + + let mut read_only = std::fs::File::open(&path).unwrap(); + read_only.sync_all().unwrap(); + let mut text = String::new(); + read_only.read_to_string(&mut text).unwrap(); + assert_eq!(text, "written then closed, twice"); + drop(read_only); + + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + })); + assert!(drive.lookup("notes.txt").is_some()); + assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_stop_ends_a_mirror_pass_after_the_uploads_it_is_sending() { diff --git a/docs/BUGS.md b/docs/BUGS.md index da4beaa..78bea66 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,8 @@ a live account since. | Entry | Title | |---|---| +| [B224](#b224--fallocate-zero-ranges-are-undone-and-collapse-ranges-corrupt-the-file) | `fallocate` zero ranges are undone and collapse ranges corrupt the file | +| [B222](#b222--a-full-disk-at-close-is-reported-to-nobody-and-fsync-on-a-read-only-file-fails) | A full disk at close is reported to nobody, and `fsync` on a read-only file fails | | [B221](#b221--a-cold-listing-holds-the-global-lock-across-a-database-transaction) | A cold listing holds the global lock across a database transaction | | [B220](#b220--thumbnails-on-a-stalled-link-hold-up-every-other-request) | Thumbnails on a stalled link hold up every other request | | [B219](#b219--listing-a-shared-folder-freezes-the-whole-mount-on-a-slow-link) | Listing a shared folder freezes the whole mount on a slow link | @@ -324,6 +326,60 @@ a live account since. Newest first. +### B224 — `fallocate` zero ranges are undone and collapse ranges corrupt the file + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, FUSE semantics). +**Where:** `fallocate`, `falloc_kind` and `FallocKind::effect` in +`crates/pdfs-fuse/src/filesystem.rs` + +**Repro:** `fallocate --zero-range -o 0 -l 4096 FILE` on a file in the mount whose content is on +Drive, then close it: the range comes back with its old bytes. `fallocate --collapse-range` on the +same file corrupts it. + +**Cause.** The handler passed any mode to the scratch file and recorded only a punched hole or +growth. A zeroed range stayed "not written", so the commit filled it in again from the remote +base. A collapse or an insert moved bytes in the scratch file that `len` and `written` did not +follow. Every failure came back as `EIO`, so glibc's `posix_fallocate` did not fall back to +writing zeros, and `ENOSPC` was lost. The syscall also ran under the global state lock. + +**Fix.** Only plain allocation, `KEEP_SIZE`, `PUNCH_HOLE | KEEP_SIZE` and `ZERO_RANGE` with or +without `KEEP_SIZE` are accepted; anything else gets `EOPNOTSUPP`. A zeroed or punched range +counts as written, and growth updates the length. The syscall's own errno is passed through, and +the lock is not held across it. + +**Test:** `filesystem::falloc_tests::only_the_modes_a_handle_can_record_are_accepted`, +`filesystem::falloc_tests::a_zeroed_or_punched_range_counts_as_written`. + +--- + +### B222 — A full disk at close is reported to nobody, and `fsync` on a read-only file fails + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.5 and section 3, FUSE semantics). +**Where:** `flush`, `serve_fsync` and `scratch_sync_errno` in `crates/pdfs-fuse/src/filesystem.rs` + +**Repro:** Fill the disk that holds the cache, then save a file in the mount: the program reports +success. Separately, `python3 -c 'import os; os.fsync(os.open("FILE", os.O_RDONLY))'` on a file +in the mount fails with `EBADF`. + +**Cause.** +- `flush` answered `ok` at once, and `release` synced the scratch file and queued it. `close(2)` + returns what `flush` answers; the kernel drops the answer to `release`. Delayed allocation + usually holds `ENOSPC` back until the sync, so a full disk was reported to nobody. +- The same sync in `release` wrote back the whole file on the dispatch loop, so copying a large + file stalled every other request on the mount. +- A descriptor opened for reading has no write handle, and `fsync` answered it with `EBADF`, + which POSIX does not allow. + +**Fix.** `flush` syncs the scratch file of a handle with writes on it, on a transfer worker, and +returns `ENOSPC`, `EDQUOT` or `EIO` when that fails. `release` then finds the pages already on +disk. `fsync` on a descriptor without a write handle succeeds. + +**Test:** `sim::daemon::tests::close_syncs_a_written_file_and_fsync_accepts_a_read_only_one`. + +--- + ### B221 — A cold listing holds the global lock across a database transaction **Status:** Fixed, not verified live From b0ff6ee44139dfb1cbe508620d88c0737efdedb2 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 10:32:05 +0200 Subject: [PATCH 08/44] fix(core): never write defaults over a config file that does not parse load_config gives defaults for a file it cannot read, and every setter loads, changes one field and saves. The first settings change after the file was damaged therefore wrote defaults over the mountpoint, the adopted device and the ignore rules. save_config now refuses while the file on disk does not parse, and the first-run default is written atomically with mode 0600 (B223). --- crates/pdfs-core/src/config.rs | 51 ++++++++++++++++++++++++++++++++-- docs/BUGS.md | 26 +++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index 74b7342..5d60d74 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -310,6 +310,22 @@ impl AppConfig { } } +/// Fail when the config file at `path` exists but does not parse, so that +/// [`AppDirs::save_config`] never writes over settings it could not read. +fn refuse_to_replace_unreadable(path: &Path) -> Result<()> { + match std::fs::read_to_string(path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(_) => Ok(()), + Err(e) => Err(Error::Other(format!( + "{} cannot be read ({e}); fix it or move it aside before changing settings", + path.display() + ))), + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + /// Resolved XDG locations for state, cache, and the default mountpoint. pub struct AppDirs { dirs: ProjectDirs, @@ -341,6 +357,7 @@ impl AppDirs { // Preserve the original bytes for diagnosis/recovery. In // particular, never replace a truncated config with defaults // and silently lose mount, device, or ignore settings. + // `save_config` refuses to write over it for the same reason. tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it"); return AppConfig::default(); } @@ -348,9 +365,8 @@ impl AppDirs { } let default_config = AppConfig::default(); - if let Ok(content) = serde_json::to_string_pretty(&default_config) { - let _ = std::fs::create_dir_all(self.dirs.config_dir()); - let _ = std::fs::write(&path, content); + if let Err(e) = self.save_config(&default_config) { + tracing::warn!(path = %path.display(), error = %e, "could not write the default config"); } default_config } @@ -358,8 +374,15 @@ impl AppDirs { /// Persist `config` to [`config_path`](Self::config_path), creating the /// config dir if missing. The Settings page calls this after editing the /// cache budget or mountpoint; the daemon re-reads the file on its next mount. + /// + /// Refuses while the file on disk does not parse. Every caller loads, + /// changes one field and saves, and [`load_config`](Self::load_config) + /// gives defaults for a file it cannot read, so the first change after + /// the file was damaged wrote defaults over the mountpoint, the device and + /// the ignore rules the user had (`docs/BUGS.md` B223). pub fn save_config(&self, config: &AppConfig) -> Result<()> { let path = self.config_path(); + refuse_to_replace_unreadable(&path)?; let parent = path .parent() .ok_or_else(|| Error::Other("configuration path has no parent".into()))?; @@ -526,6 +549,28 @@ mod tests { std::fs::metadata(p).unwrap().permissions().mode() & 0o777 } + /// A damaged config must not be replaced by defaults the next time a + /// setting changes (`docs/BUGS.md` B223). + #[test] + fn an_unreadable_config_is_never_written_over() { + let dir = std::env::temp_dir().join(format!( + "pdfs-cfg-guard-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + + assert!(refuse_to_replace_unreadable(&path).is_ok(), "no file yet"); + std::fs::write(&path, serde_json::to_vec(&AppConfig::default()).unwrap()).unwrap(); + assert!(refuse_to_replace_unreadable(&path).is_ok()); + std::fs::write(&path, br#"{"mountpoint": "/home/me/Pro"#).unwrap(); + assert!(refuse_to_replace_unreadable(&path).is_err(), "truncated"); + + let _ = std::fs::remove_dir_all(&dir); + } + /// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these /// directories hold decrypted content, a plaintext index of every node name, /// and the control socket that commands the daemon's session. diff --git a/docs/BUGS.md b/docs/BUGS.md index 78bea66..7fd0806 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B223](#b223--a-damaged-config-file-is-replaced-with-defaults-on-the-next-settings-change) | A damaged config file is replaced with defaults on the next settings change | | [B224](#b224--fallocate-zero-ranges-are-undone-and-collapse-ranges-corrupt-the-file) | `fallocate` zero ranges are undone and collapse ranges corrupt the file | | [B222](#b222--a-full-disk-at-close-is-reported-to-nobody-and-fsync-on-a-read-only-file-fails) | A full disk at close is reported to nobody, and `fsync` on a read-only file fails | | [B221](#b221--a-cold-listing-holds-the-global-lock-across-a-database-transaction) | A cold listing holds the global lock across a database transaction | @@ -326,6 +327,31 @@ a live account since. Newest first. +### B223 — A damaged config file is replaced with defaults on the next settings change + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.10). +**Where:** `AppDirs::save_config`, `AppDirs::load_config` and `refuse_to_replace_unreadable` in +`crates/pdfs-core/src/config.rs` + +**Repro:** Truncate `~/.config/proton-drive-linux/config.json`, then change any setting in the +app, or let the daemon record its device. + +**Cause.** `load_config` gives defaults for a file it cannot parse, and logs that it leaves the +file alone. But every setter loads, changes one field and saves, in the daemon and in the app. +So the first change after the damage wrote the defaults over the file, losing the mountpoint, +the device the client had adopted, the ignore rules and the `open_with` choices. ARCHITECTURE +§10.5 says the file is never replaced with defaults. The first-run default was also written with a +plain `std::fs::write`, which is not atomic and takes the umask's mode. + +**Fix.** `save_config` refuses while the file on disk exists and does not parse, and says which +file to fix. The first-run default goes through `save_config`, so it is written atomically with +mode 0600. + +**Test:** `config::tests::an_unreadable_config_is_never_written_over`. + +--- + ### B224 — `fallocate` zero ranges are undone and collapse ranges corrupt the file **Status:** Fixed, not verified live From 96b70600f0c2168f216cc724d18080871cabd92b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 10:32:05 +0200 Subject: [PATCH 09/44] fix(core): match a Takeout sidecar by prefix only where Google cut its name The prefix rule for sidecars whose names Google cut at 51 characters also matched names that were never cut, so IMG_1.jpg's sidecar gave its title and date to an IMG_12.jpg that had none. It now applies only to a sidecar name at the cap. Edited copies, which have no sidecar of their own, take their original's date by a rule of their own and keep their own name (B225). --- crates/pdfs-core/src/takeout.rs | 133 +++++++++++++++++++++++++++----- docs/BUGS.md | 30 +++++++ 2 files changed, 142 insertions(+), 21 deletions(-) diff --git a/crates/pdfs-core/src/takeout.rs b/crates/pdfs-core/src/takeout.rs index 8df5db3..db9106f 100644 --- a/crates/pdfs-core/src/takeout.rs +++ b/crates/pdfs-core/src/takeout.rs @@ -364,9 +364,13 @@ fn assemble(entries: Vec, sidecars: &HashMap>) -> Take continue; }; - let sidecar = per_folder_sidecars + let found = per_folder_sidecars .get(&folder) - .and_then(|in_folder| find_sidecar(&file_name, in_folder)) + .and_then(|in_folder| find_sidecar(&file_name, in_folder)); + // An edited copy keeps its own name: the title is its original's. + let title_is_ours = found.is_some_and(|((rule, _), _)| rule != EDITED_COPY); + let sidecar = found + .map(|(_, body)| body) .and_then(|body| serde_json::from_slice::(body).ok()) .unwrap_or_default(); @@ -388,6 +392,7 @@ fn assemble(entries: Vec, sidecars: &HashMap>) -> Take let name = sidecar .title .as_deref() + .filter(|_| title_is_ours) .map(str::trim) .filter(|title| !title.is_empty() && same_extension(title, &file_name)) .unwrap_or(&file_name) @@ -627,15 +632,25 @@ fn swap_duplicate_marker(file_name: &str) -> Option { Some(format!("{}{}{}", &file_name[..marker_start], tail, marker)) } -/// Find the sidecar belonging to `file_name` among the JSON files of its folder. +/// How long Google lets a sidecar's file name get, `.json` included. A longer +/// one is cut, into its suffix first and then into the photo name. +const SIDECAR_NAME_CAP: usize = 51; + +/// The rank [`sidecar_rank`] gives an edited copy that shares its original's +/// sidecar. Its date is the original's, but its name is its own. +const EDITED_COPY: u8 = 4; + +/// Find the sidecar belonging to `file_name` among the JSON files of its folder, +/// with the rank it matched at. /// /// Tried in order of confidence: the exact name, the `(N)`-swapped name, an -/// unknown trailing suffix, then a prefix match for the case where truncation -/// cut into the photo name itself. The best-ranked candidate in the folder wins. +/// unknown trailing suffix, a prefix match for the case where truncation cut +/// into the photo name itself, and last an edited copy's original. The +/// best-ranked candidate in the folder wins. fn find_sidecar<'a>( file_name: &str, in_folder: &BTreeMap>, -) -> Option<&'a Vec> { +) -> Option<((u8, usize), &'a Vec)> { let mut best: Option<((u8, usize), &'a Vec)> = None; for (sidecar_name, body) in in_folder { let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else { @@ -645,7 +660,7 @@ fn find_sidecar<'a>( best = Some((rank, body)); } } - best.map(|(_, body)| body) + best } /// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort @@ -683,19 +698,44 @@ fn sidecar_rank(stem: &str, file_name: &str) -> Option<(u8, usize)> { } // 3 — truncation ate into the photo name itself, leaving only a prefix of // it. Every suffix boundary in the stem is tried, so this holds for a - // localized suffix too; the longest surviving prefix wins, because a folder - // with `IMG_1.jpg` and `IMG_12.jpg` must not let the shorter stem claim the - // longer photo. - let mut longest: Option = None; - for head in suffix_boundaries(stem) { - if !head.is_empty() - && file_name.starts_with(head) - && longest.is_none_or(|len| head.len() > len) - { - longest = Some(head.len()); + // localized suffix too; the longest surviving prefix wins. + // + // Only for a sidecar whose name is as long as Google lets one get: a name + // that short of the cap was not cut, so a prefix of it is the name of a + // different photo. Without this, `IMG_1.jpg`'s sidecar gave its name and + // date to an `IMG_12.jpg` that had none (`docs/BUGS.md` B225). + if stem.chars().count() + ".json".len() >= SIDECAR_NAME_CAP - 4 { + let mut longest: Option = None; + for head in suffix_boundaries(stem) { + if !head.is_empty() + && file_name.starts_with(head) + && longest.is_none_or(|len| head.len() > len) + { + longest = Some(head.len()); + } + } + if let Some(len) = longest { + return Some((3, usize::MAX - len)); } } - longest.map(|len| (3, usize::MAX - len)) + // 4 — an edited copy, `IMG_1-edited.jpg` (the word is localized), which + // Google exports without a sidecar of its own next to `IMG_1.jpg`'s. + suffix_boundaries(stem) + .filter_map(|head| head.rsplit_once('.')) + .any(|(original, extension)| is_edited_copy_of(file_name, original, extension)) + .then_some((EDITED_COPY, 0)) +} + +/// Whether `file_name` is `-.`, the name Google +/// gives an edited copy of `.`. +fn is_edited_copy_of(file_name: &str, original: &str, extension: &str) -> bool { + !original.is_empty() + && file_name + .strip_prefix(original) + .and_then(|rest| rest.strip_prefix('-')) + .and_then(|rest| rest.strip_suffix(extension)) + .and_then(|rest| rest.strip_suffix('.')) + .is_some_and(|word| !word.is_empty() && word.chars().all(char::is_alphabetic)) } /// The stem plus each of its prefixes ending before a `.`, longest first — the @@ -994,21 +1034,25 @@ mod tests { #[test] fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() { + // Both sidecar names are at Google's cap, so both were cut into the + // photo name; the longer surviving prefix is the better claim. + let short = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_E.json"; + let long = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPO.json"; let mut sidecars = HashMap::new(); sidecar( &mut sidecars, - "Takeout/Google Photos/Photos from 2019/IMG_1.json", + short, r#"{"photoTakenTime":{"timestamp":"1"}}"#, ); sidecar( &mut sidecars, - "Takeout/Google Photos/Photos from 2019/IMG_12.json", + long, r#"{"photoTakenTime":{"timestamp":"2"}}"#, ); let scan = assemble( vec![entry( 0, - "Takeout/Google Photos/Photos from 2019/IMG_12345.jpg", + "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPOSURE.jpg", 10, )], &sidecars, @@ -1017,6 +1061,53 @@ mod tests { assert_eq!(scan.photos[0].capture_time, Some(2)); } + /// A short sidecar name was not cut, so a prefix of it names another photo: + /// `IMG_1.jpg`'s sidecar gave its title and date to `IMG_12.jpg` + /// (`docs/BUGS.md` B225). + #[test] + fn a_photo_without_a_sidecar_does_not_take_a_shorter_named_siblings() { + let mut sidecars = HashMap::new(); + sidecar( + &mut sidecars, + "Takeout/Google Photos/Photos from 2019/IMG_1.jpg.supplemental-metadata.json", + r#"{"title":"IMG_1.jpg","photoTakenTime":{"timestamp":"1"}}"#, + ); + let scan = assemble( + vec![ + entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10), + entry(0, "Takeout/Google Photos/Photos from 2019/IMG_12.jpg", 10), + ], + &sidecars, + ); + + assert_eq!(scan.photos[0].capture_time, Some(1)); + assert_eq!(scan.photos[1].name, "IMG_12.jpg"); + assert_eq!(scan.photos[1].capture_time, None); + } + + /// An edited copy has no sidecar of its own. It takes its original's date, + /// but keeps its own name rather than the original's title. + #[test] + fn an_edited_copy_takes_its_originals_date_and_keeps_its_name() { + let mut sidecars = HashMap::new(); + sidecar( + &mut sidecars, + "Takeout/Google Fotos/Urlaub/IMG_2.jpg.ergänzende-Metadaten.json", + r#"{"title":"Holiday.jpg","photoTakenTime":{"timestamp":"7"}}"#, + ); + let scan = assemble( + vec![ + entry(0, "Takeout/Google Fotos/Urlaub/IMG_2.jpg", 10), + entry(0, "Takeout/Google Fotos/Urlaub/IMG_2-bearbeitet.jpg", 10), + ], + &sidecars, + ); + + assert_eq!(scan.photos[0].name, "Holiday.jpg"); + assert_eq!(scan.photos[1].name, "IMG_2-bearbeitet.jpg"); + assert_eq!(scan.photos[1].capture_time, Some(7)); + } + #[test] fn sidecar_title_replaces_a_mangled_on_disk_name() { let mut sidecars = HashMap::new(); diff --git a/docs/BUGS.md b/docs/BUGS.md index 7fd0806..4543ddc 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B225](#b225--a-takeout-photo-without-a-sidecar-takes-a-siblings-name-and-date) | A Takeout photo without a sidecar takes a sibling's name and date | | [B223](#b223--a-damaged-config-file-is-replaced-with-defaults-on-the-next-settings-change) | A damaged config file is replaced with defaults on the next settings change | | [B224](#b224--fallocate-zero-ranges-are-undone-and-collapse-ranges-corrupt-the-file) | `fallocate` zero ranges are undone and collapse ranges corrupt the file | | [B222](#b222--a-full-disk-at-close-is-reported-to-nobody-and-fsync-on-a-read-only-file-fails) | A full disk at close is reported to nobody, and `fsync` on a read-only file fails | @@ -327,6 +328,35 @@ a live account since. Newest first. +### B225 — A Takeout photo without a sidecar takes a sibling's name and date + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.11). A scratch import through +`scan_archives` gave `IMG_12.jpg`, which had no sidecar, the name `IMG_1.jpg` and IMG_1's capture +time. It gave `IMG_2-edited.jpg` the title of its original. +**Where:** `sidecar_rank`, `find_sidecar` and `is_edited_copy_of` in +`crates/pdfs-core/src/takeout.rs` + +**Repro:** Import a Takeout folder holding `IMG_1.jpg` with its sidecar and `IMG_12.jpg` without +one. + +**Cause.** The last matching rule accepted any sidecar whose stem, cut at a `.`, was a prefix of +the photo's name. That rule exists for sidecar names Google cut at its 51-character cap, but it +also matched short names that had not been cut, so `IMG_1`'s sidecar claimed `IMG_12.jpg`. The +photo then went up under another photo's title and date, and two photos ended up with the same +name. Edited copies, which Google exports without a sidecar of their own, matched the same way +and took their original's title. + +**Fix.** The prefix rule applies only to a sidecar name that reaches the cap. An edited copy, +`-.` with the word in any language, is matched to its original's sidecar +by its own rule. It takes the capture time from that sidecar and keeps its own name. + +**Test:** `takeout::tests::a_photo_without_a_sidecar_does_not_take_a_shorter_named_siblings`, +`takeout::tests::an_edited_copy_takes_its_originals_date_and_keeps_its_name`, +`takeout::tests::longest_prefix_wins_when_the_photo_name_itself_was_truncated`. + +--- + ### B223 — A damaged config file is replaced with defaults on the next settings change **Status:** Fixed, not verified live From 607972cf1eab0c2f19e10ac985a352117d9597d8 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 10:32:05 +0200 Subject: [PATCH 10/44] fix(fuse): turn generated thumbnails the way their EXIF orientation says Thumbnails were decoded without the EXIF orientation, so a portrait photo stored as landscape pixels plus a tag got a sideways tile, and the drain uploaded that thumbnail to Drive for every client to show (B226). --- crates/pdfs-fuse/src/photos.rs | 74 +++++++++++++++++++++++++++++++--- docs/BUGS.md | 22 ++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index 519cb2d..e3a2d27 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -226,11 +226,7 @@ fn scale_thumbnail_with_exiftool( exiftool: &OsStr, timeout: Duration, ) -> ScaleAttempt { - let direct = image::ImageReader::new(std::io::Cursor::new(bytes)) - .with_guessed_format() - .ok() - .and_then(|reader| reader.decode().ok()); - let image = match direct { + let image = match decode_upright(bytes) { Some(image) => image, None if is_raw_image_name(name) => { let (preview, orientation) = @@ -256,6 +252,27 @@ fn scale_thumbnail_with_exiftool( encode_thumb(&image) } +/// Decode `bytes` and turn the picture the way its EXIF orientation says. +/// +/// `ImageReader::decode` does not: a phone stores a portrait photo as landscape +/// pixels plus an orientation tag, so every such thumbnail came out on its +/// side, and the drain uploaded it to Drive that way for every client to show +/// (`docs/BUGS.md` B226). +fn decode_upright(bytes: &[u8]) -> Option { + use image::ImageDecoder as _; + let mut decoder = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok()? + .into_decoder() + .ok()?; + let orientation = decoder + .orientation() + .unwrap_or(image::metadata::Orientation::NoTransforms); + let mut image = image::DynamicImage::from_decoder(decoder).ok()?; + image.apply_orientation(orientation); + Some(image) +} + /// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio. fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt { let (width, height) = (image.width(), image.height()); @@ -2149,6 +2166,53 @@ mod thumb_tests { assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true)); } + /// `jpeg` with an EXIF APP1 segment that carries only `orientation`. + fn jpeg_with_orientation(width: u32, height: u32, orientation: u16) -> Vec { + let plain = jpeg(width, height); + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"II*\0"); + tiff.extend_from_slice(&8u32.to_le_bytes()); + tiff.extend_from_slice(&1u16.to_le_bytes()); + tiff.extend_from_slice(&0x0112u16.to_le_bytes()); + tiff.extend_from_slice(&3u16.to_le_bytes()); + tiff.extend_from_slice(&1u32.to_le_bytes()); + tiff.extend_from_slice(&orientation.to_le_bytes()); + tiff.extend_from_slice(&[0, 0]); + tiff.extend_from_slice(&0u32.to_le_bytes()); + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend_from_slice(&tiff); + let mut out = plain[..2].to_vec(); + out.extend_from_slice(&[0xFF, 0xE1]); + out.extend_from_slice(&u16::try_from(app1.len() + 2).unwrap().to_be_bytes()); + out.extend_from_slice(&app1); + out.extend_from_slice(&plain[2..]); + out + } + + /// A phone stores a portrait photo as landscape pixels and an EXIF + /// orientation; its thumbnail came out on its side (`docs/BUGS.md` B226). + #[test] + fn a_thumbnail_is_turned_the_way_its_exif_says() { + let ScaleAttempt::Made(thumb) = scale_thumbnail( + &jpeg_with_orientation(2000, 1000, 6), + "portrait.jpg", + &std::env::temp_dir(), + ) else { + panic!("a JPEG scales"); + }; + assert!(thumb.ratio < 1.0, "turned upright: {}", thumb.ratio); + assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true)); + + let ScaleAttempt::Made(level) = scale_thumbnail( + &jpeg_with_orientation(2000, 1000, 1), + "landscape.jpg", + &std::env::temp_dir(), + ) else { + panic!("a JPEG scales"); + }; + assert!(level.ratio > 1.0); + } + #[test] fn undecodable_bytes_are_not_a_thumbnail() { // What a photo in a format this build has no decoder for looks like: the diff --git a/docs/BUGS.md b/docs/BUGS.md index 4543ddc..be2fa4f 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B226](#b226--thumbnails-of-portrait-phone-photos-lie-on-their-side) | Thumbnails of portrait phone photos lie on their side | | [B225](#b225--a-takeout-photo-without-a-sidecar-takes-a-siblings-name-and-date) | A Takeout photo without a sidecar takes a sibling's name and date | | [B223](#b223--a-damaged-config-file-is-replaced-with-defaults-on-the-next-settings-change) | A damaged config file is replaced with defaults on the next settings change | | [B224](#b224--fallocate-zero-ranges-are-undone-and-collapse-ranges-corrupt-the-file) | `fallocate` zero ranges are undone and collapse ranges corrupt the file | @@ -328,6 +329,27 @@ a live account since. Newest first. +### B226 — Thumbnails of portrait phone photos lie on their side + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.4). +**Where:** `decode_upright` and `scale_thumbnail_with_exiftool` in +`crates/pdfs-fuse/src/photos.rs` + +**Repro:** Upload a portrait photo taken with a phone that stores landscape pixels plus an EXIF +orientation tag, which most do. Its tile in the app, and in Proton's own apps, lies on its side. + +**Cause.** Generated thumbnails were decoded with `ImageReader::decode`, which ignores the EXIF +orientation. Only the RAW preview path applied it. The drain uploads the generated thumbnail with +the photo, so every client showed it sideways. + +**Fix.** `decode_upright` reads the orientation from the decoder and applies it before the image +is scaled. + +**Test:** `photos::tests::a_thumbnail_is_turned_the_way_its_exif_says`. + +--- + ### B225 — A Takeout photo without a sidecar takes a sibling's name and date **Status:** Fixed, not verified live From f8c60c9753419b2208b5c6633b4d9668b32196ce Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 10:32:05 +0200 Subject: [PATCH 11/44] build: add a release profile that strips the binaries No package ships a separate debug package, and the unstripped binaries were 43, 24 and 12 MB. LTO stays off, as the packaging comments already said the release profile kept it, and unwinding stays on for the workers that catch panics. --- Cargo.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index ce6da98..805ac5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,3 +61,15 @@ async-trait = "0.1" futures = "0.3" fuser = "0.18" libc = "0.2" + +[profile.release] +# No package ships a separate debug package (the PKGBUILD sets `!debug`, the +# RPM spec sets `debug_package` to nil), and the unstripped binaries weighed +# 43 MB (`pdfs`), 24 MB (`pdfs-app`) and 12 MB (`pdfs-tray`). +strip = "symbols" +# Off on purpose: LTO has broken GTK links for this workspace, which is why +# the packages turn it off as well. +lto = false +# `Workers` and the thumbnail build catch panics to keep the daemon alive, so +# unwinding must stay on. +panic = "unwind" From d4ef9116c83a5e80cf22e36e9d80a3b3e3a0f375 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 10:47:58 +0200 Subject: [PATCH 12/44] fix(fuse): download the base of a file open for writing on a worker A read on a write handle was answered on the dispatch loop to keep its order with the handle's writes, and when the bytes it asked for were neither written nor cached it downloaded them there, holding every request of the mount for up to the read deadline. The loop now answers from the scratch file and the local caches only, and hands a read that needs a download to a transfer worker with the handle as it was when the read came in (B227). --- crates/pdfs-fuse/src/filesystem.rs | 33 +++++-- crates/pdfs-fuse/src/reads.rs | 154 +++++++++++++++++++++++------ docs/BUGS.md | 79 ++++++++++----- 3 files changed, 203 insertions(+), 63 deletions(-) diff --git a/crates/pdfs-fuse/src/filesystem.rs b/crates/pdfs-fuse/src/filesystem.rs index bde3d3b..5059202 100644 --- a/crates/pdfs-fuse/src/filesystem.rs +++ b/crates/pdfs-fuse/src/filesystem.rs @@ -261,10 +261,13 @@ impl Filesystem for ProtonFs { // in-flight (possibly unsaved) content: authored bytes come from the // scratch file, untouched bytes from the base. // - // This path stays on the dispatch loop. `write` runs there too, so - // keeping its reads there as well preserves the ordering the write path - // has always had between a write and a read of the same handle; a mostly - // local read is not worth reasoning about that for. + // Answered on the dispatch loop when everything it needs is on this + // machine, which keeps the order `write` has with reads of the same + // handle. A base range that has to be downloaded goes to a worker + // instead, with the handle as it was when the read came in: the kernel + // issued the read before any write that comes after it, so that is + // the content it asked for. On the loop, the download held every + // request of the mount (`docs/BUGS.md` B227). let handle = { let st = self.core.state(); st.active_writes.get(&ino.0).map(|h| { @@ -279,7 +282,7 @@ impl Filesystem for ProtonFs { }) }; if let Some((file, len, uid, base_mtime, base_size, written)) = handle { - match self.core.serve_open_read( + match self.core.serve_open_read_local( &file, len, &uid, @@ -289,7 +292,25 @@ impl Filesystem for ProtonFs { offset, size as u64, ) { - Ok(bytes) => reply.data(&bytes), + Ok(Some(bytes)) => reply.data(&bytes), + Ok(None) => { + let fs = self.clone(); + self.core.workers.run(Lane::Transfer, "read", move || { + match fs.core.serve_open_read( + &file, + len, + &uid, + base_mtime, + base_size, + &written, + offset, + size as u64, + ) { + Ok(bytes) => reply.data(&bytes), + Err(e) => reply.error(e), + } + }); + } Err(e) => reply.error(e), } return; diff --git a/crates/pdfs-fuse/src/reads.rs b/crates/pdfs-fuse/src/reads.rs index 77fdc2e..5b51a26 100644 --- a/crates/pdfs-fuse/src/reads.rs +++ b/crates/pdfs-fuse/src/reads.rs @@ -1070,37 +1070,35 @@ impl Core { offset: u64, size: u64, ) -> Result, Errno> { - if offset >= len || size == 0 { - return Ok(Vec::new()); - } - let end = offset.saturating_add(size).min(len); - let mut out = Vec::with_capacity((end - offset) as usize); - for (s, e, authored) in written.segments(offset, end) { - if authored { - let mut buf = vec![0u8; (e - s) as usize]; - file.read_exact_at(&mut buf, s).map_err(|err| { - warn!(%uid, error = %err, "scratch read failed"); - Errno::EIO - })?; - out.extend_from_slice(&buf); - } else { - let bend = e.min(base_size); - if s < bend { - out.extend_from_slice(&self.read_range( - uid, - base_mtime, - base_size, - s, - bend - s, - true, - )?); - } - // Anything past the base is a hole: zero-fill. - let zeros = e.saturating_sub(s.max(base_size)); - out.resize(out.len() + zeros as usize, 0); - } - } - Ok(out) + let read = assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| { + self.read_range(uid, base_mtime, base_size, s, n, true) + .map(Some) + })?; + Ok(read.unwrap_or_default()) + } + + /// [`Core::serve_open_read`] from bytes already on this machine only: + /// `Ok(None)` the moment a range of the base would have to be downloaded. + /// + /// The dispatch loop answers a read on a write handle with this, and hands + /// it to a worker only when it says `None`. Fetching the base there held + /// every request of the mount for as long as the download took, up to the + /// read deadline (`docs/BUGS.md` B227). + #[allow(clippy::too_many_arguments)] + pub(super) fn serve_open_read_local( + &self, + file: &Arc, + len: u64, + uid: &NodeUid, + base_mtime: i64, + base_size: u64, + written: &Intervals, + offset: u64, + size: u64, + ) -> Result>, Errno> { + assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| { + Ok(self.cached_range(uid, base_mtime, base_size, s, n)) + }) } /// The largest amount [`Core::fill_gaps_cached`] will copy out of the local @@ -1242,6 +1240,49 @@ impl Core { } } +/// The bytes `[offset, offset + size)` of a file open for writing: authored +/// ranges from its scratch `file`, the rest from its base through `base`, +/// and zeros past the base. `Ok(None)` when `base` says it cannot answer. +#[allow(clippy::too_many_arguments)] +fn assemble_open_read( + file: &File, + len: u64, + uid: &NodeUid, + base_size: u64, + written: &Intervals, + offset: u64, + size: u64, + mut base: impl FnMut(u64, u64) -> Result>, Errno>, +) -> Result>, Errno> { + if offset >= len || size == 0 { + return Ok(Some(Vec::new())); + } + let end = offset.saturating_add(size).min(len); + let mut out = Vec::with_capacity((end - offset) as usize); + for (s, e, authored) in written.segments(offset, end) { + if authored { + let mut buf = vec![0u8; (e - s) as usize]; + file.read_exact_at(&mut buf, s).map_err(|err| { + warn!(%uid, error = %err, "scratch read failed"); + Errno::EIO + })?; + out.extend_from_slice(&buf); + } else { + let bend = e.min(base_size); + if s < bend { + match base(s, bend - s)? { + Some(bytes) => out.extend_from_slice(&bytes), + None => return Ok(None), + } + } + // Anything past the base is a hole: zero-fill. + let zeros = e.saturating_sub(s.max(base_size)); + out.resize(out.len() + zeros as usize, 0); + } + } + Ok(Some(out)) +} + #[cfg(test)] mod block_ring_tests { use super::*; @@ -1394,3 +1435,54 @@ mod prefetch_tests { assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1); } } + +#[cfg(test)] +mod open_read_tests { + use super::*; + use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId}; + + fn scratch(bytes: &[u8]) -> (File, PathBuf) { + let path = std::env::temp_dir().join(format!( + "pdfs-open-read-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut file = File::options() + .create_new(true) + .read(true) + .write(true) + .open(&path) + .unwrap(); + file.write_all(bytes).unwrap(); + (file, path) + } + + /// The dispatch loop answers from what is on this machine and says so + /// when the base would have to be downloaded, for a worker to do it + /// (`docs/BUGS.md` B227). + #[test] + fn a_read_needing_the_base_says_so_instead_of_fetching() { + let uid = NodeUid::new(VolumeId::from("v"), LinkId::from("f")); + let (file, path) = scratch(b"ABCDEFGH"); + let mut written = Intervals::default(); + written.add(0, 4); + + let authored = assemble_open_read(&file, 8, &uid, 8, &written, 0, 4, |_, _| { + panic!("an authored range never asks the base") + }); + assert_eq!(authored.unwrap().as_deref(), Some(&b"ABCD"[..])); + + let missing = assemble_open_read(&file, 8, &uid, 8, &written, 2, 6, |_, _| Ok(None)); + assert_eq!(missing.unwrap(), None); + + let base = assemble_open_read(&file, 10, &uid, 8, &written, 2, 8, |s, n| { + assert_eq!((s, n), (4, 4)); + Ok(Some(b"efgh".to_vec())) + }); + assert_eq!(base.unwrap().as_deref(), Some(&b"CDefgh\0\0"[..])); + let _ = std::fs::remove_file(path); + } +} diff --git a/docs/BUGS.md b/docs/BUGS.md index be2fa4f..f238331 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,10 +100,11 @@ a live account since. | Entry | Title | |---|---| +| [B227](#b227--reading-a-file-open-for-writing-downloads-on-the-dispatch-loop) | Reading a file open for writing downloads on the dispatch loop | | [B226](#b226--thumbnails-of-portrait-phone-photos-lie-on-their-side) | Thumbnails of portrait phone photos lie on their side | | [B225](#b225--a-takeout-photo-without-a-sidecar-takes-a-siblings-name-and-date) | A Takeout photo without a sidecar takes a sibling's name and date | -| [B223](#b223--a-damaged-config-file-is-replaced-with-defaults-on-the-next-settings-change) | A damaged config file is replaced with defaults on the next settings change | | [B224](#b224--fallocate-zero-ranges-are-undone-and-collapse-ranges-corrupt-the-file) | `fallocate` zero ranges are undone and collapse ranges corrupt the file | +| [B223](#b223--a-damaged-config-file-is-replaced-with-defaults-on-the-next-settings-change) | A damaged config file is replaced with defaults on the next settings change | | [B222](#b222--a-full-disk-at-close-is-reported-to-nobody-and-fsync-on-a-read-only-file-fails) | A full disk at close is reported to nobody, and `fsync` on a read-only file fails | | [B221](#b221--a-cold-listing-holds-the-global-lock-across-a-database-transaction) | A cold listing holds the global lock across a database transaction | | [B220](#b220--thumbnails-on-a-stalled-link-hold-up-every-other-request) | Thumbnails on a stalled link hold up every other request | @@ -329,6 +330,32 @@ a live account since. Newest first. +### B227 — Reading a file open for writing downloads on the dispatch loop + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.2). +**Where:** `read` in `crates/pdfs-fuse/src/filesystem.rs`; `Core::serve_open_read_local` and +`assemble_open_read` in `crates/pdfs-fuse/src/reads.rs` + +**Repro:** On a slow link, open a large file that is only on Drive with `O_RDWR`, write a few +bytes, and read elsewhere in it, as SQLite and many editors do, while another program uses the +mount. + +**Cause.** A read on a file open for writing was always answered on the dispatch loop, so that it +kept its order with the writes on the same handle. When the bytes it asked for were neither +written nor cached, it downloaded them there, for up to the 120 s read deadline plus the wait +for a connection. Every other request of the mount waited behind it. + +**Fix.** The dispatch loop answers such a read from the scratch file and the local caches only. +When a range of the base would have to be downloaded, the read goes to a transfer worker, with +the handle as it was when the read came in. The kernel issued the read before any later write, +so that is the content it asked for. + +**Test:** `reads::open_read_tests::a_read_needing_the_base_says_so_instead_of_fetching`; the +simulation runs cover the order of writes and reads on one handle. + +--- + ### B226 — Thumbnails of portrait phone photos lie on their side **Status:** Fixed, not verified live @@ -379,31 +406,6 @@ by its own rule. It takes the capture time from that sidecar and keeps its own n --- -### B223 — A damaged config file is replaced with defaults on the next settings change - -**Status:** Fixed, not verified live -**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.10). -**Where:** `AppDirs::save_config`, `AppDirs::load_config` and `refuse_to_replace_unreadable` in -`crates/pdfs-core/src/config.rs` - -**Repro:** Truncate `~/.config/proton-drive-linux/config.json`, then change any setting in the -app, or let the daemon record its device. - -**Cause.** `load_config` gives defaults for a file it cannot parse, and logs that it leaves the -file alone. But every setter loads, changes one field and saves, in the daemon and in the app. -So the first change after the damage wrote the defaults over the file, losing the mountpoint, -the device the client had adopted, the ignore rules and the `open_with` choices. ARCHITECTURE -§10.5 says the file is never replaced with defaults. The first-run default was also written with a -plain `std::fs::write`, which is not atomic and takes the umask's mode. - -**Fix.** `save_config` refuses while the file on disk exists and does not parse, and says which -file to fix. The first-run default goes through `save_config`, so it is written atomically with -mode 0600. - -**Test:** `config::tests::an_unreadable_config_is_never_written_over`. - ---- - ### B224 — `fallocate` zero ranges are undone and collapse ranges corrupt the file **Status:** Fixed, not verified live @@ -431,6 +433,31 @@ the lock is not held across it. --- +### B223 — A damaged config file is replaced with defaults on the next settings change + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.10). +**Where:** `AppDirs::save_config`, `AppDirs::load_config` and `refuse_to_replace_unreadable` in +`crates/pdfs-core/src/config.rs` + +**Repro:** Truncate `~/.config/proton-drive-linux/config.json`, then change any setting in the +app, or let the daemon record its device. + +**Cause.** `load_config` gives defaults for a file it cannot parse, and logs that it leaves the +file alone. But every setter loads, changes one field and saves, in the daemon and in the app. +So the first change after the damage wrote the defaults over the file, losing the mountpoint, +the device the client had adopted, the ignore rules and the `open_with` choices. ARCHITECTURE +§10.5 says the file is never replaced with defaults. The first-run default was also written with a +plain `std::fs::write`, which is not atomic and takes the umask's mode. + +**Fix.** `save_config` refuses while the file on disk exists and does not parse, and says which +file to fix. The first-run default goes through `save_config`, so it is written atomically with +mode 0600. + +**Test:** `config::tests::an_unreadable_config_is_never_written_over`. + +--- + ### B222 — A full disk at close is reported to nobody, and `fsync` on a read-only file fails **Status:** Fixed, not verified live From 0204f69e9ec083245a930850c69f909ef60e8c5c Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:07:23 +0200 Subject: [PATCH 13/44] docs(changelog): list the 3.5.0 fixes under Unreleased --- docs/CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4524266..91baeb9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,49 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] +Schema: **42**, unchanged. + +### Fixed +- **A folder deleted on another device took files with it that had never reached Drive.** A new + file, an edit or an ignored `.git` in a synced folder was deleted along with the folder. Such a + folder is now kept and created on Drive again (B211). +- **Files on Drive that could not be decrypted were deleted from the synced folder.** They read + as deleted on Drive. They are now left alone and named in the activity feed, and a pass in which + nothing synced is left on Drive is refused (B212). +- **An edit made while a synced folder was being checked could be deleted** when another device + had deleted the file. It is now kept and uploaded (B213). +- **Deleting a synced folder sent another device's new files in it to the trash.** The folder now + stays on Drive with them (B214). +- **A file name on Drive containing `/` or `..` could write outside the synced folder**, and made + its folder in the mount fail to list. Such names are left out (B215). +- **A damaged database lost the changes still waiting to upload.** It is still moved aside and + rebuilt, but queued changes, synced folders' state and files kept offline are copied out of it + first (B216). +- **Search slowed down over time, and the database grew far larger than needed.** The search + indexes are now compacted once a day; on one machine that took the local index from 317 MiB to + 20 MiB (B217). +- **`pdfs diagnose` and `pdfs cache inspect` froze the drive while they checked the database**, + for half a minute on a large one. They no longer hold up anything else (B218). +- **Listing a folder shared with you could freeze the whole drive on a slow connection**, and so + could folders of photos asking for thumbnails while the connection stalled (B219, B220). +- **Opening a large folder for the first time held up every other file operation on the drive.** + (B221) +- **A full disk while saving a file was not reported.** The program saving it now gets the error + when it closes the file. Copying a large file into the drive no longer stalls everything else at + the end, `fsync` on a file opened for reading no longer fails, and `fallocate` modes the drive + cannot honour are refused instead of corrupting the file (B222, B224, B227). +- **A damaged settings file was replaced with defaults at the next settings change**, losing the + mount location and the ignore rules. Settings are no longer saved over a file that cannot be + read (B223). +- **Google Photos import gave some photos another photo's name and date.** A photo without its + own metadata file took a similarly named one's, and an edited copy took its original's name. An + edited copy now takes its original's date and keeps its own name (B225). +- **Thumbnails of portrait photos lay on their side**, here and in Proton's own apps (B226). + +### Changed +- **Release binaries are stripped of their symbols**: `pdfs` goes from 43 to 35 MB, `pdfs-app` + from 24 to 19 MB and `pdfs-tray` from 12 to 8 MB. + ## [3.4.1] — 2026-10-16 Schema: **42**, unchanged. From 42b2f69fa00e421324b8f6ff3d28b8d05bfe722e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:21:48 +0200 Subject: [PATCH 14/44] fix(core,fuse): keep queued writes durable and their retries their own - A gap-filled revision blob is recorded as complete once filled, so a retry after a lost upload answer recognises its own bytes on Drive instead of forking a conflict copy (B228). - Commits that accept a change into the queue run at synchronous=FULL, so an op `close()` reported as done survives a power cut (B229). - A queued create's adoption window starts at its first send, not when it was queued, so a create queued offline no longer takes another device's empty node of the same name. A send record that does not parse is kept instead of overwritten (B230). --- crates/pdfs-core/src/db/mod.rs | 48 ++++++++- crates/pdfs-core/src/db/ops.rs | 130 ++++++++++++++++++++---- crates/pdfs-core/src/db/tests.rs | 169 ++++++++++++++++++++++++++++++- crates/pdfs-fuse/src/drain.rs | 60 ++++++++--- docs/BUGS.md | 84 +++++++++++++++ 5 files changed, 455 insertions(+), 36 deletions(-) diff --git a/crates/pdfs-core/src/db/mod.rs b/crates/pdfs-core/src/db/mod.rs index 1752e27..c397024 100644 --- a/crates/pdfs-core/src/db/mod.rs +++ b/crates/pdfs-core/src/db/mod.rs @@ -179,6 +179,45 @@ impl Drop for Reader<'_> { } } +/// The write connection with `synchronous=FULL`, back to NORMAL on drop. +/// +/// NORMAL in WAL mode keeps a commit across a crash of the process but not +/// across a power loss: the WAL is synced only at checkpoints. That is fine for +/// metadata the next listing re-fetches, and not for the queue. A staged blob +/// is synced before its op is queued, and the op row is what makes it +/// reachable; losing that row loses a write `close()` already reported as +/// done. A FULL commit syncs the WAL, which covers every NORMAL commit before +/// it as well. +pub(super) struct Durable<'a>(parking_lot::MutexGuard<'a, Connection>); + +impl<'a> Durable<'a> { + pub(super) fn new(conn: parking_lot::MutexGuard<'a, Connection>) -> Result { + conn.pragma_update(None, "synchronous", "FULL")?; + Ok(Self(conn)) + } +} + +impl std::ops::Deref for Durable<'_> { + type Target = Connection; + + fn deref(&self) -> &Connection { + &self.0 + } +} + +impl std::ops::DerefMut for Durable<'_> { + fn deref_mut(&mut self) -> &mut Connection { + &mut self.0 + } +} + +impl Drop for Durable<'_> { + fn drop(&mut self) { + // A failure leaves the writer at FULL: slower, never less safe. + let _ = self.0.pragma_update(None, "synchronous", "NORMAL"); + } +} + /// Open a second connection to the same file for reads only. /// /// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops @@ -270,6 +309,12 @@ impl Db { Ok(db) } + /// The write connection for a commit that accepts a user's change into + /// the queue. See [`Durable`]. + pub(super) fn durable(&self) -> Result> { + Durable::new(self.conn.lock()) + } + /// A connection for a `SELECT`-only method. /// /// Reads see the last committed state, which is the same thing they saw @@ -297,7 +342,8 @@ impl Db { fn open_configured(path: &Path) -> Result { let conn = Connection::open(path)?; // WAL: readers never block the single writer. NORMAL sync is the - // standard durability/throughput tradeoff for WAL. + // standard durability/throughput tradeoff for WAL; the queue commits + // that need more raise it to FULL for themselves (`Durable`). conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; diff --git a/crates/pdfs-core/src/db/ops.rs b/crates/pdfs-core/src/db/ops.rs index 9edabc3..bbddfd1 100644 --- a/crates/pdfs-core/src/db/ops.rs +++ b/crates/pdfs-core/src/db/ops.rs @@ -279,6 +279,45 @@ fn claimable() -> String { /// drain replaces it with the uid the server assigns. pub const LOCAL_VOLUME: &str = "local"; +/// One place a queued create was sent, with when it first was. +struct SentTarget { + parent: String, + name: String, + at_ms: Option, +} + +/// The `sent_to` column: `[[parent, name, at_ms], …]`, or `[[parent, name], …]` +/// as written before the time was kept. `None` when it does not parse. +fn parse_sent_to(json: Option<&str>) -> Option> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Entry { + At(String, String, i64), + Bare(String, String), + } + let Some(json) = json else { + return Some(Vec::new()); + }; + let entries: Vec = serde_json::from_str(json).ok()?; + Some( + entries + .into_iter() + .map(|entry| match entry { + Entry::At(parent, name, at) => SentTarget { + parent, + name, + at_ms: Some(at), + }, + Entry::Bare(parent, name) => SentTarget { + parent, + name, + at_ms: None, + }, + }) + .collect(), + ) +} + /// The uid a node made on this machine goes by until Drive gives it one: its /// row's local id under [`LOCAL_VOLUME`]. /// @@ -541,7 +580,7 @@ impl Db { /// land in the order they were made (`docs/BUGS.md` B141). Should it fail, /// [`Db::record_op_failure`] or [`Db::release_op_claim`] drops it. pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option)> { - let mut conn = self.conn.lock(); + let mut conn = self.durable()?; let tx = conn.transaction()?; let superseded: Option = if op_supersedes(&op.kind) { let lid = node_lid(&tx, &op.uid)?; @@ -598,7 +637,7 @@ impl Db { name: &str, created_at: i64, ) -> Result<(i64, Vec)> { - let mut conn = self.conn.lock(); + let mut conn = self.durable()?; let tx = conn.transaction()?; let blobs = drop_doomed_ops(&tx, uid)?; tx.execute( @@ -628,7 +667,7 @@ impl Db { blob_path: &str, meta_json: &str, ) -> Result> { - let conn = self.conn.lock(); + let conn = self.durable()?; let lid = node_lid(&conn, uid)?; let existing: Option<(i64, Option)> = conn .query_row( @@ -691,7 +730,7 @@ impl Db { rewrite: impl FnOnce(&str) -> Option, ) -> Result { let (local, real) = (landing.local, landing.real); - let mut conn = self.conn.lock(); + let mut conn = self.durable()?; let tx = conn.transaction()?; let row: Option = tx .query_row( @@ -808,12 +847,19 @@ impl Db { Ok(()) } - /// Record that queued create `id` is being sent to `parent` under `name`. + /// Record that queued create `id` is being sent to `parent` under `name` + /// at `at_ms`. /// /// A rename while the op waits rewrites its target, but the file a lost /// answer left stays where it was sent. The retry and the trash look - /// there too (`docs/BUGS.md` B184). - pub fn note_create_target(&self, id: i64, parent: &str, name: &str) -> Result<()> { + /// there too (`docs/BUGS.md` B184). A target sent before keeps its first + /// time. + /// + /// A record that does not parse is left as it is rather than replaced + /// with this one target: the earlier ones are what the duplicate checks + /// look for. Without them a lost answer forks a conflict copy, which + /// loses nothing. + pub fn note_create_target(&self, id: i64, parent: &str, name: &str, at_ms: i64) -> Result<()> { let mut conn = self.conn.lock(); let tx = conn.transaction()?; let sent: Option = tx @@ -822,15 +868,26 @@ impl Db { }) .optional()? .flatten(); - let mut targets: Vec<(String, String)> = sent - .and_then(|json| serde_json::from_str(&json).ok()) - .unwrap_or_default(); - let target = (parent.to_string(), name.to_string()); - if !targets.contains(&target) { - targets.push(target); + let Some(mut targets) = parse_sent_to(sent.as_deref()) else { + tracing::warn!( + id, + "a queued create's send record does not parse; keeping it" + ); + return Ok(()); + }; + if !targets.iter().any(|t| t.parent == parent && t.name == name) { + targets.push(SentTarget { + parent: parent.to_string(), + name: name.to_string(), + at_ms: Some(at_ms), + }); + let json: Vec<(&str, &str, i64)> = targets + .iter() + .map(|t| (t.parent.as_str(), t.name.as_str(), t.at_ms.unwrap_or(0))) + .collect(); tx.execute( "UPDATE pending_op SET sent_to = ?2 WHERE id = ?1", - params![id, serde_json::to_string(&targets)?], + params![id, serde_json::to_string(&json)?], )?; } tx.commit()?; @@ -840,6 +897,24 @@ impl Db { /// The parent and name queued op `id` was sent under as a create, in the /// order it was ([`Db::note_create_target`]). pub fn create_targets(&self, id: i64) -> Result> { + Ok(self + .sent_targets(id)? + .into_iter() + .map(|t| (t.parent, t.name)) + .collect()) + } + + /// When queued create `id` was first sent, if that was recorded. Rows + /// written before the time was kept have none. + pub fn create_first_sent(&self, id: i64) -> Result> { + Ok(self + .sent_targets(id)? + .into_iter() + .filter_map(|t| t.at_ms.filter(|&at| at > 0)) + .min()) + } + + fn sent_targets(&self, id: i64) -> Result> { let sent: Option = self .conn .lock() @@ -848,9 +923,7 @@ impl Db { }) .optional()? .flatten(); - Ok(sent - .and_then(|json| serde_json::from_str(&json).ok()) - .unwrap_or_default()) + Ok(parse_sent_to(sent.as_deref()).unwrap_or_default()) } /// The SHA-1 of each blob queued op `id` sent as a create @@ -898,6 +971,27 @@ impl Db { Ok(n > 0) } + /// Replace the sidecar of op `id`, but only while it still holds the blob + /// at `blob_path`. + /// + /// The drain fills an incomplete blob in place and then records it as + /// whole, so a retry after a lost answer can recognise its own bytes on the + /// server. A write that closed meanwhile has pointed the row at a newer + /// blob with its own sidecar; that one must not be overwritten. Returns + /// false when the row is gone or holds another blob. + pub fn update_op_meta_for_blob( + &self, + id: i64, + blob_path: &str, + meta_json: &str, + ) -> Result { + let n = self.conn.lock().execute( + "UPDATE pending_op SET meta_json = ?3 WHERE id = ?1 AND blob_path = ?2", + params![id, blob_path, meta_json], + )?; + Ok(n > 0) + } + /// Point a queued create at a new parent and name, for a node renamed or /// moved before it ever reached the server. /// @@ -911,7 +1005,7 @@ impl Db { /// name it no longer wants, such as one held by a queued change. A parked /// create stays parked. pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result { - let conn = self.conn.lock(); + let conn = self.durable()?; let lid = node_lid(&conn, uid)?; let parent_lid = node_lid(&conn, parent_uid)?; let n = conn.execute( diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 55a586c..8843589 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -2352,6 +2352,100 @@ fn refuses_a_schema_newer_than_the_running_build() { let _ = std::fs::remove_file(path); } +/// Queueing a write syncs it to disk, and leaves the writer at NORMAL after. +/// +/// The op row is what makes a staged blob reachable once `close()` has +/// reported success, so its commit must survive a power loss. Everything else +/// the writer commits is metadata the next listing re-fetches, and pays only +/// NORMAL's cost. +#[test] +fn queueing_a_write_commits_at_full_sync_and_restores_normal() { + let path = std::env::temp_dir().join(format!("pdfs-db-sync-{}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let db = Db::open(&path).unwrap(); + let level = |db: &Db| -> i64 { + db.conn + .lock() + .query_row("PRAGMA synchronous", [], |r| r.get(0)) + .unwrap() + }; + // 1 = NORMAL, 2 = FULL. + assert_eq!(level(&db), 1); + { + let durable = db.durable().unwrap(); + let during: i64 = durable + .query_row("PRAGMA synchronous", [], |r| r.get(0)) + .unwrap(); + assert_eq!(during, 2); + } + assert_eq!(level(&db), 1); + + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_TRASH.to_string(), + uid: uid("a").to_string(), + parent_uid: None, + name: Some("a".to_string()), + blob_path: None, + meta_json: None, + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + assert_eq!(level(&db), 1); + assert_eq!(db.pending_ops().unwrap().len(), 1); + drop(db); + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); + } +} + +/// A gap-filled blob is recorded as whole only while the op still holds it. +/// +/// A write that closes during the drain points the row at a newer blob with +/// its own sidecar. Overwriting that sidecar with the older blob's would +/// describe bytes the row no longer holds. +#[test] +fn marking_a_blob_whole_leaves_a_newer_blob_alone() { + let db = Db::open_in_memory().unwrap(); + let op = PendingOp { + id: 0, + kind: OP_REVISION.to_string(), + uid: uid("a").to_string(), + parent_uid: None, + name: None, + blob_path: Some("/staging/first".to_string()), + meta_json: Some(r#"{"complete":false}"#.to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }; + db.enqueue_op(&op).unwrap(); + let id = db.pending_ops().unwrap()[0].id; + + assert!( + db.update_op_meta_for_blob(id, "/staging/first", r#"{"complete":true}"#) + .unwrap() + ); + assert_eq!( + db.pending_ops().unwrap()[0].meta_json.as_deref(), + Some(r#"{"complete":true}"#) + ); + + assert!( + !db.update_op_meta_for_blob(id, "/staging/older", r#"{"stale":true}"#) + .unwrap(), + "the row holds another blob" + ); + assert_eq!( + db.pending_ops().unwrap()[0].meta_json.as_deref(), + Some(r#"{"complete":true}"#) + ); +} + /// A queued write whose baseline is restamped must keep everything else. /// /// The restamp happens after our *own* upload seals a new revision under a @@ -4081,15 +4175,15 @@ fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() { .unwrap(); assert!(db.create_sent(id).unwrap().is_empty()); assert!(db.create_targets(id).unwrap().is_empty()); - db.note_create_target(id, &parent, "c").unwrap(); - db.note_create_target(id, &parent, "c").unwrap(); + db.note_create_target(id, &parent, "c", 10).unwrap(); + db.note_create_target(id, &parent, "c", 20).unwrap(); db.note_create_sent(id, "aaaa").unwrap(); db.note_create_sent(id, "aaaa").unwrap(); db.record_op_failure(id, "no answer", 0).unwrap(); db.attach_blob_to_create("local~sent", "/staging/second", "{}") .unwrap() .expect("the create is still queued"); - db.note_create_target(id, &parent, "d").unwrap(); + db.note_create_target(id, &parent, "d", 30).unwrap(); db.note_create_sent(id, "bbbb").unwrap(); db.record_op_failure(id, "no answer", 0).unwrap(); db.delete_ops_for_uid("local~sent").unwrap(); @@ -4112,6 +4206,73 @@ fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() { let _ = std::fs::remove_file(path); } +/// A queued create knows when it first went out, which is where the window +/// for adopting a node its lost answer left starts. Records written before +/// the time was kept still list their targets, and one that does not parse is +/// not replaced by the next target. +#[test] +fn a_create_remembers_when_it_was_first_sent() { + let db = Db::open_in_memory().unwrap(); + let parent = uid("parent").to_string(); + let (id, _) = db + .enqueue_op(&PendingOp { + id: 0, + kind: OP_CREATE.to_string(), + uid: "local~first".to_string(), + parent_uid: Some(parent.clone()), + name: Some("a.txt".to_string()), + blob_path: None, + meta_json: None, + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + assert_eq!(db.create_first_sent(id).unwrap(), None, "never sent"); + db.note_create_target(id, &parent, "a.txt", 500).unwrap(); + db.note_create_target(id, &parent, "a.txt", 900).unwrap(); + db.note_create_target(id, &parent, "b.txt", 700).unwrap(); + assert_eq!(db.create_first_sent(id).unwrap(), Some(500)); + + let set = |json: &str| { + db.conn + .lock() + .execute( + "UPDATE pending_op SET sent_to = ?2 WHERE id = ?1", + rusqlite::params![id, json], + ) + .unwrap(); + }; + set(&format!(r#"[["{parent}","old.txt"]]"#)); + assert_eq!( + db.create_targets(id).unwrap(), + vec![(parent.clone(), "old.txt".to_string())] + ); + assert_eq!(db.create_first_sent(id).unwrap(), None); + db.note_create_target(id, &parent, "a.txt", 900).unwrap(); + assert_eq!( + db.create_targets(id).unwrap().len(), + 2, + "the old target stays" + ); + assert_eq!(db.create_first_sent(id).unwrap(), Some(900)); + + set("not json"); + db.note_create_target(id, &parent, "c.txt", 1000).unwrap(); + let raw: String = db + .conn + .lock() + .query_row("SELECT sent_to FROM pending_op WHERE id = ?1", [id], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!( + raw, "not json", + "an unreadable record is kept, not replaced" + ); +} + /// A write after a create failed clears its attempts, so the next try goes /// out at once. The create was still sent: deleting the file then left no /// trash, and the file a lost answer made stayed on Drive under the name it @@ -4135,7 +4296,7 @@ fn a_create_rewritten_after_it_failed_still_leaves_a_trash_when_deleted() { next_attempt_at: 0, }) .unwrap(); - db.note_create_target(id, &parent, "a.txt").unwrap(); + db.note_create_target(id, &parent, "a.txt", 10).unwrap(); db.note_create_sent(id, "aaaa").unwrap(); db.record_op_failure(id, "no answer", 0).unwrap(); db.attach_blob_to_create("local~sent", "/staging/second", "{}") diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 5cc7b35..56eb656 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -1118,7 +1118,7 @@ impl Core { _ => None, }; let sent = self.create_sent_digests(op.id, current); - Ok(adoptable(is_dir, &twin, op.created_at, &sent).then_some(twin.uid)) + Ok(adoptable(is_dir, &twin, self.first_sent(op), &sent).then_some(twin.uid)) } /// Record the name the remote actually gave a node, after a conflict forced @@ -1513,8 +1513,10 @@ impl Core { .as_deref() .is_some_and(|sha| holds_bytes(&twin, sha)); let sent = self.create_sent_digests(op.id, current); - Ok(adoptable(op.kind == OP_MKDIR, &twin, op.created_at, &sent) - .then_some((twin.uid, holds_blob))) + Ok( + adoptable(op.kind == OP_MKDIR, &twin, self.first_sent(op), &sent) + .then_some((twin.uid, holds_blob)), + ) } /// The SHA-1s of the bytes queued create `id` may have made its file with: @@ -1529,6 +1531,22 @@ impl Core { sent } + /// When create `op` first went out, which is the earliest a node its lost + /// answer left can have been made. An op queued offline may wait for days + /// before that; measured from when it was queued, the adoption window let + /// it take a node another device made in the meantime. Ops sent before the + /// time was recorded fall back to when they were queued. + fn first_sent(&self, op: &PendingOp) -> i64 { + match self.db.create_first_sent(op.id) { + Ok(Some(at)) => at.max(op.created_at), + Ok(None) => op.created_at, + Err(e) => { + warn!(id = op.id, error = %e, "reading when a queued create was sent failed"); + op.created_at + } + } + } + /// Make one queued `create`/`mkdir` real under a given name, and hand back /// the API's own error so the caller can tell a name clash from a failure. /// @@ -1541,7 +1559,7 @@ impl Core { name: &str, ) -> Result> { self.db - .note_create_target(op.id, &parent.to_string(), name)?; + .note_create_target(op.id, &parent.to_string(), name, now_millis())?; match op.kind == OP_MKDIR { true => Ok(self.block_on_bounded(self.drive.create_folder( parent, @@ -2236,7 +2254,7 @@ impl Core { .clone() .ok_or("pending op has no staged blob")?; let blob = PathBuf::from(blob); - let meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?; + let mut meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?; let uid = parse_node_uid(&meta.uid).ok_or("staged write has an unparseable uid")?; if let Some(reason) = self.revision_conflict(&uid, &meta, &blob)? { @@ -2275,6 +2293,22 @@ impl Core { &written, ) .map_err(|e| self.errno_error(e, "gap-fill from base failed"))?; + // The blob is whole now. Recorded, so that a retry after a lost + // answer can match these bytes against the server's revision + // (`revision_conflict`) instead of forking a conflict copy of the + // user's own write. + meta.complete = true; + match serde_json::to_string(&meta) { + Ok(json) => { + let path = op.blob_path.as_deref().unwrap_or_default(); + if let Err(e) = self.db.update_op_meta_for_blob(op.id, path, &json) { + warn!(%uid, error = %e, + "recording a gap-filled blob as whole failed; \ + a lost answer may land it as a conflict copy"); + } + } + Err(e) => warn!(%uid, error = %e, "serializing a gap-filled sidecar failed"), + } } let name = self.node_name(&uid); @@ -2758,19 +2792,19 @@ fn waits_for_name(e: &(dyn std::error::Error + 'static)) -> bool { /// wait has to end; a conflict name ends it. const NAME_HOLD_ATTEMPTS: i64 = 4; -/// How much older than its op a remote node may be and still be the one an -/// unanswered call made. The op is queued the moment the call gives up, so the -/// node is at most one call's deadline older; the rest is clock skew between -/// this machine and Proton. +/// How much older than its op's first send a remote node may be and still be +/// the one an unanswered call made. The node is made during that call, so it +/// is at most one call's deadline older; the rest is clock skew between this +/// machine and Proton. const ADOPT_WINDOW_MS: i64 = 2 * 60 * 1000; /// Whether `twin`, found holding the name a queued create wants, is the node an -/// earlier, unanswered attempt at that create made: the same kind, created -/// around when the op was queued, and for a file still empty, as the mount's +/// earlier, unanswered attempt at that create made: the same kind, created no +/// earlier than around when the op was first sent (`first_sent_ms`), and for a file still empty, as the mount's /// create leaves it, or holding exactly bytes the op uploaded, whose SHA-1 is /// one of `sent`. -fn adoptable(is_dir: bool, twin: &Node, op_created_ms: i64, sent: &[String]) -> bool { - if twin.creation_time.saturating_mul(1000) < op_created_ms - ADOPT_WINDOW_MS { +fn adoptable(is_dir: bool, twin: &Node, first_sent_ms: i64, sent: &[String]) -> bool { + if twin.creation_time.saturating_mul(1000) < first_sent_ms - ADOPT_WINDOW_MS { return false; } match &twin.kind { diff --git a/docs/BUGS.md b/docs/BUGS.md index f238331..f02a58c 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,9 @@ a live account since. | Entry | Title | |---|---| +| [B230](#b230--a-create-queued-offline-adopts-another-devices-file-of-the-same-name) | A create queued offline adopts another device's file of the same name | +| [B229](#b229--a-queued-write-can-be-lost-to-a-power-cut-after-close-returned) | A queued write can be lost to a power cut after close returned | +| [B228](#b228--a-gap-filled-write-whose-upload-answer-was-lost-becomes-a-conflict-copy-of-itself) | A gap-filled write whose upload answer was lost becomes a conflict copy of itself | | [B227](#b227--reading-a-file-open-for-writing-downloads-on-the-dispatch-loop) | Reading a file open for writing downloads on the dispatch loop | | [B226](#b226--thumbnails-of-portrait-phone-photos-lie-on-their-side) | Thumbnails of portrait phone photos lie on their side | | [B225](#b225--a-takeout-photo-without-a-sidecar-takes-a-siblings-name-and-date) | A Takeout photo without a sidecar takes a sibling's name and date | @@ -330,6 +333,87 @@ a live account since. Newest first. +### B230 — A create queued offline adopts another device's file of the same name + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.14). +**Where:** `adoptable` and `Core::first_sent` in `crates/pdfs-fuse/src/drain.rs`; +`note_create_target`, `create_first_sent` and `parse_sent_to` in +`crates/pdfs-core/src/db/ops.rs` + +**Repro:** Offline, create an empty file `notes.txt` in the mount. Before this machine goes +online again, create an empty `notes.txt` in the same folder on another device. Go online. + +**Cause.** A queued create that finds its name taken may adopt the node there, when that node is +the one an earlier attempt made and lost the answer to (B127, B184). One condition is that the +node was made around the time of the op. That time was when the op was queued, so an op queued +offline for days accepted any empty node of the same name made since, including another +device's file or folder. A folder adopted that way merged both devices' contents. + +Separately, a `sent_to` record that did not parse was read as empty, and the next target +replaced it. That erased the earlier targets the B184 and B191 checks look for. + +**Fix.** Each target in `sent_to` now records when it was first sent, and the adoption window +starts at the op's first send. Records written before this change fall back to the queue time. +A record that does not parse is kept as it is and a warning is logged. Without it a lost answer +forks a conflict copy, which loses nothing. + +**Test:** `db::tests::a_create_remembers_when_it_was_first_sent`; +`drain::tests::only_a_fresh_empty_twin_of_the_same_kind_is_adopted`. + +--- + +### B229 — A queued write can be lost to a power cut after close returned + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.13). +**Where:** `Durable` and `Db::durable` in `crates/pdfs-core/src/db/mod.rs`; `enqueue_op`, +`replace_ops_with_trash`, `attach_blob_to_create`, `finish_create` and `rewrite_op_target` in +`crates/pdfs-core/src/db/ops.rs` + +**Repro:** Write a file into the mount, let `close()` return, and cut the power before the next +WAL checkpoint. + +**Cause.** The write connection runs with `synchronous=NORMAL`. In WAL mode a commit then +survives a crash of the process but not a power loss, because the WAL is synced only at +checkpoints. The staged blob is synced to disk before its op is queued, but the `pending_op` row +that makes it reachable could roll back. The write `close()` reported as done was then gone, and +the blob was deleted as an orphan later. + +**Fix.** The commits that accept a change into the queue raise the writer to +`synchronous=FULL` for themselves and set it back to NORMAL after. A FULL commit syncs the WAL, +which covers the NORMAL commits before it too. The other writes keep NORMAL's cost. + +**Test:** `db::tests::queueing_a_write_commits_at_full_sync_and_restores_normal`. + +--- + +### B228 — A gap-filled write whose upload answer was lost becomes a conflict copy of itself + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.8). +**Where:** `Core::drain_revision` in `crates/pdfs-fuse/src/drain.rs`; `Db::update_op_meta_for_blob` +in `crates/pdfs-core/src/db/ops.rs` + +**Repro:** Offline, write a few bytes into the middle of a large file that is only on Drive, and +close it. Go online, and lose the answer to the upload that follows (a timeout, or a daemon +stopped before the op is retired). + +**Cause.** The staged blob of such a write holds only the written ranges. The drain fills the +gaps from the base before the upload, in place, but the op's sidecar still said +`complete = false`. The retry then saw a new remote revision, and both checks that recognise +the daemon's own lost upload (`is_own_self_supersede` and the B155 SHA-1 match) require a +complete blob. The write was treated as a conflict and uploaded again as a +`(sync-conflict)` copy of itself. + +**Fix.** After a successful gap-fill the drain records the sidecar as complete. The update is +made only while the op still holds that blob, so a newer write that closed in the meantime keeps +its own sidecar. + +**Test:** `db::tests::marking_a_blob_whole_leaves_a_newer_blob_alone`. + +--- + ### B227 — Reading a file open for writing downloads on the dispatch loop **Status:** Fixed, not verified live From 519ba0591a0277df910e348edfa4b46f36a39b77 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:21:57 +0200 Subject: [PATCH 15/44] fix(fuse): give uploads under a low cap the time their share needs The upload deadline assumed at least 32 KiB/s. Under an upload cap shared by every drain worker and sync task, a large file ran out of time on every attempt, took the mount offline and restarted from byte 0. The deadline now counts the upload's share of the cap as the slowest rate when that share is lower (B231). --- crates/pdfs-fuse/src/drain.rs | 6 ++--- crates/pdfs-fuse/src/link.rs | 41 ++++++++++++++++++++++++++--- crates/pdfs-fuse/src/sync.rs | 2 +- crates/pdfs-fuse/src/sync/engine.rs | 2 +- crates/pdfs-fuse/src/transfers.rs | 5 ++++ docs/BUGS.md | 22 ++++++++++++++++ 6 files changed, 69 insertions(+), 9 deletions(-) diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 56eb656..3668217 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -1604,7 +1604,7 @@ impl Core { .begin(name, op.uid.clone(), TransferDirection::Upload, meta.len); let reader = CountingReader::new(File::open(blob)?, &guard); let uid = self.block_on_within( - link::upload_deadline(meta.len), + link::upload_deadline(meta.len, self.transfers.upload_cap()), self.drive.upload_file_from( parent, name, @@ -2001,7 +2001,7 @@ impl Core { .begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len); let reader = CountingReader::new(File::open(blob)?, &guard); self.block_on_within( - link::upload_deadline(meta.len), + link::upload_deadline(meta.len, self.transfers.upload_cap()), self.drive.upload_file_from( &parent, &alt, @@ -2329,7 +2329,7 @@ impl Core { let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag()); let started = Instant::now(); let sent = self.block_on_within( - link::upload_deadline(meta.len), + link::upload_deadline(meta.len, self.transfers.upload_cap()), self.drive .upload_new_revision_from(&uid, reader, meta.len as i64, thumbnails, None), ); diff --git a/crates/pdfs-fuse/src/link.rs b/crates/pdfs-fuse/src/link.rs index bd61914..b23a352 100644 --- a/crates/pdfs-fuse/src/link.rs +++ b/crates/pdfs-fuse/src/link.rs @@ -119,10 +119,25 @@ pub(crate) fn no_answer(after: Duration) -> ProtonError { /// transfer that has stopped moving runs into it. const SLOWEST_UPLOAD: u64 = 32 * 1024; +/// How many uploads can share the upload cap at once: one per drain worker +/// and one per mirror sync task. +const UPLOAD_SLOTS: u64 = (crate::DRAIN_WORKERS + crate::sync::SYNC_CONCURRENCY) as u64; + /// How long an upload of `len` bytes may take before the link is treated as /// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`]. -pub(crate) fn upload_deadline(len: u64) -> Duration { - INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / SLOWEST_UPLOAD) +/// +/// An upload cap of `cap` bytes per second (`0` for none) is shared by every +/// upload running, so each may get as little as its share of it. Below +/// [`SLOWEST_UPLOAD`] a share counts as the slowest rate instead: measured +/// against the fixed rate, a large file under a low cap ran out of time on +/// every attempt, took the mount offline each time, and started over from +/// its first byte. +pub(crate) fn upload_deadline(len: u64, cap: u64) -> Duration { + let slowest = match cap { + 0 => SLOWEST_UPLOAD, + cap => SLOWEST_UPLOAD.min((cap / UPLOAD_SLOTS).max(1)), + }; + INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / slowest) } /// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`]. @@ -410,13 +425,31 @@ mod tests { #[test] fn an_upload_gets_longer_the_bigger_it_is() { - assert_eq!(upload_deadline(0), INTERACTIVE_CALL_TIMEOUT); + assert_eq!(upload_deadline(0, 0), INTERACTIVE_CALL_TIMEOUT); assert_eq!( - upload_deadline(1 << 30), + upload_deadline(1 << 30, 0), INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768) ); } + #[test] + fn an_upload_under_a_low_cap_gets_the_time_its_share_of_the_cap_needs() { + // A generous cap leaves the deadline as it was. + assert_eq!( + upload_deadline(1 << 30, 100 << 20), + upload_deadline(1 << 30, 0) + ); + // 512 KiB/s shared by every upload slot is below the slowest rate. + let share = (512 << 10) / UPLOAD_SLOTS; + assert!(share < SLOWEST_UPLOAD); + assert_eq!( + upload_deadline(1 << 30, 512 << 10), + INTERACTIVE_CALL_TIMEOUT + Duration::from_secs((1 << 30) / share) + ); + // A cap below one byte per slot still gives a finite deadline. + assert!(upload_deadline(1 << 20, 1) > upload_deadline(1 << 20, 512 << 10)); + } + #[test] fn a_call_past_its_deadline_reads_as_a_lost_link() { let rt = tokio::runtime::Builder::new_current_thread() diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index 8a2246b..093a2ee 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -47,7 +47,7 @@ mod engine; mod local_scan; mod model; mod planner; -use engine::SYNC_CONCURRENCY; +pub(crate) use engine::SYNC_CONCURRENCY; pub(crate) use engine::{SyncMsg, spawn}; #[cfg(test)] use engine::{classify, event_target, settle_with}; diff --git a/crates/pdfs-fuse/src/sync/engine.rs b/crates/pdfs-fuse/src/sync/engine.rs index 7755b5f..5664392 100644 --- a/crates/pdfs-fuse/src/sync/engine.rs +++ b/crates/pdfs-fuse/src/sync/engine.rs @@ -22,7 +22,7 @@ const POLL_INTERVAL: Duration = Duration::from_secs(120); /// per-item work is a network round-trip, so a folder with thousands of files is /// unusable done one-at-a-time; this bounds concurrency like the bulk-upload /// engine does. -pub(super) const SYNC_CONCURRENCY: usize = 8; +pub(crate) const SYNC_CONCURRENCY: usize = 8; /// A message to the sync engine's control thread. pub(crate) enum SyncMsg { diff --git a/crates/pdfs-fuse/src/transfers.rs b/crates/pdfs-fuse/src/transfers.rs index 3e461b1..30399fb 100644 --- a/crates/pdfs-fuse/src/transfers.rs +++ b/crates/pdfs-fuse/src/transfers.rs @@ -125,6 +125,11 @@ impl TransferRegistry { self.download_limit.set(download); } + /// The upload cap in bytes per second, `0` when there is none. + pub fn upload_cap(&self) -> u64 { + self.upload_limit.rate.load(Ordering::Relaxed) + } + fn limit(&self, direction: TransferDirection) -> &RateLimit { match direction { TransferDirection::Upload => &self.upload_limit, diff --git a/docs/BUGS.md b/docs/BUGS.md index f02a58c..d4702ee 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B231](#b231--a-large-upload-under-a-low-upload-limit-never-finishes) | A large upload under a low upload limit never finishes | | [B230](#b230--a-create-queued-offline-adopts-another-devices-file-of-the-same-name) | A create queued offline adopts another device's file of the same name | | [B229](#b229--a-queued-write-can-be-lost-to-a-power-cut-after-close-returned) | A queued write can be lost to a power cut after close returned | | [B228](#b228--a-gap-filled-write-whose-upload-answer-was-lost-becomes-a-conflict-copy-of-itself) | A gap-filled write whose upload answer was lost becomes a conflict copy of itself | @@ -333,6 +334,27 @@ a live account since. Newest first. +### B231 — A large upload under a low upload limit never finishes + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.9). +**Where:** `upload_deadline` in `crates/pdfs-fuse/src/link.rs`; `TransferRegistry::upload_cap` in +`crates/pdfs-fuse/src/transfers.rs`; the three uploads in `crates/pdfs-fuse/src/drain.rs` + +**Repro:** Set the upload limit to 512 KiB/s or less, and queue several large files. + +**Cause.** An upload's deadline allowed for at least 32 KiB/s. The upload cap is one budget +shared by every running upload: up to 16 drain workers and 8 mirror sync tasks. Under a low cap +each upload got less than 32 KiB/s, so a large file ran out of time on every attempt. The +timeout took the mount offline, and on reconnect the upload started again from its first byte. + +**Fix.** The deadline counts an upload's share of the cap as the slowest rate when that share is +below 32 KiB/s. Without a cap, or with a generous one, the deadline is unchanged. + +**Test:** `link::tests::an_upload_under_a_low_cap_gets_the_time_its_share_of_the_cap_needs`. + +--- + ### B230 — A create queued offline adopts another device's file of the same name **Status:** Fixed, not verified live From ef0f0469390f11a8cbd72e2072b1f6c8527a3d7b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:24:43 +0200 Subject: [PATCH 16/44] fix(gui): show remote names as text, not Pango markup Rows, toasts and status page descriptions parse their text as markup by default. Shared item names, inviter addresses, device names, link URLs, paths, EXIF values and daemon errors went in unescaped, so a name with `&` showed as an empty row and other names were drawn as markup. Those rows and toasts now use plain text, and status page descriptions are escaped (B232). --- crates/pdfs-gui/src/app/main.rs | 18 +++++++++++-- crates/pdfs-gui/src/app/pages/activity.rs | 4 ++- crates/pdfs-gui/src/app/pages/albums.rs | 4 ++- crates/pdfs-gui/src/app/pages/browser.rs | 4 ++- crates/pdfs-gui/src/app/pages/devices.rs | 11 ++++++-- crates/pdfs-gui/src/app/pages/duplicates.rs | 3 ++- crates/pdfs-gui/src/app/pages/locations.rs | 4 ++- crates/pdfs-gui/src/app/pages/photo_viewer.rs | 1 + crates/pdfs-gui/src/app/pages/photos.rs | 4 ++- crates/pdfs-gui/src/app/pages/places.rs | 4 ++- crates/pdfs-gui/src/app/pages/shared.rs | 6 ++++- crates/pdfs-gui/src/app/pages/shared_by_me.rs | 4 ++- crates/pdfs-gui/src/app/pages/status.rs | 1 + crates/pdfs-gui/src/app/pages/takeout.rs | 1 + crates/pdfs-gui/src/app/pages/trash.rs | 4 ++- crates/pdfs-gui/src/app/widgets/details.rs | 2 ++ .../pdfs-gui/src/app/widgets/share_dialog.rs | 2 ++ .../src/app/widgets/versions_dialog.rs | 1 + docs/BUGS.md | 25 +++++++++++++++++++ 19 files changed, 89 insertions(+), 14 deletions(-) diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 37f4378..277d6f1 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -1065,8 +1065,17 @@ pub(crate) fn set_sidebar_badge(ui: &Ui, page: &str, count: u64) { /// Show a transient toast. Non-blocking by design: an action's outcome is /// reported without stealing focus or forcing a click, so the user can keep /// working while a slow upload lands. +/// +/// A toast's title is Pango markup unless told otherwise. Messages carry file +/// names and daemon errors, and one with a `&` or `<` in it showed as an empty +/// toast, so every toast here is plain text. fn toast(ui: &Rc, message: &str) { - ui.toasts.add_toast(adw::Toast::new(message)); + ui.toasts.add_toast( + adw::Toast::builder() + .title(message) + .use_markup(false) + .build(), + ); } /// Show a toast that offers one follow-up action — an Undo, or a way to the page @@ -1077,6 +1086,7 @@ fn toast(ui: &Rc, message: &str) { fn toast_action(ui: &Rc, message: &str, label: &str, action: impl Fn(&Rc) + 'static) { let toast = adw::Toast::builder() .title(message) + .use_markup(false) .button_label(label) .timeout(8) .build(); @@ -1099,7 +1109,11 @@ fn toast_error(ui: &Rc, what: &str, detail: &str) { gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)]) }; tracing::warn!("{message}"); - let toast = adw::Toast::builder().title(&message).timeout(6).build(); + let toast = adw::Toast::builder() + .title(&message) + .use_markup(false) + .timeout(6) + .build(); ui.toasts.add_toast(toast); } diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs index 065b019..9a7b558 100644 --- a/crates/pdfs-gui/src/app/pages/activity.rs +++ b/crates/pdfs-gui/src/app/pages/activity.rs @@ -378,7 +378,9 @@ pub(crate) fn activity_status( ) { ui.activity.status.set_icon_name(Some(icon)); ui.activity.status.set_title(title); - ui.activity.status.set_description(Some(description)); + ui.activity + .status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.activity.retry.set_visible(retry); ui.activity.content.set_visible_child_name("status"); } diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs index 1ee2f34..29e64ec 100644 --- a/crates/pdfs-gui/src/app/pages/albums.rs +++ b/crates/pdfs-gui/src/app/pages/albums.rs @@ -645,7 +645,9 @@ pub(crate) fn close_album(ui: &Rc) { fn albums_status(ui: &Rc, icon: &str, title: &str, description: &str) { ui.gallery.albums_status.set_icon_name(Some(icon)); ui.gallery.albums_status.set_title(title); - ui.gallery.albums_status.set_description(Some(description)); + ui.gallery + .albums_status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.gallery.albums_stack.set_visible_child_name("status"); } diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 291e0d8..dd39886 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -500,7 +500,9 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { pub(crate) fn browser_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { ui.browser.status.set_icon_name(Some(icon)); ui.browser.status.set_title(title); - ui.browser.status.set_description(Some(description)); + ui.browser + .status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.browser.retry.set_visible(retry); // Only the empty-folder state offers a way to fill the folder; every other // status (loading, offline, error) turns them back off. diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index cdd28c7..fa7d84b 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -204,7 +204,9 @@ pub(crate) fn wire_devices(ui: &Rc, widgets: &DevicesWidgets) { pub(crate) fn devices_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { ui.devices.status.set_icon_name(Some(icon)); ui.devices.status.set_title(title); - ui.devices.status.set_description(Some(description)); + ui.devices + .status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.devices.retry.set_visible(retry); ui.devices.content.set_visible_child_name("status"); } @@ -390,6 +392,7 @@ fn this_computer_row( let row = adw::ActionRow::builder() .title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone())) .subtitle(this_computer_subtitle(folders)) + .use_markup(false) .activatable(true) .build(); row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); @@ -426,6 +429,7 @@ fn other_computer_row(ui: &Rc, dev: &DeviceInfo) -> adw::ActionRow { let row = adw::ActionRow::builder() .title(&dev.name) .subtitle(device_subtitle(dev)) + .use_markup(false) .activatable(true) .build(); row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); @@ -763,7 +767,10 @@ fn show_restore_picker( .active(true) .valign(gtk4::Align::Center) .build(); - let row = adw::EntryRow::builder().title(&f.name).build(); + let row = adw::EntryRow::builder() + .title(&f.name) + .use_markup(false) + .build(); row.set_text(&f.local_path); row.add_prefix(&check); group.add(&row); diff --git a/crates/pdfs-gui/src/app/pages/duplicates.rs b/crates/pdfs-gui/src/app/pages/duplicates.rs index 2ebc2ed..97881ff 100644 --- a/crates/pdfs-gui/src/app/pages/duplicates.rs +++ b/crates/pdfs-gui/src/app/pages/duplicates.rs @@ -209,7 +209,8 @@ fn ask(view: &Rc, finder: Finder, generation: u32) { Ok(Ok(Response::Error { message, .. })) => { view.status.set_icon_name(Some("dialog-warning-symbolic")); view.status.set_title(&failed); - view.status.set_description(Some(&message)); + view.status + .set_description(Some(>k4::glib::markup_escape_text(&message))); view.progress.set_visible(false); return; } diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index fdf22fc..a758c92 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -1145,7 +1145,9 @@ pub(crate) fn locations_status( ) { ui.locations.status.set_icon_name(Some(icon)); ui.locations.status.set_title(title); - ui.locations.status.set_description(Some(description)); + ui.locations + .status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.locations.retry.set_visible(retry); ui.locations.content.set_visible_child_name("status"); } diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index f394649..6a3ab95 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -481,6 +481,7 @@ pub(crate) fn show_info(viewer: &Rc, path: &str, info: ExifInfo) { let row = adw::ActionRow::builder() .title(label.as_str()) .subtitle(value) + .use_markup(false) .subtitle_selectable(true) .build(); row.add_css_class("property"); diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 94105b2..2e9feb2 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -3459,7 +3459,9 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { pub(crate) fn gallery_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { ui.gallery.status.set_icon_name(Some(icon)); ui.gallery.status.set_title(title); - ui.gallery.status.set_description(Some(description)); + ui.gallery + .status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.gallery.retry.set_visible(retry); // Only the empty timeline offers a way to fill it; every other status turns // those buttons back off. diff --git a/crates/pdfs-gui/src/app/pages/places.rs b/crates/pdfs-gui/src/app/pages/places.rs index ea0364b..4c817ae 100644 --- a/crates/pdfs-gui/src/app/pages/places.rs +++ b/crates/pdfs-gui/src/app/pages/places.rs @@ -448,7 +448,9 @@ pub(crate) fn close_place(ui: &Rc) { fn places_status(ui: &Rc, icon: &str, title: &str, description: &str) { ui.gallery.places_status.set_icon_name(Some(icon)); ui.gallery.places_status.set_title(title); - ui.gallery.places_status.set_description(Some(description)); + ui.gallery + .places_status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.gallery.places_stack.set_visible_child_name("status"); } diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index efc1352..2bfd3d5 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -311,7 +311,9 @@ pub(crate) fn wire_shared(ui: &Rc, widgets: &SharedWidgets) { pub(crate) fn shared_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { ui.shared.status.set_icon_name(Some(icon)); ui.shared.status.set_title(title); - ui.shared.status.set_description(Some(description)); + ui.shared + .status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.shared.retry.set_visible(retry); ui.shared.content.set_visible_child_name("status"); } @@ -818,6 +820,7 @@ fn invitation_row(inv: &InvitationInfo) -> adw::ActionRow { .title(&item) // Translators: {email} is the address of the person who sent the invitation. .subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)])) + .use_markup(false) .build(); row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir { "folder-symbolic" @@ -862,6 +865,7 @@ fn repaint_links(ui: &Rc, bookmarks: &[BookmarkInfo]) { let row = adw::ActionRow::builder() .title(&title) .subtitle(&bm.url) + .use_markup(false) .subtitle_lines(1) .activatable(true) .tooltip_text(gettext("Open in browser")) diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index 2d99bf7..42e5879 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -329,7 +329,9 @@ pub(crate) fn shared_by_me_status( ) { ui.shared_by_me.status.set_icon_name(Some(icon)); ui.shared_by_me.status.set_title(title); - ui.shared_by_me.status.set_description(Some(description)); + ui.shared_by_me + .status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.shared_by_me.retry.set_visible(retry); ui.shared_by_me.content.set_visible_child_name("status"); ui.shared_by_me.subtitle.set_subtitle(""); diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index e14bafc..dd2dfbd 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -1566,6 +1566,7 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: let row = adw::ActionRow::builder() .title(&name) .subtitle(&pin.path) + .use_markup(false) .build(); let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic"); row.add_prefix(&icon); diff --git a/crates/pdfs-gui/src/app/pages/takeout.rs b/crates/pdfs-gui/src/app/pages/takeout.rs index bbbf64c..8e888b5 100644 --- a/crates/pdfs-gui/src/app/pages/takeout.rs +++ b/crates/pdfs-gui/src/app/pages/takeout.rs @@ -510,6 +510,7 @@ pub(crate) fn repaint_archives(ui: &Rc) { .unwrap_or_else(|| path.display().to_string()), ) .subtitle(human_bytes(size)) + .use_markup(false) .build(); let remove = gtk4::Button::builder() .icon_name("edit-delete-symbolic") diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs index 5882a9a..843e524 100644 --- a/crates/pdfs-gui/src/app/pages/trash.rs +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -330,7 +330,9 @@ pub(crate) fn trash_unreachable(ui: &Rc) { pub(crate) fn trash_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { ui.trash.status.set_icon_name(Some(icon)); ui.trash.status.set_title(title); - ui.trash.status.set_description(Some(description)); + ui.trash + .status + .set_description(Some(>k4::glib::markup_escape_text(description))); ui.trash.retry.set_visible(retry); ui.trash.content.set_visible_child_name("status"); ui.trash.subtitle.set_subtitle(""); diff --git a/crates/pdfs-gui/src/app/widgets/details.rs b/crates/pdfs-gui/src/app/widgets/details.rs index 625f53b..4cb9c0a 100644 --- a/crates/pdfs-gui/src/app/widgets/details.rs +++ b/crates/pdfs-gui/src/app/widgets/details.rs @@ -98,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { .title(gettext("Location")) .subtitle("—") .subtitle_lines(3) + // A folder path, which may hold `&` or `<`. + .use_markup(false) .build(); path_row.add_css_class("property"); props.add(&size_row); diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs index 8da0459..1bca73d 100644 --- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -491,6 +491,7 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr let row = adw::ActionRow::builder() .title(&entry.email) .subtitle(&subtitle) + .use_markup(false) .build(); row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true)); @@ -605,6 +606,7 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi url.clone() }) .subtitle(&subtitle) + .use_markup(false) .build(); row.add_css_class("property"); diff --git a/crates/pdfs-gui/src/app/widgets/versions_dialog.rs b/crates/pdfs-gui/src/app/widgets/versions_dialog.rs index 6b1366d..ad9bf6c 100644 --- a/crates/pdfs-gui/src/app/widgets/versions_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/versions_dialog.rs @@ -219,6 +219,7 @@ fn repaint_versions(state: &Rc, items: &[RevisionInfo]) { let row = adw::ActionRow::builder() .title(title) .subtitle(subtitle) + .use_markup(false) .build(); let save = gtk4::Button::builder() diff --git a/docs/BUGS.md b/docs/BUGS.md index d4702ee..e93be49 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B232](#b232--names-with--or--show-as-empty-rows-or-are-drawn-as-markup) | Names with & or < show as empty rows or are drawn as markup | | [B231](#b231--a-large-upload-under-a-low-upload-limit-never-finishes) | A large upload under a low upload limit never finishes | | [B230](#b230--a-create-queued-offline-adopts-another-devices-file-of-the-same-name) | A create queued offline adopts another device's file of the same name | | [B229](#b229--a-queued-write-can-be-lost-to-a-power-cut-after-close-returned) | A queued write can be lost to a power cut after close returned | @@ -334,6 +335,30 @@ a live account since. Newest first. +### B232 — Names with & or < show as empty rows or are drawn as markup + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, Pango markup injection). +**Where:** the toast helpers in `crates/pdfs-gui/src/app/main.rs`; rows in +`app/pages/shared.rs`, `devices.rs`, `status.rs`, `takeout.rs`, `photo_viewer.rs`, +`app/widgets/share_dialog.rs`, `details.rs`, `versions_dialog.rs`; every status page helper + +**Repro:** Accept an invitation to a folder named `R&D`, or name a computer `Tom & Jerry`. +Its row in the app is empty. A name such as `x` is drawn as markup. + +**Cause.** Libadwaita rows, toasts and status page descriptions read their text as Pango markup +unless told otherwise. Names that other people control (shared items, inviter addresses, device +names, link URLs), file paths, EXIF values and daemon error messages went in unescaped. Text that +did not parse showed as an empty row; text that did was drawn as markup. + +**Fix.** Rows and toasts that show such text are built with `use_markup(false)`. Status page +descriptions, which have no such switch, are escaped with `markup_escape_text`. + +**Test:** none automated; the change only sets a widget property. Checked by reading every row, +toast and status page that takes text from outside the program. + +--- + ### B231 — A large upload under a low upload limit never finishes **Status:** Fixed, not verified live From 518763c5c934596a709dcb0a392d1a5affa9602a Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:26:29 +0200 Subject: [PATCH 17/44] fix(core,cli): end the session on Proton when signing out Logout only deleted the keyring entry, so the session's tokens stayed valid on Proton's side. It now ends the session first (DELETE auth/v4, bounded to 10 s) and signs out locally either way. `pdfs logout` says when Proton could not be reached (B233). --- crates/pdfs-cli/src/main.rs | 10 ++++- crates/pdfs-core/src/auth.rs | 57 ++++++++++++++++++++++++-- crates/pdfs-gui/src/app/pages/login.rs | 2 +- docs/BUGS.md | 24 +++++++++++ docs/CLI.md | 2 +- 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 3b60cd6..890d8f3 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -1977,8 +1977,14 @@ fn key_state_name() -> &'static str { } fn cmd_logout() -> Result<()> { - auth::logout()?; - println!("Stored session cleared."); + if auth::logout()? { + println!("Signed out: the session is ended on Proton and cleared here."); + } else { + println!( + "Stored session cleared. Proton could not be reached to end the session; \ + it stays valid until it expires or is signed out in your account's security settings." + ); + } Ok(()) } diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index 796db3c..5acb3a0 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -516,8 +516,59 @@ pub fn lock(forget: bool) -> Result<()> { Ok(()) } -/// Forget the persisted session (best-effort; absent entry is not an error). -pub fn logout() -> Result<()> { +/// How long [`logout`] waits for Proton to end the session before it signs +/// out locally without. +const END_SESSION_TIMEOUT: Duration = Duration::from_secs(10); + +/// End the stored session on Proton's side (`DELETE auth/v4`). Returns false +/// when there is none or Proton could not be reached in time. +/// +/// Runs on a thread of its own, so it works the same whether the caller is +/// inside a tokio runtime or not. +fn end_server_session() -> bool { + let Ok(stored) = load() else { + return false; + }; + let ended = std::thread::scope(|scope| { + scope + .spawn(|| -> std::result::Result<(), String> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + rt.block_on(async { + let session = resume_stored(&stored).map_err(|e| e.to_string())?; + match tokio::time::timeout(END_SESSION_TIMEOUT, session.end()).await { + Ok(result) => result.map_err(|e| e.to_string()), + Err(_) => Err(format!( + "no answer within {}s", + END_SESSION_TIMEOUT.as_secs() + )), + } + }) + }) + .join() + .unwrap_or_else(|_| Err("the sign-out thread panicked".into())) + }); + match ended { + Ok(()) => true, + Err(error) => { + tracing::warn!(%error, "ending the session on Proton failed; signing out locally"); + false + } + } +} + +/// End the session on Proton, then forget it here. Returns whether Proton +/// ended it: offline, the session stays valid on Proton's side until it +/// expires or is signed out from the account's security settings. +/// +/// Before this, signing out only deleted the keyring entry. The tokens stayed +/// valid on Proton, so a copy of them taken from this machine still worked. +/// +/// The local half is best effort; an absent entry is not an error. +pub fn logout() -> Result { + let ended = end_server_session(); // The persisted entity cache describes the account being logged out of. It // is encrypted under a key derived from that account's passphrases, but // leaving it behind would also mean the next account inherits a store it can @@ -539,7 +590,7 @@ pub fn logout() -> Result<()> { } let _ = kernelkey::UNSAVED_TOKENS.clear(); match keyring_entry()?.delete_credential() { - Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), + Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended), Err(e) => Err(e.into()), } } diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs index dcfa91d..8200a8c 100644 --- a/crates/pdfs-gui/src/app/pages/login.rs +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -602,7 +602,7 @@ pub(crate) fn sign_out(ui: &Rc) { glib::spawn_future_local(async move { let result = gio::spawn_blocking(|| { service::disable_stop(); - auth::logout().map_err(|e| e.to_string()) + auth::logout().map(|_| ()).map_err(|e| e.to_string()) }) .await; if let Ok(Err(e)) = result { diff --git a/docs/BUGS.md b/docs/BUGS.md index e93be49..9998ea2 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B233](#b233--signing-out-leaves-the-session-valid-on-proton) | Signing out leaves the session valid on Proton | | [B232](#b232--names-with--or--show-as-empty-rows-or-are-drawn-as-markup) | Names with & or < show as empty rows or are drawn as markup | | [B231](#b231--a-large-upload-under-a-low-upload-limit-never-finishes) | A large upload under a low upload limit never finishes | | [B230](#b230--a-create-queued-offline-adopts-another-devices-file-of-the-same-name) | A create queued offline adopts another device's file of the same name | @@ -335,6 +336,29 @@ a live account since. Newest first. +### B233 — Signing out leaves the session valid on Proton + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, logout). +**Where:** `logout` and `end_server_session` in `crates/pdfs-core/src/auth.rs`; `cmd_logout` in +`crates/pdfs-cli/src/main.rs` + +**Repro:** Copy the keyring entry, run `pdfs logout` (or **Sign Out** in the app), and resume a +session from the copy. + +**Cause.** Signing out only deleted the keyring entry and the local caches. The session was never +ended on Proton's side, so its tokens stayed valid until they expired. The SDK has the call for +it (`ProtonApiSession::end`, `DELETE auth/v4`); nothing used it. + +**Fix.** Logout resumes the stored session and ends it on Proton before it clears anything +locally. The call is bounded to 10 s and runs on a thread of its own. When Proton cannot be +reached, the local sign-out still happens, and `pdfs logout` says that the session stays valid +until it expires or is signed out in the account's security settings. + +**Test:** none automated; ending a session needs a live account. + +--- + ### B232 — Names with & or < show as empty rows or are drawn as markup **Status:** Fixed, not verified live diff --git a/docs/CLI.md b/docs/CLI.md index 29031cb..312da01 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -36,7 +36,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount | Command | Description | |---|---| | `pdfs login [-u EMAIL]` | Sign in (password, then a 2FA code if the account has one) and store the session in the system keyring. Restarts the service if it is running. | -| `pdfs logout` | Remove the stored session. | +| `pdfs logout` | End the session on Proton and remove the stored session. | | `pdfs status` | Account, mount, queue and cache state. | | `pdfs quota` | Storage used across all Proton products. | | `pdfs locations` | Every local place the service occupies: the Proton Drive folder and each synced folder, with mode and state. | From 51800c77d7b6b1ce3adf0fb8eddce243326923b8 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:30:40 +0200 Subject: [PATCH 18/44] fix(core,fuse): refuse sync roots that overlap home, the mount or app data Nothing stopped `/`, the home folder, the Proton Drive mount, the app's own state or cache, or a folder nested in another root from becoming a sync root, and a restore took a path from another machine's profile as it was. One validator, `syncroot::problem`, now runs where roots are added and restored, on paths resolved through symlinks with `..` refused (B234). --- crates/pdfs-core/src/lib.rs | 1 + crates/pdfs-core/src/syncroot.rs | 209 +++++++++++++++++++++++++++++++ crates/pdfs-fuse/src/devices.rs | 27 ++-- crates/pdfs-fuse/src/profile.rs | 30 +++-- docs/BUGS.md | 34 +++++ 5 files changed, 283 insertions(+), 18 deletions(-) create mode 100644 crates/pdfs-core/src/syncroot.rs diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 5338996..f429310 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod service; pub mod shell; pub mod similar; pub mod syncignore; +pub mod syncroot; pub mod takeout; pub mod tray; diff --git a/crates/pdfs-core/src/syncroot.rs b/crates/pdfs-core/src/syncroot.rs new file mode 100644 index 0000000..2fffafa --- /dev/null +++ b/crates/pdfs-core/src/syncroot.rs @@ -0,0 +1,209 @@ +//! Which local folders may become a sync root. +//! +//! A mirror uploads everything under its root and deletes what Drive deleted, +//! so a root that holds more than the user meant is dangerous. `$HOME` takes +//! the decrypted content cache, `cache.db` and `~/.ssh` up to Drive, and walks +//! into the mount, which downloads the whole Drive only to upload it again. +//! Every place a root is added or restored goes through [`problem`]. + +use std::path::{Component, Path, PathBuf}; + +use crate::config::{AppConfig, AppDirs}; + +/// The paths a sync root must stay clear of. +#[derive(Debug, Clone, Default)] +pub struct Reserved { + /// The user's home folder. A root may sit inside it, not be it or above it. + pub home: Option, + /// The Proton Drive mount. + pub mountpoint: Option, + /// This app's own configuration, state and cache folders. + pub app_dirs: Vec, +} + +impl Reserved { + /// The reserved paths of this user and `config`, resolved like a root. + pub fn current(config: &AppConfig) -> Self { + let Ok(dirs) = AppDirs::new() else { + return Self::default(); + }; + let resolved = |path: PathBuf| resolve(&path).unwrap_or(path); + let mut app_dirs = vec![dirs.state_dir(), dirs.cache_dir()]; + if let Some(config_dir) = dirs.config_path().parent() { + app_dirs.push(config_dir.to_path_buf()); + } + Self { + home: dirs.home_dir().map(resolved), + mountpoint: Some(resolved(dirs.resolved_mountpoint(config))), + app_dirs: app_dirs.into_iter().map(resolved).collect(), + } + } +} + +/// `path` as a root is compared: absolute, with symlinks resolved as far as the +/// path exists. A folder a restore is about to create does not exist yet, so +/// the part past the deepest existing folder is kept as written. +/// +/// A `..` is refused rather than resolved: before a symlink it means something +/// else than after it, and a path recorded on another machine has no business +/// holding one. +pub fn resolve(path: &Path) -> Result { + if !path.is_absolute() { + return Err(format!("{} is not an absolute path", path.display())); + } + if path.components().any(|c| c == Component::ParentDir) { + return Err(format!("{} goes up with `..`", path.display())); + } + let mut existing = path.to_path_buf(); + let mut rest = Vec::new(); + loop { + match existing.canonicalize() { + Ok(canonical) => { + return Ok(rest + .into_iter() + .rev() + .fold(canonical, |path: PathBuf, part| path.join(part))); + } + Err(_) => { + let Some(name) = existing.file_name().map(|n| n.to_os_string()) else { + return Ok(path.to_path_buf()); + }; + rest.push(name); + if !existing.pop() { + return Ok(path.to_path_buf()); + } + } + } + } +} + +/// Why `root` cannot be synced, or `None` when it can. `root` is resolved +/// ([`resolve`]); `others` are the roots already synced. +pub fn problem(root: &Path, others: &[PathBuf], reserved: &Reserved) -> Option { + let shown = root.display(); + if root.parent().is_none() { + return Some(format!("{shown} is the root of the file system")); + } + if let Some(home) = &reserved.home { + if root == home { + return Some(format!( + "{shown} is your home folder; pick a folder inside it" + )); + } + if home.starts_with(root) { + return Some(format!("{shown} holds your home folder")); + } + } + if let Some(mountpoint) = &reserved.mountpoint + && overlaps(root, mountpoint) + { + return Some(format!( + "{shown} overlaps the Proton Drive folder {}", + mountpoint.display() + )); + } + if let Some(dir) = reserved.app_dirs.iter().find(|dir| overlaps(root, dir)) { + return Some(format!( + "{shown} overlaps this app's own data in {}", + dir.display() + )); + } + if others.iter().any(|other| other == root) { + return Some(format!("{shown} is already synced")); + } + if let Some(other) = others.iter().find(|other| overlaps(root, other)) { + return Some(format!( + "{shown} overlaps the synced folder {}", + other.display() + )); + } + None +} + +/// Whether one of `a` and `b` is the other or inside it. +fn overlaps(a: &Path, b: &Path) -> bool { + a.starts_with(b) || b.starts_with(a) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reserved() -> Reserved { + Reserved { + home: Some("/home/u".into()), + mountpoint: Some("/home/u/ProtonDrive".into()), + app_dirs: vec![ + "/home/u/.local/state/proton-drive-linux".into(), + "/home/u/.cache/proton-drive-linux".into(), + "/home/u/.config/proton-drive-linux".into(), + ], + } + } + + fn refused(root: &str, others: &[&str]) -> bool { + let others: Vec = others.iter().map(PathBuf::from).collect(); + problem(Path::new(root), &others, &reserved()).is_some() + } + + #[test] + fn a_folder_inside_home_can_be_synced() { + assert!(!refused("/home/u/Documents", &[])); + assert!(!refused("/home/u/Pictures", &["/home/u/Documents"])); + assert!(!refused("/mnt/data/photos", &[])); + } + + #[test] + fn the_file_system_root_home_and_its_parents_are_refused() { + assert!(refused("/", &[])); + assert!(refused("/home/u", &[])); + assert!(refused("/home", &[])); + } + + #[test] + fn the_mount_and_the_apps_own_data_are_refused_either_way_round() { + assert!(refused("/home/u/ProtonDrive", &[])); + assert!(refused("/home/u/ProtonDrive/Documents", &[])); + assert!(refused("/home/u/.cache", &[])); + assert!(refused( + "/home/u/.local/state/proton-drive-linux/staging", + &[] + )); + assert!(refused("/home/u/.config", &[])); + } + + #[test] + fn a_root_inside_or_around_another_root_is_refused() { + assert!(refused("/home/u/Documents", &["/home/u/Documents"])); + assert!(refused("/home/u/Documents/Work", &["/home/u/Documents"])); + assert!(refused("/home/u/stuff", &["/home/u/stuff/Documents"])); + // A shared prefix of characters is not a shared folder. + assert!(!refused("/home/u/Documents2", &["/home/u/Documents"])); + } + + #[test] + fn a_path_that_goes_up_or_is_relative_does_not_resolve() { + assert!(resolve(Path::new("/home/u/../../etc")).is_err()); + assert!(resolve(Path::new("Documents")).is_err()); + } + + #[test] + fn a_folder_still_to_be_created_resolves_under_its_existing_parent() { + let base = std::env::temp_dir().join(format!("pdfs-syncroot-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let canonical = base.canonicalize().unwrap(); + assert_eq!( + resolve(&base.join("new/inner")).unwrap(), + canonical.join("new/inner") + ); + let link = base.join("link"); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(&canonical, &link).unwrap(); + assert_eq!( + resolve(&link.join("later")).unwrap(), + canonical.join("later"), + "a symlink is followed to where it points" + ); + std::fs::remove_dir_all(&base).unwrap(); + } +} diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 3f660c0..72560b7 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -105,6 +105,15 @@ pub(crate) fn adopted_device_uid() -> Option { (!uid.is_empty()).then_some(uid) } +/// The paths no sync root may overlap, with the mountpoint as the config says +/// now. +pub(crate) fn sync_root_reserved() -> pdfs_core::syncroot::Reserved { + let config = pdfs_core::config::AppDirs::new() + .map(|dirs| dirs.load_config()) + .unwrap_or_default(); + pdfs_core::syncroot::Reserved::current(&config) +} + /// Pin (or, with `None`, unpin) the adopted device uid in `config.json`. pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> { let dirs = pdfs_core::config::AppDirs::new() @@ -517,18 +526,20 @@ impl Core { .map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?; let local_str = local.to_string_lossy().to_string(); - // Reject duplicates up front for a clear error (UNIQUE would also catch it). - if self + // `/`, the home folder, the mount, this app's own data, and a folder + // inside or around another root are refused (`syncroot::problem`). A + // duplicate is one of those, refused here for a clear error before + // the UNIQUE constraint would catch it. + let others = self .db .sync_folder_list() .map_err(|e| CoreError::internal(format!("db: {e:?}")))? .iter() - .any(|f| f.local_path == local_str) - { - return Err(CoreError::invalid(format!( - "{} is already synced", - local.display() - ))); + .map(|f| PathBuf::from(&f.local_path)) + .collect::>(); + let reserved = sync_root_reserved(); + if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) { + return Err(CoreError::invalid(problem)); } let name = local diff --git a/crates/pdfs-fuse/src/profile.rs b/crates/pdfs-fuse/src/profile.rs index 348e80f..0d3ad47 100644 --- a/crates/pdfs-fuse/src/profile.rs +++ b/crates/pdfs-fuse/src/profile.rs @@ -304,6 +304,9 @@ impl Core { // offering it as a restorable folder would sync the bookkeeping // back onto the machine it describes. .filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME) + // The name becomes a folder under the home folder: one with a `/` + // or a `..` in it would put the restore somewhere else. + .filter(|n| crate::remote_name_is_safe(&n.name)) .map(|n| { let uid = n.uid.to_string(); let saved = profile @@ -349,23 +352,29 @@ impl Core { .sync_folder_list() .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + let reserved = crate::devices::sync_root_reserved(); + let mut roots: Vec = existing + .iter() + .map(|f| PathBuf::from(&f.local_path)) + .collect(); let mut restored = 0usize; let mut skipped: Vec = Vec::new(); for item in items { - let local = PathBuf::from(&item.local_path); - if !local.is_absolute() { - skipped.push(format!("{}: local path must be absolute", item.local_path)); - continue; - } + // The path is proposed from the profile another machine wrote, and + // may hold a folder name from Drive: checked like any new root. + let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) { + Ok(local) => local, + Err(e) => { + skipped.push(format!("{}: {e}", item.local_path)); + continue; + } + }; if existing.iter().any(|f| f.remote_uid == item.remote_uid) { skipped.push(format!("{}: already synced", item.local_path)); continue; } - if existing - .iter() - .any(|f| Path::new(&f.local_path) == local.as_path()) - { - skipped.push(format!("{}: path already in use", item.local_path)); + if let Some(problem) = pdfs_core::syncroot::problem(&local, &roots, &reserved) { + skipped.push(format!("{}: {problem}", item.local_path)); continue; } if let Err(e) = std::fs::create_dir_all(&local) { @@ -390,6 +399,7 @@ impl Core { let _ = self.request_sync_folder_mode(id, "ondemand"); } let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id)); + roots.push(local); restored += 1; } let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); diff --git a/docs/BUGS.md b/docs/BUGS.md index 9998ea2..e2c42cf 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B234](#b234--any-folder-can-become-a-sync-root-the-home-folder-included) | Any folder can become a sync root, the home folder included | | [B233](#b233--signing-out-leaves-the-session-valid-on-proton) | Signing out leaves the session valid on Proton | | [B232](#b232--names-with--or--show-as-empty-rows-or-are-drawn-as-markup) | Names with & or < show as empty rows or are drawn as markup | | [B231](#b231--a-large-upload-under-a-low-upload-limit-never-finishes) | A large upload under a low upload limit never finishes | @@ -336,6 +337,39 @@ a live account since. Newest first. +### B234 — Any folder can become a sync root, the home folder included + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.12). +**Where:** `crates/pdfs-core/src/syncroot.rs`; `Core::add_sync_folder` and `sync_root_reserved` in +`crates/pdfs-fuse/src/devices.rs`; `Core::restore_sync_folders` and the restore proposals in +`crates/pdfs-fuse/src/profile.rs` + +**Repro:** `pdfs sync add ~`, or `pdfs sync add /`. Or restore a profile whose recorded path is +`/home/u/../..`, or whose folder on Drive has a name with a `/`. + +**Cause.** A new sync root was only checked for being a directory and not already synced. A +restore checked only that the path was absolute. Nothing refused `/`, the home folder, a parent +of either, the Proton Drive mount, the app's own state, cache and config folders, or a root +inside or around another root. Adding the home folder uploads the decrypted content cache, +`cache.db` and `~/.ssh`, and walks into the mount, which downloads the Drive only to upload it +again. A restore proposal joined a folder name from Drive onto the home folder as it was. + +**Fix.** One check, `syncroot::problem`, runs wherever a root is added or restored. It refuses +the cases above. Paths are compared after `syncroot::resolve`, which follows symlinks as far as +the path exists and refuses `..`. Restore proposals leave out folders whose name could not be a +local file name. + +The audit also suggested not crossing into other file systems during the local walk. That was +left out on purpose: a btrfs subvolume inside a sync root has a device id of its own, and would +silently stop syncing. + +**Test:** `syncroot::tests` (six cases, among them +`the_file_system_root_home_and_its_parents_are_refused` and +`a_root_inside_or_around_another_root_is_refused`). + +--- + ### B233 — Signing out leaves the session valid on Proton **Status:** Fixed, not verified live From 60a5a981749f2367b1d072ce2fc7a0f2d1f8cb99 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:41:02 +0200 Subject: [PATCH 19/44] perf(core): find Takeout sidecars by prefix lookup, not by ranking all Each photo was ranked against every sidecar in its folder, so a folder of 10,000 photos took about 30 s to scan. The sidecars are now indexed by stem and each photo is ranked only against the stems a few range lookups return: 0.1 s for the same folder, with the same matches (B235). --- crates/pdfs-core/src/takeout.rs | 147 ++++++++++++++++++++++++++++++-- docs/BUGS.md | 24 ++++++ 2 files changed, 165 insertions(+), 6 deletions(-) diff --git a/crates/pdfs-core/src/takeout.rs b/crates/pdfs-core/src/takeout.rs index db9106f..669febf 100644 --- a/crates/pdfs-core/src/takeout.rs +++ b/crates/pdfs-core/src/takeout.rs @@ -346,6 +346,11 @@ fn assemble(entries: Vec, sidecars: &HashMap>) -> Take .insert(file.to_string(), body); } + let indexes: HashMap<&str, SidecarIndex<'_>> = per_folder_sidecars + .iter() + .map(|(folder, in_folder)| (folder.as_str(), SidecarIndex::new(in_folder))) + .collect(); + let mut scan = TakeoutScan::default(); let mut seen_albums: HashMap = HashMap::new(); @@ -364,9 +369,9 @@ fn assemble(entries: Vec, sidecars: &HashMap>) -> Take continue; }; - let found = per_folder_sidecars - .get(&folder) - .and_then(|in_folder| find_sidecar(&file_name, in_folder)); + let found = indexes + .get(folder.as_str()) + .and_then(|index| find_sidecar(&file_name, index)); // An edited copy keeps its own name: the title is its original's. let title_is_ours = found.is_some_and(|((rule, _), _)| rule != EDITED_COPY); let sidecar = found @@ -640,19 +645,98 @@ const SIDECAR_NAME_CAP: usize = 51; /// sidecar. Its date is the original's, but its name is its own. const EDITED_COPY: u8 = 4; +/// The sidecars of one folder by their `.json`-stripped stem, so a photo is +/// ranked only against those that can match it. +/// +/// Every rule in [`sidecar_rank`] needs the stem to start with a prefix of the +/// photo name that the name itself determines, and a sorted map finds the +/// stems with a given prefix as one range. Ranking every photo against every +/// sidecar instead took 27 s for a folder of 10,000 photos. +struct SidecarIndex<'a> { + by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec)>>, +} + +impl<'a> SidecarIndex<'a> { + fn new(in_folder: &'a BTreeMap>) -> Self { + let mut by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec)>> = BTreeMap::new(); + for (name, body) in in_folder { + by_stem + .entry(strip_json_suffix(name)) + .or_default() + .push((name.as_str(), *body)); + } + Self { by_stem } + } + + /// Add every sidecar whose stem starts with `prefix` to `out`. + fn starting_with(&self, prefix: &str, out: &mut BTreeMap<&'a str, &'a Vec>) { + let from = ( + std::ops::Bound::Included(prefix), + std::ops::Bound::Unbounded, + ); + for (stem, sidecars) in self.by_stem.range::(from) { + if !stem.starts_with(prefix) { + break; + } + out.extend(sidecars.iter().copied()); + } + } + + /// Add every sidecar whose stem is `head`, or starts with `head` and a `.`: + /// those that have `head` as a boundary ([`suffix_boundaries`]). + fn bounded_by(&self, head: &str, out: &mut BTreeMap<&'a str, &'a Vec>) { + if let Some(sidecars) = self.by_stem.get(head) { + out.extend(sidecars.iter().copied()); + } + self.starting_with(&format!("{head}."), out); + } + + /// Every sidecar [`sidecar_rank`] could match `file_name` to, by name. A + /// superset: each one is still ranked. + fn candidates(&self, file_name: &str) -> BTreeMap<&'a str, &'a Vec> { + let mut out = BTreeMap::new(); + // Rules 0 and 2: the stem starts with the name, or with its swapped form. + self.starting_with(file_name, &mut out); + if let Some(swapped) = swap_duplicate_marker(file_name) { + self.starting_with(&swapped, &mut out); + } + // Rule 1: the stem of `A(N).B` starts with `A.B(N)`, so with `A.`. + for (paren, _) in file_name.match_indices('(') { + self.starting_with(&format!("{}.", &file_name[..paren]), &mut out); + } + // Rule 3: a boundary of the stem is a prefix of the name. + for end in file_name + .char_indices() + .map(|(index, _)| index) + .skip(1) + .chain([file_name.len()]) + { + self.bounded_by(&file_name[..end], &mut out); + } + // Rule 4: `-.` has `.` as a boundary. + if let Some((_, extension)) = file_name.rsplit_once('.') { + for (dash, _) in file_name.match_indices('-') { + self.bounded_by(&format!("{}.{extension}", &file_name[..dash]), &mut out); + } + } + out + } +} + /// Find the sidecar belonging to `file_name` among the JSON files of its folder, /// with the rank it matched at. /// /// Tried in order of confidence: the exact name, the `(N)`-swapped name, an /// unknown trailing suffix, a prefix match for the case where truncation cut /// into the photo name itself, and last an edited copy's original. The -/// best-ranked candidate in the folder wins. +/// best-ranked candidate in the folder wins; of equal ranks, the first by +/// sidecar name. fn find_sidecar<'a>( file_name: &str, - in_folder: &BTreeMap>, + index: &SidecarIndex<'a>, ) -> Option<((u8, usize), &'a Vec)> { let mut best: Option<((u8, usize), &'a Vec)> = None; - for (sidecar_name, body) in in_folder { + for (sidecar_name, body) in index.candidates(file_name) { let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else { continue; }; @@ -1108,6 +1192,57 @@ mod tests { assert_eq!(scan.photos[1].capture_time, Some(7)); } + /// The index looks only at the sidecars that can match, and must find the + /// same one that ranking every sidecar in the folder finds. + #[test] + fn the_sidecar_index_finds_what_ranking_every_sidecar_finds() { + let long = "A_very_long_holiday_photo_name_from_the_beach_2019"; + let names = [ + "IMG_1.jpg".to_string(), + "IMG_12.jpg".to_string(), + "IMG_1(1).jpg".to_string(), + "IMG_1-edited.jpg".to_string(), + "IMG_1-bearbeitet.jpg".to_string(), + "my.photo(2).jpg".to_string(), + "x(1).jpg".to_string(), + "Ölbild.png".to_string(), + "PXL_20230101_101010.mp4".to_string(), + format!("{long}.jpg"), + format!("{long}_2.jpg"), + "nothing.heic".to_string(), + ]; + let sidecar_names = [ + "IMG_1.jpg.supplemental-metadata.json".to_string(), + "IMG_1.jpg(1).supplemental-metadata.json".to_string(), + "IMG_12.jpg.ergänzende-Metadaten.json".to_string(), + "my.photo.jpg(2).json".to_string(), + "x.jpg(1).json".to_string(), + "Ölbild.png.suppl.json".to_string(), + "PXL_20230101_101010.mp4.JSON".to_string(), + format!("{}.json", &long[..44]), + format!("{long}.jp.json"), + "IMG.json".to_string(), + "unrelated.json".to_string(), + ]; + let bodies: Vec> = (0..sidecar_names.len()) + .map(|i| i.to_string().into_bytes()) + .collect(); + let in_folder: BTreeMap> = + sidecar_names.iter().cloned().zip(bodies.iter()).collect(); + let index = SidecarIndex::new(&in_folder); + for name in &names { + let mut every: Option<((u8, usize), &Vec)> = None; + for (sidecar_name, body) in &in_folder { + if let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), name) + && every.as_ref().is_none_or(|(current, _)| rank < *current) + { + every = Some((rank, *body)); + } + } + assert_eq!(find_sidecar(name, &index), every, "{name}"); + } + } + #[test] fn sidecar_title_replaces_a_mangled_on_disk_name() { let mut sidecars = HashMap::new(); diff --git a/docs/BUGS.md b/docs/BUGS.md index e2c42cf..1286357 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B235](#b235--scanning-a-large-takeout-folder-takes-half-a-minute) | Scanning a large Takeout folder takes half a minute | | [B234](#b234--any-folder-can-become-a-sync-root-the-home-folder-included) | Any folder can become a sync root, the home folder included | | [B233](#b233--signing-out-leaves-the-session-valid-on-proton) | Signing out leaves the session valid on Proton | | [B232](#b232--names-with--or--show-as-empty-rows-or-are-drawn-as-markup) | Names with & or < show as empty rows or are drawn as markup | @@ -337,6 +338,29 @@ a live account since. Newest first. +### B235 — Scanning a large Takeout folder takes half a minute + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.1). +**Where:** `SidecarIndex` and `find_sidecar` in `crates/pdfs-core/src/takeout.rs` + +**Repro:** Scan a Google Takeout export with 10,000 photos in one folder, as "Photos from 2019" +folders often are. + +**Cause.** Each photo was ranked against every sidecar of its folder, so the scan grew with the +square of the folder size: 2,000 photos took 0.8 s, 10,000 took 27–32 s before the import +showed anything. + +**Fix.** The sidecars of a folder are kept in a sorted map by stem. Every matching rule needs the +stem to start with a prefix that the photo name determines, so each photo is ranked only against +the stems found by a few range lookups. On the same 10,000 photos the scan takes 0.1 s, with the +same matches: ties still go to the first sidecar by name. + +**Test:** `takeout::tests::the_sidecar_index_finds_what_ranking_every_sidecar_finds`, which +compares the index with ranking every sidecar, across every rule. + +--- + ### B234 — Any folder can become a sync root, the home folder included **Status:** Fixed, not verified live From 8aca7836f96629524b9cd68f83542a8b8335ce5b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:46:17 +0200 Subject: [PATCH 20/44] fix(fuse): upload thumbnails with imported, re-dated and app photos Takeout imports, re-dates and the app's photo upload all sent an empty thumbnail list, so the photos showed blank in Proton's apps. Re-dates and app uploads now make thumbnails from their file. A Takeout photo that can have one is copied out of the archive to the private staging folder, thumbnailed and uploaded from the copy (B236). --- crates/pdfs-fuse/src/control.rs | 3 +- crates/pdfs-fuse/src/photos.rs | 28 ++++++++-- crates/pdfs-fuse/src/redate.rs | 3 +- crates/pdfs-fuse/src/takeout.rs | 90 ++++++++++++++++++++++++++++----- docs/BUGS.md | 27 ++++++++++ 5 files changed, 133 insertions(+), 18 deletions(-) diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index e08b5a0..5fe3bb9 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -915,6 +915,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U capture_time, ..Default::default() }; + let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name); let guard = core.transfers .begin(name.clone(), "", TransferDirection::Upload, len); @@ -924,7 +925,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U &media_type, reader, len as i64, - Vec::new(), + thumbnails, metadata, false, )) { diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index e3a2d27..55210ee 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -434,6 +434,16 @@ fn exiftool_query( serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable) } +/// Whether [`upload_thumbnails`] can make a thumbnail for a file `name` of +/// `size` bytes, asked before the file is on disk: an image small enough to +/// decode, or a video while ffmpeg is installed. +pub(crate) fn can_make_thumbnail(name: &str, size: u64) -> bool { + if is_video_name(name) { + return ffmpeg_available(); + } + is_thumbnail_image_name(name) && size <= UPLOAD_IMAGE_MAX_BYTES +} + /// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather /// than a picture the image decoder can scale. fn is_video_name(name: &str) -> bool { @@ -2109,9 +2119,10 @@ impl Core { #[cfg(test)] mod thumb_tests { use super::{ - RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary, - fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, - similar_hashes, thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail, + RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES, + can_make_thumbnail, exiftool_binary, ffmpeg_available, fit_upload_thumb, is_video_name, + ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, similar_hashes, + thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail, }; use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name}; use proton_drive_rs::ThumbnailType; @@ -2191,6 +2202,17 @@ mod thumb_tests { /// A phone stores a portrait photo as landscape pixels and an EXIF /// orientation; its thumbnail came out on its side (`docs/BUGS.md` B226). + /// A Takeout photo is copied out of its archive only when a thumbnail can + /// be made from the copy. + #[test] + fn only_a_photo_that_can_have_a_thumbnail_is_worth_copying_out() { + assert!(can_make_thumbnail("IMG_1.jpg", 4 << 20)); + assert!(can_make_thumbnail("Screenshot.png", 4 << 20)); + assert!(!can_make_thumbnail("IMG_1.jpg", UPLOAD_IMAGE_MAX_BYTES + 1)); + assert!(!can_make_thumbnail("notes.txt", 10)); + assert_eq!(can_make_thumbnail("clip.mp4", 1 << 30), ffmpeg_available()); + } + #[test] fn a_thumbnail_is_turned_the_way_its_exif_says() { let ScaleAttempt::Made(thumb) = scale_thumbnail( diff --git a/crates/pdfs-fuse/src/redate.rs b/crates/pdfs-fuse/src/redate.rs index 7f86890..b5bf463 100644 --- a/crates/pdfs-fuse/src/redate.rs +++ b/crates/pdfs-fuse/src/redate.rs @@ -190,6 +190,7 @@ impl Core { .map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))? .len(); let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream"); + let thumbnails = self.upload_thumbnails(&path, &candidate.name); let guard = self.transfers @@ -206,7 +207,7 @@ impl Core { media_type, reader, size as i64, - Vec::new(), + thumbnails, metadata, false, )); diff --git a/crates/pdfs-fuse/src/takeout.rs b/crates/pdfs-fuse/src/takeout.rs index 43e8a5b..650e938 100644 --- a/crates/pdfs-fuse/src/takeout.rs +++ b/crates/pdfs-fuse/src/takeout.rs @@ -25,7 +25,7 @@ use std::collections::{BTreeMap, HashMap}; use std::io::Read; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection}; use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives}; @@ -36,6 +36,7 @@ use sha1::{Digest, Sha1}; use tracing::{info, warn}; use super::Core; +use crate::photos::can_make_thumbnail; use crate::transfers::CountingReader; /// The one import this daemon may be running, and how the last one ended. @@ -136,6 +137,30 @@ fn read_entry( } impl Core { + /// Copy archive entry `entry` into the thumbnail staging folder and return + /// its path. The caller removes it. + fn stage_takeout_entry( + &self, + archive: &mut zip::ZipArchive>, + entry: &str, + ) -> CoreResult { + static STAGED: AtomicU64 = AtomicU64::new(0); + let path = self.cache.raw_thumbnail_staging_dir().join(format!( + "takeout-{}-{}", + std::process::id(), + STAGED.fetch_add(1, Ordering::Relaxed) + )); + let copied = read_entry(archive, entry, |reader| { + let mut file = std::fs::File::create(&path)?; + std::io::copy(reader, &mut file) + })?; + if let Err(e) = copied { + let _ = std::fs::remove_file(&path); + return Err(CoreError::internal(format!("copy {entry}: {e}"))); + } + Ok(path) + } + /// Import Google Photos Takeout archives. Blocking; run on its own thread. /// /// `cancel` stops the run at the next photo boundary — an upload already on @@ -321,18 +346,57 @@ impl Core { capture_time: photo.capture_time, ..Default::default() }; - let uploaded = read_entry(&mut archive, &photo.entry, |reader| { - let counting = CountingReader::new(reader, &guard); - self.rt.block_on(photos.upload_photo_from( - &photo.name, - &photo.media_type, - counting, - photo.size as i64, - Vec::new(), - metadata, - false, - )) - }); + // A thumbnail is made from a file, so a photo that can have one + // is copied out of the archive first and uploaded from the copy. + // Without one, Proton's apps show a blank tile until they make + // their own. + let staged = can_make_thumbnail(&photo.name, photo.size) + .then(|| self.stage_takeout_entry(&mut archive, &photo.entry)) + .and_then(|staged| { + staged + .inspect_err(|e| { + warn!(name = photo.name, error = %e, + "copying a takeout photo out failed; uploading it without a thumbnail") + }) + .ok() + }); + let uploaded = match &staged { + Some(path) => { + let thumbnails = self.upload_thumbnails(path, &photo.name); + std::fs::File::open(path) + .map_err(|e| { + CoreError::internal(format!("open {}: {e}", path.display())) + }) + .map(|file| { + let counting = + CountingReader::new(std::io::BufReader::new(file), &guard); + self.rt.block_on(photos.upload_photo_from( + &photo.name, + &photo.media_type, + counting, + photo.size as i64, + thumbnails, + metadata, + false, + )) + }) + } + None => read_entry(&mut archive, &photo.entry, |reader| { + let counting = CountingReader::new(reader, &guard); + self.rt.block_on(photos.upload_photo_from( + &photo.name, + &photo.media_type, + counting, + photo.size as i64, + Vec::new(), + metadata, + false, + )) + }), + }; + if let Some(path) = staged { + let _ = std::fs::remove_file(path); + } drop(guard); job.step(); diff --git a/docs/BUGS.md b/docs/BUGS.md index 1286357..93b971d 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B236](#b236--imported-re-dated-and-app-uploaded-photos-have-no-thumbnail) | Imported, re-dated and app-uploaded photos have no thumbnail | | [B235](#b235--scanning-a-large-takeout-folder-takes-half-a-minute) | Scanning a large Takeout folder takes half a minute | | [B234](#b234--any-folder-can-become-a-sync-root-the-home-folder-included) | Any folder can become a sync root, the home folder included | | [B233](#b233--signing-out-leaves-the-session-valid-on-proton) | Signing out leaves the session valid on Proton | @@ -338,6 +339,32 @@ a live account since. Newest first. +### B236 — Imported, re-dated and app-uploaded photos have no thumbnail + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.2). +**Where:** `Core::import_takeout` and `stage_takeout_entry` in `crates/pdfs-fuse/src/takeout.rs`; +`Core::redate_one` in `redate.rs`; the `UploadPhoto` request in `control.rs`; +`can_make_thumbnail` in `photos.rs` + +**Repro:** Import a Google Takeout export, re-date a photo, or upload a photo from the app's +gallery. Look at the new photos in Proton's web or phone app. + +**Cause.** All three paths uploaded with an empty thumbnail list. The drain and the mirror sync +make thumbnails for what they upload, but these three did not, so the photos showed as blank +tiles in Proton's apps. The 3.0 changelog says imports carry thumbnails. + +**Fix.** The re-date and the app's upload make thumbnails from the file they upload. A Takeout +photo that can have a thumbnail (an image small enough to decode, or a video while ffmpeg is +installed) is copied out of the archive into the private staging folder first. The thumbnail is +made from the copy, the upload reads the copy, and the copy is removed. When the copy fails, the +photo is uploaded from the archive without a thumbnail, as before. + +**Test:** `photos::thumb_tests::only_a_photo_that_can_have_a_thumbnail_is_worth_copying_out`; the +thumbnail itself is covered by the existing `thumb_tests`. + +--- + ### B235 — Scanning a large Takeout folder takes half a minute **Status:** Fixed, not verified live From fcbb15cb4b0367ca667cdd537a5874f4e1ae34de Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:48:15 +0200 Subject: [PATCH 21/44] fix(core,fuse): back failed uploads off properly and spread their retries A reconnect made every failed op due at once, quota and access refusals included, and ops that failed together retried in the same second. The doubling also stopped at 128 s, short of the 300 s maximum. Reconnects now move up only unrefused ops, and the backoff reaches its maximum with a per-op spread of up to a fifth (B237). --- crates/pdfs-core/src/db/ops.rs | 20 ++++++++++++--- crates/pdfs-core/src/db/tests.rs | 26 +++++++++++++++++++ crates/pdfs-fuse/src/drain.rs | 43 ++++++++++++++++++++++++++------ crates/pdfs-fuse/src/link.rs | 5 ++-- docs/BUGS.md | 28 +++++++++++++++++++++ 5 files changed, 110 insertions(+), 12 deletions(-) diff --git a/crates/pdfs-core/src/db/ops.rs b/crates/pdfs-core/src/db/ops.rs index bbddfd1..a04be71 100644 --- a/crates/pdfs-core/src/db/ops.rs +++ b/crates/pdfs-core/src/db/ops.rs @@ -1483,12 +1483,26 @@ impl Db { } /// [`retry_op_now`](Self::retry_op_now) for every op that has failed at - /// least once. Returns how many rows changed. + /// least once, for the user's "Retry all". Returns how many rows changed. pub fn retry_failed_ops_now(&self, now: i64) -> Result { + self.retry_ops_now(now, "") + } + + /// [`retry_failed_ops_now`](Self::retry_failed_ops_now) for the ops Drive + /// did not refuse, for a reconnect. A link coming back changes nothing + /// about a full quota or a missing permission, so those keep their backoff + /// instead of all being sent again at once. + pub fn retry_unrefused_ops_now(&self, now: i64) -> Result { + self.retry_ops_now(now, "AND issue IS NULL") + } + + fn retry_ops_now(&self, now: i64, filter: &str) -> Result { let conn = self.conn.lock(); let changed = conn.execute( - "UPDATE pending_op SET next_attempt_at = ?1 - WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2", + &format!( + "UPDATE pending_op SET next_attempt_at = ?1 + WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2 {filter}" + ), params![now, PARK_UNTIL], )?; Ok(changed) diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 8843589..21a7f59 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -7531,6 +7531,32 @@ fn a_refused_change_names_its_node_by_local_id() { ); } +/// A reconnect moves up the ops that failed for want of a link. One Drive +/// refused, for quota or access, keeps its backoff: the link coming back +/// changes nothing about it. "Retry all" still moves both. +#[test] +fn a_reconnect_leaves_refused_ops_to_their_backoff() { + let db = Db::open_in_memory().unwrap(); + let offline = queued_op(&db, "vol~a", 2, 90_000); + let refused = queued_op(&db, "vol~b", 2, 90_000); + db.set_op_issue(refused, Some("quota")).unwrap(); + + assert_eq!(db.retry_unrefused_ops_now(5_000).unwrap(), 1); + let due = |id: i64| { + db.pending_ops() + .unwrap() + .into_iter() + .find(|op| op.id == id) + .unwrap() + .next_attempt_at + }; + assert_eq!(due(offline), 5_000); + assert_eq!(due(refused), 90_000); + + assert_eq!(db.retry_failed_ops_now(5_000).unwrap(), 1); + assert_eq!(due(refused), 5_000); +} + #[test] fn retry_all_touches_only_ops_that_have_failed() { let db = Db::open_in_memory().unwrap(); diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 3668217..e530787 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -389,13 +389,7 @@ impl Core { // A name a change of ours is about to free is a wait, not a // failure: the trash or rename holding it lands in moments. let held = waits_for_name(e.as_ref()); - let backoff = if held { - DRAIN_BACKOFF_MIN - } else { - DRAIN_BACKOFF_MIN - .saturating_mul(1u32 << attempts.min(6)) - .min(DRAIN_BACKOFF_MAX) - }; + let backoff = drain_backoff(attempts, held, op.id); warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry"); if let Err(e) = self.db.record_attempt_failure( op.id, @@ -2787,6 +2781,25 @@ fn waits_for_name(e: &(dyn std::error::Error + 'static)) -> bool { std::iter::successors(Some(e), |e| e.source()).any(|e| e.is::()) } +/// How long an op waits after its `attempts`-th failure: doubling from +/// [`DRAIN_BACKOFF_MIN`] up to [`DRAIN_BACKOFF_MAX`], plus up to a fifth more, +/// different for each op (`seed`), so that ops that failed together are not +/// all retried in the same second. One that waits for a name (`held`) waits +/// the minimum. +/// +/// The doubling stopped at 2^6, 128 s, so the maximum was never reached. +fn drain_backoff(attempts: i64, held: bool, seed: i64) -> Duration { + if held { + return DRAIN_BACKOFF_MIN; + } + let base = DRAIN_BACKOFF_MIN + .saturating_mul(1u32 << attempts.clamp(0, 8)) + .min(DRAIN_BACKOFF_MAX); + // A cheap, stable spread: the same op and attempt always wait the same. + let spread = (seed.wrapping_mul(0x9E37_79B9).wrapping_add(attempts) as u64 % 21) as u32; + base + base * spread / 100 +} + /// How many attempts an op waits for a rename or a create on the wire to free /// the name it wants. Two files swapping names each wait for the other, so the /// wait has to end; a conflict name ends it. @@ -3386,6 +3399,22 @@ mod tests { } } + #[test] + fn a_failing_op_backs_off_to_the_maximum_with_a_spread() { + let at = |attempts| drain_backoff(attempts, false, 7); + assert!(at(1) >= DRAIN_BACKOFF_MIN * 2); + assert!(at(2) > at(1)); + // Reached, which the doubling capped at 2^6 never did. + assert!(at(8) >= DRAIN_BACKOFF_MAX); + assert!(at(30) <= DRAIN_BACKOFF_MAX + DRAIN_BACKOFF_MAX / 5); + // Ops that failed together do not all come back together. + let waits: std::collections::HashSet<_> = + (1..=20).map(|id| drain_backoff(8, false, id)).collect(); + assert!(waits.len() > 5, "{waits:?}"); + // A wait for a name stays short however often it repeats. + assert_eq!(drain_backoff(30, true, 7), DRAIN_BACKOFF_MIN); + } + #[test] fn only_a_fresh_empty_twin_of_the_same_kind_is_adopted() { // The op was queued at t=1000 s, after a create that got no answer. diff --git a/crates/pdfs-fuse/src/link.rs b/crates/pdfs-fuse/src/link.rs index b23a352..3430235 100644 --- a/crates/pdfs-fuse/src/link.rs +++ b/crates/pdfs-fuse/src/link.rs @@ -280,8 +280,9 @@ impl Core { self.relist_offline_listings(); // Anything written while offline is queued and waiting on exactly this, // including ops that failed on the way down and are sitting out a - // backoff sized for a server that keeps refusing. - if let Err(e) = self.db.retry_failed_ops_now(crate::now_millis()) { + // backoff sized for a server that keeps refusing. Ops Drive refused + // keep theirs: the link was not why. + if let Err(e) = self.db.retry_unrefused_ops_now(crate::now_millis()) { warn!(error = %e, "moving backed-off ops up after a reconnect failed"); } self.wake_drain(); diff --git a/docs/BUGS.md b/docs/BUGS.md index 93b971d..282a941 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B237](#b237--every-reconnect-resends-refused-uploads-all-in-the-same-second) | Every reconnect resends refused uploads, all in the same second | | [B236](#b236--imported-re-dated-and-app-uploaded-photos-have-no-thumbnail) | Imported, re-dated and app-uploaded photos have no thumbnail | | [B235](#b235--scanning-a-large-takeout-folder-takes-half-a-minute) | Scanning a large Takeout folder takes half a minute | | [B234](#b234--any-folder-can-become-a-sync-root-the-home-folder-included) | Any folder can become a sync root, the home folder included | @@ -339,6 +340,33 @@ a live account since. Newest first. +### B237 — Every reconnect resends refused uploads, all in the same second + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.3.3). +**Where:** `drain_backoff` in `crates/pdfs-fuse/src/drain.rs`; the reconnect in +`crates/pdfs-fuse/src/link.rs`; `Db::retry_unrefused_ops_now` in +`crates/pdfs-core/src/db/ops.rs` + +**Repro:** Fill the Drive quota and queue a few hundred files, then let the link drop and come +back a few times. + +**Cause.** Three things together: +- Every reconnect made every failed op due at once, the ones Drive refused for quota or access + included. Each reconnect then sent the whole refused queue again. +- All ops that failed together were retried together: the backoff had no spread. +- The doubling stopped at 2^6, so the wait never grew past 128 s, and the 300 s maximum was never + reached. + +**Fix.** A reconnect makes due only the ops without a recorded refusal. "Retry all" still moves +every op. The backoff doubles up to the 300 s maximum, plus up to a fifth more, which differs +from op to op. + +**Test:** `db::tests::a_reconnect_leaves_refused_ops_to_their_backoff`; +`drain::tests::a_failing_op_backs_off_to_the_maximum_with_a_spread`. + +--- + ### B236 — Imported, re-dated and app-uploaded photos have no thumbnail **Status:** Fixed, not verified live From 937a0a6824fda26f904832f223e1f1a1e953dfdb Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:50:56 +0200 Subject: [PATCH 22/44] perf(core): index queued ops by kind and name (schema 43) The name checks the drain makes before each create, rename and trash, and the wake of ops waiting for a name, read the whole queue, which made a large offline batch quadratic to drain. Schema 43 adds an index on pending_op(kind, name) (B238). --- crates/pdfs-core/src/db/migrations.rs | 23 ++++++++++++++++++++++- crates/pdfs-core/src/db/tests.rs | 24 ++++++++++++++++++++++++ docs/BUGS.md | 25 +++++++++++++++++++++++++ docs/CHANGELOG.md | 3 ++- 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index 5a4d5b2..1689224 100644 --- a/crates/pdfs-core/src/db/migrations.rs +++ b/crates/pdfs-core/src/db/migrations.rs @@ -11,7 +11,7 @@ use super::Db; use crate::Result; /// Current schema version. Bump on every forward migration added below. -pub(super) const SCHEMA_VERSION: i64 = 42; +pub(super) const SCHEMA_VERSION: i64 = 43; impl Db { pub(super) fn migrate(&self) -> Result<()> { @@ -440,6 +440,16 @@ impl Db { tx.execute_batch(MIGRATION_V42)?; } } + if current < 43 { + let has_ops: bool = tx.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + if has_ops { + tx.execute_batch(MIGRATION_V43)?; + } + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -1330,3 +1340,14 @@ const MIGRATION_V42: &str = " ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT; ALTER TABLE pending_op ADD COLUMN sent_to TEXT; "; + +/// Schema v43: queued ops by kind and name. +/// +/// Before each create, rename and trash the drain asks whether a queued trash +/// or rename holds the name it wants, and a released name wakes the ops +/// waiting for it. Each of those scanned the whole queue, so a large offline +/// batch made draining it quadratic. The kind leads, so a lookup of every +/// queued rename uses the index too. +const MIGRATION_V43: &str = " +CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name); +"; diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 21a7f59..63d4c1c 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -7531,6 +7531,30 @@ fn a_refused_change_names_its_node_by_local_id() { ); } +/// The name checks the drain makes before each op look the name up in an +/// index. Scanning the queue instead made a large offline batch quadratic. +#[test] +fn queue_name_checks_use_an_index() { + let db = Db::open_in_memory().unwrap(); + let conn = db.conn.lock(); + let plan = |sql: &str| -> String { + let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap(); + stmt.query_map([], |r| r.get::<_, String>(3)) + .unwrap() + .map(|row| row.unwrap()) + .collect::>() + .join("; ") + }; + for sql in [ + "SELECT 1 FROM pending_op WHERE kind = 'trash' AND name = 'a'", + "SELECT 1 FROM pending_op WHERE name = 'a' AND kind IN ('create', 'mkdir', 'rename')", + "SELECT 1 FROM pending_op WHERE kind = 'rename'", + ] { + let plan = plan(sql); + assert!(plan.contains("pending_op_kind_name"), "{sql}: {plan}"); + } +} + /// A reconnect moves up the ops that failed for want of a link. One Drive /// refused, for quota or access, keeps its backoff: the link coming back /// changes nothing about it. "Retry all" still moves both. diff --git a/docs/BUGS.md b/docs/BUGS.md index 282a941..7037d55 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B238](#b238--draining-a-large-offline-batch-is-quadratic-in-its-size) | Draining a large offline batch is quadratic in its size | | [B237](#b237--every-reconnect-resends-refused-uploads-all-in-the-same-second) | Every reconnect resends refused uploads, all in the same second | | [B236](#b236--imported-re-dated-and-app-uploaded-photos-have-no-thumbnail) | Imported, re-dated and app-uploaded photos have no thumbnail | | [B235](#b235--scanning-a-large-takeout-folder-takes-half-a-minute) | Scanning a large Takeout folder takes half a minute | @@ -340,6 +341,30 @@ a live account since. Newest first. +### B238 — Draining a large offline batch is quadratic in its size + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.2.5). +**Where:** `MIGRATION_V43` in `crates/pdfs-core/src/db/migrations.rs`; the queries in +`has_pending_trash_named`, `has_withdrawn_create`, `has_pending_move_from` and +`wake_ops_waiting_for_tx` in `crates/pdfs-core/src/db/ops.rs` + +**Repro:** Offline, copy a folder of 20,000 small files into the mount, then go online and watch +the queue drain. + +**Cause.** Before each create, rename and trash, the drain asks whether a queued trash or rename +holds the name it wants, and a name that is let go wakes the ops waiting for it. Nothing indexed +the queue by name, so each of those questions read the whole queue. Draining a large offline +batch therefore took time quadratic in its size, under the database's write lock for the wake. + +**Fix.** Schema 43 adds an index on `pending_op(kind, name)`. The kind leads, so the lookup of +every queued rename uses it too. The `json_extract` on a rename's original name still reads the +renames, which are few. + +**Test:** `db::tests::queue_name_checks_use_an_index`, which checks the query plans. + +--- + ### B237 — Every reconnect resends refused uploads, all in the same second **Status:** Fixed, not verified live diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 91baeb9..f56578d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,7 +11,8 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] -Schema: **42**, unchanged. +Schema: **43**. Queued changes get an index by kind and name. Nothing is rewritten, and the +upgrade takes a moment. ### Fixed - **A folder deleted on another device took files with it that had never reached Drive.** A new From 4836c5a84affd205c272786b1548a547e70066ac Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:56:14 +0200 Subject: [PATCH 23/44] fix(cli,fuse,gui): make --wait fail when an import or re-date did `import-google-photos --wait` and `redate-photos --wait` exited 0 however the run ended, and a run that failed as a whole left no summary, so the CLI said no import had run and the app said it finished. Failed runs now leave a summary with the reason, `--wait` exits 1 on failure, cancel or failed photos, and the app shows the reason (B239). --- crates/pdfs-cli/src/main.rs | 44 +- crates/pdfs-core/src/control.rs | 8 + crates/pdfs-fuse/src/redate.rs | 9 +- crates/pdfs-fuse/src/takeout.rs | 10 +- crates/pdfs-gui/src/app/pages/takeout.rs | 20 +- docs/BUGS.md | 27 + po/de.po | 1360 ++++++++++----------- po/en_GB.po | 1360 ++++++++++----------- po/es.po | 1360 ++++++++++----------- po/fr.po | 1360 ++++++++++----------- po/it.po | 1360 ++++++++++----------- po/nl.po | 1360 ++++++++++----------- po/pdfs.pot | 1363 +++++++++++----------- po/pl.po | 1360 ++++++++++----------- po/pt_BR.po | 1360 ++++++++++----------- 15 files changed, 6259 insertions(+), 6102 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 890d8f3..6986e54 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2598,7 +2598,10 @@ fn cmd_import_google_photos(archives: Vec, dry_run: bool, wait: bool) - } => continue, CtlResponse::ImportStatus { summary, .. } => { print_import_summary(summary.as_ref()); - return Ok(()); + return finished_run( + "the import", + summary.map(|s| (s.failed, s.cancelled, s.error)), + ); } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), @@ -2641,7 +2644,10 @@ fn cmd_redate_photos( CtlResponse::RedateStatus { running: true, .. } => continue, CtlResponse::RedateStatus { summary, .. } => { print_redate_summary(summary.as_ref()); - return Ok(()); + return finished_run( + "the re-date", + summary.map(|s| (s.failed, s.cancelled, s.error)), + ); } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), @@ -2649,6 +2655,22 @@ fn cmd_redate_photos( } } +/// How a run `--wait` followed ended, as the exit status: an error when it +/// failed, was cancelled, or left photos failed, so a script can tell. +/// `outcome` is `(failed, cancelled, error)`; `None` when the daemon has no +/// result, which happens when it restarted during the run. +fn finished_run(what: &str, outcome: Option<(usize, bool, Option)>) -> Result<()> { + match outcome { + None => bail!("{what} ended without a result; the service may have restarted"), + Some((_, _, Some(error))) => bail!("{what} failed: {error}"), + Some((_, true, None)) => bail!("{what} was cancelled"), + Some((failed, false, None)) if failed > 0 => { + bail!("{what} finished, but {failed} photo(s) failed") + } + Some(_) => Ok(()), + } +} + /// A `YYYY-MM-DD` day as epoch seconds at UTC midnight. Reuses the Takeout /// scanner's date reader, which already accepts exactly this spelling. fn parse_day(day: &str) -> Result { @@ -2684,6 +2706,10 @@ fn print_redate_summary(summary: Option<&pdfs_core::control::RedateSummary>) { println!("No re-date has run yet."); return; }; + if let Some(error) = &summary.error { + println!("Failed : {error}"); + return; + } println!("Photos examined : {}", summary.examined); println!("Mis-dated : {}", summary.candidates); if summary.dry_run { @@ -2743,6 +2769,10 @@ fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) { println!("No import has run yet."); return; }; + if let Some(error) = &summary.error { + println!("Failed : {error}"); + return; + } println!("Photos in export : {}", summary.found); println!("Uploaded : {}", summary.uploaded); println!("Already on Proton: {}", summary.duplicates); @@ -3757,6 +3787,16 @@ mod login_wait_tests { assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15)); } + #[test] + fn a_waited_for_run_that_did_not_fully_succeed_exits_with_an_error() { + assert!(finished_run("the import", Some((0, false, None))).is_ok()); + assert!(finished_run("the import", Some((3, false, None))).is_err()); + assert!(finished_run("the import", Some((0, true, None))).is_err()); + let failed = finished_run("the import", Some((0, false, Some("not a zip".into())))); + assert!(failed.unwrap_err().to_string().contains("not a zip")); + assert!(finished_run("the import", None).is_err()); + } + #[test] fn waiting_for_a_session_stops_hammering_the_keyring() { let mut checks_left = 20; diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 1a01fa9..202240d 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -1591,6 +1591,10 @@ pub struct RedateSummary { /// name implies)` — what a dry run is read for. #[serde(default)] pub samples: Vec<(String, i64, i64)>, + /// Why the run as a whole failed, when it did. The counts are then + /// empty: the run never got to the photos. + #[serde(default)] + pub error: Option, } /// How a Google Photos Takeout import is going, or how the last one ended @@ -1620,6 +1624,10 @@ pub struct ImportSummary { pub bytes: u64, /// True when the run stopped early because it was cancelled. pub cancelled: bool, + /// Why the run as a whole failed, when it did, such as an archive that + /// is not a zip. The counts are then empty. + #[serde(default)] + pub error: Option, } /// One album in a [`Response::Albums`] listing. diff --git a/crates/pdfs-fuse/src/redate.rs b/crates/pdfs-fuse/src/redate.rs index b5bf463..aeda224 100644 --- a/crates/pdfs-fuse/src/redate.rs +++ b/crates/pdfs-fuse/src/redate.rs @@ -292,7 +292,14 @@ impl Core { let mut state = REDATE.lock(); state.running = false; state.cancel = None; - state.last = outcome.as_ref().ok().cloned(); + state.last = Some(match &outcome { + Ok(summary) => summary.clone(), + Err(e) => RedateSummary { + error: Some(e.to_string()), + dry_run, + ..Default::default() + }, + }); match &outcome { Ok(summary) => info!( candidates = summary.candidates, diff --git a/crates/pdfs-fuse/src/takeout.rs b/crates/pdfs-fuse/src/takeout.rs index 650e938..a3031d6 100644 --- a/crates/pdfs-fuse/src/takeout.rs +++ b/crates/pdfs-fuse/src/takeout.rs @@ -544,7 +544,15 @@ impl Core { let mut state = IMPORT.lock(); state.running = false; state.cancel = None; - state.last = outcome.as_ref().ok().cloned(); + // A failed run is reported too: without it, `--wait` read + // "no import has run" and exited as if all was well. + state.last = Some(match &outcome { + Ok(summary) => summary.clone(), + Err(e) => ImportSummary { + error: Some(e.to_string()), + ..Default::default() + }, + }); } match outcome { Ok(summary) => { diff --git a/crates/pdfs-gui/src/app/pages/takeout.rs b/crates/pdfs-gui/src/app/pages/takeout.rs index 8e888b5..62a6049 100644 --- a/crates/pdfs-gui/src/app/pages/takeout.rs +++ b/crates/pdfs-gui/src/app/pages/takeout.rs @@ -646,7 +646,12 @@ pub(crate) fn refresh_takeout(ui: &Rc) { // An import runs for hours, so its ending is announced rather than // left to be discovered: a toast for whoever is looking at the // window, and a desktop notification for whoever is not. - if was_running { + let failed = summary.as_ref().and_then(|s| s.error.clone()); + if was_running && let Some(error) = &failed { + let headline = gettext("Couldn't import from Google Photos"); + toast_error(&ui, &headline, error); + notify("takeout-import", &headline, error); + } else if was_running { let dry = ui.takeout.dry_run.get(); let headline = if dry { gettext("Scan finished") @@ -702,7 +707,18 @@ fn repaint_summary(ui: &Rc, summary: &ImportSummary) { } else { gettext("Last import") }); - ui.takeout.summary_group.set_description(Some(&if dry { + ui.takeout.summary_group.set_description(Some(&if let Some(error) = &summary.error { + // The reason is the service's own text, which is not translated. A + // group's description is markup, and the reason may name a path. + glib::markup_escape_text(&gettext_f( + "{what}: {detail}", + &[ + ("what", &gettext("Couldn't import from Google Photos")), + ("detail", error), + ], + )) + .to_string() + } else if dry { gettext("Nothing was uploaded — this is what an import would do.") } else if summary.cancelled { gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.") diff --git a/docs/BUGS.md b/docs/BUGS.md index 7037d55..30d333d 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B239](#b239----wait-reports-success-for-imports-and-re-dates-that-failed) | --wait reports success for imports and re-dates that failed | | [B238](#b238--draining-a-large-offline-batch-is-quadratic-in-its-size) | Draining a large offline batch is quadratic in its size | | [B237](#b237--every-reconnect-resends-refused-uploads-all-in-the-same-second) | Every reconnect resends refused uploads, all in the same second | | [B236](#b236--imported-re-dated-and-app-uploaded-photos-have-no-thumbnail) | Imported, re-dated and app-uploaded photos have no thumbnail | @@ -341,6 +342,32 @@ a live account since. Newest first. +### B239 — --wait reports success for imports and re-dates that failed + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, CLI). +**Where:** `finished_run`, `cmd_import_google_photos` and `cmd_redate_photos` in +`crates/pdfs-cli/src/main.rs`; the run threads in `crates/pdfs-fuse/src/takeout.rs` and +`redate.rs`; `ImportSummary::error` and `RedateSummary::error` in `crates/pdfs-core/src/control.rs`; +`refresh_takeout` in `crates/pdfs-gui/src/app/pages/takeout.rs` + +**Repro:** `pdfs import-google-photos --wait notes.txt; echo $?`, or an import where some photos +fail to upload, or one cancelled from the app. + +**Cause.** `--wait` exited 0 however the run ended. A run that failed as a whole (an archive that +is not a zip) left no summary at all, so the CLI printed "No import has run yet" and the app +announced "Import finished". A run with failed photos, or a cancelled one, also exited 0. A +script could not tell any of these from a clean run. + +**Fix.** A run that fails as a whole now leaves a summary with the reason. `--wait` exits 1 when +the run failed, was cancelled, left photos failed, or ended without a result, and says which. +The app shows the reason instead of "Import finished". + +**Test:** `tests::a_waited_for_run_that_did_not_fully_succeed_exits_with_an_error` in +`crates/pdfs-cli/src/main.rs`. + +--- + ### B238 — Draining a large offline batch is quadratic in its size **Status:** Fixed, not verified live diff --git a/po/de.po b/po/de.po index fc20637..2f70772 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-13 18:38+0200\n" +"POT-Creation-Date: 2026-10-16 11:55+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -30,7 +30,7 @@ msgstr[1] "vor {n} Min." #. Translators: strftime format for the time of day in the activity feed, such as "14:05". #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 -#: crates/pdfs-gui/src/app/pages/activity.rs:838 +#: crates/pdfs-gui/src/app/pages/activity.rs:840 #: crates/pdfs-gui/src/app/pages/photos.rs:2774 msgid "%H:%M" msgstr "%H:%M" @@ -60,17 +60,17 @@ msgstr "%A, %-d. %B %Y, %H:%M" #: crates/pdfs-gui/src/app/main.rs:764 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 -#: crates/pdfs-gui/src/app/pages/browser.rs:831 -#: crates/pdfs-gui/src/app/pages/browser.rs:1236 -#: crates/pdfs-gui/src/app/pages/browser.rs:2102 -#: crates/pdfs-gui/src/app/pages/browser.rs:2179 -#: crates/pdfs-gui/src/app/pages/locations.rs:1665 +#: crates/pdfs-gui/src/app/pages/browser.rs:833 +#: crates/pdfs-gui/src/app/pages/browser.rs:1238 +#: crates/pdfs-gui/src/app/pages/browser.rs:2104 +#: crates/pdfs-gui/src/app/pages/browser.rs:2181 +#: crates/pdfs-gui/src/app/pages/locations.rs:1667 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:767 crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:767 crates/pdfs-gui/src/app/main.rs:1412 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:845 #: crates/pdfs-gui/src/app/pages/places.rs:441 @@ -79,7 +79,7 @@ msgstr "Fotos" #: crates/pdfs-gui/src/app/main.rs:772 #: crates/pdfs-gui/src/app/pages/shared.rs:168 -#: crates/pdfs-gui/src/app/pages/shared.rs:482 +#: crates/pdfs-gui/src/app/pages/shared.rs:484 msgid "Shared with me" msgstr "Mit mir geteilt" @@ -90,7 +90,7 @@ msgstr "Von mir geteilt" #: crates/pdfs-gui/src/app/main.rs:780 #: crates/pdfs-gui/src/app/pages/devices.rs:140 -#: crates/pdfs-gui/src/app/pages/devices.rs:485 +#: crates/pdfs-gui/src/app/pages/devices.rs:489 msgid "Computers" msgstr "Computer" @@ -107,36 +107,37 @@ msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1099 +#: crates/pdfs-gui/src/app/main.rs:1109 +#: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1118 +#: crates/pdfs-gui/src/app/main.rs:1132 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1119 +#: crates/pdfs-gui/src/app/main.rs:1133 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1120 +#: crates/pdfs-gui/src/app/main.rs:1134 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1121 +#: crates/pdfs-gui/src/app/main.rs:1135 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1122 +#: crates/pdfs-gui/src/app/main.rs:1136 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1209 +#: crates/pdfs-gui/src/app/main.rs:1223 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1217 +#: crates/pdfs-gui/src/app/main.rs:1231 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -148,23 +149,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1222 +#: crates/pdfs-gui/src/app/main.rs:1236 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1226 +#: crates/pdfs-gui/src/app/main.rs:1240 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1230 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1235 +#: crates/pdfs-gui/src/app/main.rs:1249 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1239 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -189,7 +190,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1247 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +210,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1263 +#: crates/pdfs-gui/src/app/main.rs:1277 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -206,7 +207,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1263 +#: crates/pdfs-gui/src/app/main.rs:1277 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +208,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1263 +#: crates/pdfs-gui/src/app/main.rs:1277 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +209,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1263 +#: crates/pdfs-gui/src/app/main.rs:1277 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -206,7 +207,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1263 +#: crates/pdfs-gui/src/app/main.rs:1277 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +208,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1263 +#: crates/pdfs-gui/src/app/main.rs:1277 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -33,7 +33,7 @@ msgstr[1] "" #. Translators: strftime format for the time of day in the activity feed, such as "14:05". #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 -#: crates/pdfs-gui/src/app/pages/activity.rs:838 +#: crates/pdfs-gui/src/app/pages/activity.rs:840 #: crates/pdfs-gui/src/app/pages/photos.rs:2774 msgid "%H:%M" msgstr "" @@ -63,17 +63,17 @@ msgstr "" #: crates/pdfs-gui/src/app/main.rs:764 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 -#: crates/pdfs-gui/src/app/pages/browser.rs:831 -#: crates/pdfs-gui/src/app/pages/browser.rs:1236 -#: crates/pdfs-gui/src/app/pages/browser.rs:2102 -#: crates/pdfs-gui/src/app/pages/browser.rs:2179 -#: crates/pdfs-gui/src/app/pages/locations.rs:1665 +#: crates/pdfs-gui/src/app/pages/browser.rs:833 +#: crates/pdfs-gui/src/app/pages/browser.rs:1238 +#: crates/pdfs-gui/src/app/pages/browser.rs:2104 +#: crates/pdfs-gui/src/app/pages/browser.rs:2181 +#: crates/pdfs-gui/src/app/pages/locations.rs:1667 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:767 crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:767 crates/pdfs-gui/src/app/main.rs:1412 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:845 #: crates/pdfs-gui/src/app/pages/places.rs:441 @@ -82,7 +82,7 @@ msgstr "" #: crates/pdfs-gui/src/app/main.rs:772 #: crates/pdfs-gui/src/app/pages/shared.rs:168 -#: crates/pdfs-gui/src/app/pages/shared.rs:482 +#: crates/pdfs-gui/src/app/pages/shared.rs:484 msgid "Shared with me" msgstr "" @@ -93,7 +93,7 @@ msgstr "" #: crates/pdfs-gui/src/app/main.rs:780 #: crates/pdfs-gui/src/app/pages/devices.rs:140 -#: crates/pdfs-gui/src/app/pages/devices.rs:485 +#: crates/pdfs-gui/src/app/pages/devices.rs:489 msgid "Computers" msgstr "" @@ -110,36 +110,37 @@ msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1099 +#: crates/pdfs-gui/src/app/main.rs:1109 +#: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1118 +#: crates/pdfs-gui/src/app/main.rs:1132 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1119 +#: crates/pdfs-gui/src/app/main.rs:1133 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1120 +#: crates/pdfs-gui/src/app/main.rs:1134 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1121 +#: crates/pdfs-gui/src/app/main.rs:1135 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1122 +#: crates/pdfs-gui/src/app/main.rs:1136 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1209 +#: crates/pdfs-gui/src/app/main.rs:1223 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1217 +#: crates/pdfs-gui/src/app/main.rs:1231 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -147,23 +148,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1222 +#: crates/pdfs-gui/src/app/main.rs:1236 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1226 +#: crates/pdfs-gui/src/app/main.rs:1240 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1230 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1235 +#: crates/pdfs-gui/src/app/main.rs:1249 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1239 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -176,21 +177,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1247 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1263 +#: crates/pdfs-gui/src/app/main.rs:1277 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +211,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1263 +#: crates/pdfs-gui/src/app/main.rs:1277 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -206,7 +207,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1263 +#: crates/pdfs-gui/src/app/main.rs:1277 msgid "" "The street map, when turned on, comes from OpenFreeMap in the Date: Fri, 16 Oct 2026 11:58:28 +0200 Subject: [PATCH 24/44] fix(cli,core): keep secret-holding commands out of core dumps Only `pdfs daemon` marked itself non-dumpable, though `mount`, `login`, `unlock` and `logout` hold the same passphrases or tokens. All five now do. The file-manager script also passes each path after `--`, so a file named like an option stays a path (B240). --- crates/pdfs-cli/src/main.rs | 29 ++++++++++++++++++++++++++--- crates/pdfs-core/src/shell.rs | 17 ++++++++++++++++- docs/BUGS.md | 31 +++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 6986e54..6187a21 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -831,6 +831,20 @@ fn main() -> Result<()> { let cli = Cli::parse(); JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed); + // The commands that hold the password, the key passphrases or the session + // tokens in memory: keep them out of core dumps and away from same-user + // ptrace. Only the daemon did, though `mount` holds the same keys for as + // long as it runs. + if matches!( + cli.command, + Command::Login { .. } + | Command::Unlock { .. } + | Command::Logout + | Command::Mount { .. } + | Command::Daemon { .. } + ) { + pdfs_core::kernelkey::forbid_dumps(); + } match cli.command { Command::Login { username, remember } => cmd_login(username, remember), Command::Unlock { remember } => cmd_unlock(remember), @@ -2227,9 +2241,6 @@ where /// an external unmount triggers a remount; errors back off and retry. This is /// the entry point for the systemd user service. fn cmd_daemon(mountpoint: Option) -> Result<()> { - // The daemon holds the account's key passphrases in memory; keep them out - // of core dumps and away from same-user ptrace. - pdfs_core::kernelkey::forbid_dumps(); loop { // Wait until a session is stored. The GUI enables this service on login, // but the service may also start at boot before the user has logged in. @@ -3787,6 +3798,18 @@ mod login_wait_tests { assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15)); } + /// The file-manager script passes each selected path after `--`, so a + /// file named like an option is still the path. + #[test] + fn a_path_after_a_double_dash_is_a_path_even_when_it_looks_like_a_flag() { + let cli = Cli::try_parse_from(["pdfs", "pin", "--", "--json"]).unwrap(); + assert!(!cli.json); + match cli.command { + Command::Pin { path } => assert_eq!(path, PathBuf::from("--json")), + _ => panic!("not a pin"), + } + } + #[test] fn a_waited_for_run_that_did_not_fully_succeed_exits_with_an_error() { assert!(finished_run("the import", Some((0, false, None))).is_ok()); diff --git a/crates/pdfs-core/src/shell.rs b/crates/pdfs-core/src/shell.rs index 22932a7..a5323a4 100644 --- a/crates/pdfs-core/src/shell.rs +++ b/crates/pdfs-core/src/shell.rs @@ -23,6 +23,11 @@ const SCRIPTS: [(&str, &str); 2] = [ /// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same /// selection when the script runs on a local (non-URI) folder, so we prefer the /// environment and fall back to `"$@"` for the Nemo/Caja variants. +/// +/// The `--` keeps a file named like an option, such as `--json`, a path. A +/// name with a newline in it still arrives split in two from Nautilus, whose +/// list is newline-separated; `pdfs` then fails on both halves, which touches +/// nothing. fn script_body(subcommand: &str) -> String { format!( "#!/bin/sh\n\ @@ -41,7 +46,7 @@ fn script_body(subcommand: &str) -> String { \n\ printf '%s' \"$paths\" | while IFS= read -r path; do\n\ \x20 [ -n \"$path\" ] || continue\n\ - \x20 pdfs {subcommand} \"$path\" || exit 1\n\ + \x20 pdfs {subcommand} -- \"$path\" || exit 1\n\ done\n" ) } @@ -95,3 +100,13 @@ fn write_scripts(dir: &PathBuf) -> io::Result<()> { tracing::debug!("installed file-manager scripts in {dir:?}"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::script_body; + + #[test] + fn the_script_ends_options_before_each_path() { + assert!(script_body("pin").contains(r#"pdfs pin -- "$path""#)); + } +} diff --git a/docs/BUGS.md b/docs/BUGS.md index 30d333d..4694f51 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B240](#b240--pdfs-mount-can-be-core-dumped-with-the-keys-in-memory) | pdfs mount can be core-dumped with the keys in memory | | [B239](#b239----wait-reports-success-for-imports-and-re-dates-that-failed) | --wait reports success for imports and re-dates that failed | | [B238](#b238--draining-a-large-offline-batch-is-quadratic-in-its-size) | Draining a large offline batch is quadratic in its size | | [B237](#b237--every-reconnect-resends-refused-uploads-all-in-the-same-second) | Every reconnect resends refused uploads, all in the same second | @@ -342,6 +343,36 @@ a live account since. Newest first. +### B240 — pdfs mount can be core-dumped with the keys in memory + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, core dumps and the +file-manager script). +**Where:** `main` in `crates/pdfs-cli/src/main.rs`; `script_body` in +`crates/pdfs-core/src/shell.rs` + +**Repro:** +- Run `pdfs mount` and send it `SIGSEGV`, with core dumps enabled. The dump holds the key + passphrases. +- In the mount, name a file `--json` and choose **Scripts → Keep offline** on it in Nautilus. + +**Cause.** Only `pdfs daemon` marked itself non-dumpable. `pdfs mount`, `login`, `unlock` and +`logout` hold the password, the key passphrases or the session tokens too, and could be +core-dumped or ptraced by another process of the same user. Separately, the file-manager script +ran `pdfs pin "$path"`, so a file named like an option was read as one. + +**Fix.** Those five commands mark themselves non-dumpable at start. The script passes each path +after `--`. A name with a newline still arrives split from Nautilus, whose list is +newline-separated; `pdfs` then fails on both halves and changes nothing. + +The app's own login was left as it is: a non-dumpable GUI process hides its `/proc` entries from +the desktop portal, which identifies the caller through them. + +**Test:** `tests::a_path_after_a_double_dash_is_a_path_even_when_it_looks_like_a_flag` in +`crates/pdfs-cli/src/main.rs`; `shell::tests::the_script_ends_options_before_each_path`. + +--- + ### B239 — --wait reports success for imports and re-dates that failed **Status:** Fixed, not verified live From 5557aa66d554941e6cefabb07ce3f663bd4a17c3 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:58:56 +0200 Subject: [PATCH 25/44] ci: run the simulations on pull requests and lint with --locked --- .github/workflows/ci.yml | 5 +++-- docs/TESTING.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5b54f7..be30d78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: run: cargo fmt --all -- --check - name: Lints - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --locked -- -D warnings - name: Unit tests run: cargo test --workspace --locked @@ -66,8 +66,9 @@ jobs: # The seeded simulation runs (docs/TESTING.md): real FUSE # daemons against an in-memory Drive, so they need /dev/fuse but no account. + # They run on pull requests too: most data-loss fixes are only exercised + # here, and a change that breaks one should not reach main first. simulation: - if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 30 diff --git a/docs/TESTING.md b/docs/TESTING.md index 8b9036e..c0c15f5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -5,7 +5,7 @@ Three layers check the client, from fast and offline to slow and against a real | Layer | Needs | Runs in CI | |---|---|---| | [Unit tests](#unit-tests) | Nothing | Every push and pull request | -| [Simulation runs](#simulation-runs) | `/dev/fuse` | Every push to `main` | +| [Simulation runs](#simulation-runs) | `/dev/fuse` | Every push to `main` and every pull request | | [Acceptance suite](#acceptance-suite) | Nothing for the reference run; a signed-in account for the rest | The reference run only | ## Unit tests From 99707c48fa60a19ab368bed2b6181b46b12ec919 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:03:02 +0200 Subject: [PATCH 26/44] fix(gui): free the photo viewer and the share dialog when they close The viewer's handlers held strong references to the viewer and its window, which own them, so every closed viewer stayed in memory with its full-size photo. The share dialog's row handlers had the same cycle with the dialog state. The handlers now hold weak references, and the viewer's one strong handle is dropped on close with the photo (B241). --- crates/pdfs-gui/src/app/pages/photo_viewer.rs | 165 ++++++++++++++---- .../pdfs-gui/src/app/widgets/share_dialog.rs | 29 ++- docs/BUGS.md | 27 +++ 3 files changed, 178 insertions(+), 43 deletions(-) diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index 6a3ab95..fba9fe0 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -1260,8 +1260,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { // Any real pointer movement brings the controls back; resting over one of // them keeps them up. let motion = gtk4::EventControllerMotion::new(); - let viewer_motion = viewer.clone(); + let viewer_motion = Rc::downgrade(&viewer); motion.connect_motion(move |_, x, y| { + let Some(viewer_motion) = viewer_motion.upgrade() else { + return; + }; let (last_x, last_y) = viewer_motion.pointer.get(); viewer_motion.pointer.set((x, y)); if (x - last_x).abs() > 1.0 || (y - last_y).abs() > 1.0 { @@ -1271,10 +1274,17 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { body.add_controller(motion); for revealer in [&top_reveal, &prev_reveal, &next_reveal] { let hover = gtk4::EventControllerMotion::new(); - let viewer_enter = viewer.clone(); - hover.connect_enter(move |_, _, _| viewer_enter.chrome_hover.set(true)); - let viewer_leave = viewer.clone(); + let viewer_enter = Rc::downgrade(&viewer); + hover.connect_enter(move |_, _, _| { + if let Some(viewer) = viewer_enter.upgrade() { + viewer.chrome_hover.set(true); + } + }); + let viewer_leave = Rc::downgrade(&viewer); hover.connect_leave(move |_| { + let Some(viewer_leave) = viewer_leave.upgrade() else { + return; + }; viewer_leave.chrome_hover.set(false); show_chrome(&viewer_leave); }); @@ -1284,8 +1294,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { // Ctrl+scroll zooms around the pointer; a plain scroll pans a zoomed photo. let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL); wheel.set_propagation_phase(gtk4::PropagationPhase::Capture); - let viewer_wheel = viewer.clone(); + let viewer_wheel = Rc::downgrade(&viewer); wheel.connect_scroll(move |controller, _, dy| { + let Some(viewer_wheel) = viewer_wheel.upgrade() else { + return glib::Propagation::Proceed; + }; if !controller .current_event_state() .contains(gtk4::gdk::ModifierType::CONTROL_MASK) @@ -1305,14 +1318,20 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { scroller.add_controller(wheel); let pinch = gtk4::GestureZoom::new(); - let viewer_pinch = viewer.clone(); + let viewer_pinch = Rc::downgrade(&viewer); pinch.connect_begin(move |_, _| { + let Some(viewer_pinch) = viewer_pinch.upgrade() else { + return; + }; viewer_pinch .pinch_start .set(current_scale(&viewer_pinch).unwrap_or(1.0)); }); - let viewer_pinch = viewer.clone(); + let viewer_pinch = Rc::downgrade(&viewer); pinch.connect_scale_changed(move |gesture, delta| { + let Some(viewer_pinch) = viewer_pinch.upgrade() else { + return; + }; let start = viewer_pinch.pinch_start.get(); zoom_to( &viewer_pinch, @@ -1324,8 +1343,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { // Dragging a zoomed photo pans it. let drag = gtk4::GestureDrag::new(); - let viewer_drag = viewer.clone(); + let viewer_drag = Rc::downgrade(&viewer); drag.connect_drag_begin(move |_, _, _| { + let Some(viewer_drag) = viewer_drag.upgrade() else { + return; + }; let scroller = &viewer_drag.scroller; viewer_drag.drag_start.set(( scroller.hadjustment().value(), @@ -1335,8 +1357,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { viewer_drag.picture.set_cursor_from_name(Some("grabbing")); } }); - let viewer_drag = viewer.clone(); + let viewer_drag = Rc::downgrade(&viewer); drag.connect_drag_update(move |_, dx, dy| { + let Some(viewer_drag) = viewer_drag.upgrade() else { + return; + }; if viewer_drag.zoom.get().is_none() { return; } @@ -1344,39 +1369,58 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { viewer_drag.scroller.hadjustment().set_value(x - dx); viewer_drag.scroller.vadjustment().set_value(y - dy); }); - let viewer_drag = viewer.clone(); + let viewer_drag = Rc::downgrade(&viewer); drag.connect_drag_end(move |_, _, _| { + let Some(viewer_drag) = viewer_drag.upgrade() else { + return; + }; if viewer_drag.zoom.get().is_some() { viewer_drag.picture.set_cursor_from_name(Some("grab")); } }); scroller.add_controller(drag); - let w_close = window.clone(); + let w_close = window.downgrade(); close_btn.connect_clicked(move |_| { + let Some(w_close) = w_close.upgrade() else { + return; + }; w_close.close(); }); - let viewer_closing = viewer.clone(); + // The one strong handle on the viewer. Every handler holds a weak one: a + // strong one in a handler of a widget the viewer holds is a cycle, which + // kept every closed lightbox, its window and its full-size photo in memory + // for the life of the app. + let keep = RefCell::new(Some(viewer.clone())); window.connect_close_request(move |_| { - stop_video(&viewer_closing); - disarm_slide(&viewer_closing); - if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() { - source.remove(); + if let Some(viewer) = keep.borrow_mut().take() { + stop_video(&viewer); + disarm_slide(&viewer); + if let Some(source) = viewer.chrome_source.borrow_mut().take() { + source.remove(); + } + viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE); } glib::Propagation::Proceed }); - let viewer_info = viewer.clone(); + let viewer_info = Rc::downgrade(&viewer); info_toggle.connect_toggled(move |toggle| { + let Some(viewer_info) = viewer_info.upgrade() else { + return; + }; viewer_info .info_revealer .set_reveal_child(toggle.is_active()); }); let ui_slides = ui.clone(); - let viewer_slides = viewer.clone(); + let viewer_slides = Rc::downgrade(&viewer); slideshow_btn.connect_toggled(move |toggle| { + let Some(viewer_slides) = viewer_slides.upgrade() else { + return; + }; if toggle.is_active() { start_slideshow(&ui_slides, &viewer_slides); } else { @@ -1388,9 +1432,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { info_close.connect_clicked(move |_| toggle_off.set_active(false)); let ui_map = ui.clone(); - let viewer_map = viewer.clone(); - let w_map = window.clone(); + let viewer_map = Rc::downgrade(&viewer); + let w_map = window.downgrade(); info_map.connect_clicked(move |_| { + let Some(w_map) = w_map.upgrade() else { + return; + }; + let Some(viewer_map) = viewer_map.upgrade() else { + return; + }; let coords = *viewer_map.coords.borrow(); if let Some((lat, lon)) = coords { w_map.close(); @@ -1399,8 +1449,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { }); let ui_fav = ui.clone(); - let viewer_fav = viewer.clone(); + let viewer_fav = Rc::downgrade(&viewer); favorite_btn.connect_toggled(move |btn| { + let Some(viewer_fav) = viewer_fav.upgrade() else { + return; + }; if viewer_fav.favorite_suppress.get() { return; } @@ -1444,17 +1497,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { // gone, and leaving it open on a trashed photo invites a second Delete on // something the user cannot see. let ui_delete = ui.clone(); - let viewer_delete = viewer.clone(); - let w_delete = window.clone(); + let viewer_delete = Rc::downgrade(&viewer); + let w_delete = window.downgrade(); delete_btn.connect_clicked(move |_| { + let Some(w_delete) = w_delete.upgrade() else { + return; + }; + let Some(viewer_delete) = viewer_delete.upgrade() else { + return; + }; let uid = viewer_delete.uid.borrow().clone(); w_delete.close(); trash_photos(&ui_delete, vec![uid], false); }); let ui_group = ui.clone(); - let viewer_group = viewer.clone(); + let viewer_group = Rc::downgrade(&viewer); group_btn.connect_clicked(move |_| { + let Some(viewer_group) = viewer_group.upgrade() else { + return; + }; let uid = viewer_group.uid.borrow().clone(); let next = next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone()); @@ -1465,9 +1527,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { }); let ui_download = ui.clone(); - let w_download = window.clone(); - let viewer_download = viewer.clone(); + let w_download = window.downgrade(); + let viewer_download = Rc::downgrade(&viewer); download_btn.connect_clicked(move |_| { + let Some(w_download) = w_download.upgrade() else { + return; + }; + let Some(viewer_download) = viewer_download.upgrade() else { + return; + }; if let Some(path) = viewer_download.path.borrow().as_deref() { let name = viewer_download.name.borrow(); save_photo_to_disk( @@ -1479,23 +1547,35 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { } }); - let w_ext = window.clone(); - let viewer_ext = viewer.clone(); + let w_ext = window.downgrade(); + let viewer_ext = Rc::downgrade(&viewer); open_ext_btn.connect_clicked(move |_| { + let Some(w_ext) = w_ext.upgrade() else { + return; + }; + let Some(viewer_ext) = viewer_ext.upgrade() else { + return; + }; if let Some(path) = viewer_ext.path.borrow().as_deref() { open_with(&w_ext, path, viewer_ext.name.borrow().as_deref()); } }); let ui_prev = ui.clone(); - let viewer_prev = viewer.clone(); + let viewer_prev = Rc::downgrade(&viewer); prev_btn.connect_clicked(move |_| { + let Some(viewer_prev) = viewer_prev.upgrade() else { + return; + }; navigate_photo(&ui_prev, &viewer_prev, -1); }); let ui_next = ui.clone(); - let viewer_next = viewer.clone(); + let viewer_next = Rc::downgrade(&viewer); next_btn.connect_clicked(move |_| { + let Some(viewer_next) = viewer_next.upgrade() else { + return; + }; navigate_photo(&ui_next, &viewer_next, 1); }); @@ -1504,8 +1584,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { // reaching for the image doesn't fling the window shut; a zoomed photo has // no backdrop to speak of. Double-click toggles between fitted and 100 %. let backdrop = gtk4::GestureClick::new(); - let viewer_click = viewer.clone(); + let viewer_click = Rc::downgrade(&viewer); backdrop.connect_pressed(move |_, n_press, x, y| { + let Some(viewer_click) = viewer_click.upgrade() else { + return; + }; if n_press != 2 { return; } @@ -1522,9 +1605,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { zoom_to(&viewer_click, Some((fit * 2.0).max(1.0)), Some(anchor)); } }); - let viewer_click = viewer.clone(); - let w_click = window.clone(); + let viewer_click = Rc::downgrade(&viewer); + let w_click = window.downgrade(); backdrop.connect_released(move |_, n_press, x, y| { + let Some(w_click) = w_click.upgrade() else { + return; + }; + let Some(viewer_click) = viewer_click.upgrade() else { + return; + }; if n_press == 1 && viewer_click.zoom.get().is_none() && !over_photo(&viewer_click.picture, x, y) @@ -1536,9 +1625,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { let key_controller = gtk4::EventControllerKey::new(); let ui_key = ui.clone(); - let viewer_key = viewer.clone(); - let w_key = window.clone(); + let viewer_key = Rc::downgrade(&viewer); + let w_key = window.downgrade(); key_controller.connect_key_pressed(move |_, key, _keycode, state| { + let Some(w_key) = w_key.upgrade() else { + return glib::Propagation::Proceed; + }; + let Some(viewer_key) = viewer_key.upgrade() else { + return glib::Propagation::Proceed; + }; let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK); let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video"); match key.name().as_deref() { diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs index 1bca73d..1ab467e 100644 --- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -507,10 +507,16 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr .selected(role_wire_to_index(&entry.role)) .valign(gtk4::Align::Center) .build(); - let state_role = state.clone(); + // Weak, here and in every row below: the rows belong to groups the + // dialog state holds, so a strong handle in a row's handler is a + // cycle that kept each closed dialog in memory. + let state_role = Rc::downgrade(state); let id = entry.id.clone(); let kind = entry.kind; drop.connect_selected_notify(move |d| { + let Some(state_role) = state_role.upgrade() else { + return; + }; share_dialog_op( &state_role, state_role.target.update_role( @@ -540,12 +546,14 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr .valign(gtk4::Align::Center) .build(); remove.add_css_class("flat"); - let state_rm = state.clone(); + let state_rm = Rc::downgrade(state); let id = entry.id.clone(); let kind = entry.kind; let who = entry.email.clone(); remove.connect_clicked(move |btn| { - let state = state_rm.clone(); + let Some(state) = state_rm.upgrade() else { + return; + }; let id = id.clone(); let button = btn.clone(); confirm_destructive( @@ -617,12 +625,12 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi .valign(gtk4::Align::Center) .build(); copy.add_css_class("flat"); - let state_copy = state.clone(); + let ui_copy = state.ui.clone(); let url_copy = url.clone(); copy.connect_clicked(move |btn| { btn.clipboard().set_text(&url_copy); flash_copied(btn, "edit-copy-symbolic"); - toast(&state_copy.ui, &gettext("Link copied")); + toast(&ui_copy, &gettext("Link copied")); }); row.add_suffix(©); } @@ -633,10 +641,12 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi .valign(gtk4::Align::Center) .build(); remove.add_css_class("flat"); - let state_rm = state.clone(); + let state_rm = Rc::downgrade(state); let id = link.id.clone(); remove.connect_clicked(move |btn| { - let state = state_rm.clone(); + let Some(state) = state_rm.upgrade() else { + return; + }; let id = id.clone(); let button = btn.clone(); confirm_destructive( @@ -690,9 +700,12 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi let btn = create.clone(); pw_row.connect_entry_activated(move |_| btn.emit_clicked()); - let state_c = state.clone(); + let state_c = Rc::downgrade(state); let pw_for = pw_row.clone(); create.connect_clicked(move |btn| { + let Some(state_c) = state_c.upgrade() else { + return; + }; if !btn.is_sensitive() { return; } diff --git a/docs/BUGS.md b/docs/BUGS.md index 4694f51..f66e334 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B241](#b241--every-closed-photo-viewer-stays-in-memory-with-its-photo) | Every closed photo viewer stays in memory with its photo | | [B240](#b240--pdfs-mount-can-be-core-dumped-with-the-keys-in-memory) | pdfs mount can be core-dumped with the keys in memory | | [B239](#b239----wait-reports-success-for-imports-and-re-dates-that-failed) | --wait reports success for imports and re-dates that failed | | [B238](#b238--draining-a-large-offline-batch-is-quadratic-in-its-size) | Draining a large offline batch is quadratic in its size | @@ -343,6 +344,32 @@ a live account since. Newest first. +### B241 — Every closed photo viewer stays in memory with its photo + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.5.2). +**Where:** `open_photo_viewer` in `crates/pdfs-gui/src/app/pages/photo_viewer.rs`; +`repaint_share_people` and `repaint_share_link` in +`crates/pdfs-gui/src/app/widgets/share_dialog.rs` + +**Repro:** Open and close the photo viewer twenty times on large photos, and watch the app's +memory. Or open and close the Share dialog of a shared folder many times. + +**Cause.** Every handler of the viewer held a strong `Rc`, and the viewer holds the +widgets those handlers belong to. Several handlers also held the window, which owns them. Each +pair was a reference cycle, so a closed viewer was never freed: not its window, and not the +full-size photo its picture showed. The Share dialog had the same cycle between its state and +the handlers of the rows inside the groups that state holds. + +**Fix.** The handlers hold weak handles to the viewer and its window. The one strong handle sits +in the window's close handler, which drops it on close and clears the picture. The Share +dialog's row handlers hold the state weakly too. + +**Test:** none automated; the GUI tests run without a display. Checked by reading every handler +`open_photo_viewer` and the share rows connect. + +--- + ### B240 — pdfs mount can be core-dumped with the keys in memory **Status:** Fixed, not verified live From 6ec80439345e63e764315a7c436dd6c701b006f0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:04:05 +0200 Subject: [PATCH 27/44] fix(gui): decode viewer photos and read their EXIF off the main thread The viewer decoded each full-size photo, read its EXIF and stat'ed it on the GTK main thread, freezing the app for a large photo. That now runs in gio::spawn_blocking, and a result for a photo the user has left is dropped (B242). --- crates/pdfs-gui/src/app/pages/photo_viewer.rs | 49 ++++++++++++++----- docs/BUGS.md | 22 +++++++++ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index fba9fe0..05460a6 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -275,14 +275,42 @@ pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { viewer.status.set_label(message); viewer.status.set_visible(true); }; - match result { - Ok(Ok(Response::FilePath { path })) if is_video => { + let path = match result { + Ok(Ok(Response::FilePath { path })) => path, + Ok(Ok(Response::Error { message, .. })) => return fail(&message), + Ok(Ok(_)) => return fail(&gettext("Unexpected reply from the Proton Drive service.")), + Ok(Err(_)) | Err(_) => return fail(&gettext("Couldn't reach Proton Drive.")), + }; + // Decoding a full-size photo and reading its EXIF take a good part of + // a second for a large one, which froze the whole app on the main + // thread. Done on a worker; a reply for a photo the user has since + // left is dropped like the request's own. + let loaded = { + let path = path.clone(); + gio::spawn_blocking(move || { + let size = std::fs::metadata(&path).ok().map(|meta| meta.len()); + if is_video { + return (size, None, ExifInfo::default()); + } + let texture = gtk4::gdk::Texture::from_filename(&path); + (size, Some(texture), read_exif(&path)) + }) + .await + }; + if *viewer.uid.borrow() != uid { + return; + } + let Ok((size, texture, info)) = loaded else { + return fail(&gettext("Couldn't render this photo.")); + }; + match texture { + None => { viewer.video.set_filename(Some(&path)); viewer.media.set_visible_child_name("video"); *viewer.path.borrow_mut() = Some(path.clone()); - show_info(&viewer, &path, ExifInfo::default()); + show_info(&viewer, size, ExifInfo::default()); } - Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) { + Some(texture) => match texture { Ok(texture) => { // Zoomed in on the thumbnail: keep the photo the size it is // on screen, now drawn from the full-size pixels. @@ -297,16 +325,13 @@ pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { zoom_to(&viewer, Some(zoom), None); } *viewer.path.borrow_mut() = Some(path.clone()); - show_info(&viewer, &path, read_exif(&path)); + show_info(&viewer, size, info); } Err(e) => { tracing::error!("Failed to load texture for {path}: {e}"); fail(&gettext("Couldn't render this photo.")); } }, - Ok(Ok(Response::Error { message, .. })) => fail(&message), - Ok(Ok(_)) => fail(&gettext("Unexpected reply from the Proton Drive service.")), - Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")), } }); } @@ -452,16 +477,16 @@ pub(crate) fn clear_info(viewer: &Rc) { } /// Fill the details drawer for the photo now on screen: its own file facts -/// (size, dimensions) plus whatever EXIF it carries. -pub(crate) fn show_info(viewer: &Rc, path: &str, info: ExifInfo) { +/// (`size`, dimensions) plus whatever EXIF it carries. +pub(crate) fn show_info(viewer: &Rc, size: Option, info: ExifInfo) { while let Some(row) = viewer.info_rows.first_child() { viewer.info_rows.remove(&row); } let mut fields: Vec<(String, String)> = Vec::new(); - if let Ok(meta) = std::fs::metadata(path) { + if let Some(size) = size { // Translators: label of a photo's file size in the details panel. - fields.push((pgettext("property", "Size"), human_bytes(meta.len()))); + fields.push((pgettext("property", "Size"), human_bytes(size))); } fields.extend(info.fields); diff --git a/docs/BUGS.md b/docs/BUGS.md index f66e334..784f9a9 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B242](#b242--opening-a-large-photo-freezes-the-app) | Opening a large photo freezes the app | | [B241](#b241--every-closed-photo-viewer-stays-in-memory-with-its-photo) | Every closed photo viewer stays in memory with its photo | | [B240](#b240--pdfs-mount-can-be-core-dumped-with-the-keys-in-memory) | pdfs mount can be core-dumped with the keys in memory | | [B239](#b239----wait-reports-success-for-imports-and-re-dates-that-failed) | --wait reports success for imports and re-dates that failed | @@ -344,6 +345,27 @@ a live account since. Newest first. +### B242 — Opening a large photo freezes the app + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.5.1). +**Where:** `load_photo` and `show_info` in `crates/pdfs-gui/src/app/pages/photo_viewer.rs` + +**Repro:** Open a 50-megapixel photo in the viewer and press → repeatedly while each one loads. + +**Cause.** When the full-size file arrived, the viewer decoded it with +`Texture::from_filename`, read its EXIF and stat'ed it on the GTK main thread. For a large photo +that took a good part of a second each time, and the whole app froze for it: no redraw, no +input, no other page. + +**Fix.** The decode, the EXIF read and the stat run on a worker (`gio::spawn_blocking`). The +result is used only if the viewer still shows that photo; a reply for one the user has left is +dropped. + +**Test:** none automated; the GUI tests run without a display. + +--- + ### B241 — Every closed photo viewer stays in memory with its photo **Status:** Fixed, not verified live From 48c8910e7be8c2b8d372712a3f2a66368e58b303 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:07:48 +0200 Subject: [PATCH 28/44] fix(gui,core): ask the mount off the main thread when opening, reap children Opening a file or a location stat'ed the mount on the GTK main thread, so a hung mount hung the app. The browser now uses the launcher's bounded worker check (`present_within`, 2 s), and opening a location runs on a thread. Every child the GUI starts is also waited for on a thread, so it no longer stays a zombie (B243). --- crates/pdfs-core/src/opener.rs | 29 +++++++++- crates/pdfs-gui/src/activation.rs | 35 ++++++++++--- crates/pdfs-gui/src/app/main.rs | 12 +++-- crates/pdfs-gui/src/app/pages/browser.rs | 67 ++++++++++++------------ crates/pdfs-gui/src/app/pages/photos.rs | 6 +-- crates/pdfs-gui/src/hud/mod.rs | 26 ++------- crates/pdfs-gui/src/main.rs | 5 +- docs/BUGS.md | 29 ++++++++++ 8 files changed, 137 insertions(+), 72 deletions(-) diff --git a/crates/pdfs-core/src/opener.rs b/crates/pdfs-core/src/opener.rs index f1e356b..6795f86 100644 --- a/crates/pdfs-core/src/opener.rs +++ b/crates/pdfs-core/src/opener.rs @@ -189,11 +189,21 @@ fn spawn(argv: &[String], path: &Path) { .stderr(Stdio::null()) .process_group(0) .spawn(); - if let Err(e) = spawned { - tracing::error!(command = ?argv, "open failed: {e}"); + match spawned { + Ok(child) => reap(child), + Err(e) => tracing::error!(command = ?argv, "open failed: {e}"), } } +/// Wait for `child` on a thread of its own. A child nobody waits for stays a +/// zombie for as long as its parent lives, which for the app and the tray is +/// the whole session. +pub fn reap(mut child: std::process::Child) { + std::thread::spawn(move || { + let _ = child.wait(); + }); +} + /// [`open`] with the policy from the user's `config.json`, read once per /// process. For callers that open the occasional path and have no reason to /// carry a policy around; long-lived windows should hold their own copy. @@ -472,6 +482,21 @@ fn glob_match(pattern: &str, name: &str) -> bool { mod tests { use super::*; + #[test] + fn a_reaped_child_does_not_stay_a_zombie() { + let child = std::process::Command::new("true").spawn().unwrap(); + let proc_entry = std::path::PathBuf::from(format!("/proc/{}", child.id())); + reap(child); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while proc_entry.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!( + !proc_entry.exists(), + "the child is still in the process table" + ); + } + fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule { OpenRule { patterns: patterns.iter().map(|p| (*p).to_string()).collect(), diff --git a/crates/pdfs-gui/src/activation.rs b/crates/pdfs-gui/src/activation.rs index b697134..8da2fe1 100644 --- a/crates/pdfs-gui/src/activation.rs +++ b/crates/pdfs-gui/src/activation.rs @@ -10,9 +10,14 @@ //! `dead_code` allowances below are for — not code nobody uses. use std::path::{Path, PathBuf}; +use std::time::Duration; use pdfs_core::control::{PhotoKind, SearchHit}; +/// How long a `stat` of the mount may take before the file counts as not +/// there. A frozen mount never answers, and every caller has a fallback. +const STAT_TIMEOUT: Duration = Duration::from_secs(2); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DriveActivation { Folder, @@ -55,18 +60,32 @@ pub(crate) fn mounted_target(mountpoint: &Path, hit: &SearchHit) -> Option Option { - present(mounted_path(mountpoint, relative_path)) -} - fn present(path: PathBuf) -> Option { path.symlink_metadata().is_ok().then_some(path) } +/// Whether anything is at `path`, asked on a thread of its own and given up +/// on after [`STAT_TIMEOUT`]. `None` means "not there" or "no answer". +/// +/// For the GTK binaries, whose main thread must not `stat` the mount: a +/// mount that hangs hung the whole window with it. The thread of a `stat` +/// that never returns is left behind; there is no way to cancel one. +#[allow(dead_code)] // GTK binaries only +pub(crate) async fn present_within(path: PathBuf) -> Option { + let (tx, rx) = async_channel::bounded(1); + std::thread::spawn(move || { + let _ = tx.send_blocking(present(path)); + }); + match gtk4::glib::future_with_timeout(STAT_TIMEOUT, rx.recv()).await { + Ok(Ok(target)) => target, + Ok(Err(_)) => None, + Err(_) => { + tracing::warn!("the mount did not answer in time"); + None + } + } +} + /// The mount path for a hit, whether or not anything is there — for callers /// that have no fallback (a folder cannot be materialised) or already know the /// entry came from a mount listing. diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 277d6f1..ba820ae 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -297,7 +297,10 @@ fn present_window(app: &adw::Application) { /// Spawn the tray icon process in the background. pub(crate) fn spawn_tray() { match Command::new("pdfs-tray").spawn() { - Ok(_) => tracing::info!("spawned `pdfs-tray`"), + Ok(child) => { + tracing::info!("spawned `pdfs-tray`"); + pdfs_core::opener::reap(child); + } Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"), } } @@ -1713,9 +1716,12 @@ pub(crate) fn flash_copied(button: >k4::Button, icon: &'static str) { /// Open a local path with the handler the user configured — `xdg-open` unless /// `open_with` in `config.json` overrides it for this kind of file. +/// +/// On a thread of its own: telling a folder from a file is a `stat`, and the +/// path is often in the mount, which may hang. fn open_path(path: &str) { - let path = Path::new(path); - pdfs_core::opener::open_default(path, path.is_dir()); + let path = PathBuf::from(path); + std::thread::spawn(move || pdfs_core::opener::open_default(&path, path.is_dir())); } /// [`open_path`] for a file the daemon materialised into the content cache, diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index dd39886..1d6ced8 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -1,4 +1,4 @@ -use crate::activation::{DriveActivation, drive_activation, mounted_path, mounted_target_rel}; +use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within}; use crate::*; pub(crate) struct BrowserState { @@ -983,28 +983,27 @@ pub(crate) fn background_context_menu(ui: &Rc) -> ActionMenu { pub(crate) fn open_entry_with(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); - let Some(path) = mounted_target_rel(&mountpoint, &rel) else { - toast_error( - ui, - &gettext("Couldn't open file"), - &gettext("Open With needs the Proton Drive service to be running."), - ); - return; - }; - let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path))); - launcher.set_always_ask(true); let ui = ui.clone(); - launcher.launch( - ui_window(&ui).as_ref(), - gio::Cancellable::NONE, - move |result| { + glib::spawn_future_local(async move { + let Some(path) = present_within(mounted_path(&mountpoint, &rel)).await else { + toast_error( + &ui, + &gettext("Couldn't open file"), + &gettext("Open With needs the Proton Drive service to be running."), + ); + return; + }; + let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path))); + launcher.set_always_ask(true); + let window = ui_window(&ui); + launcher.launch(window.as_ref(), gio::Cancellable::NONE, move |result| { if let Err(e) = result && !e.matches(gtk4::DialogError::Dismissed) { toast_error(&ui, &gettext("Couldn't open file"), &e.to_string()); } - }, - ); + }); + }); } /// Copy the entry's public link, when it has one this client can read the @@ -1112,27 +1111,29 @@ pub(crate) fn activate_entry(ui: &Rc, entry: &DirEntry) { /// [`mounted_target`](crate::activation::mounted_target). pub(crate) fn download_and_open(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); - let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); - if let Some(path) = mounted_target_rel(&mountpoint, &rel) { - open_path(&path.to_string_lossy()); - return; - } - // Ignore a repeat activation of a file already downloading, so an impatient - // double-click doesn't kick off a second round-trip. + // Ignore a repeat activation of a file already opening, so an impatient + // double-click doesn't open it twice or kick off a second round-trip. if !ui.opening.borrow_mut().insert(rel.clone()) { return; } - ui.busy_begin(); - let name = entry.name.clone(); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::OpenFile { - path: rel.clone(), - uid: Some(entry.uid.clone()), - }, - ); + let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); + let (name, uid) = (entry.name.clone(), entry.uid.clone()); let ui = ui.clone(); glib::spawn_future_local(async move { + // Asked off the main thread: a mount that hangs used to hang the app. + if let Some(path) = present_within(mounted_path(&mountpoint, &rel)).await { + ui.opening.borrow_mut().remove(&rel); + open_path(&path.to_string_lossy()); + return; + } + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::OpenFile { + path: rel.clone(), + uid: Some(uid), + }, + ); let result = rx.recv().await; ui.busy_end(); ui.opening.borrow_mut().remove(&rel); diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 2e9feb2..3015e9b 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -3481,10 +3481,10 @@ pub(crate) fn gallery_unreachable(ui: &Rc) { /// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the /// user's default handler when mpv isn't installed. pub(crate) fn play_external(path: &str) { - if Command::new("mpv").arg(path).spawn().is_ok() { - return; + match Command::new("mpv").arg(path).spawn() { + Ok(child) => pdfs_core::opener::reap(child), + Err(_) => open_path(path), } - open_path(path); } #[cfg(test)] diff --git a/crates/pdfs-gui/src/hud/mod.rs b/crates/pdfs-gui/src/hud/mod.rs index c282a39..04287c3 100644 --- a/crates/pdfs-gui/src/hud/mod.rs +++ b/crates/pdfs-gui/src/hud/mod.rs @@ -15,7 +15,6 @@ mod thumbs; use std::cell::{Cell, RefCell}; use std::path::{Path, PathBuf}; use std::rc::Rc; -use std::time::Duration; use adw::prelude::*; use gtk4::{gdk, gio, glib}; @@ -24,7 +23,7 @@ use pdfs_core::config::AppDirs; use pdfs_core::control::{Request, Response, SearchFilters, SearchHit, SearchSource}; use pdfs_core::opener::{self, OpenWith}; -use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target}; +use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, present_within}; use crate::compat::{Spinner, spinner}; use crate::theme::set_proton_theme; use crate::{ @@ -38,10 +37,6 @@ use thumbs::Thumbs; /// Recent files shown above the pinned ones while the query is empty. const RECENT_LIMIT: usize = 8; -/// How long opening waits for the mount to answer whether it has a file. A -/// frozen mount must not freeze the launcher; the daemon is asked instead. -const STAT_TIMEOUT: Duration = Duration::from_secs(2); - /// The titled groups of the result list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Section { @@ -516,22 +511,11 @@ pub(crate) fn build_window(app: &adw::Application) -> Option> { Some(ui) } -/// Whether the mount has `hit`, asked on a worker thread and given up on -/// after [`STAT_TIMEOUT`]. `None` means "not there" or "no answer"; either -/// way the daemon is the fallback. +/// Whether the mount has `hit`, without blocking the window +/// ([`present_within`]). `None` means "not there" or "no answer"; either way +/// the daemon is the fallback. async fn mounted_target_within(mountpoint: PathBuf, hit: SearchHit) -> Option { - let (tx, rx) = async_channel::bounded(1); - std::thread::spawn(move || { - let _ = tx.send_blocking(mounted_target(&mountpoint, &hit)); - }); - match glib::future_with_timeout(STAT_TIMEOUT, rx.recv()).await { - Ok(Ok(target)) => target, - Ok(Err(_)) => None, - Err(_) => { - tracing::warn!("the mount did not answer in time; opening through the daemon"); - None - } - } + present_within(mounted_or_relative(&mountpoint, &hit)).await } impl Ui { diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index 8accc27..d177c80 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -361,8 +361,9 @@ fn open_manager(args: &[&str]) { .and_then(|p| p.parent().map(|d| d.join("pdfs-app"))) .filter(|p| p.exists()) .unwrap_or_else(|| PathBuf::from("pdfs-app")); - if let Err(e) = Command::new(&exe).args(args).spawn() { - tracing::error!("failed to launch {}: {e}", exe.display()); + match Command::new(&exe).args(args).spawn() { + Ok(child) => pdfs_core::opener::reap(child), + Err(e) => tracing::error!("failed to launch {}: {e}", exe.display()), } } diff --git a/docs/BUGS.md b/docs/BUGS.md index 784f9a9..ca7f099 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B243](#b243--a-hung-mount-freezes-the-app-when-a-file-is-opened) | A hung mount freezes the app when a file is opened | | [B242](#b242--opening-a-large-photo-freezes-the-app) | Opening a large photo freezes the app | | [B241](#b241--every-closed-photo-viewer-stays-in-memory-with-its-photo) | Every closed photo viewer stays in memory with its photo | | [B240](#b240--pdfs-mount-can-be-core-dumped-with-the-keys-in-memory) | pdfs mount can be core-dumped with the keys in memory | @@ -345,6 +346,34 @@ a live account since. Newest first. +### B243 — A hung mount freezes the app when a file is opened + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.5.3, and the unreaped child processes +in section 3). +**Where:** `present_within` in `crates/pdfs-gui/src/activation.rs`; `download_and_open` and +`open_entry_with` in `crates/pdfs-gui/src/app/pages/browser.rs`; `open_path` in +`crates/pdfs-gui/src/app/main.rs`; `reap` in `crates/pdfs-core/src/opener.rs` + +**Repro:** Let the mount hang (a stalled link with a request stuck in the kernel), then +double-click a file in the app's browser, or open the Proton Drive location. Separately, open a +few files from the app and look for `` children of `pdfs-app` in `ps`. + +**Cause.** Opening a file asked the mount whether it had the file with a `stat` on the GTK main +thread, and opening a location asked whether it was a folder the same way. A hung mount never +answers, so the whole app hung with it. The search launcher already asked on a worker with a +2 s limit. Separately, nothing waited for the applications the app, the tray and the player +button started, so each one stayed a zombie until the app quit. + +**Fix.** The browser's Open and Open With ask through `present_within`, which the launcher now +shares: a thread of its own, given up on after 2 s, with the daemon as the fallback. Opening a +location runs on a thread. Every child the GUI starts is waited for on a thread of its own. + +**Test:** `opener::tests::a_reaped_child_does_not_stay_a_zombie`; the mount checks have no +automated test, as the GUI tests run without a display. + +--- + ### B242 — Opening a large photo freezes the app **Status:** Fixed, not verified live From 47934b7ddb1ef8b1c763b904a91ccedec8bbe482 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:12:41 +0200 Subject: [PATCH 29/44] fix(core,fuse,gui): date photos by their EXIF when nothing else does A Takeout photo with no sidecar and no date in its name was filed under the day of the import, and the app dated uploads by modification time. A new `exiftime` module reads DateTimeOriginal (then DateTimeDigitized) with its offset. Takeout uses it for undated photos it copies out for a thumbnail, and the daemon uses it for app uploads before falling back to the modification time (B244). --- Cargo.lock | 1 + crates/pdfs-core/Cargo.toml | 1 + crates/pdfs-core/src/exiftime.rs | 133 ++++++++++++++++++++++++ crates/pdfs-core/src/lib.rs | 1 + crates/pdfs-core/src/takeout.rs | 2 +- crates/pdfs-fuse/src/control.rs | 11 ++ crates/pdfs-fuse/src/takeout.rs | 8 ++ crates/pdfs-gui/src/app/pages/photos.rs | 8 +- docs/BUGS.md | 30 ++++++ 9 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 crates/pdfs-core/src/exiftime.rs diff --git a/Cargo.lock b/Cargo.lock index c571294..f2f1b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3497,6 +3497,7 @@ dependencies = [ "dbus-secret-service-keyring-store", "directories", "ignore", + "kamadak-exif", "keyring-core", "libc", "parking_lot", diff --git a/crates/pdfs-core/Cargo.toml b/crates/pdfs-core/Cargo.toml index 0804486..82acd1a 100644 --- a/crates/pdfs-core/Cargo.toml +++ b/crates/pdfs-core/Cargo.toml @@ -30,3 +30,4 @@ async-trait.workspace = true libc.workspace = true zeroize.workspace = true base64.workspace = true +exif = { version = "0.6", package = "kamadak-exif" } diff --git a/crates/pdfs-core/src/exiftime.rs b/crates/pdfs-core/src/exiftime.rs new file mode 100644 index 0000000..a7cb391 --- /dev/null +++ b/crates/pdfs-core/src/exiftime.rs @@ -0,0 +1,133 @@ +//! When a photo was taken, as its own EXIF tags say. +//! +//! The fallback for a photo nothing else dates: a Takeout photo without a +//! sidecar or a date in its name, and a photo uploaded from the app. Without +//! it the first was filed under the moment of the import and the second under +//! the file's modification time, which a copy resets. + +use std::io::{BufRead, Seek}; +use std::path::Path; + +use crate::takeout::days_from_civil; + +/// The capture time in the EXIF of the file at `path`, in epoch seconds. +pub fn capture_time_of(path: &Path) -> Option { + let file = std::fs::File::open(path).ok()?; + capture_time(&mut std::io::BufReader::new(file)) +} + +/// The capture time in the EXIF `reader` holds: `DateTimeOriginal`, then +/// `DateTimeDigitized`, each adjusted by its `OffsetTime…` tag when the camera +/// wrote one. Without an offset the time is read as UTC, as a date in a file +/// name is (`takeout::capture_time_from_name`). +pub fn capture_time(reader: &mut (impl BufRead + Seek)) -> Option { + let exif = exif::Reader::new().read_from_container(reader).ok()?; + let ascii = |tag: exif::Tag| -> Option { + let field = exif.get_field(tag, exif::In::PRIMARY)?; + match &field.value { + exif::Value::Ascii(parts) => parts.first().map(|part| { + String::from_utf8_lossy(part) + .trim_matches('\0') + .trim() + .to_string() + }), + _ => None, + } + }; + [ + (exif::Tag::DateTimeOriginal, exif::Tag::OffsetTimeOriginal), + (exif::Tag::DateTimeDigitized, exif::Tag::OffsetTimeDigitized), + ] + .into_iter() + .find_map(|(time, offset)| { + let local = parse_exif_time(&ascii(time)?)?; + let offset = ascii(offset).and_then(|o| parse_offset(&o)).unwrap_or(0); + Some(local - offset) + }) +} + +/// `YYYY:MM:DD HH:MM:SS` as epoch seconds, read as UTC. `None` for the +/// all-zero or blank value cameras write when they do not know. +fn parse_exif_time(text: &str) -> Option { + let bytes = text.as_bytes(); + if bytes.len() < 19 || bytes[4] != b':' || bytes[7] != b':' || bytes[13] != b':' { + return None; + } + let number = |range: std::ops::Range| text.get(range)?.parse::().ok(); + let (year, month, day) = (number(0..4)?, number(5..7)?, number(8..10)?); + let (hour, minute, second) = (number(11..13)?, number(14..16)?, number(17..19)?); + if !(1800..=9999).contains(&year) + || !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || hour > 23 + || minute > 59 + || second > 60 + { + return None; + } + Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second) +} + +/// `+HH:MM` or `-HH:MM` as seconds east of UTC. +fn parse_offset(text: &str) -> Option { + let sign = match text.as_bytes().first()? { + b'+' => 1, + b'-' => -1, + _ => return None, + }; + let (hours, minutes) = text.get(1..)?.split_once(':')?; + let (hours, minutes) = (hours.parse::().ok()?, minutes.parse::().ok()?); + (hours <= 14 && minutes < 60).then_some(sign * (hours * 3600 + minutes * 60)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_exif_time_reads_as_utc_and_its_offset_moves_it() { + // 2023-06-01 12:00:00 UTC. + let noon = 1_685_620_800; + assert_eq!(parse_exif_time("2023:06:01 12:00:00"), Some(noon)); + assert_eq!(parse_offset("+02:00"), Some(7200)); + assert_eq!(parse_offset("-05:30"), Some(-19_800)); + assert_eq!( + parse_exif_time("2023:06:01 14:00:00").map(|t| t - parse_offset("+02:00").unwrap()), + Some(noon) + ); + } + + #[test] + fn an_unknown_exif_time_is_no_time() { + assert_eq!(parse_exif_time("0000:00:00 00:00:00"), None); + assert_eq!(parse_exif_time(" : : : : "), None); + assert_eq!(parse_exif_time(""), None); + assert_eq!(parse_offset("02:00"), None); + } + + #[test] + fn a_photo_is_dated_by_its_original_time_and_offset() { + let ascii = |tag, text: &str| exif::Field { + tag, + ifd_num: exif::In::PRIMARY, + value: exif::Value::Ascii(vec![text.as_bytes().to_vec()]), + }; + let taken = ascii(exif::Tag::DateTimeOriginal, "2023:06:01 14:00:00"); + let offset = ascii(exif::Tag::OffsetTimeOriginal, "+02:00"); + let digitized = ascii(exif::Tag::DateTimeDigitized, "2024:01:01 00:00:00"); + let mut writer = exif::experimental::Writer::new(); + writer.push_field(&taken); + writer.push_field(&offset); + writer.push_field(&digitized); + let mut tiff = std::io::Cursor::new(Vec::new()); + writer.write(&mut tiff, false).unwrap(); + tiff.set_position(0); + assert_eq!(capture_time(&mut tiff), Some(1_685_620_800)); + } + + #[test] + fn a_file_without_exif_has_no_capture_time() { + let mut png = std::io::Cursor::new(b"\x89PNG\r\n\x1a\n".to_vec()); + assert_eq!(capture_time(&mut png), None); + } +} diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index f429310..73a84a7 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod config; pub mod control; pub mod db; pub mod error; +pub mod exiftime; pub mod kernelkey; pub mod localindex; pub mod menu; diff --git a/crates/pdfs-core/src/takeout.rs b/crates/pdfs-core/src/takeout.rs index 669febf..df9cf40 100644 --- a/crates/pdfs-core/src/takeout.rs +++ b/crates/pdfs-core/src/takeout.rs @@ -608,7 +608,7 @@ pub fn capture_time_from_name(name: &str) -> Option { /// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's /// `days_from_civil`). Avoids pulling a date crate in for one conversion. -fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { +pub(crate) fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { let year = year - i64::from(month <= 2); let era = if year >= 0 { year } else { year - 399 } / 400; let year_of_era = year - era * 400; diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 5fe3bb9..1e0f5ee 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -911,6 +911,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U ))), Ok((file, len)) => { let photos = core.photos(); + // Unless the caller knows better: the photo's own EXIF, + // then the file's modification time, which a copy resets. + let capture_time = capture_time.or_else(|| { + let path = Path::new(&source_path); + pdfs_core::exiftime::capture_time_of(path).or_else(|| { + let modified = + std::fs::metadata(path).and_then(|m| m.modified()).ok()?; + let since = modified.duration_since(std::time::UNIX_EPOCH).ok()?; + Some(since.as_secs() as i64) + }) + }); let metadata = proton_drive_rs::PhotoUploadMetadata { capture_time, ..Default::default() diff --git a/crates/pdfs-fuse/src/takeout.rs b/crates/pdfs-fuse/src/takeout.rs index a3031d6..9194731 100644 --- a/crates/pdfs-fuse/src/takeout.rs +++ b/crates/pdfs-fuse/src/takeout.rs @@ -363,6 +363,14 @@ impl Core { let uploaded = match &staged { Some(path) => { let thumbnails = self.upload_thumbnails(path, &photo.name); + // No sidecar and no date in the name: the photo's own + // EXIF, before Drive files it under today. + let metadata = PhotoUploadMetadata { + capture_time: metadata + .capture_time + .or_else(|| pdfs_core::exiftime::capture_time_of(path)), + ..metadata + }; std::fs::File::open(path) .map_err(|e| { CoreError::internal(format!("open {}: {e}", path.display())) diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 3015e9b..e5b0322 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -1379,11 +1379,9 @@ pub(crate) fn wire_gallery( "tiff" | "tif" => "image/tiff", _ => "image/jpeg", }; - let capture_time = std::fs::metadata(&path) - .and_then(|m| m.modified()) - .ok() - .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64); + // The daemon dates the photo: by its EXIF, or by the file's + // modification time when it has none. + let capture_time = None; ui.busy_begin(); let rx = spawn_request( diff --git a/docs/BUGS.md b/docs/BUGS.md index ca7f099..b672a2f 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B244](#b244--photos-without-a-sidecar-or-a-dated-name-are-filed-under-the-upload-day) | Photos without a sidecar or a dated name are filed under the upload day | | [B243](#b243--a-hung-mount-freezes-the-app-when-a-file-is-opened) | A hung mount freezes the app when a file is opened | | [B242](#b242--opening-a-large-photo-freezes-the-app) | Opening a large photo freezes the app | | [B241](#b241--every-closed-photo-viewer-stays-in-memory-with-its-photo) | Every closed photo viewer stays in memory with its photo | @@ -346,6 +347,35 @@ a live account since. Newest first. +### B244 — Photos without a sidecar or a dated name are filed under the upload day + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.3). +**Where:** `crates/pdfs-core/src/exiftime.rs`; the `UploadPhoto` request in +`crates/pdfs-fuse/src/control.rs`; `Core::import_takeout` in `crates/pdfs-fuse/src/takeout.rs`; +the upload in `crates/pdfs-gui/src/app/pages/photos.rs` + +**Repro:** +- Import a Takeout export in which a photo has no sidecar and no date in its name, such as + `DSC01234.JPG`. It lands in the timeline under the day of the import. +- Copy a photo from a camera card with a tool that does not keep modification times, and upload + it from the app's gallery. It lands under the day of the copy. + +**Cause.** Nothing read a photo's own EXIF for its capture time. A Takeout photo fell back to its +sidecar, then to a date in its name, then to nothing, which Drive files as "now". The app sent +the file's modification time. + +**Fix.** `exiftime::capture_time` reads `DateTimeOriginal`, then `DateTimeDigitized`, each moved +by its `OffsetTime…` tag when present and read as UTC otherwise, like a date in a file name. A +Takeout photo with no other date uses it when the photo was copied out of the archive for its +thumbnail (B236). The app no longer sends a time; the daemon dates the upload by EXIF, then by +the modification time. + +**Test:** `exiftime::tests` (four cases, among them +`a_photo_is_dated_by_its_original_time_and_offset`). + +--- + ### B243 — A hung mount freezes the app when a file is opened **Status:** Fixed, not verified live From aa0c60f5a29fbc10ab825802a46a2cbb97c01500 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:14:17 +0200 Subject: [PATCH 30/44] docs(changelog): list the second round of 3.5.0 fixes --- docs/CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f56578d..73e2815 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -50,8 +50,49 @@ upgrade takes a moment. own metadata file took a similarly named one's, and an edited copy took its original's name. An edited copy now takes its original's date and keeps its own name (B225). - **Thumbnails of portrait photos lay on their side**, here and in Proton's own apps (B226). +- **A write could be lost to a power cut after the program saving it had closed the file.** Saving + a change into the upload queue is now written through to disk (B229). +- **A partly written file whose upload answer was lost came back as a conflict copy of itself.** + The retry now recognises its own upload (B228). +- **A file or folder created offline could merge with another device's of the same name** made + while this computer was offline (B230). +- **A large upload under a low upload limit never finished.** It ran out of time on every try, + took the drive offline and started over. Its time limit now allows for its share of the limit + (B231). +- **Every reconnect sent all failed uploads again at once,** including those Proton had refused + for a full quota or missing access, and failed uploads were retried no later than every two + minutes. Refused uploads now keep their wait, which grows to five minutes, and retries are + spread out (B237). +- **Signing out left the session valid on Proton.** It is now ended there too; `pdfs logout` says + so when Proton cannot be reached (B233). +- **Any folder could be synced, the home folder included,** which uploaded the app's own cache + and walked into the drive itself. The root of the file system, the home folder and the folders + above it, the Proton Drive folder, the app's own data and folders inside or around another + synced folder are now refused, also when restoring another computer's folders (B234). +- **Names with `&` or `<` showed as empty rows in the app**, and some names were drawn as + formatting, for example shared items, computer names and link addresses (B232). +- **Photos imported from Google Photos, re-dated, or uploaded from the app had no thumbnail** in + Proton's apps (B236). +- **Photos with no other date were filed under the day they were uploaded.** Their own EXIF date + is now used, for Google Photos imports and for uploads from the app (B244). +- **Scanning a Google Photos export with a large folder took up to half a minute.** 10,000 photos + now take a tenth of a second (B235). +- **Every photo opened in the viewer stayed in memory**, with its full-size image, until the app + quit. The Share dialog leaked the same way (B241). +- **Opening a large photo froze the app** while it was decoded (B242). +- **The app froze when opening a file while the drive did not respond.** It now falls back to + downloading the file after two seconds. Applications the app started also no longer linger as + zombie processes (B243). +- **`pdfs import-google-photos --wait` and `pdfs redate-photos --wait` succeeded when the run + failed.** They now exit with an error when the run failed, was cancelled or left photos failed, + and the app shows why an import failed (B239). +- **`pdfs mount`, `login`, `unlock` and `logout` could be core-dumped with the account's keys in + memory.** Only the background service was protected. A file named like an option, such as + `--json`, also broke the file manager's "Keep offline" script (B240). ### Changed +- **Draining a large batch of offline changes no longer slows down with its size.** The queue is + indexed by name (schema 43, B238). - **Release binaries are stripped of their symbols**: `pdfs` goes from 43 to 35 MB, `pdfs-app` from 24 to 19 MB and `pdfs-tray` from 12 to 8 MB. From 4bf176a631f315d869755973e150220bdee3dae6 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:17:53 +0200 Subject: [PATCH 31/44] fix(packaging): accept watchdog pings from the daemon only (B245) --- docs/BUGS.md | 22 ++++++++++++++++++++++ packaging/proton-drive.service | 13 ++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/BUGS.md b/docs/BUGS.md index b672a2f..ceb79e3 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B245](#b245--any-child-of-the-daemon-can-answer-the-watchdog-for-it) | Any child of the daemon can answer the watchdog for it | | [B244](#b244--photos-without-a-sidecar-or-a-dated-name-are-filed-under-the-upload-day) | Photos without a sidecar or a dated name are filed under the upload day | | [B243](#b243--a-hung-mount-freezes-the-app-when-a-file-is-opened) | A hung mount freezes the app when a file is opened | | [B242](#b242--opening-a-large-photo-freezes-the-app) | Opening a large photo freezes the app | @@ -347,6 +348,27 @@ a live account since. Newest first. +### B245 — Any child of the daemon can answer the watchdog for it + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, systemd unit). +**Where:** `packaging/proton-drive.service` + +**Repro:** A child of the daemon, such as `ffmpeg` making a video thumbnail, inherits +`NOTIFY_SOCKET` and can send `WATCHDOG=1` while the daemon itself is wedged. + +**Cause.** The unit set `NotifyAccess=all`, which accepts notifications from every process of the +unit. The watchdog exists to restart a daemon that stopped answering, and any child could keep it +from doing that. + +**Fix.** `NotifyAccess=main`: only the daemon's own process is heard. The rest of the hardening +the audit listed was left out on purpose. In a user unit every seccomp-based option implies +`NoNewPrivileges`, which stops the setuid `fusermount3` from mounting. + +**Test:** none automated; `systemd-analyze verify --user` accepts the unit. + +--- + ### B244 — Photos without a sidecar or a dated name are filed under the upload day **Status:** Fixed, not verified live diff --git a/packaging/proton-drive.service b/packaging/proton-drive.service index eddceae..ada69ef 100644 --- a/packaging/proton-drive.service +++ b/packaging/proton-drive.service @@ -8,10 +8,17 @@ PartOf=graphical-session.target [Service] # Deliberately not Type=notify: the daemon waits for a stored login before it # mounts anything, and a machine that boots before the user logs in would then -# fail the unit on the start timeout. NotifyAccess=all gets the daemon a -# NOTIFY_SOCKET all the same, which is all the watchdog below needs. +# fail the unit on the start timeout. NotifyAccess=main gets the daemon a +# NOTIFY_SOCKET all the same, which is all the watchdog below needs, and keeps +# its children (fusermount3, ffmpeg, exiftool) from answering the watchdog for +# it. "all" let any of them do so. +# +# Most sandboxing options are left out on purpose: in a user unit, every +# seccomp-based one (SystemCallFilter, LockPersonality, RestrictAddressFamilies +# and the like) implies NoNewPrivileges, which stops the setuid fusermount3 +# from mounting anything. Type=simple -NotifyAccess=all +NotifyAccess=main # The daemon waits for a stored login, mounts ~/ProtonDrive, and keeps it # mounted. SIGTERM (from `systemctl --user stop`) triggers a clean lazy unmount. ExecStart=/usr/bin/pdfs daemon From 7384d8e97d6b48c11db729adec56061f9618aab6 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:31:40 +0200 Subject: [PATCH 32/44] fix(gui): read the new session off the main thread after sign-in (B246) --- crates/pdfs-gui/src/app/pages/login.rs | 8 +++++++- docs/BUGS.md | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs index 8200a8c..9c1151c 100644 --- a/crates/pdfs-gui/src/app/pages/login.rs +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -199,7 +199,13 @@ pub(crate) fn wire_login(ui: &Rc) { ui.login.login_status.set_text(""); ui.login.password.set_text(""); // Cache the new identity so `refresh` never hits the keyring. - *ui.session.borrow_mut() = auth::load().ok(); + // Read on a worker: a keyring that has to be unlocked first + // answers only once the user has, and the window must not + // freeze until then. + *ui.session.borrow_mut() = gio::spawn_blocking(|| auth::load().ok()) + .await + .ok() + .flatten(); // Enable+start the mount service now that we have a session. let _ = gio::spawn_blocking(service::enable_start).await; refresh(&ui); diff --git a/docs/BUGS.md b/docs/BUGS.md index ceb79e3..a964dc0 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B246](#b246--signing-in-with-a-locked-keyring-freezes-the-app-window) | Signing in with a locked keyring freezes the app window | | [B245](#b245--any-child-of-the-daemon-can-answer-the-watchdog-for-it) | Any child of the daemon can answer the watchdog for it | | [B244](#b244--photos-without-a-sidecar-or-a-dated-name-are-filed-under-the-upload-day) | Photos without a sidecar or a dated name are filed under the upload day | | [B243](#b243--a-hung-mount-freezes-the-app-when-a-file-is-opened) | A hung mount freezes the app when a file is opened | @@ -348,6 +349,26 @@ a live account since. Newest first. +### B246 — Signing in with a locked keyring freezes the app window + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.5.4). +**Where:** `sign_in` in `crates/pdfs-gui/src/app/pages/login.rs` + +**Repro:** Sign in from the app while the system keyring is locked. The keyring asks for its +password, and the app window stops redrawing until that prompt is answered. + +**Cause.** After a successful sign-in the app read the stored session back from the keyring on +the GTK main thread. A locked keyring answers that read only once it is unlocked. + +**Fix.** The read runs in `gio::spawn_blocking`. The read at startup was left as it is: it runs +before any window exists, and the launch actions (`--page`, `--confirm-stop`) need the window +built in the same turn. + +**Test:** none automated; the GUI tests run without a display. + +--- + ### B245 — Any child of the daemon can answer the watchdog for it **Status:** Fixed, not verified live From cc9db51b78a20cb21763ce3d7457dde53ca9dc31 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:31:40 +0200 Subject: [PATCH 33/44] fix(gui): translate the tray's transfer status lines (B247) --- crates/pdfs-gui/src/main.rs | 16 +- docs/BUGS.md | 20 + po/de.po | 847 ++++++++++++++++++------------------ po/en_GB.po | 847 ++++++++++++++++++------------------ po/es.po | 847 ++++++++++++++++++------------------ po/fr.po | 847 ++++++++++++++++++------------------ po/it.po | 847 ++++++++++++++++++------------------ po/nl.po | 847 ++++++++++++++++++------------------ po/pdfs.pot | 847 ++++++++++++++++++------------------ po/pl.po | 847 ++++++++++++++++++------------------ po/pt_BR.po | 847 ++++++++++++++++++------------------ 11 files changed, 3903 insertions(+), 3756 deletions(-) diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index d177c80..083466e 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -109,7 +109,8 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String { &[("title", &j.title), ("done", &done), ("total", &total)], ) } - Some(j) => format!("{}…", j.title), + // Translators: {title} names a running job whose size is not known yet. + Some(j) => gettext_f("{title}…", &[("title", &j.title)]), None => String::new(), }, [t] => { @@ -118,10 +119,19 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String { TransferDirection::Upload => "↑", }; if t.bytes_total == 0 { - format!("{arrow} {}…", t.name) + // Translators: one transfer of unknown size; {arrow} is ↑ or ↓, {name} a file name. + gettext_f("{arrow} {name}…", &[("arrow", arrow), ("name", &t.name)]) } else { let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100); - format!("{arrow} {} ({pct}%)", t.name) + // Translators: one transfer; {arrow} is ↑ or ↓, {name} a file name, {percent} how much is done. + gettext_f( + "{arrow} {name} ({percent}%)", + &[ + ("arrow", arrow), + ("name", &t.name), + ("percent", &pct.to_string()), + ], + ) } } _ => { diff --git a/docs/BUGS.md b/docs/BUGS.md index a964dc0..f37c358 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B247](#b247--tray-status-lines-are-not-translated) | Tray status lines are not translated | | [B246](#b246--signing-in-with-a-locked-keyring-freezes-the-app-window) | Signing in with a locked keyring freezes the app window | | [B245](#b245--any-child-of-the-daemon-can-answer-the-watchdog-for-it) | Any child of the daemon can answer the watchdog for it | | [B244](#b244--photos-without-a-sidecar-or-a-dated-name-are-filed-under-the-upload-day) | Photos without a sidecar or a dated name are filed under the upload day | @@ -349,6 +350,25 @@ a live account since. Newest first. +### B247 — Tray status lines are not translated + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, tray strings). +**Where:** `sync_line` in `crates/pdfs-gui/src/main.rs` + +**Repro:** Run the tray in German or French while one file uploads. The tray's status line reads +`↑ report.pdf (45%)`, with the percent sign as English writes it. + +**Cause.** Three status lines of the tray were built with `format!`, outside gettext, so no +language could adapt them. + +**Fix.** They go through `gettext_f` and are translated in every catalog. German and French put +a space before the percent sign, as they already do for the storage quota. + +**Test:** `msgfmt --check --statistics` on every catalog reports only translated messages. + +--- + ### B246 — Signing in with a locked keyring freezes the app window **Status:** Fixed, not verified live diff --git a/po/de.po b/po/de.po index 2f70772..44d560a 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-16 11:55+0200\n" +"POT-Creation-Date: 2026-10-16 12:29+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -31,7 +31,7 @@ msgstr[1] "vor {n} Min." #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:2774 +#: crates/pdfs-gui/src/app/pages/photos.rs:2772 msgid "%H:%M" msgstr "%H:%M" @@ -57,87 +57,87 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:764 +#: crates/pdfs-gui/src/app/main.rs:767 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:833 -#: crates/pdfs-gui/src/app/pages/browser.rs:1238 -#: crates/pdfs-gui/src/app/pages/browser.rs:2104 -#: crates/pdfs-gui/src/app/pages/browser.rs:2181 +#: crates/pdfs-gui/src/app/pages/browser.rs:1239 +#: crates/pdfs-gui/src/app/pages/browser.rs:2105 +#: crates/pdfs-gui/src/app/pages/browser.rs:2182 #: crates/pdfs-gui/src/app/pages/locations.rs:1667 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:767 crates/pdfs-gui/src/app/main.rs:1412 +#: crates/pdfs-gui/src/app/main.rs:770 crates/pdfs-gui/src/app/main.rs:1415 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:845 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:772 +#: crates/pdfs-gui/src/app/main.rs:775 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:484 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:777 +#: crates/pdfs-gui/src/app/main.rs:780 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:780 +#: crates/pdfs-gui/src/app/main.rs:783 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:489 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:781 +#: crates/pdfs-gui/src/app/main.rs:784 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:783 +#: crates/pdfs-gui/src/app/main.rs:786 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1109 +#: crates/pdfs-gui/src/app/main.rs:1112 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1132 +#: crates/pdfs-gui/src/app/main.rs:1135 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1133 +#: crates/pdfs-gui/src/app/main.rs:1136 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1134 +#: crates/pdfs-gui/src/app/main.rs:1137 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1135 +#: crates/pdfs-gui/src/app/main.rs:1138 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1136 +#: crates/pdfs-gui/src/app/main.rs:1139 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1223 +#: crates/pdfs-gui/src/app/main.rs:1226 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1231 +#: crates/pdfs-gui/src/app/main.rs:1234 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -149,23 +149,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1236 +#: crates/pdfs-gui/src/app/main.rs:1239 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1240 +#: crates/pdfs-gui/src/app/main.rs:1243 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1247 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1249 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1256 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -190,7 +190,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1264 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1272 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1277 +#: crates/pdfs-gui/src/app/main.rs:1280 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1272 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1277 +#: crates/pdfs-gui/src/app/main.rs:1280 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1272 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1277 +#: crates/pdfs-gui/src/app/main.rs:1280 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1272 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1277 +#: crates/pdfs-gui/src/app/main.rs:1280 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1272 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1277 +#: crates/pdfs-gui/src/app/main.rs:1280 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1272 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1277 +#: crates/pdfs-gui/src/app/main.rs:1280 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -34,7 +34,7 @@ msgstr[1] "" #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:2774 +#: crates/pdfs-gui/src/app/pages/photos.rs:2772 msgid "%H:%M" msgstr "" @@ -60,87 +60,87 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:764 +#: crates/pdfs-gui/src/app/main.rs:767 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:833 -#: crates/pdfs-gui/src/app/pages/browser.rs:1238 -#: crates/pdfs-gui/src/app/pages/browser.rs:2104 -#: crates/pdfs-gui/src/app/pages/browser.rs:2181 +#: crates/pdfs-gui/src/app/pages/browser.rs:1239 +#: crates/pdfs-gui/src/app/pages/browser.rs:2105 +#: crates/pdfs-gui/src/app/pages/browser.rs:2182 #: crates/pdfs-gui/src/app/pages/locations.rs:1667 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:767 crates/pdfs-gui/src/app/main.rs:1412 +#: crates/pdfs-gui/src/app/main.rs:770 crates/pdfs-gui/src/app/main.rs:1415 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:845 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:772 +#: crates/pdfs-gui/src/app/main.rs:775 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:484 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:777 +#: crates/pdfs-gui/src/app/main.rs:780 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:780 +#: crates/pdfs-gui/src/app/main.rs:783 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:489 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:781 +#: crates/pdfs-gui/src/app/main.rs:784 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:783 +#: crates/pdfs-gui/src/app/main.rs:786 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1109 +#: crates/pdfs-gui/src/app/main.rs:1112 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1132 +#: crates/pdfs-gui/src/app/main.rs:1135 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1133 +#: crates/pdfs-gui/src/app/main.rs:1136 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1134 +#: crates/pdfs-gui/src/app/main.rs:1137 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1135 +#: crates/pdfs-gui/src/app/main.rs:1138 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1136 +#: crates/pdfs-gui/src/app/main.rs:1139 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1223 +#: crates/pdfs-gui/src/app/main.rs:1226 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1231 +#: crates/pdfs-gui/src/app/main.rs:1234 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -148,23 +148,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1236 +#: crates/pdfs-gui/src/app/main.rs:1239 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1240 +#: crates/pdfs-gui/src/app/main.rs:1243 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1247 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1249 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1256 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -177,21 +177,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1264 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1272 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1277 +#: crates/pdfs-gui/src/app/main.rs:1280 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1272 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -211,7 +211,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1277 +#: crates/pdfs-gui/src/app/main.rs:1280 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1272 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1277 +#: crates/pdfs-gui/src/app/main.rs:1280 msgid "" "The street map, when turned on, comes from OpenFreeMap in the Date: Fri, 16 Oct 2026 12:31:51 +0200 Subject: [PATCH 34/44] docs(changelog): add B245 to B247 --- docs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 73e2815..b165c11 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -89,6 +89,11 @@ upgrade takes a moment. - **`pdfs mount`, `login`, `unlock` and `logout` could be core-dumped with the account's keys in memory.** Only the background service was protected. A file named like an option, such as `--json`, also broke the file manager's "Keep offline" script (B240). +- **Signing in while the system keyring was locked froze the app window** until the keyring was + unlocked (B246). +- **A program the background service started could keep it from being restarted when it hung** + (B245). +- **The tray's transfer line was not translated** (B247). ### Changed - **Draining a large batch of offline changes no longer slows down with its size.** The queue is From 7cd516a1648097417172cfa0a0f0d8d06b25f0df Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:58:06 +0200 Subject: [PATCH 35/44] chore(release): 3.5.0 --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- docs/CHANGELOG.md | 3 +++ packaging/PKGBUILD | 2 +- po/pdfs.pot | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f2f1b81..0bac2c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "pdfs-cli" -version = "3.4.1" +version = "3.5.0" dependencies = [ "anyhow", "clap", @@ -3490,7 +3490,7 @@ dependencies = [ [[package]] name = "pdfs-core" -version = "3.4.1" +version = "3.5.0" dependencies = [ "async-trait", "base64 0.23.1", @@ -3517,7 +3517,7 @@ dependencies = [ [[package]] name = "pdfs-fuse" -version = "3.4.1" +version = "3.5.0" dependencies = [ "async-trait", "base64 0.23.1", @@ -3539,7 +3539,7 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "3.4.1" +version = "3.5.0" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index 805ac5e..82ae7b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] -version = "3.4.1" +version = "3.5.0" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b165c11..88d6e29 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,8 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] +## [3.5.0] — 2026-10-16 + Schema: **43**. Queued changes get an index by kind and name. Nothing is rewritten, and the upgrade takes a moment. @@ -1894,6 +1896,7 @@ 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 by a new POSIX compliance suite for the filesystem. +[3.5.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.5.0 [3.4.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.1 [3.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.0 [3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0 diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index b939366..5560b28 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -9,7 +9,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=3.4.1 +pkgver=3.5.0 pkgrel=1 pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" arch=('x86_64') diff --git a/po/pdfs.pot b/po/pdfs.pot index fdfbb7c..714674b 100644 --- a/po/pdfs.pot +++ b/po/pdfs.pot @@ -6,7 +6,7 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: proton-drive-linux 3.4.1\n" +"Project-Id-Version: proton-drive-linux 3.5.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" "POT-Creation-Date: 2026-10-16 12:29+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" From 05b84bf7963ed695875f0b655c07aa1e409c37d3 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 14:48:32 +0200 Subject: [PATCH 36/44] fix(fuse,core): keep a mirrored folder safe when another computer syncs it A mirrored folder assumed it was the only client writing to its folder on Drive. With a second computer syncing the same folder, edits could be lost: - The first sync took a same-size file at the same path for the same file. It now records the baseline only if the local file's SHA-1 matches Drive's digest, and otherwise keeps a conflict copy (B248). - An upload replaced a revision another client had committed since the pass looked. The upload now checks Drive's active revision first, and an edit that read back with someone else's digest is left for the next pass to keep as a conflict copy (B249). - A download recorded a revision committed while it ran as synced, so that edit never arrived. The baseline now records the revision the pass planned to download (B250). - Conflict copies now carry the computer's hostname, so two computers do not make the same name on Drive (B251). The baseline gains the revision id it was synced at (schema 44), and a new revision counts as a remote change even at the same mtime and size. --- crates/pdfs-core/src/db/migrations.rs | 33 +- crates/pdfs-core/src/db/sync.rs | 35 +- crates/pdfs-core/src/db/tests.rs | 70 ++++ crates/pdfs-fuse/src/lib.rs | 104 +++++- crates/pdfs-fuse/src/sim/daemon.rs | 210 ++++++++++++ crates/pdfs-fuse/src/sweep.rs | 42 ++- crates/pdfs-fuse/src/sync.rs | 439 ++++++++++++++++++++------ crates/pdfs-fuse/src/sync/model.rs | 80 ++++- crates/pdfs-fuse/src/sync/planner.rs | 151 +++++++-- crates/pdfs-fuse/src/takeout.rs | 2 +- 10 files changed, 998 insertions(+), 168 deletions(-) diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index 1689224..08b946b 100644 --- a/crates/pdfs-core/src/db/migrations.rs +++ b/crates/pdfs-core/src/db/migrations.rs @@ -11,7 +11,7 @@ use super::Db; use crate::Result; /// Current schema version. Bump on every forward migration added below. -pub(super) const SCHEMA_VERSION: i64 = 43; +pub(super) const SCHEMA_VERSION: i64 = 44; impl Db { pub(super) fn migrate(&self) -> Result<()> { @@ -450,6 +450,23 @@ impl Db { tx.execute_batch(MIGRATION_V43)?; } } + if current < 44 { + // Same guards as V26-V42. + let has_column: bool = tx.query_row( + "SELECT COUNT(*) FROM pragma_table_info('sync_entry') \ + WHERE name = 'remote_revision_id'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + let has_entries: bool = tx.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + if has_entries && !has_column { + tx.execute_batch(MIGRATION_V44)?; + } + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -1351,3 +1368,17 @@ ALTER TABLE pending_op ADD COLUMN sent_to TEXT; const MIGRATION_V43: &str = " CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name); "; + +/// Schema v44: a synced file's baseline remembers the revision it was synced +/// at. +/// +/// A mirrored file edited here is uploaded as a new revision of whatever is +/// current on Drive, and another computer syncing the same folder may have +/// committed one since this pass looked. The revision id is what the upload +/// checks first, so that edit becomes a conflict copy instead of being +/// replaced (B249). `NULL` means "this row predates the column", and the check +/// falls back to the modification time and size; each row gains a real value +/// the next time its path is synced. +const MIGRATION_V44: &str = " +ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT; +"; diff --git a/crates/pdfs-core/src/db/sync.rs b/crates/pdfs-core/src/db/sync.rs index af93b07..960c00a 100644 --- a/crates/pdfs-core/src/db/sync.rs +++ b/crates/pdfs-core/src/db/sync.rs @@ -45,6 +45,12 @@ pub struct StoredSyncEntry { pub local_mtime_ns: Option, pub remote_rev: Option, pub remote_hash: Option, + /// The id of the remote revision this row was synced at. A mirror upload + /// checks it before it supersedes what Drive holds, because another client + /// may have committed a revision since (`docs/BUGS.md` B249). `None` on a + /// row written before schema 44, or by a pass that did not learn it; the + /// check then falls back to `remote_rev` and `remote_hash`. + pub remote_revision_id: Option, } impl Db { @@ -188,7 +194,7 @@ impl Db { let conn = self.read(); let mut stmt = conn.prepare( "SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash, - local_mtime_ns + local_mtime_ns, remote_revision_id FROM sync_entry WHERE folder_id = ?1", )?; let rows = stmt.query_map(params![folder_id], |r| { @@ -200,6 +206,7 @@ impl Db { remote_rev: r.get(4)?, remote_hash: r.get(5)?, local_mtime_ns: r.get(6)?, + remote_revision_id: r.get(7)?, }) })?; let mut map = HashMap::new(); @@ -238,15 +245,16 @@ impl Db { conn.execute( "INSERT INTO sync_entry (folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, - remote_hash, local_mtime_ns) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + remote_hash, local_mtime_ns, remote_revision_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(folder_id, rel_path) DO UPDATE SET - remote_uid = excluded.remote_uid, - local_mtime = excluded.local_mtime, - local_size = excluded.local_size, - remote_rev = excluded.remote_rev, - remote_hash = excluded.remote_hash, - local_mtime_ns = excluded.local_mtime_ns", + remote_uid = excluded.remote_uid, + local_mtime = excluded.local_mtime, + local_size = excluded.local_size, + remote_rev = excluded.remote_rev, + remote_hash = excluded.remote_hash, + local_mtime_ns = excluded.local_mtime_ns, + remote_revision_id = excluded.remote_revision_id", params![ folder_id, e.rel_path, @@ -256,6 +264,7 @@ impl Db { e.remote_rev, e.remote_hash, e.local_mtime_ns, + e.remote_revision_id, ], )?; Ok(()) @@ -306,7 +315,7 @@ impl Db { { let mut select = tx.prepare( "SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash, - local_mtime_ns + local_mtime_ns, remote_revision_id FROM sync_entry WHERE folder_id = ?1 AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')", @@ -321,6 +330,7 @@ impl Db { remote_rev: r.get(4)?, remote_hash: r.get(5)?, local_mtime_ns: r.get(6)?, + remote_revision_id: r.get(7)?, }) })? .collect::>>()?; @@ -332,8 +342,8 @@ impl Db { let mut insert = tx.prepare( "INSERT OR REPLACE INTO sync_entry (folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, - remote_hash, local_mtime_ns) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + remote_hash, local_mtime_ns, remote_revision_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", )?; for row in rows { let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]); @@ -346,6 +356,7 @@ impl Db { row.remote_rev, row.remote_hash, row.local_mtime_ns, + row.remote_revision_id, ])?; } } diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 63d4c1c..549eb8c 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -1327,6 +1327,7 @@ fn a_rebuild_keeps_the_queue_baselines_and_pins_of_the_damaged_file() { local_size: 6, remote_rev: Some("7".into()), remote_hash: Some("8".into()), + remote_revision_id: None, }, ) .unwrap(); @@ -5343,6 +5344,7 @@ fn baseline_row(rel: &str) -> StoredSyncEntry { local_size: 7, remote_rev: Some("1700000000".into()), remote_hash: Some("7".into()), + remote_revision_id: None, } } @@ -5437,6 +5439,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() { local_size: 2, remote_rev: Some("3".into()), remote_hash: Some("2".into()), + remote_revision_id: None, local_mtime_ns: None, }, ) @@ -5454,6 +5457,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() { local_size: 2, remote_rev: Some("3".into()), remote_hash: Some("2".into()), + remote_revision_id: None, local_mtime_ns: None, }, ) @@ -6631,6 +6635,71 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() { let _ = std::fs::remove_file(format!("{}.lock", path.display())); } +/// V44 adds the remote revision a baseline row was synced at. A row carried +/// over from V43 has none, and the mirror then compares the modification time +/// and size as before, so upgrading changes nothing about what is in step +/// (`docs/BUGS.md` B249). +#[test] +fn migration_v44_adds_the_synced_revision_without_disturbing_a_v43_baseline() { + let path = std::env::temp_dir().join(format!( + "pdfs-db-v43-fixture-{}-{}.db", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let row = StoredSyncEntry { + rel_path: "notes.txt".into(), + remote_uid: Some("vol~notes".into()), + local_mtime: 1_700_000_000, + local_mtime_ns: Some(1_700_000_000_123_456_789), + local_size: 42, + remote_rev: Some("1700000000".into()), + remote_hash: Some("42".into()), + remote_revision_id: Some("rev-1".into()), + }; + { + let db = Db::open(&path).unwrap(); + let folder = db + .sync_folder_add("/home/me/Mirror", "vol~root", "share") + .unwrap(); + db.sync_entry_upsert(folder, &row).unwrap(); + } + { + // Put the file back in the state a released V43 database was in. + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch( + "ALTER TABLE sync_entry DROP COLUMN remote_revision_id; + UPDATE sync_state SET value = '43' WHERE key = 'schema_version';", + ) + .unwrap(); + } + + let db = Db::open(&path).unwrap(); + let entries = db.sync_entries(1).unwrap(); + let entry = entries.get("notes.txt").expect("the baseline row survives"); + assert_eq!( + entry, + &StoredSyncEntry { + remote_revision_id: None, + ..row.clone() + } + ); + + // And the column is live, including through a move of the row. + db.sync_entry_upsert(1, &row).unwrap(); + db.sync_entries_move(1, "notes.txt", 1, "moved.txt") + .unwrap(); + let entries = db.sync_entries(1).unwrap(); + assert_eq!( + entries["moved.txt"].remote_revision_id.as_deref(), + Some("rev-1") + ); + drop(db); + let _ = std::fs::remove_file(&path); +} + /// A mirror baseline carried over from V27 keeps its rows, and each one reads /// back with no nanosecond time. That `None` is the whole point: it makes the /// comparison fall back to whole seconds for rows written before the column @@ -6661,6 +6730,7 @@ fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() local_size: 42, remote_rev: Some("1700000000".into()), remote_hash: Some("42".into()), + remote_revision_id: None, }, ) .unwrap(); diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 229bc66..8d02340 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -6165,11 +6165,61 @@ fn is_gone(e: &(dyn std::error::Error + 'static)) -> bool { /// (`sync.rs`, `conflict_path`), so the two halves of the product name the same /// situation the same way and a user only has to learn it once. fn conflict_name(name: &str, stamp: i64) -> String { + conflict_name_as(name, conflict_label(), stamp) +} + +/// [`conflict_name`] with the machine's label given. +fn conflict_name_as(name: &str, label: &str, stamp: i64) -> String { let path = Path::new(name); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name); + let tag = conflict_tag(label, stamp, 0); match path.extension().and_then(|s| s.to_str()) { - Some(ext) => format!("{stem} (sync-conflict {stamp}).{ext}"), - None => format!("{stem} (sync-conflict {stamp})"), + Some(ext) => format!("{stem} (sync-conflict {tag}).{ext}"), + None => format!("{stem} (sync-conflict {tag})"), + } +} + +/// This machine's name as its conflict copies carry it (`docs/BUGS.md` B251). +/// +/// Two computers syncing one folder that each keep a copy of the same file in +/// the same second would otherwise make the same name on Drive, and nobody +/// could tell which computer a copy came from. Read once, from the hostname. +fn conflict_label() -> &'static str { + static LABEL: OnceLock = OnceLock::new(); + LABEL.get_or_init(|| conflict_label_of(&this_hostname())) +} + +/// `host` as one token: anything but ASCII letters, digits, `-`, `_` and `.` +/// becomes `-`, so the label holds no space for `conflict_base_name` to trip +/// on and no `/` or `)` to break the name. +fn conflict_label_of(host: &str) -> String { + let label: String = host + .chars() + .take(32) + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '-' + } + }) + .collect(); + label.trim_matches('-').to_string() +} + +/// The inside of a conflict copy's marker: `GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1272 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1280 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1272 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1280 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1272 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1280 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1272 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1280 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1272 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1280 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1272 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1280 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,7 +60,7 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:767 +#: crates/pdfs-gui/src/app/main.rs:849 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:833 @@ -73,74 +73,74 @@ msgstr "" msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:770 crates/pdfs-gui/src/app/main.rs:1415 +#: crates/pdfs-gui/src/app/main.rs:852 crates/pdfs-gui/src/app/main.rs:1517 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:845 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:775 +#: crates/pdfs-gui/src/app/main.rs:857 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:484 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:780 +#: crates/pdfs-gui/src/app/main.rs:862 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:783 +#: crates/pdfs-gui/src/app/main.rs:865 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:493 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:784 +#: crates/pdfs-gui/src/app/main.rs:866 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:786 +#: crates/pdfs-gui/src/app/main.rs:868 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1112 +#: crates/pdfs-gui/src/app/main.rs:1214 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1135 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1136 +#: crates/pdfs-gui/src/app/main.rs:1238 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1137 +#: crates/pdfs-gui/src/app/main.rs:1239 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1138 +#: crates/pdfs-gui/src/app/main.rs:1240 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1139 +#: crates/pdfs-gui/src/app/main.rs:1241 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1226 +#: crates/pdfs-gui/src/app/main.rs:1328 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1234 +#: crates/pdfs-gui/src/app/main.rs:1336 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -148,23 +148,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1239 +#: crates/pdfs-gui/src/app/main.rs:1341 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1243 +#: crates/pdfs-gui/src/app/main.rs:1345 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1247 +#: crates/pdfs-gui/src/app/main.rs:1349 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1354 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1256 +#: crates/pdfs-gui/src/app/main.rs:1358 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -177,21 +177,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1264 +#: crates/pdfs-gui/src/app/main.rs:1366 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1272 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1280 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1272 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -211,7 +211,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1280 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1272 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1280 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "The street map, when turned on, comes from OpenFreeMap in the Date: Fri, 16 Oct 2026 17:21:46 +0200 Subject: [PATCH 43/44] docs: window size, keyring banner and the mailbox password per session Add the window size and the narrow layout to the 3.5.0 changelog with B253, document the `window` config field, and explain in the troubleshooting guide why a two-password account asks for its mailbox password after every reboot, and how to keep it unlocked. --- docs/CHANGELOG.md | 7 +++++++ docs/CONFIGURATION.md | 2 ++ docs/TROUBLESHOOTING.md | 10 +++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 274c92e..6fb963c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,8 +22,15 @@ version on Proton Drive it was synced at. Nothing is rewritten, and the upgrade computer's backup alongside it, then offers its folders for syncing here. Both computers keep them in step, deletions included. Each computer now keeps its own settings backup in the shared backup, so they no longer overwrite each other (B252). +- **The app remembers its window size**, and whether it was maximized, between runs. ### Fixed +- **The app's window could not shrink to fit a small or scaled screen**, so the minimize, + maximize and close buttons ended up off-screen. Below about 760 pixels the sidebar and the + page now take turns, and every page fits in a window 480 pixels wide. +- **The app showed the sign-in page when the system keyring was locked or not running yet**, as + if the sign-in had been lost. It now says that the keyring could not be read, and opens on its + own once the keyring answers (B253). - **An edit in a synced folder could replace one another computer had just saved.** The newer version from the other computer was left only in the version history. The edit is now kept as a conflict copy (B249). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f6aee91..f121275 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -62,6 +62,7 @@ fix it. "online_map": false, "tray_hidden": false, "files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true }, + "window": { "width": 980, "height": 680, "maximized": false }, "open_with": { "terminal": ["alacritty", "-e"], "rules": [{ "match": ["@text"], "command": ["nvim"], "terminal": true }] @@ -87,6 +88,7 @@ fix it. | `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap | | `tray_hidden` | bool | `false` | General → Appearance | Hide the tray icon | | `files_view` | object | grid, by name | My files view menu | `list`, `sort` (`name`, `size`, `modified`), `descending`, `folders_first` | +| `window` | object | 980 × 680 | Resizing the app window | `width`, `height` (the unmaximized size), `maximized` | | `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) | | `prompt` | object | built-in window | — | See [`prompt`](#prompt) | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 5a23578..b0211aa 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -97,7 +97,15 @@ this block, 5003 is a release Proton no longer accepts. **Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service -enabled). `pdfs diagnose` shows `keyring session` as failing in that case. +enabled). `pdfs diagnose` shows `keyring session` as failing in that case. When the app finds +a saved sign-in it cannot read, it says so above the sign-in form and opens on its own once the +keyring is unlocked. You don't have to sign in again. + +**Asked for the mailbox password after every reboot** — the account has a separate mailbox +password (two-password mode). Such accounts are locked by default: the unlock lasts until you log +out or restart. To stay unlocked, turn on **Keep unlocked on this computer** in the unlock dialog, +or run `pdfs unlock --remember`. Either one stores the unlock in the system keyring. +[SECURITY_MODEL.md](SECURITY_MODEL.md) explains what that trades away. ## The Proton Drive folder From 8f771fdcc40ce06fb394818b35e3270482364488 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 18:44:51 +0200 Subject: [PATCH 44/44] fix(fuse): read the size again for a baseline row without a revision id A mirror upload that Drive did not list yet when it was read back left a baseline row with no revision id. The next walk reused the recorded size whenever the modification time matched, and a row without an id never counts as moved. So a revision another computer saved in the same second looked unchanged and never arrived (B254). The B250 simulation hit this about one run in five on CI. A row without an id now reuses its size only while Drive gives no id either. Otherwise the walk reads the size, and an unchanged file gets Drive's revision id written into its row, so each row costs one extra read. Rows from before schema 44 are read once after upgrading. --- crates/pdfs-fuse/src/sync.rs | 18 +++++++++++++++++- crates/pdfs-fuse/src/sync/planner.rs | 20 +++++++++++++++++++- docs/BUGS.md | 28 ++++++++++++++++++++++++++++ docs/CHANGELOG.md | 3 +++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index 9c9ff3d..12cf67d 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -776,7 +776,23 @@ impl Core { } match plan_file(l, r, baseline.get(rel)) { - FilePlan::Unchanged => {} + // The size was read for real for a row without a revision id + // (B254), and still matches: give the row the id, so later + // walks trust the recorded size again. + FilePlan::Unchanged => { + if let (Some(base), Some(revision)) = + (baseline.get(rel), r.and_then(|r| r.revision.as_ref())) + && base.remote_revision_id.is_none() + { + let row = StoredSyncEntry { + remote_revision_id: Some(revision.clone()), + ..base.clone() + }; + if let Err(e) = self.db.sync_entry_upsert(folder_id, &row) { + debug!(rel, error = ?e, "sync: cannot record the revision id"); + } + } + } FilePlan::AdoptBaseline => { // First sync of a file the same size on both sides: the apply // step hashes the local copy and records the baseline only if diff --git a/crates/pdfs-fuse/src/sync/planner.rs b/crates/pdfs-fuse/src/sync/planner.rs index 8fe5fa6..95b6f04 100644 --- a/crates/pdfs-fuse/src/sync/planner.rs +++ b/crates/pdfs-fuse/src/sync/planner.rs @@ -333,6 +333,13 @@ pub(super) fn remote_subtree_changed( /// The size the baseline recorded for `rel`, if its remote signature's mtime /// still matches `mtime` and its revision is still `revision`. +/// +/// A row without a revision id, while Drive reports one, cannot vouch for the +/// revision, so the size is read for real. Such a row comes from before +/// schema 44, or from an upload Drive did not list yet when it was read back. +/// Another computer's revision in the same second as that upload kept the +/// modification time, and only the size could show it (B254). The pass then +/// records the id on the row, so this costs one read per row. pub(super) fn unchanged_remote_size( baseline: &HashMap, rel: &str, @@ -340,7 +347,9 @@ pub(super) fn unchanged_remote_size( revision: Option<&str>, ) -> Option { let base = baseline.get(rel)?; - if revision_moved(Some(base), revision) { + if revision_moved(Some(base), revision) + || (base.remote_revision_id.is_none() && revision.is_some()) + { return None; } match remote_sig(base) { @@ -756,6 +765,15 @@ mod file_plan_tests { assert_eq!(unchanged_remote_size(&rows, "file", 31, Some("r1")), None); } + /// A row without a revision id reuses its size only while Drive gives no + /// id either (B254). + #[test] + fn a_row_without_a_revision_makes_the_walk_read_the_size_again() { + let rows = HashMap::from([("file".to_string(), baseline(10, 20, 30, 40))]); + assert_eq!(unchanged_remote_size(&rows, "file", 30, Some("r1")), None); + assert_eq!(unchanged_remote_size(&rows, "file", 30, None), Some(40)); + } + #[test] fn a_missing_remote_signature_is_treated_as_changed() { let local = local(10, 20); diff --git a/docs/BUGS.md b/docs/BUGS.md index 92b99e3..aad9d31 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives | | [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked | | [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile | | [B251](#b251--two-computers-can-make-the-same-conflict-copy-name) | Two computers can make the same conflict copy name | @@ -356,6 +357,33 @@ a live account since. Newest first. +### B254 — An edit in the same second as a new upload never arrives + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, the B250 simulation failing about one run in five on CI. +**Where:** `unchanged_remote_size` in `crates/pdfs-fuse/src/sync/planner.rs`; the +`FilePlan::Unchanged` arm of `do_reconcile` in `crates/pdfs-fuse/src/sync.rs` + +**Repro:** A mirror pass uploads a new file, and Drive does not list it yet when the pass +reads it back. Another computer saves a new revision with the same modification time, to the +second. The mirror keeps the old content, and no pass ever downloads the new one. + +**Cause.** The read-back found no node, so the baseline row got the local modification time +and size but no revision id. The next walk reused the recorded size whenever the modification +time matched and the revision id had not moved. A row without an id never counts as moved, so +the walk never read the new size, and the file looked unchanged. + +**Fix.** A row without a revision id reuses its size only while Drive gives no id either. +Otherwise the walk reads the size for real. If the file is still unchanged, the pass writes +Drive's revision id into the row, so the cost is one extra read per row. Rows from before +schema 44 have no id either, so the first pass after upgrading reads every file's size once. + +**Test:** `a_row_without_a_revision_makes_the_walk_read_the_size_again` in `pdfs-fuse`. The +simulation `a_revision_made_during_a_mirror_download_still_arrives` failed 3 of about 15 runs +before the fix and passed 25 of 25 after it. + +--- + ### B253 — The app shows the sign-in page when the keyring is locked **Status:** Fixed, not verified live diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6fb963c..5ddcb5c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -31,6 +31,9 @@ version on Proton Drive it was synced at. Nothing is rewritten, and the upgrade - **The app showed the sign-in page when the system keyring was locked or not running yet**, as if the sign-in had been lost. It now says that the keyring could not be read, and opens on its own once the keyring answers (B253). +- **A new version saved on another computer in the same second as an upload from here could + be missed for good** when Drive had not listed the uploaded file yet. The first sync pass + after upgrading reads each file's size from Drive once more to close this (B254). - **An edit in a synced folder could replace one another computer had just saved.** The newer version from the other computer was left only in the version history. The edit is now kept as a conflict copy (B249).