mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b860ac3b49 | ||
|
|
6f504d2e83 | ||
|
|
8c1c37a928 | ||
|
|
e4d2340bbf | ||
|
|
c1b72e455e | ||
|
|
2e3b7748e6 | ||
|
|
7f4f11ef18 | ||
|
|
cc9f7c6d8a | ||
|
|
9add185c1f | ||
|
|
0e7ffe1261 | ||
|
|
a5acae41b3 | ||
|
|
ea10a43216 | ||
|
|
917e7268f9 | ||
|
|
9089c8d13c | ||
|
|
071a11ef65 | ||
|
|
4edcff3f87 | ||
|
|
ee59e338b6 | ||
|
|
c1d9538669 | ||
|
|
a73ed34421 | ||
|
|
e065586d7e | ||
|
|
fd80c34152 |
No files matched your search
Generated
+8
-8
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.5.0"
|
version = "3.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.5.0"
|
version = "3.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3517,7 +3517,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.5.0"
|
version = "3.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3539,7 +3539,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.5.0"
|
version = "3.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3779,9 +3779,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.9.1"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e6b87f78711e8fc72737c8eb24ebda156079308d79ab37ca2b1b32de6812880"
|
checksum = "8f70318394b2815b009c52f804d75747086db93a338ceb6eae62541f2b25d5bf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3806,9 +3806,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.9.1"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f1936755c603b98de1c82a5b343c6232e9fea8adaa77139f7cb8fcddcda697"
|
checksum = "4196392032f56b3bbaac0bb1f24eea62fa7e2a0af72004de8d08e518d14720fb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.5.0"
|
version = "3.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.9.1" }
|
proton-sdk = { version = "0.10.0" }
|
||||||
proton-drive-rs = { version = "0.9.1" }
|
proton-drive-rs = { version = "0.10.0" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
+217
-6
@@ -13,7 +13,7 @@ use pdfs_core::auth;
|
|||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::cache::ContentCache;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
ConflictKeep, ErrorKind, LinkChange, MountKind, RefreshScope, Request as CtlRequest,
|
||||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
||||||
daemon_outdated, pending_summary,
|
daemon_outdated, pending_summary,
|
||||||
};
|
};
|
||||||
@@ -356,6 +356,26 @@ enum Command {
|
|||||||
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
||||||
paths: Vec<PathBuf>,
|
paths: Vec<PathBuf>,
|
||||||
},
|
},
|
||||||
|
/// Copy files or folders into another folder in My files via the running
|
||||||
|
/// daemon.
|
||||||
|
///
|
||||||
|
/// The copy happens on Proton Drive, so nothing is downloaded or uploaded,
|
||||||
|
/// unlike `cp` inside the mount. A name already taken in the destination
|
||||||
|
/// gets a " (1)" suffix. The last path is the destination folder.
|
||||||
|
Copy {
|
||||||
|
/// Files/folders to copy, then the destination folder: inside My files,
|
||||||
|
/// or relative to the mountpoint.
|
||||||
|
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Show how much a folder holds on Proton Drive.
|
||||||
|
///
|
||||||
|
/// Proton Drive adds it up, so nothing is downloaded. The size is the
|
||||||
|
/// encrypted bytes on storage and includes items in the trash.
|
||||||
|
Du {
|
||||||
|
/// Folder path, inside My files or relative to the mountpoint.
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
/// Trash a file or folder via the running daemon.
|
/// Trash a file or folder via the running daemon.
|
||||||
Rm {
|
Rm {
|
||||||
/// File/folder path, inside My files or an on-demand synced folder, or
|
/// File/folder path, inside My files or an on-demand synced folder, or
|
||||||
@@ -689,6 +709,13 @@ enum SyncCmd {
|
|||||||
/// Optional synced-folder id; omit to reconcile every folder.
|
/// Optional synced-folder id; omit to reconcile every folder.
|
||||||
id: Option<i64>,
|
id: Option<i64>,
|
||||||
},
|
},
|
||||||
|
/// Forget what a synced folder synced before and compare it with Proton
|
||||||
|
/// Drive afresh: what one side has alone is copied to the other, and a file
|
||||||
|
/// that differs keeps both versions. Nothing is deleted.
|
||||||
|
Reset {
|
||||||
|
/// Synced-folder id (from `sync list`).
|
||||||
|
id: i64,
|
||||||
|
},
|
||||||
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
||||||
Pause {
|
Pause {
|
||||||
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
|
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
|
||||||
@@ -777,6 +804,29 @@ enum PublicLinkCmd {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
expires: Option<i64>,
|
expires: Option<i64>,
|
||||||
},
|
},
|
||||||
|
/// Change a node's public link. The URL stays the same; an option not
|
||||||
|
/// given is kept.
|
||||||
|
Update {
|
||||||
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Public-link id, from `pdfs members`.
|
||||||
|
id: String,
|
||||||
|
/// New role: viewer or editor.
|
||||||
|
#[arg(long)]
|
||||||
|
role: Option<String>,
|
||||||
|
/// New custom password protecting the link.
|
||||||
|
#[arg(long, conflicts_with = "no_password")]
|
||||||
|
password: Option<String>,
|
||||||
|
/// Remove the custom password.
|
||||||
|
#[arg(long)]
|
||||||
|
no_password: bool,
|
||||||
|
/// New expiry, Unix epoch seconds.
|
||||||
|
#[arg(long, conflicts_with = "no_expiry")]
|
||||||
|
expires: Option<i64>,
|
||||||
|
/// Remove the expiry.
|
||||||
|
#[arg(long)]
|
||||||
|
no_expiry: bool,
|
||||||
|
},
|
||||||
/// Remove a node's public link.
|
/// Remove a node's public link.
|
||||||
Remove {
|
Remove {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
@@ -919,6 +969,11 @@ fn main() -> Result<()> {
|
|||||||
let new_parent = paths.pop().expect("clap requires at least two paths");
|
let new_parent = paths.pop().expect("clap requires at least two paths");
|
||||||
cmd_move(paths, new_parent)
|
cmd_move(paths, new_parent)
|
||||||
}
|
}
|
||||||
|
Command::Copy { mut paths } => {
|
||||||
|
let new_parent = paths.pop().expect("clap requires at least two paths");
|
||||||
|
cmd_copy(paths, new_parent)
|
||||||
|
}
|
||||||
|
Command::Du { path } => cmd_du(path),
|
||||||
Command::Rm { path } => cmd_rm(path),
|
Command::Rm { path } => cmd_rm(path),
|
||||||
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
|
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
|
||||||
Command::Upload { sources, parent } => cmd_upload(sources, parent),
|
Command::Upload { sources, parent } => cmd_upload(sources, parent),
|
||||||
@@ -1088,7 +1143,14 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
// A pass in flight says what it is doing, indented under it.
|
// A pass in flight says what it is doing, indented under it.
|
||||||
if let Some(p) = &f.progress {
|
if let Some(p) = &f.progress {
|
||||||
match p.phase {
|
match p.phase {
|
||||||
SyncPhase::Scanning if p.total == 0 => println!(" scanning…"),
|
SyncPhase::Scanning if p.total == 0 && p.done == 0 => {
|
||||||
|
println!(" scanning…")
|
||||||
|
}
|
||||||
|
// A first pass has no estimate yet; the count alone
|
||||||
|
// shows a long first scan is moving.
|
||||||
|
SyncPhase::Scanning if p.total == 0 => {
|
||||||
|
println!(" scanning: {} checked", p.done)
|
||||||
|
}
|
||||||
SyncPhase::Scanning => {
|
SyncPhase::Scanning => {
|
||||||
println!(
|
println!(
|
||||||
" scanning: {} of {}",
|
" scanning: {} of {}",
|
||||||
@@ -1117,6 +1179,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
})?)?
|
})?)?
|
||||||
}
|
}
|
||||||
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
|
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
|
||||||
|
SyncCmd::Reset { id } => ok_or_bail(control_request(CtlRequest::ResetSyncFolder { id })?)?,
|
||||||
SyncCmd::Pause {
|
SyncCmd::Pause {
|
||||||
folder: Some(id), ..
|
folder: Some(id), ..
|
||||||
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
|
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
|
||||||
@@ -1493,9 +1556,13 @@ fn cmd_versions(action: VersionCmd) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_members(path: PathBuf) -> Result<()> {
|
fn cmd_members(path: PathBuf) -> Result<()> {
|
||||||
match control_request(CtlRequest::ListShare {
|
let response = control_request(CtlRequest::ListShare {
|
||||||
path: path_arg(&path)?,
|
path: path_arg(&path)?,
|
||||||
})? {
|
})?;
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match response {
|
||||||
CtlResponse::Share { entries, link } => {
|
CtlResponse::Share { entries, link } => {
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
println!("No members or pending invitations.");
|
println!("No members or pending invitations.");
|
||||||
@@ -1512,7 +1579,11 @@ fn cmd_members(path: PathBuf) -> Result<()> {
|
|||||||
Some(l) => {
|
Some(l) => {
|
||||||
let url = l.url.as_deref().unwrap_or("(url hidden)");
|
let url = l.url.as_deref().unwrap_or("(url hidden)");
|
||||||
let pw = if l.has_password { " +password" } else { "" };
|
let pw = if l.has_password { " +password" } else { "" };
|
||||||
println!("public link ({}{pw}): {url} [{}]", l.role, l.id);
|
let expiry = l
|
||||||
|
.expires
|
||||||
|
.map(|at| format!(", expires {}", format_epoch_utc(at)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("public link ({}{pw}{expiry}): {url} [{}]", l.role, l.id);
|
||||||
}
|
}
|
||||||
None => println!("public link: none"),
|
None => println!("public link: none"),
|
||||||
}
|
}
|
||||||
@@ -1558,6 +1629,29 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
|||||||
password,
|
password,
|
||||||
expires,
|
expires,
|
||||||
})? {
|
})? {
|
||||||
|
response if emit_json(&response)? => {}
|
||||||
|
CtlResponse::PublicLink { link } => {
|
||||||
|
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
},
|
||||||
|
PublicLinkCmd::Update {
|
||||||
|
path,
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
password,
|
||||||
|
no_password,
|
||||||
|
expires,
|
||||||
|
no_expiry,
|
||||||
|
} => match control_request(CtlRequest::UpdatePublicLink {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
password: link_change(password, no_password),
|
||||||
|
expires: link_change(expires, no_expiry),
|
||||||
|
})? {
|
||||||
|
response if emit_json(&response)? => {}
|
||||||
CtlResponse::PublicLink { link } => {
|
CtlResponse::PublicLink { link } => {
|
||||||
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
|
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
|
||||||
}
|
}
|
||||||
@@ -1574,6 +1668,15 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `--x VALUE` / `--no-x` flag pair as one link option change.
|
||||||
|
fn link_change<T>(value: Option<T>, remove: bool) -> LinkChange<T> {
|
||||||
|
match (value, remove) {
|
||||||
|
(Some(value), _) => LinkChange::Set(value),
|
||||||
|
(None, true) => LinkChange::Remove,
|
||||||
|
(None, false) => LinkChange::Keep,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_shared() -> Result<()> {
|
fn cmd_shared() -> Result<()> {
|
||||||
match control_request(CtlRequest::ListSharedByMe)? {
|
match control_request(CtlRequest::ListSharedByMe)? {
|
||||||
CtlResponse::SharedByMe { items } if items.is_empty() => {
|
CtlResponse::SharedByMe { items } if items.is_empty() => {
|
||||||
@@ -1765,6 +1868,7 @@ fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
|
|||||||
Sync => "sync",
|
Sync => "sync",
|
||||||
Rename => "rename",
|
Rename => "rename",
|
||||||
Move => "move",
|
Move => "move",
|
||||||
|
Copy => "copy",
|
||||||
CreateFolder => "mkdir",
|
CreateFolder => "mkdir",
|
||||||
Trash => "trash",
|
Trash => "trash",
|
||||||
Restore => "restore",
|
Restore => "restore",
|
||||||
@@ -2920,6 +3024,56 @@ fn cmd_move(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_copy(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
|
||||||
|
let new_parent = path_arg(&new_parent)?;
|
||||||
|
let mut failed = 0;
|
||||||
|
for source in &sources {
|
||||||
|
let outcome = path_arg(source).and_then(|path| {
|
||||||
|
match control_request(CtlRequest::Copy {
|
||||||
|
path,
|
||||||
|
new_parent: new_parent.clone(),
|
||||||
|
})? {
|
||||||
|
CtlResponse::Ok { message } => Ok(message),
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
match outcome {
|
||||||
|
Ok(message) => println!("{message}"),
|
||||||
|
Err(e) if sources.len() == 1 => return Err(e),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{}: {e:#}", source.display());
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
bail!("{failed} of {} could not be copied", sources.len());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_du(path: PathBuf) -> Result<()> {
|
||||||
|
let response = control_request(CtlRequest::FolderSize {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
})?;
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match response {
|
||||||
|
CtlResponse::FolderSize { size, items } => {
|
||||||
|
let noun = if items == 1 { "item" } else { "items" };
|
||||||
|
println!(
|
||||||
|
"{} in {items} {noun} (on Proton Drive, trash included)",
|
||||||
|
human_bytes(size)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_rm(path: PathBuf) -> Result<()> {
|
fn cmd_rm(path: PathBuf) -> Result<()> {
|
||||||
match control_request(CtlRequest::Delete {
|
match control_request(CtlRequest::Delete {
|
||||||
path: path_arg(&path)?,
|
path: path_arg(&path)?,
|
||||||
@@ -3519,10 +3673,13 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
println!("\nAccount");
|
println!("\nAccount");
|
||||||
|
let mut keys_unlocked = false;
|
||||||
match pdfs_core::auth::load() {
|
match pdfs_core::auth::load() {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
||||||
match key_state_name() {
|
let key_state = key_state_name();
|
||||||
|
keys_unlocked = key_state != "locked";
|
||||||
|
match key_state {
|
||||||
"locked" => report.finding(
|
"locked" => report.finding(
|
||||||
DiagnoseLevel::Fail,
|
DiagnoseLevel::Fail,
|
||||||
" account keys",
|
" account keys",
|
||||||
@@ -3561,6 +3718,7 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
);
|
);
|
||||||
// A stale socket file with no listener is the common failure, so the reply
|
// A stale socket file with no listener is the common failure, so the reply
|
||||||
// is what decides this rather than the file's existence.
|
// is what decides this rather than the file's existence.
|
||||||
|
let mut daemon_responding = false;
|
||||||
match control_request(CtlRequest::Status) {
|
match control_request(CtlRequest::Status) {
|
||||||
Ok(CtlResponse::Status {
|
Ok(CtlResponse::Status {
|
||||||
mountpoint,
|
mountpoint,
|
||||||
@@ -3572,6 +3730,7 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
version,
|
version,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
|
daemon_responding = true;
|
||||||
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
||||||
if daemon_outdated(&version) {
|
if daemon_outdated(&version) {
|
||||||
report.finding(
|
report.finding(
|
||||||
@@ -3713,6 +3872,14 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
mountpoint.display().to_string(),
|
mountpoint.display().to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The daemon opens its control socket only once My Files is mounted, so a
|
||||||
|
// daemon that answers has the keys it needs. One that does not may be stuck
|
||||||
|
// on a key that does not open (#40); only then is Proton asked.
|
||||||
|
if keys_unlocked && !daemon_responding {
|
||||||
|
println!("\nKeys");
|
||||||
|
diagnose_keys(&mut report);
|
||||||
|
}
|
||||||
|
|
||||||
if report.failures == 0 {
|
if report.failures == 0 {
|
||||||
if report.warnings == 0 {
|
if report.warnings == 0 {
|
||||||
println!("\nNo problems found.");
|
println!("\nNo problems found.");
|
||||||
@@ -3732,6 +3899,43 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask Proton which account and address keys open, and which of them the My
|
||||||
|
/// Files share needs. The findings hold key IDs and no secrets.
|
||||||
|
fn diagnose_keys(report: &mut DiagnoseReport) {
|
||||||
|
use pdfs_core::keycheck::{self, Level};
|
||||||
|
|
||||||
|
let checked = tokio::runtime::Runtime::new()
|
||||||
|
.map_err(anyhow::Error::from)
|
||||||
|
.and_then(|rt| {
|
||||||
|
rt.block_on(async {
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(30), keycheck::run())
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("Proton did not answer within 30 seconds"))?
|
||||||
|
.map_err(anyhow::Error::from)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let (keys, share) = match checked {
|
||||||
|
Ok(checked) => checked,
|
||||||
|
Err(e) => {
|
||||||
|
report.finding(DiagnoseLevel::Warn, " key check", format!("{e:#}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let findings = keycheck::findings(&keys, &share);
|
||||||
|
let any_failed = findings.iter().any(|f| f.level != Level::Ok);
|
||||||
|
for finding in findings {
|
||||||
|
let level = match finding.level {
|
||||||
|
Level::Ok => DiagnoseLevel::Ok,
|
||||||
|
Level::Warn => DiagnoseLevel::Warn,
|
||||||
|
Level::Fail => DiagnoseLevel::Fail,
|
||||||
|
};
|
||||||
|
report.finding(level, &format!(" {}", finding.label), finding.detail);
|
||||||
|
}
|
||||||
|
if any_failed {
|
||||||
|
println!(" (key IDs are not secret; include this section when you report it)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send one request to the running mount daemon's control socket and read its
|
/// Send one request to the running mount daemon's control socket and read its
|
||||||
/// reply. Errors if no daemon is listening.
|
/// reply. Errors if no daemon is listening.
|
||||||
fn control_request(req: CtlRequest) -> Result<CtlResponse> {
|
fn control_request(req: CtlRequest) -> Result<CtlResponse> {
|
||||||
@@ -3906,6 +4110,13 @@ mod login_wait_tests {
|
|||||||
assert_eq!(slept, 0);
|
assert_eq!(slept, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_flag_pair_keeps_removes_or_sets() {
|
||||||
|
assert_eq!(link_change::<i64>(None, false), LinkChange::Keep);
|
||||||
|
assert_eq!(link_change::<i64>(None, true), LinkChange::Remove);
|
||||||
|
assert_eq!(link_change(Some(5), false), LinkChange::Set(5));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn epoch_seconds_format_as_utc_calendar_dates() {
|
fn epoch_seconds_format_as_utc_calendar_dates() {
|
||||||
assert_eq!(format_epoch_utc(0), "1970-01-01");
|
assert_eq!(format_epoch_utc(0), "1970-01-01");
|
||||||
|
|||||||
@@ -344,6 +344,17 @@ pub enum Request {
|
|||||||
/// or a mirror synced folder), and the move is still made on Proton Drive
|
/// or a mirror synced folder), and the move is still made on Proton Drive
|
||||||
/// without transferring content. Replies with [`Response::Ok`].
|
/// without transferring content. Replies with [`Response::Ok`].
|
||||||
Move { path: String, new_parent: String },
|
Move { path: String, new_parent: String },
|
||||||
|
/// Copy a file or folder into a folder on Proton Drive, without
|
||||||
|
/// downloading or uploading it. Both `path` and `new_parent` are
|
||||||
|
/// mountpoint-relative, or absolute paths inside My files. A name already
|
||||||
|
/// taken in `new_parent` gets the next free ` (n)` suffix. Replies with
|
||||||
|
/// [`Response::Ok`].
|
||||||
|
Copy { path: String, new_parent: String },
|
||||||
|
/// Ask Proton Drive how much the folder at mountpoint-relative `path`
|
||||||
|
/// holds. Replies with [`Response::FolderSize`].
|
||||||
|
FolderSize { path: String },
|
||||||
|
/// The by-uid twin of [`Request::FolderSize`].
|
||||||
|
FolderSizeByUid { uid: String },
|
||||||
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
|
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
|
||||||
/// [`Response::Ok`].
|
/// [`Response::Ok`].
|
||||||
Delete { path: String },
|
Delete { path: String },
|
||||||
@@ -469,6 +480,11 @@ pub enum Request {
|
|||||||
/// Pause or resume one mirror folder's reconcile, independent of the
|
/// Pause or resume one mirror folder's reconcile, independent of the
|
||||||
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
|
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
|
||||||
SetSyncFolderPaused { id: i64, paused: bool },
|
SetSyncFolderPaused { id: i64, paused: bool },
|
||||||
|
/// Forget what a mirror folder synced before, so its next pass compares
|
||||||
|
/// both sides afresh: what is on one side only is copied to the other, and
|
||||||
|
/// a file that differs keeps both versions. Nothing is deleted. Replies
|
||||||
|
/// with [`Response::Ok`].
|
||||||
|
ResetSyncFolder { id: i64 },
|
||||||
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
|
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
|
||||||
/// Replies with [`Response::Ok`].
|
/// Replies with [`Response::Ok`].
|
||||||
SyncNow { id: Option<i64> },
|
SyncNow { id: Option<i64> },
|
||||||
@@ -596,6 +612,24 @@ pub enum Request {
|
|||||||
RemovePublicLink { path: String, id: String },
|
RemovePublicLink { path: String, id: String },
|
||||||
/// The by-uid twin of [`Request::RemovePublicLink`].
|
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||||
RemovePublicLinkByUid { uid: String, id: String },
|
RemovePublicLinkByUid { uid: String, id: String },
|
||||||
|
/// Change the public link `id` on the node at `path`, keeping its URL.
|
||||||
|
/// `role` is "viewer" or "editor", or `None` to keep the current one.
|
||||||
|
/// Replies with [`Response::PublicLink`].
|
||||||
|
UpdatePublicLink {
|
||||||
|
path: String,
|
||||||
|
id: String,
|
||||||
|
role: Option<String>,
|
||||||
|
password: LinkChange<String>,
|
||||||
|
expires: LinkChange<i64>,
|
||||||
|
},
|
||||||
|
/// The by-uid twin of [`Request::UpdatePublicLink`].
|
||||||
|
UpdatePublicLinkByUid {
|
||||||
|
uid: String,
|
||||||
|
id: String,
|
||||||
|
role: Option<String>,
|
||||||
|
password: LinkChange<String>,
|
||||||
|
expires: LinkChange<i64>,
|
||||||
|
},
|
||||||
/// Stop sharing the node at `path`: remove its public link, every pending
|
/// Stop sharing the node at `path`: remove its public link, every pending
|
||||||
/// invitation and every member. Each removal is attempted even if an
|
/// invitation and every member. Each removal is attempted even if an
|
||||||
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
||||||
@@ -1075,6 +1109,18 @@ pub struct PublicLinkInfo {
|
|||||||
pub created: i64,
|
pub created: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What [`Request::UpdatePublicLink`] does with one option of a link: its
|
||||||
|
/// custom password or its expiry.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LinkChange<T> {
|
||||||
|
/// Leave the option as it is.
|
||||||
|
Keep,
|
||||||
|
/// Remove the option: no custom password, or no expiry.
|
||||||
|
Remove,
|
||||||
|
/// Set the option to this value.
|
||||||
|
Set(T),
|
||||||
|
}
|
||||||
|
|
||||||
/// One invitation addressed to me (in [`Response::Invitations`]).
|
/// One invitation addressed to me (in [`Response::Invitations`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct InvitationInfo {
|
pub struct InvitationInfo {
|
||||||
@@ -1169,6 +1215,7 @@ pub enum ActivityKind {
|
|||||||
Sync,
|
Sync,
|
||||||
Rename,
|
Rename,
|
||||||
Move,
|
Move,
|
||||||
|
Copy,
|
||||||
CreateFolder,
|
CreateFolder,
|
||||||
Trash,
|
Trash,
|
||||||
Restore,
|
Restore,
|
||||||
@@ -2185,9 +2232,14 @@ pub enum Response {
|
|||||||
entries: Vec<ShareEntry>,
|
entries: Vec<ShareEntry>,
|
||||||
link: Option<PublicLinkInfo>,
|
link: Option<PublicLinkInfo>,
|
||||||
},
|
},
|
||||||
/// A freshly created public link, carrying the shareable URL (reply to
|
/// A freshly created or changed public link, carrying the shareable URL
|
||||||
/// [`Request::CreatePublicLink`]).
|
/// (reply to [`Request::CreatePublicLink`] and
|
||||||
|
/// [`Request::UpdatePublicLink`]).
|
||||||
PublicLink { link: PublicLinkInfo },
|
PublicLink { link: PublicLinkInfo },
|
||||||
|
/// What a folder holds on Proton Drive (reply to [`Request::FolderSize`]):
|
||||||
|
/// the bytes on storage and the number of items below it, folders and
|
||||||
|
/// trashed items included.
|
||||||
|
FolderSize { size: u64, items: u64 },
|
||||||
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
|
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
|
||||||
Invitations { items: Vec<InvitationInfo> },
|
Invitations { items: Vec<InvitationInfo> },
|
||||||
/// Saved public links (reply to [`Request::ListBookmarks`]).
|
/// Saved public links (reply to [`Request::ListBookmarks`]).
|
||||||
@@ -2687,6 +2739,10 @@ mod tests {
|
|||||||
path: "a/b.txt".into(),
|
path: "a/b.txt".into(),
|
||||||
new_parent: "d".into(),
|
new_parent: "d".into(),
|
||||||
},
|
},
|
||||||
|
Request::Copy {
|
||||||
|
path: "a/b.txt".into(),
|
||||||
|
new_parent: "d".into(),
|
||||||
|
},
|
||||||
Request::Delete {
|
Request::Delete {
|
||||||
path: "a/b.txt".into(),
|
path: "a/b.txt".into(),
|
||||||
},
|
},
|
||||||
@@ -3158,6 +3214,7 @@ mod tests {
|
|||||||
id: 3,
|
id: 3,
|
||||||
paused: true,
|
paused: true,
|
||||||
},
|
},
|
||||||
|
Request::ResetSyncFolder { id: 3 },
|
||||||
Request::SetBandwidthLimits {
|
Request::SetBandwidthLimits {
|
||||||
upload: 1_000_000,
|
upload: 1_000_000,
|
||||||
download: 0,
|
download: 0,
|
||||||
@@ -3247,6 +3304,24 @@ mod tests {
|
|||||||
uid: "vol~link".into(),
|
uid: "vol~link".into(),
|
||||||
id: "url-1".into(),
|
id: "url-1".into(),
|
||||||
},
|
},
|
||||||
|
Request::UpdatePublicLink {
|
||||||
|
path: "a/b".into(),
|
||||||
|
id: "url-1".into(),
|
||||||
|
role: Some("editor".into()),
|
||||||
|
password: LinkChange::Set("pw".into()),
|
||||||
|
expires: LinkChange::Remove,
|
||||||
|
},
|
||||||
|
Request::UpdatePublicLinkByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "url-1".into(),
|
||||||
|
role: None,
|
||||||
|
password: LinkChange::Keep,
|
||||||
|
expires: LinkChange::Set(1_700_000_000),
|
||||||
|
},
|
||||||
|
Request::FolderSize { path: "a".into() },
|
||||||
|
Request::FolderSizeByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
},
|
||||||
Request::ListSharedByMe,
|
Request::ListSharedByMe,
|
||||||
Request::ListActivity { limit: 100 },
|
Request::ListActivity { limit: 100 },
|
||||||
Request::ListSharedWithMe,
|
Request::ListSharedWithMe,
|
||||||
@@ -3621,6 +3696,16 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"RemovePublicLinkByUid",
|
"RemovePublicLinkByUid",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
Request::UpdatePublicLinkByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "link".into(),
|
||||||
|
role: None,
|
||||||
|
password: LinkChange::Keep,
|
||||||
|
expires: LinkChange::Keep,
|
||||||
|
},
|
||||||
|
"UpdatePublicLinkByUid",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
Request::StopSharingByUid {
|
Request::StopSharingByUid {
|
||||||
uid: "vol~link".into(),
|
uid: "vol~link".into(),
|
||||||
@@ -3646,6 +3731,7 @@ mod tests {
|
|||||||
| Request::RemoveShareEntryByUid { uid, .. }
|
| Request::RemoveShareEntryByUid { uid, .. }
|
||||||
| Request::CreatePublicLinkByUid { uid, .. }
|
| Request::CreatePublicLinkByUid { uid, .. }
|
||||||
| Request::RemovePublicLinkByUid { uid, .. }
|
| Request::RemovePublicLinkByUid { uid, .. }
|
||||||
|
| Request::UpdatePublicLinkByUid { uid, .. }
|
||||||
| Request::StopSharingByUid { uid } => uid,
|
| Request::StopSharingByUid { uid } => uid,
|
||||||
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
||||||
};
|
};
|
||||||
@@ -3654,6 +3740,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_update_names_what_it_keeps_removes_and_sets() {
|
||||||
|
let request = Request::UpdatePublicLink {
|
||||||
|
path: "a/b".into(),
|
||||||
|
id: "url-1".into(),
|
||||||
|
role: None,
|
||||||
|
password: LinkChange::Keep,
|
||||||
|
expires: LinkChange::Set(1_700_000_000),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&request).unwrap(),
|
||||||
|
r#"{"UpdatePublicLink":{"path":"a/b","id":"url-1","role":null,"password":"Keep","expires":{"Set":1700000000}}}"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&LinkChange::<String>::Remove).unwrap(),
|
||||||
|
r#""Remove""#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
|
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
|
||||||
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
|
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//! Trace the key chain that opens My Files, for `pdfs diagnose`.
|
||||||
|
//!
|
||||||
|
//! The daemon only says "missing key" when the My Files share does not open
|
||||||
|
//! (#40). This walks the chain the other way and names the link that breaks:
|
||||||
|
//! the share's passphrase is encrypted to an address key, whose token is
|
||||||
|
//! encrypted to an account key, which opens with the stored passphrase. Every
|
||||||
|
//! finding holds key IDs and flags only, never key material, so the output is
|
||||||
|
//! safe to post in an issue.
|
||||||
|
|
||||||
|
use proton_sdk::account::{AddressKeyReport, KeyReport, UserKeyReport};
|
||||||
|
use proton_sdk::ids::AddressId;
|
||||||
|
|
||||||
|
use crate::auth;
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Level {
|
||||||
|
Ok,
|
||||||
|
Warn,
|
||||||
|
Fail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Finding {
|
||||||
|
pub level: Level,
|
||||||
|
pub label: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address and recipients of the My Files share passphrase, or why they
|
||||||
|
/// could not be read.
|
||||||
|
pub type ShareRecipients = std::result::Result<(AddressId, Vec<Option<String>>), String>;
|
||||||
|
|
||||||
|
/// Resume the stored session and ask Proton for the account's keys and the
|
||||||
|
/// My Files share. Fails when there is no session, the keys are locked, or
|
||||||
|
/// Proton cannot be reached; reading the share is allowed to fail on its own.
|
||||||
|
///
|
||||||
|
/// The session's tokens are the daemon's too. A refresh here spends the
|
||||||
|
/// refresh token the daemon holds, which it recovers from by resuming the
|
||||||
|
/// stored session (see [`auth::stored_session_is_newer`]).
|
||||||
|
pub async fn run() -> Result<(KeyReport, ShareRecipients)> {
|
||||||
|
let (client, _session) = auth::resume_client().await?;
|
||||||
|
let report = client.account().key_report().await?;
|
||||||
|
let share = client
|
||||||
|
.my_files_share_recipients()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
Ok((report, share))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One finding per key, then one for the My Files share.
|
||||||
|
pub fn findings(report: &KeyReport, share: &ShareRecipients) -> Vec<Finding> {
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
for key in &report.user_keys {
|
||||||
|
findings.push(user_key_finding(key));
|
||||||
|
}
|
||||||
|
for address in &report.addresses {
|
||||||
|
for key in &address.keys {
|
||||||
|
findings.push(address_key_finding(report, &address.email, key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
findings.push(share_finding(report, share));
|
||||||
|
findings
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_key_finding(key: &UserKeyReport) -> Finding {
|
||||||
|
let label = format!("account key {}", short(key.id.as_str()));
|
||||||
|
let (level, detail) = match (key.active, key.unlocks) {
|
||||||
|
(true, true) => (Level::Ok, "opens".to_string()),
|
||||||
|
(true, false) => (
|
||||||
|
Level::Fail,
|
||||||
|
format!(
|
||||||
|
"does not open: {}",
|
||||||
|
key.error.as_deref().unwrap_or("unknown")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(false, true) => (Level::Ok, "inactive, opens with your password".to_string()),
|
||||||
|
(false, false) => (Level::Ok, "inactive, does not open".to_string()),
|
||||||
|
};
|
||||||
|
Finding {
|
||||||
|
level,
|
||||||
|
label,
|
||||||
|
detail: with_primary(key.primary, detail),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn address_key_finding(report: &KeyReport, email: &str, key: &AddressKeyReport) -> Finding {
|
||||||
|
let label = format!("{email} key {}", short(key.id.as_str()));
|
||||||
|
let (level, detail) = if !key.active {
|
||||||
|
(Level::Ok, "inactive, not used".to_string())
|
||||||
|
} else if key.unlocks {
|
||||||
|
(Level::Ok, "opens".to_string())
|
||||||
|
} else {
|
||||||
|
// A non-primary key matters only for what was encrypted to it; the
|
||||||
|
// share finding says whether My Files is.
|
||||||
|
let level = if key.primary {
|
||||||
|
Level::Fail
|
||||||
|
} else {
|
||||||
|
Level::Warn
|
||||||
|
};
|
||||||
|
(level, format!("does not open: {}", why_closed(report, key)))
|
||||||
|
};
|
||||||
|
Finding {
|
||||||
|
level,
|
||||||
|
label,
|
||||||
|
detail: with_primary(key.primary, detail),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why an active address key does not open, named by the account key its
|
||||||
|
/// token was meant for when the token has one.
|
||||||
|
fn why_closed(report: &KeyReport, key: &AddressKeyReport) -> String {
|
||||||
|
let error = key.error.as_deref().unwrap_or("unknown");
|
||||||
|
let Some(recipients) = &key.token_recipients else {
|
||||||
|
return error.to_string();
|
||||||
|
};
|
||||||
|
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
|
||||||
|
if let Some(user_key) = named.iter().find_map(|r| report.user_key_named(r)) {
|
||||||
|
let id = short(user_key.id.as_str());
|
||||||
|
return match (user_key.active, user_key.unlocks) {
|
||||||
|
(false, true) => {
|
||||||
|
format!(
|
||||||
|
"its token is for inactive account key {id}, which opens with your password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(false, false) => {
|
||||||
|
format!("its token is for inactive account key {id}, which does not open either")
|
||||||
|
}
|
||||||
|
(true, false) => format!("its token is for account key {id}, which does not open"),
|
||||||
|
// The token opened; the key itself refused its passphrase.
|
||||||
|
(true, true) => error.to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match named.first() {
|
||||||
|
Some(recipient) => {
|
||||||
|
format!("its token is for key {recipient}, which is none of your account keys")
|
||||||
|
}
|
||||||
|
None if recipients.is_empty() => error.to_string(),
|
||||||
|
None => "its token does not name the key it is for".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_finding(report: &KeyReport, share: &ShareRecipients) -> Finding {
|
||||||
|
let label = "My Files share".to_string();
|
||||||
|
let (level, detail) = match share {
|
||||||
|
Err(e) => (Level::Warn, format!("could not be read: {e}")),
|
||||||
|
Ok((address_id, recipients)) => {
|
||||||
|
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
|
||||||
|
match named.iter().find_map(|r| report.address_key_named(r)) {
|
||||||
|
Some((address, key)) => {
|
||||||
|
let name = format!("{} key {}", address.email, short(key.id.as_str()));
|
||||||
|
if key.unlocks {
|
||||||
|
(Level::Ok, format!("encrypted to {name}, which opens"))
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
Level::Fail,
|
||||||
|
format!("encrypted to {name}, which does not open"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
Level::Fail,
|
||||||
|
match named.first() {
|
||||||
|
Some(recipient) => format!(
|
||||||
|
"encrypted to key {recipient}, which is none of the keys of address {address_id}"
|
||||||
|
),
|
||||||
|
None => "its passphrase does not name the key it is for".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Finding {
|
||||||
|
level,
|
||||||
|
label,
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_primary(primary: bool, detail: String) -> String {
|
||||||
|
if primary {
|
||||||
|
format!("primary, {detail}")
|
||||||
|
} else {
|
||||||
|
detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The start of a Proton key ID: enough to tell keys apart and to find them
|
||||||
|
/// in the daemon log, which prints the whole ID.
|
||||||
|
fn short(id: &str) -> String {
|
||||||
|
match id.char_indices().nth(8) {
|
||||||
|
Some((end, _)) => format!("{}…", &id[..end]),
|
||||||
|
None => id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use proton_sdk::account::AddressReport;
|
||||||
|
use proton_sdk::ids::{AddressKeyId, UserKeyId};
|
||||||
|
|
||||||
|
fn user_key(id: &str, active: bool, unlocks: bool, pgp: &str) -> UserKeyReport {
|
||||||
|
UserKeyReport {
|
||||||
|
id: UserKeyId::new(id),
|
||||||
|
primary: active,
|
||||||
|
active,
|
||||||
|
unlocks,
|
||||||
|
error: (!unlocks).then(|| "unlock: wrong passphrase".to_string()),
|
||||||
|
key_ids: vec![pgp.to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn address_key(id: &str, primary: bool, token_for: &str, pgp: &str) -> AddressKeyReport {
|
||||||
|
AddressKeyReport {
|
||||||
|
id: AddressKeyId::new(id),
|
||||||
|
primary,
|
||||||
|
active: true,
|
||||||
|
token_recipients: Some(vec![Some(token_for.to_string())]),
|
||||||
|
unlocks: token_for == "u1",
|
||||||
|
error: (token_for != "u1").then(|| "token: failed to decrypt: missing key".to_string()),
|
||||||
|
key_ids: vec![pgp.to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account in #40, as far as its log shows: the primary address key
|
||||||
|
/// opens, an older one does not, and My Files is encrypted to the older one.
|
||||||
|
fn report(stranded_token_for: &str) -> KeyReport {
|
||||||
|
KeyReport {
|
||||||
|
user_keys: vec![
|
||||||
|
user_key("UserKeyOne-123", true, true, "u1"),
|
||||||
|
user_key("UserKeyTwo-456", false, true, "u2"),
|
||||||
|
],
|
||||||
|
addresses: vec![AddressReport {
|
||||||
|
id: AddressId::new("address-1"),
|
||||||
|
email: "user@proton.me".to_string(),
|
||||||
|
keys: vec![
|
||||||
|
address_key("AddrKeyNew-789", true, "u1", "a1"),
|
||||||
|
address_key("AddrKeyOld-000", false, stranded_token_for, "a2"),
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stranded_address_key_is_traced_to_the_inactive_account_key() {
|
||||||
|
let report = report("u2");
|
||||||
|
let share = Ok((AddressId::new("address-1"), vec![Some("a2".to_string())]));
|
||||||
|
let findings = findings(&report, &share);
|
||||||
|
|
||||||
|
assert_eq!(findings[0].detail, "primary, opens");
|
||||||
|
assert_eq!(findings[1].detail, "inactive, opens with your password");
|
||||||
|
assert_eq!(findings[2].level, Level::Ok);
|
||||||
|
assert_eq!(findings[3].level, Level::Warn);
|
||||||
|
assert_eq!(findings[3].label, "user@proton.me key AddrKeyO…");
|
||||||
|
assert_eq!(
|
||||||
|
findings[3].detail,
|
||||||
|
"does not open: its token is for inactive account key UserKeyT…, which opens with your password"
|
||||||
|
);
|
||||||
|
assert_eq!(findings[4].level, Level::Fail);
|
||||||
|
assert_eq!(
|
||||||
|
findings[4].detail,
|
||||||
|
"encrypted to user@proton.me key AddrKeyO…, which does not open"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_token_for_a_key_the_account_does_not_list_says_so() {
|
||||||
|
let report = report("ffff");
|
||||||
|
let detail = &findings(&report, &Err("offline".to_string()))[3].detail;
|
||||||
|
assert_eq!(
|
||||||
|
detail,
|
||||||
|
"does not open: its token is for key ffff, which is none of your account keys"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_that_opens_and_one_that_cannot_be_read() {
|
||||||
|
let report = report("u2");
|
||||||
|
let open = Ok((AddressId::new("address-1"), vec![Some("a1".to_string())]));
|
||||||
|
let last = findings(&report, &open).pop().unwrap();
|
||||||
|
assert_eq!(last.level, Level::Ok);
|
||||||
|
assert_eq!(
|
||||||
|
last.detail,
|
||||||
|
"encrypted to user@proton.me key AddrKeyN…, which opens"
|
||||||
|
);
|
||||||
|
|
||||||
|
let last = findings(&report, &Err("offline".to_string()))
|
||||||
|
.pop()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(last.level, Level::Warn);
|
||||||
|
assert_eq!(last.detail, "could not be read: offline");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub mod db;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod exiftime;
|
pub mod exiftime;
|
||||||
pub mod kernelkey;
|
pub mod kernelkey;
|
||||||
|
pub mod keycheck;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
|||||||
|
|
||||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// Answer a [`CtlRequest::FolderSize`].
|
||||||
|
fn folder_size_reply(size: CoreResult<proton_drive_rs::FolderSize>) -> CtlResponse {
|
||||||
|
match size {
|
||||||
|
Ok(size) => CtlResponse::FolderSize {
|
||||||
|
size: size.size,
|
||||||
|
items: size.descendants,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||||
/// into an error that names what is still shared.
|
/// into an error that names what is still shared.
|
||||||
fn stop_sharing_reply(
|
fn stop_sharing_reply(
|
||||||
@@ -377,6 +388,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
| R::RemoveSyncFolder { .. }
|
| R::RemoveSyncFolder { .. }
|
||||||
| R::SetSyncFolderMode { .. }
|
| R::SetSyncFolderMode { .. }
|
||||||
| R::SetSyncFolderPaused { .. }
|
| R::SetSyncFolderPaused { .. }
|
||||||
|
| R::ResetSyncFolder { .. }
|
||||||
| R::RestoreSyncFolders { .. }
|
| R::RestoreSyncFolders { .. }
|
||||||
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||||
R::RetryPendingOp { .. } => &[Topic::Queue],
|
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||||
@@ -392,6 +404,8 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
| R::CreatePublicLinkByUid { .. }
|
| R::CreatePublicLinkByUid { .. }
|
||||||
| R::RemovePublicLink { .. }
|
| R::RemovePublicLink { .. }
|
||||||
| R::RemovePublicLinkByUid { .. }
|
| R::RemovePublicLinkByUid { .. }
|
||||||
|
| R::UpdatePublicLink { .. }
|
||||||
|
| R::UpdatePublicLinkByUid { .. }
|
||||||
| R::StopSharing { .. }
|
| R::StopSharing { .. }
|
||||||
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||||
R::LeaveShared { .. }
|
R::LeaveShared { .. }
|
||||||
@@ -1063,6 +1077,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::Copy { path, new_parent }) => {
|
||||||
|
match (
|
||||||
|
rel_to_mount(mountpoint, &path),
|
||||||
|
rel_to_mount(mountpoint, &new_parent),
|
||||||
|
) {
|
||||||
|
(Ok(rel), Ok(parent_rel)) => match core.copy_to(&rel, &parent_rel) {
|
||||||
|
Ok(name) => {
|
||||||
|
let dest = if new_parent.is_empty() {
|
||||||
|
"My files".to_string()
|
||||||
|
} else {
|
||||||
|
new_parent.clone()
|
||||||
|
};
|
||||||
|
core.log_activity(ActivityKind::Copy, &name, format!("to {dest}"), true);
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: format!("copied to {name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
core.log_activity(ActivityKind::Copy, &path, &e, false);
|
||||||
|
CtlResponse::error(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::FolderSize { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => folder_size_reply(core.folder_size(&rel)),
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::FolderSizeByUid { uid }) => folder_size_reply(core.folder_size_by_uid(&uid)),
|
||||||
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
|
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
|
||||||
Ok((core, rel)) => match core.delete(&rel) {
|
Ok((core, rel)) => match core.delete(&rel) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
@@ -1389,6 +1433,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::ResetSyncFolder { id }) => match core.reset_sync_folder(id) {
|
||||||
|
Ok(message) => {
|
||||||
|
core.log_activity(ActivityKind::Sync, &message, "", true);
|
||||||
|
CtlResponse::Ok { message }
|
||||||
|
}
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::SyncNow { id }) => {
|
Ok(CtlRequest::SyncNow { id }) => {
|
||||||
core.sync_now(id);
|
core.sync_now(id);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -1630,6 +1681,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
CtlResponse::error(e)
|
CtlResponse::error(e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::UpdatePublicLink {
|
||||||
|
path,
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
password,
|
||||||
|
expires,
|
||||||
|
}) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => {
|
||||||
|
match core.update_public_link(&rel, &id, role.as_deref(), password, expires) {
|
||||||
|
Ok(link) => CtlResponse::PublicLink { link },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::UpdatePublicLinkByUid {
|
||||||
|
uid,
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
password,
|
||||||
|
expires,
|
||||||
|
}) => match core.update_public_link_by_uid(&uid, &id, role.as_deref(), password, expires) {
|
||||||
|
Ok(link) => CtlResponse::PublicLink { link },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
|||||||
@@ -1080,6 +1080,50 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forget what a mirror folder synced before, so its next pass compares the
|
||||||
|
/// two sides as on the first sync: what one side has alone is copied to the
|
||||||
|
/// other, and a file that differs is kept twice as a conflict. Nothing is
|
||||||
|
/// deleted on either side.
|
||||||
|
///
|
||||||
|
/// This is the way out for a folder whose passes refuse to run because its
|
||||||
|
/// whole local copy, or its whole copy on Drive, is gone (#46). The baseline
|
||||||
|
/// is what makes that read as "delete everything", and it is what this
|
||||||
|
/// drops. A queued switch to on-demand is dropped with it: the push pass
|
||||||
|
/// that switch runs reads an unrecorded local file as new and would upload
|
||||||
|
/// a second copy of every file Drive already has.
|
||||||
|
pub(crate) fn reset_sync_folder(&self, id: i64) -> CoreResult<String> {
|
||||||
|
let lock = self.sync_lock(id);
|
||||||
|
let Some(_guard) = lock.try_lock() else {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"this folder is syncing; try again when the pass ends",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let folder = self
|
||||||
|
.db
|
||||||
|
.sync_folder_get(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
|
if folder.mode != "mirror" {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"an online-only folder keeps nothing on this computer to sync from scratch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.db
|
||||||
|
.sync_entries_clear(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
if folder.pending_mode.as_deref() == Some("ondemand") {
|
||||||
|
self.db
|
||||||
|
.sync_folder_set_pending_mode(id, None)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
}
|
||||||
|
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||||
|
info!(id, path = %folder.local_path, "sync folder reset");
|
||||||
|
Ok(format!(
|
||||||
|
"{} will sync from scratch",
|
||||||
|
base_name(&folder.local_path)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a secondary FUSE session rooted at `root` over `local` from a
|
/// Spawn a secondary FUSE session rooted at `root` over `local` from a
|
||||||
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
|
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
|
||||||
/// one, which would fail the fresh mount with EBUSY).
|
/// one, which would fail the fresh mount with EBUSY).
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ 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};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient,
|
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
|
||||||
RevisionReader, Thumbnail,
|
ProtonDriveClient, RevisionReader, Thumbnail,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||||
@@ -138,6 +138,22 @@ pub(crate) trait DriveApi: Send + Sync {
|
|||||||
|
|
||||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
|
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
|
||||||
|
|
||||||
|
/// See [`ProtonDriveClient::copy_node`].
|
||||||
|
async fn copy_node(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
new_parent: &NodeUid,
|
||||||
|
name: Option<&str>,
|
||||||
|
) -> Result<NodeUid>;
|
||||||
|
|
||||||
|
/// See [`ProtonDriveClient::get_available_name`].
|
||||||
|
async fn get_available_name(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
target: AvailableNameTarget,
|
||||||
|
) -> Result<String>;
|
||||||
|
|
||||||
fn move_nodes_streaming<'a>(
|
fn move_nodes_streaming<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
items: Vec<NodeMoveItem>,
|
items: Vec<NodeMoveItem>,
|
||||||
@@ -448,6 +464,24 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
ProtonDriveClient::move_node(self, uid, new_parent).await
|
ProtonDriveClient::move_node(self, uid, new_parent).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn copy_node(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
new_parent: &NodeUid,
|
||||||
|
name: Option<&str>,
|
||||||
|
) -> Result<NodeUid> {
|
||||||
|
ProtonDriveClient::copy_node(self, uid, new_parent, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_available_name(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
target: AvailableNameTarget,
|
||||||
|
) -> Result<String> {
|
||||||
|
ProtonDriveClient::get_available_name(self, parent_uid, name, target).await
|
||||||
|
}
|
||||||
|
|
||||||
fn move_nodes_streaming<'a>(
|
fn move_nodes_streaming<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
items: Vec<NodeMoveItem>,
|
items: Vec<NodeMoveItem>,
|
||||||
|
|||||||
@@ -430,59 +430,11 @@ impl Filesystem for ProtonFs {
|
|||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fh = fh.0;
|
match self.stage_write(ino.0, fh.0, offset, data) {
|
||||||
// Stage the bytes straight into the scratch file (no base download): only
|
Ok(()) => reply.written(data.len() as u32),
|
||||||
// the untouched remainder is pulled from the remote, and only at commit.
|
Err(error) => reply.error(error),
|
||||||
let file = {
|
|
||||||
let st = self.core.state();
|
|
||||||
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
|
|
||||||
Some(aw) => aw.file.clone(),
|
|
||||||
None => {
|
|
||||||
reply.error(Errno::EBADF);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
if let Err(e) = file.write_all_at(data, offset) {
|
|
||||||
error!(ino = ino.0, fh, error = %e, "scratch write failed");
|
|
||||||
// A full local disk is not a broken file: answer `ENOSPC` so the
|
|
||||||
// application can say so (and so `cp`/editors take their own
|
|
||||||
// out-of-space path), and free what the cache is holding — the
|
|
||||||
// scratch file the next write goes to shares that filesystem.
|
|
||||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
|
||||||
self.core.cache.emergency_evict();
|
|
||||||
reply.error(Errno::ENOSPC);
|
|
||||||
} else {
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let new_len = {
|
|
||||||
let mut st = self.core.state();
|
|
||||||
let Some(aw) = st
|
|
||||||
.handles
|
|
||||||
.get(&fh)
|
|
||||||
.copied()
|
|
||||||
.and_then(|i| st.active_writes.get_mut(&i))
|
|
||||||
else {
|
|
||||||
reply.error(Errno::EBADF);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
aw.record_write(offset, offset + data.len() as u64);
|
|
||||||
let len = aw.len;
|
|
||||||
st.set_size(ino.0, len);
|
|
||||||
len
|
|
||||||
};
|
|
||||||
debug!(
|
|
||||||
ino = ino.0,
|
|
||||||
fh,
|
|
||||||
offset,
|
|
||||||
len = data.len(),
|
|
||||||
new_len,
|
|
||||||
"staged write"
|
|
||||||
);
|
|
||||||
reply.written(data.len() as u32);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn setattr(
|
fn setattr(
|
||||||
@@ -719,7 +671,7 @@ impl Filesystem for ProtonFs {
|
|||||||
reply: ReplyEmpty,
|
reply: ReplyEmpty,
|
||||||
) {
|
) {
|
||||||
let ino = self.tree(ino);
|
let ino = self.tree(ino);
|
||||||
let (handle, unlinked_uid) = {
|
let (handle, unlinked_uid, releasing) = {
|
||||||
let mut st = self.core.state();
|
let mut st = self.core.state();
|
||||||
let unlinked_uid = release_unlinked_entry(&mut st, ino.0);
|
let unlinked_uid = release_unlinked_entry(&mut st, ino.0);
|
||||||
let ino = st.handles.remove(&fh.0);
|
let ino = st.handles.remove(&fh.0);
|
||||||
@@ -732,7 +684,11 @@ impl Filesystem for ProtonFs {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
(h, unlinked_uid)
|
let releasing = ino.filter(|_| h.is_some());
|
||||||
|
if let Some(ino) = releasing {
|
||||||
|
st.releasing.insert(ino);
|
||||||
|
}
|
||||||
|
(h, unlinked_uid, releasing)
|
||||||
};
|
};
|
||||||
// Deliberately **not** handed to a worker, unlike the other slow
|
// Deliberately **not** handed to a worker, unlike the other slow
|
||||||
// handlers here. The kernel does not wait for a `release` reply before
|
// handlers here. The kernel does not wait for a `release` reply before
|
||||||
@@ -749,6 +705,9 @@ impl Filesystem for ProtonFs {
|
|||||||
// finish it (audit F1). What is left is a rename, two fsyncs, a database
|
// finish it (audit F1). What is left is a rename, two fsyncs, a database
|
||||||
// insert, and at most a bounded copy out of the local cache.
|
// insert, and at most a bounded copy out of the local cache.
|
||||||
self.finish_release(handle, unlinked_uid, reply);
|
self.finish_release(handle, unlinked_uid, reply);
|
||||||
|
if let Some(ino) = releasing {
|
||||||
|
self.core.state().releasing.remove(&ino);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
||||||
@@ -1016,20 +975,59 @@ impl Filesystem for ProtonFs {
|
|||||||
fn copy_file_range(
|
fn copy_file_range(
|
||||||
&self,
|
&self,
|
||||||
_req: &Request,
|
_req: &Request,
|
||||||
_ino_in: INodeNo,
|
ino_in: INodeNo,
|
||||||
_fh_in: FileHandle,
|
_fh_in: FileHandle,
|
||||||
_offset_in: u64,
|
offset_in: u64,
|
||||||
_ino_out: INodeNo,
|
ino_out: INodeNo,
|
||||||
_fh_out: FileHandle,
|
fh_out: FileHandle,
|
||||||
_offset_out: u64,
|
offset_out: u64,
|
||||||
_len: u64,
|
len: u64,
|
||||||
_flags: CopyFileRangeFlags,
|
flags: CopyFileRangeFlags,
|
||||||
reply: ReplyWrite,
|
reply: ReplyWrite,
|
||||||
) {
|
) {
|
||||||
// Also must stay ENOSYS: there is no server-side copy, and this is the
|
// Copied here when the source bytes are on this machine, as `write`
|
||||||
// errno that sends coreutils (and the kernel) down the read/write path
|
// would stage them, minus a FUSE round trip per page: `cp` of a cached
|
||||||
// instead of failing the copy outright.
|
// 256 MiB file ran at about 100 MiB/s through read and write. Anything
|
||||||
reply.error(Errno::ENOSYS);
|
// that would have to be downloaded gets `EOPNOTSUPP`, which makes the
|
||||||
|
// kernel copy that one call through read and write itself; `ENOSYS`
|
||||||
|
// would turn the op off for the whole mount. There is no server-side
|
||||||
|
// copy into a file `cp` has already created, so the copy still uploads.
|
||||||
|
//
|
||||||
|
// On the dispatch loop, like `write`, so it keeps its order with the
|
||||||
|
// reads and writes of the same handle. [`COPY_RANGE_MAX`] bounds how
|
||||||
|
// long one call holds the loop.
|
||||||
|
let (ino_in, ino_out) = (self.tree(ino_in), self.tree(ino_out));
|
||||||
|
if !flags.is_empty() || ino_in == ino_out {
|
||||||
|
reply.error(Errno::EOPNOTSUPP);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(error) = self.core.require_writable(ino_out.0) {
|
||||||
|
reply.error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bytes = match self
|
||||||
|
.core
|
||||||
|
.read_local(ino_in.0, offset_in, len.min(COPY_RANGE_MAX))
|
||||||
|
{
|
||||||
|
Ok(Some(bytes)) => bytes,
|
||||||
|
Ok(None) => {
|
||||||
|
debug!(ino_in = ino_in.0, offset_in, "copy range not local");
|
||||||
|
reply.error(Errno::EOPNOTSUPP);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
reply.error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if bytes.is_empty() {
|
||||||
|
reply.written(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self.stage_write(ino_out.0, fh_out.0, offset_out, &bytes) {
|
||||||
|
Ok(()) => reply.written(bytes.len() as u32),
|
||||||
|
Err(error) => reply.error(error),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,6 +1055,11 @@ pub struct ProtonFs {
|
|||||||
/// worker busy indefinitely.
|
/// worker busy indefinitely.
|
||||||
const OPEN_BASE_ATTEMPTS: u32 = 3;
|
const OPEN_BASE_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// The most one `copy_file_range` call copies; the caller loops for the rest.
|
||||||
|
/// A local copy of this much takes milliseconds, which is how long it holds
|
||||||
|
/// the dispatch loop.
|
||||||
|
const COPY_RANGE_MAX: u64 = 16 * 1024 * 1024;
|
||||||
|
|
||||||
/// One directory's entries as `readdir` reports them: `.` and `..` first, then
|
/// One directory's entries as `readdir` reports them: `.` and `..` first, then
|
||||||
/// the interned children in `children` order.
|
/// the interned children in `children` order.
|
||||||
///
|
///
|
||||||
@@ -1641,6 +1644,48 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The body of [`Filesystem::create`], run off the dispatch loop.
|
/// The body of [`Filesystem::create`], run off the dispatch loop.
|
||||||
|
/// Stage `data` at `offset` of the write handle `fh` on `ino`, straight into
|
||||||
|
/// its scratch file (no base download): only the untouched remainder is
|
||||||
|
/// pulled from the remote, and only at commit.
|
||||||
|
fn stage_write(&self, ino: u64, fh: u64, offset: u64, data: &[u8]) -> Result<(), Errno> {
|
||||||
|
let file = {
|
||||||
|
let st = self.core.state();
|
||||||
|
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
|
||||||
|
Some(aw) => aw.file.clone(),
|
||||||
|
None => return Err(Errno::EBADF),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = file.write_all_at(data, offset) {
|
||||||
|
error!(ino, fh, error = %e, "scratch write failed");
|
||||||
|
// A full local disk is not a broken file: answer `ENOSPC` so the
|
||||||
|
// application can say so (and so `cp`/editors take their own
|
||||||
|
// out-of-space path), and free what the cache is holding — the
|
||||||
|
// scratch file the next write goes to shares that filesystem.
|
||||||
|
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||||
|
self.core.cache.emergency_evict();
|
||||||
|
return Err(Errno::ENOSPC);
|
||||||
|
}
|
||||||
|
return Err(Errno::EIO);
|
||||||
|
}
|
||||||
|
let new_len = {
|
||||||
|
let mut st = self.core.state();
|
||||||
|
let Some(aw) = st
|
||||||
|
.handles
|
||||||
|
.get(&fh)
|
||||||
|
.copied()
|
||||||
|
.and_then(|i| st.active_writes.get_mut(&i))
|
||||||
|
else {
|
||||||
|
return Err(Errno::EBADF);
|
||||||
|
};
|
||||||
|
aw.record_write(offset, offset + data.len() as u64);
|
||||||
|
let len = aw.len;
|
||||||
|
st.set_size(ino, len);
|
||||||
|
len
|
||||||
|
};
|
||||||
|
debug!(ino, fh, offset, len = data.len(), new_len, "staged write");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
|
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
|
||||||
if let Err(error) = self.core.require_writable(parent) {
|
if let Err(error) = self.core.require_writable(parent) {
|
||||||
reply.error(error);
|
reply.error(error);
|
||||||
|
|||||||
+132
-3
@@ -74,8 +74,8 @@ use proton_drive_rs::proton_sdk::api::ResponseCode;
|
|||||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, ShareId, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, ShareId, VolumeId};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem, ProtonDriveClient,
|
AvailableNameTarget, DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem,
|
||||||
ProtonPhotosClient, SharedWithMeItem, ThumbnailType,
|
ProtonDriveClient, ProtonPhotosClient, SharedWithMeItem, ThumbnailType,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod albums;
|
mod albums;
|
||||||
@@ -5206,6 +5206,133 @@ impl Core {
|
|||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy a file or folder into `new_parent_rel` on Proton Drive, which copies
|
||||||
|
/// the content there: nothing is downloaded or uploaded. Both paths are
|
||||||
|
/// mountpoint-relative. A name already taken in the folder gets the next
|
||||||
|
/// free ` (n)` suffix. Returns the copy's name.
|
||||||
|
///
|
||||||
|
/// Drive copies what it holds, so the copy is refused offline and while the
|
||||||
|
/// mount still owes Drive part of it: a node or folder only queued locally,
|
||||||
|
/// a file with a queued upload or unsaved writes, or — for a folder, whose
|
||||||
|
/// queued changes could sit anywhere below it — any queued op at all.
|
||||||
|
fn copy_to(&self, rel: &Path, new_parent_rel: &Path) -> CoreResult<String> {
|
||||||
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
|
let (pino, new_parent_uid) = self
|
||||||
|
.resolve_path(new_parent_rel)
|
||||||
|
.map_err(|e| self.errno_error(e, "resolve new parent"))?;
|
||||||
|
self.ensure_children(pino)
|
||||||
|
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||||
|
self.require_uid_writable(&new_parent_uid)
|
||||||
|
.map_err(|error| self.errno_error(error, "copy access"))?;
|
||||||
|
let (name, is_folder, unsaved) = {
|
||||||
|
let st = self.state();
|
||||||
|
let entry = st
|
||||||
|
.entries
|
||||||
|
.get(&ino)
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("{} is gone", rel.display())))?;
|
||||||
|
// Unsaved writes, or a release still queueing them, to the file or,
|
||||||
|
// for a folder, to any file below it.
|
||||||
|
let unsaved = st
|
||||||
|
.active_writes
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, w)| w.dirty)
|
||||||
|
.map(|(&open, _)| open)
|
||||||
|
.chain(st.releasing.iter().copied())
|
||||||
|
.any(|open| relocate::ino_is_under(&st, open, ino));
|
||||||
|
(entry.node.name.clone(), entry.node.is_folder(), unsaved)
|
||||||
|
};
|
||||||
|
// Unlike a move, a copy cannot wait in the queue: it needs the node as
|
||||||
|
// Drive has it, so it goes now or not at all, local-first or not.
|
||||||
|
if !self.is_online() {
|
||||||
|
return Err(CoreError::offline());
|
||||||
|
}
|
||||||
|
let owed = unsaved
|
||||||
|
|| is_local_uid(&uid)
|
||||||
|
|| is_local_uid(&new_parent_uid)
|
||||||
|
|| self.db.has_any_op(&uid.to_string()).unwrap_or(true)
|
||||||
|
|| (is_folder
|
||||||
|
&& self
|
||||||
|
.db
|
||||||
|
.pending_op_counts()
|
||||||
|
.map(|counts| counts.uploads + counts.changes > 0)
|
||||||
|
.unwrap_or(true));
|
||||||
|
if owed {
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{name} has changes that have not reached Proton Drive yet; \
|
||||||
|
copy it once they have synced"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let target = if is_folder {
|
||||||
|
AvailableNameTarget::Folder
|
||||||
|
} else {
|
||||||
|
AvailableNameTarget::File
|
||||||
|
};
|
||||||
|
let copy_name = self
|
||||||
|
.rt
|
||||||
|
.block_on(
|
||||||
|
self.drive
|
||||||
|
.get_available_name(&new_parent_uid, &name, target),
|
||||||
|
)
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "copy"))?;
|
||||||
|
let new_uid = self
|
||||||
|
.rt
|
||||||
|
.block_on(
|
||||||
|
self.drive
|
||||||
|
.copy_node(&uid, &new_parent_uid, Some(©_name)),
|
||||||
|
)
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "copy"))?;
|
||||||
|
let node = self
|
||||||
|
.fetch_minted_node(&new_uid, ©_name)
|
||||||
|
.ok_or_else(|| minted_but_unlisted(©_name))?;
|
||||||
|
let mut st = self.state();
|
||||||
|
let copy_ino = st.intern(pino, node);
|
||||||
|
if let Some(kids) = st.children.get_mut(&pino)
|
||||||
|
&& !kids.contains(©_ino)
|
||||||
|
{
|
||||||
|
kids.push(copy_ino);
|
||||||
|
}
|
||||||
|
drop(st);
|
||||||
|
self.flush_access_changes();
|
||||||
|
Ok(copy_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the folder at `rel` holds on Proton Drive: bytes on storage and the
|
||||||
|
/// number of items below it. Drive adds it up, so nothing is listed here;
|
||||||
|
/// trashed items count, and the bytes are encrypted ones.
|
||||||
|
pub(crate) fn folder_size(&self, rel: &Path) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||||
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
|
let is_folder = self
|
||||||
|
.state()
|
||||||
|
.entries
|
||||||
|
.get(&ino)
|
||||||
|
.is_some_and(|entry| entry.node.is_folder());
|
||||||
|
if !is_folder {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"{} is not a folder",
|
||||||
|
rel.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.folder_size_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::folder_size`] for a folder addressed through any daemon location.
|
||||||
|
pub(crate) fn folder_size_by_uid(&self, uid: &str) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.folder_size_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder_size_for_uid(&self, uid: &NodeUid) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||||
|
if is_local_uid(uid) {
|
||||||
|
return Err(CoreError::conflict("the folder is not on Proton Drive yet"));
|
||||||
|
}
|
||||||
|
if !self.is_online() {
|
||||||
|
return Err(CoreError::offline());
|
||||||
|
}
|
||||||
|
self.rt
|
||||||
|
.block_on(self.client.get_folder_size(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "folder size"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
|
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
|
||||||
/// evicts any cached content, and invalidates the parent listing.
|
/// evicts any cached content, and invalidates the parent listing.
|
||||||
fn delete(&self, rel: &Path) -> CoreResult<String> {
|
fn delete(&self, rel: &Path) -> CoreResult<String> {
|
||||||
@@ -7185,13 +7312,15 @@ mod size_upgrade_tests {
|
|||||||
fn a_listing_waits_for_the_whole_size_batch_but_not_forever() {
|
fn a_listing_waits_for_the_whole_size_batch_but_not_forever() {
|
||||||
let slot = Arc::new(SizeUpgrade::default());
|
let slot = Arc::new(SizeUpgrade::default());
|
||||||
let worker = slot.clone();
|
let worker = slot.clone();
|
||||||
|
// Read before the spawn: the worker's sleeps can start before this
|
||||||
|
// thread runs again, which made the bound below fail on a busy runner.
|
||||||
|
let started = Instant::now();
|
||||||
let t = std::thread::spawn(move || {
|
let t = std::thread::spawn(move || {
|
||||||
std::thread::sleep(Duration::from_millis(30));
|
std::thread::sleep(Duration::from_millis(30));
|
||||||
worker.chunk_done();
|
worker.chunk_done();
|
||||||
std::thread::sleep(Duration::from_millis(60));
|
std::thread::sleep(Duration::from_millis(60));
|
||||||
worker.finish();
|
worker.finish();
|
||||||
});
|
});
|
||||||
let started = Instant::now();
|
|
||||||
slot.wait_finished(started + Duration::from_secs(5));
|
slot.wait_finished(started + Duration::from_secs(5));
|
||||||
// Past the first chunk, up to the end of the batch.
|
// Past the first chunk, up to the end of the batch.
|
||||||
assert!(started.elapsed() >= Duration::from_millis(90));
|
assert!(started.elapsed() >= Duration::from_millis(90));
|
||||||
|
|||||||
@@ -450,6 +450,10 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
|
|
||||||
|
// The reply is gone once the app has shown it; the log keeps why.
|
||||||
|
for reason in &skipped {
|
||||||
|
info!(reason, "restore skipped a folder");
|
||||||
|
}
|
||||||
let mut message = format!("restored {restored} folder(s)");
|
let mut message = format!("restored {restored} folder(s)");
|
||||||
if !skipped.is_empty() {
|
if !skipped.is_empty() {
|
||||||
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
|
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
|
||||||
|
|||||||
@@ -1101,6 +1101,92 @@ impl Core {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bytes `[offset, offset + size)` of file `ino` as `read` would serve
|
||||||
|
/// them, but only from this machine: an open write handle, a queued write,
|
||||||
|
/// or the caches. `Ok(None)` the moment any of it would have to be
|
||||||
|
/// downloaded, and for a size still provisional (B100), which only the
|
||||||
|
/// network settles. Short or empty past the end of the file.
|
||||||
|
///
|
||||||
|
/// `copy_file_range` answers with this and leaves anything else to the
|
||||||
|
/// kernel's read/write copy.
|
||||||
|
pub(super) fn read_local(
|
||||||
|
&self,
|
||||||
|
ino: u64,
|
||||||
|
offset: u64,
|
||||||
|
size: u64,
|
||||||
|
) -> Result<Option<Vec<u8>>, Errno> {
|
||||||
|
let handle = self.state().active_writes.get(&ino).map(|h| {
|
||||||
|
(
|
||||||
|
h.file.clone(),
|
||||||
|
h.len,
|
||||||
|
h.uid.clone(),
|
||||||
|
h.base_mtime,
|
||||||
|
h.base_size,
|
||||||
|
h.written.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
|
||||||
|
return self.serve_open_read_local(
|
||||||
|
&file, len, &uid, base_mtime, base_size, &written, offset, size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (uid, mtime, fsize, provisional) = {
|
||||||
|
let st = self.state();
|
||||||
|
match st.entries.get(&ino) {
|
||||||
|
Some(e) if e.node.is_file() => (
|
||||||
|
e.uid.clone(),
|
||||||
|
e.node.modification_time,
|
||||||
|
node_size(&e.node),
|
||||||
|
matches!(
|
||||||
|
&e.node.kind,
|
||||||
|
NodeKind::File {
|
||||||
|
claimed_size: None,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Some(_) => return Err(Errno::EISDIR),
|
||||||
|
None => return Err(Errno::ENOENT),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let uid = match is_local_uid(&uid) {
|
||||||
|
true => self.landed_stand_in_uid(&uid).unwrap_or(uid),
|
||||||
|
false => uid,
|
||||||
|
};
|
||||||
|
if let Some(pending) = self.pending.lock().get(&uid).cloned() {
|
||||||
|
let m = &pending.meta;
|
||||||
|
let file = File::open(&pending.path).map_err(|e| {
|
||||||
|
error!(%uid, path = %pending.path.display(), error = %e, "open staged blob failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
let mut written = Intervals::default();
|
||||||
|
for &(s, e) in &m.authored {
|
||||||
|
written.add(s, e);
|
||||||
|
}
|
||||||
|
return self.serve_open_read_local(
|
||||||
|
&Arc::new(file),
|
||||||
|
m.len,
|
||||||
|
&uid,
|
||||||
|
m.base_mtime,
|
||||||
|
m.base_size,
|
||||||
|
&written,
|
||||||
|
offset,
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Created here and never written: an empty file.
|
||||||
|
if is_local_uid(&uid) {
|
||||||
|
return Ok(Some(Vec::new()));
|
||||||
|
}
|
||||||
|
if provisional {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if offset >= fsize || size == 0 {
|
||||||
|
return Ok(Some(Vec::new()));
|
||||||
|
}
|
||||||
|
Ok(self.cached_range(&uid, mtime, fsize, offset, size.min(fsize - offset)))
|
||||||
|
}
|
||||||
|
|
||||||
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
|
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
|
||||||
/// caches while a `release(2)` is in progress.
|
/// caches while a `release(2)` is in progress.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
|||||||
|
|
||||||
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
|
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
|
||||||
/// parent cycle in corrupt data cannot hang the caller.
|
/// parent cycle in corrupt data cannot hang the caller.
|
||||||
fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
|
pub(crate) fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
|
||||||
for _ in 0..MAX_TREE_DEPTH {
|
for _ in 0..MAX_TREE_DEPTH {
|
||||||
if ino == root {
|
if ino == root {
|
||||||
return true;
|
return true;
|
||||||
@@ -456,6 +456,7 @@ impl Core {
|
|||||||
writing = st
|
writing = st
|
||||||
.active_writes
|
.active_writes
|
||||||
.keys()
|
.keys()
|
||||||
|
.chain(&st.releasing)
|
||||||
.any(|&open| ino_is_under(st, open, ino));
|
.any(|&open| ino_is_under(st, open, ino));
|
||||||
});
|
});
|
||||||
Ok(writing)
|
Ok(writing)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
BookmarkInfo, DirEntry, InvitationInfo, LinkChange, PublicLinkInfo, ShareEntry, ShareEntryKind,
|
||||||
|
SharedItem,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
@@ -367,6 +368,89 @@ impl Core {
|
|||||||
Ok(public_link_info(link))
|
Ok(public_link_info(link))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change the public link `id` on the node at `rel`, keeping its URL.
|
||||||
|
pub(crate) fn update_public_link(
|
||||||
|
&self,
|
||||||
|
rel: &Path,
|
||||||
|
id: &str,
|
||||||
|
role: Option<&str>,
|
||||||
|
password: LinkChange<String>,
|
||||||
|
expires: LinkChange<i64>,
|
||||||
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::update_public_link`] for a node addressed through any daemon
|
||||||
|
/// location.
|
||||||
|
pub(crate) fn update_public_link_by_uid(
|
||||||
|
&self,
|
||||||
|
uid: &str,
|
||||||
|
id: &str,
|
||||||
|
role: Option<&str>,
|
||||||
|
password: LinkChange<String>,
|
||||||
|
expires: LinkChange<i64>,
|
||||||
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SDK sets all three options on every update, so each `Keep` is
|
||||||
|
/// filled in from the link as it stands.
|
||||||
|
fn update_public_link_for_uid(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
id: &str,
|
||||||
|
role: Option<&str>,
|
||||||
|
password: LinkChange<String>,
|
||||||
|
expires: LinkChange<i64>,
|
||||||
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
|
let link = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.get_public_link(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||||
|
.filter(|l| l.public_link_id == id)
|
||||||
|
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||||
|
let role = match role {
|
||||||
|
Some(role) => role_from_str(role)?,
|
||||||
|
None => link.role,
|
||||||
|
};
|
||||||
|
let unchanged = role == link.role
|
||||||
|
&& matches!(password, LinkChange::Keep)
|
||||||
|
&& matches!(expires, LinkChange::Keep);
|
||||||
|
if unchanged {
|
||||||
|
return Ok(public_link_info(link));
|
||||||
|
}
|
||||||
|
let password = match password {
|
||||||
|
LinkChange::Keep if link.has_custom_password => {
|
||||||
|
// Sending `None` here would quietly remove the password.
|
||||||
|
Some(link.custom_password.clone().ok_or_else(|| {
|
||||||
|
CoreError::conflict(
|
||||||
|
"the link's password could not be read; set a new one or remove it",
|
||||||
|
)
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
LinkChange::Keep | LinkChange::Remove => None,
|
||||||
|
LinkChange::Set(password) if password.is_empty() => {
|
||||||
|
return Err(CoreError::invalid("the new password is empty"));
|
||||||
|
}
|
||||||
|
LinkChange::Set(password) => Some(password),
|
||||||
|
};
|
||||||
|
let expires = match expires {
|
||||||
|
LinkChange::Keep => link.expiration_time,
|
||||||
|
LinkChange::Remove => None,
|
||||||
|
LinkChange::Set(at) => Some(at),
|
||||||
|
};
|
||||||
|
let link = self
|
||||||
|
.rt
|
||||||
|
.block_on(
|
||||||
|
self.client
|
||||||
|
.update_public_link(uid, role, password.as_deref(), expires),
|
||||||
|
)
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "update public link"))?;
|
||||||
|
Ok(public_link_info(link))
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove the public link `id` from the node at `rel`.
|
/// Remove the public link `id` from the node at `rel`.
|
||||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||||
let (_ino, uid) = self.resolve(rel)?;
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ impl Daemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the mount believes Proton is reachable, as `pdfs status` shows
|
||||||
|
/// it.
|
||||||
|
pub(crate) fn online(&self) -> io::Result<bool> {
|
||||||
|
match self.request(&Request::Status)? {
|
||||||
|
Response::Status { online, .. } => Ok(online),
|
||||||
|
other => Err(io::Error::other(format!("unexpected answer {other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send one control request, as the `pdfs` CLI does, and fail on a
|
/// Send one control request, as the `pdfs` CLI does, and fail on a
|
||||||
/// refusal.
|
/// refusal.
|
||||||
pub(crate) fn request(&self, request: &Request) -> io::Result<Response> {
|
pub(crate) fn request(&self, request: &Request) -> io::Result<Response> {
|
||||||
@@ -648,7 +657,19 @@ mod tests {
|
|||||||
.pending()
|
.pending()
|
||||||
.is_ok_and(|items| items.iter().any(|item| item.attempts > 0))
|
.is_ok_and(|items| items.iter().any(|item| item.attempts > 0))
|
||||||
}));
|
}));
|
||||||
|
// The drain records the failure just before it takes the mount
|
||||||
|
// offline. Left online, only the event poll would, much later.
|
||||||
|
assert!(wait_until(Duration::from_secs(2), || daemon
|
||||||
|
.online()
|
||||||
|
.is_ok_and(|online| !online)));
|
||||||
daemon.client.set_online(true);
|
daemon.client.set_online(true);
|
||||||
|
// The probe that sees the link return runs 2 s after the drop, then
|
||||||
|
// 6 s, and a loaded run can reach this line after the first one. So
|
||||||
|
// the 3 s below count from the mount being back, not from the link:
|
||||||
|
// an op left on its backoff waits 4 s or more after its failure.
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || daemon
|
||||||
|
.online()
|
||||||
|
.unwrap_or(false)));
|
||||||
assert!(
|
assert!(
|
||||||
wait_until(Duration::from_secs(3), || {
|
wait_until(Duration::from_secs(3), || {
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
@@ -2214,6 +2235,75 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes() {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("pdfs-copy");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
|
||||||
|
let mount = &daemon.mountpoint;
|
||||||
|
std::fs::create_dir_all(mount.join("src/inner")).unwrap();
|
||||||
|
std::fs::create_dir_all(mount.join("dest")).unwrap();
|
||||||
|
std::fs::write(mount.join("src/a.txt"), b"alpha").unwrap();
|
||||||
|
std::fs::write(mount.join("src/inner/b.txt"), b"beta").unwrap();
|
||||||
|
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
drive.tree().get("src/inner/b.txt").cloned() == file(b"beta")
|
||||||
|
&& daemon.pending().is_ok_and(|ops| ops.is_empty())
|
||||||
|
}));
|
||||||
|
|
||||||
|
let path = |rel: &str| mount.join(rel).to_string_lossy().into_owned();
|
||||||
|
let copy = |from: &str, to: &str| {
|
||||||
|
daemon.request(&Request::Copy {
|
||||||
|
path: path(from),
|
||||||
|
new_parent: path(to),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let uploads = drive.tree().len();
|
||||||
|
copy("src/a.txt", "dest").unwrap();
|
||||||
|
// A name already taken gets a suffix rather than an error.
|
||||||
|
copy("src/a.txt", "dest").unwrap();
|
||||||
|
copy("src", "dest").unwrap();
|
||||||
|
assert_eq!(std::fs::read(mount.join("dest/a.txt")).unwrap(), b"alpha");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(mount.join("dest/a (1).txt")).unwrap(),
|
||||||
|
b"alpha"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(mount.join("dest/src/inner/b.txt")).unwrap(),
|
||||||
|
b"beta"
|
||||||
|
);
|
||||||
|
let tree = drive.tree();
|
||||||
|
assert_eq!(tree.get("dest/src/a.txt").cloned(), file(b"alpha"));
|
||||||
|
assert_eq!(
|
||||||
|
tree.len(),
|
||||||
|
uploads + 6,
|
||||||
|
"{:?}",
|
||||||
|
tree.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drive would copy what it has, not the bytes still queued.
|
||||||
|
let paused = |paused| Request::SetSyncPaused {
|
||||||
|
paused,
|
||||||
|
until: None,
|
||||||
|
};
|
||||||
|
daemon.request(&paused(true)).unwrap();
|
||||||
|
std::fs::write(mount.join("src/a.txt"), b"alpha, edited").unwrap();
|
||||||
|
match copy("src/a.txt", "dest") {
|
||||||
|
Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"),
|
||||||
|
Ok(answer) => panic!("the copy went ahead: {answer:?}"),
|
||||||
|
}
|
||||||
|
match copy("src", "dest") {
|
||||||
|
Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"),
|
||||||
|
Ok(answer) => panic!("the folder copy went ahead: {answer:?}"),
|
||||||
|
}
|
||||||
|
daemon.request(&paused(false)).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
fn a_pdfs_rename_after_a_queued_rename_is_not_undone() {
|
fn a_pdfs_rename_after_a_queued_rename_is_not_undone() {
|
||||||
@@ -2364,6 +2454,152 @@ mod tests {
|
|||||||
eprintln!("wifi: syscalls {wifi_syscalls:?}, drain {wifi_drain:?}");
|
eprintln!("wifi: syscalls {wifi_syscalls:?}, drain {wifi_drain:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `copy_file_range` inside the mount is answered from the bytes the daemon
|
||||||
|
/// holds, and left to the kernel for bytes it would have to download. Every
|
||||||
|
/// source kind has to come out byte for byte: cached, queued, open with
|
||||||
|
/// unsaved writes, and on Drive only.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_copy_inside_the_mount_has_the_source_bytes() {
|
||||||
|
use std::os::fd::AsRawFd as _;
|
||||||
|
use std::os::unix::fs::FileExt as _;
|
||||||
|
|
||||||
|
fn copy_range(from: &std::fs::File, at: u64, to: &std::fs::File, to_at: u64, len: usize) {
|
||||||
|
let (mut at, mut to_at) = (at as i64, to_at as i64);
|
||||||
|
let mut left = len;
|
||||||
|
while left > 0 {
|
||||||
|
// SAFETY: both descriptors are open; the offsets are ours.
|
||||||
|
let n = unsafe {
|
||||||
|
libc::copy_file_range(
|
||||||
|
from.as_raw_fd(),
|
||||||
|
&mut at,
|
||||||
|
to.as_raw_fd(),
|
||||||
|
&mut to_at,
|
||||||
|
left,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert!(n > 0, "copy_file_range: {}", io::Error::last_os_error());
|
||||||
|
left -= n as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pattern = |len: usize, seed: u8| -> Vec<u8> {
|
||||||
|
(0..len).map(|i| (i % 253) as u8 ^ seed).collect()
|
||||||
|
};
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
// On Drive before the mount knows it, so nothing of it is cached.
|
||||||
|
let remote = pattern(9 << 20, 1);
|
||||||
|
drive.device().write("remote.bin", &remote);
|
||||||
|
let dir = scratch("copy-range");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let mount = &daemon.mountpoint;
|
||||||
|
|
||||||
|
// Downloaded through the kernel's own copy, then served from the cache.
|
||||||
|
std::fs::copy(mount.join("remote.bin"), mount.join("remote-1.bin")).unwrap();
|
||||||
|
assert!(std::fs::read(mount.join("remote-1.bin")).unwrap() == remote);
|
||||||
|
std::fs::copy(mount.join("remote.bin"), mount.join("remote-2.bin")).unwrap();
|
||||||
|
assert!(std::fs::read(mount.join("remote-2.bin")).unwrap() == remote);
|
||||||
|
|
||||||
|
// Queued and not on Drive yet: the staged blob is the content.
|
||||||
|
let paused = |paused| Request::SetSyncPaused {
|
||||||
|
paused,
|
||||||
|
until: None,
|
||||||
|
};
|
||||||
|
daemon.request(&paused(true)).unwrap();
|
||||||
|
let queued = pattern(20 << 20, 2);
|
||||||
|
std::fs::write(mount.join("queued.bin"), &queued).unwrap();
|
||||||
|
std::fs::copy(mount.join("queued.bin"), mount.join("queued-copy.bin")).unwrap();
|
||||||
|
assert!(std::fs::read(mount.join("queued-copy.bin")).unwrap() == queued);
|
||||||
|
|
||||||
|
// Open with writes not saved yet: the copy sees them, as a read would.
|
||||||
|
let open = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(mount.join("queued.bin"))
|
||||||
|
.unwrap();
|
||||||
|
open.write_all_at(b"UNSAVED", 100).unwrap();
|
||||||
|
let target = std::fs::File::create(mount.join("unsaved-copy.bin")).unwrap();
|
||||||
|
copy_range(&open, 96, &target, 0, 16);
|
||||||
|
drop(target);
|
||||||
|
drop(open);
|
||||||
|
let mut expected = queued[96..112].to_vec();
|
||||||
|
expected[4..11].copy_from_slice(b"UNSAVED");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(mount.join("unsaved-copy.bin")).unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
|
||||||
|
// A range into the middle of a file keeps the bytes around it.
|
||||||
|
std::fs::write(mount.join("middle.bin"), b"0123456789").unwrap();
|
||||||
|
let source = std::fs::File::open(mount.join("remote.bin")).unwrap();
|
||||||
|
let target = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(mount.join("middle.bin"))
|
||||||
|
.unwrap();
|
||||||
|
copy_range(&source, 5, &target, 3, 4);
|
||||||
|
drop(target);
|
||||||
|
let mut expected = b"0123456789".to_vec();
|
||||||
|
expected[3..7].copy_from_slice(&remote[5..9]);
|
||||||
|
assert_eq!(std::fs::read(mount.join("middle.bin")).unwrap(), expected);
|
||||||
|
|
||||||
|
daemon.request(&paused(false)).unwrap();
|
||||||
|
let mut edited = queued.clone();
|
||||||
|
edited[100..107].copy_from_slice(b"UNSAVED");
|
||||||
|
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||||
|
assert!(wait_until(Duration::from_secs(120), || {
|
||||||
|
let tree = drive.tree();
|
||||||
|
daemon.pending().is_ok_and(|ops| ops.is_empty())
|
||||||
|
&& tree.get("queued.bin").cloned() == file(&edited)
|
||||||
|
&& tree.get("queued-copy.bin").cloned() == file(&queued)
|
||||||
|
&& tree.get("remote-2.bin").cloned() == file(&remote)
|
||||||
|
&& tree.get("middle.bin").cloned() == file(&expected)
|
||||||
|
}));
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long `cp` inside the mount takes for a file the daemon already
|
||||||
|
/// holds, cold and from the page cache. It decides whether the daemon
|
||||||
|
/// should answer `copy_file_range` itself rather than leave the kernel to
|
||||||
|
/// copy through read and write. Runs when `PDFS_SIM_MEASURE` is set.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `PDFS_SIM_MEASURE=1 cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_copy_inside_the_mount_measures_its_speed() {
|
||||||
|
if std::env::var_os("PDFS_SIM_MEASURE").is_none() {
|
||||||
|
eprintln!("skipped: set PDFS_SIM_MEASURE to measure the copy");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SIZE: usize = 256 << 20;
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("copy-speed");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let mount = &daemon.mountpoint;
|
||||||
|
let bytes: Vec<u8> = (0..SIZE).map(|i| (i % 251) as u8).collect();
|
||||||
|
std::fs::write(mount.join("big.bin"), &bytes).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(120), || {
|
||||||
|
daemon.pending().is_ok_and(|ops| ops.is_empty())
|
||||||
|
}));
|
||||||
|
for (round, name) in ["copy-1.bin", "copy-2.bin", "copy-3.bin"]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let start = Instant::now();
|
||||||
|
let status = std::process::Command::new("cp")
|
||||||
|
.arg(mount.join("big.bin"))
|
||||||
|
.arg(mount.join(name))
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
let took = start.elapsed();
|
||||||
|
assert!(status.success());
|
||||||
|
let mib_per_s = (SIZE >> 20) as f64 / took.as_secs_f64();
|
||||||
|
eprintln!("cp round {round}: {took:?}, {mib_per_s:.0} MiB/s");
|
||||||
|
assert!(std::fs::read(mount.join(name)).unwrap() == bytes);
|
||||||
|
}
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
fn half_of_a_wide_folder_unlinked_at_once_leaves_the_other_half() {
|
fn half_of_a_wide_folder_unlinked_at_once_leaves_the_other_half() {
|
||||||
@@ -2658,4 +2894,112 @@ mod tests {
|
|||||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The synced folder `id` as the daemon lists it.
|
||||||
|
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
||||||
|
match daemon.request(&Request::ListSyncFolders) {
|
||||||
|
Ok(Response::SyncFolders { items }) => items.into_iter().find(|f| f.id == id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mirror folder holding `a.txt` and `sub/b.txt`, synced to Drive, whose
|
||||||
|
/// local copy is then deleted in full while the folder is paused, so no
|
||||||
|
/// pass sees half of it. Waits for the pass that refuses to run (#46).
|
||||||
|
fn folder_whose_local_copy_is_gone(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
|
||||||
|
let id = sync_folder(daemon, local, "mirror");
|
||||||
|
std::fs::write(local.join("a.txt"), b"alpha").unwrap();
|
||||||
|
std::fs::create_dir(local.join("sub")).unwrap();
|
||||||
|
std::fs::write(local.join("sub/b.txt"), b"beta").unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
let on_drive = |name: &str, content: &[u8]| {
|
||||||
|
drive.tree().iter().any(|(path, entry)| {
|
||||||
|
path.ends_with(name) && *entry == Entry::File(Arc::new(content.to_vec()))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || on_drive("a.txt", b"alpha")
|
||||||
|
&& on_drive("sub/b.txt", b"beta")
|
||||||
|
&& listed_folder(daemon, id).is_some_and(|f| f.state == "idle")),
|
||||||
|
"the folder synced: {:?} {:?}",
|
||||||
|
listed_folder(daemon, id),
|
||||||
|
drive.tree().keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused { id, paused: true })
|
||||||
|
.unwrap();
|
||||||
|
std::fs::remove_file(local.join("a.txt")).unwrap();
|
||||||
|
std::fs::remove_dir_all(local.join("sub")).unwrap();
|
||||||
|
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")),
|
||||||
|
"the pass refused to trash everything: {:?}",
|
||||||
|
listed_folder(daemon, id)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
on_drive("a.txt", b"alpha") && on_drive("sub/b.txt", b"beta"),
|
||||||
|
"nothing was trashed on Drive: {:?}",
|
||||||
|
drive.tree().keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset() {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("reset-gone-local-copy");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let local = dir.join("docs");
|
||||||
|
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
|
||||||
|
|
||||||
|
daemon.request(&Request::ResetSyncFolder { id }).unwrap();
|
||||||
|
let has = |rel: &str, content: &[u8]| {
|
||||||
|
std::fs::read(local.join(rel)).is_ok_and(|bytes| bytes == content)
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || has("a.txt", b"alpha")
|
||||||
|
&& has("sub/b.txt", b"beta")
|
||||||
|
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")),
|
||||||
|
"the reset downloaded the folder again: {:?}",
|
||||||
|
listed_folder(&daemon, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_folder_whose_local_copy_is_gone_still_goes_on_demand() {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("ondemand-gone-local-copy");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let local = dir.join("docs");
|
||||||
|
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderMode {
|
||||||
|
id,
|
||||||
|
mode: "ondemand".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || listed_folder(&daemon, id)
|
||||||
|
.is_some_and(|f| f.mode == "ondemand" && f.pending_mode.is_none())
|
||||||
|
&& is_mounted(&local)),
|
||||||
|
"the queued switch landed: {:?}",
|
||||||
|
listed_folder(&daemon, id)
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(local.join("a.txt")).unwrap(), b"alpha");
|
||||||
|
assert_eq!(std::fs::read(local.join("sub/b.txt")).unwrap(), b"beta");
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -61,8 +61,8 @@ use proton_drive_rs::proton_sdk::ids::{
|
|||||||
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
|
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
|
||||||
};
|
};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem,
|
AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
|
||||||
RevisionState, Thumbnail,
|
NodeKind, NodeMoveItem, RevisionState, Thumbnail,
|
||||||
};
|
};
|
||||||
use sha1::{Digest as _, Sha1};
|
use sha1::{Digest as _, Sha1};
|
||||||
|
|
||||||
@@ -185,6 +185,39 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy `uid` and, for a folder, everything live below it under `parent` as
|
||||||
|
/// `name`, the way Drive's copy endpoint does. Returns the copy's uid.
|
||||||
|
fn copy_subtree(&mut self, uid: &NodeUid, parent: &NodeUid, name: String) -> NodeUid {
|
||||||
|
let source = self
|
||||||
|
.current(uid)
|
||||||
|
.cloned()
|
||||||
|
.expect("the caller checked the source");
|
||||||
|
let copy = self.mint_uid();
|
||||||
|
let now = now_secs();
|
||||||
|
self.put(
|
||||||
|
©,
|
||||||
|
Some(Remote {
|
||||||
|
parent: Some(parent.clone()),
|
||||||
|
name,
|
||||||
|
created: now,
|
||||||
|
..source
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let children: Vec<(NodeUid, String)> = self
|
||||||
|
.nodes
|
||||||
|
.keys()
|
||||||
|
.filter_map(|child| {
|
||||||
|
let remote = self.current(child)?;
|
||||||
|
(remote.parent.as_ref() == Some(uid) && !remote.trashed)
|
||||||
|
.then(|| (child.clone(), remote.name.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (child, child_name) in children {
|
||||||
|
self.copy_subtree(&child, ©, child_name);
|
||||||
|
}
|
||||||
|
copy
|
||||||
|
}
|
||||||
|
|
||||||
fn mint_uid(&mut self) -> NodeUid {
|
fn mint_uid(&mut self) -> NodeUid {
|
||||||
self.next_link += 1;
|
self.next_link += 1;
|
||||||
uid(&format!("L{}", self.next_link))
|
uid(&format!("L{}", self.next_link))
|
||||||
@@ -1490,6 +1523,61 @@ impl DriveApi for FakeClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn copy_node(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
new_parent: &NodeUid,
|
||||||
|
name: Option<&str>,
|
||||||
|
) -> Result<NodeUid> {
|
||||||
|
self.request(true, |server| {
|
||||||
|
if !server.is_folder(new_parent) {
|
||||||
|
return Err(api(ResponseCode::DoesNotExist, "parent folder not found"));
|
||||||
|
}
|
||||||
|
let source = server
|
||||||
|
.current(uid)
|
||||||
|
.filter(|remote| !remote.trashed)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| api(ResponseCode::DoesNotExist, "node not found"))?;
|
||||||
|
if server.is_below(new_parent, uid) {
|
||||||
|
return Err(api(
|
||||||
|
ResponseCode::InvalidRequirements,
|
||||||
|
"cannot copy a folder into itself",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let name = name.map_or(source.name.clone(), str::to_owned);
|
||||||
|
if server.live_child_named(new_parent, &name).is_some() {
|
||||||
|
return Err(api(
|
||||||
|
ResponseCode::AlreadyExists,
|
||||||
|
"a file or folder with that name already exists",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(server.copy_subtree(uid, new_parent, name))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_available_name(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
target: AvailableNameTarget,
|
||||||
|
) -> Result<String> {
|
||||||
|
self.request(false, |server| {
|
||||||
|
let (stem, extension) = match (target, name.rsplit_once('.')) {
|
||||||
|
(AvailableNameTarget::File, Some((stem, ext))) if !stem.is_empty() => {
|
||||||
|
(stem, format!(".{ext}"))
|
||||||
|
}
|
||||||
|
_ => (name, String::new()),
|
||||||
|
};
|
||||||
|
let free = std::iter::once(name.to_owned())
|
||||||
|
.chain((1..).map(|n| format!("{stem} ({n}){extension}")))
|
||||||
|
.find(|candidate| server.live_child_named(parent_uid, candidate).is_none())
|
||||||
|
.expect("an unbounded sequence has a free name");
|
||||||
|
Ok(free)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
fn move_nodes_streaming<'a>(
|
fn move_nodes_streaming<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
items: Vec<NodeMoveItem>,
|
items: Vec<NodeMoveItem>,
|
||||||
|
|||||||
@@ -266,6 +266,11 @@ pub(crate) struct State {
|
|||||||
pub(crate) next_ino: u64,
|
pub(crate) next_ino: u64,
|
||||||
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
|
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
|
||||||
pub(crate) active_writes: HashMap<u64, WriteHandle>,
|
pub(crate) active_writes: HashMap<u64, WriteHandle>,
|
||||||
|
/// Inodes whose last write handle `release` has taken out of
|
||||||
|
/// [`State::active_writes`] and is still queueing. `close(2)` returns
|
||||||
|
/// before that, and in between the edit is in neither the handles nor the
|
||||||
|
/// queue, so a check for changes not on Drive yet looks here too.
|
||||||
|
pub(crate) releasing: HashSet<u64>,
|
||||||
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
|
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
|
||||||
/// have no entry here.
|
/// have no entry here.
|
||||||
pub(crate) handles: HashMap<u64, u64>,
|
pub(crate) handles: HashMap<u64, u64>,
|
||||||
@@ -428,6 +433,7 @@ impl State {
|
|||||||
roots: HashSet::new(),
|
roots: HashSet::new(),
|
||||||
next_ino: FALLBACK_INO,
|
next_ino: FALLBACK_INO,
|
||||||
active_writes: HashMap::new(),
|
active_writes: HashMap::new(),
|
||||||
|
releasing: HashSet::new(),
|
||||||
handles: HashMap::new(),
|
handles: HashMap::new(),
|
||||||
next_fh: 1,
|
next_fh: 1,
|
||||||
dir_snapshots: HashMap::new(),
|
dir_snapshots: HashMap::new(),
|
||||||
|
|||||||
@@ -369,7 +369,20 @@ impl Core {
|
|||||||
// guard on every pass — wedging the folder's sync permanently — the
|
// guard on every pass — wedging the folder's sync permanently — the
|
||||||
// moment a rule covers the whole tree.
|
// moment a rule covers the whole tree.
|
||||||
let tracked = tracked_baseline(&baseline, rules, &unscanned);
|
let tracked = tracked_baseline(&baseline, rules, &unscanned);
|
||||||
guard_local_wipe(&tracked, &local)?;
|
// A local copy that is gone in its entirety is not trashed on Drive,
|
||||||
|
// for the reason `guard_local_wipe` gives. A full pass stops there, but
|
||||||
|
// this one exists for a switch to on-demand, which needs nothing local
|
||||||
|
// and leaves Drive as it is: refusing the pass would only keep the
|
||||||
|
// folder out of `idle`, wedging the switch for good (#46). So the pass
|
||||||
|
// forgets those paths instead of trashing them, and the mount then
|
||||||
|
// shows everything Drive still has.
|
||||||
|
let local_gone = match guard_local_wipe(&tracked, &local) {
|
||||||
|
Ok(()) => false,
|
||||||
|
Err(reason) => {
|
||||||
|
warn!(id = folder_id, %reason, "sync: going on-demand without trashing anything on Drive");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// The remote folder uids come from the baseline instead of a walk: every
|
// The remote folder uids come from the baseline instead of a walk: every
|
||||||
// directory this device has synced recorded its uid there when it was
|
// directory this device has synced recorded its uid there when it was
|
||||||
@@ -579,8 +592,27 @@ impl Core {
|
|||||||
// showing the folder's remote contents — otherwise everything they deleted
|
// showing the folder's remote contents — otherwise everything they deleted
|
||||||
// comes back the moment the switch lands. Shallowest first, skipping anything
|
// comes back the moment the switch lands. Shallowest first, skipping anything
|
||||||
// under a folder already trashed (its children went with it).
|
// under a folder already trashed (its children went with it).
|
||||||
|
let deletions = local_deletions(&tracked, &local);
|
||||||
|
if local_gone {
|
||||||
|
// Forgotten instead of trashed. A row left behind would read to
|
||||||
|
// the switch as a deletion not on Drive yet and withdraw it, and
|
||||||
|
// to a later full pass as a reason to stop again. Forgotten, a full
|
||||||
|
// pass downloads the paths again.
|
||||||
|
let mut forgotten: HashSet<&str> = HashSet::new();
|
||||||
|
for rel in &deletions {
|
||||||
|
let mut parent = parent_rel(rel);
|
||||||
|
while !parent.is_empty() && !forgotten.contains(parent) {
|
||||||
|
parent = parent_rel(parent);
|
||||||
|
}
|
||||||
|
if parent.is_empty() {
|
||||||
|
let _ = self.db.sync_entries_remove_subtree(folder_id, rel);
|
||||||
|
forgotten.insert(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(outcome);
|
||||||
|
}
|
||||||
let mut trashed: Vec<String> = Vec::new();
|
let mut trashed: Vec<String> = Vec::new();
|
||||||
for rel in &local_deletions(&tracked, &local) {
|
for rel in &deletions {
|
||||||
if trashed
|
if trashed
|
||||||
.iter()
|
.iter()
|
||||||
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ pub(super) fn guard_local_wipe<B, L>(
|
|||||||
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"every one of the {} synced paths is missing locally; refusing to trash \
|
"every one of the {} synced paths is missing locally; refusing to trash \
|
||||||
them on Drive. Check that the folder is mounted and readable.",
|
them on Drive. Check that the folder is mounted and readable; to download \
|
||||||
|
them again, sync the folder from scratch.",
|
||||||
baseline.len()
|
baseline.len()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -213,7 +214,8 @@ pub(super) fn guard_remote_wipe<B, R>(
|
|||||||
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
|
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"every one of the {} synced paths is missing on Proton Drive; refusing to \
|
"every one of the {} synced paths is missing on Proton Drive; refusing to \
|
||||||
delete them here. If they were deleted on purpose, remove the folder from sync.",
|
delete them here. If they were deleted on purpose, remove the folder from sync; \
|
||||||
|
to upload them again, sync the folder from scratch.",
|
||||||
baseline.len()
|
baseline.len()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,11 +75,11 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
|
|||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
|
||||||
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
|
||||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
|
||||||
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
|
ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
|
||||||
TransferDirection, TransferItem, send,
|
ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
|
||||||
};
|
};
|
||||||
|
|
||||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
|
|||||||
@@ -1043,6 +1043,7 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
|||||||
ActivityKind::Sync => "pdfs-sync-symbolic",
|
ActivityKind::Sync => "pdfs-sync-symbolic",
|
||||||
ActivityKind::Rename => "document-edit-symbolic",
|
ActivityKind::Rename => "document-edit-symbolic",
|
||||||
ActivityKind::Move => "go-jump-symbolic",
|
ActivityKind::Move => "go-jump-symbolic",
|
||||||
|
ActivityKind::Copy => "edit-copy-symbolic",
|
||||||
ActivityKind::CreateFolder => "folder-new-symbolic",
|
ActivityKind::CreateFolder => "folder-new-symbolic",
|
||||||
ActivityKind::Trash => "user-trash-symbolic",
|
ActivityKind::Trash => "user-trash-symbolic",
|
||||||
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
|
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String
|
|||||||
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
|
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
|
||||||
// Translators: {name} is a file or folder name.
|
// Translators: {name} is a file or folder name.
|
||||||
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
|
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Copy, true) => gettext_f("Copied {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Copy, false) => gettext_f("Couldn't copy {name}", &args),
|
||||||
// Translators: {name} is a folder name.
|
// Translators: {name} is a folder name.
|
||||||
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
||||||
// Translators: {name} is a folder name.
|
// Translators: {name} is a folder name.
|
||||||
|
|||||||
@@ -904,6 +904,12 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
|||||||
menu.item(&gettext("Move To…"), move || {
|
menu.item(&gettext("Move To…"), move || {
|
||||||
prompt_move(&ui_c, vec![entry_c.clone()])
|
prompt_move(&ui_c, vec![entry_c.clone()])
|
||||||
});
|
});
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Make a Copy"), move || make_copy(&ui_c, &entry_c));
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Copy To…"), move || {
|
||||||
|
prompt_copy(&ui_c, vec![entry_c.clone()])
|
||||||
|
});
|
||||||
if !entry.is_dir {
|
if !entry.is_dir {
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
menu.item(&gettext("Versions…"), move || {
|
menu.item(&gettext("Versions…"), move || {
|
||||||
@@ -938,6 +944,10 @@ pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMe
|
|||||||
menu.item(&gettext("Move To…"), move || {
|
menu.item(&gettext("Move To…"), move || {
|
||||||
prompt_move(&ui_c, batch.clone())
|
prompt_move(&ui_c, batch.clone())
|
||||||
});
|
});
|
||||||
|
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||||
|
menu.item(&gettext("Copy To…"), move || {
|
||||||
|
prompt_copy(&ui_c, batch.clone())
|
||||||
|
});
|
||||||
menu.labelled_section(&ngettext_f(
|
menu.labelled_section(&ngettext_f(
|
||||||
"{n} selected",
|
"{n} selected",
|
||||||
"{n} selected",
|
"{n} selected",
|
||||||
@@ -1940,17 +1950,63 @@ pub(crate) fn prompt_rename(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
/// still made on Proton Drive, so nothing is downloaded or uploaded again; it
|
/// still made on Proton Drive, so nothing is downloaded or uploaded again; it
|
||||||
/// is sent with absolute paths, which is how the daemon tells locations apart.
|
/// is sent with absolute paths, which is how the daemon tells locations apart.
|
||||||
pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||||
|
prompt_destination(ui, entries, PickerKind::Move);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask where to copy `entries`, then copy them there on Proton Drive. Only
|
||||||
|
/// My files is offered: a copy is made on the server, inside the mount.
|
||||||
|
pub(crate) fn prompt_copy(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||||
|
prompt_destination(ui, entries, PickerKind::Copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy one entry into the folder it is in. The copy gets a free name, such
|
||||||
|
/// as "report (1).pdf".
|
||||||
|
pub(crate) fn make_copy(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
let source = entry_rel(ui, entry);
|
||||||
|
let parent = source
|
||||||
|
.rfind('/')
|
||||||
|
.map(|i| source[..i].to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
run_bulk_copy(ui, vec![source], parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the folder picker chooses a destination for.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub(crate) enum PickerKind {
|
||||||
|
Move,
|
||||||
|
Copy,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_destination(ui: &Rc<Ui>, entries: Vec<DirEntry>, kind: PickerKind) {
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let sources: Vec<String> = entries.iter().map(|e| entry_rel(ui, e)).collect();
|
let sources: Vec<String> = entries.iter().map(|e| entry_rel(ui, e)).collect();
|
||||||
let heading = match entries.as_slice() {
|
let (heading, body, confirm) = match (kind, entries.as_slice()) {
|
||||||
[one] => gettext_f("Move “{name}”", &[("name", &one.name)]),
|
(PickerKind::Move, [one]) => (
|
||||||
many => ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
|
gettext_f("Move “{name}”", &[("name", &one.name)]),
|
||||||
|
gettext("Choose the folder to move into."),
|
||||||
|
gettext("Move Here"),
|
||||||
|
),
|
||||||
|
(PickerKind::Move, many) => (
|
||||||
|
ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
|
||||||
|
gettext("Choose the folder to move into."),
|
||||||
|
gettext("Move Here"),
|
||||||
|
),
|
||||||
|
(PickerKind::Copy, [one]) => (
|
||||||
|
gettext_f("Copy “{name}”", &[("name", &one.name)]),
|
||||||
|
gettext("Choose the folder to copy into."),
|
||||||
|
gettext("Copy Here"),
|
||||||
|
),
|
||||||
|
(PickerKind::Copy, many) => (
|
||||||
|
ngettext_f("Copy {n} item", "Copy {n} items", many.len() as u64, &[]),
|
||||||
|
gettext("Choose the folder to copy into."),
|
||||||
|
gettext("Copy Here"),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(heading)
|
.heading(heading)
|
||||||
.body(gettext("Choose the folder to move into."))
|
.body(body)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let up = gtk4::Button::builder()
|
let up = gtk4::Button::builder()
|
||||||
@@ -1981,7 +2037,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
|||||||
body.append(&scroll);
|
body.append(&scroll);
|
||||||
dialog.set_extra_child(Some(&body));
|
dialog.set_extra_child(Some(&body));
|
||||||
dialog.add_response("cancel", &gettext("Cancel"));
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
dialog.add_response("move", &gettext("Move Here"));
|
dialog.add_response("move", &confirm);
|
||||||
dialog.set_response_appearance("move", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("move", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("move"));
|
dialog.set_default_response(Some("move"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
@@ -1994,6 +2050,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
|||||||
list,
|
list,
|
||||||
up: up.clone(),
|
up: up.clone(),
|
||||||
sources: sources.clone(),
|
sources: sources.clone(),
|
||||||
|
kind,
|
||||||
folder: RefCell::new(MoveTarget::Locations),
|
folder: RefCell::new(MoveTarget::Locations),
|
||||||
generation: Cell::new(0),
|
generation: Cell::new(0),
|
||||||
});
|
});
|
||||||
@@ -2013,6 +2070,10 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
|||||||
}
|
}
|
||||||
match picker_done.folder.borrow().clone() {
|
match picker_done.folder.borrow().clone() {
|
||||||
MoveTarget::Locations => {}
|
MoveTarget::Locations => {}
|
||||||
|
MoveTarget::Drive(folder) if kind == PickerKind::Copy => {
|
||||||
|
run_bulk_copy(&ui, sources.clone(), folder)
|
||||||
|
}
|
||||||
|
MoveTarget::Synced { .. } if kind == PickerKind::Copy => {}
|
||||||
MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder),
|
MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder),
|
||||||
MoveTarget::Synced { path, .. } => {
|
MoveTarget::Synced { path, .. } => {
|
||||||
let absolute = sources
|
let absolute = sources
|
||||||
@@ -2088,8 +2149,9 @@ struct MovePicker {
|
|||||||
location: gtk4::Label,
|
location: gtk4::Label,
|
||||||
list: gtk4::ListBox,
|
list: gtk4::ListBox,
|
||||||
up: gtk4::Button,
|
up: gtk4::Button,
|
||||||
/// What is being moved, mountpoint-relative.
|
/// What is being moved or copied, mountpoint-relative.
|
||||||
sources: Vec<String>,
|
sources: Vec<String>,
|
||||||
|
kind: PickerKind,
|
||||||
/// The folder on show, and the destination if the user confirms now.
|
/// The folder on show, and the destination if the user confirms now.
|
||||||
folder: RefCell<MoveTarget>,
|
folder: RefCell<MoveTarget>,
|
||||||
/// Drops a listing that arrives after the user went elsewhere.
|
/// Drops a listing that arrives after the user went elsewhere.
|
||||||
@@ -2110,11 +2172,13 @@ impl MovePicker {
|
|||||||
self.location.set_tooltip_text(Some(&tooltip));
|
self.location.set_tooltip_text(Some(&tooltip));
|
||||||
self.up
|
self.up
|
||||||
.set_sensitive(!matches!(folder, MoveTarget::Locations));
|
.set_sensitive(!matches!(folder, MoveTarget::Locations));
|
||||||
let allowed = match &folder {
|
let allowed = match (&folder, self.kind) {
|
||||||
MoveTarget::Locations => false,
|
(MoveTarget::Locations, _) => false,
|
||||||
MoveTarget::Drive(rel) => move_target_allowed(&self.sources, rel),
|
(MoveTarget::Drive(rel), PickerKind::Move) => move_target_allowed(&self.sources, rel),
|
||||||
|
(MoveTarget::Drive(rel), PickerKind::Copy) => copy_target_allowed(&self.sources, rel),
|
||||||
// What is moved is in My files, so it cannot be in a synced folder.
|
// What is moved is in My files, so it cannot be in a synced folder.
|
||||||
MoveTarget::Synced { .. } => true,
|
(MoveTarget::Synced { .. }, PickerKind::Move) => true,
|
||||||
|
(MoveTarget::Synced { .. }, PickerKind::Copy) => false,
|
||||||
};
|
};
|
||||||
self.dialog.set_response_enabled("move", allowed);
|
self.dialog.set_response_enabled("move", allowed);
|
||||||
*self.folder.borrow_mut() = folder.clone();
|
*self.folder.borrow_mut() = folder.clone();
|
||||||
@@ -2180,6 +2244,10 @@ impl MovePicker {
|
|||||||
/// My files and every synced folder, as rows of the top level.
|
/// My files and every synced folder, as rows of the top level.
|
||||||
async fn locations(&self) -> Option<Vec<PickerRow>> {
|
async fn locations(&self) -> Option<Vec<PickerRow>> {
|
||||||
let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))];
|
let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))];
|
||||||
|
// A copy is made on the server, so it can only land in My files.
|
||||||
|
if self.kind == PickerKind::Copy {
|
||||||
|
return Some(rows);
|
||||||
|
}
|
||||||
let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders);
|
let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders);
|
||||||
let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else {
|
let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else {
|
||||||
// My files alone is still somewhere to move to.
|
// My files alone is still somewhere to move to.
|
||||||
@@ -2286,11 +2354,16 @@ pub(crate) fn move_target_allowed(sources: &[String], target: &str) -> bool {
|
|||||||
.map(|i| path[..i].to_string())
|
.map(|i| path[..i].to_string())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
let inside = sources
|
|
||||||
.iter()
|
|
||||||
.any(|src| target == src || target.starts_with(&format!("{src}/")));
|
|
||||||
let all_here = sources.iter().all(|src| parent(src) == target);
|
let all_here = sources.iter().all(|src| parent(src) == target);
|
||||||
!inside && !all_here
|
copy_target_allowed(sources, target) && !all_here
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `target` is somewhere `sources` can be copied to: anywhere but into
|
||||||
|
/// one of themselves. The folder they are in is fine; the copy gets a free name.
|
||||||
|
pub(crate) fn copy_target_allowed(sources: &[String], target: &str) -> bool {
|
||||||
|
!sources
|
||||||
|
.iter()
|
||||||
|
.any(|src| target == src || target.starts_with(&format!("{src}/")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move each of `sources` into `target`, one request at a time, then report
|
/// Move each of `sources` into `target`, one request at a time, then report
|
||||||
@@ -2309,38 +2382,14 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
|
|||||||
clear_selection(ui);
|
clear_selection(ui);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let mut done = 0usize;
|
let (done, failure) = send_each(&socket, sources, |path| Request::Move {
|
||||||
let mut failure: Option<(String, ErrorKind)> = None;
|
|
||||||
for path in sources {
|
|
||||||
let rx = spawn_request(
|
|
||||||
socket.clone(),
|
|
||||||
Request::Move {
|
|
||||||
path,
|
path,
|
||||||
new_parent: target.clone(),
|
new_parent: target.clone(),
|
||||||
},
|
})
|
||||||
);
|
.await;
|
||||||
match rx.recv().await {
|
|
||||||
Ok(Ok(Response::Ok { .. })) => done += 1,
|
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
|
||||||
failure.get_or_insert((message, kind));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
failure.get_or_insert_with(|| {
|
|
||||||
(
|
|
||||||
gettext("The Proton Drive service didn't respond."),
|
|
||||||
ErrorKind::Internal,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
reload_listing(&ui);
|
reload_listing(&ui);
|
||||||
let place = match target.rsplit('/').next() {
|
let place = quoted_place(&target);
|
||||||
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
|
|
||||||
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
|
|
||||||
_ => "Proton Drive".to_string(),
|
|
||||||
};
|
|
||||||
match (done, failure) {
|
match (done, failure) {
|
||||||
(0, Some((message, kind))) => {
|
(0, Some((message, kind))) => {
|
||||||
toast_failure(&ui, &gettext("Couldn't move"), &message, kind)
|
toast_failure(&ui, &gettext("Couldn't move"), &message, kind)
|
||||||
@@ -2373,6 +2422,101 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy each of `sources` into `target` on Proton Drive, one request at a
|
||||||
|
/// time, then report once for the lot.
|
||||||
|
pub(crate) fn run_bulk_copy(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
|
||||||
|
if !*ui.mounted.borrow() {
|
||||||
|
toast_error(
|
||||||
|
ui,
|
||||||
|
&gettext("Couldn't copy"),
|
||||||
|
&gettext("Proton Drive isn't connected."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let socket = ui.dirs.control_socket();
|
||||||
|
ui.busy_begin();
|
||||||
|
clear_selection(ui);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let (done, failure) = send_each(&socket, sources, |path| Request::Copy {
|
||||||
|
path,
|
||||||
|
new_parent: target.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
ui.busy_end();
|
||||||
|
reload_listing(&ui);
|
||||||
|
let place = quoted_place(&target);
|
||||||
|
match (done, failure) {
|
||||||
|
(0, Some((message, kind))) => {
|
||||||
|
toast_failure(&ui, &gettext("Couldn't copy"), &message, kind)
|
||||||
|
}
|
||||||
|
(0, None) => {}
|
||||||
|
(n, Some((message, _))) => toast_error(
|
||||||
|
&ui,
|
||||||
|
// Translators: {place} is a quoted folder name or "Proton Drive".
|
||||||
|
&ngettext_f(
|
||||||
|
"Copied {n} item to {place}, but not all",
|
||||||
|
"Copied {n} items to {place}, but not all",
|
||||||
|
n as u64,
|
||||||
|
&[("place", &place)],
|
||||||
|
),
|
||||||
|
&message,
|
||||||
|
),
|
||||||
|
// Translators: {place} is a quoted folder name or "Proton Drive".
|
||||||
|
(1, None) => toast(&ui, &gettext_f("Copied to {place}", &[("place", &place)])),
|
||||||
|
(n, None) => toast(
|
||||||
|
&ui,
|
||||||
|
// Translators: {place} is a quoted folder name or "Proton Drive".
|
||||||
|
&ngettext_f(
|
||||||
|
"Copied {n} item to {place}",
|
||||||
|
"Copied {n} items to {place}",
|
||||||
|
n as u64,
|
||||||
|
&[("place", &place)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one request per source, in order, and count how many succeeded. The
|
||||||
|
/// first failure is kept for the report.
|
||||||
|
async fn send_each(
|
||||||
|
socket: &Path,
|
||||||
|
sources: Vec<String>,
|
||||||
|
request: impl Fn(String) -> Request,
|
||||||
|
) -> (usize, Option<(String, ErrorKind)>) {
|
||||||
|
let mut done = 0usize;
|
||||||
|
let mut failure: Option<(String, ErrorKind)> = None;
|
||||||
|
for path in sources {
|
||||||
|
let rx = spawn_request(socket.to_path_buf(), request(path));
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => done += 1,
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
failure.get_or_insert((message, kind));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
failure.get_or_insert_with(|| {
|
||||||
|
(
|
||||||
|
gettext("The Proton Drive service didn't respond."),
|
||||||
|
ErrorKind::Internal,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(done, failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The destination folder for a report: its name in quotes, or "Proton Drive"
|
||||||
|
/// for My files.
|
||||||
|
fn quoted_place(target: &str) -> String {
|
||||||
|
match target.rsplit('/').next() {
|
||||||
|
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
|
||||||
|
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
|
||||||
|
_ => "Proton Drive".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Move one entry to Trash, through the batch path so it gets the same Undo.
|
/// Move one entry to Trash, through the batch path so it gets the same Undo.
|
||||||
pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
run_bulk_delete(ui, vec![entry.clone()]);
|
run_bulk_delete(ui, vec![entry.clone()]);
|
||||||
@@ -2978,8 +3122,8 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed,
|
MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary,
|
||||||
show_thumbnail_build_progress, sort_entries,
|
move_target_allowed, show_thumbnail_build_progress, sort_entries,
|
||||||
};
|
};
|
||||||
use pdfs_core::config::{FileSort, FilesView};
|
use pdfs_core::config::{FileSort, FilesView};
|
||||||
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
|
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
|
||||||
@@ -3067,6 +3211,16 @@ mod tests {
|
|||||||
assert!(move_target_allowed(&mixed, ""));
|
assert!(move_target_allowed(&mixed, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_refuses_its_own_subtree_but_not_the_folder_it_is_in() {
|
||||||
|
let sources = ["Docs/Work".to_string()];
|
||||||
|
assert!(!copy_target_allowed(&sources, "Docs/Work"));
|
||||||
|
assert!(!copy_target_allowed(&sources, "Docs/Work/Old"));
|
||||||
|
assert!(copy_target_allowed(&sources, "Docs"));
|
||||||
|
assert!(copy_target_allowed(&sources, ""));
|
||||||
|
assert!(copy_target_allowed(&sources, "Docs/Workshop"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn listing_summary_matches_dolphin_order_and_wording() {
|
fn listing_summary_matches_dolphin_order_and_wording() {
|
||||||
assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files");
|
assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files");
|
||||||
|
|||||||
@@ -629,9 +629,17 @@ pub(crate) fn sync_state_label(state: &str) -> String {
|
|||||||
/// — so both read "12 of 40" rather than a percentage that could go backwards.
|
/// — so both read "12 of 40" rather than a percentage that could go backwards.
|
||||||
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
||||||
match p.phase {
|
match p.phase {
|
||||||
// Before the first pass finishes there is no estimate, so the count would
|
SyncPhase::Scanning if p.total == 0 && p.done == 0 => gettext("checking for changes…"),
|
||||||
// be "checked 12 of 12" — worse than saying nothing.
|
// Before the first pass finishes there is no estimate, so "12 of 12" would
|
||||||
SyncPhase::Scanning if p.total == 0 => gettext("checking for changes…"),
|
// say nothing. The count alone still shows a first scan of a large folder
|
||||||
|
// moving, which can take many minutes before anything downloads.
|
||||||
|
// Translators: {n} counts the folders and files checked so far.
|
||||||
|
SyncPhase::Scanning if p.total == 0 => ngettext_f(
|
||||||
|
"checking for changes — {n} item checked",
|
||||||
|
"checking for changes — {n} items checked",
|
||||||
|
p.done as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
// Translators: {done} and {total} count folders and files checked so far.
|
// Translators: {done} and {total} count folders and files checked so far.
|
||||||
SyncPhase::Scanning => gettext_f(
|
SyncPhase::Scanning => gettext_f(
|
||||||
"checking for changes — {done} of {total}",
|
"checking for changes — {done} of {total}",
|
||||||
@@ -1296,6 +1304,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scanning(done: usize, total: usize) -> SyncProgress {
|
||||||
|
SyncProgress {
|
||||||
|
phase: SyncPhase::Scanning,
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
current: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_first_scan_counts_what_it_checked() {
|
||||||
|
// A first sync of a large folder scans for many minutes before anything
|
||||||
|
// downloads; without a count it reads as stuck (#46).
|
||||||
|
assert_eq!(
|
||||||
|
sync_progress_label(&scanning(0, 0)),
|
||||||
|
"checking for changes…"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sync_progress_label(&scanning(1, 0)),
|
||||||
|
"checking for changes — 1 item checked"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sync_progress_label(&scanning(1234, 0)),
|
||||||
|
"checking for changes — 1234 items checked"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sync_progress_label(&scanning(12, 40)),
|
||||||
|
"checking for changes — 12 of 40"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_restore_picker_names_the_computer_it_restores_from() {
|
fn the_restore_picker_names_the_computer_it_restores_from() {
|
||||||
assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
|
assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
|
||||||
|
|||||||
@@ -1506,8 +1506,13 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
|
|||||||
} else {
|
} else {
|
||||||
gettext("Pause Syncing")
|
gettext("Pause Syncing")
|
||||||
};
|
};
|
||||||
|
let ui_pause = ui.clone();
|
||||||
|
menu.item(&label, move || set_folder_paused(&ui_pause, id, !paused));
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
menu.item(&label, move || set_folder_paused(&ui, id, !paused));
|
let path = spec.local_path.clone();
|
||||||
|
menu.item(&gettext("Sync From Scratch…"), move || {
|
||||||
|
confirm_reset_sync_folder(&ui, id, &path)
|
||||||
|
});
|
||||||
menu.section();
|
menu.section();
|
||||||
}
|
}
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
@@ -1520,6 +1525,51 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask before syncing a folder from scratch. Nothing is deleted, but files
|
||||||
|
/// that differ come back as conflict copies, so it is not a click to make by
|
||||||
|
/// accident.
|
||||||
|
fn confirm_reset_sync_folder(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Sync From Scratch?"))
|
||||||
|
// Translators: {path} is a local folder path.
|
||||||
|
.body(gettext_f("This computer forgets what it synced in {path} before and compares the folder with Proton Drive again. Nothing is deleted: a file on one side only is copied to the other, and a file that differs keeps both versions.", &[("path", path)]))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
|
dialog.add_response("reset", &gettext("Sync From Scratch"));
|
||||||
|
dialog.set_response_appearance("reset", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let win = ui_window(ui);
|
||||||
|
let ui = ui.clone();
|
||||||
|
dialog.connect_response(None, move |_, response| {
|
||||||
|
if response == "reset" {
|
||||||
|
reset_sync_folder(&ui, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(win.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a folder's sync records, then repaint so its row shows the new pass.
|
||||||
|
fn reset_sync_folder(ui: &Rc<Ui>, id: i64) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ResetSyncFolder { id });
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let failed = gettext("Couldn't sync the folder from scratch");
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Syncing folder…")),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &failed, &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&failed,
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
refresh_locations(&ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask before making a synced folder online only: the switch frees disk space
|
/// Ask before making a synced folder online only: the switch frees disk space
|
||||||
/// by removing the local copies, which is not something to do by accident.
|
/// by removing the local copies, which is not something to do by accident.
|
||||||
fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
|
fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||||
|
|||||||
@@ -368,12 +368,19 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
} else {
|
} else {
|
||||||
gettext("File")
|
gettext("File")
|
||||||
});
|
});
|
||||||
d.size_row.set_subtitle(&if entry.is_dir {
|
// A folder's size is Proton Drive's count, which takes a request; it lands
|
||||||
"—".to_string()
|
// in `load_details_extras`.
|
||||||
|
if entry.is_dir {
|
||||||
|
d.size_row.set_title(&gettext("Size on Proton Drive"));
|
||||||
|
d.size_row.set_subtitle("—");
|
||||||
|
d.size_row.set_tooltip_text(Some(&gettext(
|
||||||
|
"Counted by Proton Drive, including items in the trash.",
|
||||||
|
)));
|
||||||
} else {
|
} else {
|
||||||
human_bytes(entry.size)
|
d.size_row.set_title(&pgettext("column", "Size"));
|
||||||
});
|
d.size_row.set_subtitle(&human_bytes(entry.size));
|
||||||
d.size_row.set_visible(!entry.is_dir);
|
d.size_row.set_tooltip_text(None);
|
||||||
|
}
|
||||||
d.modified_row
|
d.modified_row
|
||||||
.set_subtitle(&dates::short_date(entry.modified));
|
.set_subtitle(&dates::short_date(entry.modified));
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
@@ -426,8 +433,8 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
d.pages.set_visible_child_name("details");
|
d.pages.set_visible_child_name("details");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fill the Sharing and Versions rows, which need the network. The answers
|
/// Fill the Sharing and Versions rows, and a folder's size, which need the
|
||||||
/// land only if the pane still shows the same entry.
|
/// network. The answers land only if the pane still shows the same entry.
|
||||||
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
|
let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
@@ -453,6 +460,24 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
|
let req = if by_uid {
|
||||||
|
Request::FolderSizeByUid {
|
||||||
|
uid: entry.uid.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Request::FolderSize { path: rel }
|
||||||
|
};
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||||
|
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let text = match rx.recv().await {
|
||||||
|
Ok(Ok(Response::FolderSize { size, items })) => folder_size_summary(size, items),
|
||||||
|
_ => gettext("Couldn't load the size"),
|
||||||
|
};
|
||||||
|
if details_showing(&ui_c, &uid) {
|
||||||
|
ui_c.details.details.size_row.set_subtitle(&text);
|
||||||
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let req = if by_uid {
|
let req = if by_uid {
|
||||||
@@ -499,6 +524,18 @@ pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "1.2 GiB · 340 items": what a folder holds, as Proton Drive counts it.
|
||||||
|
pub(crate) fn folder_size_summary(size: u64, items: u64) -> String {
|
||||||
|
// Translators: {size} is a size such as "1.2 GiB"; {n} counts the files
|
||||||
|
// and folders inside a folder.
|
||||||
|
ngettext_f(
|
||||||
|
"{size} · {n} item",
|
||||||
|
"{size} · {n} items",
|
||||||
|
items,
|
||||||
|
&[("size", &human_bytes(size))],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// "3 versions · latest 2 Sep 2026".
|
/// "3 versions · latest 2 Sep 2026".
|
||||||
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
|
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
|
||||||
let latest = items.iter().map(|r| r.created).max();
|
let latest = items.iter().map(|r| r.created).max();
|
||||||
@@ -547,6 +584,12 @@ pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folder_size_summary_reads_size_and_item_count() {
|
||||||
|
assert_eq!(folder_size_summary(0, 0), "0 bytes · 0 items");
|
||||||
|
assert_eq!(folder_size_summary(2048, 1), "2.0 KiB · 1 item");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sharing_summary_names_people_and_link() {
|
fn sharing_summary_names_people_and_link() {
|
||||||
assert_eq!(sharing_summary(0, false), "Only you");
|
assert_eq!(sharing_summary(0, false), "Only you");
|
||||||
|
|||||||
@@ -51,6 +51,48 @@ pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
|
|||||||
.map(|days| now + days * 86_400)
|
.map(|days| now + days * 86_400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The labels of the Expires dropdown on an existing link. When the link has
|
||||||
|
/// an expiry, the first entry is that expiry, so the dropdown shows what is
|
||||||
|
/// set and choosing it again changes nothing.
|
||||||
|
pub(crate) fn link_expiry_choices(current: Option<i64>, now: i64) -> Vec<String> {
|
||||||
|
let current = current.map(|at| capitalize(&link_expiry_label(at, now)));
|
||||||
|
current
|
||||||
|
.into_iter()
|
||||||
|
.chain(LINK_EXPIRY.iter().map(|(name, _)| gettext(name)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What choosing entry `idx` of [`link_expiry_choices`] does to the expiry.
|
||||||
|
pub(crate) fn link_expiry_change(idx: u32, current: Option<i64>, now: i64) -> LinkChange<i64> {
|
||||||
|
let idx = match (current, idx) {
|
||||||
|
(Some(_), 0) => return LinkChange::Keep,
|
||||||
|
(Some(_), idx) => idx - 1,
|
||||||
|
(None, idx) => idx,
|
||||||
|
};
|
||||||
|
match link_expiry_at(idx, now) {
|
||||||
|
Some(at) => LinkChange::Set(at),
|
||||||
|
None if current.is_some() => LinkChange::Remove,
|
||||||
|
None => LinkChange::Keep,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row with a title and a dropdown of `labels` as its suffix.
|
||||||
|
fn dropdown_row(title: &str, labels: &[String], selected: u32) -> (adw::ActionRow, gtk4::DropDown) {
|
||||||
|
let drop = gtk4::DropDown::builder()
|
||||||
|
.model(&string_list(labels))
|
||||||
|
.selected(selected)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
let row = adw::ActionRow::builder().title(title).build();
|
||||||
|
row.add_suffix(&drop);
|
||||||
|
(row, drop)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The roles a public link can grant, in dropdown order.
|
||||||
|
fn link_roles() -> [String; 2] {
|
||||||
|
[pgettext("role", "Viewer"), pgettext("role", "Editor")]
|
||||||
|
}
|
||||||
|
|
||||||
/// A loose shape check for an email address: one `@` with something before
|
/// A loose shape check for an email address: one `@` with something before
|
||||||
/// it and a dotted domain after it. The server does the real validation; this
|
/// it and a dotted domain after it. The server does the real validation; this
|
||||||
/// only catches typos before the Invite button is pressed.
|
/// only catches typos before the Invite button is pressed.
|
||||||
@@ -237,6 +279,31 @@ impl ShareTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_link(
|
||||||
|
&self,
|
||||||
|
id: String,
|
||||||
|
role: Option<String>,
|
||||||
|
password: LinkChange<String>,
|
||||||
|
expires: LinkChange<i64>,
|
||||||
|
) -> Request {
|
||||||
|
match self {
|
||||||
|
ShareTarget::Path(path) => Request::UpdatePublicLink {
|
||||||
|
path: path.clone(),
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
password,
|
||||||
|
expires,
|
||||||
|
},
|
||||||
|
ShareTarget::Uid(uid) => Request::UpdatePublicLinkByUid {
|
||||||
|
uid: uid.clone(),
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
password,
|
||||||
|
expires,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn remove_link(&self, id: String) -> Request {
|
fn remove_link(&self, id: String) -> Request {
|
||||||
match self {
|
match self {
|
||||||
ShareTarget::Path(path) => Request::RemovePublicLink {
|
ShareTarget::Path(path) => Request::RemovePublicLink {
|
||||||
@@ -585,8 +652,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
*state.people_rows.borrow_mut() = rows;
|
*state.people_rows.borrow_mut() = rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the public-link section: an existing link (copy / remove) or a
|
/// Rebuild the public-link section: an existing link (copy / remove, and its
|
||||||
/// create control.
|
/// settings) or a create control.
|
||||||
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
|
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
|
||||||
for row in state.link_rows.borrow_mut().drain(..) {
|
for row in state.link_rows.borrow_mut().drain(..) {
|
||||||
state.link_group.remove(&row);
|
state.link_group.remove(&row);
|
||||||
@@ -670,30 +737,19 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
|
|
||||||
state.link_group.add(&row);
|
state.link_group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
|
|
||||||
|
let settings = link_settings_row(state, link);
|
||||||
|
state.link_group.add(&settings);
|
||||||
|
rows.push(settings.upcast());
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]);
|
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), 0);
|
||||||
let role_drop = gtk4::DropDown::builder()
|
|
||||||
.model(&role_model)
|
|
||||||
.selected(0)
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
let role_row = adw::ActionRow::builder()
|
|
||||||
.title(gettext("Link role"))
|
|
||||||
.build();
|
|
||||||
role_row.add_suffix(&role_drop);
|
|
||||||
let pw_row = adw::PasswordEntryRow::builder()
|
let pw_row = adw::PasswordEntryRow::builder()
|
||||||
.title(gettext("Password (optional)"))
|
.title(gettext("Password (optional)"))
|
||||||
.build();
|
.build();
|
||||||
let expiry_names: Vec<String> =
|
let expiry_names: Vec<String> =
|
||||||
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
|
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
|
||||||
let expiry_drop = gtk4::DropDown::builder()
|
let (expiry_row, expiry_drop) = dropdown_row(&gettext("Expires"), &expiry_names, 0);
|
||||||
.model(&string_list(&expiry_names))
|
|
||||||
.selected(0)
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
|
||||||
expiry_row.add_suffix(&expiry_drop);
|
|
||||||
let create = group_button(&gettext("Create Public Link"));
|
let create = group_button(&gettext("Create Public Link"));
|
||||||
|
|
||||||
// Enter in the password field creates the link.
|
// Enter in the password field creates the link.
|
||||||
@@ -734,6 +790,142 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
*state.link_rows.borrow_mut() = rows;
|
*state.link_rows.borrow_mut() = rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The settings of an existing link, collapsed under one row. Each control
|
||||||
|
/// applies at once, the way a member's role does. While a change is on its way
|
||||||
|
/// the whole row is insensitive: the daemon fills in what a change keeps from
|
||||||
|
/// the link as it stands, so two changes in flight could undo each other.
|
||||||
|
fn link_settings_row(state: &Rc<ShareDialog>, link: &PublicLinkInfo) -> adw::ExpanderRow {
|
||||||
|
let now = glib::real_time() / 1_000_000;
|
||||||
|
let expander = adw::ExpanderRow::builder()
|
||||||
|
.title(gettext("Link settings"))
|
||||||
|
.subtitle(gettext(
|
||||||
|
"Change who can edit, the password or the expiry. The link stays the same.",
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let selected_role = u32::from(link.role == "editor");
|
||||||
|
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), selected_role);
|
||||||
|
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||||
|
role_drop.connect_selected_notify(move |d| {
|
||||||
|
let Some(state) = state_w.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let role = if d.selected() == 1 {
|
||||||
|
"editor"
|
||||||
|
} else {
|
||||||
|
"viewer"
|
||||||
|
};
|
||||||
|
let request = state.target.update_link(
|
||||||
|
id.clone(),
|
||||||
|
Some(role.into()),
|
||||||
|
LinkChange::Keep,
|
||||||
|
LinkChange::Keep,
|
||||||
|
);
|
||||||
|
share_dialog_update_link(&state, request, &expander_w);
|
||||||
|
});
|
||||||
|
expander.add_row(&role_row);
|
||||||
|
|
||||||
|
let current = link.expires;
|
||||||
|
let (expiry_row, expiry_drop) =
|
||||||
|
dropdown_row(&gettext("Expires"), &link_expiry_choices(current, now), 0);
|
||||||
|
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||||
|
expiry_drop.connect_selected_notify(move |d| {
|
||||||
|
let Some(state) = state_w.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let expires = link_expiry_change(d.selected(), current, glib::real_time() / 1_000_000);
|
||||||
|
if expires == LinkChange::Keep {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let request = state
|
||||||
|
.target
|
||||||
|
.update_link(id.clone(), None, LinkChange::Keep, expires);
|
||||||
|
share_dialog_update_link(&state, request, &expander_w);
|
||||||
|
});
|
||||||
|
expander.add_row(&expiry_row);
|
||||||
|
|
||||||
|
let pw_row = adw::PasswordEntryRow::builder()
|
||||||
|
.title(if link.has_password {
|
||||||
|
gettext("New password")
|
||||||
|
} else {
|
||||||
|
gettext("Add a password")
|
||||||
|
})
|
||||||
|
.show_apply_button(true)
|
||||||
|
.build();
|
||||||
|
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||||
|
pw_row.connect_apply(move |row| {
|
||||||
|
let Some(state) = state_w.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let password = row.text().to_string();
|
||||||
|
if password.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let request = state.target.update_link(
|
||||||
|
id.clone(),
|
||||||
|
None,
|
||||||
|
LinkChange::Set(password),
|
||||||
|
LinkChange::Keep,
|
||||||
|
);
|
||||||
|
share_dialog_update_link(&state, request, &expander_w);
|
||||||
|
});
|
||||||
|
expander.add_row(&pw_row);
|
||||||
|
|
||||||
|
if link.has_password {
|
||||||
|
let remove = gtk4::Button::builder()
|
||||||
|
.label(gettext("Remove"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
remove.add_css_class("flat");
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(gettext("Remove the password"))
|
||||||
|
.subtitle(gettext(
|
||||||
|
"Anyone with the link can open it without a password.",
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
row.add_suffix(&remove);
|
||||||
|
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||||
|
remove.connect_clicked(move |_| {
|
||||||
|
let Some(state) = state_w.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let request =
|
||||||
|
state
|
||||||
|
.target
|
||||||
|
.update_link(id.clone(), None, LinkChange::Remove, LinkChange::Keep);
|
||||||
|
share_dialog_update_link(&state, request, &expander_w);
|
||||||
|
});
|
||||||
|
expander.add_row(&row);
|
||||||
|
}
|
||||||
|
expander
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a link change, then reload the dialog: on success to show the link as
|
||||||
|
/// it now is, on failure to put the controls back to what the link still is.
|
||||||
|
fn share_dialog_update_link(
|
||||||
|
state: &Rc<ShareDialog>,
|
||||||
|
request: Request,
|
||||||
|
settings: &adw::ExpanderRow,
|
||||||
|
) {
|
||||||
|
settings.set_sensitive(false);
|
||||||
|
let rx = spawn_request(state.ui.dirs.control_socket(), request);
|
||||||
|
let state = state.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::PublicLink { .. })) => toast(&state.ui, &gettext("Link updated")),
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
toast_error(&state.ui, &gettext("Couldn't change the link"), &message)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&state.ui,
|
||||||
|
&gettext("Couldn't change the link"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
share_dialog_reload(&state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a public link, then reload the dialog so the copy/remove controls
|
/// Create a public link, then reload the dialog so the copy/remove controls
|
||||||
/// replace the create form (and the freshly minted URL is shown).
|
/// replace the create form (and the freshly minted URL is shown).
|
||||||
pub(crate) fn share_dialog_create_link(
|
pub(crate) fn share_dialog_create_link(
|
||||||
@@ -863,6 +1055,28 @@ mod tests {
|
|||||||
assert_eq!(link_expiry_at(99, 0), None);
|
assert_eq!(link_expiry_at(99, 0), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn choosing_an_expiry_keeps_removes_or_sets_it() {
|
||||||
|
let now = 1_000;
|
||||||
|
// No expiry yet: the choices are just Never and the lifetimes.
|
||||||
|
assert_eq!(link_expiry_choices(None, now).len(), LINK_EXPIRY.len());
|
||||||
|
assert_eq!(link_expiry_change(0, None, now), LinkChange::Keep);
|
||||||
|
assert_eq!(
|
||||||
|
link_expiry_change(2, None, now),
|
||||||
|
LinkChange::Set(now + 7 * 86_400)
|
||||||
|
);
|
||||||
|
|
||||||
|
// An expiry set: it comes first, then Never and the lifetimes.
|
||||||
|
let at = Some(2_000_000_000);
|
||||||
|
assert_eq!(link_expiry_choices(at, now).len(), LINK_EXPIRY.len() + 1);
|
||||||
|
assert_eq!(link_expiry_change(0, at, now), LinkChange::Keep);
|
||||||
|
assert_eq!(link_expiry_change(1, at, now), LinkChange::Remove);
|
||||||
|
assert_eq!(
|
||||||
|
link_expiry_change(2, at, now),
|
||||||
|
LinkChange::Set(now + 86_400)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn past_expiry_reads_expired() {
|
fn past_expiry_reads_expired() {
|
||||||
assert_eq!(link_expiry_label(10, 20), "expired");
|
assert_eq!(link_expiry_label(10, 20), "expired");
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
|
||||||
|
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
|
||||||
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
|
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
|
||||||
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
|
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
|
||||||
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
|
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
|
||||||
@@ -357,6 +359,60 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
|
### B256 — `pdfs copy` right after a save copies the old version
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
|
||||||
|
failing on most runs. The feature was not released yet.
|
||||||
|
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
|
||||||
|
`crates/pdfs-fuse/src/lib.rs`; `has_pending_work_under` in `crates/pdfs-fuse/src/relocate.rs`
|
||||||
|
|
||||||
|
**Repro:** Save a file in the mount and copy it, or its folder, with `pdfs copy` straight away.
|
||||||
|
The copy goes ahead and Drive copies the version before the save.
|
||||||
|
|
||||||
|
**Cause.** `close(2)` returns before the kernel sends `release`. `release` takes the write handle
|
||||||
|
out of the open writes and then queues the revision, which takes a few milliseconds. A copy
|
||||||
|
checked in between found the edit in neither place. A folder copy looked only at the queue, so
|
||||||
|
it also missed a file still open with unsaved writes.
|
||||||
|
|
||||||
|
**Fix.** `release` keeps the inode in `State::releasing` until the revision is queued. A copy
|
||||||
|
refuses a file or folder with unsaved writes, or a release still queueing, at it or below it.
|
||||||
|
A move checks the same set.
|
||||||
|
|
||||||
|
**Test:** The simulation above passed 6 of 6 runs after the fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B255 — A synced folder whose files are all gone here is stuck for good
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
|
||||||
|
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
|
||||||
|
`crates/pdfs-fuse/src/devices.rs`
|
||||||
|
|
||||||
|
**Repro:** Sync a folder in mirror mode, then delete all of its files on this computer while
|
||||||
|
the folder is paused. Resume it. Every pass stops with `every one of the N synced paths is
|
||||||
|
missing locally`, the folder stays in error, and a switch to online only waits for good.
|
||||||
|
|
||||||
|
**Cause.** The wipe guard is right to stop a pass that would trash the whole folder on Drive.
|
||||||
|
But nothing got the folder out of that state. A switch to on-demand waits for a pass that ends
|
||||||
|
`idle`, and its push pass hit the same guard. The baseline that made the empty folder read as
|
||||||
|
"delete everything" could only be cleared by removing the folder from sync. How the reporter's
|
||||||
|
folder lost its files is not known yet.
|
||||||
|
|
||||||
|
**Fix.** The push pass for a switch to on-demand forgets the baseline rows of a local copy that
|
||||||
|
is gone in full, instead of trashing them on Drive, so the switch goes ahead and the mount shows
|
||||||
|
what Drive has. **Sync From Scratch…** in the app and `pdfs sync reset ID` clear a mirror
|
||||||
|
folder's baseline, so the next pass compares both sides as on the first sync: one-sided files
|
||||||
|
are copied across, files that differ keep both versions, and nothing is deleted. Restore now
|
||||||
|
logs why it skipped a folder.
|
||||||
|
|
||||||
|
**Test:** The simulations `a_folder_whose_local_copy_is_gone_still_goes_on_demand` (which
|
||||||
|
failed in exactly the reported state before the fix) and
|
||||||
|
`a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### B254 — An edit in the same second as a new upload never arrives
|
### B254 — An edit in the same second as a new upload never arrives
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
@@ -5877,6 +5933,13 @@ Only the three namespace ops were strictly required (the other four silence
|
|||||||
themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the
|
themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the
|
||||||
refusal explicit and documented at the call site rather than inherited.
|
refusal explicit and documented at the call site rather than inherited.
|
||||||
|
|
||||||
|
`copy_file_range` has since been implemented: the daemon copies a range whose bytes are on this
|
||||||
|
machine (an open write handle, a queued write, the cache) into the destination's scratch file,
|
||||||
|
and answers `EOPNOTSUPP` for anything it would have to download. Unlike `ENOSYS`, that errno makes
|
||||||
|
the kernel copy that one call through read and write and keeps asking the daemon next time.
|
||||||
|
A warm `cp` of a 256 MiB file inside the mount went from about 100 MiB/s to about 450 MiB/s
|
||||||
|
(sim, debug build; `a_copy_inside_the_mount_measures_its_speed`).
|
||||||
|
|
||||||
**Verified:** full live acceptance run after the fix — 19 pass / 1 skip, the
|
**Verified:** full live acceptance run after the fix — 19 pass / 1 skip, the
|
||||||
capability diff identical to the pre-fix run, and **0** `Not Implemented` lines
|
capability diff identical to the pre-fix run, and **0** `Not Implemented` lines
|
||||||
in the journal across the whole run (was 17, 10 of them `link`).
|
in the journal across the whole run (was 17, 10 of them `link`).
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.6.0] — 2026-10-27
|
||||||
|
|
||||||
|
Schema: **44**, unchanged.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **`pdfs diagnose` names the key that keeps My Files closed.** When you are signed in and the
|
||||||
|
service does not answer, a new **Keys** section asks Proton which account and address keys
|
||||||
|
open, which account key each address key depends on, and which address key the My Files share
|
||||||
|
is encrypted to. It shows key IDs only, no secrets (#40).
|
||||||
- **A COPR repository for Fedora**, so `dnf upgrade` brings each new release:
|
- **A COPR repository for Fedora**, so `dnf upgrade` brings each new release:
|
||||||
`sudo dnf copr enable narl-rs/proton-drive-linux`. Every release also ships the vendored crates
|
`sudo dnf copr enable narl-rs/proton-drive-linux`. Every release also ships the vendored crates
|
||||||
as `proton-drive-linux-vendor-<version>.tar.xz`, which the COPR build uses offline (#42).
|
as `proton-drive-linux-vendor-<version>.tar.xz`, which the COPR build uses offline (#42).
|
||||||
@@ -19,8 +27,33 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
the app asks GitHub once a day and shows a banner with a **Download** button when a newer
|
the app asks GitHub once a day and shows a banner with a **Download** button when a newer
|
||||||
version is out. It is off by default, and not offered for packages that a package manager
|
version is out. It is off by default, and not offered for packages that a package manager
|
||||||
updates (COPR, the AUR, Nix) (#42).
|
updates (COPR, the AUR, Nix) (#42).
|
||||||
|
- **Copy files and folders on Proton Drive**, without downloading or uploading them. In My files,
|
||||||
|
right-click an item and choose **Make a Copy** to copy it next to itself, or **Copy To…** to pick
|
||||||
|
another folder; **Copy To…** also works on a selection. From a terminal:
|
||||||
|
`pdfs copy ~/ProtonDrive/report.pdf ~/ProtonDrive/Archive`. A folder is copied with everything
|
||||||
|
in it, and a name already taken gets a " (1)" suffix. A file or folder with changes that have
|
||||||
|
not synced yet is refused until they have.
|
||||||
|
- **Change a public link without getting a new one.** In **Share…**, open **Link settings** under
|
||||||
|
the link to switch between Viewer and Editor, set, change or remove the password, and change or
|
||||||
|
remove the expiry. The address stays the same, so people who already have the link keep it.
|
||||||
|
From a terminal: `pdfs public-link update PATH ID [--role] [--password | --no-password]
|
||||||
|
[--expires EPOCH | --no-expiry]`. `pdfs members` now also shows when a link expires.
|
||||||
|
- **Folder sizes.** **Details** shows how much a folder holds and how many items are in it, as
|
||||||
|
Proton Drive counts it, including items in the trash. From a terminal: `pdfs du PATH`.
|
||||||
|
|
||||||
|
- **Sync a folder from scratch.** In a synced folder's menu on **Sync → Folders**, **Sync From
|
||||||
|
Scratch…** forgets what was synced before and compares the folder with Proton Drive again. A
|
||||||
|
file on one side only is copied to the other, and a file that differs keeps both versions.
|
||||||
|
Nothing is deleted. From the terminal: `pdfs sync reset ID`.
|
||||||
|
|
||||||
|
- **`--json` for `pdfs members`, `pdfs du` and `pdfs public-link create` / `update`**, so scripts
|
||||||
|
can read a link's id, role, password flag and expiry, and a folder's size and item count.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Copying a file inside the mount is faster** when the computer already has the file, for
|
||||||
|
example with `cp` or a file manager: the copy is made on this computer, about four times
|
||||||
|
faster for a 256 MiB file, instead of going through the mount page by page. A file that is
|
||||||
|
only on Proton Drive is copied as before. The copy is then uploaded as usual.
|
||||||
- **Reorganised documentation, now also on the
|
- **Reorganised documentation, now also on the
|
||||||
[wiki](https://github.com/narrrl/proton-drive-linux/wiki).** A new
|
[wiki](https://github.com/narrrl/proton-drive-linux/wiki).** A new
|
||||||
[Getting started](https://github.com/narrrl/proton-drive-linux/wiki/Getting-Started) page and
|
[Getting started](https://github.com/narrrl/proton-drive-linux/wiki/Getting-Started) page and
|
||||||
@@ -28,6 +61,28 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
pages for files, synced folders, Photos, sharing, computers, and the launcher and tray.
|
pages for files, synced folders, Photos, sharing, computers, and the launcher and tray.
|
||||||
Installation puts the COPR repository first for Fedora and explains updating and uninstalling
|
Installation puts the COPR repository first for Fedora and explains updating and uninstalling
|
||||||
for each kind of package. Each release publishes `docs/` to the wiki.
|
for each kind of package. Each release publishes `docs/` to the wiki.
|
||||||
|
- **Acceptance cases for the 3.6.0 features:** `copy_file_range` over several calls and at
|
||||||
|
offsets, `pdfs copy` (including B256), `pdfs du`, `pdfs public-link update`, Sync From Scratch,
|
||||||
|
and the switch of an emptied mirror folder to on-demand (B255). `docs/TESTING.md` lists what to
|
||||||
|
check by hand in the app.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A synced folder whose files were all gone from this computer no longer gets stuck** (#46).
|
||||||
|
The sync still refuses to delete everything on Proton Drive, but a switch to online only now
|
||||||
|
goes ahead and shows what Proton Drive has, instead of waiting for good. To download the
|
||||||
|
files again instead, sync the folder from scratch. Restoring folders also logs why it
|
||||||
|
skipped a folder.
|
||||||
|
|
||||||
|
- **The first sync of a folder shows how far its scan got** (#46). Before anything downloads,
|
||||||
|
the first pass reads every item on Proton Drive, which takes many minutes for a large folder.
|
||||||
|
The folder showed only "checking for changes…" all that time, and `pdfs sync list` only
|
||||||
|
`scanning…`, which looked stuck. Both now count the items checked so far.
|
||||||
|
|
||||||
|
- **Saved public links show the name of what they point at**, in the app and in `pdfs bookmarks`,
|
||||||
|
instead of "Shared link". For a link with a custom password, the address no longer has the custom
|
||||||
|
password stuck to its end, so it matches the link the web shows and the page asks for the
|
||||||
|
password.
|
||||||
|
|
||||||
## [3.5.0] — 2026-10-16
|
## [3.5.0] — 2026-10-16
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -58,6 +58,8 @@ browser, complete it, then press Enter.
|
|||||||
| `pdfs mkdir PARENT NAME` | Create a folder. |
|
| `pdfs mkdir PARENT NAME` | Create a folder. |
|
||||||
| `pdfs rename PATH NEW_NAME` | Rename a file or folder. |
|
| `pdfs rename PATH NEW_NAME` | Rename a file or folder. |
|
||||||
| `pdfs move PATH... NEW_PARENT` | Move one or more files or folders into another folder. The two may be in different locations (My files, an on-demand or a mirrored synced folder); the move happens on Proton Drive, without downloading or uploading anything. |
|
| `pdfs move PATH... NEW_PARENT` | Move one or more files or folders into another folder. The two may be in different locations (My files, an on-demand or a mirrored synced folder); the move happens on Proton Drive, without downloading or uploading anything. |
|
||||||
|
| `pdfs copy PATH... NEW_PARENT` | Copy one or more files or folders into another folder in My files. The copy happens on Proton Drive, without downloading or uploading anything; a name already taken gets a " (1)" suffix. Changes that have not synced yet must sync first. |
|
||||||
|
| `pdfs du PATH` | Show how much a folder holds on Proton Drive and how many items are in it. The size includes items in the trash. |
|
||||||
| `pdfs rm PATH` | Move to the Trash. |
|
| `pdfs rm PATH` | Move to the Trash. |
|
||||||
| `pdfs upload SOURCES... [-t FOLDER]` | Upload local files and folders (recursively) in the background. Follow with `pdfs transfers`. |
|
| `pdfs upload SOURCES... [-t FOLDER]` | Upload local files and folders (recursively) in the background. Follow with `pdfs transfers`. |
|
||||||
| `pdfs refresh [TARGET] [--full]` | Forget a cached listing so the next read fetches it again. `TARGET` is a folder, `trash` or `photos`; `--full` with `photos` re-reads every photo's metadata. |
|
| `pdfs refresh [TARGET] [--full]` | Forget a cached listing so the next read fetches it again. `TARGET` is a folder, `trash` or `photos`; `--full` with `photos` re-reads every photo's metadata. |
|
||||||
@@ -82,6 +84,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `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`). |
|
||||||
| `pdfs sync now [ID]` | Start a sync pass now, for all folders or one. |
|
| `pdfs sync now [ID]` | Start a sync pass now, for all folders or one. |
|
||||||
|
| `pdfs sync reset ID` | Forget what a synced folder synced before and compare it with Proton Drive again. A file on one side only is copied to the other, and a file that differs keeps both versions. Nothing is deleted. |
|
||||||
| `pdfs sync pause [--for 1h] [--folder ID]` | Pause uploads and synced folders. Reading files keeps working. `--for` ends the pause by itself (`30m`, `1h`, `2d`). |
|
| `pdfs sync pause [--for 1h] [--folder ID]` | Pause uploads and synced folders. Reading files keeps working. `--for` ends the pause by itself (`30m`, `1h`, `2d`). |
|
||||||
| `pdfs sync resume [--folder ID]` | Resume after a pause. |
|
| `pdfs sync resume [--folder ID]` | Resume after a pause. |
|
||||||
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
|
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
|
||||||
@@ -133,10 +136,11 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs share PATH EMAILS... [--role viewer\|editor\|admin] [--message TEXT]` | Invite people to a file or folder. |
|
| `pdfs share PATH EMAILS... [--role viewer\|editor\|admin] [--message TEXT]` | Invite people to a file or folder. |
|
||||||
| `pdfs members PATH` | List members, pending invitations and the public link. |
|
| `pdfs members PATH` | List members, pending invitations and the public link, with the link's id and expiry. |
|
||||||
| `pdfs share-role PATH ID KIND ROLE` | Change a member's or invitation's role. |
|
| `pdfs share-role PATH ID KIND ROLE` | Change a member's or invitation's role. |
|
||||||
| `pdfs unshare PATH [ID KIND]` | Stop sharing, or remove one member or invitation. |
|
| `pdfs unshare PATH [ID KIND]` | Stop sharing, or remove one member or invitation. |
|
||||||
| `pdfs public-link create PATH [--role] [--password] [--expires EPOCH]` | Create a public link. `--expires` takes Unix seconds. |
|
| `pdfs public-link create PATH [--role] [--password] [--expires EPOCH]` | Create a public link. `--expires` takes Unix seconds. |
|
||||||
|
| `pdfs public-link update PATH ID [--role] [--password TEXT \| --no-password] [--expires EPOCH \| --no-expiry]` | Change a public link's role, password or expiry. The URL stays the same, and an option you leave out is kept. |
|
||||||
| `pdfs public-link remove PATH ID` | Remove a public link. |
|
| `pdfs public-link remove PATH ID` | Remove a public link. |
|
||||||
| `pdfs shared` | List what you share. |
|
| `pdfs shared` | List what you share. |
|
||||||
| `pdfs shared-with-me [UID]` | List what others share with you, or the contents of one shared folder. |
|
| `pdfs shared-with-me [UID]` | List what others share with you, or the contents of one shared folder. |
|
||||||
@@ -175,7 +179,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs diagnose` | Check the installation and print a report. Works without the service. Exits non-zero if a check fails. |
|
| `pdfs diagnose` | Check the installation and print a report. Works without the service. When the service does not answer, also asks Proton which of your keys open. Exits non-zero if a check fails. |
|
||||||
| `pdfs diagnostics` | What a running service is doing now: worker threads, queues, requests in flight, memory. For a service that has stopped answering. |
|
| `pdfs diagnostics` | What a running service is doing now: worker threads, queues, requests in flight, memory. For a service that has stopped answering. |
|
||||||
| `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). |
|
| `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). |
|
||||||
| `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. |
|
| `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. |
|
||||||
@@ -186,8 +190,9 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
## Scripting with `--json`
|
## Scripting with `--json`
|
||||||
|
|
||||||
The global `--json` flag makes query commands print JSON: `status`, `ls`, `pins`, `sync list`,
|
The global `--json` flag makes query commands print JSON: `status`, `ls`, `pins`, `sync list`,
|
||||||
`devices list`, `locations`, `transfers`, `activity`, `trash` and `cache inspect`. Commands that change
|
`devices list`, `locations`, `transfers`, `activity`, `trash`, `cache inspect`, `members` and `du`.
|
||||||
something keep their human-readable output; check their exit code.
|
`public-link create` and `public-link update` print the link they made or changed. Other commands
|
||||||
|
that change something keep their human-readable output; check their exit code.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pdfs --json status | jq -r '.mount.mountpoint'
|
pdfs --json status | jq -r '.mount.mountpoint'
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ for l in $(cat po/LINGUAS); do msgfmt --check --statistics -o /dev/null po/$l.po
|
|||||||
| Tool | Use |
|
| Tool | Use |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `RUST_LOG` | Log filter for every binary, for example `RUST_LOG=pdfs_fuse::sync=trace,info` |
|
| `RUST_LOG` | Log filter for every binary, for example `RUST_LOG=pdfs_fuse::sync=trace,info` |
|
||||||
| `pdfs diagnose` | Static checks: directories, FUSE, keyring, socket, database integrity. Works without the daemon |
|
| `pdfs diagnose` | Static checks: directories, FUSE, keyring, socket, database integrity. Works without the daemon. When the daemon does not answer, also traces which account and address keys open (`pdfs_core::keycheck`) |
|
||||||
| `pdfs diagnostics` | Live state of a running daemon: each worker's current job and its age, queue depth, control requests in flight, memory |
|
| `pdfs diagnostics` | Live state of a running daemon: each worker's current job and its age, queue depth, control requests in flight, memory |
|
||||||
| `pdfs cache inspect --deep` | Database size, row counts and SQLite `integrity_check` |
|
| `pdfs cache inspect --deep` | Database size, row counts and SQLite `integrity_check` |
|
||||||
| `pdfs --json …` | Raw protocol payloads for query commands |
|
| `pdfs --json …` | Raw protocol payloads for query commands |
|
||||||
|
|||||||
+8
-1
@@ -59,7 +59,14 @@ folder off instead.
|
|||||||
The **My files** page shows the same files without going through the folder. It has a grid and
|
The **My files** page shows the same files without going through the folder. It has a grid and
|
||||||
a list view, sorting, thumbnails (including camera RAW previews), multi-select, and drag and drop
|
a list view, sorting, thumbnails (including camera RAW previews), multi-select, and drag and drop
|
||||||
to upload. **Details** (Alt+Enter) shows the size, dates, sharing and version history of the
|
to upload. **Details** (Alt+Enter) shows the size, dates, sharing and version history of the
|
||||||
selected item.
|
selected item. For a folder, the size and the number of items in it come from Proton Drive and
|
||||||
|
include items in the trash; `pdfs du PATH` prints the same.
|
||||||
|
|
||||||
|
Right-click an item for more. **Make a Copy** copies it into the same folder, and **Copy To…**
|
||||||
|
copies it, or a whole selection, into another folder in My files. The copy is made on Proton
|
||||||
|
Drive, so nothing is downloaded or uploaded, and a name already taken gets a " (1)" suffix. An
|
||||||
|
item with changes that have not reached Proton Drive yet can be copied once they have. From a
|
||||||
|
terminal: `pdfs copy PATH... NEW_PARENT`.
|
||||||
|
|
||||||
## Trash
|
## Trash
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
|
|||||||
the tag to update:
|
the tag to update:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.5.0";
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.6.0";
|
||||||
```
|
```
|
||||||
|
|
||||||
Each release tag is built with Nix before the release is published.
|
Each release tag is built with Nix before the release is published.
|
||||||
|
|||||||
+8
-1
@@ -33,6 +33,12 @@ In **Share…**, create a public link. Anyone with the link can open the item. Y
|
|||||||
link with a password and let it expire on a date. From a terminal, use `pdfs public-link create`
|
link with a password and let it expire on a date. From a terminal, use `pdfs public-link create`
|
||||||
and `pdfs public-link remove`.
|
and `pdfs public-link remove`.
|
||||||
|
|
||||||
|
To change a link later, open **Link settings** under it. You can switch it between Viewer and
|
||||||
|
Editor, set, change or remove its password, and change or remove its expiry. The address stays
|
||||||
|
the same, so everyone who has the link keeps it. From a terminal, use `pdfs public-link update`;
|
||||||
|
`pdfs members` shows the link's id. A link made long ago by an older Proton app may not be
|
||||||
|
changeable; remove it and create a new one instead.
|
||||||
|
|
||||||
## See what you share
|
## See what you share
|
||||||
|
|
||||||
**Shared by me** lists everything you share. Filter it by links, by people, or by expired links.
|
**Shared by me** lists everything you share. Filter it by links, by people, or by expired links.
|
||||||
@@ -47,6 +53,7 @@ a viewer are read-only.
|
|||||||
|
|
||||||
- **Invitations** wait in a banner at the top of the page until you accept or reject them. From a
|
- **Invitations** wait in a banner at the top of the page until you accept or reject them. From a
|
||||||
terminal: `pdfs invitations list`, then `accept ID` or `reject ID`.
|
terminal: `pdfs invitations list`, then `accept ID` or `reject ID`.
|
||||||
- **Saved public links** have their own view. `pdfs bookmarks` manages them.
|
- **Saved public links** have their own view, listed by the name of what they point at.
|
||||||
|
`pdfs bookmarks` manages them.
|
||||||
- **Leave…** a share you no longer need, or run `pdfs leave UID` with the ID from
|
- **Leave…** a share you no longer need, or run `pdfs leave UID` with the ID from
|
||||||
`pdfs shared-with-me`.
|
`pdfs shared-with-me`.
|
||||||
@@ -76,6 +76,14 @@ again when they move into a synced one.
|
|||||||
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`.
|
||||||
|
|
||||||
|
## Sync a folder from scratch
|
||||||
|
|
||||||
|
**Sync From Scratch…** in the folder's menu, or `pdfs sync reset ID`, makes this computer forget
|
||||||
|
what it synced in the folder before and compare it with Proton Drive again, as on the first sync.
|
||||||
|
A file on one side only is copied to the other, and a file that differs keeps both versions.
|
||||||
|
Nothing is deleted. Use it when a folder's files went missing on this computer and the sync
|
||||||
|
refuses to go on (see [Troubleshooting](TROUBLESHOOTING.md#a-synced-folder-stays-in-error-and-nothing-downloads)).
|
||||||
|
|
||||||
## Excluding files with `.pdfsignore`
|
## Excluding files with `.pdfsignore`
|
||||||
|
|
||||||
Synced folders skip build output, dependency folders and editor leftovers. Two sets of rules
|
Synced folders skip build output, dependency folders and editor leftovers. Two sets of rules
|
||||||
|
|||||||
+31
-1
@@ -116,7 +116,10 @@ for something a network filesystem cannot do.
|
|||||||
the move cases in every pairing of on-demand and mirror. Before a folder switches to on-demand,
|
the move cases in every pairing of on-demand and mirror. Before a folder switches to on-demand,
|
||||||
a new file is written into it; once mounted, the file must be listed with its bytes. In every
|
a new file is written into it; once mounted, the file must be listed with its bytes. In every
|
||||||
pairing each folder must still hold the file written into it at setup.
|
pairing each folder must still hold the file written into it at setup.
|
||||||
4. Removes everything it made, locally and on Drive, and reports `cleanup`. A run that leaves
|
4. Registers a third sync folder for the recovery cases (`account` in `--list`): Sync From Scratch
|
||||||
|
must change nothing that already matched, and a mirror folder whose local files are all gone
|
||||||
|
must still switch to on-demand, with every file intact on Drive (B255).
|
||||||
|
5. Removes everything it made, locally and on Drive, and reports `cleanup`. A run that leaves
|
||||||
anything behind fails.
|
anything behind fails.
|
||||||
|
|
||||||
Cleanup finds remote folders by uid, never by name, so nothing of yours is touched. It removes
|
Cleanup finds remote folders by uid, never by name, so nothing of yours is touched. It removes
|
||||||
@@ -199,6 +202,15 @@ Besides the syscall contract:
|
|||||||
both directions, several sources in one `pdfs move`, and a tree through My files and out again.
|
both directions, several sources in one `pdfs move`, and a tree through My files and out again.
|
||||||
The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
|
The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
|
||||||
mirrored source holding something Drive lacks, a file still open for writing.
|
mirrored source holding something Drive lacks, a file still open for writing.
|
||||||
|
- **`copy_file_range`.** A 17 MiB copy that takes several calls, copies into the middle of a file
|
||||||
|
and past its end, a short copy at the source's end, and a copy from a file whose writes are not
|
||||||
|
released yet. On a live mount the results are also read back from Drive.
|
||||||
|
- **Copies, sizes and links on Drive** (My files only). `pdfs copy` of a tree and of a file beside
|
||||||
|
itself, read back as listed and after `pdfs refresh`, with nothing queued; the refusal while a
|
||||||
|
file has writes Drive lacks, and never the version before a save (B256). `pdfs du` counts every
|
||||||
|
item below a folder and keeps counting one moved to the trash. `pdfs public-link update` keeps
|
||||||
|
the URL and every option it was not asked to change, and refuses a link id that is not the
|
||||||
|
folder's.
|
||||||
- **The CLI.** `pdfs` into a closed pipe exits 0 with nothing on stderr; `pdfs mkdir`, `rename`
|
- **The CLI.** `pdfs` into a closed pipe exits 0 with nothing on stderr; `pdfs mkdir`, `rename`
|
||||||
and `rm` by local path, and their refusals.
|
and `rm` by local path, and their refusals.
|
||||||
|
|
||||||
@@ -263,3 +275,21 @@ typo it is. A release run leaves it unset.
|
|||||||
|
|
||||||
What no automated run covers yet is listed under
|
What no automated run covers yet is listed under
|
||||||
[Verification still owed](ROADMAP.md#verification-still-owed).
|
[Verification still owed](ROADMAP.md#verification-still-owed).
|
||||||
|
|
||||||
|
### By hand in the app
|
||||||
|
|
||||||
|
The suite drives the CLI, not the app. Before a release that changes one of these screens, check
|
||||||
|
it by hand on a test account:
|
||||||
|
|
||||||
|
1. **Make a Copy** on a file in My files: the copy appears beside it with ` (1)`, and opens with
|
||||||
|
the same content. **Copy To…** a folder into another folder: the whole tree arrives.
|
||||||
|
2. Make a Copy on a file that is still open in an editor with unsaved changes: the app refuses,
|
||||||
|
and no copy appears.
|
||||||
|
3. **Details** of a folder: **Size on Proton Drive** shows a size and an item count, and its
|
||||||
|
tooltip says items in the trash are counted. Details of a file still shows **Size**.
|
||||||
|
4. **Share → Link settings** on a link with a password: change **Link role**. The link in the
|
||||||
|
dialog stays the same, and **Remove the password** is still offered. Then remove the password
|
||||||
|
and pick an expiry under **Expires**: the link row shows when it expires.
|
||||||
|
5. **Sync From Scratch…** on a mirror folder: after the pass, the folder holds the same files and
|
||||||
|
no conflict copies.
|
||||||
|
6. Each of the above in a second language (`LANGUAGE=de pdfs-app`): no English left.
|
||||||
+23
-1
@@ -20,6 +20,11 @@ information listed under [Reporting a problem](#reporting-a-problem).
|
|||||||
Checks the installation and prints a report. It does not need the service, because the state
|
Checks the installation and prints a report. It does not need the service, because the state
|
||||||
worth diagnosing is often the one where the service will not start.
|
worth diagnosing is often the one where the service will not start.
|
||||||
|
|
||||||
|
When you are signed in but the service does not answer, it also asks Proton which of your keys
|
||||||
|
open, under **Keys**: each account key, each address key, and the address key your My Files
|
||||||
|
share is encrypted to. A key that does not open is named with the key it depends on. The section
|
||||||
|
shows key IDs, which are not secret, so you can post it in an issue.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ pdfs diagnose
|
$ pdfs diagnose
|
||||||
Paths
|
Paths
|
||||||
@@ -146,7 +151,8 @@ session before it shows your files.
|
|||||||
Your account keys cannot open your Drive, which happens after a password reset: Proton locks the
|
Your account keys cannot open your Drive, which happens after a password reset: Proton locks the
|
||||||
files that were encrypted with the old keys. Since 3.4.1 the client then starts a new, empty My
|
files that were encrypted with the old keys. Since 3.4.1 the client then starts a new, empty My
|
||||||
files. Your old files are still there, locked: open [drive.proton.me](https://drive.proton.me) and
|
files. Your old files are still there, locked: open [drive.proton.me](https://drive.proton.me) and
|
||||||
restore them with your previous password.
|
restore them with your previous password. If your files are not locked and the error stays, run
|
||||||
|
`pdfs diagnose` and post its **Keys** section in an issue.
|
||||||
|
|
||||||
### Everything in the folder hangs
|
### Everything in the folder hangs
|
||||||
|
|
||||||
@@ -226,6 +232,22 @@ Check that:
|
|||||||
|
|
||||||
`pdfs sync now` starts a sync pass at once.
|
`pdfs sync now` starts a sync pass at once.
|
||||||
|
|
||||||
|
### A synced folder stays in error and nothing downloads
|
||||||
|
|
||||||
|
The log says `every one of the N synced paths is missing locally; refusing to trash them on
|
||||||
|
Drive`. The folder synced before, and now none of its files are on this computer. The sync
|
||||||
|
stops so that it does not delete them on Proton Drive too. Check that the folder is the right
|
||||||
|
one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the
|
||||||
|
folder's menu on **Sync → Folders**, or run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs sync list # find the folder's ID
|
||||||
|
pdfs sync reset 1
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads what Proton Drive has and uploads what only this computer has. Nothing is deleted.
|
||||||
|
If you deleted the files on purpose, delete them on Proton Drive too, or stop syncing the folder.
|
||||||
|
|
||||||
### Files named `(sync-conflict …)` appeared
|
### Files named `(sync-conflict …)` appeared
|
||||||
|
|
||||||
The file changed in two places at once. Nothing was lost. See
|
The file changed in two places at once. Nothing was lost. See
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=3.5.0
|
pkgver=3.6.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
||||||
%global _lto_cflags %{nil}
|
%global _lto_cflags %{nil}
|
||||||
|
|
||||||
%{!?version: %global version 3.5.0}
|
%{!?version: %global version 3.6.0}
|
||||||
%global forge https://github.com/narrrl/proton-drive-linux
|
%global forge https://github.com/narrrl/proton-drive-linux
|
||||||
# Files come from the checkout, or from the unpacked tag archive.
|
# Files come from the checkout, or from the unpacked tag archive.
|
||||||
%global src %{?git_dir}%{!?git_dir:.}
|
%global src %{?git_dir}%{!?git_dir:.}
|
||||||
|
|||||||
+538
-370
File diff suppressed because it is too large.
Load diff
+542
-378
File diff suppressed because it is too large.
Load diff
+539
-370
File diff suppressed because it is too large.
Load diff
+491
-2
@@ -566,6 +566,355 @@ def test_mmap_and_copy_paths(ctx: Context) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_range(ctx: Context, key: str, src: int, dst: int, count: int, offset_src: int, offset_dst: int) -> int | None:
|
||||||
|
"""`copy_file_range` until `count` bytes moved or the source ran out.
|
||||||
|
|
||||||
|
Returns how many bytes moved, or None when the first call was refused
|
||||||
|
cleanly. The mount copies at most 16 MiB a call, and `cp` loops the same
|
||||||
|
way, so a large copy reaches it as several calls with growing offsets.
|
||||||
|
"""
|
||||||
|
moved = 0
|
||||||
|
first = ctx.probe(
|
||||||
|
key,
|
||||||
|
lambda: os.copy_file_range(src, dst, count, offset_src, offset_dst),
|
||||||
|
allow=CLEAN_UNSUPPORTED | {errno.EXDEV},
|
||||||
|
)
|
||||||
|
if not first.ok:
|
||||||
|
return None
|
||||||
|
step = first.value
|
||||||
|
calls = 1
|
||||||
|
while step and moved + step < count:
|
||||||
|
moved += step
|
||||||
|
step = os.copy_file_range(src, dst, count - moved, offset_src + moved, offset_dst + moved)
|
||||||
|
calls += 1
|
||||||
|
ctx.note(f"{key}.calls", calls)
|
||||||
|
return moved + step
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_file_range_ranges(ctx: Context) -> None:
|
||||||
|
"""`copy_file_range` beyond the first call, at offsets, and from an open file.
|
||||||
|
|
||||||
|
The mount answers the call from bytes this machine already has: the write
|
||||||
|
handle's scratch, a pending upload, or the cache. Each answer is a write
|
||||||
|
into the destination, so it has to land exactly where the offsets say,
|
||||||
|
fill a gap past the end with zeros, and survive the upload.
|
||||||
|
"""
|
||||||
|
root = ctx.root
|
||||||
|
big = pattern(17 * 1024 * 1024 + 4099, "cfr-big")
|
||||||
|
source = root / "cfr-source.bin"
|
||||||
|
write_durable(source, big)
|
||||||
|
|
||||||
|
def copy(key: str, src_path: Path, dst_path: Path, count: int, offset_src: int = 0, offset_dst: int = 0) -> int | None:
|
||||||
|
src = os.open(src_path, os.O_RDONLY)
|
||||||
|
dst = os.open(dst_path, os.O_CREAT | os.O_WRONLY, 0o600)
|
||||||
|
try:
|
||||||
|
moved = _copy_range(ctx, key, src, dst, count, offset_src, offset_dst)
|
||||||
|
os.fsync(dst)
|
||||||
|
finally:
|
||||||
|
os.close(src)
|
||||||
|
os.close(dst)
|
||||||
|
return moved
|
||||||
|
|
||||||
|
expected: dict[Path, bytes] = {}
|
||||||
|
|
||||||
|
whole = root / "cfr-whole.bin"
|
||||||
|
moved = copy("whole", source, whole, len(big))
|
||||||
|
if moved is not None:
|
||||||
|
check(moved == len(big), f"whole: copy_file_range moved {moved} bytes, expected {len(big)}")
|
||||||
|
expected[whole] = big
|
||||||
|
|
||||||
|
# Into the middle of a file that exists, and past the end of another.
|
||||||
|
middle = root / "cfr-middle.bin"
|
||||||
|
base = pattern(300_000, "cfr-base")
|
||||||
|
write_durable(middle, base)
|
||||||
|
moved = copy("middle", source, middle, 100_000, offset_src=5_000_000, offset_dst=123_457)
|
||||||
|
if moved is not None:
|
||||||
|
check(moved == 100_000, f"middle: copy_file_range moved {moved} bytes, expected {100_000}")
|
||||||
|
expected[middle] = base[:123_457] + big[5_000_000:5_100_000] + base[223_457:]
|
||||||
|
gap = root / "cfr-gap.bin"
|
||||||
|
write_durable(gap, b"head")
|
||||||
|
moved = copy("gap", source, gap, 70_001, offset_src=1, offset_dst=200_000)
|
||||||
|
if moved is not None:
|
||||||
|
check(moved == 70_001, f"gap: copy_file_range moved {moved} bytes, expected {70_001}")
|
||||||
|
expected[gap] = b"head" + bytes(200_000 - 4) + big[1:70_002]
|
||||||
|
|
||||||
|
# Past the source's end the copy is short, and at it nothing moves.
|
||||||
|
tail = root / "cfr-tail.bin"
|
||||||
|
moved = copy("tail", source, tail, 10_000, offset_src=len(big) - 99)
|
||||||
|
if moved is not None:
|
||||||
|
check(moved == 99, f"tail: copy_file_range moved {moved} bytes, expected {99}")
|
||||||
|
expected[tail] = big[-99:]
|
||||||
|
|
||||||
|
# From a file whose last write has not been released yet.
|
||||||
|
unsaved = root / "cfr-unsaved.bin"
|
||||||
|
fresh = pattern(600_001, "cfr-unsaved")
|
||||||
|
writer = os.open(unsaved, os.O_CREAT | os.O_RDWR | os.O_TRUNC, 0o600)
|
||||||
|
try:
|
||||||
|
write_all(writer, fresh)
|
||||||
|
moved = copy("unsaved", unsaved, root / "cfr-from-unsaved.bin", len(fresh))
|
||||||
|
finally:
|
||||||
|
os.close(writer)
|
||||||
|
if moved is not None:
|
||||||
|
check(moved == len(fresh), f"unsaved: copy_file_range moved {moved} bytes, expected {len(fresh)}")
|
||||||
|
expected[root / "cfr-from-unsaved.bin"] = fresh
|
||||||
|
|
||||||
|
def verify(when: str) -> None:
|
||||||
|
for path, data in expected.items():
|
||||||
|
size = os.lstat(path).st_size
|
||||||
|
check(size == len(data), f"{path.name} {when}: {size} bytes, expected {len(data)}")
|
||||||
|
check_bytes(read(path), data, f"{path.name} {when}")
|
||||||
|
|
||||||
|
verify("right after the copy")
|
||||||
|
if not ctx.is_live or ctx.daemon is None:
|
||||||
|
return
|
||||||
|
ctx.daemon.wait_for_queue()
|
||||||
|
verify("after the upload")
|
||||||
|
# A server-side copy has a new uid, so nothing of it is cached: reading
|
||||||
|
# it reads what Drive holds.
|
||||||
|
if _my_files_root(ctx) is None:
|
||||||
|
return
|
||||||
|
landed = root / "cfr-landed"
|
||||||
|
landed.mkdir()
|
||||||
|
ctx.daemon.wait_for_queue()
|
||||||
|
for path in expected:
|
||||||
|
_pdfs_copy(ctx.daemon, path, landed)
|
||||||
|
for path, data in expected.items():
|
||||||
|
check_bytes(read(landed / path.name), data, f"{path.name} as Drive holds it")
|
||||||
|
|
||||||
|
|
||||||
|
def _my_files_root(ctx: Context) -> Path | None:
|
||||||
|
"""The My files mount the case runs in, or None anywhere else.
|
||||||
|
|
||||||
|
`pdfs copy`, `pdfs du` and public links address a path inside My files;
|
||||||
|
synced folders have no path of their own there.
|
||||||
|
"""
|
||||||
|
if ctx.daemon is None:
|
||||||
|
return None
|
||||||
|
mountpoint = (ctx.daemon.status().get("mount") or {}).get("mountpoint")
|
||||||
|
if not mountpoint:
|
||||||
|
return None
|
||||||
|
mountpoint = Path(mountpoint).resolve()
|
||||||
|
return mountpoint if mountpoint in ctx.root.resolve().parents else None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_my_files(ctx: Context) -> Daemon:
|
||||||
|
daemon = ctx.require_daemon()
|
||||||
|
if _my_files_root(ctx) is None:
|
||||||
|
raise Skip("needs a root inside My files")
|
||||||
|
return daemon
|
||||||
|
|
||||||
|
|
||||||
|
COPIED_TO = re.compile(r"^copied to (.+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _pdfs_copy(daemon: Daemon, source: Path, dest: Path) -> str:
|
||||||
|
"""`pdfs copy`, returning the name the copy got."""
|
||||||
|
message = daemon.command("copy", str(source), str(dest)).strip()
|
||||||
|
match = COPIED_TO.match(message)
|
||||||
|
check(match is not None, f"pdfs copy {source.name}: unexpected reply {message!r}")
|
||||||
|
return match[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _pdfs_copy_refused(daemon: Daemon, source: Path, dest: Path) -> str | None:
|
||||||
|
"""`pdfs copy` expected to be refused for changes Drive lacks: the refusal,
|
||||||
|
or None if the copy went ahead (and returned its name in the log)."""
|
||||||
|
try:
|
||||||
|
daemon.command("copy", str(source), str(dest))
|
||||||
|
except RuntimeError as error:
|
||||||
|
detail = str(error)
|
||||||
|
check("have not reached Proton Drive" in detail, f"pdfs copy {source.name}: unexpected refusal: {detail}")
|
||||||
|
return detail
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_copy(ctx: Context) -> None:
|
||||||
|
"""`pdfs copy` makes an exact copy on Drive and uploads nothing.
|
||||||
|
|
||||||
|
A tree and a lone file are copied; the file into its own folder, where
|
||||||
|
its name is taken and the copy gets the next free one. The copy is read
|
||||||
|
back as listed and again after its listing is dropped, and nothing of it
|
||||||
|
may wait in the queue: Drive made it, so there is nothing to send.
|
||||||
|
"""
|
||||||
|
daemon = _require_my_files(ctx)
|
||||||
|
root = ctx.root
|
||||||
|
name = f"tree-{uuid.uuid4().hex[:8]}"
|
||||||
|
files = _move_tree(root, name)
|
||||||
|
dest = root / "copies"
|
||||||
|
dest.mkdir()
|
||||||
|
daemon.wait_for_queue()
|
||||||
|
|
||||||
|
check(_pdfs_copy(daemon, root / name, dest) == name, f"the copy of {name} was renamed")
|
||||||
|
note = root / name / "note.txt"
|
||||||
|
twin = _pdfs_copy(daemon, note, note.parent)
|
||||||
|
check(twin != note.name, f"the copy of {note.name} beside it kept the name {twin!r}")
|
||||||
|
check((note.parent / twin).is_file(), f"{twin} is not listed beside {note.name}")
|
||||||
|
queued = [
|
||||||
|
item
|
||||||
|
for item in daemon.queue()
|
||||||
|
if root.name in (parts := Path(item.get("path", "")).parts) and ("copies" in parts or twin in parts)
|
||||||
|
]
|
||||||
|
check(not queued, f"a server copy queued work: {_describe_uploads(queued)}")
|
||||||
|
|
||||||
|
def verify(when: str) -> None:
|
||||||
|
for relative, data in files.items():
|
||||||
|
size = os.lstat(dest / relative).st_size
|
||||||
|
check(size == len(data), f"{relative} {when}: {size} bytes, expected {len(data)}")
|
||||||
|
check_bytes(read(dest / relative), data, f"{relative} {when}")
|
||||||
|
check((dest / name / "empty-dir").is_dir(), f"the empty folder is missing {when}")
|
||||||
|
check_bytes(read(note.parent / twin), files[Path(name) / "note.txt"], f"{twin} {when}")
|
||||||
|
|
||||||
|
verify("right after the copy")
|
||||||
|
folders = sorted({dest / relative.parent for relative in files} | {dest / name, note.parent})
|
||||||
|
for folder in folders:
|
||||||
|
daemon.command("refresh", str(folder))
|
||||||
|
verify("after dropping the cached listing")
|
||||||
|
_unchanged_at(root, files, "after copying it")
|
||||||
|
|
||||||
|
|
||||||
|
def _unchanged_at(root: Path, files: dict[Path, bytes], what: str) -> None:
|
||||||
|
for relative, data in files.items():
|
||||||
|
check_bytes(read(root / relative), data, f"{relative} {what}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_copy_never_copies_an_old_version(ctx: Context) -> None:
|
||||||
|
"""`pdfs copy` of a file being written is refused, and never copies the
|
||||||
|
version before the last save (B256).
|
||||||
|
|
||||||
|
Drive copies what it holds. A file open with unsaved writes, or saved but
|
||||||
|
not yet uploaded, holds more here than there: the copy has to wait.
|
||||||
|
"""
|
||||||
|
daemon = _require_my_files(ctx)
|
||||||
|
root = ctx.root
|
||||||
|
path = root / "being-written.txt"
|
||||||
|
write_durable(path, b"first version")
|
||||||
|
dest = root / "copies"
|
||||||
|
dest.mkdir()
|
||||||
|
daemon.wait_for_queue()
|
||||||
|
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_TRUNC)
|
||||||
|
try:
|
||||||
|
write_all(fd, b"second version, still open")
|
||||||
|
check(
|
||||||
|
_pdfs_copy_refused(daemon, path, dest) is not None,
|
||||||
|
"pdfs copy went ahead while the file had unsaved writes",
|
||||||
|
)
|
||||||
|
check(not (dest / path.name).exists(), "a refused copy left a file behind")
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
# Right after the close: refused while the upload is owed, then the new
|
||||||
|
# bytes. Never the first version.
|
||||||
|
saved = b"third version, saved"
|
||||||
|
write_durable(path, saved)
|
||||||
|
deadline = time.monotonic() + daemon.timeout
|
||||||
|
refusals = 0
|
||||||
|
while _pdfs_copy_refused(daemon, path, dest) is not None:
|
||||||
|
refusals += 1
|
||||||
|
check(time.monotonic() < deadline, f"pdfs copy was still refused after {daemon.timeout}s")
|
||||||
|
time.sleep(1)
|
||||||
|
ctx.note("refusals before the upload landed", refusals)
|
||||||
|
check_bytes(read(dest / path.name), saved, "the copy made right after a save")
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_size(ctx: Context) -> None:
|
||||||
|
"""`pdfs du` counts exactly what is below a folder, trash included.
|
||||||
|
|
||||||
|
Drive adds the size up from encrypted bytes, so it is at least the sum of
|
||||||
|
the files. A file moved to the trash still counts, as `pdfs du` says.
|
||||||
|
"""
|
||||||
|
daemon = _require_my_files(ctx)
|
||||||
|
root = ctx.root
|
||||||
|
name = f"sized-{uuid.uuid4().hex[:8]}"
|
||||||
|
files = _move_tree(root, name)
|
||||||
|
folder = root / name
|
||||||
|
items = sum(1 for _ in folder.rglob("*"))
|
||||||
|
daemon.wait_for_queue()
|
||||||
|
|
||||||
|
def size() -> dict:
|
||||||
|
return json.loads(daemon.command("du", str(folder), json_output=True))
|
||||||
|
|
||||||
|
# Drive may add a new tree up a moment after it lands.
|
||||||
|
deadline = time.monotonic() + daemon.timeout
|
||||||
|
while (answer := size())["items"] != items and time.monotonic() < deadline:
|
||||||
|
time.sleep(2)
|
||||||
|
check(answer["items"] == items, f"pdfs du counts {answer['items']} items, expected {items}")
|
||||||
|
total = sum(len(data) for data in files.values())
|
||||||
|
check(answer["size"] >= total, f"pdfs du reports {answer['size']} bytes for {total} bytes of files")
|
||||||
|
ctx.info(f"{answer['size']} bytes on Drive for {total} bytes in {items} items")
|
||||||
|
|
||||||
|
os.unlink(folder / "note.txt")
|
||||||
|
daemon.wait_for_queue()
|
||||||
|
check(size()["items"] == items, "a file moved to the trash stopped counting in pdfs du")
|
||||||
|
|
||||||
|
|
||||||
|
def _public_link(daemon: Daemon, path: Path) -> dict | None:
|
||||||
|
return json.loads(daemon.command("members", str(path), json_output=True)).get("link")
|
||||||
|
|
||||||
|
|
||||||
|
def _link_token(url: str | None) -> str:
|
||||||
|
"""The part of a public link's URL that names the link, without the
|
||||||
|
password fragment."""
|
||||||
|
check(bool(url), "the link has no URL")
|
||||||
|
return url.split("#", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_link_update(ctx: Context) -> None:
|
||||||
|
"""`pdfs public-link update` changes a link and keeps its URL.
|
||||||
|
|
||||||
|
Each option changed alone leaves the others as they were: a role change
|
||||||
|
keeps the password, removing the password keeps the expiry. A link id
|
||||||
|
that is not the folder's link is refused.
|
||||||
|
"""
|
||||||
|
daemon = _require_my_files(ctx)
|
||||||
|
folder = ctx.root / "linked"
|
||||||
|
folder.mkdir()
|
||||||
|
write_durable(folder / "inside.txt", b"behind a public link")
|
||||||
|
daemon.wait_for_queue()
|
||||||
|
|
||||||
|
password = f"pw-{uuid.uuid4().hex[:12]}"
|
||||||
|
created = json.loads(
|
||||||
|
daemon.command("public-link", "create", str(folder), "--password", password, json_output=True)
|
||||||
|
)["link"]
|
||||||
|
link_id, token = created["id"], _link_token(created["url"])
|
||||||
|
try:
|
||||||
|
check(created["has_password"], "the new link has no password")
|
||||||
|
|
||||||
|
def update(*args: str) -> dict:
|
||||||
|
link = json.loads(
|
||||||
|
daemon.command("public-link", "update", str(folder), link_id, *args, json_output=True)
|
||||||
|
)["link"]
|
||||||
|
check(link["id"] == link_id, f"the update replaced the link: {link['id']} for {link_id}")
|
||||||
|
if link.get("url"):
|
||||||
|
check(_link_token(link["url"]) == token, "the update changed the link's URL")
|
||||||
|
listed = _public_link(daemon, folder)
|
||||||
|
check(listed is not None and listed["id"] == link_id, f"the link is gone after the update: {listed}")
|
||||||
|
return listed
|
||||||
|
|
||||||
|
listed = update("--role", "editor")
|
||||||
|
check(listed["role"] == "editor", f"the role is {listed['role']} after changing it to editor")
|
||||||
|
check(listed["has_password"], "changing the role removed the password")
|
||||||
|
|
||||||
|
expires = int(time.time()) + 7 * 24 * 3600
|
||||||
|
listed = update("--no-password", "--expires", str(expires))
|
||||||
|
check(not listed["has_password"], "--no-password kept the password")
|
||||||
|
check(listed["expires"] == expires, f"the expiry is {listed['expires']}, expected {expires}")
|
||||||
|
check(listed["role"] == "editor", "removing the password changed the role")
|
||||||
|
|
||||||
|
listed = update("--no-expiry")
|
||||||
|
check(listed["expires"] is None, f"--no-expiry kept the expiry {listed['expires']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
daemon.command("public-link", "update", str(folder), f"not-{link_id}", "--role", "viewer")
|
||||||
|
except RuntimeError as error:
|
||||||
|
check("not found" in str(error), f"a wrong link id was refused for another reason: {error}")
|
||||||
|
else:
|
||||||
|
check(False, "an update with a wrong link id went ahead")
|
||||||
|
check(_public_link(daemon, folder)["role"] == "editor", "a refused update changed the link")
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
daemon.command("public-link", "remove", str(folder), link_id)
|
||||||
|
|
||||||
|
|
||||||
def test_namespace_and_errors(ctx: Context) -> None:
|
def test_namespace_and_errors(ctx: Context) -> None:
|
||||||
root = ctx.root
|
root = ctx.root
|
||||||
a, b = root / "dir-a", root / "dir-b"
|
a, b = root / "dir-a", root / "dir-b"
|
||||||
@@ -2625,6 +2974,7 @@ TESTS = [
|
|||||||
Case("positioned and vectored I/O", test_positioned_and_vectored_io),
|
Case("positioned and vectored I/O", test_positioned_and_vectored_io),
|
||||||
Case("truncate, growth, sparse ranges, and EOF", test_resize_and_sparse_io),
|
Case("truncate, growth, sparse ranges, and EOF", test_resize_and_sparse_io),
|
||||||
Case("mmap and zero-copy copy paths", test_mmap_and_copy_paths),
|
Case("mmap and zero-copy copy paths", test_mmap_and_copy_paths),
|
||||||
|
Case("copy_file_range across calls, at offsets, and from an open file", test_copy_file_range_ranges),
|
||||||
Case("namespace operations and POSIX errors", test_namespace_and_errors),
|
Case("namespace operations and POSIX errors", test_namespace_and_errors),
|
||||||
Case("open-handle lifetime across unlink and rename", test_open_lifetime),
|
Case("open-handle lifetime across unlink and rename", test_open_lifetime),
|
||||||
Case("names, lookup, stat, and enumeration", test_names_and_enumeration),
|
Case("names, lookup, stat, and enumeration", test_names_and_enumeration),
|
||||||
@@ -2670,6 +3020,10 @@ TESTS = [
|
|||||||
Case("regression B116: trashing a folder drops the creates queued in its subfolders", test_regression_b116_trashed_folder_drops_deeper_creates, (LIVE,)),
|
Case("regression B116: trashing a folder drops the creates queued in its subfolders", test_regression_b116_trashed_folder_drops_deeper_creates, (LIVE,)),
|
||||||
Case("regression B117: a moved file's queued upload keeps its path", test_regression_b117_moved_upload_keeps_its_path, (LIVE,)),
|
Case("regression B117: a moved file's queued upload keeps its path", test_regression_b117_moved_upload_keeps_its_path, (LIVE,)),
|
||||||
Case("regression B119: an uploaded file stays cached", test_regression_b119_uploaded_file_stays_cached, (LIVE,)),
|
Case("regression B119: an uploaded file stays cached", test_regression_b119_uploaded_file_stays_cached, (LIVE,)),
|
||||||
|
Case("pdfs copy: a copy on Drive reads back exactly and uploads nothing", test_server_copy, (LIVE,)),
|
||||||
|
Case("regression B256: pdfs copy never copies the version before a save", test_server_copy_never_copies_an_old_version, (LIVE,)),
|
||||||
|
Case("pdfs du counts what is below a folder, trash included", test_folder_size, (LIVE,)),
|
||||||
|
Case("pdfs public-link update keeps the URL and the options not changed", test_public_link_update, (LIVE,)),
|
||||||
Case("durability across a daemon restart", test_durability_across_restart, (LIVE,)),
|
Case("durability across a daemon restart", test_durability_across_restart, (LIVE,)),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2942,7 +3296,7 @@ def select_cases(kind: str, pool: list[Case] | None = None) -> list[Case]:
|
|||||||
# typo worth failing on.
|
# typo worth failing on.
|
||||||
for needle in needles:
|
for needle in needles:
|
||||||
check(
|
check(
|
||||||
any(needle in case.name.lower() for case in TESTS + MOVE_CASES),
|
any(needle in case.name.lower() for case in TESTS + MOVE_CASES + RECOVERY_CASES),
|
||||||
f"PDFS_ACCEPTANCE_ONLY: {needle!r} matched no tests",
|
f"PDFS_ACCEPTANCE_ONLY: {needle!r} matched no tests",
|
||||||
)
|
)
|
||||||
return [case for case in cases if any(needle in case.name.lower() for needle in needles)]
|
return [case for case in cases if any(needle in case.name.lower() for needle in needles)]
|
||||||
@@ -3894,7 +4248,7 @@ class ManagedSyncPair:
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"cannot find {self.pdfs!r}; set PDFS_ACCEPTANCE_PDFS to the CLI binary"
|
f"cannot find {self.pdfs!r}; set PDFS_ACCEPTANCE_PDFS to the CLI binary"
|
||||||
)
|
)
|
||||||
if self.paths[0] == self.paths[1]:
|
if len(set(self.paths)) != len(self.paths):
|
||||||
raise ValueError("managed live paths must be different directories")
|
raise ValueError("managed live paths must be different directories")
|
||||||
deadline = time.monotonic() + self.timeout
|
deadline = time.monotonic() + self.timeout
|
||||||
last_error = None
|
last_error = None
|
||||||
@@ -4537,6 +4891,138 @@ def run_move_contract(
|
|||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Getting a synced folder unstuck
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryContext:
|
||||||
|
"""One mirror folder of the run's own, for cases that reset or empty it.
|
||||||
|
|
||||||
|
The mode matrix needs its folders' sentinels intact, so these cases get a
|
||||||
|
folder nobody else checks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pair: ManagedSyncPair, daemon: Daemon) -> None:
|
||||||
|
self.pair = pair
|
||||||
|
self.daemon = daemon
|
||||||
|
self.root = pair.paths[0]
|
||||||
|
self.id = pair.ids[self.root]
|
||||||
|
self.infos: list[str] = []
|
||||||
|
|
||||||
|
def folder(self) -> dict:
|
||||||
|
return next(item for item in self.pair.folders() if Path(item["local_path"]).resolve() == self.root)
|
||||||
|
|
||||||
|
def tree(self) -> tuple[str, dict[Path, bytes]]:
|
||||||
|
"""A fresh tree in the folder, with one file named after it, so that a
|
||||||
|
trash entry of it cannot be the user's."""
|
||||||
|
name = f"kept-{uuid.uuid4().hex[:8]}"
|
||||||
|
files = _move_tree(self.root, name)
|
||||||
|
files[Path(name) / f"{name}.txt"] = b"named after its tree " + name.encode()
|
||||||
|
write_durable(self.root / name / f"{name}.txt", files[Path(name) / f"{name}.txt"])
|
||||||
|
return name, files
|
||||||
|
|
||||||
|
def trashed(self, names: set[str]) -> list[str]:
|
||||||
|
self.daemon.command("refresh", "trash")
|
||||||
|
return sorted(entry["name"] for entry in self.daemon.trash() if entry["name"] in names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_reset_changes_nothing(rc: RecoveryContext) -> None:
|
||||||
|
"""Sync From Scratch compares the folder with Drive again and changes
|
||||||
|
nothing that already matched: no deletes, no conflict copies.
|
||||||
|
|
||||||
|
`pdfs sync reset` forgets the folder's record of what it synced. The next
|
||||||
|
pass then sees every file on both sides with no history, which is the
|
||||||
|
case that must copy what is missing and leave matching files alone.
|
||||||
|
"""
|
||||||
|
name, files = rc.tree()
|
||||||
|
rc.pair.force_sync(rc.root)
|
||||||
|
rc.daemon.command("sync", "reset", str(rc.id))
|
||||||
|
# Added after the reset, so the pass after it has to upload it.
|
||||||
|
later = Path(name) / "added-after-the-reset.txt"
|
||||||
|
files[later] = b"only here when the pass starts " + name.encode()
|
||||||
|
write_durable(rc.root / later, files[later])
|
||||||
|
rc.pair.force_sync(rc.root)
|
||||||
|
rc.pair.force_sync(rc.root)
|
||||||
|
|
||||||
|
_unchanged_at(rc.root, files, "after Sync From Scratch")
|
||||||
|
copies = [str(path) for path in rc.root.rglob("*") if "(sync-conflict" in path.name]
|
||||||
|
check(not copies, f"Sync From Scratch made conflict copies: {copies}")
|
||||||
|
trashed = rc.trashed({name, f"{name}.txt"})
|
||||||
|
check(not trashed, f"Sync From Scratch trashed {trashed}")
|
||||||
|
state = rc.folder()
|
||||||
|
check(state["state"] == "idle", f"the folder is {state['state']} after Sync From Scratch")
|
||||||
|
|
||||||
|
|
||||||
|
def test_emptied_folder_switches_to_ondemand(rc: RecoveryContext) -> None:
|
||||||
|
"""A mirror folder whose local files are all gone switches to online only,
|
||||||
|
and Drive keeps every file (B255, issue #46).
|
||||||
|
|
||||||
|
The wipe guard rightly refuses to pass the local loss on to Drive, but it
|
||||||
|
also blocked the push pass a queued switch runs first, so the switch never
|
||||||
|
landed and the folder stayed in error. Through the mount afterwards, every
|
||||||
|
file has to read back as it was.
|
||||||
|
"""
|
||||||
|
names: set[str] = set()
|
||||||
|
files: dict[Path, bytes] = {}
|
||||||
|
for _ in range(2):
|
||||||
|
name, tree = rc.tree()
|
||||||
|
names |= {name, f"{name}.txt"}
|
||||||
|
files.update(tree)
|
||||||
|
rc.pair.force_sync(rc.root)
|
||||||
|
for child in list(rc.root.iterdir()):
|
||||||
|
if child.is_dir() and not child.is_symlink():
|
||||||
|
shutil.rmtree(child)
|
||||||
|
else:
|
||||||
|
child.unlink()
|
||||||
|
rc.daemon.command("sync", "mode", str(rc.id), "ondemand")
|
||||||
|
|
||||||
|
# The folder may pass through error while the guard holds the deletions
|
||||||
|
# back; the switch has to land anyway.
|
||||||
|
deadline = time.monotonic() + rc.pair.timeout
|
||||||
|
while True:
|
||||||
|
state = rc.folder()
|
||||||
|
if state["mode"] == "ondemand" and state.get("pending_mode") is None and is_mountpoint(rc.root):
|
||||||
|
break
|
||||||
|
check(time.monotonic() < deadline, f"the switch to on-demand did not land: {state}")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
for relative, data in files.items():
|
||||||
|
data_read = rc.pair.through_outage(lambda relative=relative: read(rc.root / relative), f"reading {relative}", rc.infos)
|
||||||
|
check_bytes(data_read, data, f"{relative} on Drive after the local copy was gone")
|
||||||
|
sentinel = rc.root / "pdfs-mode-preservation.bin"
|
||||||
|
check_bytes(read(sentinel), rc.pair.sentinels[rc.root], f"{sentinel.name} on Drive")
|
||||||
|
trashed = rc.trashed(names)
|
||||||
|
check(not trashed, f"the switch trashed {trashed} on Drive")
|
||||||
|
|
||||||
|
|
||||||
|
RECOVERY_CASES = [
|
||||||
|
Case("Sync From Scratch changes nothing that already matched", test_sync_reset_changes_nothing, (LIVE,)),
|
||||||
|
Case("regression B255: an emptied mirror folder switches to on-demand", test_emptied_folder_switches_to_ondemand, (LIVE,)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync_recovery(janitor: Janitor, args) -> None:
|
||||||
|
"""The recovery cases, in a sync folder of their own."""
|
||||||
|
cases = select_cases(LIVE, RECOVERY_CASES)
|
||||||
|
if not cases:
|
||||||
|
return
|
||||||
|
timeout = int(os.environ.get("PDFS_ACCEPTANCE_SYNC_TIMEOUT", "180"))
|
||||||
|
path = janitor.new_sync_folder()
|
||||||
|
pair = ManagedSyncPair([path], timeout)
|
||||||
|
daemon = Daemon.discover(timeout)
|
||||||
|
check(daemon is not None, "the daemon stopped answering")
|
||||||
|
run = Run("sync folder recovery", LIVE, path)
|
||||||
|
REPORT.append(run)
|
||||||
|
announce(run, {"folder": f"mirror, {path}", "cases": describe_cases(LIVE, cases, RECOVERY_CASES)})
|
||||||
|
started = time.monotonic()
|
||||||
|
pair.create()
|
||||||
|
context = RecoveryContext(pair, daemon)
|
||||||
|
run_cases(run, cases, lambda _case: context, max(args.timeout, 8 * timeout), args.fail_fast)
|
||||||
|
run.wall = time.monotonic() - started
|
||||||
|
CONSOLE.fact("result", tally(run.counts(), run.wall))
|
||||||
|
|
||||||
|
|
||||||
def is_mountpoint(path: Path) -> bool:
|
def is_mountpoint(path: Path) -> bool:
|
||||||
"""True for a distinct mount, including FUSE mounts over an existing dir."""
|
"""True for a distinct mount, including FUSE mounts over an existing dir."""
|
||||||
return os.path.ismount(path)
|
return os.path.ismount(path)
|
||||||
@@ -4694,6 +5180,8 @@ def main() -> int:
|
|||||||
print(f"{','.join(case.kinds):<18} {case.name}")
|
print(f"{','.join(case.kinds):<18} {case.name}")
|
||||||
for case in MOVE_CASES:
|
for case in MOVE_CASES:
|
||||||
print(f"{'managed':<18} {case.name}")
|
print(f"{'managed':<18} {case.name}")
|
||||||
|
for case in RECOVERY_CASES:
|
||||||
|
print(f"{'account':<18} {case.name}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
install_interrupt_handlers()
|
install_interrupt_handlers()
|
||||||
@@ -4814,6 +5302,7 @@ def run_account(reference: Run, args) -> None:
|
|||||||
)
|
)
|
||||||
paths = [janitor.new_sync_folder(), janitor.new_sync_folder()]
|
paths = [janitor.new_sync_folder(), janitor.new_sync_folder()]
|
||||||
run_managed_matrix(paths, reference, args, janitor)
|
run_managed_matrix(paths, reference, args, janitor)
|
||||||
|
run_sync_recovery(janitor, args)
|
||||||
finally:
|
finally:
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
leftovers = janitor.cleanup()
|
leftovers = janitor.cleanup()
|
||||||
|
|||||||
Reference in new issue
Block a user