Compare commits

...
Author SHA1 Message Date
nvrl b860ac3b49 test(fuse): start the size batch clock before the worker
The worker thread's sleeps could begin before the test read the clock, so the elapsed time came out a few milliseconds under the 90 ms bound on a busy runner. Reading the clock first makes the bound hold.
2026-10-27 01:02:29 +01:00
nvrl 6f504d2e83 Merge pull request #48 from narrrl/test/acceptance-new-features
3.6.0: acceptance cases for the new features, --json for members/du/public-link
2026-10-27 00:44:30 +01:00
nvrl 8c1c37a928 test(acceptance): cover copy, folder size, link updates and sync recovery
New cases for what 3.6.0 adds:

- copy_file_range over several calls, into the middle of a file, past
  its end, at the source's end, and from a file whose writes are not
  released yet; on a live mount also read back from Drive.
- pdfs copy of a tree and of a file beside itself, read back as listed
  and after a refresh, with nothing queued; refused while a file has
  writes Drive lacks, and never the version before a save (B256).
- pdfs du counts every item below a folder, trash included.
- pdfs public-link update keeps the URL and the options it was not
  asked to change, and refuses a link id that is not the folder's.
- In a sync folder of their own: Sync From Scratch changes nothing that
  already matched, and an emptied mirror folder still switches to
  on-demand with every file intact on Drive (B255).

