From 3f45e89391a3c54d175e64882145f232ec3546a4 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 13 Nov 2026 23:50:03 +0100 Subject: [PATCH] fix(fuse): keep files offline in on-demand synced folders `pdfs pin` and `pdfs unpin` resolved every path against the primary mount with `rel_to_mount`. An absolute path in an on-demand synced folder such as ~/Pictures was refused as "not under the mountpoint", and a relative path named a file in My files instead. A synced folder's file is a different Drive node from a same-named file in My files, so keeping one offline did nothing for the other, and every program reading the synced copy downloaded it. Pin and unpin now route through `route_to_mount`, like ls, refresh, rm and mv since B86, and run on the mount that owns the path. A path in a mirror folder gets an error saying its files are already on this computer. A pin made through an on-demand folder records the absolute local path, so `pdfs pins`, the prompt and the HUD open the file where it was kept; pins under the primary mount still record the mount-relative path. Fixes B281. --- crates/pdfs-fuse/src/control.rs | 54 +++++++++++++++++++++++++----- crates/pdfs-fuse/src/lib.rs | 11 ++++-- crates/pdfs-fuse/src/sim/daemon.rs | 50 +++++++++++++++++++++++++++ docs/BUGS.md | 34 +++++++++++++++++++ docs/CHANGELOG.md | 5 +++ docs/CLI.md | 2 +- 6 files changed, 143 insertions(+), 13 deletions(-) diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 4985d36..0d7e51e 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -204,18 +204,42 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor /// B99), so resolving one needs this too. A mirror folder has no mount to run /// against: a file there is an ordinary local file. fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> { + route_within(core, mountpoint, path, |path| { + format!("{path} is in a mirrored folder; change the local file there instead") + }) +} + +/// [`route_to_mount`] for keeping a node offline or releasing it. A file in an +/// on-demand synced folder is a different Drive node from any file of the +/// same name under the primary mount, and keeping one offline did nothing for +/// the other: a program reading the synced folder's copy downloaded it +/// (`docs/BUGS.md` B281). A mirror folder already holds every file on disk. +fn route_keep(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> { + route_within(core, mountpoint, path, |path| { + format!( + "{path} is in a mirrored folder, which keeps all its files on this computer already" + ) + }) +} + +/// [`route_to_mount`], refusing a path that leaves the mount it routed to and +/// answering a path in a mirror folder, which has no mount, with `mirrored`. +fn route_within( + core: &Core, + mountpoint: &Path, + path: &str, + mirrored: impl FnOnce(&str) -> String, +) -> CoreResult<(Core, PathBuf)> { let routed = route_to_mount(core, mountpoint, path).map_err(|e| { let p = Path::new(path); - let mirrored = core + let in_mirror = core .db .sync_folder_list() .unwrap_or_default() .iter() .any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path)); - if mirrored { - CoreError::invalid(format!( - "{path} is in a mirrored folder; change the local file there instead" - )) + if in_mirror { + CoreError::invalid(mirrored(path)) } else { e } @@ -231,6 +255,18 @@ fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, } } +/// The path the pin registry keeps for a pin made through `routed`: the +/// mount-relative path on the primary mount, as it always was, and the +/// absolute path the request named on an on-demand folder's mount, since +/// that node is not under the primary mountpoint (`docs/BUGS.md` B281). +fn pin_record(routed: &Core, rel: &Path, path: &str) -> PathBuf { + if routed.primary { + rel.to_path_buf() + } else { + Path::new(path).components().collect() + } +} + /// [`route_change`] for a request that trashes or renames the node it names. /// A mount's root is My files or a whole synced folder. An on-demand folder's /// root has a parent on Drive, so nothing further down would stop `pdfs rm @@ -521,8 +557,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(CtlRequest::Subscribe) => { CtlResponse::error(CoreError::invalid("a subscription is served on its own")) } - Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) { - Ok(rel) => match core.pin(&rel) { + Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) { + Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) { Ok(name) => { core.touch_profile(); CtlResponse::Ok { @@ -533,8 +569,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, - Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) { - Ok(rel) => match core.unpin(&rel) { + Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) { + Ok((routed, rel)) => match routed.unpin(&rel) { Ok(name) => { core.touch_profile(); CtlResponse::Ok { diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index c10e354..9a319a8 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -4270,7 +4270,12 @@ impl Core { /// recursive pin and downloads every descendant file on a background thread, /// reported as a job: a big folder far outlasts the socket read timeout. /// Returns a human message. - fn pin(&self, rel: &Path) -> CoreResult { + /// + /// `recorded` is the path the pin registry keeps for the front ends to + /// open the pin by: `rel` itself on the primary mount, which they join to + /// the mountpoint, and the absolute local path on an on-demand folder's + /// mount, whose node is not reachable under the primary mountpoint at all. + fn pin(&self, rel: &Path, recorded: &Path) -> CoreResult { let (ino, uid) = self.resolve(rel)?; let (name, is_folder, mtime, size) = { let st = self.state(); @@ -4294,7 +4299,7 @@ impl Core { // Record the recursive pin first so every descendant is eviction- // exempt before we start filling the cache with the subtree. self.cache - .add_pin(&uid, rel, true) + .add_pin(&uid, recorded, true) .map_err(|e| CoreError::from_api(&e, "pin"))?; let core = self.clone(); let folder = name.clone(); @@ -4306,7 +4311,7 @@ impl Core { } self.fetch_content(&uid, &name, mtime, size)?; self.cache - .add_pin(&uid, rel, false) + .add_pin(&uid, recorded, false) .map_err(|e| CoreError::from_api(&e, "pin"))?; Ok(name) } diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index d1f2d41..8983901 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -3447,6 +3447,56 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] + fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() { + // Keeping a file offline took only paths under the primary mount, so + // the copy in an on-demand synced folder could not be kept, and a + // program reading it there downloaded it every time (B281). + let drive = FakeDrive::new(); + let dir = scratch("pin-in-ondemand"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let ondemand = dir.join("ondemand"); + sync_folder(&daemon, &ondemand, "ondemand"); + + let content = b"not a real png, but kept all the same".repeat(64); + let file = ondemand.join("photo.png"); + std::fs::write(&file, &content).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + })); + daemon.request(&Request::PurgeCache).unwrap(); + + let path = file.canonicalize().unwrap().to_string_lossy().into_owned(); + let pinned = daemon + .request(&Request::Pin { path: path.clone() }) + .unwrap(); + assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}"); + let pins = || match daemon.request(&Request::ListPins) { + Ok(Response::Pins { pins }) => pins, + other => panic!("pins: {other:?}"), + }; + let listed = pins(); + assert!( + listed.iter().any(|pin| pin.path == path && pin.cached), + "the pin opens by the path it was made through: {listed:?}" + ); + + // With the link gone, only a kept copy can answer the read. + daemon.client.set_online(false); + assert_eq!(std::fs::read(&file).unwrap(), content); + daemon.client.set_online(true); + + let unpinned = daemon + .request(&Request::Unpin { path: path.clone() }) + .unwrap(); + assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}"); + assert!(pins().is_empty()); + + assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); + let _ = std::fs::remove_dir_all(&dir); + } + /// The synced folder `id` as the daemon lists it. fn listed_folder(daemon: &Daemon, id: i64) -> Option { match daemon.request(&Request::ListSyncFolders) { diff --git a/docs/BUGS.md b/docs/BUGS.md index 9b2155e..8dddd70 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -109,6 +109,7 @@ a live account since. | Entry | Title | |---|---| +| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline | | [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed | | [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back | | [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds | @@ -383,6 +384,39 @@ a live account since. Newest first. +### B281 — A file in a synced folder cannot be kept offline + +**Status:** Fixed, not verified live +**Found:** 2026-11-13. An image was made available offline in the app, and it opened from the app +with no download. `feh` opened it from a terminal and downloaded it each time. The two were not +the same file. The app had kept `~/ProtonDrive/Pictures/photo.png`, in My files. The +terminal opened `~/Pictures/photo.png`, in the on-demand synced folder `~/Pictures`. That +copy is a separate node under Computers, uploaded on its own, with the same name and size but a +different mtime. The daemon treats every program the same way: it does not look at the process +that reads. +**Where:** the `Pin` and `Unpin` handlers in `crates/pdfs-fuse/src/control.rs` + +**Repro:** Add a synced folder in on-demand mode and put a file in it. `pdfs pin` on its absolute +path fails with "is not under the mountpoint". A relative path is resolved against My files, so +it keeps a file of the same name there, if one exists, or fails. Reading the file in the synced +folder then downloads it. + +**Cause:** Both handlers turned the path into a mount-relative one with `rel_to_mount`, which only +knows the primary mount. B86 moved `ls`, `refresh`, `rm` and `mv` to `route_to_mount`, which finds +the mount that serves an absolute path. Pin and unpin were left out. + +**Fix:** Pin and unpin route through `route_to_mount` and run on the mount that owns the path. A +path in a mirror folder gets an error that says its files are already on this computer. The pin +registry records the absolute local path for a pin made through an on-demand folder, so +`pdfs pins`, the prompt and the HUD open the file through the folder it was kept from. Pins +under the primary mount still record the path relative to it. + +**Test:** `pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy` in +`crates/pdfs-fuse/src/sim/daemon.rs`. It pins a file through an on-demand folder's absolute path, +takes the link down, and reads the file through that folder. + +--- + ### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed **Status:** Fixed, not verified live diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6e75d36..b701da6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -54,6 +54,11 @@ Existing folders stay device backups. Older builds cannot open the upgraded data restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side, and a very large photo waits and is made on its own. +- **Keep a file offline in an on-demand synced folder** (B281). `pdfs pin` and `pdfs unpin` now + accept a path in a synced folder such as `~/Pictures`. Before, they only took paths under the + Proton Drive mount, so the copy in the synced folder was downloaded again by every program that + read it. + ## [3.7.0] — 2026-11-02 Schema: **44**, unchanged. diff --git a/docs/CLI.md b/docs/CLI.md index df95563..6b766b1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -102,7 +102,7 @@ commands are for scripts and for cases where the mount is not available. | Command | Description | |---|---| -| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). | +| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). PATH can be under the Proton Drive mount or in an on-demand synced folder. A file in a mirror folder is already on this computer. | | `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. | | `pdfs pins` | List files and folders kept available offline. |