Files
proton-drive-linux/crates/pdfs-fuse/src/profile.rs
T
nvrl aa384ae2eb chore: cite docs/BUGS.md and drop references to notes outside the repo
66 comments cited `bugs.md B<n>`, the ledger's old name; they now say
`docs/BUGS.md B<n>`, and every cited number has an entry. About 90 more
cited offline.md, devices.md, features.md, mount-architecture.md,
plan.md and improvements.md, planning notes that were never in the
repository. Those citations are removed; the comments keep their text.
2026-10-06 19:29:02 +02:00

556 lines
22 KiB
Rust

//! 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,
};
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 `profile.json`
/// inside the device's `.proton-drive-linux` folder, replacing any existing
/// one.
///
/// 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 profile = self.build_profile(&device.uid)?;
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, PROFILE_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,
PROFILE_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) -> CoreResult<Profile> {
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(),
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<Option<NodeUid>> {
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 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<NodeUid> {
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 this device's profile, if there is one.
///
/// 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<Option<Profile>> {
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,
};
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
}
None => return Ok(None),
},
};
let mut buf: Vec<u8> = 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)
}
// ---- 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 `~/<name>` 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<Vec<RestorableFolder>> {
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 profile = match self.load_profile(&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
}
};
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<String> = 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)
.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 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<String> {
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 mut restored = 0usize;
let mut skipped: Vec<String> = Vec::new();
for item in items {
let local = PathBuf::from(&item.local_path);
if !local.is_absolute() {
skipped.push(format!("{}: local path must be absolute", 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 existing
.iter()
.any(|f| Path::new(&f.local_path) == local.as_path())
{
skipped.push(format!("{}: path already in use", 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 = self
.db
.sync_folder_add(&local.to_string_lossy(), &item.remote_uid, &device.share_id)
.map_err(|e| 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));
restored += 1;
}
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let mut message = format!("restored {restored} folder(s)");
if !skipped.is_empty() {
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
}
info!(restored, skipped = skipped.len(), "restore complete");
Ok(message)
}
}
/// 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<MountMode> {
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())
}
/// `~/<name>`, or `/<name>` 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_profile(");
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"
);
}
/// 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);
}
}