docs/TESTING.md gains a checklist for the new screens in the app. The
CHANGELOG moves these into 3.6.0, which is not tagged yet.
2026-10-27 00:42:44 +01:00
nvrl e4d2340bbf feat(cli): --json for members, du and public-link create/update
Scripts, the acceptance suite among them, can now read a link's id,
role, password flag and expiry, and a folder's size and item count,
instead of parsing the human-readable lines.
2026-10-27 00:42:44 +01:00
nvrl c1b72e455e test(fuse): time the short-outage reconnect from the mount coming back
The probe that sees the link return runs 2 s after the drop, then 6 s.
A loaded run could turn the link back on after the first probe, and the
next one came after the test's 3 s were up. The test now checks that the
drain takes the mount offline within 2 s, which is the B189 fix itself,
and counts the 3 s from the mount reporting it is back online.
2026-10-27 00:32:49 +01:00
nvrl 2e3b7748e6 Merge pull request #47 from narrrl/feat/server-copy
Release 3.6.0: server copy, link settings, folder size, sync fixes (#46)
2026-10-26 22:47:47 +01:00
nvrl 7f4f11ef18 chore(release): 3.6.0 2026-10-26 22:45:36 +01:00
nvrl cc9f7c6d8a Merge remote-tracking branch 'origin/main' into feat/server-copy
# Conflicts:
#	Cargo.lock
#	Cargo.toml
2026-10-26 22:41:40 +01:00
nvrl 9add185c1f Merge pull request #43 from narrrl/feat/diagnose-keys
feat(cli,core): trace the keys that open My Files in pdfs diagnose
2026-10-26 22:35:35 +01:00
nvrl 0e7ffe1261 fix(gui,cli): count what a folder's first scan has checked (#46)
The first pass over a synced folder has no baseline to estimate from, so
its scan showed only "checking for changes…" in the app and "scanning…"
in `pdfs sync list`. For a large folder that scan reads every item on
Proton Drive before anything downloads, which takes many minutes and
looked stuck. Both now show how many items the scan has checked so far.
2026-10-26 22:28:46 +01:00
nvrl a5acae41b3 fix(sync,gui,cli): get a synced folder unstuck when its local copy is gone (#46)
A mirror folder whose files were all gone from this computer stayed in
error for good. The wipe guard rightly refuses to trash the whole folder
on Drive, but nothing got the folder out of that state: a queued switch
to on-demand waits for a pass that ends idle, and its push pass hit the
same guard. Only removing the folder from sync cleared the baseline that
made the empty folder read as "delete everything".

- 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, so the
  switch goes ahead and the mount shows what Drive has.
- New Request::ResetSyncFolder, `pdfs sync reset ID` and "Sync From
  Scratch…" in a synced folder's menu clear a mirror folder's baseline
  (and a queued switch to on-demand, whose push pass would upload a
  duplicate of every file). The next pass compares both sides as on the
  first sync: one-sided files are copied across, files that differ keep
  both versions, nothing is deleted.
- The wipe guards' messages name the reset; restore logs why it skipped
  a folder.

Two simulations cover it. The on-demand one failed in exactly the
reported state (mirror, pending ondemand, error) before the fix.
All 8 catalogs translated.
2026-10-26 17:45:22 +01:00
nvrl ea10a43216 fix(fuse): a copy right after a save waits for the edit (B256)
close(2) returns before the kernel sends release, and release takes the
write handle out of the open writes a few milliseconds before the
revision is in the queue. A `pdfs copy` checked in that gap found the
edit nowhere and copied the version Drive had. A folder copy looked only
at the queue, so it also missed files below it still open with unsaved
writes.

release now 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, and a move checks the same
set.
2026-10-26 17:45:14 +01:00
nvrl 917e7268f9 build(deps): proton-sdk and proton-drive-rs 0.10.0
0.10.0 adds copy_node, get_folder_size, update_public_link and a link's custom password.
2026-10-26 17:03:33 +01:00
nvrl 9089c8d13c feat(fuse): answer copy_file_range from bytes already on this machine
A copy inside the mount went through the kernel's read/write loop, one
FUSE round trip per page, because copy_file_range answered ENOSYS. The
daemon now copies a range whose source bytes are local (an open write
handle, a queued write, the cache) into the destination's scratch file,
the same way write stages them. A range it would have to download gets
EOPNOTSUPP, which makes the kernel copy that one call itself and keeps
the operation enabled. The copy still uploads: Drive cannot copy into a
file cp has already created.

In the simulation, a warm cp of a 256 MiB file went from about
100 MiB/s to about 450 MiB/s (debug build).
2026-10-26 16:57:06 +01:00
nvrl 071a11ef65 Merge remote-tracking branch 'origin/main' into feat/diagnose-keys
# Conflicts:
#	docs/CHANGELOG.md
#	docs/TROUBLESHOOTING.md
2026-10-26 16:45:36 +01:00
nvrl 4edcff3f87 feat(sharing,gui,cli): change public links and show folder sizes
The share dialog gets Link settings under an existing link: role, password
and expiry apply at once and keep the URL. The daemon fills in the options a
change keeps from the link as it stands, and refuses to keep a password it
cannot read rather than dropping it. pdfs public-link update does the same
from a terminal, and pdfs members shows the expiry.

Details shows a folder's size and item count as Proton Drive counts it,
trash included, and pdfs du prints it. Both are refused offline and for a
folder that is not on Drive yet.

Needs proton-drive-rs with update_public_link, get_folder_size and
PublicLink::custom_password.
2026-10-25 22:18:11 +01:00
nvrl ee59e338b6 feat(gui): make a copy and copy to another folder in My files
The entry menu gains Make a Copy, which copies an item next to itself, and
Copy To…, which reuses the move folder picker limited to My files; Copy
To… is also in the selection menu. Both send Request::Copy, so the copy is
made on Proton Drive.

The move and copy reports share one request loop. Docs describe the new
actions, and the changelog notes that saved public links now show their
names once the SDK decrypts them.
2026-10-25 21:41:21 +01:00
nvrl c1d9538669 feat(cli,fuse,core): copy files and folders on Proton Drive
`pdfs copy PATH... NEW_PARENT` sends a new control request, Copy, which
copies a file with its content or a folder with its subtree on Proton
Drive through proton-drive-rs's copy_node. Nothing is downloaded or
uploaded. A name already taken in the destination gets the next free
" (n)" suffix through get_available_name.

A copy cannot wait in the queue, because Drive copies what it holds.
So it is refused offline, and while the mount still owes Drive part of
the node: a local placeholder, a queued op, unsaved writes, or, for a
folder, any queued op at all. Local-first does not change this.

DriveApi gains copy_node and get_available_name, and the fake drive
copies subtrees, so a FUSE sim test covers the copy, the suffix, the
folder subtree and both refusals. The activity feed logs a new Copy
kind, translated in every catalog.

Needs the proton-drive-rs release that adds copy_node; until then, build
with a [patch.crates-io] pointing at proton-sdk-rs.
2026-10-25 21:10:50 +01:00
nvrl a73ed34421 Merge pull request #45 from narrrl/docs/wiki
docs: publish docs/ to the GitHub wiki and reorganise the user guide
2026-10-17 14:06:46 +02:00
nvrl e065586d7e build(deps): proton-sdk and proton-drive-rs 0.9.2
0.9.2 adds the key report pdfs diagnose uses.
2026-10-17 12:23:19 +02:00
nvrl fd80c34152 feat(cli,core): trace the keys that open My Files in pdfs diagnose
When the session is stored and unlocked but the daemon does not answer,
pdfs diagnose asks Proton for a key report and prints a Keys section:
each account key, each address key and the account key its token is
for, and the address key the My Files share is encrypted to. It holds
key IDs only, so it can be posted in an issue.

Refs #40
2026-10-17 12:13:27 +02:00
50 changed files with 7831 additions and 3529 deletions

No files matched your search

Generated
+8 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+107 -2
View File
@@ -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"}}"#;
+294
View File
@@ -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");
}
}
+1
View File
@@ -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;
+76
View File
@@ -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(()) => {
+44
View File
@@ -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).
+36 -2
View File
@@ -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>,
+109 -64
View File
@@ -430,58 +430,10 @@ 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)]
@@ -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
View File
@@ -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(&copy_name)),
)
.map_err(|e| CoreError::from_api(&e, "copy"))?;
let node = self
.fetch_minted_node(&new_uid, &copy_name)
.ok_or_else(|| minted_but_unlisted(&copy_name))?;
let mut st = self.state();
let copy_ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
&& !kids.contains(&copy_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));
+4
View File
@@ -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(", ")));
+86
View File
@@ -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.
/// ///
+2 -1
View File
@@ -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)
+85 -1
View File
@@ -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)?;
+344
View File
@@ -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);
}
} }
+90 -2
View File
@@ -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(
&copy,
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, &copy, 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>,
+6
View File
@@ -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(),
+34 -2
View File
@@ -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}/")))
+4 -2
View File
@@ -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()
)); ));
} }
+5 -5
View File
@@ -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.
+200 -46
View File
@@ -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; path,
for path in sources { new_parent: target.clone(),
let rx = spawn_request( })
socket.clone(), .await;
Request::Move {
path,
new_parent: target.clone(),
},
);
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");
+42 -3
View File
@@ -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”"));
+51 -1
View File
@@ -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) {
+50 -7
View File
@@ -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");
+233 -19
View File
@@ -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");
+63
View File
@@ -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`).
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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')
+1 -1
View File
@@ -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:.}
+540 -370
View File
File diff suppressed because it is too large. Load diff
+538 -370
View File
File diff suppressed because it is too large. Load diff
+539 -370
View File
File diff suppressed because it is too large. Load diff
+540 -370
View File
File diff suppressed because it is too large. Load diff
+540 -370
View File
File diff suppressed because it is too large. Load diff
+540 -370
View File
File diff suppressed because it is too large. Load diff
+542 -378
View File
File diff suppressed because it is too large. Load diff
+545 -370
View File
File diff suppressed because it is too large. Load diff
+539 -370
View File
File diff suppressed because it is too large. Load diff
+491 -2
View File
@@ -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()