mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-13 17:27:03 +01:00
feat(sync): attach local folders to existing My files folders
This commit is contained in:
1 parent
800705ccb0
commit
5ca0272f15
13 files changed
+453
-1
No files matched your search
@@ -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)? {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
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 {
|
||||||
|
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_add(&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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,31 @@ 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) => {
|
||||||
|
core.log_activity(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
&local_path,
|
||||||
|
"attached existing My files folder",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
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),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -513,6 +518,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() {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -389,6 +389,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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2912,6 +2912,220 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
## [3.7.0] — 2026-11-02
|
## [3.7.0] — 2026-11-02
|
||||||
|
|
||||||
Schema: **44**, unchanged.
|
Schema: **44**, unchanged.
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `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 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 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. |
|
||||||
|
|||||||
@@ -169,3 +169,19 @@ 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; adding
|
||||||
|
`--delete-remote` also trashes the linked cloud folder.
|
||||||
Reference in new issue
Block a user