diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 90cd4ce..b7425ab 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -696,6 +696,15 @@ enum SyncCmd { /// Local directory to sync. path: PathBuf, }, + /// Sync a real local folder with an existing folder in My files. + /// The first pass preserves local-only files and keeps differing versions. + Attach { + /// Existing local directory; it remains a real local directory. + path: PathBuf, + /// Existing folder in My files, relative to the cloud mount or inside it. + #[arg(long)] + to: PathBuf, + }, /// List this machine's synced folders. List, /// Remove a synced folder by id. @@ -1116,6 +1125,22 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { .to_string(); ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)? } + SyncCmd::Attach { path, to } => { + let abs = std::fs::canonicalize(&path) + .with_context(|| format!("resolve {}", path.display()))?; + let local_path = abs + .to_str() + .ok_or_else(|| anyhow!("path is not valid UTF-8"))? + .to_owned(); + let remote_path = to + .to_str() + .ok_or_else(|| anyhow!("remote path is not valid UTF-8"))? + .to_owned(); + ok_or_bail(control_request(CtlRequest::AttachSyncFolder { + local_path, + remote_path, + })?)? + } SyncCmd::List => { let response = control_request(CtlRequest::ListSyncFolders)?; if emit_json(&response)? { @@ -2294,6 +2319,9 @@ fn cmd_locations() -> Result<()> { MountKind::Device { sync_folder_id } => { format!("Device folder {sync_folder_id}") } + MountKind::Attached { sync_folder_id } => { + format!("My files mirror {sync_folder_id}") + } MountKind::Shared { .. } => "Shared folder".to_string(), }; let mode = match location.pending_mode { @@ -4268,6 +4296,7 @@ mod diagnose_tests { #[test] fn a_synced_folder_in_error_is_reported() { let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo { + kind: "device".into(), id, local_path: format!("/home/u/f{id}"), remote_uid: "vol~link".into(), diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 3b7c590..b0dc401 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -469,6 +469,13 @@ pub enum Request { /// Add a local folder to this machine's device, uploading its tree and /// registering the device on first use. Replies with [`Response::Ok`]. AddSyncFolder { local_path: String }, + /// Keep a real local directory in step with an existing folder in My files. + /// First sync unions one-sided files and preserves differing versions. + /// Replies with [`Response::Ok`]; no device backup is created. + AttachSyncFolder { + local_path: String, + remote_path: String, + }, /// List this device's synced folders. Replies with [`Response::SyncFolders`]. ListSyncFolders, /// Remove a synced folder by id; `delete_remote` also trashes its cloud copy. @@ -985,14 +992,17 @@ impl SyncIssue { } } -/// One synced local folder on this machine's device (in [`Response::SyncFolders`]). +/// One device backup or attached My files mirror (in [`Response::SyncFolders`]). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SyncFolderInfo { /// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`]. pub id: i64, + /// `device` backup or `attached` My files mirror. Older daemons only had backups. + #[serde(default = "default_sync_folder_kind")] + pub kind: String, /// Absolute local folder path. pub local_path: String, - /// The uid of the folder's remote root under the device root. + /// The uid of the remote root under the device root or My files. pub remote_uid: String, /// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount). pub mode: String, @@ -1015,6 +1025,10 @@ pub struct SyncFolderInfo { pub paused: bool, } +fn default_sync_folder_kind() -> String { + "device".into() +} + /// A folder under this machine's device offered for restore (in /// [`Response::RestorableFolders`]). #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index 08b946b..0e9ac0e 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 = 44; +pub(super) const SCHEMA_VERSION: i64 = 45; impl Db { pub(super) fn migrate(&self) -> Result<()> { @@ -467,6 +467,29 @@ impl Db { tx.execute_batch(MIGRATION_V44)?; } } + if current < 45 { + let has_folders: bool = tx.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_folder'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + let has_kind: bool = tx.query_row( + "SELECT COUNT(*) FROM pragma_table_info('sync_folder') WHERE name = 'kind'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + if has_folders && !has_kind { + tx.execute_batch(MIGRATION_V45_FOLDERS)?; + let has_mounts: bool = tx.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'mount'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + if has_mounts { + tx.execute_batch(MIGRATION_V45_MOUNTS)?; + } + } + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -1382,3 +1405,57 @@ CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name); const MIGRATION_V44: &str = " ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT; "; + +/// Schema v45: distinguish device backups from My files attachments. Existing +/// released rows are device backups. Attachments cannot evict their local tree. +const MIGRATION_V45_FOLDERS: &str = " +ALTER TABLE sync_folder ADD COLUMN kind TEXT NOT NULL DEFAULT 'device' + CHECK (kind IN ('device', 'attached')); +CREATE TRIGGER sync_folder_attached_insert +BEFORE INSERT ON sync_folder +WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL) +BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END; +CREATE TRIGGER sync_folder_attached_update +BEFORE UPDATE ON sync_folder +WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL) +BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END; +"; + +const MIGRATION_V45_MOUNTS: &str = " +DROP TRIGGER mount_sync_folder_insert; +DROP INDEX mount_myfiles; +DROP INDEX mount_device; +DROP INDEX mount_shared; +ALTER TABLE mount RENAME TO mount_v44; +CREATE TABLE mount ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('myfiles', 'device', 'attached', 'shared')), + sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE, + share_root_uid TEXT, + local_path TEXT NOT NULL DEFAULT '', + root_uid TEXT NOT NULL DEFAULT '', + root_share_id TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL DEFAULT 'unknown', + access TEXT NOT NULL DEFAULT 'rw' CHECK (access IN ('rw', 'ro')), + CHECK ( + (kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL) + OR (kind IN ('device', 'attached') AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL) + OR (kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL) + ) +); +INSERT INTO mount SELECT * FROM mount_v44; +DROP TABLE mount_v44; +CREATE UNIQUE INDEX mount_myfiles ON mount(kind) WHERE kind = 'myfiles'; +CREATE UNIQUE INDEX mount_device ON mount(sync_folder_id) WHERE kind IN ('device', 'attached'); +CREATE UNIQUE INDEX mount_shared ON mount(share_root_uid) WHERE kind = 'shared'; +CREATE TRIGGER mount_sync_folder_insert +AFTER INSERT ON sync_folder +BEGIN + INSERT INTO mount (kind, sync_folder_id) VALUES (NEW.kind, NEW.id); +END; +CREATE TRIGGER mount_sync_folder_kind_update +AFTER UPDATE OF kind ON sync_folder +BEGIN + UPDATE mount SET kind = NEW.kind WHERE sync_folder_id = NEW.id; +END; +"; diff --git a/crates/pdfs-core/src/db/mounts.rs b/crates/pdfs-core/src/db/mounts.rs index 08f6865..6da757f 100644 --- a/crates/pdfs-core/src/db/mounts.rs +++ b/crates/pdfs-core/src/db/mounts.rs @@ -1,6 +1,6 @@ //! Unified mount/location presentation rows. //! -//! A device row stores only its `sync_folder` identity. Its path, remote root, +//! A device or attached row stores only its `sync_folder` identity. Its path, remote root, //! mode, state, pending mode, and last-sync timestamp are selected from //! `sync_folder`, which remains the authoritative sync-engine table. @@ -72,7 +72,7 @@ impl Db { )? > 0) } - /// Every local location, with device fields joined from `sync_folder`. + /// Every local location, with synced-folder fields joined from `sync_folder`. pub fn mount_list(&self) -> Result> { let conn = self.read(); let mut stmt = conn.prepare( @@ -81,14 +81,14 @@ impl Db { m.kind, m.sync_folder_id, m.share_root_uid, - CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END, - CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END, - CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END, - CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END, + CASE WHEN m.kind IN ('device', 'attached') THEN sf.local_path ELSE m.local_path END, + CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_uid ELSE m.root_uid END, + CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_share_id ELSE m.root_share_id END, + CASE WHEN m.kind IN ('device', 'attached') THEN sf.mode ELSE m.mode END, m.access, - CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END, - CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END, - CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END + CASE WHEN m.kind IN ('device', 'attached') THEN sf.state ELSE 'idle' END, + CASE WHEN m.kind IN ('device', 'attached') THEN sf.last_sync ELSE 0 END, + CASE WHEN m.kind IN ('device', 'attached') THEN sf.pending_mode ELSE NULL END FROM mount AS m LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id ORDER BY CASE m.kind @@ -138,6 +138,11 @@ impl Db { Error::Other(format!("mount row {id} has no sync_folder_id")) })?, }, + "attached" => MountKind::Attached { + sync_folder_id: sync_folder_id.ok_or_else(|| { + Error::Other(format!("mount row {id} has no sync_folder_id")) + })?, + }, "shared" => MountKind::Shared { share_root_uid: share_root_uid.ok_or_else(|| { Error::Other(format!("mount row {id} has no share_root_uid")) diff --git a/crates/pdfs-core/src/db/sync.rs b/crates/pdfs-core/src/db/sync.rs index 960c00a..57bcae2 100644 --- a/crates/pdfs-core/src/db/sync.rs +++ b/crates/pdfs-core/src/db/sync.rs @@ -9,6 +9,8 @@ use std::collections::HashMap; pub struct StoredSyncFolder { pub id: i64, + /// `device` backup or `attached` My files mirror. + pub kind: String, pub local_path: String, pub remote_uid: String, pub remote_share_id: String, @@ -59,12 +61,32 @@ impl Db { local_path: &str, remote_uid: &str, remote_share_id: &str, + ) -> Result { + self.sync_folder_insert(local_path, remote_uid, remote_share_id, "device") + } + + /// Attach an existing My files folder, without creating a device backup. + pub fn sync_folder_attach( + &self, + local_path: &str, + remote_uid: &str, + remote_share_id: &str, + ) -> Result { + self.sync_folder_insert(local_path, remote_uid, remote_share_id, "attached") + } + + fn sync_folder_insert( + &self, + local_path: &str, + remote_uid: &str, + remote_share_id: &str, + kind: &str, ) -> Result { let conn = self.conn.lock(); conn.execute( - "INSERT INTO sync_folder (local_path, remote_uid, remote_share_id) - VALUES (?1, ?2, ?3)", - params![local_path, remote_uid, remote_share_id], + "INSERT INTO sync_folder (local_path, remote_uid, remote_share_id, kind) + VALUES (?1, ?2, ?3, ?4)", + params![local_path, remote_uid, remote_share_id, kind], )?; Ok(conn.last_insert_rowid()) } @@ -73,7 +95,7 @@ impl Db { pub fn sync_folder_list(&self) -> Result> { let conn = self.read(); let mut stmt = conn.prepare( - "SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync + "SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind FROM sync_folder ORDER BY id", )?; let rows = stmt.query_map([], |r| { @@ -86,6 +108,7 @@ impl Db { pending_mode: r.get(5)?, state: r.get(6)?, last_sync: r.get(7)?, + kind: r.get(8)?, }) })?; let mut items = Vec::new(); @@ -99,7 +122,7 @@ impl Db { pub fn sync_folder_get(&self, id: i64) -> Result> { let conn = self.read(); conn.query_row( - "SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync + "SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind FROM sync_folder WHERE id = ?1", params![id], |r| { @@ -112,6 +135,7 @@ impl Db { pending_mode: r.get(5)?, state: r.get(6)?, last_sync: r.get(7)?, + kind: r.get(8)?, }) }, ) diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 549eb8c..baa5a22 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -2076,7 +2076,11 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() { { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch( - "DROP TRIGGER mount_sync_folder_insert; + "DROP TRIGGER IF EXISTS mount_sync_folder_kind_update; + DROP TRIGGER mount_sync_folder_insert; + DROP TRIGGER sync_folder_attached_insert; + DROP TRIGGER sync_folder_attached_update; + ALTER TABLE sync_folder DROP COLUMN kind; DROP TABLE mount; DROP TABLE share_access; DROP TABLE album_photos; @@ -7691,3 +7695,69 @@ fn a_queued_op_stops_existing_once_the_node_is_deleted() { assert_eq!(blobs, vec!["/staging/journal"]); assert!(!db.op_exists(id).unwrap()); } + +#[test] +fn attachments_have_their_own_location_and_cannot_evict_local_files() { + use crate::mounts::MountKind; + let db = Db::open_in_memory().unwrap(); + let id = db + .sync_folder_attach("/work", "vol~work", "myfiles-share") + .unwrap(); + let folder = db.sync_folder_get(id).unwrap().unwrap(); + assert_eq!(folder.kind, "attached"); + assert_eq!(folder.mode, "mirror"); + assert_eq!(db.sync_folder_list().unwrap()[0].kind, "attached"); + let locations = db.mount_list().unwrap(); + assert!( + matches!(locations[0].kind, MountKind::Attached { sync_folder_id } if sync_folder_id == id) + ); + assert_eq!(locations[0].local_path, "/work"); + assert_eq!(locations[0].root_share_id, "myfiles-share"); + assert!(db.sync_folder_set_mode(id, "ondemand").is_err()); + assert!( + db.sync_folder_set_pending_mode(id, Some("ondemand")) + .is_err() + ); + assert_eq!(db.sync_folder_get(id).unwrap().unwrap().mode, "mirror"); + db.sync_folder_remove(id).unwrap(); + assert!(db.mount_list().unwrap().is_empty()); +} + +#[test] +fn migration_v45_keeps_existing_device_folders_and_location_ids() { + let db = Db::open_in_memory().unwrap(); + let id = db + .sync_folder_add("/backup", "vol~backup", "share") + .unwrap(); + db.sync_folder_set_mode(id, "ondemand").unwrap(); + db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap(); + let location_id = db.mount_list().unwrap()[0].id; + db.conn + .lock() + .execute_batch( + "DROP TRIGGER sync_folder_attached_insert; + DROP TRIGGER sync_folder_attached_update; + DROP TRIGGER IF EXISTS mount_sync_folder_kind_update; + DROP TRIGGER mount_sync_folder_insert; + ALTER TABLE sync_folder DROP COLUMN kind; + CREATE TRIGGER mount_sync_folder_insert AFTER INSERT ON sync_folder BEGIN + INSERT INTO mount (kind, sync_folder_id) VALUES ('device', NEW.id); END; + UPDATE sync_state SET value = '44' WHERE key = 'schema_version';", + ) + .unwrap(); + db.migrate().unwrap(); + let folder = db.sync_folder_get(id).unwrap().unwrap(); + assert_eq!(folder.kind, "device"); + assert_eq!(folder.mode, "ondemand"); + assert_eq!(folder.pending_mode.as_deref(), Some("mirror")); + assert_eq!(db.mount_list().unwrap()[0].id, location_id); + let attached = db + .sync_folder_attach("/work", "vol~work", "myfiles") + .unwrap(); + assert_eq!( + db.sync_folder_get(attached).unwrap().unwrap().kind, + "attached" + ); + db.migrate().unwrap(); + assert_eq!(db.mount_list().unwrap().len(), 2); +} diff --git a/crates/pdfs-core/src/mounts.rs b/crates/pdfs-core/src/mounts.rs index bfa33df..a0fa2cf 100644 --- a/crates/pdfs-core/src/mounts.rs +++ b/crates/pdfs-core/src/mounts.rs @@ -81,6 +81,8 @@ pub enum MountKind { MyFiles, /// A folder owned by this machine's Proton Drive device. Device { sync_folder_id: i64 }, + /// A real local mirror of an existing My files folder. + Attached { sync_folder_id: i64 }, /// A future standalone mount rooted at a node shared with this account. Shared { share_root_uid: String }, } @@ -88,8 +90,8 @@ pub enum MountKind { /// One local Proton Drive location as exposed over the control protocol. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MountSpec { - /// Presentation-row id. Device actions use the `sync_folder_id` carried by - /// [`MountKind::Device`], not this id. + /// Presentation-row id. Synced-folder actions use the `sync_folder_id` + /// carried by [`MountKind::Device`] or [`MountKind::Attached`], not this id. pub id: i64, pub kind: MountKind, pub local_path: String, diff --git a/crates/pdfs-fuse/src/attach.rs b/crates/pdfs-fuse/src/attach.rs new file mode 100644 index 0000000..0819bdb --- /dev/null +++ b/crates/pdfs-fuse/src/attach.rs @@ -0,0 +1,136 @@ +//! Explicitly bind an existing My files folder to a real local mirror. +//! +//! Unlike a FUSE mount, this never covers or evicts the local directory. +//! The existing three-way engine performs the initial, non-destructive union. + +use super::{Core, parse_uid, sync}; +use pdfs_core::db::StoredSyncFolder; +use pdfs_core::{CoreError, CoreResult}; +use proton_drive_rs::proton_sdk::ids::NodeUid; +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; + +impl Core { + /// A bounded, server-verified ancestry walk. Do not decide ownership or + /// remote-root overlap from a possibly incomplete cached folder listing. + pub(super) fn attach_ancestors(&self, start: &NodeUid) -> CoreResult> { + let mut ancestors = Vec::new(); + let mut seen = HashSet::new(); + let mut next = Some(start.clone()); + while let Some(uid) = next { + if !seen.insert(uid.clone()) || ancestors.len() >= 256 { + return Err(CoreError::invalid( + "invalid or excessively deep remote folder ancestry", + )); + } + ancestors.push(uid.clone()); + if uid == self.primary_root_uid { + break; + } + let nodes = self + .rt + .block_on(self.drive.enumerate_nodes(std::slice::from_ref(&uid))) + .map_err(|e| CoreError::from_api(&e, "verify sync folder ancestry"))?; + let node = nodes + .into_iter() + .next() + .ok_or_else(|| CoreError::not_found("remote sync folder is missing"))?; + if node.trashed { + return Err(CoreError::invalid("remote sync folder is in the trash")); + } + next = node.parent_uid; + } + Ok(ancestors) + } + + pub(crate) fn attach_sync_folder( + &self, + local: &Path, + remote: &Path, + mountpoint: &Path, + ) -> CoreResult { + let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock(); + if !self.primary { + return Err(CoreError::invalid("attach must use My files")); + } + if remote.is_absolute() + || remote + .components() + .any(|c| !matches!(c, Component::Normal(_) | Component::CurDir)) + { + return Err(CoreError::invalid( + "remote folder must stay inside My files", + )); + } + let local = local + .canonicalize() + .map_err(|e| CoreError::invalid(format!("resolve {}: {e}", local.display())))?; + if !local.is_dir() { + return Err(CoreError::invalid("local sync path is not a directory")); + } + let existing = self + .db + .sync_folder_list() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + let others = existing + .iter() + .map(|f| PathBuf::from(&f.local_path)) + .collect::>(); + let mut reserved = crate::devices::sync_root_reserved(); + reserved.mountpoint = Some( + mountpoint + .canonicalize() + .map_err(|e| CoreError::internal(format!("resolve mountpoint: {e}")))?, + ); + if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) { + return Err(CoreError::invalid(problem)); + } + let (ino, remote_uid) = self.resolve(remote)?; + let is_folder = self + .state() + .entries + .get(&ino) + .is_some_and(|e| e.node.is_folder() && !e.node.trashed); + if !is_folder { + return Err(CoreError::invalid("remote sync path is not a folder")); + } + let ancestors = self.attach_ancestors(&remote_uid)?; + if remote_uid == self.primary_root_uid || !ancestors.contains(&self.primary_root_uid) { + return Err(CoreError::invalid( + "choose an owned folder below My files, not its root or a shared folder", + )); + } + for other in existing.iter().filter(|f| f.kind == "attached") { + let uid = parse_uid(&other.remote_uid) + .ok_or_else(|| CoreError::internal("invalid existing remote sync root"))?; + if uid == remote_uid + || ancestors.contains(&uid) + || self.attach_ancestors(&uid)?.contains(&remote_uid) + { + return Err(CoreError::invalid( + "remote folder overlaps an existing synced folder", + )); + } + } + let share_id = self + .rt + .block_on(self.drive.context_share_id(&self.primary_root_uid)) + .map_err(|e| CoreError::from_api(&e, "resolve My files share"))? + .to_string(); + if share_id.is_empty() { + return Err(CoreError::invalid("My files share is unavailable")); + } + let id = self + .db + .sync_folder_attach(&local.to_string_lossy(), &remote_uid.to_string(), &share_id) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + // An empty baseline makes one-sided files new, never deletions. The + // engine hashes matching-size files and preserves divergent versions. + let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); + let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id)); + self.db + .sync_folder_get(id) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + .ok_or_else(|| CoreError::not_found("new sync folder disappeared")) + } +} diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index d45fa95..4985d36 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -385,6 +385,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] { R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices], R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations], R::AddSyncFolder { .. } + | R::AttachSyncFolder { .. } | R::RemoveSyncFolder { .. } | R::SetSyncFolderMode { .. } | R::SetSyncFolderPaused { .. } @@ -1397,6 +1398,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: format!("adding {local_path}"), } } + Ok(CtlRequest::AttachSyncFolder { + local_path, + remote_path, + }) => { + let result = rel_to_mount(mountpoint, &remote_path).and_then(|remote| { + core.attach_sync_folder(Path::new(&local_path), &remote, mountpoint) + }); + match result { + Ok(folder) => CtlResponse::Ok { + message: format!("sync folder {} attached: {}", folder.id, folder.local_path), + }, + Err(error) => CtlResponse::error(error), + } + } Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() { Ok(items) => CtlResponse::SyncFolders { items }, Err(e) => CtlResponse::error(e), diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index f7e4ca4..20b602e 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -33,6 +33,11 @@ use super::{ now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname, }; +/// Registration validates local and remote root overlap before inserting a +/// mapping. Serialize all registration paths so concurrent control requests +/// cannot both validate against the same old set of roots. +pub(super) static SYNC_ROOT_REGISTRATION: Mutex<()> = Mutex::new(()); + /// A mode that this daemon is allowed to apply. /// /// Persisted and control-protocol values first pass through [`MountMode`] so @@ -152,7 +157,9 @@ impl Core { .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; for location in &mut locations { location.mounted = self.states.is_mounted_at(Path::new(&location.local_path)); - if let MountKind::Device { sync_folder_id } = &location.kind { + if let MountKind::Device { sync_folder_id } | MountKind::Attached { sync_folder_id } = + &location.kind + { location.progress = progress.get(sync_folder_id).cloned(); location.paused = self.sync_folder_paused(*sync_folder_id); } @@ -495,7 +502,7 @@ impl Core { } /// Everything directly under a device root, trashed nodes included. - fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult> { + pub(super) fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult> { let uids = self .rt .block_on(self.drive.enumerate_folder_children_node_uids(root_uid)) @@ -513,6 +520,7 @@ impl Core { /// tree into it once, and record the mapping. Phase 1 is a one-shot upload — /// the two-way engine (Phase 2) reconciles later changes. pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult { + let _registration = SYNC_ROOT_REGISTRATION.lock(); let meta = std::fs::metadata(local) .map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?; if !meta.is_dir() { @@ -699,6 +707,11 @@ impl Core { .sync_folder_get(id) .map_err(|e| CoreError::internal(format!("db: {e:?}")))? .ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?; + if folder.kind == "attached" && delete_remote { + return Err(CoreError::invalid( + "attached My files folders cannot be deleted by stopping sync; omit --delete-remote", + )); + } // An `ondemand` folder *is* a FUSE mount over its local path, so dropping // only the row would strand the mount: the path would keep serving a // folder the daemon no longer tracks, and nothing would ever unmount it. @@ -785,6 +798,11 @@ impl Core { .sync_folder_get(id) .map_err(|e| CoreError::internal(format!("db: {e:?}")))? .ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?; + if folder.kind == "attached" && target_mode != ApplicableMountMode::Mirror { + return Err(CoreError::invalid( + "attached My files folders only support mirror mode; use the My files mount for online-only access", + )); + } let current_mode = ApplicableMountMode::parse(&folder.mode) .map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?; // A switch waits on the folder's reconcile, which a pause skips; refuse @@ -960,6 +978,11 @@ impl Core { .sync_folder_get(id) .map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))? .ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?; + if folder.kind == "attached" && mode != ApplicableMountMode::Mirror { + return Err(SwitchBlocked::Failed( + "attached My files folders only support mirror mode".into(), + )); + } if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) { return Err(SwitchBlocked::Superseded); } @@ -1199,7 +1222,7 @@ impl Core { } }; for folder in folders { - if folder.mode != "ondemand" { + if folder.kind != "device" || folder.mode != "ondemand" { continue; } let Some(root_uid) = parse_uid(&folder.remote_uid) else { @@ -1395,6 +1418,7 @@ mod tests { fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder { StoredSyncFolder { + kind: "device".into(), id: 7, local_path: local_path.to_string(), remote_uid: remote_uid.to_string(), diff --git a/crates/pdfs-fuse/src/drive.rs b/crates/pdfs-fuse/src/drive.rs index 5d9a261..b2d6342 100644 --- a/crates/pdfs-fuse/src/drive.rs +++ b/crates/pdfs-fuse/src/drive.rs @@ -26,7 +26,7 @@ use futures::StreamExt as _; use futures::stream::BoxStream; use proton_drive_rs::proton_sdk::account::Quota; use proton_drive_rs::proton_sdk::error::Result; -use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid}; +use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId}; use proton_drive_rs::{ AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail, @@ -43,6 +43,8 @@ pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>> pub(crate) trait DriveApi: Send + Sync { async fn get_my_files_folder(&self) -> Result; + async fn context_share_id(&self, uid: &NodeUid) -> Result; + async fn get_node(&self, uid: &NodeUid) -> Result>; async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result>; @@ -305,6 +307,10 @@ impl RevisionRead for RevisionReader { #[async_trait] impl DriveApi for ProtonDriveClient { + async fn context_share_id(&self, uid: &NodeUid) -> Result { + ProtonDriveClient::context_share_id(self, uid).await + } + async fn get_my_files_folder(&self) -> Result { ProtonDriveClient::get_my_files_folder(self).await } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 29a256e..af1a731 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -79,6 +79,7 @@ use proton_drive_rs::{ }; mod albums; +mod attach; mod background; mod conflicts; mod control; @@ -6441,6 +6442,7 @@ fn sync_folder_info( ) -> SyncFolderInfo { SyncFolderInfo { id: f.id, + kind: f.kind, local_path: f.local_path, remote_uid: f.remote_uid, mode: f.mode, diff --git a/crates/pdfs-fuse/src/profile.rs b/crates/pdfs-fuse/src/profile.rs index cd24a8d..63bc1b5 100644 --- a/crates/pdfs-fuse/src/profile.rs +++ b/crates/pdfs-fuse/src/profile.rs @@ -141,19 +141,11 @@ impl Core { /// Snapshot the local arrangement into a [`Profile`]. fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult { - let folders = self - .db - .sync_folder_list() - .map_err(|e| CoreError::internal(format!("db: {e:?}")))? - .into_iter() - .map(|f| ProfileFolder { - remote_uid: f.remote_uid, - local_path: f.local_path, - // A queued switch is where the user asked the folder to be, so - // that is what the next machine should set up. - mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()), - }) - .collect(); + let folders = profile_folders( + self.db + .sync_folder_list() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?, + ); let pins = self .db .pin_list() @@ -389,6 +381,7 @@ impl Core { items: &[pdfs_core::control::RestoreItem], device: Option<&str>, ) -> CoreResult { + let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock(); if items.is_empty() { return Err(CoreError::invalid("nothing to restore")); } @@ -398,6 +391,11 @@ impl Core { .sync_folder_list() .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + // A caller must not smuggle a My files root into a device mapping, + // where destructive device-only operations would otherwise be allowed. + let device_root = super::parse_uid(&device.root_uid) + .ok_or_else(|| CoreError::internal("invalid device root"))?; + let children = self.device_root_nodes(&device_root)?; let reserved = crate::devices::sync_root_reserved(); let mut roots: Vec = existing .iter() @@ -406,6 +404,18 @@ impl Core { let mut restored = 0usize; let mut skipped: Vec = Vec::new(); for item in items { + if !children.iter().any(|node| { + node.uid.to_string() == item.remote_uid + && node.is_folder() + && !node.trashed + && node.name != PROFILE_DIR_NAME + }) { + skipped.push(format!( + "{}: not a folder under this device", + 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)) { @@ -468,6 +478,19 @@ impl Core { } } +/// Only device backups belong in the device's cross-machine profile. +fn profile_folders(folders: Vec) -> Vec { + folders + .into_iter() + .filter(|f| f.kind == "device") + .map(|f| ProfileFolder { + remote_uid: f.remote_uid, + local_path: f.local_path, + mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()), + }) + .collect() +} + /// What a restore did: how many folders it attached, and why it left out the /// others. pub(crate) struct RestoreOutcome { @@ -530,6 +553,21 @@ fn default_local_path(name: &str) -> PathBuf { mod tests { use super::*; + #[test] + fn attached_my_files_folders_are_not_in_the_device_profile() { + let dir = crate::sim::daemon::scratch("profile-folder-kinds"); + let db = pdfs_core::db::Db::open(&dir.join("cache.db")).unwrap(); + db.sync_folder_add("/backup", "vol~backup", "device-share") + .unwrap(); + db.sync_folder_attach("/work", "vol~work", "my-files-share") + .unwrap(); + let folders = profile_folders(db.sync_folder_list().unwrap()); + assert_eq!(folders.len(), 1); + assert_eq!(folders[0].remote_uid, "vol~backup"); + drop(db); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn foreign_home_is_not_reused() { assert!(!path_is_usable_here(Path::new( diff --git a/crates/pdfs-fuse/src/relocate.rs b/crates/pdfs-fuse/src/relocate.rs index 945d85e..f835517 100644 --- a/crates/pdfs-fuse/src/relocate.rs +++ b/crates/pdfs-fuse/src/relocate.rs @@ -555,6 +555,7 @@ mod tests { fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder { StoredSyncFolder { + kind: "device".into(), id, local_path: local_path.to_string(), remote_uid: format!("vol~root{id}"), diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 561aa83..d1f2d41 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -2912,6 +2912,433 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + #[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"] + fn attaching_my_files_preserves_local_files_and_syncs_both_ways() { + use std::os::unix::fs::MetadataExt; + let drive = FakeDrive::new(); + let dir = scratch("attach-my-files"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work").is_some() + })); + drive.device().write("Work/cloud.txt", b"from cloud"); + drive.device().write("Work/common.txt", b"remote version"); + drive.device().write("Work/identical.txt", b"same bytes"); + let local = dir.join("local"); + std::fs::create_dir(&local).unwrap(); + std::fs::write(local.join("local.txt"), b"only on this computer").unwrap(); + std::fs::write(local.join("common.txt"), b"local version").unwrap(); + std::fs::write(local.join("identical.txt"), b"same bytes").unwrap(); + daemon + .request(&Request::AttachSyncFolder { + local_path: local.to_str().unwrap().into(), + remote_path: "Work".into(), + }) + .unwrap(); + let folder = match daemon.request(&Request::ListSyncFolders).unwrap() { + Response::SyncFolders { items } => { + items + .into_iter() + .find(|f| f.local_path == local.to_str().unwrap()) + .unwrap() + .id + } + other => panic!("unexpected reply: {other:?}"), + }; + assert!(wait_until(Duration::from_secs(30), || { + drive.lookup("Work/local.txt").is_some() + && std::fs::read(local.join("cloud.txt")).is_ok_and(|b| b == b"from cloud") + && std::fs::read(local.join("common.txt")).is_ok_and(|b| b == b"remote version") + && !conflict_copies(&local, "common").is_empty() + })); + assert_ne!( + std::fs::metadata(&local).unwrap().dev(), + std::fs::metadata(&daemon.mountpoint).unwrap().dev() + ); + assert!( + conflict_copies(&local, "common") + .iter() + .any(|p| std::fs::read(p).is_ok_and(|b| b == b"local version")) + ); + assert!( + daemon + .request(&Request::AttachSyncFolder { + local_path: local.to_str().unwrap().into(), + remote_path: "Work".into() + }) + .is_err() + ); + let duplicate = dir.join("duplicate"); + std::fs::create_dir(&duplicate).unwrap(); + assert!( + daemon + .request(&Request::AttachSyncFolder { + local_path: duplicate.to_str().unwrap().into(), + remote_path: "Work".into() + }) + .is_err() + ); + assert!( + daemon + .request(&Request::AttachSyncFolder { + local_path: duplicate.to_str().unwrap().into(), + remote_path: "../Work".into() + }) + .is_err() + ); + // Parent/child mappings, the account root and files cannot be attached. + std::fs::create_dir(daemon.mountpoint.join("Work/Sub")).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + && drive.lookup("Work/Sub").is_some() + })); + for remote in ["Work/Sub", ".", "Work/cloud.txt"] { + assert!( + daemon + .request(&Request::AttachSyncFolder { + local_path: duplicate.to_str().unwrap().into(), + remote_path: remote.into(), + }) + .is_err() + ); + } + // Local overlap and symlink aliases into the actual cloud mount are refused. + let alias = dir.join("cloud-alias"); + std::os::unix::fs::symlink(daemon.mountpoint.join("Work"), &alias).unwrap(); + std::fs::create_dir_all(local.join("Sub")).unwrap(); + for path in [ + local.join("Sub"), + dir.clone(), + daemon.mountpoint.join("Work"), + alias, + ] { + let error = daemon + .request(&Request::AttachSyncFolder { + local_path: path.to_string_lossy().into_owned(), + remote_path: "Work/Sub".into(), + }) + .unwrap_err() + .to_string(); + assert!(error.contains("overlaps"), "{error}"); + } + assert!(conflict_copies(&local, "identical").is_empty()); + assert_eq!( + std::fs::read(local.join("identical.txt")).unwrap(), + b"same bytes" + ); + // Simultaneous requests cannot register two local trees for one cloud root. + std::fs::create_dir(daemon.mountpoint.join("Other")).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Other").is_some() + })); + let candidate_a = dir.join("candidate-a"); + let candidate_b = dir.join("candidate-b"); + std::fs::create_dir(&candidate_a).unwrap(); + std::fs::create_dir(&candidate_b).unwrap(); + let outcomes = std::thread::scope(|scope| { + let a = scope.spawn(|| { + daemon + .request(&Request::AttachSyncFolder { + local_path: candidate_a.to_str().unwrap().into(), + remote_path: "Other".into(), + }) + .map_err(|e| e.to_string()) + }); + let b = scope.spawn(|| { + daemon + .request(&Request::AttachSyncFolder { + local_path: candidate_b.to_str().unwrap().into(), + remote_path: "Other".into(), + }) + .map_err(|e| e.to_string()) + }); + [a.join().unwrap(), b.join().unwrap()] + }); + assert_eq!( + outcomes.iter().filter(|result| result.is_ok()).count(), + 1, + "exactly one mapping may own a remote root: {outcomes:?}" + ); + drive + .device() + .write("Work/cloud.txt", b"new cloud revision"); + daemon + .request(&Request::SyncNow { id: Some(folder) }) + .unwrap(); + assert!(wait_until(Duration::from_secs(30), || std::fs::read( + local.join("cloud.txt") + ) + .is_ok_and(|b| b == b"new cloud revision"))); + std::fs::write(local.join("local.txt"), b"new local revision").unwrap(); + daemon + .request(&Request::SyncNow { id: Some(folder) }) + .unwrap(); + assert!(wait_until(Duration::from_secs(30), || drive + .tree() + .get("Work/local.txt") + == Some(&Entry::File(Arc::new(b"new local revision".to_vec()))))); + assert!(wait_until(Duration::from_secs(30), || listed_folder( + &daemon, folder + ) + .is_some_and(|f| f.state == "idle"))); + daemon + .request(&Request::SetSyncFolderPaused { + id: folder, + paused: true, + }) + .unwrap(); + let before = drive.tree(); + for entry in std::fs::read_dir(&local).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + std::fs::remove_dir_all(path).unwrap(); + } else { + std::fs::remove_file(path).unwrap(); + } + } + daemon + .request(&Request::SetSyncFolderPaused { + id: folder, + paused: false, + }) + .unwrap(); + daemon + .request(&Request::SyncNow { id: Some(folder) }) + .unwrap(); + assert!(wait_until(Duration::from_secs(30), || listed_folder( + &daemon, folder + ) + .is_some_and(|f| f.state == "error"))); + assert_eq!( + drive.tree(), + before, + "the wipe guard must preserve the remote tree" + ); + assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); + let db = Db::open(&dir.join("pdfs.db")).unwrap(); + assert_eq!( + db.sync_folder_get(folder).unwrap().unwrap().remote_share_id, + "my-files-share" + ); + drop(db); + let _ = std::fs::remove_dir_all(&dir); + } + + fn attached_my_files(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 { + std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.drained() && drive.lookup("Work").is_some() + })); + std::fs::create_dir(local).unwrap(); + daemon + .request(&Request::AttachSyncFolder { + local_path: local.to_string_lossy().into_owned(), + remote_path: "Work".into(), + }) + .unwrap(); + match daemon.request(&Request::ListSyncFolders).unwrap() { + Response::SyncFolders { items } => { + items + .into_iter() + .find(|f| f.local_path == local.to_string_lossy()) + .unwrap() + .id + } + other => panic!("unexpected reply: {other:?}"), + } + } + + #[test] + #[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"] + fn attached_my_files_views_preserve_concurrent_edits() { + let drive = FakeDrive::new(); + let dir = scratch("attach-views"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let local = dir.join("local"); + let id = attached_my_files(&daemon, &drive, &local); + let mounted = daemon.mountpoint.join("Work"); + // Prime the mount listing before a mirror upload, to catch stale listings. + assert_eq!(std::fs::read_dir(&mounted).unwrap().count(), 0); + std::fs::write(local.join("mirror.txt"), b"mirror bytes").unwrap(); + daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); + assert!( + wait_until(Duration::from_secs(30), || { + std::fs::read_dir(&mounted) + .unwrap() + .any(|e| e.unwrap().file_name() == "mirror.txt") + && std::fs::read(mounted.join("mirror.txt")).is_ok_and(|b| b == b"mirror bytes") + }), + "mirror upload must reach the mounted view without a restart" + ); + std::fs::write(mounted.join("mount.txt"), b"mounted bytes").unwrap(); + assert!(wait_until(Duration::from_secs(30), || daemon.drained())); + daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); + assert!( + wait_until(Duration::from_secs(30), || { + std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mounted bytes") + && listed_folder(&daemon, id).is_some_and(|f| f.state == "idle") + }), + "mounted writes must reach the mirror" + ); + + daemon + .request(&Request::SetSyncFolderPaused { id, paused: true }) + .unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + listed_folder(&daemon, id).is_some_and(|f| f.state != "syncing") + })); + std::fs::write(mounted.join("mount.txt"), b"mount changed").unwrap(); + assert!(wait_until(Duration::from_secs(30), || daemon.drained())); + std::fs::write(local.join("mount.txt"), b"local changed").unwrap(); + daemon + .request(&Request::SetSyncFolderPaused { id, paused: false }) + .unwrap(); + daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); + assert!( + wait_until(Duration::from_secs(30), || { + std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mount changed") + && conflict_copies(&local, "mount") + .iter() + .any(|p| std::fs::read(p).is_ok_and(|b| b == b"local changed")) + && drive.tree().iter().any(|(path, entry)| { + path.starts_with("Work/mount (sync-conflict") + && entry == &Entry::File(Arc::new(b"local changed".to_vec())) + }) + }), + "both edits must survive the next pass" + ); + + let error = daemon + .request(&Request::SetSyncFolderMode { + id, + mode: "ondemand".into(), + }) + .unwrap_err() + .to_string(); + assert!(error.contains("only support mirror"), "{error}"); + let error = daemon + .request(&Request::RemoveSyncFolder { + id, + delete_remote: true, + }) + .unwrap_err() + .to_string(); + assert!(error.contains("cannot be deleted"), "{error}"); + let folder = listed_folder(&daemon, id).unwrap(); + assert_eq!(folder.kind, "attached"); + assert_eq!(folder.mode, "mirror"); + assert!(folder.pending_mode.is_none()); + // Restore must not reclassify a My files root as a device backup, + // which would make the device-only destructive operations available. + let restore_path = dir.join("forged-device-restore"); + daemon + .request(&Request::RestoreSyncFolders { + items: vec![control::RestoreItem { + remote_uid: folder.remote_uid.clone(), + local_path: restore_path.to_string_lossy().into_owned(), + mode: "ondemand".into(), + }], + }) + .unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + matches!(daemon.request(&Request::ListActivity { limit: 100 }), + Ok(Response::Activity { items }) if items.iter().any(|item| { + !item.ok && item.detail.contains("not a folder under this device") + })) + })); + assert!(!restore_path.exists()); + match daemon.request(&Request::ListLocations).unwrap() { + Response::Locations { items } => assert!(items.iter().any(|m| { + matches!(m.kind, pdfs_core::mounts::MountKind::Attached { sync_folder_id } if sync_folder_id == id) + })), + other => panic!("unexpected reply: {other:?}"), + } + daemon + .request(&Request::RemoveSyncFolder { + id, + delete_remote: false, + }) + .unwrap(); + assert!(listed_folder(&daemon, id).is_none()); + assert!(drive.lookup("Work/mount.txt").is_some()); + assert_eq!( + std::fs::read(local.join("mount.txt")).unwrap(), + b"mount changed" + ); + daemon.stop().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + #[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"] + fn attached_my_files_trashed_root_keeps_local_files() { + attached_root_loss_keeps_local_files(false); + } + + #[test] + #[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"] + fn attached_my_files_root_moved_outside_my_files_keeps_local_files() { + attached_root_loss_keeps_local_files(true); + } + + fn attached_root_loss_keeps_local_files(move_outside: bool) { + let drive = FakeDrive::new(); + let dir = scratch("attach-root-gone"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let local = dir.join("local"); + let id = attached_my_files(&daemon, &drive, &local); + std::fs::write(local.join("keep.txt"), b"keep my bytes").unwrap(); + daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + drive.lookup("Work/keep.txt").is_some() + && listed_folder(&daemon, id).is_some_and(|f| f.state == "idle") + })); + daemon + .request(&Request::SetSyncFolderPaused { id, paused: true }) + .unwrap(); + if move_outside { + use crate::drive::DriveApi; + let rt = daemon.rt.as_ref().unwrap(); + let device = rt + .block_on( + daemon + .client + .create_device("other computer", proton_drive_rs::DeviceType::Linux), + ) + .unwrap(); + let root = drive.lookup("Work").unwrap(); + rt.block_on(daemon.client.move_node(&root, &device.root_folder_uid)) + .unwrap(); + } else { + drive.device().trash("Work"); + } + daemon + .request(&Request::SetSyncFolderPaused { id, paused: false }) + .unwrap(); + daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + listed_folder(&daemon, id).is_some_and(|f| f.state == "error") + })); + assert_eq!( + std::fs::read(local.join("keep.txt")).unwrap(), + b"keep my bytes" + ); + assert_eq!(std::fs::read_dir(&local).unwrap().count(), 1); + let daemon = daemon.restart().unwrap(); + daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + listed_folder(&daemon, id).is_some_and(|f| f.state == "error") + })); + assert_eq!( + std::fs::read(local.join("keep.txt")).unwrap(), + b"keep my bytes" + ); + daemon.stop().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + /// Register `local` as a synced folder in `mode` and wait until the daemon /// lists it that way, returning its id. fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 { diff --git a/crates/pdfs-fuse/src/sim/fake_drive.rs b/crates/pdfs-fuse/src/sim/fake_drive.rs index eaf7e52..cbfc18e 100644 --- a/crates/pdfs-fuse/src/sim/fake_drive.rs +++ b/crates/pdfs-fuse/src/sim/fake_drive.rs @@ -1123,6 +1123,14 @@ fn delete_one(server: &mut Server, uid: &NodeUid) -> Result<()> { #[async_trait] impl DriveApi for FakeClient { + async fn context_share_id(&self, uid: &NodeUid) -> Result { + self.request(false, |server| { + assert_eq!(uid, &server.root); + Ok(ShareId::new("my-files-share")) + }) + .await + } + async fn get_my_files_folder(&self) -> Result { self.request(false, |server| { let root = server.root.clone(); diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index 23f7dfc..3d0b9dc 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -170,7 +170,24 @@ impl Core { } let started = Instant::now(); self.progress_begin(folder.id); - let result = if push_only { + // An attachment's root can be trashed or moved by the web app. A + // missing root must never turn into a scan of an empty remote tree. + let attached_root = if current.kind == "attached" { + self.attach_ancestors(&remote_root).and_then(|ancestors| { + if ancestors.contains(&self.primary_root_uid) { + Ok(()) + } else { + Err(pdfs_core::CoreError::invalid( + "attached folder is no longer below My files", + )) + } + }) + } else { + Ok(()) + }; + let result = if let Err(error) = attached_root { + Err(PassAbort::Failed(error.to_string())) + } else if push_only { self.push_pass(folder.id, &local_root, &remote_root, &rules) } else { match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) { diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index b11517a..4956266 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -588,6 +588,7 @@ fn platform_label(device_type: &str) -> &str { /// How many folders this machine backs up, and whether any of them needs /// attention — the one fact worth surfacing away from the folder list itself. pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String { + let folders: Vec<_> = folders.iter().filter(|f| f.kind == "device").collect(); if folders.is_empty() { return gettext("No folders backed up yet"); } @@ -939,7 +940,13 @@ pub(crate) fn reload_sync_pages(ui: &Rc) { } /// Confirm, then stop syncing a folder. Offers to also delete the cloud copy. -pub(crate) fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondemand: bool) { +pub(crate) fn prompt_remove_sync_folder( + ui: &Rc, + id: i64, + path: &str, + ondemand: bool, + attached: bool, +) { let win = ui_window(ui); // The two modes leave the user in opposite places, so they can't share a // sentence. A mirror folder's files are already on this disk and stay there. @@ -947,7 +954,12 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondema // in Proton Drive, so unmounting it leaves an empty directory — which is a // nasty surprise if the dialog claimed the local files were safe, and is // recoverable only by turning On-demand off *first* and letting it download. - let body = if ondemand { + let body = if attached { + gettext_f( + "Stop syncing “{path}”? Both the local files and the folder in My files are kept.", + &[("path", path)], + ) + } else if ondemand { // Translators: {path} is a local folder path. gettext_f( "Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.", @@ -975,7 +987,9 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondema .active(false) .build(); group.add(&delete_remote); - dialog.set_extra_child(Some(&group)); + if !attached { + dialog.set_extra_child(Some(&group)); + } dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("remove", &gettext("Stop Syncing")); dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); @@ -988,7 +1002,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondema &ui, Request::RemoveSyncFolder { id, - delete_remote: delete_remote.is_active(), + delete_remote: !attached && delete_remote.is_active(), }, &gettext("Stopped syncing folder"), &gettext("Couldn't stop syncing the folder"), @@ -1291,6 +1305,7 @@ mod tests { fn folder(state: &str) -> SyncFolderInfo { SyncFolderInfo { + kind: "device".into(), id: 1, local_path: "/home/u/Documents".into(), remote_uid: "vol~link".into(), @@ -1303,6 +1318,16 @@ mod tests { } } + #[test] + fn attached_my_files_folders_do_not_count_as_device_backups() { + let mut attached = folder("error"); + attached.kind = "attached".into(); + assert_eq!( + this_computer_subtitle(&[attached]), + "No folders backed up yet" + ); + } + #[test] fn a_machine_with_no_folders_says_so() { assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet"); diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index a538248..b6cf862 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -1361,6 +1361,9 @@ fn location_row(ui: &Rc, spec: &MountSpec) -> LocationRow { prompt_mountpoint(&ui) }); } + MountKind::Attached { sync_folder_id } => { + add_device_items(ui, &mut menu, spec, *sync_folder_id); + } MountKind::Device { sync_folder_id } => { row.add_suffix(&mode_button(ui, spec, *sync_folder_id)); add_device_items(ui, &mut menu, spec, *sync_folder_id); @@ -1520,8 +1523,9 @@ fn add_device_items(ui: &Rc, menu: &mut ActionMenu, spec: &MountSpec, id: i6 // The folder's *current* mode, not a queued one: a switch that hasn't landed // yet has not moved the files anywhere. let is_ondemand = spec.mode == MountMode::OnDemand; + let attached = matches!(spec.kind, MountKind::Attached { .. }); menu.item(&gettext("Stop Syncing…"), move || { - prompt_remove_sync_folder(&ui, id, &path, is_ondemand) + prompt_remove_sync_folder(&ui, id, &path, is_ondemand, attached) }); } @@ -1699,7 +1703,7 @@ fn sync_folder_now(ui: &Rc, id: i64) { pub(crate) fn location_icon(kind: &MountKind) -> &'static str { match kind { MountKind::MyFiles => "folder-remote-symbolic", - MountKind::Device { .. } => "folder-symbolic", + MountKind::Device { .. } | MountKind::Attached { .. } => "folder-symbolic", MountKind::Shared { .. } => "pdfs-people-symbolic", } } @@ -1716,6 +1720,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String { match &spec.kind { MountKind::MyFiles => parts.push(gettext("My files")), MountKind::Shared { .. } => parts.push(gettext("Shared folder")), + MountKind::Attached { .. } => parts.push(gettext("My files mirror")), MountKind::Device { .. } => match (spec.pending_mode, spec.mode) { (Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")), (Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")), @@ -1725,7 +1730,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String { (Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)), }, } - if matches!(spec.kind, MountKind::Device { .. }) { + if matches!( + spec.kind, + MountKind::Device { .. } | MountKind::Attached { .. } + ) { parts.push(match &spec.progress { Some(p) => sync_progress_label(p), None if spec.paused => pgettext("state", "paused"), @@ -1737,8 +1745,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String { } // Only the locations that are supposed to have a session report its absence: // a mirror folder never has one. - let expects_session = !matches!(spec.kind, MountKind::Device { .. }) - || spec.mode == MountMode::OnDemand + let expects_session = !matches!( + spec.kind, + MountKind::Device { .. } | MountKind::Attached { .. } + ) || spec.mode == MountMode::OnDemand || spec.pending_mode == Some(MountMode::OnDemand); if expects_session && !spec.mounted { // Translators: a folder's files can't be opened right now. @@ -1769,6 +1779,18 @@ mod tests { } } + #[test] + fn an_attached_mirror_is_available_without_a_fuse_session() { + let spec = spec( + MountKind::Attached { sync_folder_id: 7 }, + MountMode::Mirror, + false, + ); + let subtitle = location_subtitle(&spec); + assert!(subtitle.contains("My files mirror"), "{subtitle}"); + assert!(!subtitle.contains("not available"), "{subtitle}"); + } + #[test] fn a_mirror_folder_is_never_reported_as_unmounted() { // It is a plain local directory: no FUSE session is expected, so saying diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 968efc8..fe0030d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,16 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] +Schema: **45**. Synced folders now distinguish device backups from My files attachments. +Existing folders remain device backups; older builds cannot open the upgraded database. + +### Added + +- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing + My files folder. The first sync preserves one-sided files and differing versions (#67). + Attachments stay in mirror mode, are excluded from device profiles, and cannot delete + the remote root when syncing is stopped. + ## [3.7.0] — 2026-11-02 Schema: **44**, unchanged. diff --git a/docs/CLI.md b/docs/CLI.md index 96b6134..2ff6c3d 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -80,6 +80,7 @@ commands are for scripts and for cases where the mount is not available. | Command | Description | |---|---| | `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. | +| `pdfs sync attach PATH --to REMOTE` | Merge a real local folder with an existing My files folder, keeping both versions on initial conflicts. | | `pdfs sync list` | List synced folders with their IDs and state. | | `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. | | `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). | diff --git a/docs/SYNCED_FOLDERS.md b/docs/SYNCED_FOLDERS.md index 9d5a34b..65a33ee 100644 --- a/docs/SYNCED_FOLDERS.md +++ b/docs/SYNCED_FOLDERS.md @@ -5,6 +5,7 @@ up to Proton Drive and kept in step with it. This page covers adding one, what t that should not be backed up, changes that are still waiting to upload, conflicts, and pausing. - [Add a folder](#add-a-folder) +- [Attach an existing My files folder](#attach-an-existing-my-files-folder) - [Synced or online only](#synced-or-online-only) - [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files) - [Stop syncing a folder](#stop-syncing-a-folder) @@ -74,7 +75,8 @@ again when they move into a synced one. **Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**, -or add `--delete-remote`. +or add `--delete-remote`. Attached My files folders always keep the cloud copy; +`--delete-remote` is refused for them. ## Sync a folder from scratch @@ -169,3 +171,28 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone. `pdfs sync resume` continues at once. - **Limit bandwidth** under **Preferences → General → Network**, or with `pdfs sync limit --up 2M --down 10M`. `0` removes a limit. + +## Attach an existing My files folder + +Run `pdfs sync attach /path/to/local --to Work` to merge a real local directory +with the existing `Work` folder in My files. `--to` also accepts an absolute path +inside the My files mount. Both folders must already exist. + +The local directory stays a normal mirrored directory. The first pass copies files +found on only one side, adopts identical files, and keeps a conflict copy when +versions differ; it does not propagate deletions. Later passes use ordinary +bidirectional sync, including deletions and the existing wipe guards. + +The My files root, shared folders, overlapping sync roots, and local paths inside +or containing the cloud mount are refused. Use `pdfs sync list` to find the new +folder's ID and monitor its state. `pdfs sync rm ID` stops syncing and keeps both +copies. `--delete-remote` is refused for attached folders. + +Attached folders support **mirror** mode only. Use the main My files mount for online-only +access. They are listed separately from device backups and are not included in the device +profile used to restore another computer. + +On the first sync, the remote version keeps the original name when both versions differ. +The local version is preserved as a conflict copy next to it and is uploaded too. If the +remote root is trashed or moved outside My files, syncing stops with an error and local +files are kept.