mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
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-<machine id>.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.
This commit is contained in:
1 parent
05b84bf796
commit
950056b48d
3 files changed
+241
-38
No files matched your search
@@ -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<String> {
|
||||
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
|
||||
|
||||
@@ -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-<machine id>.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<String>,
|
||||
/// When it was written, epoch seconds.
|
||||
pub saved_at: i64,
|
||||
#[serde(default)]
|
||||
@@ -92,6 +118,25 @@ pub struct Profile {
|
||||
pub mountpoint: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Self, String> {
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user