//! Reading and writing this machine's `profile.json` in its device root, and //! the restore that rebuilds a machine from one. //! //! The document itself is defined in [`pdfs_core::profile`]; this is the half //! that talks to Drive. Saving is best-effort and never blocks a user action: //! the profile is a convenience for a *future* machine, so failing to write it //! must not fail the folder add that triggered it. use std::io::Cursor; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; use pdfs_core::control::RestorableFolder; use pdfs_core::mounts::MountMode; use pdfs_core::profile::{ PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin, is_profile_file_name, profile_file_name, saved_folder, }; use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::ids::NodeUid; use tracing::{info, warn}; use super::sync; use super::{Core, now_secs, parse_uid, this_hostname}; /// Coalescing flags for background profile saves. /// /// A single burst of user actions (adding three folders, pinning a tree) should /// cost one upload, not one per action. `SAVING` admits one worker at a time and /// `DIRTY` records that something changed while it was busy, so the worker takes /// exactly one more lap afterwards. /// /// Process-global rather than a [`Core`] field because on-demand mounts fork /// their own `Core` (`Core::fork_state`) and all of them describe the same /// machine — there is one profile per daemon, so there is one flag pair. static SAVE_STATE: OnceLock<(AtomicBool, AtomicBool)> = OnceLock::new(); fn save_state() -> &'static (AtomicBool, AtomicBool) { SAVE_STATE.get_or_init(|| (AtomicBool::new(false), AtomicBool::new(false))) } impl Core { /// Note that the arrangement this machine's profile describes has changed, /// and upload a fresh profile in the background. /// /// Fire-and-forget by design: callers are mutation paths (add/remove a sync /// folder, switch a mode, pin a node, save settings) whose success does not /// depend on the backup succeeding. pub(crate) fn touch_profile(&self) { let (saving, dirty) = save_state(); dirty.store(true, Ordering::SeqCst); // Someone is already uploading; they will see DIRTY and take another lap. if saving.swap(true, Ordering::SeqCst) { return; } let core = self.clone(); std::thread::spawn(move || { let (saving, dirty) = save_state(); while dirty.swap(false, Ordering::SeqCst) { if let Err(e) = core.save_profile() { warn!(error = %e.message, "profile backup failed"); break; } } saving.store(false, Ordering::SeqCst); }); } /// Serialize this machine's arrangement and upload it to its own /// `profile-.json` inside the device's `.proton-drive-linux` /// folder, replacing any existing one. /// /// Each machine writes its own file because several computers may sync /// into one device. They used to share `profile.json`, and each save /// replaced the other computers' folders with its own (B252). That file is /// left as it is, so a client before 3.5.0 still finds it. /// /// Does nothing when no device is registered: a machine that has never /// synced a folder has nothing to describe, and registering a device just to /// hold an empty profile would put a stray computer in the user's Drive. pub(crate) fn save_profile(&self) -> CoreResult<()> { let Some(device) = self .db .device_get() .map_err(|e| CoreError::internal(format!("db: {e:?}")))? else { return Ok(()); }; let root_uid = parse_uid(&device.root_uid).ok_or_else(|| { CoreError::internal(format!("bad device root uid: {}", device.root_uid)) })?; let machine_id = pdfs_core::config::AppDirs::new() .and_then(|dirs| dirs.machine_id()) .map_err(|e| CoreError::internal(format!("machine id: {e}")))?; let file_name = profile_file_name(&machine_id); let profile = self.build_profile(&device.uid, &machine_id)?; let bytes = profile.to_bytes().map_err(CoreError::internal)?; let len = bytes.len() as i64; // The device root itself rejects file creation (B78), so the profile // lives one level down, in a folder created on first save. let dir_uid = self.ensure_profile_dir(&root_uid)?; // Replace the existing document rather than adding a second file of the // same name: the profile is a single mutable record, and a folder with // `profile.json` and `profile (1).json` in it teaches the restore // nothing about which to trust. let existing = self.find_device_child_file(&dir_uid, &file_name)?; match existing { Some(uid) => self .rt .block_on(self.drive.upload_new_revision_from( &uid, Cursor::new(bytes), len, Vec::new(), Some(now_secs()), )) .map(|_| ()) .map_err(|e| CoreError::from_api(&e, "upload profile revision"))?, None => { self.rt .block_on(self.drive.upload_file_replacing_draft_from( &dir_uid, &file_name, "application/json", Cursor::new(bytes), len, Vec::new(), Some(now_secs()), false, )) .map_err(|e| CoreError::from_api(&e, "upload profile"))?; } } info!(folders = profile.folders.len(), "profile backed up"); Ok(()) } /// 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 pins = self .db .pin_list() .map_err(|e| CoreError::internal(format!("db: {e:?}")))? .into_iter() .map(|row| ProfilePin { uid: row.uid, path: row.path, recursive: row.recursive, }) .collect(); let cfg = pdfs_core::config::AppDirs::new() .map(|d| d.load_config()) .unwrap_or_default(); Ok(Profile { version: PROFILE_VERSION, device_uid: device_uid.to_string(), hostname: this_hostname(), machine_id: Some(machine_id.to_string()), saved_at: now_secs(), folders, pins, ignore_patterns: cfg.ignore_patterns, cache_budget: cfg.cache_budget, mountpoint: cfg.mountpoint, }) } /// An untrashed *file* named `name` directly under `parent_uid`. /// /// The sibling of `find_device_child_folder`; kept separate because a folder /// named `profile.json` must not be mistaken for the profile. fn find_device_child_file( &self, parent_uid: &NodeUid, name: &str, ) -> CoreResult> { let uids = self .rt .block_on(self.drive.enumerate_folder_children_node_uids(parent_uid)) .map_err(|e| CoreError::from_api(&e, "list device root"))?; if uids.is_empty() { return Ok(None); } let nodes = self .rt .block_on(self.drive.enumerate_nodes(&uids)) .map_err(|e| CoreError::from_api(&e, "resolve device root children"))?; Ok(nodes .into_iter() .find(|n| !n.is_folder() && !n.trashed && n.name == name) .map(|n| n.uid)) } /// The untrashed files directly under `parent_uid` whose name passes /// `keep`. fn device_child_files( &self, parent_uid: &NodeUid, keep: impl Fn(&str) -> bool, ) -> CoreResult> { let uids = self .rt .block_on(self.drive.enumerate_folder_children_node_uids(parent_uid)) .map_err(|e| CoreError::from_api(&e, "list device root"))?; if uids.is_empty() { return Ok(Vec::new()); } let nodes = self .rt .block_on(self.drive.enumerate_nodes(&uids)) .map_err(|e| CoreError::from_api(&e, "resolve device root children"))?; Ok(nodes .into_iter() .filter(|n| !n.is_folder() && !n.trashed && keep(&n.name)) .map(|n| n.uid) .collect()) } /// The device's `.proton-drive-linux` folder, creating it if this is the /// first save on this device. /// /// Reused by stable identity rather than recreated: a second folder of the /// same name would split the profile across two documents, and the restore /// would have no way to tell which is current. fn ensure_profile_dir(&self, root_uid: &NodeUid) -> CoreResult { if let Some(uid) = self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? { return Ok(uid); } let uid = self .rt .block_on( self.drive .create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())), ) .map_err(|e| CoreError::from_api(&e, "create profile folder"))?; info!(name = PROFILE_DIR_NAME, "created device profile folder"); Ok(uid) } /// Download and parse the profiles of every computer that syncs this /// device. /// /// Reads every profile in `.proton-drive-linux/`: one per machine, and the /// shared `profile.json` of clients before 3.5.0. Falls back to the device /// root, where clients before the B78 fix wrote it. The fallback is /// read-only: the next save writes to the folder, and the legacy document /// is left alone rather than trashed, so an older client on another /// machine keeps working. A profile that cannot be read is left out and /// logged; the others still count. fn load_profiles(&self, root_uid: &NodeUid) -> CoreResult> { let mut uids = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? { Some(dir_uid) => self.device_child_files(&dir_uid, is_profile_file_name)?, None => Vec::new(), }; if uids.is_empty() { uids = self.device_child_files(root_uid, |name| name == PROFILE_FILE_NAME)?; if !uids.is_empty() { info!("reading legacy profile from the device root"); } } let mut profiles = Vec::new(); for uid in uids { let mut buf: Vec = Vec::new(); let parsed = self .rt .block_on(self.drive.download_file_to(&uid, &mut buf)) .map_err(|e| CoreError::from_api(&e, "download profile")) .and_then(|()| Profile::parse(&buf).map_err(CoreError::invalid)); match parsed { Ok(profile) => profiles.push(profile), Err(e) => { warn!(%uid, error = %e.message, "could not read a profile; leaving it out") } } } Ok(profiles) } // ---- restore ---------------------------------------- /// The folders under a device that could be synced here, each carrying a /// proposed local path. `device` names another registered device by uid; /// `None` is this machine's own. /// /// The proposal comes from the profile when one exists and its path is /// usable on this machine, and from `~/` otherwise — a restored /// `/home/other-user/Documents` would be wrong on a machine where that user /// does not exist, and silently creating it would be worse. pub(crate) fn list_restorable_folders( &self, device: Option<&str>, ) -> CoreResult> { let device = self.restore_source(device)?; let root_uid = parse_uid(&device.root_uid).ok_or_else(|| { CoreError::internal(format!("bad device root uid: {}", device.root_uid)) })?; let profiles = match self.load_profiles(&root_uid) { Ok(p) => p, // A missing or unreadable profile is not fatal: the remote tree // alone is enough to offer a restore, just with plainer defaults. Err(e) => { warn!(error = %e.message, "could not read profiles; proposing defaults"); Vec::new() } }; let hostname = this_hostname(); let uids = self .rt .block_on(self.drive.enumerate_folder_children_node_uids(&root_uid)) .map_err(|e| CoreError::from_api(&e, "list device root"))?; let nodes = if uids.is_empty() { Vec::new() } else { self.rt .block_on(self.drive.enumerate_nodes(&uids)) .map_err(|e| CoreError::from_api(&e, "resolve device root children"))? }; // Folders already synced here are not restorable — they are already // restored — but they are still listed, flagged, so the picker can show // the whole device rather than a confusing subset. let existing: Vec = self .db .sync_folder_list() .map_err(|e| CoreError::internal(format!("db: {e:?}")))? .into_iter() .map(|f| f.remote_uid) .collect(); Ok(nodes .into_iter() // `.proton-drive-linux` holds the profile itself, not user data: // offering it as a restorable folder would sync the bookkeeping // back onto the machine it describes. .filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME) // The name becomes a folder under the home folder: one with a `/` // or a `..` in it would put the restore somewhere else. .filter(|n| crate::remote_name_is_safe(&n.name)) .map(|n| { let uid = n.uid.to_string(); let saved = saved_folder(&profiles, &uid, &hostname); let proposed = saved .map(|f| PathBuf::from(&f.local_path)) .filter(|p| path_is_usable_here(p)) .unwrap_or_else(|| default_local_path(&n.name)); RestorableFolder { already_synced: existing.iter().any(|e| e == &uid), remote_uid: uid, name: n.name, local_path: proposed.to_string_lossy().to_string(), mode: saved .map(|f| f.mode.to_string()) .unwrap_or_else(|| "mirror".into()), } }) .collect()) } /// Attach remote device folders to local paths and let the sync engine fill /// them in. /// /// The download is not written here: a new sync-folder row starts with an /// empty baseline, and an empty baseline against an empty local directory /// and a populated remote already classifies as "download everything". The /// restore is therefore just bookkeeping plus a `Reconcile`, and it reuses /// the engine's conflict handling for the case where the local directory /// turns out not to be empty after all. pub(crate) fn restore_sync_folders( &self, items: &[pdfs_core::control::RestoreItem], device: Option<&str>, ) -> CoreResult { if items.is_empty() { return Err(CoreError::invalid("nothing to restore")); } let device = self.restore_source(device)?; let existing = self .db .sync_folder_list() .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; let reserved = crate::devices::sync_root_reserved(); let mut roots: Vec = existing .iter() .map(|f| PathBuf::from(&f.local_path)) .collect(); let mut restored = 0usize; let mut skipped: Vec = Vec::new(); for item in items { // The path is proposed from the profile another machine wrote, and // may hold a folder name from Drive: checked like any new root. let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) { Ok(local) => local, Err(e) => { skipped.push(format!("{}: {e}", item.local_path)); continue; } }; if existing.iter().any(|f| f.remote_uid == item.remote_uid) { skipped.push(format!("{}: already synced", item.local_path)); continue; } if let Some(problem) = pdfs_core::syncroot::problem(&local, &roots, &reserved) { skipped.push(format!("{}: {problem}", item.local_path)); continue; } if let Err(e) = std::fs::create_dir_all(&local) { skipped.push(format!("{}: {e}", item.local_path)); continue; } let mode = match restore_mode(&item.mode) { Some(mode) => mode, None => { skipped.push(format!("{}: unknown mode {}", item.local_path, item.mode)); continue; } }; let id = match self.db.sync_folder_add( &local.to_string_lossy(), &item.remote_uid, &device.share_id, ) { Ok(id) => id, Err(e) => { // The folders restored before this one still need their // watches. let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); return Err(CoreError::internal(format!("db: {e:?}"))); } }; // `ondemand` is applied through the normal request path, which // mounts the FUSE session and handles the folder being busy — the // restore does not get its own copy of that logic. if mode == MountMode::OnDemand { let _ = self.request_sync_folder_mode(id, "ondemand"); } let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id)); roots.push(local); restored += 1; } let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); // The reply is gone once the app has shown it; the log keeps why. for reason in &skipped { info!(reason, "restore skipped a folder"); } info!(restored, skipped = skipped.len(), "restore complete"); Ok(RestoreOutcome { restored, skipped }) } } /// What a restore did: how many folders it attached, and why it left out the /// others. pub(crate) struct RestoreOutcome { pub(crate) restored: usize, pub(crate) skipped: Vec, } impl RestoreOutcome { /// The activity entry's headline. pub(crate) fn summary(&self) -> String { match (self.restored, self.skipped.len()) { (n, 0) => format!("restored {n} folder(s)"), (0, k) => format!("restored no folders; skipped {k}"), (n, k) => format!("restored {n} folder(s); skipped {k}"), } } /// Whether the restore did all it was asked. A skipped folder is a /// failure the user has to see: the reply went out as "restoring folders" /// before any work, and a restore that skipped every folder used to be /// logged as a success, so nothing anywhere said that nothing would /// download (`docs/BUGS.md` B269). pub(crate) fn complete(&self) -> bool { self.skipped.is_empty() } } /// Accept only modes this build can safely restore. `MountMode` itself is /// tolerant so a future value does not reject the whole profile; the folder /// carrying that value is skipped here with the message assembled above. fn restore_mode(value: &str) -> Option { match MountMode::from(value) { MountMode::Mirror => Some(MountMode::Mirror), MountMode::OnDemand => Some(MountMode::OnDemand), MountMode::Unknown => None, } } /// Whether a path recorded on another machine can be reused verbatim here. /// /// Requires the *parent* to exist: `~/Documents` on a machine with the same /// username is a fine proposal even though the directory itself is gone (the /// restore creates it), but `/home/someone-else/Documents` is not. fn path_is_usable_here(path: &Path) -> bool { path.is_absolute() && path.parent().is_some_and(|p| p.is_dir()) } /// `~/`, or `/` if the home directory cannot be determined. fn default_local_path(name: &str) -> PathBuf { match pdfs_core::config::AppDirs::new() .ok() .and_then(|d| d.home_dir()) { Some(home) => home.join(name), None => PathBuf::from("/").join(name), } } #[cfg(test)] mod tests { use super::*; #[test] fn foreign_home_is_not_reused() { assert!(!path_is_usable_here(Path::new( "/home/definitely-not-a-user-here/Documents" ))); assert!(!path_is_usable_here(Path::new("relative/path"))); } #[test] fn unknown_profile_mode_is_skipped_without_rejecting_known_modes() { assert_eq!(restore_mode("mirror"), Some(MountMode::Mirror)); assert_eq!(restore_mode("ondemand"), Some(MountMode::OnDemand)); assert_eq!(restore_mode("streaming"), None); assert_eq!(restore_mode("unknown"), None); } /// The device root refuses file creation (B78), so every upload in /// `save_profile` must be addressed to the profile folder. Checked against /// the source because both branches are pure network calls. #[test] fn the_profile_is_never_uploaded_to_the_device_root() { let source = include_str!("profile.rs"); let save = function_source(source, "fn save_profile("); assert!( save.contains("ensure_profile_dir(&root_uid)"), "save_profile resolves the profile folder" ); for upload in [ "upload_new_revision_from", "upload_file_replacing_draft_from", ] { let call = save.find(upload).expect("upload call exists"); let args = &save[call..]; let root = args.find("&root_uid").unwrap_or(usize::MAX); let dir = args.find("&dir_uid").unwrap_or(usize::MAX); assert!(dir < root, "{upload} must target the profile folder"); } } /// A read has to keep finding profiles written before the fix, which sat in /// the device root. #[test] fn loading_falls_back_to_a_legacy_device_root_profile() { let source = include_str!("profile.rs"); let load = function_source(source, "fn load_profiles("); assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder"); assert!( load.find("PROFILE_DIR_NAME").unwrap() < load.find("legacy profile").expect("legacy fallback exists"), "the folder is consulted before the device root" ); } /// Several computers may share a device, so each saves to a file named /// after its own machine id and never to the shared `profile.json` /// (B252). #[test] fn each_machine_saves_its_own_profile() { let save = function_source(include_str!("profile.rs"), "fn save_profile("); assert!(save.contains("profile_file_name(&machine_id)")); assert!(!save.contains("PROFILE_FILE_NAME")); } /// The profile folder is the client's own bookkeeping: neither the restore /// picker nor `add_sync_folder` may treat it as a user folder. #[test] fn the_profile_folder_is_not_offered_as_user_data() { let restorable = function_source(include_str!("profile.rs"), "fn list_restorable_folders("); assert!( restorable.contains("n.name != PROFILE_DIR_NAME"), "restorable folders exclude the profile folder" ); let add = function_source(include_str!("devices.rs"), "fn add_sync_folder("); assert_before(add, "PROFILE_DIR_NAME", "ensure_device()"); } /// Restoring another computer's folders reads that device, but must not /// rewrite the cached device row: this machine would then back up as the /// other computer. #[test] fn restoring_from_another_device_keeps_this_machines_identity() { let source = function_source(include_str!("devices.rs"), "fn restore_source("); assert!(source.contains("ensure_device()"), "None is this machine"); assert!( !source.contains("device_set"), "the cached row is untouched" ); } fn function_source<'a>(source: &'a str, signature: &str) -> &'a str { let start = source.find(signature).expect("function signature exists"); let body = &source[start..]; let open = body.find('{').expect("function body starts"); let mut depth = 0usize; for (offset, byte) in body.as_bytes()[open..].iter().enumerate() { match byte { b'{' => depth += 1, b'}' => { depth -= 1; if depth == 0 { return &body[..open + offset + 1]; } } _ => {} } } panic!("function body closes") } fn assert_before(source: &str, first: &str, second: &str) { let first_at = source.find(first).expect("first operation exists"); let second_at = source.find(second).expect("second operation exists"); assert!(first_at < second_at, "{first} must precede {second}"); } /// A path whose parent exists but which does not itself is exactly the /// restore case — the directory is about to be created. #[test] fn missing_leaf_under_existing_parent_is_reused() { let dir = std::env::temp_dir().join("pdfs-restore-probe"); std::fs::create_dir_all(&dir).unwrap(); assert!(path_is_usable_here(&dir.join("Documents"))); let _ = std::fs::remove_dir_all(&dir); } }