diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index c8cdf35..b7425ab 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2319,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 { @@ -4293,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 0407f94..b0dc401 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -992,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, @@ -1022,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 index 810b2c5..0819bdb 100644 --- a/crates/pdfs-fuse/src/attach.rs +++ b/crates/pdfs-fuse/src/attach.rs @@ -13,7 +13,7 @@ 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. - fn attach_ancestors(&self, start: &NodeUid) -> CoreResult> { + 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()); @@ -100,7 +100,7 @@ impl Core { "choose an owned folder below My files, not its root or a shared folder", )); } - for other in &existing { + 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 @@ -122,7 +122,7 @@ impl Core { } let id = self .db - .sync_folder_add(&local.to_string_lossy(), &remote_uid.to_string(), &share_id) + .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. diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index e2209a1..4985d36 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -1406,20 +1406,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U core.attach_sync_folder(Path::new(&local_path), &remote, mountpoint) }); match result { - Ok(folder) => { - core.log_activity( - ActivityKind::Upload, - &local_path, - "attached existing My files folder", - true, - ); - CtlResponse::Ok { - message: format!( - "sync folder {} attached: {}", - folder.id, folder.local_path - ), - } - } + Ok(folder) => CtlResponse::Ok { + message: format!("sync folder {} attached: {}", folder.id, folder.local_path), + }, Err(error) => CtlResponse::error(error), } } diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 1129ebe..20b602e 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -157,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); } @@ -500,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)) @@ -705,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. @@ -791,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 @@ -966,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); } @@ -1205,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 { @@ -1401,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/lib.rs b/crates/pdfs-fuse/src/lib.rs index e2432e9..af1a731 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -6442,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 23aa8ac..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() @@ -399,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() @@ -407,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)) { @@ -469,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 { @@ -531,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 6c2ee39..d1f2d41 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -3126,6 +3126,219 @@ mod tests { 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/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 17ecb14..fe0030d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,10 +11,15 @@ 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 diff --git a/docs/CLI.md b/docs/CLI.md index 14a3b2d..2ff6c3d 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -79,8 +79,8 @@ commands are for scripts and for cases where the mount is not available. | Command | Description | |---|---| -| `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 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 504f205..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 @@ -183,5 +185,14 @@ 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; adding -`--delete-remote` also trashes the linked cloud folder. +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.