From 950056b48d783f16f8825566dcbec8e8fdf20674 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 14:55:13 +0200 Subject: [PATCH] feat(core,fuse): one profile per machine on a shared device Every computer that synced into one device wrote the same profile.json, so each save replaced the other computers' folders with its own (B252). Each machine now writes profile-.json, where the id is random, made on first use and kept in machine-id next to config.json. No host identifier goes to Drive. Restore reads every profile in .proton-drive-linux, including the shared one older clients wrote. A folder's proposed path and mode come from this computer's own profile, otherwise from the newest one that has the folder. The legacy file is left as it is, so a client before 3.5.0 still finds it. --- crates/pdfs-core/src/config.rs | 55 ++++++++++++++ crates/pdfs-core/src/profile.rs | 94 ++++++++++++++++++++++- crates/pdfs-fuse/src/profile.rs | 130 +++++++++++++++++++++++--------- 3 files changed, 241 insertions(+), 38 deletions(-) diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index 5d60d74..a48006e 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -406,6 +406,46 @@ impl AppDirs { Ok(()) } + /// This machine's id (e.g. ~/.config/proton-drive-linux/machine-id). See + /// [`machine_id`](Self::machine_id). + pub fn machine_id_path(&self) -> PathBuf { + self.dirs.config_dir().join("machine-id") + } + + /// A random id for this machine, made on first use and kept in + /// [`machine_id_path`](Self::machine_id_path). + /// + /// It names this machine's profile in a device it may share with other + /// computers. Random rather than `/etc/machine-id`, so nothing that + /// identifies the host goes to Drive. In a file of its own rather than + /// `config.json`, because the app saves the config from a copy it loaded + /// earlier, and that would drop an id the daemon had just added. + pub fn machine_id(&self) -> Result { + let path = self.machine_id_path(); + match std::fs::read_to_string(&path) { + Ok(id) if is_machine_id(id.trim()) => return Ok(id.trim().to_string()), + Ok(_) => { + tracing::warn!(path = %path.display(), "machine id is unusable; making a new one"); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + let mut bytes = [0u8; 16]; + use std::io::Read as _; + std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; + let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + let parent = path + .parent() + .ok_or_else(|| Error::Other("machine id path has no parent".into()))?; + std::fs::create_dir_all(parent)?; + let tmp = path.with_extension(format!("tmp-{}", std::process::id())); + std::fs::write(&tmp, format!("{id}\n"))?; + std::fs::File::open(&tmp)?.sync_all()?; + std::fs::rename(&tmp, &path)?; + std::fs::File::open(parent)?.sync_all()?; + Ok(id) + } + /// Persistent state: inode map / cache index DB lives here. pub fn state_dir(&self) -> PathBuf { // `state_dir` is Linux-only in `directories`; fall back to data dir. @@ -494,6 +534,12 @@ impl AppDirs { } } +/// Whether `id` is what [`AppDirs::machine_id`] writes: 32 lowercase hex +/// digits. +fn is_machine_id(id: &str) -> bool { + id.len() == 32 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + /// Make `dir` owner-only (`0700`). These directories contain decrypted bytes, /// plaintext names, and an authenticated control socket, so failure is fatal. fn restrict_dir(dir: &Path) -> Result<()> { @@ -805,6 +851,15 @@ mod tests { assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce)); } + #[test] + fn a_machine_id_is_32_lowercase_hex_digits() { + assert!(is_machine_id("0123456789abcdef0123456789abcdef")); + assert!(!is_machine_id("0123456789ABCDEF0123456789ABCDEF")); + assert!(!is_machine_id("0123456789abcdef")); + assert!(!is_machine_id("../../../../etc/passwd/0123456789")); + assert!(!is_machine_id("")); + } + #[test] fn a_config_predating_ignore_patterns_still_loads() { // Every installed config on disk looks like this; the field must not be diff --git a/crates/pdfs-core/src/profile.rs b/crates/pdfs-core/src/profile.rs index a0c439e..91ee174 100644 --- a/crates/pdfs-core/src/profile.rs +++ b/crates/pdfs-core/src/profile.rs @@ -19,9 +19,31 @@ use serde::{Deserialize, Serialize}; use crate::mounts::MountMode; -/// File name of the profile. +/// File name of the profile clients before 3.5.0 wrote, one per device. +/// +/// Read, never written: two computers sharing a device each overwrote the +/// other's. See [`profile_file_name`]. pub const PROFILE_FILE_NAME: &str = "profile.json"; +/// File name of one machine's profile: `profile-.json`. +/// +/// One per machine, because several computers may sync into one device, and +/// each describes its own arrangement. A single shared file went to whichever +/// computer saved last. +pub fn profile_file_name(machine_id: &str) -> String { + format!("profile-{machine_id}.json") +} + +/// Whether `name` is a profile: [`PROFILE_FILE_NAME`] or one machine's +/// [`profile_file_name`]. +pub fn is_profile_file_name(name: &str) -> bool { + name == PROFILE_FILE_NAME + || name + .strip_prefix("profile-") + .and_then(|rest| rest.strip_suffix(".json")) + .is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric())) +} + /// Folder, directly under the device root, that holds the profile. /// /// The API refuses `Cannot create file at the root of a device` (HTTP 422), so @@ -74,6 +96,10 @@ pub struct Profile { /// Hostname of the machine that wrote it, so a restore prompt can say /// *"restore settings from laptop-xyz?"* rather than quoting a uid. pub hostname: String, + /// The writing machine's id, as in its [`profile_file_name`]. `None` in a + /// profile written before 3.5.0. + #[serde(default)] + pub machine_id: Option, /// When it was written, epoch seconds. pub saved_at: i64, #[serde(default)] @@ -92,6 +118,25 @@ pub struct Profile { pub mountpoint: Option, } +/// The saved arrangement of the folder with uid `remote_uid`, from the +/// profiles of every computer that syncs the device. +/// +/// The profile of the machine named `hostname` wins, since it describes this +/// computer; otherwise the most recently saved one that has the folder. +pub fn saved_folder<'a>( + profiles: &'a [Profile], + remote_uid: &str, + hostname: &str, +) -> Option<&'a ProfileFolder> { + let holding = |p: &&'a Profile| p.folders.iter().any(|f| f.remote_uid == remote_uid); + let profile = profiles + .iter() + .filter(holding) + .find(|p| p.hostname == hostname) + .or_else(|| profiles.iter().filter(holding).max_by_key(|p| p.saved_at))?; + profile.folders.iter().find(|f| f.remote_uid == remote_uid) +} + impl Profile { /// Parse a profile document, refusing one written by a newer client. pub fn parse(bytes: &[u8]) -> Result { @@ -122,6 +167,7 @@ mod tests { version: PROFILE_VERSION, device_uid: "dev-1".to_string(), hostname: "laptop-xyz".to_string(), + machine_id: Some("0123456789abcdef0123456789abcdef".to_string()), saved_at: 1_700_000_000, folders: vec![ProfileFolder { remote_uid: "vol~link".to_string(), @@ -219,6 +265,52 @@ mod tests { assert!(p.ignore_patterns.is_none()); } + #[test] + fn a_profile_name_is_the_legacy_one_or_one_per_machine() { + assert!(is_profile_file_name(PROFILE_FILE_NAME)); + let name = profile_file_name("0123456789abcdef0123456789abcdef"); + assert_eq!(name, "profile-0123456789abcdef0123456789abcdef.json"); + assert!(is_profile_file_name(&name)); + assert!(!is_profile_file_name("profile-.json")); + assert!(!is_profile_file_name("profile-a/b.json")); + assert!(!is_profile_file_name("profile (1).json")); + assert!(!is_profile_file_name("notes.json")); + } + + /// A profile written before 3.5.0 has no machine id and still parses. + #[test] + fn a_profile_without_a_machine_id_parses() { + let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#; + assert_eq!(Profile::parse(json).unwrap().machine_id, None); + } + + /// Several computers sync one device, each with its own profile. This + /// computer's own wins, then the newest that has the folder. + #[test] + fn the_saved_folder_comes_from_this_computer_then_the_newest_profile() { + let profile = |hostname: &str, saved_at: i64, path: &str| Profile { + hostname: hostname.to_string(), + saved_at, + folders: vec![ProfileFolder { + remote_uid: "vol~docs".to_string(), + local_path: path.to_string(), + mode: MountMode::Mirror, + }], + ..sample() + }; + let profiles = vec![ + profile("desktop", 10, "/home/a/Docs"), + profile("laptop", 5, "/home/b/Docs"), + profile("old", 1, "/home/c/Docs"), + ]; + let path = + |host: &str| saved_folder(&profiles, "vol~docs", host).map(|f| f.local_path.as_str()); + assert_eq!(path("laptop"), Some("/home/b/Docs")); + assert_eq!(path("new-machine"), Some("/home/a/Docs")); + assert_eq!(saved_folder(&profiles, "vol~other", "laptop"), None); + assert_eq!(saved_folder(&[], "vol~docs", "laptop"), None); + } + #[test] fn future_version_is_refused() { let mut p = sample(); diff --git a/crates/pdfs-fuse/src/profile.rs b/crates/pdfs-fuse/src/profile.rs index 0d3ad47..b1df4bc 100644 --- a/crates/pdfs-fuse/src/profile.rs +++ b/crates/pdfs-fuse/src/profile.rs @@ -15,6 +15,7 @@ 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; @@ -66,9 +67,14 @@ impl Core { }); } - /// Serialize this machine's arrangement and upload it to `profile.json` - /// inside the device's `.proton-drive-linux` folder, replacing any existing - /// one. + /// 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 @@ -85,7 +91,11 @@ impl Core { CoreError::internal(format!("bad device root uid: {}", device.root_uid)) })?; - let profile = self.build_profile(&device.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; @@ -97,7 +107,7 @@ impl Core { // 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, PROFILE_FILE_NAME)?; + let existing = self.find_device_child_file(&dir_uid, &file_name)?; match existing { Some(uid) => self .rt @@ -114,7 +124,7 @@ impl Core { self.rt .block_on(self.drive.upload_file_replacing_draft_from( &dir_uid, - PROFILE_FILE_NAME, + &file_name, "application/json", Cursor::new(bytes), len, @@ -130,7 +140,7 @@ impl Core { } /// Snapshot the local arrangement into a [`Profile`]. - fn build_profile(&self, device_uid: &str) -> CoreResult { + fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult { let folders = self .db .sync_folder_list() @@ -162,6 +172,7 @@ impl Core { 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, @@ -197,6 +208,31 @@ impl Core { .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. /// @@ -218,32 +254,43 @@ impl Core { Ok(uid) } - /// Download and parse this device's profile, if there is one. + /// Download and parse the profiles of every computer that syncs this + /// device. /// - /// Looks in `.proton-drive-linux/` first and 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. - fn load_profile(&self, root_uid: &NodeUid) -> CoreResult> { - let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? { - Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?, - None => None, + /// 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(), }; - let uid = match current { - Some(uid) => uid, - None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? { - Some(uid) => { - info!("reading legacy profile from the device root"); - uid + 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") } - None => return Ok(None), - }, - }; - let mut buf: Vec = Vec::new(); - self.rt - .block_on(self.drive.download_file_to(&uid, &mut buf)) - .map_err(|e| CoreError::from_api(&e, "download profile"))?; - Profile::parse(&buf).map(Some).map_err(CoreError::invalid) + } + } + Ok(profiles) } // ---- restore ---------------------------------------- @@ -265,15 +312,16 @@ impl Core { CoreError::internal(format!("bad device root uid: {}", device.root_uid)) })?; - let profile = match self.load_profile(&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 profile; proposing defaults"); - None + warn!(error = %e.message, "could not read profiles; proposing defaults"); + Vec::new() } }; + let hostname = this_hostname(); let uids = self .rt @@ -309,9 +357,7 @@ impl Core { .filter(|n| crate::remote_name_is_safe(&n.name)) .map(|n| { let uid = n.uid.to_string(); - let saved = profile - .as_ref() - .and_then(|p| p.folders.iter().find(|f| f.remote_uid == uid)); + let saved = saved_folder(&profiles, &uid, &hostname); let proposed = saved .map(|f| PathBuf::from(&f.local_path)) .filter(|p| path_is_usable_here(p)) @@ -492,7 +538,7 @@ mod tests { #[test] fn loading_falls_back_to_a_legacy_device_root_profile() { let source = include_str!("profile.rs"); - let load = function_source(source, "fn load_profile("); + let load = function_source(source, "fn load_profiles("); assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder"); assert!( load.find("PROFILE_DIR_NAME").unwrap() @@ -501,6 +547,16 @@ mod tests { ); } + /// 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]