mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
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.
556 lines
22 KiB
Rust
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);
|
|
}
|
|
}
|