Compare commits

...
Author SHA1 Message Date
nvrl c77c548d7e Merge pull request #77 from danielebrox/feat/sync-attach-my-files
feat(sync): attach local folders to existing My files folders
2026-11-06 22:58:16 +01:00
nvrl d4a14a17a8 fix(sync): isolate My files attachments
Schema 45 separates attachments from device backups so stopping sync or
changing mode cannot trash My files folders or evict their local data.
Exclude attachments from device profiles and validate device restores.

Verify attached roots before each pass and cover both local views,
concurrent edits, removal guards, and root loss across daemon restarts.

Refs #77
2026-11-05 17:11:57 +01:00
danielebrox 5ca0272f15 feat(sync): attach local folders to existing My files folders 2026-11-02 19:23:49 +01:00
Nils Pukropp 800705ccb0 chore(aur): update packages to 3.7.0 2026-11-02 15:16:31 +00:00
nvrl 7ab14bb8f7 Merge pull request #76 from narrrl/chore/release-3.7.0
chore(release): 3.7.0
2026-11-02 15:34:08 +01:00
28 changed files with 1050 additions and 70 deletions

No files matched your search

+29
View File
@@ -696,6 +696,15 @@ enum SyncCmd {
/// Local directory to sync. /// Local directory to sync.
path: PathBuf, path: PathBuf,
}, },
/// Sync a real local folder with an existing folder in My files.
/// The first pass preserves local-only files and keeps differing versions.
Attach {
/// Existing local directory; it remains a real local directory.
path: PathBuf,
/// Existing folder in My files, relative to the cloud mount or inside it.
#[arg(long)]
to: PathBuf,
},
/// List this machine's synced folders. /// List this machine's synced folders.
List, List,
/// Remove a synced folder by id. /// Remove a synced folder by id.
@@ -1116,6 +1125,22 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
.to_string(); .to_string();
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)? ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
} }
SyncCmd::Attach { path, to } => {
let abs = std::fs::canonicalize(&path)
.with_context(|| format!("resolve {}", path.display()))?;
let local_path = abs
.to_str()
.ok_or_else(|| anyhow!("path is not valid UTF-8"))?
.to_owned();
let remote_path = to
.to_str()
.ok_or_else(|| anyhow!("remote path is not valid UTF-8"))?
.to_owned();
ok_or_bail(control_request(CtlRequest::AttachSyncFolder {
local_path,
remote_path,
})?)?
}
SyncCmd::List => { SyncCmd::List => {
let response = control_request(CtlRequest::ListSyncFolders)?; let response = control_request(CtlRequest::ListSyncFolders)?;
if emit_json(&response)? { if emit_json(&response)? {
@@ -2294,6 +2319,9 @@ fn cmd_locations() -> Result<()> {
MountKind::Device { sync_folder_id } => { MountKind::Device { sync_folder_id } => {
format!("Device folder {sync_folder_id}") format!("Device folder {sync_folder_id}")
} }
MountKind::Attached { sync_folder_id } => {
format!("My files mirror {sync_folder_id}")
}
MountKind::Shared { .. } => "Shared folder".to_string(), MountKind::Shared { .. } => "Shared folder".to_string(),
}; };
let mode = match location.pending_mode { let mode = match location.pending_mode {
@@ -4268,6 +4296,7 @@ mod diagnose_tests {
#[test] #[test]
fn a_synced_folder_in_error_is_reported() { fn a_synced_folder_in_error_is_reported() {
let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo { let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo {
kind: "device".into(),
id, id,
local_path: format!("/home/u/f{id}"), local_path: format!("/home/u/f{id}"),
remote_uid: "vol~link".into(), remote_uid: "vol~link".into(),
+16 -2
View File
@@ -469,6 +469,13 @@ pub enum Request {
/// Add a local folder to this machine's device, uploading its tree and /// Add a local folder to this machine's device, uploading its tree and
/// registering the device on first use. Replies with [`Response::Ok`]. /// registering the device on first use. Replies with [`Response::Ok`].
AddSyncFolder { local_path: String }, AddSyncFolder { local_path: String },
/// Keep a real local directory in step with an existing folder in My files.
/// First sync unions one-sided files and preserves differing versions.
/// Replies with [`Response::Ok`]; no device backup is created.
AttachSyncFolder {
local_path: String,
remote_path: String,
},
/// List this device's synced folders. Replies with [`Response::SyncFolders`]. /// List this device's synced folders. Replies with [`Response::SyncFolders`].
ListSyncFolders, ListSyncFolders,
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy. /// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
@@ -985,14 +992,17 @@ impl SyncIssue {
} }
} }
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]). /// One device backup or attached My files mirror (in [`Response::SyncFolders`]).
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SyncFolderInfo { pub struct SyncFolderInfo {
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`]. /// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
pub id: i64, pub id: i64,
/// `device` backup or `attached` My files mirror. Older daemons only had backups.
#[serde(default = "default_sync_folder_kind")]
pub kind: String,
/// Absolute local folder path. /// Absolute local folder path.
pub local_path: String, pub local_path: String,
/// The uid of the folder's remote root under the device root. /// The uid of the remote root under the device root or My files.
pub remote_uid: String, pub remote_uid: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount). /// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String, pub mode: String,
@@ -1015,6 +1025,10 @@ pub struct SyncFolderInfo {
pub paused: bool, pub paused: bool,
} }
fn default_sync_folder_kind() -> String {
"device".into()
}
/// A folder under this machine's device offered for restore (in /// A folder under this machine's device offered for restore (in
/// [`Response::RestorableFolders`]). /// [`Response::RestorableFolders`]).
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
+78 -1
View File
@@ -11,7 +11,7 @@ use super::Db;
use crate::Result; use crate::Result;
/// Current schema version. Bump on every forward migration added below. /// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 44; pub(super) const SCHEMA_VERSION: i64 = 45;
impl Db { impl Db {
pub(super) fn migrate(&self) -> Result<()> { pub(super) fn migrate(&self) -> Result<()> {
@@ -467,6 +467,29 @@ impl Db {
tx.execute_batch(MIGRATION_V44)?; tx.execute_batch(MIGRATION_V44)?;
} }
} }
if current < 45 {
let has_folders: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_folder'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_kind: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sync_folder') WHERE name = 'kind'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_folders && !has_kind {
tx.execute_batch(MIGRATION_V45_FOLDERS)?;
let has_mounts: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'mount'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_mounts {
tx.execute_batch(MIGRATION_V45_MOUNTS)?;
}
}
}
tx.execute( tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -1382,3 +1405,57 @@ CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name);
const MIGRATION_V44: &str = " const MIGRATION_V44: &str = "
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT; ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
"; ";
/// Schema v45: distinguish device backups from My files attachments. Existing
/// released rows are device backups. Attachments cannot evict their local tree.
const MIGRATION_V45_FOLDERS: &str = "
ALTER TABLE sync_folder ADD COLUMN kind TEXT NOT NULL DEFAULT 'device'
CHECK (kind IN ('device', 'attached'));
CREATE TRIGGER sync_folder_attached_insert
BEFORE INSERT ON sync_folder
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
CREATE TRIGGER sync_folder_attached_update
BEFORE UPDATE ON sync_folder
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
";
const MIGRATION_V45_MOUNTS: &str = "
DROP TRIGGER mount_sync_folder_insert;
DROP INDEX mount_myfiles;
DROP INDEX mount_device;
DROP INDEX mount_shared;
ALTER TABLE mount RENAME TO mount_v44;
CREATE TABLE mount (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('myfiles', 'device', 'attached', 'shared')),
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
share_root_uid TEXT,
local_path TEXT NOT NULL DEFAULT '',
root_uid TEXT NOT NULL DEFAULT '',
root_share_id TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'unknown',
access TEXT NOT NULL DEFAULT 'rw' CHECK (access IN ('rw', 'ro')),
CHECK (
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
OR (kind IN ('device', 'attached') AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
OR (kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
)
);
INSERT INTO mount SELECT * FROM mount_v44;
DROP TABLE mount_v44;
CREATE UNIQUE INDEX mount_myfiles ON mount(kind) WHERE kind = 'myfiles';
CREATE UNIQUE INDEX mount_device ON mount(sync_folder_id) WHERE kind IN ('device', 'attached');
CREATE UNIQUE INDEX mount_shared ON mount(share_root_uid) WHERE kind = 'shared';
CREATE TRIGGER mount_sync_folder_insert
AFTER INSERT ON sync_folder
BEGIN
INSERT INTO mount (kind, sync_folder_id) VALUES (NEW.kind, NEW.id);
END;
CREATE TRIGGER mount_sync_folder_kind_update
AFTER UPDATE OF kind ON sync_folder
BEGIN
UPDATE mount SET kind = NEW.kind WHERE sync_folder_id = NEW.id;
END;
";
+14 -9
View File
@@ -1,6 +1,6 @@
//! Unified mount/location presentation rows. //! Unified mount/location presentation rows.
//! //!
//! A device row stores only its `sync_folder` identity. Its path, remote root, //! A device or attached row stores only its `sync_folder` identity. Its path, remote root,
//! mode, state, pending mode, and last-sync timestamp are selected from //! mode, state, pending mode, and last-sync timestamp are selected from
//! `sync_folder`, which remains the authoritative sync-engine table. //! `sync_folder`, which remains the authoritative sync-engine table.
@@ -72,7 +72,7 @@ impl Db {
)? > 0) )? > 0)
} }
/// Every local location, with device fields joined from `sync_folder`. /// Every local location, with synced-folder fields joined from `sync_folder`.
pub fn mount_list(&self) -> Result<Vec<MountSpec>> { pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
@@ -81,14 +81,14 @@ impl Db {
m.kind, m.kind,
m.sync_folder_id, m.sync_folder_id,
m.share_root_uid, m.share_root_uid,
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END, CASE WHEN m.kind IN ('device', 'attached') THEN sf.local_path ELSE m.local_path END,
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END, CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_uid ELSE m.root_uid END,
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END, CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_share_id ELSE m.root_share_id END,
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END, CASE WHEN m.kind IN ('device', 'attached') THEN sf.mode ELSE m.mode END,
m.access, m.access,
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END, CASE WHEN m.kind IN ('device', 'attached') THEN sf.state ELSE 'idle' END,
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END, CASE WHEN m.kind IN ('device', 'attached') THEN sf.last_sync ELSE 0 END,
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END CASE WHEN m.kind IN ('device', 'attached') THEN sf.pending_mode ELSE NULL END
FROM mount AS m FROM mount AS m
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
ORDER BY CASE m.kind ORDER BY CASE m.kind
@@ -138,6 +138,11 @@ impl Db {
Error::Other(format!("mount row {id} has no sync_folder_id")) Error::Other(format!("mount row {id} has no sync_folder_id"))
})?, })?,
}, },
"attached" => MountKind::Attached {
sync_folder_id: sync_folder_id.ok_or_else(|| {
Error::Other(format!("mount row {id} has no sync_folder_id"))
})?,
},
"shared" => MountKind::Shared { "shared" => MountKind::Shared {
share_root_uid: share_root_uid.ok_or_else(|| { share_root_uid: share_root_uid.ok_or_else(|| {
Error::Other(format!("mount row {id} has no share_root_uid")) Error::Other(format!("mount row {id} has no share_root_uid"))
+29 -5
View File
@@ -9,6 +9,8 @@ use std::collections::HashMap;
pub struct StoredSyncFolder { pub struct StoredSyncFolder {
pub id: i64, pub id: i64,
/// `device` backup or `attached` My files mirror.
pub kind: String,
pub local_path: String, pub local_path: String,
pub remote_uid: String, pub remote_uid: String,
pub remote_share_id: String, pub remote_share_id: String,
@@ -59,12 +61,32 @@ impl Db {
local_path: &str, local_path: &str,
remote_uid: &str, remote_uid: &str,
remote_share_id: &str, remote_share_id: &str,
) -> Result<i64> {
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "device")
}
/// Attach an existing My files folder, without creating a device backup.
pub fn sync_folder_attach(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
) -> Result<i64> {
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "attached")
}
fn sync_folder_insert(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
kind: &str,
) -> Result<i64> { ) -> Result<i64> {
let conn = self.conn.lock(); let conn = self.conn.lock();
conn.execute( conn.execute(
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id) "INSERT INTO sync_folder (local_path, remote_uid, remote_share_id, kind)
VALUES (?1, ?2, ?3)", VALUES (?1, ?2, ?3, ?4)",
params![local_path, remote_uid, remote_share_id], params![local_path, remote_uid, remote_share_id, kind],
)?; )?;
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
} }
@@ -73,7 +95,7 @@ impl Db {
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> { pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync "SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
FROM sync_folder ORDER BY id", FROM sync_folder ORDER BY id",
)?; )?;
let rows = stmt.query_map([], |r| { let rows = stmt.query_map([], |r| {
@@ -86,6 +108,7 @@ impl Db {
pending_mode: r.get(5)?, pending_mode: r.get(5)?,
state: r.get(6)?, state: r.get(6)?,
last_sync: r.get(7)?, last_sync: r.get(7)?,
kind: r.get(8)?,
}) })
})?; })?;
let mut items = Vec::new(); let mut items = Vec::new();
@@ -99,7 +122,7 @@ impl Db {
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> { pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
let conn = self.read(); let conn = self.read();
conn.query_row( conn.query_row(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync "SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
FROM sync_folder WHERE id = ?1", FROM sync_folder WHERE id = ?1",
params![id], params![id],
|r| { |r| {
@@ -112,6 +135,7 @@ impl Db {
pending_mode: r.get(5)?, pending_mode: r.get(5)?,
state: r.get(6)?, state: r.get(6)?,
last_sync: r.get(7)?, last_sync: r.get(7)?,
kind: r.get(8)?,
}) })
}, },
) )
+71 -1
View File
@@ -2076,7 +2076,11 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
{ {
let conn = rusqlite::Connection::open(&path).unwrap(); let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch( conn.execute_batch(
"DROP TRIGGER mount_sync_folder_insert; "DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
DROP TRIGGER mount_sync_folder_insert;
DROP TRIGGER sync_folder_attached_insert;
DROP TRIGGER sync_folder_attached_update;
ALTER TABLE sync_folder DROP COLUMN kind;
DROP TABLE mount; DROP TABLE mount;
DROP TABLE share_access; DROP TABLE share_access;
DROP TABLE album_photos; DROP TABLE album_photos;
@@ -7691,3 +7695,69 @@ fn a_queued_op_stops_existing_once_the_node_is_deleted() {
assert_eq!(blobs, vec!["/staging/journal"]); assert_eq!(blobs, vec!["/staging/journal"]);
assert!(!db.op_exists(id).unwrap()); assert!(!db.op_exists(id).unwrap());
} }
#[test]
fn attachments_have_their_own_location_and_cannot_evict_local_files() {
use crate::mounts::MountKind;
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_attach("/work", "vol~work", "myfiles-share")
.unwrap();
let folder = db.sync_folder_get(id).unwrap().unwrap();
assert_eq!(folder.kind, "attached");
assert_eq!(folder.mode, "mirror");
assert_eq!(db.sync_folder_list().unwrap()[0].kind, "attached");
let locations = db.mount_list().unwrap();
assert!(
matches!(locations[0].kind, MountKind::Attached { sync_folder_id } if sync_folder_id == id)
);
assert_eq!(locations[0].local_path, "/work");
assert_eq!(locations[0].root_share_id, "myfiles-share");
assert!(db.sync_folder_set_mode(id, "ondemand").is_err());
assert!(
db.sync_folder_set_pending_mode(id, Some("ondemand"))
.is_err()
);
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().mode, "mirror");
db.sync_folder_remove(id).unwrap();
assert!(db.mount_list().unwrap().is_empty());
}
#[test]
fn migration_v45_keeps_existing_device_folders_and_location_ids() {
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_add("/backup", "vol~backup", "share")
.unwrap();
db.sync_folder_set_mode(id, "ondemand").unwrap();
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
let location_id = db.mount_list().unwrap()[0].id;
db.conn
.lock()
.execute_batch(
"DROP TRIGGER sync_folder_attached_insert;
DROP TRIGGER sync_folder_attached_update;
DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
DROP TRIGGER mount_sync_folder_insert;
ALTER TABLE sync_folder DROP COLUMN kind;
CREATE TRIGGER mount_sync_folder_insert AFTER INSERT ON sync_folder BEGIN
INSERT INTO mount (kind, sync_folder_id) VALUES ('device', NEW.id); END;
UPDATE sync_state SET value = '44' WHERE key = 'schema_version';",
)
.unwrap();
db.migrate().unwrap();
let folder = db.sync_folder_get(id).unwrap().unwrap();
assert_eq!(folder.kind, "device");
assert_eq!(folder.mode, "ondemand");
assert_eq!(folder.pending_mode.as_deref(), Some("mirror"));
assert_eq!(db.mount_list().unwrap()[0].id, location_id);
let attached = db
.sync_folder_attach("/work", "vol~work", "myfiles")
.unwrap();
assert_eq!(
db.sync_folder_get(attached).unwrap().unwrap().kind,
"attached"
);
db.migrate().unwrap();
assert_eq!(db.mount_list().unwrap().len(), 2);
}
+4 -2
View File
@@ -81,6 +81,8 @@ pub enum MountKind {
MyFiles, MyFiles,
/// A folder owned by this machine's Proton Drive device. /// A folder owned by this machine's Proton Drive device.
Device { sync_folder_id: i64 }, Device { sync_folder_id: i64 },
/// A real local mirror of an existing My files folder.
Attached { sync_folder_id: i64 },
/// A future standalone mount rooted at a node shared with this account. /// A future standalone mount rooted at a node shared with this account.
Shared { share_root_uid: String }, Shared { share_root_uid: String },
} }
@@ -88,8 +90,8 @@ pub enum MountKind {
/// One local Proton Drive location as exposed over the control protocol. /// One local Proton Drive location as exposed over the control protocol.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MountSpec { pub struct MountSpec {
/// Presentation-row id. Device actions use the `sync_folder_id` carried by /// Presentation-row id. Synced-folder actions use the `sync_folder_id`
/// [`MountKind::Device`], not this id. /// carried by [`MountKind::Device`] or [`MountKind::Attached`], not this id.
pub id: i64, pub id: i64,
pub kind: MountKind, pub kind: MountKind,
pub local_path: String, pub local_path: String,
+136
View File
@@ -0,0 +1,136 @@
//! Explicitly bind an existing My files folder to a real local mirror.
//!
//! Unlike a FUSE mount, this never covers or evicts the local directory.
//! The existing three-way engine performs the initial, non-destructive union.
use super::{Core, parse_uid, sync};
use pdfs_core::db::StoredSyncFolder;
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
impl Core {
/// A bounded, server-verified ancestry walk. Do not decide ownership or
/// remote-root overlap from a possibly incomplete cached folder listing.
pub(super) fn attach_ancestors(&self, start: &NodeUid) -> CoreResult<Vec<NodeUid>> {
let mut ancestors = Vec::new();
let mut seen = HashSet::new();
let mut next = Some(start.clone());
while let Some(uid) = next {
if !seen.insert(uid.clone()) || ancestors.len() >= 256 {
return Err(CoreError::invalid(
"invalid or excessively deep remote folder ancestry",
));
}
ancestors.push(uid.clone());
if uid == self.primary_root_uid {
break;
}
let nodes = self
.rt
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&uid)))
.map_err(|e| CoreError::from_api(&e, "verify sync folder ancestry"))?;
let node = nodes
.into_iter()
.next()
.ok_or_else(|| CoreError::not_found("remote sync folder is missing"))?;
if node.trashed {
return Err(CoreError::invalid("remote sync folder is in the trash"));
}
next = node.parent_uid;
}
Ok(ancestors)
}
pub(crate) fn attach_sync_folder(
&self,
local: &Path,
remote: &Path,
mountpoint: &Path,
) -> CoreResult<StoredSyncFolder> {
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
if !self.primary {
return Err(CoreError::invalid("attach must use My files"));
}
if remote.is_absolute()
|| remote
.components()
.any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
{
return Err(CoreError::invalid(
"remote folder must stay inside My files",
));
}
let local = local
.canonicalize()
.map_err(|e| CoreError::invalid(format!("resolve {}: {e}", local.display())))?;
if !local.is_dir() {
return Err(CoreError::invalid("local sync path is not a directory"));
}
let existing = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let others = existing
.iter()
.map(|f| PathBuf::from(&f.local_path))
.collect::<Vec<_>>();
let mut reserved = crate::devices::sync_root_reserved();
reserved.mountpoint = Some(
mountpoint
.canonicalize()
.map_err(|e| CoreError::internal(format!("resolve mountpoint: {e}")))?,
);
if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
return Err(CoreError::invalid(problem));
}
let (ino, remote_uid) = self.resolve(remote)?;
let is_folder = self
.state()
.entries
.get(&ino)
.is_some_and(|e| e.node.is_folder() && !e.node.trashed);
if !is_folder {
return Err(CoreError::invalid("remote sync path is not a folder"));
}
let ancestors = self.attach_ancestors(&remote_uid)?;
if remote_uid == self.primary_root_uid || !ancestors.contains(&self.primary_root_uid) {
return Err(CoreError::invalid(
"choose an owned folder below My files, not its root or a shared folder",
));
}
for other in existing.iter().filter(|f| f.kind == "attached") {
let uid = parse_uid(&other.remote_uid)
.ok_or_else(|| CoreError::internal("invalid existing remote sync root"))?;
if uid == remote_uid
|| ancestors.contains(&uid)
|| self.attach_ancestors(&uid)?.contains(&remote_uid)
{
return Err(CoreError::invalid(
"remote folder overlaps an existing synced folder",
));
}
}
let share_id = self
.rt
.block_on(self.drive.context_share_id(&self.primary_root_uid))
.map_err(|e| CoreError::from_api(&e, "resolve My files share"))?
.to_string();
if share_id.is_empty() {
return Err(CoreError::invalid("My files share is unavailable"));
}
let id = self
.db
.sync_folder_attach(&local.to_string_lossy(), &remote_uid.to_string(), &share_id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// An empty baseline makes one-sided files new, never deletions. The
// engine hashes matching-size files and preserves divergent versions.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
self.db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found("new sync folder disappeared"))
}
}
+15
View File
@@ -385,6 +385,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices], R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations], R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
R::AddSyncFolder { .. } R::AddSyncFolder { .. }
| R::AttachSyncFolder { .. }
| R::RemoveSyncFolder { .. } | R::RemoveSyncFolder { .. }
| R::SetSyncFolderMode { .. } | R::SetSyncFolderMode { .. }
| R::SetSyncFolderPaused { .. } | R::SetSyncFolderPaused { .. }
@@ -1397,6 +1398,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("adding {local_path}"), message: format!("adding {local_path}"),
} }
} }
Ok(CtlRequest::AttachSyncFolder {
local_path,
remote_path,
}) => {
let result = rel_to_mount(mountpoint, &remote_path).and_then(|remote| {
core.attach_sync_folder(Path::new(&local_path), &remote, mountpoint)
});
match result {
Ok(folder) => CtlResponse::Ok {
message: format!("sync folder {} attached: {}", folder.id, folder.local_path),
},
Err(error) => CtlResponse::error(error),
}
}
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() { Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
Ok(items) => CtlResponse::SyncFolders { items }, Ok(items) => CtlResponse::SyncFolders { items },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
+27 -3
View File
@@ -33,6 +33,11 @@ use super::{
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname, now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
}; };
/// Registration validates local and remote root overlap before inserting a
/// mapping. Serialize all registration paths so concurrent control requests
/// cannot both validate against the same old set of roots.
pub(super) static SYNC_ROOT_REGISTRATION: Mutex<()> = Mutex::new(());
/// A mode that this daemon is allowed to apply. /// A mode that this daemon is allowed to apply.
/// ///
/// Persisted and control-protocol values first pass through [`MountMode`] so /// Persisted and control-protocol values first pass through [`MountMode`] so
@@ -152,7 +157,9 @@ impl Core {
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?; .map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
for location in &mut locations { for location in &mut locations {
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path)); location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
if let MountKind::Device { sync_folder_id } = &location.kind { if let MountKind::Device { sync_folder_id } | MountKind::Attached { sync_folder_id } =
&location.kind
{
location.progress = progress.get(sync_folder_id).cloned(); location.progress = progress.get(sync_folder_id).cloned();
location.paused = self.sync_folder_paused(*sync_folder_id); location.paused = self.sync_folder_paused(*sync_folder_id);
} }
@@ -495,7 +502,7 @@ impl Core {
} }
/// Everything directly under a device root, trashed nodes included. /// Everything directly under a device root, trashed nodes included.
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> { pub(super) fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
let uids = self let uids = self
.rt .rt
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid)) .block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
@@ -513,6 +520,7 @@ impl Core {
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload — /// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
/// the two-way engine (Phase 2) reconciles later changes. /// the two-way engine (Phase 2) reconciles later changes.
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> { pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
let _registration = SYNC_ROOT_REGISTRATION.lock();
let meta = std::fs::metadata(local) let meta = std::fs::metadata(local)
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?; .map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
if !meta.is_dir() { if !meta.is_dir() {
@@ -699,6 +707,11 @@ impl Core {
.sync_folder_get(id) .sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))? .map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?; .ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if folder.kind == "attached" && delete_remote {
return Err(CoreError::invalid(
"attached My files folders cannot be deleted by stopping sync; omit --delete-remote",
));
}
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping // An `ondemand` folder *is* a FUSE mount over its local path, so dropping
// only the row would strand the mount: the path would keep serving a // only the row would strand the mount: the path would keep serving a
// folder the daemon no longer tracks, and nothing would ever unmount it. // folder the daemon no longer tracks, and nothing would ever unmount it.
@@ -785,6 +798,11 @@ impl Core {
.sync_folder_get(id) .sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))? .map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?; .ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if folder.kind == "attached" && target_mode != ApplicableMountMode::Mirror {
return Err(CoreError::invalid(
"attached My files folders only support mirror mode; use the My files mount for online-only access",
));
}
let current_mode = ApplicableMountMode::parse(&folder.mode) let current_mode = ApplicableMountMode::parse(&folder.mode)
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?; .map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
// A switch waits on the folder's reconcile, which a pause skips; refuse // A switch waits on the folder's reconcile, which a pause skips; refuse
@@ -960,6 +978,11 @@ impl Core {
.sync_folder_get(id) .sync_folder_get(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))? .map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?; .ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
if folder.kind == "attached" && mode != ApplicableMountMode::Mirror {
return Err(SwitchBlocked::Failed(
"attached My files folders only support mirror mode".into(),
));
}
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) { if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
return Err(SwitchBlocked::Superseded); return Err(SwitchBlocked::Superseded);
} }
@@ -1199,7 +1222,7 @@ impl Core {
} }
}; };
for folder in folders { for folder in folders {
if folder.mode != "ondemand" { if folder.kind != "device" || folder.mode != "ondemand" {
continue; continue;
} }
let Some(root_uid) = parse_uid(&folder.remote_uid) else { let Some(root_uid) = parse_uid(&folder.remote_uid) else {
@@ -1395,6 +1418,7 @@ mod tests {
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder { fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
StoredSyncFolder { StoredSyncFolder {
kind: "device".into(),
id: 7, id: 7,
local_path: local_path.to_string(), local_path: local_path.to_string(),
remote_uid: remote_uid.to_string(), remote_uid: remote_uid.to_string(),
+7 -1
View File
@@ -26,7 +26,7 @@ use futures::StreamExt as _;
use futures::stream::BoxStream; use futures::stream::BoxStream;
use proton_drive_rs::proton_sdk::account::Quota; use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::error::Result; use proton_drive_rs::proton_sdk::error::Result;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid}; use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId};
use proton_drive_rs::{ use proton_drive_rs::{
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
ProtonDriveClient, RevisionReader, Thumbnail, ProtonDriveClient, RevisionReader, Thumbnail,
@@ -43,6 +43,8 @@ pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>
pub(crate) trait DriveApi: Send + Sync { pub(crate) trait DriveApi: Send + Sync {
async fn get_my_files_folder(&self) -> Result<Node>; async fn get_my_files_folder(&self) -> Result<Node>;
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId>;
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>; async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>; async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
@@ -305,6 +307,10 @@ impl RevisionRead for RevisionReader {
#[async_trait] #[async_trait]
impl DriveApi for ProtonDriveClient { impl DriveApi for ProtonDriveClient {
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
ProtonDriveClient::context_share_id(self, uid).await
}
async fn get_my_files_folder(&self) -> Result<Node> { async fn get_my_files_folder(&self) -> Result<Node> {
ProtonDriveClient::get_my_files_folder(self).await ProtonDriveClient::get_my_files_folder(self).await
} }
+2
View File
@@ -79,6 +79,7 @@ use proton_drive_rs::{
}; };
mod albums; mod albums;
mod attach;
mod background; mod background;
mod conflicts; mod conflicts;
mod control; mod control;
@@ -6441,6 +6442,7 @@ fn sync_folder_info(
) -> SyncFolderInfo { ) -> SyncFolderInfo {
SyncFolderInfo { SyncFolderInfo {
id: f.id, id: f.id,
kind: f.kind,
local_path: f.local_path, local_path: f.local_path,
remote_uid: f.remote_uid, remote_uid: f.remote_uid,
mode: f.mode, mode: f.mode,
+51 -13
View File
@@ -141,19 +141,11 @@ impl Core {
/// Snapshot the local arrangement into a [`Profile`]. /// Snapshot the local arrangement into a [`Profile`].
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> { fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
let folders = self let folders = profile_folders(
.db self.db
.sync_folder_list() .sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))? .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 let pins = self
.db .db
.pin_list() .pin_list()
@@ -389,6 +381,7 @@ impl Core {
items: &[pdfs_core::control::RestoreItem], items: &[pdfs_core::control::RestoreItem],
device: Option<&str>, device: Option<&str>,
) -> CoreResult<RestoreOutcome> { ) -> CoreResult<RestoreOutcome> {
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
if items.is_empty() { if items.is_empty() {
return Err(CoreError::invalid("nothing to restore")); return Err(CoreError::invalid("nothing to restore"));
} }
@@ -398,6 +391,11 @@ impl Core {
.sync_folder_list() .sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?; .map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// A caller must not smuggle a My files root into a device mapping,
// where destructive device-only operations would otherwise be allowed.
let device_root = super::parse_uid(&device.root_uid)
.ok_or_else(|| CoreError::internal("invalid device root"))?;
let children = self.device_root_nodes(&device_root)?;
let reserved = crate::devices::sync_root_reserved(); let reserved = crate::devices::sync_root_reserved();
let mut roots: Vec<PathBuf> = existing let mut roots: Vec<PathBuf> = existing
.iter() .iter()
@@ -406,6 +404,18 @@ impl Core {
let mut restored = 0usize; let mut restored = 0usize;
let mut skipped: Vec<String> = Vec::new(); let mut skipped: Vec<String> = Vec::new();
for item in items { for item in items {
if !children.iter().any(|node| {
node.uid.to_string() == item.remote_uid
&& node.is_folder()
&& !node.trashed
&& node.name != PROFILE_DIR_NAME
}) {
skipped.push(format!(
"{}: not a folder under this device",
item.local_path
));
continue;
}
// The path is proposed from the profile another machine wrote, and // The path is proposed from the profile another machine wrote, and
// may hold a folder name from Drive: checked like any new root. // may hold a folder name from Drive: checked like any new root.
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) { let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
@@ -468,6 +478,19 @@ impl Core {
} }
} }
/// Only device backups belong in the device's cross-machine profile.
fn profile_folders(folders: Vec<pdfs_core::db::StoredSyncFolder>) -> Vec<ProfileFolder> {
folders
.into_iter()
.filter(|f| f.kind == "device")
.map(|f| ProfileFolder {
remote_uid: f.remote_uid,
local_path: f.local_path,
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
})
.collect()
}
/// What a restore did: how many folders it attached, and why it left out the /// What a restore did: how many folders it attached, and why it left out the
/// others. /// others.
pub(crate) struct RestoreOutcome { pub(crate) struct RestoreOutcome {
@@ -530,6 +553,21 @@ fn default_local_path(name: &str) -> PathBuf {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn attached_my_files_folders_are_not_in_the_device_profile() {
let dir = crate::sim::daemon::scratch("profile-folder-kinds");
let db = pdfs_core::db::Db::open(&dir.join("cache.db")).unwrap();
db.sync_folder_add("/backup", "vol~backup", "device-share")
.unwrap();
db.sync_folder_attach("/work", "vol~work", "my-files-share")
.unwrap();
let folders = profile_folders(db.sync_folder_list().unwrap());
assert_eq!(folders.len(), 1);
assert_eq!(folders[0].remote_uid, "vol~backup");
drop(db);
std::fs::remove_dir_all(dir).unwrap();
}
#[test] #[test]
fn foreign_home_is_not_reused() { fn foreign_home_is_not_reused() {
assert!(!path_is_usable_here(Path::new( assert!(!path_is_usable_here(Path::new(
+1
View File
@@ -555,6 +555,7 @@ mod tests {
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder { fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
StoredSyncFolder { StoredSyncFolder {
kind: "device".into(),
id, id,
local_path: local_path.to_string(), local_path: local_path.to_string(),
remote_uid: format!("vol~root{id}"), remote_uid: format!("vol~root{id}"),
+427
View File
@@ -2912,6 +2912,433 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attaching_my_files_preserves_local_files_and_syncs_both_ways() {
use std::os::unix::fs::MetadataExt;
let drive = FakeDrive::new();
let dir = scratch("attach-my-files");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work").is_some()
}));
drive.device().write("Work/cloud.txt", b"from cloud");
drive.device().write("Work/common.txt", b"remote version");
drive.device().write("Work/identical.txt", b"same bytes");
let local = dir.join("local");
std::fs::create_dir(&local).unwrap();
std::fs::write(local.join("local.txt"), b"only on this computer").unwrap();
std::fs::write(local.join("common.txt"), b"local version").unwrap();
std::fs::write(local.join("identical.txt"), b"same bytes").unwrap();
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_str().unwrap().into(),
remote_path: "Work".into(),
})
.unwrap();
let folder = match daemon.request(&Request::ListSyncFolders).unwrap() {
Response::SyncFolders { items } => {
items
.into_iter()
.find(|f| f.local_path == local.to_str().unwrap())
.unwrap()
.id
}
other => panic!("unexpected reply: {other:?}"),
};
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("Work/local.txt").is_some()
&& std::fs::read(local.join("cloud.txt")).is_ok_and(|b| b == b"from cloud")
&& std::fs::read(local.join("common.txt")).is_ok_and(|b| b == b"remote version")
&& !conflict_copies(&local, "common").is_empty()
}));
assert_ne!(
std::fs::metadata(&local).unwrap().dev(),
std::fs::metadata(&daemon.mountpoint).unwrap().dev()
);
assert!(
conflict_copies(&local, "common")
.iter()
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local version"))
);
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_str().unwrap().into(),
remote_path: "Work".into()
})
.is_err()
);
let duplicate = dir.join("duplicate");
std::fs::create_dir(&duplicate).unwrap();
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: "Work".into()
})
.is_err()
);
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: "../Work".into()
})
.is_err()
);
// Parent/child mappings, the account root and files cannot be attached.
std::fs::create_dir(daemon.mountpoint.join("Work/Sub")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Work/Sub").is_some()
}));
for remote in ["Work/Sub", ".", "Work/cloud.txt"] {
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: remote.into(),
})
.is_err()
);
}
// Local overlap and symlink aliases into the actual cloud mount are refused.
let alias = dir.join("cloud-alias");
std::os::unix::fs::symlink(daemon.mountpoint.join("Work"), &alias).unwrap();
std::fs::create_dir_all(local.join("Sub")).unwrap();
for path in [
local.join("Sub"),
dir.clone(),
daemon.mountpoint.join("Work"),
alias,
] {
let error = daemon
.request(&Request::AttachSyncFolder {
local_path: path.to_string_lossy().into_owned(),
remote_path: "Work/Sub".into(),
})
.unwrap_err()
.to_string();
assert!(error.contains("overlaps"), "{error}");
}
assert!(conflict_copies(&local, "identical").is_empty());
assert_eq!(
std::fs::read(local.join("identical.txt")).unwrap(),
b"same bytes"
);
// Simultaneous requests cannot register two local trees for one cloud root.
std::fs::create_dir(daemon.mountpoint.join("Other")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Other").is_some()
}));
let candidate_a = dir.join("candidate-a");
let candidate_b = dir.join("candidate-b");
std::fs::create_dir(&candidate_a).unwrap();
std::fs::create_dir(&candidate_b).unwrap();
let outcomes = std::thread::scope(|scope| {
let a = scope.spawn(|| {
daemon
.request(&Request::AttachSyncFolder {
local_path: candidate_a.to_str().unwrap().into(),
remote_path: "Other".into(),
})
.map_err(|e| e.to_string())
});
let b = scope.spawn(|| {
daemon
.request(&Request::AttachSyncFolder {
local_path: candidate_b.to_str().unwrap().into(),
remote_path: "Other".into(),
})
.map_err(|e| e.to_string())
});
[a.join().unwrap(), b.join().unwrap()]
});
assert_eq!(
outcomes.iter().filter(|result| result.is_ok()).count(),
1,
"exactly one mapping may own a remote root: {outcomes:?}"
);
drive
.device()
.write("Work/cloud.txt", b"new cloud revision");
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || std::fs::read(
local.join("cloud.txt")
)
.is_ok_and(|b| b == b"new cloud revision")));
std::fs::write(local.join("local.txt"), b"new local revision").unwrap();
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || drive
.tree()
.get("Work/local.txt")
== Some(&Entry::File(Arc::new(b"new local revision".to_vec())))));
assert!(wait_until(Duration::from_secs(30), || listed_folder(
&daemon, folder
)
.is_some_and(|f| f.state == "idle")));
daemon
.request(&Request::SetSyncFolderPaused {
id: folder,
paused: true,
})
.unwrap();
let before = drive.tree();
for entry in std::fs::read_dir(&local).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
std::fs::remove_dir_all(path).unwrap();
} else {
std::fs::remove_file(path).unwrap();
}
}
daemon
.request(&Request::SetSyncFolderPaused {
id: folder,
paused: false,
})
.unwrap();
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || listed_folder(
&daemon, folder
)
.is_some_and(|f| f.state == "error")));
assert_eq!(
drive.tree(),
before,
"the wipe guard must preserve the remote tree"
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let db = Db::open(&dir.join("pdfs.db")).unwrap();
assert_eq!(
db.sync_folder_get(folder).unwrap().unwrap().remote_share_id,
"my-files-share"
);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
}
fn attached_my_files(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.drained() && drive.lookup("Work").is_some()
}));
std::fs::create_dir(local).unwrap();
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_string_lossy().into_owned(),
remote_path: "Work".into(),
})
.unwrap();
match daemon.request(&Request::ListSyncFolders).unwrap() {
Response::SyncFolders { items } => {
items
.into_iter()
.find(|f| f.local_path == local.to_string_lossy())
.unwrap()
.id
}
other => panic!("unexpected reply: {other:?}"),
}
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_views_preserve_concurrent_edits() {
let drive = FakeDrive::new();
let dir = scratch("attach-views");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("local");
let id = attached_my_files(&daemon, &drive, &local);
let mounted = daemon.mountpoint.join("Work");
// Prime the mount listing before a mirror upload, to catch stale listings.
assert_eq!(std::fs::read_dir(&mounted).unwrap().count(), 0);
std::fs::write(local.join("mirror.txt"), b"mirror bytes").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read_dir(&mounted)
.unwrap()
.any(|e| e.unwrap().file_name() == "mirror.txt")
&& std::fs::read(mounted.join("mirror.txt")).is_ok_and(|b| b == b"mirror bytes")
}),
"mirror upload must reach the mounted view without a restart"
);
std::fs::write(mounted.join("mount.txt"), b"mounted bytes").unwrap();
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mounted bytes")
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
}),
"mounted writes must reach the mirror"
);
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state != "syncing")
}));
std::fs::write(mounted.join("mount.txt"), b"mount changed").unwrap();
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
std::fs::write(local.join("mount.txt"), b"local changed").unwrap();
daemon
.request(&Request::SetSyncFolderPaused { id, paused: false })
.unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mount changed")
&& conflict_copies(&local, "mount")
.iter()
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local changed"))
&& drive.tree().iter().any(|(path, entry)| {
path.starts_with("Work/mount (sync-conflict")
&& entry == &Entry::File(Arc::new(b"local changed".to_vec()))
})
}),
"both edits must survive the next pass"
);
let error = daemon
.request(&Request::SetSyncFolderMode {
id,
mode: "ondemand".into(),
})
.unwrap_err()
.to_string();
assert!(error.contains("only support mirror"), "{error}");
let error = daemon
.request(&Request::RemoveSyncFolder {
id,
delete_remote: true,
})
.unwrap_err()
.to_string();
assert!(error.contains("cannot be deleted"), "{error}");
let folder = listed_folder(&daemon, id).unwrap();
assert_eq!(folder.kind, "attached");
assert_eq!(folder.mode, "mirror");
assert!(folder.pending_mode.is_none());
// Restore must not reclassify a My files root as a device backup,
// which would make the device-only destructive operations available.
let restore_path = dir.join("forged-device-restore");
daemon
.request(&Request::RestoreSyncFolders {
items: vec![control::RestoreItem {
remote_uid: folder.remote_uid.clone(),
local_path: restore_path.to_string_lossy().into_owned(),
mode: "ondemand".into(),
}],
})
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
matches!(daemon.request(&Request::ListActivity { limit: 100 }),
Ok(Response::Activity { items }) if items.iter().any(|item| {
!item.ok && item.detail.contains("not a folder under this device")
}))
}));
assert!(!restore_path.exists());
match daemon.request(&Request::ListLocations).unwrap() {
Response::Locations { items } => assert!(items.iter().any(|m| {
matches!(m.kind, pdfs_core::mounts::MountKind::Attached { sync_folder_id } if sync_folder_id == id)
})),
other => panic!("unexpected reply: {other:?}"),
}
daemon
.request(&Request::RemoveSyncFolder {
id,
delete_remote: false,
})
.unwrap();
assert!(listed_folder(&daemon, id).is_none());
assert!(drive.lookup("Work/mount.txt").is_some());
assert_eq!(
std::fs::read(local.join("mount.txt")).unwrap(),
b"mount changed"
);
daemon.stop().unwrap();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_trashed_root_keeps_local_files() {
attached_root_loss_keeps_local_files(false);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_root_moved_outside_my_files_keeps_local_files() {
attached_root_loss_keeps_local_files(true);
}
fn attached_root_loss_keeps_local_files(move_outside: bool) {
let drive = FakeDrive::new();
let dir = scratch("attach-root-gone");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("local");
let id = attached_my_files(&daemon, &drive, &local);
std::fs::write(local.join("keep.txt"), b"keep my bytes").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("Work/keep.txt").is_some()
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
}));
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
if move_outside {
use crate::drive::DriveApi;
let rt = daemon.rt.as_ref().unwrap();
let device = rt
.block_on(
daemon
.client
.create_device("other computer", proton_drive_rs::DeviceType::Linux),
)
.unwrap();
let root = drive.lookup("Work").unwrap();
rt.block_on(daemon.client.move_node(&root, &device.root_folder_uid))
.unwrap();
} else {
drive.device().trash("Work");
}
daemon
.request(&Request::SetSyncFolderPaused { id, paused: false })
.unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
}));
assert_eq!(
std::fs::read(local.join("keep.txt")).unwrap(),
b"keep my bytes"
);
assert_eq!(std::fs::read_dir(&local).unwrap().count(), 1);
let daemon = daemon.restart().unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
}));
assert_eq!(
std::fs::read(local.join("keep.txt")).unwrap(),
b"keep my bytes"
);
daemon.stop().unwrap();
let _ = std::fs::remove_dir_all(dir);
}
/// Register `local` as a synced folder in `mode` and wait until the daemon /// Register `local` as a synced folder in `mode` and wait until the daemon
/// lists it that way, returning its id. /// lists it that way, returning its id.
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 { fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
+8
View File
@@ -1123,6 +1123,14 @@ fn delete_one(server: &mut Server, uid: &NodeUid) -> Result<()> {
#[async_trait] #[async_trait]
impl DriveApi for FakeClient { impl DriveApi for FakeClient {
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
self.request(false, |server| {
assert_eq!(uid, &server.root);
Ok(ShareId::new("my-files-share"))
})
.await
}
async fn get_my_files_folder(&self) -> Result<Node> { async fn get_my_files_folder(&self) -> Result<Node> {
self.request(false, |server| { self.request(false, |server| {
let root = server.root.clone(); let root = server.root.clone();
+18 -1
View File
@@ -170,7 +170,24 @@ impl Core {
} }
let started = Instant::now(); let started = Instant::now();
self.progress_begin(folder.id); self.progress_begin(folder.id);
let result = if push_only { // An attachment's root can be trashed or moved by the web app. A
// missing root must never turn into a scan of an empty remote tree.
let attached_root = if current.kind == "attached" {
self.attach_ancestors(&remote_root).and_then(|ancestors| {
if ancestors.contains(&self.primary_root_uid) {
Ok(())
} else {
Err(pdfs_core::CoreError::invalid(
"attached folder is no longer below My files",
))
}
})
} else {
Ok(())
};
let result = if let Err(error) = attached_root {
Err(PassAbort::Failed(error.to_string()))
} else if push_only {
self.push_pass(folder.id, &local_root, &remote_root, &rules) self.push_pass(folder.id, &local_root, &remote_root, &rules)
} else { } else {
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) { match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
+29 -4
View File
@@ -588,6 +588,7 @@ fn platform_label(device_type: &str) -> &str {
/// How many folders this machine backs up, and whether any of them needs /// How many folders this machine backs up, and whether any of them needs
/// attention — the one fact worth surfacing away from the folder list itself. /// attention — the one fact worth surfacing away from the folder list itself.
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String { pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
let folders: Vec<_> = folders.iter().filter(|f| f.kind == "device").collect();
if folders.is_empty() { if folders.is_empty() {
return gettext("No folders backed up yet"); return gettext("No folders backed up yet");
} }
@@ -939,7 +940,13 @@ pub(crate) fn reload_sync_pages(ui: &Rc<Ui>) {
} }
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy. /// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) { pub(crate) fn prompt_remove_sync_folder(
ui: &Rc<Ui>,
id: i64,
path: &str,
ondemand: bool,
attached: bool,
) {
let win = ui_window(ui); let win = ui_window(ui);
// The two modes leave the user in opposite places, so they can't share a // The two modes leave the user in opposite places, so they can't share a
// sentence. A mirror folder's files are already on this disk and stay there. // sentence. A mirror folder's files are already on this disk and stay there.
@@ -947,7 +954,12 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
// in Proton Drive, so unmounting it leaves an empty directory — which is a // in Proton Drive, so unmounting it leaves an empty directory — which is a
// nasty surprise if the dialog claimed the local files were safe, and is // nasty surprise if the dialog claimed the local files were safe, and is
// recoverable only by turning On-demand off *first* and letting it download. // recoverable only by turning On-demand off *first* and letting it download.
let body = if ondemand { let body = if attached {
gettext_f(
"Stop syncing “{path}”? Both the local files and the folder in My files are kept.",
&[("path", path)],
)
} else if ondemand {
// Translators: {path} is a local folder path. // Translators: {path} is a local folder path.
gettext_f( gettext_f(
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.", "Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
@@ -975,7 +987,9 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
.active(false) .active(false)
.build(); .build();
group.add(&delete_remote); group.add(&delete_remote);
dialog.set_extra_child(Some(&group)); if !attached {
dialog.set_extra_child(Some(&group));
}
dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("remove", &gettext("Stop Syncing")); dialog.add_response("remove", &gettext("Stop Syncing"));
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
@@ -988,7 +1002,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
&ui, &ui,
Request::RemoveSyncFolder { Request::RemoveSyncFolder {
id, id,
delete_remote: delete_remote.is_active(), delete_remote: !attached && delete_remote.is_active(),
}, },
&gettext("Stopped syncing folder"), &gettext("Stopped syncing folder"),
&gettext("Couldn't stop syncing the folder"), &gettext("Couldn't stop syncing the folder"),
@@ -1291,6 +1305,7 @@ mod tests {
fn folder(state: &str) -> SyncFolderInfo { fn folder(state: &str) -> SyncFolderInfo {
SyncFolderInfo { SyncFolderInfo {
kind: "device".into(),
id: 1, id: 1,
local_path: "/home/u/Documents".into(), local_path: "/home/u/Documents".into(),
remote_uid: "vol~link".into(), remote_uid: "vol~link".into(),
@@ -1303,6 +1318,16 @@ mod tests {
} }
} }
#[test]
fn attached_my_files_folders_do_not_count_as_device_backups() {
let mut attached = folder("error");
attached.kind = "attached".into();
assert_eq!(
this_computer_subtitle(&[attached]),
"No folders backed up yet"
);
}
#[test] #[test]
fn a_machine_with_no_folders_says_so() { fn a_machine_with_no_folders_says_so() {
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet"); assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
+27 -5
View File
@@ -1361,6 +1361,9 @@ fn location_row(ui: &Rc<Ui>, spec: &MountSpec) -> LocationRow {
prompt_mountpoint(&ui) prompt_mountpoint(&ui)
}); });
} }
MountKind::Attached { sync_folder_id } => {
add_device_items(ui, &mut menu, spec, *sync_folder_id);
}
MountKind::Device { sync_folder_id } => { MountKind::Device { sync_folder_id } => {
row.add_suffix(&mode_button(ui, spec, *sync_folder_id)); row.add_suffix(&mode_button(ui, spec, *sync_folder_id));
add_device_items(ui, &mut menu, spec, *sync_folder_id); add_device_items(ui, &mut menu, spec, *sync_folder_id);
@@ -1520,8 +1523,9 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
// The folder's *current* mode, not a queued one: a switch that hasn't landed // The folder's *current* mode, not a queued one: a switch that hasn't landed
// yet has not moved the files anywhere. // yet has not moved the files anywhere.
let is_ondemand = spec.mode == MountMode::OnDemand; let is_ondemand = spec.mode == MountMode::OnDemand;
let attached = matches!(spec.kind, MountKind::Attached { .. });
menu.item(&gettext("Stop Syncing…"), move || { menu.item(&gettext("Stop Syncing…"), move || {
prompt_remove_sync_folder(&ui, id, &path, is_ondemand) prompt_remove_sync_folder(&ui, id, &path, is_ondemand, attached)
}); });
} }
@@ -1699,7 +1703,7 @@ fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
pub(crate) fn location_icon(kind: &MountKind) -> &'static str { pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
match kind { match kind {
MountKind::MyFiles => "folder-remote-symbolic", MountKind::MyFiles => "folder-remote-symbolic",
MountKind::Device { .. } => "folder-symbolic", MountKind::Device { .. } | MountKind::Attached { .. } => "folder-symbolic",
MountKind::Shared { .. } => "pdfs-people-symbolic", MountKind::Shared { .. } => "pdfs-people-symbolic",
} }
} }
@@ -1716,6 +1720,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
match &spec.kind { match &spec.kind {
MountKind::MyFiles => parts.push(gettext("My files")), MountKind::MyFiles => parts.push(gettext("My files")),
MountKind::Shared { .. } => parts.push(gettext("Shared folder")), MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
MountKind::Attached { .. } => parts.push(gettext("My files mirror")),
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) { MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")), (Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")),
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")), (Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
@@ -1725,7 +1730,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
(Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)), (Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
}, },
} }
if matches!(spec.kind, MountKind::Device { .. }) { if matches!(
spec.kind,
MountKind::Device { .. } | MountKind::Attached { .. }
) {
parts.push(match &spec.progress { parts.push(match &spec.progress {
Some(p) => sync_progress_label(p), Some(p) => sync_progress_label(p),
None if spec.paused => pgettext("state", "paused"), None if spec.paused => pgettext("state", "paused"),
@@ -1737,8 +1745,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
} }
// Only the locations that are supposed to have a session report its absence: // Only the locations that are supposed to have a session report its absence:
// a mirror folder never has one. // a mirror folder never has one.
let expects_session = !matches!(spec.kind, MountKind::Device { .. }) let expects_session = !matches!(
|| spec.mode == MountMode::OnDemand spec.kind,
MountKind::Device { .. } | MountKind::Attached { .. }
) || spec.mode == MountMode::OnDemand
|| spec.pending_mode == Some(MountMode::OnDemand); || spec.pending_mode == Some(MountMode::OnDemand);
if expects_session && !spec.mounted { if expects_session && !spec.mounted {
// Translators: a folder's files can't be opened right now. // Translators: a folder's files can't be opened right now.
@@ -1769,6 +1779,18 @@ mod tests {
} }
} }
#[test]
fn an_attached_mirror_is_available_without_a_fuse_session() {
let spec = spec(
MountKind::Attached { sync_folder_id: 7 },
MountMode::Mirror,
false,
);
let subtitle = location_subtitle(&spec);
assert!(subtitle.contains("My files mirror"), "{subtitle}");
assert!(!subtitle.contains("not available"), "{subtitle}");
}
#[test] #[test]
fn a_mirror_folder_is_never_reported_as_unmounted() { fn a_mirror_folder_is_never_reported_as_unmounted() {
// It is a plain local directory: no FUSE session is expected, so saying // It is a plain local directory: no FUSE session is expected, so saying
+10
View File
@@ -11,6 +11,16 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
Schema: **45**. Synced folders now distinguish device backups from My files attachments.
Existing folders remain device backups; older builds cannot open the upgraded database.
### Added
- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
My files folder. The first sync preserves one-sided files and differing versions (#67).
Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
the remote root when syncing is stopped.
## [3.7.0] — 2026-11-02 ## [3.7.0] — 2026-11-02
Schema: **44**, unchanged. Schema: **44**, unchanged.
+1
View File
@@ -80,6 +80,7 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. | | `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. |
| `pdfs sync attach PATH --to REMOTE` | Merge a real local folder with an existing My files folder, keeping both versions on initial conflicts. |
| `pdfs sync list` | List synced folders with their IDs and state. | | `pdfs sync list` | List synced folders with their IDs and state. |
| `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. | | `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. |
| `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). | | `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). |
+28 -1
View File
@@ -5,6 +5,7 @@ up to Proton Drive and kept in step with it. This page covers adding one, what t
that should not be backed up, changes that are still waiting to upload, conflicts, and pausing. that should not be backed up, changes that are still waiting to upload, conflicts, and pausing.
- [Add a folder](#add-a-folder) - [Add a folder](#add-a-folder)
- [Attach an existing My files folder](#attach-an-existing-my-files-folder)
- [Synced or online only](#synced-or-online-only) - [Synced or online only](#synced-or-online-only)
- [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files) - [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files)
- [Stop syncing a folder](#stop-syncing-a-folder) - [Stop syncing a folder](#stop-syncing-a-folder)
@@ -74,7 +75,8 @@ again when they move into a synced one.
**Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on **Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on
Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**, Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**,
or add `--delete-remote`. or add `--delete-remote`. Attached My files folders always keep the cloud copy;
`--delete-remote` is refused for them.
## Sync a folder from scratch ## Sync a folder from scratch
@@ -169,3 +171,28 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
`pdfs sync resume` continues at once. `pdfs sync resume` continues at once.
- **Limit bandwidth** under **Preferences → General → Network**, or with - **Limit bandwidth** under **Preferences → General → Network**, or with
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit. `pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
## Attach an existing My files folder
Run `pdfs sync attach /path/to/local --to Work` to merge a real local directory
with the existing `Work` folder in My files. `--to` also accepts an absolute path
inside the My files mount. Both folders must already exist.
The local directory stays a normal mirrored directory. The first pass copies files
found on only one side, adopts identical files, and keeps a conflict copy when
versions differ; it does not propagate deletions. Later passes use ordinary
bidirectional sync, including deletions and the existing wipe guards.
The My files root, shared folders, overlapping sync roots, and local paths inside
or containing the cloud mount are refused. Use `pdfs sync list` to find the new
folder's ID and monitor its state. `pdfs sync rm ID` stops syncing and keeps both
copies. `--delete-remote` is refused for attached folders.
Attached folders support **mirror** mode only. Use the main My files mount for online-only
access. They are listed separately from device backups and are not included in the device
profile used to restore another computer.
On the first sync, the remote version keeps the original name when both versions differ.
The local version is preserved as a conflict copy next to it and is uploaded too. If the
remote root is trashed or moved outside My files, syncing stops with an error and local
files are kept.
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 3.6.0 pkgver = 3.7.0
pkgrel = 1 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
install = proton-drive.install install = proton-drive.install
@@ -22,21 +22,21 @@ pkgbase = proton-drive-for-linux-bin
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.6.0 provides = proton-drive-for-linux=3.7.0
provides = proton-drive-linux=3.6.0 provides = proton-drive-linux=3.7.0
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !strip options = !strip
options = !debug options = !debug
source = proton-drive-linux-3.6.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.6.0/proton-drive-linux-3.6.0-x86_64.tar.gz source = proton-drive-linux-3.7.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.7.0/proton-drive-linux-3.7.0-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.desktop source = io.narl.proton-drive-linux-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux-tray.desktop source = io.narl.proton-drive-linux-tray-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.6.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.svg source = io.narl.proton-drive-linux-3.7.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.6.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/proton-drive.service source = proton-drive-3.7.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/proton-drive.service
source = LICENSE-3.6.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/LICENSE source = LICENSE-3.7.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/LICENSE
sha256sums = 8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7 sha256sums = 3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123 sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0 sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828 sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin pkgname=proton-drive-for-linux-bin
pkgver=3.6.0 pkgver=3.7.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64') arch=('x86_64')
@@ -36,7 +36,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg" "io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service" "proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE") "LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7' sha256sums=('3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123' 'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0' '1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828' 'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-git pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 3.6.0.r0.g2720191 pkgver = 3.7.0.r0.g7ab14bb
pkgrel = 1 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
install = proton-drive.install install = proton-drive.install
@@ -25,8 +25,8 @@ pkgbase = proton-drive-for-linux-git
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.6.0.r0.g2720191 provides = proton-drive-for-linux=3.7.0.r0.g7ab14bb
provides = proton-drive-linux=3.6.0.r0.g2720191 provides = proton-drive-linux=3.7.0.r0.g7ab14bb
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git pkgname=proton-drive-for-linux-git
pkgver=3.6.0.r0.g2720191 pkgver=3.7.0.r0.g7ab14bb
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64') arch=('x86_64')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 3.6.0 pkgver = 3.7.0
pkgrel = 1 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
install = proton-drive.install install = proton-drive.install
@@ -24,13 +24,13 @@ pkgbase = proton-drive-for-linux
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=3.6.0 provides = proton-drive-linux=3.7.0
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !lto options = !lto
source = proton-drive-for-linux-3.6.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.6.0.tar.gz source = proton-drive-for-linux-3.7.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.7.0.tar.gz
sha256sums = d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d sha256sums = 542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737
pkgname = proton-drive-for-linux pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux pkgname=proton-drive-for-linux
pkgver=3.6.0 pkgver=3.7.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray" pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64') arch=('x86_64')
@@ -26,7 +26,7 @@ options=('!lto')
# Stops the running daemon before removal, so the mount stops mirroring deletes. # Stops the running daemon before removal, so the mount stops mirroring deletes.
install=proton-drive.install install=proton-drive.install
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d') sha256sums=('542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737')
_srcname="proton-drive-linux-$pkgver" _srcname="proton-drive-linux-$pkgver"