Compare commits

...
17 Commits
Author SHA1 Message Date
nvrl 77198ed261 version bumped to 1.2 2026-07-29 21:36:24 +02:00
nvrl 0d79c46729 version bumped to 1.2 2026-07-29 21:30:10 +02:00
nvrl a8210ce8fd implemented shared with me + verification 2026-07-29 21:29:07 +02:00
nvrl 551e14b644 implemented shared id permissions 2026-07-29 19:26:57 +02:00
nvrl 7de667b386 implemented primary mount shared with me 2026-07-29 17:04:42 +02:00
nvrl 295bbeadac implemented share_access, POSIX read-only mode 2026-07-29 14:57:56 +02:00
nvrlandClaude Opus 5 32817870a5 chore(packaging): bump PKGBUILD pkgver to 1.1.1
Version bump in b93e41e updated Cargo.toml only, so the release
workflow's tag/manifest/PKGBUILD agreement check failed on v1.1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEWKS8smdPfLMZacgDXjAR
2026-07-27 03:25:56 +02:00
nvrl b93e41efc5 version bump to 1.1.1 2026-07-26 20:18:45 +02:00
nvrl 082ef8522a fixed refresh pull bug for trash 2026-07-26 20:17:59 +02:00
nvrl 2e95655244 Merge pull request #19 from narrrl/release/1.1.0
release 1.1.0
2026-07-26 19:03:00 +02:00
nvrl 942cb9f583 fixed concurrency bugs + performance 2026-07-26 19:01:48 +02:00
nvrl 8470662c54 fixed race conditions + extendet test suite 2026-07-26 15:56:58 +02:00
nvrl a091c2f34f implemented sweep mode and better conflict handling 2026-07-26 02:06:58 +02:00
nvrlandClaude Opus 5 bca0fe34bc fix(fuse,core): key conflict detection on revision id, stop transient-name seals (B69, B70)
B69 — spurious `(sync-conflict)` copies came from `(mtime, size)` being used as a
revision identity. The server re-stamps a sealed revision with its own commit
time, so the drain diverted a write into a conflict copy of its own base; and
`plan_file` classified a no-baseline (local, remote) pair as `Conflict`, forking
every pre-existing file on first reconcile.

- `Baseline` carries `revision_id` (from the SDK's new `active_revision_id`),
  captured at write-open and preserved across supersede/rebaseline.
  `revision_changed` trusts the id and only falls back to `(mtime, size)` for
  old sidecars.
- Planner gains `FilePlan::AdoptBaseline`: equal-size no-baseline pairs are
  recorded, not conflicted. Divergent sizes stay a real conflict.
- New `sweep.rs` (`pdfs-conflict-sweep`, 5-min cadence) removes conflict copies
  proven identical by size + `content_sha1` and surfaces divergent ones once as
  an `ActivityKind::Conflict` entry. Never removes what it cannot prove.

B70 — browsers downloading into an on-demand mount had their in-flight temps
sealed as canonical revisions, exiling the finished file to a conflict copy.
`syncignore::is_transient_name` now recognises download/scratch temps, and the
on-demand `release`/`rename` paths hold them (`set_create_hold`) instead of
sealing until the name is finalized.

Workspace deps bumped to proton-sdk/proton-drive-rs 0.2.2 (registry, no patch).

Live validation against a real account still pending for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8P42asymU158CPnhjRwri
2026-07-25 15:24:31 +02:00
nvrl 6f70eca78e Merge pull request #18 from narrrl/release/1.0.0
release/1.0.0
2026-07-22 15:18:00 +02:00
nvrl d5165b36a2 Merge pull request #17 from narrrl/feature/testing
Feature/testing
2026-07-22 14:29:39 +02:00
nvrl e9009dd47c Merge pull request #16 from narrrl/fix/prompt
Fix/prompt
2026-07-22 11:51:43 +02:00
42 changed files with 12889 additions and 1196 deletions

No files matched your search

Generated
+55 -55
View File
@@ -378,7 +378,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
"syn 3.0.3",
]
[[package]]
@@ -663,9 +663,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.1"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
@@ -747,9 +747,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -856,9 +856,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.2"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
dependencies = [
"clap_builder",
"clap_derive",
@@ -878,14 +878,14 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.1"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -1479,9 +1479,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.16.0"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "elliptic-curve"
@@ -1568,7 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2359,9 +2359,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.10.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
"atomic-waker",
"bytes",
@@ -2559,9 +2559,9 @@ dependencies = [
[[package]]
name = "ignore"
version = "0.4.30"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7"
checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83"
dependencies = [
"crossbeam-deque",
"globset",
@@ -2916,9 +2916,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.186"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libdbus-sys"
@@ -3203,7 +3203,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3518,10 +3518,10 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pdfs-cli"
version = "1.0.0"
version = "1.2.0"
dependencies = [
"anyhow",
"clap 4.6.2",
"clap 4.6.4",
"pdfs-core",
"pdfs-fuse",
"rpassword",
@@ -3533,7 +3533,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "1.0.0"
version = "1.2.0"
dependencies = [
"directories",
"ignore",
@@ -3553,7 +3553,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "1.0.0"
version = "1.2.0"
dependencies = [
"fuser",
"image",
@@ -3569,7 +3569,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "1.0.0"
version = "1.2.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3830,9 +3830,9 @@ dependencies = [
[[package]]
name = "proton-drive-rs"
version = "0.1.11"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a76197ffe7c9588c0bc1fa38a51e8aa8642b10e81ec16272d4962bd60525920"
checksum = "a02afd5a53ecbf0e1ea18f3102e7b99e5ad61977ee0709bd214279b2376e0077"
dependencies = [
"async-trait",
"base64",
@@ -3857,9 +3857,9 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.1.11"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "805df5ff2805e0e935ac7f5a237482231208a706d8c6ab3dd91157ae2e95c9f6"
checksum = "46b5f84f0c33ac7a19b0131e971e7a17f10f12d6dc6df64c3b9dda7f37aa35bb"
dependencies = [
"aes-gcm 0.11.0",
"async-trait",
@@ -4214,7 +4214,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
"syn 3.0.3",
]
[[package]]
@@ -4401,7 +4401,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4432,9 +4432,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
@@ -4458,7 +4458,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4599,14 +4599,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
"syn 3.0.3",
]
[[package]]
name = "serde_json"
version = "1.0.150"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -4623,7 +4623,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
"syn 3.0.3",
]
[[package]]
@@ -4784,18 +4784,18 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "snafu"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522"
checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7"
dependencies = [
"snafu-derive",
]
[[package]]
name = "snafu-derive"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda"
checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72"
dependencies = [
"heck",
"proc-macro2",
@@ -4898,9 +4898,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.0"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
@@ -4962,7 +4962,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5011,7 +5011,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
"syn 3.0.3",
]
[[package]]
@@ -5064,9 +5064,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.0"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
@@ -5101,9 +5101,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.18"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
@@ -5628,7 +5628,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6138,18 +6138,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.54"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.54"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
dependencies = [
"proc-macro2",
"quote",
+3 -3
View File
@@ -3,15 +3,15 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "1.0.0"
version = "1.2.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96"
[workspace.dependencies]
proton-sdk = { version = "0.1" }
proton-drive-rs = { version = "0.1" }
proton-sdk = { version = "0.3.1" }
proton-drive-rs = { version = "0.3.1" }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
+49 -3
View File
@@ -13,7 +13,7 @@ use pdfs_core::auth;
use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
ErrorKind, MountKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
};
use pdfs_core::db::Db;
@@ -28,7 +28,7 @@ struct Cli {
/// Emit machine-readable JSON instead of formatted text.
///
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
/// `devices list`, `transfers`, `activity`, `cache inspect`). Commands that
/// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that
/// perform an action keep their human output — a script that needs to know
/// whether one succeeded has the exit code.
#[arg(long, global = true)]
@@ -105,6 +105,8 @@ enum Command {
Logout,
/// Show account login state, and mount status if a daemon is running.
Status,
/// List every local Proton Drive location.
Locations,
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
Mount {
/// Mountpoint; defaults to ~/ProtonDrive.
@@ -507,6 +509,7 @@ fn main() -> Result<()> {
Command::Login { username } => cmd_login(username),
Command::Logout => cmd_logout(),
Command::Status => cmd_status(),
Command::Locations => cmd_locations(),
Command::Mount { mountpoint } => cmd_mount(mountpoint),
Command::Daemon { mountpoint } => cmd_daemon(mountpoint),
Command::Pin { path } => cmd_pin(path),
@@ -1002,6 +1005,7 @@ fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
Share => "share",
PublicLink => "link",
Unshare => "unshare",
Conflict => "conflict",
}
}
@@ -1200,6 +1204,45 @@ fn cmd_status() -> Result<()> {
Ok(())
}
fn cmd_locations() -> Result<()> {
let response = control_request(CtlRequest::ListLocations)?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Locations { items } if items.is_empty() => {
println!("No Proton Drive locations.");
}
CtlResponse::Locations { items } => {
for location in items {
let kind = match &location.kind {
MountKind::MyFiles => "My Files".to_string(),
MountKind::Device { sync_folder_id } => {
format!("Device folder {sync_folder_id}")
}
MountKind::Shared { .. } => "Shared folder".to_string(),
};
let mode = match location.pending_mode {
Some(pending) => format!("{} -> {pending}", location.mode),
None => location.mode.to_string(),
};
let mounted = if location.mounted {
"mounted"
} else {
"not mounted"
};
println!(
"{kind}: {} ({mode}, {}, {}, {mounted})",
location.local_path, location.access, location.state
);
}
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
mount_once(mountpoint)?;
Ok(())
@@ -1290,7 +1333,10 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
cache,
&control_socket,
db,
username,
pdfs_fuse::MountOptions {
username,
sweep_mode: config.resolved_conflict_sweep(),
},
);
// Clean unmount check (as a best-effort final backup).
+108
View File
@@ -0,0 +1,108 @@
//! Effective access carried by mounted Drive nodes.
use proton_drive_rs::MemberRole;
/// Effective write access for a node in a mounted tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
Owner,
Editor,
Viewer,
Unknown,
}
impl Access {
pub fn writable(self) -> bool {
matches!(self, Self::Owner | Self::Editor)
}
pub(crate) fn as_db_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Editor => "editor",
Self::Viewer => "viewer",
Self::Unknown => "unknown",
}
}
pub(crate) fn from_db_str(value: &str) -> Option<Self> {
match value {
"owner" => Some(Self::Owner),
"editor" => Some(Self::Editor),
"viewer" => Some(Self::Viewer),
"unknown" => Some(Self::Unknown),
_ => None,
}
}
}
/// Resolve a share root's effective access from its exact membership role.
///
/// Owned trees fail open. A share whose role is absent or unrecognised fails
/// closed. Descendants normally inherit directly at intern time; `Inherited`
/// exists for the explicit wire role and therefore returns `parent`.
pub fn access_for(role: Option<MemberRole>, parent: Access, under_share: bool) -> Access {
match role {
Some(MemberRole::Viewer) => Access::Viewer,
Some(MemberRole::Editor | MemberRole::Admin) => Access::Editor,
Some(MemberRole::Inherited) => parent,
None if under_share => Access::Viewer,
None => Access::Owner,
}
}
/// POSIX permission bits advertised for a mounted node.
pub fn perm_bits(is_dir: bool, access: Access) -> u16 {
match (is_dir, access.writable()) {
(true, true) => 0o755,
(false, true) => 0o644,
(true, false) => 0o555,
(false, false) => 0o444,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_for_covers_the_fail_open_and_fail_closed_table() {
for parent in [
Access::Owner,
Access::Editor,
Access::Viewer,
Access::Unknown,
] {
assert_eq!(access_for(None, parent, false), Access::Owner);
assert_eq!(access_for(None, parent, true), Access::Viewer);
assert_eq!(
access_for(Some(MemberRole::Viewer), parent, true),
Access::Viewer
);
assert_eq!(
access_for(Some(MemberRole::Editor), parent, true),
Access::Editor
);
assert_eq!(
access_for(Some(MemberRole::Admin), parent, true),
Access::Editor
);
assert_eq!(
access_for(Some(MemberRole::Inherited), parent, true),
parent
);
}
}
#[test]
fn permission_bits_follow_effective_access() {
for writable in [Access::Owner, Access::Editor] {
assert_eq!(perm_bits(true, writable), 0o755);
assert_eq!(perm_bits(false, writable), 0o644);
}
for read_only in [Access::Viewer, Access::Unknown] {
assert_eq!(perm_bits(true, read_only), 0o555);
assert_eq!(perm_bits(false, read_only), 0o444);
}
}
}
+291 -14
View File
@@ -17,6 +17,7 @@
//! [`Node`]: proton_drive_rs::Node
use std::collections::{BTreeMap, HashSet};
use std::fmt;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -28,10 +29,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::db::{CacheEntryInput, Db};
use crate::error::Result;
use crate::error::{Error, Result};
/// `cache_entries.kind` tag for whole-file content blobs.
const KIND_BLOB: &str = "blob";
static STAGING_N: AtomicU64 = AtomicU64::new(0);
/// `cache_entries.kind` tag for on-demand block-cache chunks.
const KIND_BLOCK: &str = "block";
/// `cache_entries.kind` tag for cached thumbnails.
@@ -150,15 +152,52 @@ pub struct StagedWrite {
pub based_on: Option<Baseline>,
}
/// The identity of a remote revision, as far as we can observe it: a file whose
/// size and mtime both still match the ones a queued change was made against has
/// not been rewritten by anyone else in the meantime.
/// The identity of a remote revision, as far as we can observe it.
///
/// `revision_id` is the authoritative identity when present: the server assigns a
/// fresh id to every sealed revision, so an unchanged id proves no one rewrote
/// the file — even when the server re-stamped its `mtime` on the *same* revision,
/// which is the drift that used to cut spurious conflict copies (B16/B25).
/// `mtime`/`size` remain the fallback for a baseline recorded before the id was
/// observable, or a node read through a surface that does not carry it.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Baseline {
pub mtime: i64,
pub size: u64,
#[serde(default)]
pub hash: Option<String>,
/// Server revision id this change was made against (`None` if unknown).
#[serde(default)]
pub revision_id: Option<String>,
}
/// A denied-write rescue that retained the bytes but could not fully publish
/// their recovery metadata.
#[derive(Debug)]
pub struct PreserveWriteError {
/// The actual path that still contains the accepted bytes.
pub surviving_path: PathBuf,
/// The recovery metadata that still describes those bytes, when one could
/// be made durable. Callers must never clear this after an error.
pub marker_path: Option<PathBuf>,
error: Error,
}
impl fmt::Display for PreserveWriteError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{} (bytes retained at {})",
self.error,
self.surviving_path.display()
)
}
}
impl std::error::Error for PreserveWriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.error)
}
}
/// Content cache rooted at a directory, with a sibling pin-registry file.
@@ -217,6 +256,18 @@ pub struct ContentCache {
}
impl ContentCache {
fn staging_path(&self, meta: &StagedWrite) -> PathBuf {
// The uid goes in the name so a staged file can be tied back to its node
// without a database; `/` in a uid would otherwise open a path.
let safe_uid = meta.uid.replace(['/', '\\'], "_");
self.staging_dir.join(format!(
"{}-{}-{}",
safe_uid,
now_millis(),
STAGING_N.fetch_add(1, Ordering::Relaxed)
))
}
/// Open (and create) a cache under `content_dir`, with the pin registry at
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
/// directories are created if missing. `db` is the shared metadata database
@@ -902,16 +953,7 @@ impl ContentCache {
pub fn stage_write(&self, meta: &StagedWrite, scratch: &Path) -> Result<PathBuf> {
use std::io::Write as _;
static N: AtomicU64 = AtomicU64::new(0);
// The uid goes in the name so a staged file can be tied back to its node
// without a database; `/` in a uid would otherwise open a path.
let safe_uid = meta.uid.replace(['/', '\\'], "_");
let path = self.staging_dir.join(format!(
"{}-{}-{}",
safe_uid,
now_millis(),
N.fetch_add(1, Ordering::Relaxed)
));
let path = self.staging_path(meta);
let copied = if let Err(e) = std::fs::rename(scratch, &path) {
if e.kind() != std::io::ErrorKind::CrossesDevices {
return Err(e.into());
@@ -941,6 +983,154 @@ impl ContentCache {
Ok(path)
}
/// Preserve accepted bytes which cannot be queued.
///
/// Scratch and staging normally share a filesystem, so the normal path is
/// an atomic rename. If that is impossible, the source first receives a
/// durable recovery marker and remains in place until a copied destination,
/// its sidecar, and the staging directory are all synced.
pub fn preserve_write(
&self,
meta: &StagedWrite,
scratch: &Path,
) -> std::result::Result<PathBuf, PreserveWriteError> {
let path = self.staging_path(meta);
self.preserve_write_at(meta, scratch, &path)
}
fn preserve_write_at(
&self,
meta: &StagedWrite,
scratch: &Path,
path: &Path,
) -> std::result::Result<PathBuf, PreserveWriteError> {
use std::io::Write as _;
let retained = |surviving_path: &Path, error: Error| {
let adjacent = Self::scratch_sidecar(surviving_path);
let source = Self::scratch_sidecar(scratch);
let marker_path = [adjacent, source].into_iter().find(|path| path.is_file());
PreserveWriteError {
surviving_path: surviving_path.to_path_buf(),
marker_path,
error,
}
};
// If rename cannot happen, this marker is what lets the next open move
// the still-authoritative source into recovery instead of deleting it.
let marker_result = self.mark_scratch_durable(scratch, meta);
let side = path.with_extension("json");
let side_tmp = path.with_extension("json.tmp");
let publish_sidecar = || -> Result<()> {
let mut side_file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&side_tmp)?;
side_file.write_all(&serde_json::to_vec_pretty(meta)?)?;
side_file.sync_all()?;
drop(side_file);
std::fs::rename(&side_tmp, &side)?;
std::fs::File::open(&self.staging_dir)?.sync_all()?;
Ok(())
};
match std::fs::rename(scratch, path) {
Ok(()) => {
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
if std::fs::rename(path, scratch).is_ok() {
if !Self::scratch_sidecar(scratch).is_file() {
let _ = self.mark_scratch_durable(scratch, meta);
}
let _ =
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
return Err(retained(scratch, error.into()));
}
return Err(retained(path, error.into()));
}
let scratch_side = Self::scratch_sidecar(scratch);
let side_result = if marker_result.is_ok() {
std::fs::rename(&scratch_side, &side)
.map_err(Error::from)
.and_then(|()| {
std::fs::File::open(&self.staging_dir)?
.sync_all()
.map_err(Error::from)
})
} else {
publish_sidecar()
};
if let Err(error) = side_result {
let _ = std::fs::remove_file(&side_tmp);
// Publication did not complete. Put the blob back beside
// the source marker so startup recovery still has a
// reconstructable pair, rather than an unexplained staging
// blob and a marker whose source vanished.
if std::fs::rename(path, scratch).is_ok() {
if !Self::scratch_sidecar(scratch).is_file() {
let _ = self.mark_scratch_durable(scratch, meta);
}
let _ =
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
return Err(retained(scratch, error));
}
return Err(retained(path, error));
}
Ok(path.to_path_buf())
}
Err(rename_error) => {
let source_marked =
marker_result.is_ok() || Self::scratch_sidecar(scratch).exists();
if let Err(marker_error) = marker_result {
tracing::warn!(
error = %marker_error,
path = %scratch.display(),
"could not mark rescue source durable before copy fallback"
);
}
if let Err(error) = std::fs::copy(scratch, path) {
if !source_marked {
let recovery = self.recovery_dir.join(
path.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("denied-write")),
);
if std::fs::rename(scratch, &recovery).is_ok() {
let _ = std::fs::File::open(&self.recovery_dir)
.and_then(|dir| dir.sync_all());
return Err(retained(
&recovery,
Error::Other(format!(
"rename failed ({rename_error}); copy fallback failed ({error})"
)),
));
}
}
return Err(retained(
scratch,
Error::Other(format!(
"rename failed ({rename_error}); copy fallback failed ({error})"
)),
));
}
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
let surviving = if source_marked { scratch } else { path };
return Err(retained(surviving, error.into()));
}
if let Err(error) = publish_sidecar() {
let _ = std::fs::remove_file(&side_tmp);
let surviving = if source_marked { scratch } else { path };
return Err(retained(surviving, error));
}
// Deliberately last: fallback never removes its marked source
// until destination publication is complete.
if let Err(error) = std::fs::remove_file(scratch) {
return Err(retained(path, error.into()));
}
self.clear_scratch_durable(scratch);
Ok(path.to_path_buf())
}
}
}
/// Drop a staged blob and its sidecar, once its bytes are safely on the
/// remote (or the op that owned them has been superseded). Best effort:
/// leftovers cost disk, while failing here would strand a drained op.
@@ -1873,6 +2063,7 @@ mod tests {
mtime: 100,
size: 40,
hash: None,
revision_id: None,
}),
};
let staged = c.stage_write(&meta, &scratch).unwrap();
@@ -1897,6 +2088,92 @@ mod tests {
);
}
fn preservation_meta() -> StagedWrite {
StagedWrite {
uid: uid("preserved").to_string(),
len: 14,
base_size: 0,
base_mtime: 100,
authored: vec![(0, 14)],
complete: true,
based_on: None,
}
}
#[test]
fn orphan_preservation_publishes_exact_bytes_then_removes_source() {
use std::io::Write as _;
let (c, _dir) = cache();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"accepted bytes").unwrap();
file.sync_all().unwrap();
drop(file);
let preserved = c.preserve_write(&preservation_meta(), &scratch).unwrap();
assert!(!scratch.exists());
assert_eq!(std::fs::read(&preserved).unwrap(), b"accepted bytes");
let sidecar: StagedWrite =
serde_json::from_slice(&std::fs::read(preserved.with_extension("json")).unwrap())
.unwrap();
let expected = preservation_meta();
assert_eq!(sidecar.uid, expected.uid);
assert_eq!(sidecar.len, expected.len);
assert_eq!(sidecar.authored, expected.authored);
assert_eq!(sidecar.complete, expected.complete);
}
#[test]
fn orphan_preservation_sidecar_failure_reports_surviving_blob_across_reopen() {
use std::io::Write as _;
let (c, dir) = cache();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"only surviving copy").unwrap();
file.sync_all().unwrap();
drop(file);
let destination = c.staging_dir.join("forced-preservation-failure");
let side = destination.with_extension("json");
std::fs::create_dir(&side).unwrap();
let error = c
.preserve_write_at(&preservation_meta(), &scratch, &destination)
.unwrap_err();
assert_eq!(error.surviving_path, scratch);
assert!(
scratch.exists(),
"failed sidecar publication must roll the blob back beside its marker"
);
assert_eq!(
std::fs::read(&error.surviving_path).unwrap(),
b"only surviving copy"
);
assert_eq!(
error.marker_path,
Some(ContentCache::scratch_sidecar(&scratch))
);
assert!(
side.is_dir(),
"the induced sidecar failure remains observable"
);
let content = dir.path().join("content");
let pins = dir.path().join("pins-reopen.json");
drop(c);
let reopened =
ContentCache::open(content, pins, 0, Arc::new(Db::open_in_memory().unwrap())).unwrap();
let recovered = reopened.recovered_writes();
assert_eq!(recovered.len(), 1);
assert_eq!(
std::fs::read(&recovered[0].0).unwrap(),
b"only surviving copy"
);
assert_eq!(recovered[0].1.uid, preservation_meta().uid);
drop(reopened);
}
/// A2: `fsync(2)` promises the bytes survive a crash, but queueing happens
/// at `release`. A scratch file marked durable must therefore outlive the
/// open that clears the scratch directory — and an unmarked one must not,
+130
View File
@@ -20,6 +20,59 @@ pub const KEYRING_SERVICE: &str = "proton-drive-linux";
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
/// Environment override for [`AppConfig::resolved_conflict_sweep`]. Exists so a
/// support case can disable the sweep without editing the config file or
/// reinstalling — it wins over the stored setting.
pub const CONFLICT_SWEEP_ENV: &str = "PDFS_CONFLICT_SWEEP";
/// What the background conflict sweep (`pdfs-fuse`'s `pdfs-conflict-sweep`
/// thread) is allowed to do about a `(sync-conflict …)` copy it believes is a
/// redundant duplicate.
///
/// Defaults to [`SweepMode::Report`]: the sweep's whole reason to exist is that
/// the *previous* revision-identity check was wrong (B69), so the replacement is
/// not trusted with deletion until it has a release cycle of field evidence
/// behind it. See `docs/BUGS.md` B71.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SweepMode {
/// Do not run the sweep at all — the thread is never spawned.
Off,
/// Run, and log what it *would* remove, but never trash anything. Divergent
/// and orphaned copies are still surfaced to the activity feed as usual.
#[default]
Report,
/// Trash copies proven identical to their live sibling.
Enforce,
}
impl SweepMode {
/// Parse an operator-supplied value ([`CONFLICT_SWEEP_ENV`] or a CLI flag).
/// `None` for anything unrecognised, so a typo falls back to the stored
/// setting rather than silently picking a mode the operator did not ask for.
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"off" | "no" | "false" | "0" | "disabled" => Some(Self::Off),
"report" | "dry-run" | "dryrun" | "observe" => Some(Self::Report),
"enforce" | "on" | "yes" | "true" | "1" => Some(Self::Enforce),
_ => None,
}
}
/// Whether this mode is allowed to trash a duplicate.
pub fn is_enforcing(self) -> bool {
matches!(self, Self::Enforce)
}
}
/// Precedence for [`AppConfig::resolved_conflict_sweep`], split out from the
/// environment read so it can be unit-tested without mutating process state.
fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode {
env.and_then(SweepMode::parse)
.or(stored)
.unwrap_or_default()
}
/// Configuration structure allowing the user to customize client identification headers.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AppConfig {
@@ -48,6 +101,11 @@ pub struct AppConfig {
/// by hostname". Defaulted for configs predating the field.
#[serde(default)]
pub device_uid: Option<String>,
/// What the background conflict sweep may do. `None` means "use
/// [`SweepMode::default`]" (report-only). Defaulted for configs predating
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
#[serde(default)]
pub conflict_sweep: Option<SweepMode>,
}
impl Default for AppConfig {
@@ -59,6 +117,7 @@ impl Default for AppConfig {
mountpoint: None,
ignore_patterns: None,
device_uid: None,
conflict_sweep: None,
}
}
}
@@ -70,6 +129,20 @@ impl AppConfig {
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
}
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
/// it parses, then the user's stored choice, then [`SweepMode::default`].
///
/// The environment wins so a support case can stop a destructive background
/// loop without editing config or reinstalling (`docs/BUGS.md` B71). An
/// unparseable value is ignored rather than treated as "off", so a typo
/// cannot silently change behaviour in either direction.
pub fn resolved_conflict_sweep(&self) -> SweepMode {
resolve_sweep_mode(
std::env::var(CONFLICT_SWEEP_ENV).ok().as_deref(),
self.conflict_sweep,
)
}
/// The effective global sync ignore patterns: the user's explicit list, or
/// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is
/// honoured as "no global patterns", which is why this is not a
@@ -374,6 +447,7 @@ mod tests {
mountpoint: Some("/tmp/x".to_string()),
ignore_patterns: Some(vec!["build/".to_string()]),
device_uid: Some("dev-uid".to_string()),
conflict_sweep: Some(SweepMode::Enforce),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
@@ -388,6 +462,62 @@ mod tests {
assert_eq!(default_config.user_agent, USER_AGENT);
}
#[test]
fn sweep_mode_defaults_to_report_only() {
// The sweep trashes user files, so an installed config that has never
// heard of the setting must not get the enforcing behaviour.
assert_eq!(SweepMode::default(), SweepMode::Report);
assert!(!SweepMode::default().is_enforcing());
assert_eq!(resolve_sweep_mode(None, None), SweepMode::Report);
}
#[test]
fn sweep_mode_parses_operator_spellings() {
for s in ["off", "OFF", " no ", "false", "0", "disabled"] {
assert_eq!(SweepMode::parse(s), Some(SweepMode::Off), "{s}");
}
for s in ["report", "dry-run", "dryrun", "observe"] {
assert_eq!(SweepMode::parse(s), Some(SweepMode::Report), "{s}");
}
for s in ["enforce", "on", "yes", "true", "1"] {
assert_eq!(SweepMode::parse(s), Some(SweepMode::Enforce), "{s}");
}
// A typo must not resolve to anything, so it falls through to the config.
assert_eq!(SweepMode::parse("offf"), None);
assert_eq!(SweepMode::parse(""), None);
}
#[test]
fn sweep_mode_env_overrides_config_but_a_typo_does_not() {
let stored = Some(SweepMode::Enforce);
// The point of the override: kill a destructive loop without editing config.
assert_eq!(resolve_sweep_mode(Some("off"), stored), SweepMode::Off);
// Unrecognised value falls back to the stored choice rather than guessing.
assert_eq!(resolve_sweep_mode(Some("nope"), stored), SweepMode::Enforce);
assert_eq!(resolve_sweep_mode(None, stored), SweepMode::Enforce);
// No env, no stored value: report-only.
assert_eq!(resolve_sweep_mode(Some("nope"), None), SweepMode::Report);
}
#[test]
fn a_config_predating_conflict_sweep_still_loads() {
let json = r#"{"app_version":"v","user_agent":"u"}"#;
let decoded: AppConfig = serde_json::from_str(json).unwrap();
assert!(decoded.conflict_sweep.is_none());
}
#[test]
fn sweep_mode_round_trips_through_the_config_file() {
let config = AppConfig {
conflict_sweep: Some(SweepMode::Enforce),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains(r#""conflict_sweep":"enforce""#), "{json}");
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
}
#[test]
fn a_config_predating_ignore_patterns_still_loads() {
// Every installed config on disk looks like this; the field must not be
+394
View File
@@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize};
use crate::cache::Pin;
use crate::error::Result;
pub use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
/// Cap on the *write* half of a round-trip. A crashed daemon can leave its
/// listening socket in the kernel (e.g. as a `<defunct>` zombie): `connect`
@@ -194,6 +195,11 @@ pub enum Request {
/// it invalidates, it does not fetch.
Refresh { scope: RefreshScope },
// ---- locations --------------------------------------------------------
/// List every local Proton Drive location. Replies with
/// [`Response::Locations`].
ListLocations,
// ---- devices ----------------------------------------------------------
/// List the account's registered devices. Replies with [`Response::Devices`].
ListDevices,
@@ -240,9 +246,20 @@ pub enum Request {
role: String,
message: Option<String>,
},
/// The by-uid twin of [`Request::ShareNode`], for nodes proven to belong to
/// an on-demand or mirror device location. This is a distinct variant so an
/// older daemon rejects it instead of ignoring `uid` and acting on `path`.
ShareNodeByUid {
uid: String,
emails: Vec<String>,
role: String,
message: Option<String>,
},
/// List the members, pending invitations and public link of the node at
/// mountpoint-relative `path`. Replies with [`Response::Share`].
ListShare { path: String },
/// The by-uid twin of [`Request::ListShare`].
ListShareByUid { uid: String },
/// Change the role of a share entry (member or pending invitation) on the node
/// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]).
/// Replies with [`Response::Ok`].
@@ -252,6 +269,13 @@ pub enum Request {
kind: ShareEntryKind,
role: String,
},
/// The by-uid twin of [`Request::UpdateShareRole`].
UpdateShareRoleByUid {
uid: String,
id: String,
kind: ShareEntryKind,
role: String,
},
/// Remove a share entry (member, pending Proton invite, or external invite)
/// from the node at `path`. Replies with [`Response::Ok`].
RemoveShareEntry {
@@ -259,6 +283,12 @@ pub enum Request {
id: String,
kind: ShareEntryKind,
},
/// The by-uid twin of [`Request::RemoveShareEntry`].
RemoveShareEntryByUid {
uid: String,
id: String,
kind: ShareEntryKind,
},
/// Create a public link on the node at `path`. `role` is "viewer" or "editor";
/// `password` optionally adds a custom password; `expires` is an optional Unix
/// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL).
@@ -268,9 +298,18 @@ pub enum Request {
password: Option<String>,
expires: Option<i64>,
},
/// The by-uid twin of [`Request::CreatePublicLink`].
CreatePublicLinkByUid {
uid: String,
role: String,
password: Option<String>,
expires: Option<i64>,
},
/// Remove the public link `id` from the node at `path`. Replies with
/// [`Response::Ok`].
RemovePublicLink { path: String, id: String },
/// The by-uid twin of [`Request::RemovePublicLink`].
RemovePublicLinkByUid { uid: String, id: String },
// ---- shared by me -----------------------------------------------------
/// List the nodes I have shared with others — collaborative shares that still
@@ -573,6 +612,11 @@ pub enum ActivityKind {
Share,
PublicLink,
Unshare,
/// A `(sync-conflict …)` copy that needs the user's attention: it diverges
/// from the live file (or has no sibling), so the auto-sweep left it in place
/// rather than removing it. An auto-*removed* identical copy is logged as a
/// [`ActivityKind::Trash`] instead, since that is what happened to it.
Conflict,
}
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
@@ -1089,6 +1133,8 @@ pub enum Response {
},
/// The account's devices (reply to [`Request::ListDevices`]).
Devices { items: Vec<DeviceInfo> },
/// Every local Proton Drive location (reply to [`Request::ListLocations`]).
Locations { items: Vec<MountSpec> },
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
SyncFolders { items: Vec<SyncFolderInfo> },
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
@@ -1536,6 +1582,7 @@ mod tests {
#[test]
fn sharing_requests_roundtrip() {
let reqs = [
Request::ListLocations,
Request::ListDevices,
Request::RenameDevice {
uid: "dev-1".into(),
@@ -1575,28 +1622,58 @@ mod tests {
role: "editor".into(),
message: Some("hi".into()),
},
Request::ShareNodeByUid {
uid: "vol~link".into(),
emails: vec!["x@proton.me".into(), "y@example.com".into()],
role: "editor".into(),
message: Some("hi".into()),
},
Request::ListShare { path: "a/b".into() },
Request::ListShareByUid {
uid: "vol~link".into(),
},
Request::UpdateShareRole {
path: "a/b".into(),
id: "mid-1".into(),
kind: ShareEntryKind::Member,
role: "admin".into(),
},
Request::UpdateShareRoleByUid {
uid: "vol~link".into(),
id: "mid-1".into(),
kind: ShareEntryKind::Member,
role: "admin".into(),
},
Request::RemoveShareEntry {
path: "a/b".into(),
id: "iid-1".into(),
kind: ShareEntryKind::ExternalInvite,
},
Request::RemoveShareEntryByUid {
uid: "vol~link".into(),
id: "iid-1".into(),
kind: ShareEntryKind::ExternalInvite,
},
Request::CreatePublicLink {
path: "a/b".into(),
role: "viewer".into(),
password: Some("pw".into()),
expires: Some(1_700_000_000),
},
Request::CreatePublicLinkByUid {
uid: "vol~link".into(),
role: "viewer".into(),
password: Some("pw".into()),
expires: Some(1_700_000_000),
},
Request::RemovePublicLink {
path: "a/b".into(),
id: "url-1".into(),
},
Request::RemovePublicLinkByUid {
uid: "vol~link".into(),
id: "url-1".into(),
},
Request::ListSharedByMe,
Request::ListActivity { limit: 100 },
Request::ListSharedWithMe,
@@ -1630,6 +1707,323 @@ mod tests {
}
}
#[test]
fn locations_response_roundtrips_typed_mounts() {
let response = Response::Locations {
items: vec![
MountSpec {
id: 1,
kind: MountKind::MyFiles,
local_path: "/home/me/ProtonDrive".into(),
root_uid: "vol~root".into(),
root_share_id: "share-main".into(),
mode: MountMode::OnDemand,
access: MountAccess::Rw,
state: "idle".into(),
last_sync: 0,
pending_mode: None,
mounted: true,
progress: None,
},
MountSpec {
id: 2,
kind: MountKind::Device { sync_folder_id: 7 },
local_path: "/home/me/Work".into(),
root_uid: "device-vol~work".into(),
root_share_id: "device-share".into(),
mode: MountMode::Mirror,
access: MountAccess::Rw,
state: "syncing".into(),
last_sync: 42,
pending_mode: Some(MountMode::OnDemand),
mounted: false,
progress: None,
},
MountSpec {
id: 3,
kind: MountKind::Shared {
share_root_uid: "shared-vol~root".into(),
},
local_path: "/home/me/Shared".into(),
root_uid: "shared-vol~root".into(),
root_share_id: "shared-share".into(),
mode: MountMode::Unknown,
access: MountAccess::Ro,
state: "error".into(),
last_sync: 0,
pending_mode: Some(MountMode::Unknown),
mounted: false,
progress: Some(SyncProgress {
phase: SyncPhase::Applying,
done: 2,
total: 5,
current: "report.pdf".into(),
}),
},
],
};
let wire = serde_json::to_string(&response).unwrap();
assert!(!wire.contains('\n'), "wire form must be a single line");
assert!(wire.contains(r#""mode":"ondemand""#));
assert!(wire.contains(r#""kind":"shared""#));
assert!(wire.contains(r#""access":"ro""#));
assert!(wire.contains(r#""mode":"unknown""#));
let back: Response = serde_json::from_str(&wire).unwrap();
match back {
Response::Locations { items } => {
assert_eq!(items.len(), 3);
assert!(matches!(
items[1].kind,
MountKind::Device { sync_folder_id: 7 }
));
assert_eq!(items[1].pending_mode, Some(MountMode::OnDemand));
assert!(matches!(
&items[2].kind,
MountKind::Shared { share_root_uid }
if share_root_uid == "shared-vol~root"
));
assert_eq!(items[2].access, MountAccess::Ro);
assert_eq!(items[2].mode, MountMode::Unknown);
assert_eq!(items[2].pending_mode, Some(MountMode::Unknown));
let progress = items[2].progress.as_ref().unwrap();
assert_eq!(progress.phase, SyncPhase::Applying);
assert_eq!((progress.done, progress.total), (2, 5));
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test]
fn legacy_daemons_reject_list_locations_cleanly() {
#[allow(dead_code)]
#[derive(serde::Deserialize)]
enum LegacyLocationRequest {
ListSyncFolders,
}
let wire = serde_json::to_string(&Request::ListLocations).unwrap();
assert!(
serde_json::from_str::<LegacyLocationRequest>(&wire).is_err(),
"an old daemon must reject ListLocations instead of interpreting another request"
);
}
#[test]
fn path_share_requests_keep_their_existing_wire_shape() {
let cases = [
(
Request::ShareNode {
path: "a/b".into(),
emails: vec!["x@example.com".into()],
role: "viewer".into(),
message: None,
},
r#"{"ShareNode":{"path":"a/b","emails":["x@example.com"],"role":"viewer","message":null}}"#,
),
(
Request::ListShare { path: "a/b".into() },
r#"{"ListShare":{"path":"a/b"}}"#,
),
(
Request::UpdateShareRole {
path: "a/b".into(),
id: "member".into(),
kind: ShareEntryKind::Member,
role: "editor".into(),
},
r#"{"UpdateShareRole":{"path":"a/b","id":"member","kind":"Member","role":"editor"}}"#,
),
(
Request::RemoveShareEntry {
path: "a/b".into(),
id: "invite".into(),
kind: ShareEntryKind::ProtonInvite,
},
r#"{"RemoveShareEntry":{"path":"a/b","id":"invite","kind":"ProtonInvite"}}"#,
),
(
Request::CreatePublicLink {
path: "a/b".into(),
role: "viewer".into(),
password: None,
expires: None,
},
r#"{"CreatePublicLink":{"path":"a/b","role":"viewer","password":null,"expires":null}}"#,
),
(
Request::RemovePublicLink {
path: "a/b".into(),
id: "link".into(),
},
r#"{"RemovePublicLink":{"path":"a/b","id":"link"}}"#,
),
];
for (request, expected) in cases {
assert_eq!(serde_json::to_string(&request).unwrap(), expected);
assert!(
!expected.contains("\"uid\""),
"path variants must never acquire an optional uid"
);
}
}
#[test]
fn by_uid_share_requests_are_distinct_wire_variants() {
let cases = [
(
Request::ShareNodeByUid {
uid: "vol~link".into(),
emails: vec!["x@example.com".into()],
role: "viewer".into(),
message: None,
},
"ShareNodeByUid",
),
(
Request::ListShareByUid {
uid: "vol~link".into(),
},
"ListShareByUid",
),
(
Request::UpdateShareRoleByUid {
uid: "vol~link".into(),
id: "member".into(),
kind: ShareEntryKind::Member,
role: "editor".into(),
},
"UpdateShareRoleByUid",
),
(
Request::RemoveShareEntryByUid {
uid: "vol~link".into(),
id: "invite".into(),
kind: ShareEntryKind::ProtonInvite,
},
"RemoveShareEntryByUid",
),
(
Request::CreatePublicLinkByUid {
uid: "vol~link".into(),
role: "viewer".into(),
password: None,
expires: None,
},
"CreatePublicLinkByUid",
),
(
Request::RemovePublicLinkByUid {
uid: "vol~link".into(),
id: "link".into(),
},
"RemovePublicLinkByUid",
),
];
for (request, variant) in cases {
let wire = serde_json::to_string(&request).unwrap();
let object = serde_json::from_str::<serde_json::Value>(&wire)
.unwrap()
.as_object()
.cloned()
.unwrap();
assert_eq!(object.len(), 1);
assert!(object.contains_key(variant));
let decoded = serde_json::from_str::<Request>(&wire).unwrap();
let decoded_uid = match &decoded {
Request::ShareNodeByUid { uid, .. }
| Request::ListShareByUid { uid }
| Request::UpdateShareRoleByUid { uid, .. }
| Request::RemoveShareEntryByUid { uid, .. }
| Request::CreatePublicLinkByUid { uid, .. }
| Request::RemovePublicLinkByUid { uid, .. } => uid,
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
};
assert_eq!(decoded_uid, "vol~link");
assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
}
}
#[test]
fn legacy_daemons_reject_new_by_uid_variants() {
#[allow(dead_code)]
#[derive(serde::Deserialize)]
enum LegacyShareRequest {
ShareNode {
path: String,
emails: Vec<String>,
role: String,
message: Option<String>,
},
ListShare {
path: String,
},
UpdateShareRole {
path: String,
id: String,
kind: ShareEntryKind,
role: String,
},
RemoveShareEntry {
path: String,
id: String,
kind: ShareEntryKind,
},
CreatePublicLink {
path: String,
role: String,
password: Option<String>,
expires: Option<i64>,
},
RemovePublicLink {
path: String,
id: String,
},
}
let requests = [
Request::ShareNodeByUid {
uid: "vol~link".into(),
emails: Vec::new(),
role: "viewer".into(),
message: None,
},
Request::ListShareByUid {
uid: "vol~link".into(),
},
Request::UpdateShareRoleByUid {
uid: "vol~link".into(),
id: "member".into(),
kind: ShareEntryKind::Member,
role: "viewer".into(),
},
Request::RemoveShareEntryByUid {
uid: "vol~link".into(),
id: "member".into(),
kind: ShareEntryKind::Member,
},
Request::CreatePublicLinkByUid {
uid: "vol~link".into(),
role: "viewer".into(),
password: None,
expires: None,
},
Request::RemovePublicLinkByUid {
uid: "vol~link".into(),
id: "link".into(),
},
];
for request in requests {
let wire = serde_json::to_string(&request).unwrap();
assert!(
serde_json::from_str::<LegacyShareRequest>(&wire).is_err(),
"an old daemon must reject {wire}, never reinterpret it as a path request"
);
}
}
/// A daemon built before `kind` existed sends `Error` without the field.
/// It must still parse, and land on the class that promises the least.
#[test]
+2
View File
@@ -77,10 +77,12 @@ const REPORTED_TABLES: &[&str] = &[
"trash",
"device",
"sync_folder",
"mount",
"sync_entry",
"pending_op",
"activity",
"local_files",
"share_access",
];
impl Db {
+62 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 16;
pub(super) const SCHEMA_VERSION: i64 = 18;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -89,6 +89,12 @@ impl Db {
if current < 16 {
tx.execute_batch(MIGRATION_V16)?;
}
if current < 17 {
tx.execute_batch(MIGRATION_V17)?;
}
if current < 18 {
tx.execute_batch(MIGRATION_V18)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -411,3 +417,58 @@ CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
ON local_files(name COLLATE NOCASE);
INSERT INTO local_fts(local_fts) VALUES('rebuild');
";
/// Schema v17: effective access for roots shared with this account. The role is
/// persisted separately from `nodes.node_json` so a cold/offline mount can
/// enforce the last known permission even when the SDK cannot refresh metadata.
const MIGRATION_V17: &str = "
CREATE TABLE share_access (
root_uid TEXT PRIMARY KEY,
access TEXT NOT NULL
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
);
";
/// Schema v18: unified location presentation. Device rows carry only their
/// `sync_folder` identity; reads join all device state from that authoritative
/// table. The trigger projects future folders, while the foreign key removes a
/// presentation row with its sync folder.
const MIGRATION_V18: &str = "
CREATE TABLE mount (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL
CHECK (kind IN ('myfiles', 'device', 'shared')),
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
share_root_uid TEXT,
local_path TEXT NOT NULL DEFAULT '',
root_uid TEXT NOT NULL DEFAULT '',
root_share_id TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'unknown',
access TEXT NOT NULL DEFAULT 'rw'
CHECK (access IN ('rw', 'ro')),
CHECK (
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
OR
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
OR
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
)
);
CREATE UNIQUE INDEX mount_myfiles
ON mount(kind) WHERE kind = 'myfiles';
CREATE UNIQUE INDEX mount_device
ON mount(sync_folder_id) WHERE kind = 'device';
CREATE UNIQUE INDEX mount_shared
ON mount(share_root_uid) WHERE kind = 'shared';
INSERT INTO mount (kind, sync_folder_id)
SELECT 'device', id FROM sync_folder;
CREATE TRIGGER mount_sync_folder_insert
AFTER INSERT ON sync_folder
BEGIN
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
VALUES ('device', NEW.id);
END;
";
+9 -3
View File
@@ -27,10 +27,12 @@ mod devices;
mod local;
mod maintenance;
mod migrations;
mod mounts;
mod nodes;
mod ops;
mod photos;
mod pins;
mod share_access;
mod state;
mod sync;
mod trash;
@@ -40,10 +42,10 @@ pub use cache::CacheEntryInput;
pub use devices::StoredDevice;
pub use local::LocalFileHit;
pub use maintenance::{DbStats, VacuumOutcome};
pub use nodes::{SearchHit, StoredNode};
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
pub use ops::{
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
PendingCounts, PendingOp, op_supersedes,
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
PendingCounts, PendingOp, RenameMeta, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
pub use sync::{StoredSyncEntry, StoredSyncFolder};
@@ -109,6 +111,10 @@ impl Db {
#[cfg(test)]
pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
// Keep relational behavior identical to `open`: V18 relies on the
// `mount.sync_folder_id` cascade, and SQLite disables foreign keys per
// connection unless explicitly enabled.
conn.pragma_update(None, "foreign_keys", "ON")?;
let db = Self {
conn: Mutex::new(conn),
};
+178
View File
@@ -0,0 +1,178 @@
//! Unified mount/location presentation rows.
//!
//! A device row stores only its `sync_folder` identity. Its path, remote root,
//! mode, state, pending mode, and last-sync timestamp are selected from
//! `sync_folder`, which remains the authoritative sync-engine table.
use rusqlite::params;
use super::Db;
use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
use crate::{Error, Result};
impl Db {
/// Project the configured primary mount into the presentation table.
///
/// `root_share_id` is optional because an offline startup may only have the
/// cached root node. A previously learned share id is retained only when it
/// belongs to the same root uid.
pub fn mount_upsert_my_files(
&self,
local_path: &str,
root_uid: &str,
root_share_id: Option<&str>,
) -> Result<i64> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let updated = tx.execute(
"UPDATE mount
SET local_path = ?1,
root_share_id = CASE
WHEN root_uid = ?2
THEN COALESCE(NULLIF(?3, ''), root_share_id)
ELSE COALESCE(?3, '')
END,
root_uid = ?2,
mode = 'ondemand',
access = 'rw'
WHERE kind = 'myfiles'",
params![local_path, root_uid, root_share_id],
)?;
let id = if updated == 0 {
tx.execute(
"INSERT INTO mount
(kind, local_path, root_uid, root_share_id, mode, access)
VALUES ('myfiles', ?1, ?2, COALESCE(?3, ''), 'ondemand', 'rw')",
params![local_path, root_uid, root_share_id],
)?;
tx.last_insert_rowid()
} else {
tx.query_row("SELECT id FROM mount WHERE kind = 'myfiles'", [], |row| {
row.get(0)
})?
};
tx.commit()?;
Ok(id)
}
/// Store a share id learned asynchronously, but only if the projection still
/// names the root it was resolved for.
pub fn mount_repair_my_files_share_id(
&self,
root_uid: &str,
root_share_id: &str,
) -> Result<bool> {
let conn = self.conn.lock();
Ok(conn.execute(
"UPDATE mount
SET root_share_id = ?2
WHERE kind = 'myfiles'
AND root_uid = ?1",
params![root_uid, root_share_id],
)? > 0)
}
/// Every local location, with device fields joined from `sync_folder`.
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT
m.id,
m.kind,
m.sync_folder_id,
m.share_root_uid,
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
m.access,
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
FROM mount AS m
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
ORDER BY CASE m.kind
WHEN 'myfiles' THEN 0
WHEN 'device' THEN 1
ELSE 2
END,
m.id",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<i64>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
row.get::<_, String>(9)?,
row.get::<_, i64>(10)?,
row.get::<_, Option<String>>(11)?,
))
})?;
let mut mounts = Vec::new();
for row in rows {
let (
id,
kind,
sync_folder_id,
share_root_uid,
local_path,
root_uid,
root_share_id,
mode,
access,
state,
last_sync,
pending_mode,
) = row?;
let kind = match kind.as_str() {
"myfiles" => MountKind::MyFiles,
"device" => MountKind::Device {
sync_folder_id: sync_folder_id.ok_or_else(|| {
Error::Other(format!("mount row {id} has no sync_folder_id"))
})?,
},
"shared" => MountKind::Shared {
share_root_uid: share_root_uid.ok_or_else(|| {
Error::Other(format!("mount row {id} has no share_root_uid"))
})?,
},
other => {
return Err(Error::Other(format!(
"mount row {id} has unknown kind {other:?}"
)));
}
};
let access = match access.as_str() {
"rw" => MountAccess::Rw,
"ro" => MountAccess::Ro,
other => {
return Err(Error::Other(format!(
"mount row {id} has unknown access {other:?}"
)));
}
};
mounts.push(MountSpec {
id,
kind,
local_path,
root_uid,
root_share_id,
mode: MountMode::from(mode.as_str()),
access,
state,
last_sync,
pending_mode: pending_mode.as_deref().map(MountMode::from),
mounted: false,
progress: None,
});
}
Ok(mounts)
}
}
+375 -79
View File
@@ -1,11 +1,13 @@
//! Node rows: the persisted mirror of the remote tree, plus the trigram
//! full-text index over names and paths that backs `Request::Search`.
use rusqlite::{OptionalExtension, params};
use std::collections::HashSet;
use rusqlite::{OptionalExtension, Transaction, params};
use super::Db;
use crate::Result;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use crate::{Access, Result};
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use proton_drive_rs::{Node, NodeKind};
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
@@ -22,6 +24,11 @@ pub struct SearchHit {
pub path: String,
}
pub struct PublishedSharedRoot {
pub node: Node,
pub access: Access,
}
impl Db {
pub fn upsert_node(&self, node: &Node) -> Result<()> {
self.upsert_nodes(std::slice::from_ref(node))
@@ -42,87 +49,188 @@ impl Db {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
for node in nodes {
let json = serde_json::to_string(node)?;
let uid = node.uid.to_string();
tx.execute(
"INSERT INTO nodes
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(uid) DO UPDATE SET
parent_uid = excluded.parent_uid,
name = excluded.name,
is_dir = excluded.is_dir,
size = excluded.size,
mtime = excluded.mtime,
trashed = excluded.trashed,
node_json = excluded.node_json",
params![
uid,
node.parent_uid.as_ref().map(|u| u.to_string()),
node.name,
node.is_folder() as i64,
node_size(node),
node.modification_time,
node.trashed as i64,
json,
],
)?;
// FTS5 has no UPSERT, so refresh the row by delete-then-insert.
// Trashed nodes are kept out of the index entirely so they never
// surface in search results.
//
// Keyed by the node's rowid, never by its uid: the uid is not an
// indexed FTS column, so deleting by it scans the entire index once
// per node written (B12). The rowid is stable across the upsert
// above — `ON CONFLICT DO UPDATE` keeps the existing row — so it is
// the same key before and after.
let rowid: i64 = tx.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)?;
// A folder rename/move changes every descendant's searchable path.
// Refresh the affected subtree, while retaining efficient rowid
// deletes for the overwhelmingly common one-file case.
let mut affected = vec![rowid];
if node.is_folder() {
let mut stmt = tx.prepare(
"WITH RECURSIVE descendants(rowid, uid) AS (
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
UNION ALL
SELECT n.rowid, n.uid FROM nodes n
JOIN descendants d ON n.parent_uid = d.uid
) SELECT rowid FROM descendants",
)?;
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
affected.push(descendant?);
}
}
for affected_rowid in affected {
tx.execute(
"DELETE FROM nodes_fts WHERE rowid = ?1",
params![affected_rowid],
)?;
let indexed: Option<(String, String)> = tx
.query_row(
"SELECT name, uid FROM nodes WHERE rowid = ?1 AND trashed = 0",
params![affected_rowid],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
if let Some((name, indexed_uid)) = indexed {
let path = path_of(&tx, &indexed_uid)?;
tx.execute(
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
params![affected_rowid, name, path],
)?;
}
}
upsert_node_tx(&tx, node)?;
}
tx.commit()?;
Ok(())
}
/// Return visible direct children without consulting the parent's `listed`
/// flag. Synthetic shared listings use this to serve the last completed
/// snapshot while offline, including after a live event expired its TTL.
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT node_json FROM nodes
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name, uid",
)?;
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?;
let mut nodes = Vec::new();
for row in rows {
nodes.push(serde_json::from_str(&row?)?);
}
Ok(nodes)
}
/// Atomically publish one completed `Shared with me` root listing.
///
/// Roots absent from `accepted` and every persisted descendant are
/// tombstoned, not deleted. Accepted roots omitted from materialization keep
/// their visible snapshot but are downgraded to Viewer until membership is
/// verified again. Both cases preserve queued operations and staged writes.
pub fn publish_shared_roots(
&self,
parent: &NodeUid,
accepted: &[NodeUid],
roots: &[PublishedSharedRoot],
) -> Result<Vec<NodeUid>> {
let parent = parent.to_string();
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
let materialized: HashSet<String> = roots
.iter()
.map(|published| published.node.uid.to_string())
.collect();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let existing = direct_child_uids_tx(&tx, &parent)?;
let removed: Vec<String> = existing
.into_iter()
.filter(|uid| !present.contains(uid))
.collect();
for published in roots {
if !present.contains(&published.node.uid.to_string()) {
continue;
}
upsert_node_tx(&tx, &published.node)?;
upsert_share_access_tx(&tx, &published.node.uid.to_string(), published.access)?;
}
for uid in present.difference(&materialized) {
upsert_share_access_tx(&tx, uid, Access::Viewer)?;
}
for root in &removed {
tombstone_subtree_tx(&tx, root)?;
upsert_share_access_tx(&tx, root, Access::Viewer)?;
}
tx.execute(
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
params![parent],
)?;
tx.commit()?;
Ok(removed
.into_iter()
.filter_map(|uid| parse_node_uid(&uid))
.collect())
}
/// Atomically withdraw a deleted foreign subtree and deny stale handles.
///
/// Node rows and queued operations remain so retries and staged writes keep
/// their references. FTS and completed-listing state are withdrawn for the
/// whole subtree, and the deleted UID becomes a fail-closed authority.
pub fn tombstone_foreign_subtree(&self, uid: &NodeUid) -> Result<()> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tombstone_subtree_tx(&tx, &uid)?;
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
tx.commit()?;
Ok(())
}
/// Publish a completed foreign-folder listing from its authoritative UID
/// list and the subset that materialized successfully. Accepted-but-omitted
/// children retain their previous snapshot; only absent UIDs are tombstoned.
pub fn publish_foreign_children(
&self,
parent: &NodeUid,
accepted: &[NodeUid],
materialized: &[Node],
) -> Result<Vec<NodeUid>> {
let parent = parent.to_string();
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let removed: Vec<String> = direct_child_uids_tx(&tx, &parent)?
.into_iter()
.filter(|uid| !present.contains(uid))
.collect();
for node in materialized {
if present.contains(&node.uid.to_string()) {
upsert_node_tx(&tx, node)?;
}
}
for uid in &removed {
tombstone_subtree_tx(&tx, uid)?;
}
tx.execute(
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
params![parent],
)?;
tx.commit()?;
Ok(removed
.into_iter()
.filter_map(|uid| parse_node_uid(&uid))
.collect())
}
/// Atomically publish the synthetic root's pinned name, node/FTS state, and
/// Viewer authority.
///
/// The pin is inserted last so any failure there rolls back the node and
/// its descendant FTS updates as well as the access row. Returning `false`
/// guarantees no write occurred, which keeps cached root lookups O(1).
pub fn publish_virtual_root(&self, pinned_name_key: &str, node: &Node) -> Result<bool> {
let uid = node.uid.to_string();
let desired_parent = node.parent_uid.as_ref().map(NodeUid::to_string);
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let pinned_name: Option<String> = tx
.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
params![pinned_name_key],
|row| row.get(0),
)
.optional()?;
let stored: Option<(Option<String>, String, i64, i64)> = tx
.query_row(
"SELECT parent_uid, name, is_dir, trashed FROM nodes WHERE uid = ?1",
params![uid],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()?;
let access: Option<String> = tx
.query_row(
"SELECT access FROM share_access WHERE root_uid = ?1",
params![uid],
|row| row.get(0),
)
.optional()?;
let unchanged = stored.is_some_and(|(parent, name, is_dir, trashed)| {
parent == desired_parent
&& name == node.name
&& is_dir == 1
&& trashed == node.trashed as i64
}) && access.as_deref() == Some(Access::Viewer.as_db_str())
&& pinned_name.is_some();
if unchanged {
tx.commit()?;
return Ok(false);
}
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
upsert_node_tx(&tx, node)?;
tx.execute(
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO NOTHING",
params![pinned_name_key, node.name],
)?;
tx.commit()?;
Ok(true)
}
/// Drop a node row (delete or trash from the hot cache). Children rows are
/// not cascaded here; the daemon forgets a whole subtree node-by-node.
pub fn delete_node(&self, uid: &NodeUid) -> Result<()> {
@@ -148,6 +256,32 @@ impl Db {
Ok(())
}
/// Atomically retire a landed trash operation and its retained authority.
///
/// Queued trash keeps the node row so drain-time permission checks can
/// resolve its shared-tree access. Removing the op first could resurrect
/// that row after a crash; removing the row first could strand a now
/// unauthorizable op. One transaction closes both windows.
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let rowid: Option<i64> = tx
.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)
.optional()?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
if let Some(rowid) = rowid {
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
}
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
tx.commit()?;
Ok(())
}
/// Check if a folder node has any non-trashed children in the database.
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
let uid_str = parent_uid.to_string();
@@ -387,6 +521,168 @@ impl Db {
// in sync on every store/read/evict, so it is authoritative for eviction.
}
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
let json = serde_json::to_string(node)?;
let uid = node.uid.to_string();
tx.execute(
"INSERT INTO nodes
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(uid) DO UPDATE SET
parent_uid = excluded.parent_uid,
name = excluded.name,
is_dir = excluded.is_dir,
size = excluded.size,
mtime = excluded.mtime,
trashed = excluded.trashed,
node_json = excluded.node_json",
params![
uid,
node.parent_uid.as_ref().map(|u| u.to_string()),
node.name,
node.is_folder() as i64,
node_size(node),
node.modification_time,
node.trashed as i64,
json,
],
)?;
// FTS5 has no UPSERT. Folder moves also change every descendant path, so
// refresh the whole affected subtree while keeping rowid deletes indexed.
let rowid: i64 = tx.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)?;
let mut affected = vec![rowid];
if node.is_folder() {
let mut stmt = tx.prepare(
"WITH RECURSIVE descendants(rowid, uid) AS (
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
UNION ALL
SELECT n.rowid, n.uid FROM nodes n
JOIN descendants d ON n.parent_uid = d.uid
) SELECT rowid FROM descendants",
)?;
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
affected.push(descendant?);
}
}
for affected_rowid in affected {
tx.execute(
"DELETE FROM nodes_fts WHERE rowid = ?1",
params![affected_rowid],
)?;
let indexed: Option<(String, String)> = tx
.query_row(
"SELECT name, uid FROM nodes WHERE rowid = ?1",
params![affected_rowid],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
if let Some((name, indexed_uid)) = indexed
&& node_is_indexable_tx(tx, &indexed_uid)?
{
let path = path_of(tx, &indexed_uid)?;
tx.execute(
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
params![affected_rowid, name, path],
)?;
}
}
Ok(())
}
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?;
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
let mut uids = Vec::new();
for row in rows {
uids.push(row?);
}
Ok(uids)
}
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
let indexable: i64 = tx.query_row(
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, path) AS (
SELECT uid, parent_uid, trashed, char(31) || uid || char(31)
FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid, n.trashed,
a.path || n.uid || char(31)
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
)
SELECT CASE
WHEN COUNT(*) = 0 THEN 0
WHEN MAX(trashed) != 0 THEN 0
WHEN MAX(CASE WHEN parent_uid IS NULL THEN 1 ELSE 0 END) = 0 THEN 0
ELSE 1
END
FROM ancestors",
[uid],
|row| row.get(0),
)?;
Ok(indexable == 1)
}
fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
let rows = {
let mut stmt = tx.prepare(
"WITH RECURSIVE subtree(rowid, uid, node_json) AS (
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
UNION
SELECT n.rowid, n.uid, n.node_json FROM nodes n
JOIN subtree s ON n.parent_uid = s.uid
)
SELECT rowid, uid, node_json FROM subtree",
)?;
let mapped = stmt.query_map([root_uid], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
let mut rows = Vec::new();
for row in mapped {
rows.push(row?);
}
rows
};
for (rowid, uid, json) in rows {
let json = match json {
Some(json) => {
let mut node: Node = serde_json::from_str(&json)?;
node.trashed = true;
Some(serde_json::to_string(&node)?)
}
None => None,
};
tx.execute(
"UPDATE nodes SET trashed = 1, listed = 0, node_json = ?2 WHERE uid = ?1",
params![uid, json],
)?;
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", [rowid])?;
}
Ok(())
}
fn upsert_share_access_tx(tx: &Transaction<'_>, uid: &str, access: Access) -> Result<()> {
tx.execute(
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
params![uid, access.as_db_str()],
)?;
Ok(())
}
fn parse_node_uid(value: &str) -> Option<NodeUid> {
let (volume, link) = value.split_once('~')?;
Some(NodeUid::new(VolumeId::from(volume), LinkId::from(link)))
}
/// Unique quoted character trigrams suitable for an FTS5 OR expression.
pub(super) fn candidate_trigrams(query: &str) -> Vec<String> {
let chars: Vec<char> = query.to_lowercase().chars().collect();
+154 -2
View File
@@ -3,6 +3,7 @@
//! order, so a child never drains before the parent that gives it a real uid.
use rusqlite::{OptionalExtension, params};
use serde::{Deserialize, Serialize};
use super::Db;
use crate::Result;
@@ -35,6 +36,16 @@ pub const OP_MKDIR: &str = "mkdir";
/// that folder's own drain, exactly as for [`OP_CREATE`].
pub const OP_RENAME: &str = "rename";
/// Drain-time authority retained by a queued rename.
///
/// The optimistic local move rewrites the node's persisted ancestry to the
/// destination immediately. Keeping the original parent here prevents that
/// rewrite from erasing the shared tree that admitted the move.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenameMeta {
pub original_parent_uid: String,
}
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
/// (offline.md Phase 3b).
///
@@ -63,6 +74,16 @@ pub fn op_supersedes(kind: &str) -> bool {
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// A `next_attempt_at` far enough in the future that [`Db::next_due_op`] never
/// selects the op (ms since epoch, ≈ year 2223). Used to *park* a queued create
/// for a transient file — a browser's in-flight `*.crdownload`/`*.part`, an
/// editor's `*.swp` — so its bytes never upload while it wears that name. A
/// rename to the finished name un-parks it (see [`Db::set_create_hold`]), which
/// is the only moment the completed file is meant to reach Drive. Without the
/// park, every partial and every abandoned temp would upload and, on a
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
/// A mutation that has been accepted locally but not yet performed against the
/// API — the durable half of the write-back queue (offline.md Phase 3).
///
@@ -117,6 +138,32 @@ pub struct AttachedBlob {
}
impl Db {
/// Whether a specific desired-state operation is still queued for a node.
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
let conn = self.conn.lock();
conn.query_row(
"SELECT EXISTS(
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
)",
params![uid, kind],
|row| row.get(0),
)
.map_err(Into::into)
}
/// Read an existing operation's metadata before a superseding enqueue.
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
let conn = self.conn.lock();
conn.query_row(
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![uid, kind],
|row| row.get(0),
)
.optional()
.map(|value| value.flatten())
.map_err(Into::into)
}
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
@@ -157,6 +204,50 @@ impl Db {
Ok((id, superseded))
}
/// Atomically replace every queued operation for `uid` and its queued
/// descendants with one remote trash intent.
///
/// Staged blob paths are returned only after the transaction commits. A
/// failed trash insert therefore rolls the deletions back and leaves every
/// prior operation owning its bytes.
pub fn replace_ops_with_trash(
&self,
uid: &str,
name: &str,
created_at: i64,
) -> Result<(i64, Vec<String>)> {
const SUBTREE: &str = "
WITH RECURSIVE doomed(uid) AS (
SELECT ?1
UNION
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
)";
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let blobs: Vec<String> = {
let mut stmt = tx.prepare(&format!(
"{SUBTREE}
SELECT blob_path FROM pending_op
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
))?;
let rows = stmt.query_map(params![uid], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
tx.execute(
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
params![uid],
)?;
tx.execute(
"INSERT INTO pending_op
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
VALUES (?1, ?2, NULL, ?3, NULL, NULL, ?4, 0)",
params![OP_TRASH, uid, name, created_at],
)?;
let id = tx.last_insert_rowid();
tx.commit()?;
Ok((id, blobs))
}
/// Point a queued create at the bytes that were just written to it, returning
/// any blob it previously held so the caller can discard it.
///
@@ -183,11 +274,18 @@ impl Db {
let Some((id, superseded)) = existing else {
return Ok(None);
};
// Clearing the backoff lets the fresh bytes be tried promptly — except
// when the create is *parked* (a transient file, next_attempt_at at the
// PARK_UNTIL sentinel). Attaching a new revision to a still-growing
// download must not wake it; only the finalize rename does. So preserve a
// park and reset an ordinary backoff.
conn.execute(
"UPDATE pending_op
SET blob_path = ?2, meta_json = ?3, attempts = 0, next_attempt_at = 0
SET blob_path = ?2, meta_json = ?3, attempts = 0,
next_attempt_at = CASE WHEN next_attempt_at >= ?4
THEN next_attempt_at ELSE 0 END
WHERE id = ?1",
params![id, blob_path, meta_json],
params![id, blob_path, meta_json, PARK_UNTIL],
)?;
Ok(Some(AttachedBlob { id, superseded }))
}
@@ -233,6 +331,25 @@ impl Db {
Ok(n > 0)
}
/// Park or un-park a queued create/mkdir for `uid`, returning whether a row
/// was touched.
///
/// Parking sets `next_attempt_at` to [`PARK_UNTIL`] so [`Db::next_due_op`]
/// skips it indefinitely; un-parking sets it due now. Used to keep a
/// transient file's bytes off Drive until it is renamed to its finished name
/// (docs/BUGS.md B70). Un-parking a create that was never parked is harmless:
/// it just makes the create due immediately, which for a normal local node it
/// already is.
pub fn set_create_hold(&self, uid: &str, held: bool) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute(
"UPDATE pending_op SET next_attempt_at = ?2
WHERE uid = ?1 AND kind IN (?3, ?4)",
params![uid, if held { PARK_UNTIL } else { 0 }, OP_CREATE, OP_MKDIR],
)?;
Ok(n > 0)
}
/// Check if a queued create or mkdir op exists for `uid`.
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
let conn = self.conn.lock();
@@ -244,6 +361,22 @@ impl Db {
Ok(count > 0)
}
/// Check whether *any* op of any kind is queued against `uid`.
///
/// Used as a safety interlock by callers that are about to remove a node
/// they only know about from a stale snapshot: a queued op means the user's
/// bytes are still owed an upload, and dropping it would discard the staged
/// blob that holds them (`docs/BUGS.md` B71).
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
let conn = self.conn.lock();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
params![uid],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Drop every op targeting a node **or anything queued beneath it**,
/// returning the staged blobs they held so the caller can delete them.
///
@@ -457,4 +590,23 @@ impl Db {
)?;
Ok(())
}
/// Defer a due operation without recording a failed remote attempt.
///
/// Access checks happen before the drain calls Proton, so a locally denied
/// operation has not failed remotely and must not consume a retry. Preserve
/// the long-lived transient-create sentinel if this is ever called for one.
pub fn defer_op_without_attempt(&self, id: i64, next_attempt_at: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op
SET next_attempt_at = CASE
WHEN next_attempt_at >= ?3 THEN next_attempt_at
ELSE ?2
END
WHERE id = ?1",
params![id, next_attempt_at, PARK_UNTIL],
)?;
Ok(())
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Persisted effective access for roots shared with this account.
use std::collections::HashMap;
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::{Access, Error, Result};
impl Db {
/// Load the complete, deliberately small shared-root authority map.
///
/// Mount state keeps this resident so ordinary node interning and subtree
/// propagation never turn into one SQLite query per inode. The database
/// remains the restart/offline authority and every explicit role change is
/// written through before this cache is updated.
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut access = HashMap::new();
for row in rows {
let (uid, value) = row?;
let (volume, link) = uid.split_once('~').ok_or_else(|| {
Error::Other(format!(
"invalid shared-root uid {uid:?} stored in database"
))
})?;
let access_value = Access::from_db_str(&value).ok_or_else(|| {
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
})?;
access.insert(
NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
access_value,
);
}
Ok(access)
}
/// Read the effective access recorded for a shared-tree root.
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
let conn = self.conn.lock();
let value = conn
.query_row(
"SELECT access FROM share_access WHERE root_uid = ?1",
[uid.to_string()],
|row| row.get::<_, String>(0),
)
.optional()?;
value
.map(|value| {
Access::from_db_str(&value).ok_or_else(|| {
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
})
})
.transpose()
}
/// Insert or replace the effective access for a shared-tree root.
pub fn set_share_access(&self, uid: &NodeUid, access: Access) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
params![uid.to_string(), access.as_db_str()],
)?;
Ok(())
}
/// Forget a shared-tree root's effective access.
pub fn delete_share_access(&self, uid: &NodeUid) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM share_access WHERE root_uid = ?1",
[uid.to_string()],
)?;
Ok(())
}
/// Fail closed every persisted shared-tree authority.
///
/// Owned and device trees have no row in `share_access`, so this only
/// affects roots already identified as shared with this account.
pub fn downgrade_all_share_access(&self) -> Result<usize> {
let conn = self.conn.lock();
Ok(conn.execute(
"UPDATE share_access SET access = 'viewer' WHERE access != 'viewer'",
[],
)?)
}
/// Resolve a persisted node's effective access through its nearest recorded
/// share root. `None` means the node itself is not persisted, which callers
/// must not treat as owned: stale handles need to fail closed.
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
let conn = self.conn.lock();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
[uid.to_string()],
|row| row.get(0),
)?;
if !exists {
return Ok(None);
}
let value = conn
.query_row(
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
FROM nodes WHERE uid = ?1
UNION ALL
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
a.path || a.parent_uid || char(31)
FROM ancestors a
LEFT JOIN nodes n ON n.uid = a.parent_uid
WHERE a.parent_uid IS NOT NULL
AND instr(
a.path,
char(31) || a.parent_uid || char(31)
) = 0
)
SELECT sa.access
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
ORDER BY a.depth ASC
LIMIT 1",
[uid.to_string()],
|row| row.get::<_, String>(0),
)
.optional()?;
value
.map(|value| {
Access::from_db_str(&value).ok_or_else(|| {
Error::Other(format!(
"invalid effective share access {value:?} for {uid}"
))
})
})
.transpose()
.map(|access| Some(access.unwrap_or(Access::Owner)))
}
}
+42 -5
View File
@@ -115,14 +115,17 @@ impl Db {
Ok(n > 0)
}
/// Update a synced folder's mode (`mirror`/`ondemand`). The folder has
/// reached the mode it was asked for, so any queued request is satisfied and
/// cleared in the same write — a `pending_mode` outliving the switch it asked
/// for would have the engine try to apply it again on the next pass.
/// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for
/// that mode is satisfied and cleared in the same write. An opposite pending
/// mode arrived after this transition started, so preserve it as the newer
/// last-request intent.
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET mode = ?2, pending_mode = NULL WHERE id = ?1",
"UPDATE sync_folder
SET mode = ?2,
pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END
WHERE id = ?1",
params![id, mode],
)?;
Ok(())
@@ -139,6 +142,18 @@ impl Db {
Ok(())
}
/// Withdraw a queued mode only if it is still the intent the caller
/// inspected. A newer request must survive an older settlement attempt.
pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result<bool> {
let conn = self.conn.lock();
let changed = conn.execute(
"UPDATE sync_folder SET pending_mode = NULL
WHERE id = ?1 AND pending_mode = ?2",
params![id, expected],
)?;
Ok(changed > 0)
}
/// Update a synced folder's state and stamp `last_sync` to now.
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
let conn = self.conn.lock();
@@ -174,6 +189,28 @@ impl Db {
Ok(map)
}
/// Whether `uid` is represented by a mirror location.
///
/// An on-demand location proves residency through its live inode state.
/// Mirror locations have no FUSE state, so their folder root comes from
/// `sync_folder` and descendants come from the last successful
/// `sync_entry` baseline.
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
let conn = self.conn.lock();
conn.query_row(
"SELECT EXISTS(
SELECT 1
FROM sync_folder AS folder
LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id
WHERE folder.mode = 'mirror'
AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1)
)",
params![uid],
|row| row.get(0),
)
.map_err(Into::into)
}
/// Insert or replace one baseline row.
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
let conn = self.conn.lock();
File diff suppressed because it is too large. Load diff
+3
View File
@@ -2,6 +2,7 @@
//! identity, XDG paths, and the login/session/keyring layer that every
//! front-end (CLI today, daemon + GUI later) shares.
pub mod access;
pub mod auth;
pub mod cache;
pub mod config;
@@ -9,12 +10,14 @@ pub mod control;
pub mod db;
pub mod error;
pub mod localindex;
pub mod mounts;
pub mod profile;
pub mod search;
pub mod service;
pub mod shell;
pub mod syncignore;
pub use access::{Access, access_for, perm_bits};
pub use error::{CoreError, CoreResult, Error, Result};
// Re-export the SDK surface downstream crates need, so they depend on
+146
View File
@@ -0,0 +1,146 @@
//! Unified presentation model for every local Proton Drive location.
//!
//! Device sync state remains owned by `sync_folder`; these types are the stable
//! shape exposed to front-ends after that state is joined with the `mount`
//! presentation table.
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::control::SyncProgress;
/// How a location makes remote content available locally.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum MountMode {
/// A full local copy reconciled in both directions.
Mirror,
/// A FUSE session that fetches content as it is accessed.
OnDemand,
/// A mode written by a newer client.
#[default]
#[serde(other)]
Unknown,
}
impl MountMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Mirror => "mirror",
Self::OnDemand => "ondemand",
Self::Unknown => "unknown",
}
}
}
impl From<&str> for MountMode {
fn from(value: &str) -> Self {
match value {
"mirror" => Self::Mirror,
"ondemand" => Self::OnDemand,
_ => Self::Unknown,
}
}
}
impl fmt::Display for MountMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Whether the location itself was declared writable.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MountAccess {
Rw,
Ro,
}
impl MountAccess {
pub const fn as_str(self) -> &'static str {
match self {
Self::Rw => "rw",
Self::Ro => "ro",
}
}
}
impl fmt::Display for MountAccess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// What remote root a local location presents.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum MountKind {
/// The account's primary My Files mount.
MyFiles,
/// A folder owned by this machine's Proton Drive device.
Device { sync_folder_id: i64 },
/// A future standalone mount rooted at a node shared with this account.
Shared { share_root_uid: String },
}
/// One local Proton Drive location as exposed over the control protocol.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MountSpec {
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
/// [`MountKind::Device`], not this id.
pub id: i64,
pub kind: MountKind,
pub local_path: String,
pub root_uid: String,
pub root_share_id: String,
pub mode: MountMode,
pub access: MountAccess,
/// `idle` | `syncing` | `error` | `conflict` for device locations.
pub state: String,
/// Last successful device sync, epoch seconds; `0` when not applicable.
pub last_sync: i64,
/// A device mode switch accepted but not yet applied.
#[serde(default)]
pub pending_mode: Option<MountMode>,
/// Whether a FUSE session currently owns `local_path`.
#[serde(default)]
pub mounted: bool,
/// Live device sync progress, absent when no pass is running.
#[serde(default)]
pub progress: Option<SyncProgress>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mount_mode_keeps_legacy_wire_strings() {
assert_eq!(
serde_json::to_string(&MountMode::Mirror).unwrap(),
r#""mirror""#
);
assert_eq!(
serde_json::to_string(&MountMode::OnDemand).unwrap(),
r#""ondemand""#
);
assert_eq!(
serde_json::from_str::<MountMode>(r#""mirror""#).unwrap(),
MountMode::Mirror
);
assert_eq!(
serde_json::from_str::<MountMode>(r#""ondemand""#).unwrap(),
MountMode::OnDemand
);
}
#[test]
fn mount_mode_tolerates_unknown_values() {
assert_eq!(
serde_json::from_str::<MountMode>(r#""from-the-future""#).unwrap(),
MountMode::Unknown
);
}
}
+52 -3
View File
@@ -17,6 +17,8 @@
use serde::{Deserialize, Serialize};
use crate::mounts::MountMode;
/// File name of the profile inside the device root folder.
pub const PROFILE_FILE_NAME: &str = "profile.json";
@@ -39,8 +41,9 @@ pub struct ProfileFolder {
/// Absolute local path it was synced to. A *suggestion* on restore: another
/// machine may have a different username, home, or mount layout.
pub local_path: String,
/// `mirror` or `ondemand`.
pub mode: String,
/// `mirror` or `ondemand`. Unknown values written by a newer client do not
/// invalidate the rest of the profile.
pub mode: MountMode,
}
/// One pinned node (P5 pins), as `pins` stores it.
@@ -114,7 +117,7 @@ mod tests {
folders: vec![ProfileFolder {
remote_uid: "vol~link".to_string(),
local_path: "/home/nils/Documents".to_string(),
mode: "mirror".to_string(),
mode: MountMode::Mirror,
}],
pins: vec![ProfilePin {
uid: "vol~pin".to_string(),
@@ -133,6 +136,52 @@ mod tests {
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
}
#[test]
fn profile_keeps_legacy_mode_bytes_and_parses_old_documents() {
let bytes = sample().to_bytes().unwrap();
let json = String::from_utf8(bytes).unwrap();
assert!(json.contains(r#""mode": "mirror""#));
let old = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "old-client",
"saved_at": 1,
"folders": [
{
"remote_uid": "vol~mirror",
"local_path": "/home/me/Mirror",
"mode": "mirror"
},
{
"remote_uid": "vol~ondemand",
"local_path": "/home/me/Cloud",
"mode": "ondemand"
}
]
}"#;
let profile = Profile::parse(old).unwrap();
assert_eq!(profile.folders[0].mode, MountMode::Mirror);
assert_eq!(profile.folders[1].mode, MountMode::OnDemand);
}
#[test]
fn unknown_folder_mode_does_not_reject_profile() {
let json = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "future-client",
"saved_at": 1,
"folders": [{
"remote_uid": "vol~future",
"local_path": "/home/me/Future",
"mode": "streaming"
}]
}"#;
let profile = Profile::parse(json).unwrap();
assert_eq!(profile.folders[0].mode, MountMode::Unknown);
}
/// A field added by a later client must not break this one's restore: the
/// common subset is still usable, which is the whole reason `version` is
/// reserved for breaking changes only.
+72
View File
@@ -45,6 +45,45 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
"Thumbs.db",
];
/// Suffixes of files that are, by convention, incomplete or throwaway while they
/// wear the name: a download in flight, an editor's swap or backup, an atomic
/// write's scratch copy. On an on-demand mount these should never seal a remote
/// revision — the app renames the finished file to its real name, and only that
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
const TRANSIENT_SUFFIXES: &[&str] = &[
".crdownload", // Chromium/Brave/Edge
".part", // Firefox, wget, aria2c
".partial", // some downloaders / older IE
".download", // Safari / macOS
".tmp", // generic scratch
".temp", // generic scratch
".swp", // vim swap
".swx", // vim swap (secondary)
];
/// Whether `name` is a transient scratch/in-flight file whose bytes should not
/// be uploaded on an on-demand mount until it is renamed to its finished name.
///
/// Recognises browser download temps, editor swap/backup files, and lock files.
/// Case-insensitive on the suffix so `FOO.CRDOWNLOAD` counts too. This is the
/// on-demand-write counterpart to [`DEFAULT_IGNORE_PATTERNS`] (which only covers
/// mirror-folder reconcile); keep the two conceptually in step.
pub fn is_transient_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if TRANSIENT_SUFFIXES.iter().any(|s| lower.ends_with(s)) {
return true;
}
// Emacs/gedit/kate backup copies.
if name.ends_with('~') {
return true;
}
// LibreOffice lock files (`.~lock.<doc>#`) and MS Office temps (`~$doc`).
if name.starts_with(".~lock.") || name.starts_with("~$") {
return true;
}
false
}
/// Filenames accepted as a folder's ignore file, in precedence order.
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
@@ -272,4 +311,37 @@ mod tests {
// have nothing to reconcile against.
assert!(!rules.is_ignored("", true));
}
#[test]
fn transient_download_and_editor_names_are_recognised() {
for name in [
"Unconfirmed 540477.crdownload",
"movie.mkv.part",
"big.iso.partial",
"photo.jpg.download",
"export.xml.tmp",
"notes.TEMP",
".notes.txt.swp",
"doc.txt~",
".~lock.report.odt#",
"~$budget.xlsx",
"FOO.CRDOWNLOAD",
] {
assert!(is_transient_name(name), "{name} should be transient");
}
}
#[test]
fn finished_names_are_not_transient() {
for name in [
"teamspeak.tar.gz",
"nils.zip",
"report.xml",
"archive.partly.zip", // ".partly" is not ".part"
"template.dotx",
"readme",
] {
assert!(!is_transient_name(name), "{name} should not be transient");
}
}
}
+598 -79
View File
@@ -2,19 +2,151 @@
use super::*;
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
/// entry is gone. Shared by the trash and delete paths, which differ only in
/// which event carried them.
///
/// Returns whether this mount actually held the node, so the caller can evict
/// its content blob once if *any* mount did.
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
// Capture the inode before `forget` clears the uid mapping.
let child = st.by_uid.get(uid).copied();
let Some((parent, name)) = st.forget(uid) else {
return false;
};
if let Some(notifier) = notifier {
match child {
Some(child) => {
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
}
None => {
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
}
}
}
true
}
fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) {
let Some(notifier) = notifier else {
return;
};
for &ino in changed {
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
}
fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) {
let child = st.by_uid.get(uid).copied();
let dentry = child.and_then(|ino| {
st.entries
.get(&ino)
.map(|entry| (entry.parent, entry.node.name.clone()))
});
let changed = st.downgrade_shared_subtree(uid);
st.hide_foreign_subtree(uid);
invalidate_access_changes(notifier, &changed);
if let (Some(notifier), Some((parent, name)), Some(child)) = (notifier, dentry, child) {
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
}
}
/// Persist the fail-closed part of an access event and install the same denial
/// in resident state even when SQLite rejects the write. The caller must not
/// acknowledge the event when this returns an error.
fn apply_access_downgrade(
db: &Db,
event: &DriveEvent,
mut deny: impl FnMut(Option<&NodeUid>),
) -> pdfs_core::Result<()> {
match event {
DriveEvent::NodeDeleted { node_uid, .. } => {
let result = db.set_share_access(node_uid, Access::Viewer);
deny(Some(node_uid));
result
}
DriveEvent::ContinuityLost { .. }
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => {
let result = db.downgrade_all_share_access().map(|_| ());
deny(None);
result
}
_ => Ok(()),
}
}
fn apply_foreign_delete(
db: &Db,
uid: &NodeUid,
mut deny_and_hide: impl FnMut(&NodeUid),
) -> pdfs_core::Result<()> {
let result = db.tombstone_foreign_subtree(uid);
deny_and_hide(uid);
result
}
fn is_foreign_node_delete(event: &DriveEvent, own_volume: &VolumeId) -> bool {
match event {
DriveEvent::NodeDeleted { node_uid, .. } => {
!is_own_or_virtual_uid(node_uid, own_volume) && !is_local_uid(node_uid)
}
_ => false,
}
}
fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId) -> bool {
matches!(
event,
DriveEvent::ContinuityLost { .. }
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. }
) || is_foreign_node_delete(event, own_volume)
}
/// Apply one remote event to the local cache and notify the kernel so it drops
/// any stale cached metadata/data for the affected inodes.
///
/// The cache is authoritative-by-absence: dropping a directory's `children`
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
/// most events only need to invalidate listings rather than re-fetch eagerly.
fn apply_event(
state: &Mutex<State>,
content: &ContentCache,
pending: &Mutex<HashMap<NodeUid, PendingRevision>>,
notifier: &Notifier,
event: &DriveEvent,
) {
///
/// Applied to **every** mounted inode space, not just the primary one. This task
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid,
/// not a mount — so a file trashed from another device has to be withdrawn from
/// whichever of our sessions is showing it, which for anything under a sync
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for
/// the primary state alone left forks serving a deleted file indefinitely, the
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
/// mount must be notified through its **own** channel; that pairing is what
/// [`Core::for_each_mount`] exists to preserve.
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> {
let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
let serializes_shared_publication =
event_serializes_shared_publication(event, &core.primary_root_uid.volume_id);
let _publication = serializes_shared_publication.then(|| core.shared_publication.lock());
if serializes_shared_publication {
core.invalidate_shared_refreshes();
}
let mut apply_result = if let DriveEvent::NodeDeleted { node_uid, .. } = event
&& foreign_delete
{
apply_foreign_delete(&core.db, node_uid, |uid| {
core.for_each_mount(|st, notifier| {
hide_foreign_deleted_and_notify(st, notifier, uid);
});
})
} else {
apply_access_downgrade(&core.db, event, |root| {
core.for_each_mount(|st, notifier| {
let changed = match root {
Some(uid) => st.downgrade_shared_subtree(uid),
None => st.downgrade_known_shared_access(),
};
invalidate_access_changes(notifier, &changed);
});
})
};
match event {
DriveEvent::NodeUpdated {
node_uid,
@@ -22,79 +154,183 @@ fn apply_event(
is_trashed,
..
} => {
let mut st = state.lock();
if *is_trashed {
// Trashing makes a node vanish from its parent listing.
let child = st.by_uid.get(node_uid).copied();
if let Some((parent, name)) = st.forget(node_uid) {
content.evict(node_uid);
match child {
Some(child) => {
let _ =
notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
}
None => {
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
}
// A node we owe an upload for is *ahead* of the remote, not behind
// it: this event is almost always the echo of our own empty-file
// create, and re-fetching would replace the size and mtime of the
// write we just accepted with the stale revision's — making a file
// that was copied in seconds ago read as empty until its upload
// lands (offline.md Phase 3).
//
// A write that has already drained is the same story one step later:
// the drain brought the tree level with the revision it sealed, so
// the feed's report of that revision has nothing to add, and acting
// on it evicts the very bytes we uploaded. Claimed unconditionally,
// even for a trash, so an echo we no longer care about does not
// linger to suppress a later foreign change.
let echo = core.take_self_change(node_uid);
let ours = !*is_trashed && (echo || core.pending.lock().contains_key(node_uid));
if ours {
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
}
let mut had_node = false;
core.for_each_mount(|st, notifier| {
if *is_trashed {
// Trashing makes a node vanish from its parent listing.
had_node |= forget_and_notify(st, notifier, node_uid);
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
// Known node changed: drop its cached attrs/data (and
// listing if it is a directory) so the next access
// re-fetches. Its content blob may now be stale too.
had_node = true;
st.invalidate_listing(ino);
if let Some(notifier) = notifier {
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
}
} else if pending.lock().contains_key(node_uid) {
// A node we owe an upload for is *ahead* of the remote, not
// behind it: this event is almost always the echo of our own
// empty-file create, and re-fetching would replace the size and
// mtime of the write we just accepted with the stale revision's
// — making a file that was copied in seconds ago read as empty
// until its upload lands (offline.md Phase 3).
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
} else if let Some(&ino) = st.by_uid.get(node_uid) {
// Known node changed: drop its cached attrs/data (and listing if
// it is a directory) so the next access re-fetches. Its content
// blob may now be stale, so evict it too.
st.invalidate_listing(ino);
content.evict(node_uid);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
});
// Once, not once per mount: the content cache is per-daemon.
if had_node {
core.cache.evict(node_uid);
}
// A create (or move-in) shows up as a change to the parent listing;
// drop it so the new child is picked up on the next readdir.
if let Some(parent_uid) = parent_node_uid
&& let Some(&parent) = st.by_uid.get(parent_uid)
{
st.invalidate_listing(parent);
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
// A create, rename or move-in shows up as a change to the parent
// listing too, which is recorded for the end of the batch rather
// than acted on here — see [`DirtyParents`].
if let Some(parent_uid) = parent_node_uid {
match ours {
true => dirty
.ours
.entry(parent_uid.clone())
.or_default()
.insert(node_uid.clone()),
false => dirty.foreign.insert(parent_uid.clone()),
};
}
}
DriveEvent::NodeDeleted { node_uid, .. } => {
let mut st = state.lock();
// Capture the inode before `forget` clears the uid mapping.
let child = st.by_uid.get(node_uid).copied();
content.evict(node_uid);
if let Some((parent, name)) = st.forget(node_uid) {
match child {
Some(child) => {
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
}
None => {
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
}
}
core.cache.evict(node_uid);
if !foreign_delete {
core.for_each_mount(|st, notifier| {
forget_and_notify(st, notifier, node_uid);
});
}
}
// Continuity or scope was lost: our cached listings may be arbitrarily
// stale, so drop every listing and tell the kernel to forget all
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
let mut st = state.lock();
let dirs: Vec<u64> = st.children.keys().copied().collect();
for &ino in &dirs {
st.invalidate_listing(ino);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
// Losing event continuity or any shared-access signal makes persisted
// shared roots untrustworthy. Keep owned/device trees unchanged, but
// fail every known shared subtree closed until a root refresh carries a
// current membership role.
DriveEvent::ContinuityLost { .. }
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => {
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
core.for_each_mount(|st, notifier| {
let dirs: Vec<u64> = st.children.keys().copied().collect();
for &ino in &dirs {
st.invalidate_listing(ino);
if let Some(notifier) = notifier {
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
}
});
}
// No substantive local change; the cursor advance is handled by the
// caller persisting the event id.
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
DriveEvent::CursorAdvanced { .. } => {}
}
apply_result
}
fn acknowledge_applied_event(
db: &Db,
event: &DriveEvent,
applied: pdfs_core::Result<()>,
) -> pdfs_core::Result<DriveEventId> {
applied?;
let id = event.id().clone();
db.set_event_cursor(id.as_str())?;
Ok(id)
}
/// The folders one batch of events changed, collected so the listings are
/// dropped once at the end of the batch rather than once per event.
///
/// Both halves of that matter. A burst of uploads produces one event per file
/// all naming the *same* folder, and dropping the listing per event made every
/// interleaved lookup re-enumerate that folder from the API — quadratic in its
/// size, and slow enough to stall a busy mount. Splitting foreign changes from
/// our own then removes the re-enumeration entirely for the common case: a file
/// this daemon created is already in the tree under the right parent, so there
/// is nothing for a re-enumeration to discover.
///
/// Neither is a weakening. The state after the batch is what it always was.
#[derive(Default)]
struct DirtyParents {
/// Folders changed by someone else. Their listings must go: an event names
/// no name, so a rename inside a folder is indistinguishable from a write to
/// a file in it, and only re-enumerating can tell us which happened.
foreign: HashSet<NodeUid>,
/// Folders changed by this daemon, and the children the changes were about.
/// Checked per mount — a mount that already lists every one of those
/// children under that parent keeps its listing.
ours: HashMap<NodeUid, HashSet<NodeUid>>,
}
/// Whether this mount's cached listing of `parent_uid` already accounts for
/// `child_uid`, so a change we made to that child leaves nothing to re-read.
///
/// "Nothing cached" counts as current: an absent listing is re-enumerated on the
/// next `readdir` regardless, so there is nothing to drop.
fn listing_accounts_for(st: &State, parent_uid: &NodeUid, child_uid: &NodeUid) -> bool {
let Some(&parent) = st.by_uid.get(parent_uid) else {
return true;
};
let Some(children) = st.children.get(&parent) else {
return true;
};
let Some(&child) = st.by_uid.get(child_uid) else {
return false;
};
children.contains(&child) && st.entries.get(&child).is_some_and(|e| e.parent == parent)
}
/// Drop the cached listing of every folder a batch of events changed, in every
/// mount that holds one, so the next `readdir`/`lookup` re-enumerates it.
///
/// Runs strictly after the whole batch is applied: doing it per event would
/// leave a window where a concurrent `readdir` re-populates a listing that a
/// later event in the same batch then has to drop again.
fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
return;
}
core.for_each_mount(|st, notifier| {
let drop_listing = |st: &mut State, parent_uid: &NodeUid| {
let Some(&parent) = st.by_uid.get(parent_uid) else {
return;
};
st.invalidate_listing(parent);
if let Some(notifier) = notifier {
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
}
};
for parent_uid in &dirty.foreign {
drop_listing(st, parent_uid);
}
for (parent_uid, children) in &dirty.ours {
if dirty.foreign.contains(parent_uid) {
continue;
}
// One child this mount has not placed is enough: the listing it is
// serving is missing something the user just made.
if children
.iter()
.all(|child| listing_accounts_for(st, parent_uid, child))
{
continue;
}
drop_listing(st, parent_uid);
}
});
}
/// Poll the remote event cursor forever, applying each batch to the shared
@@ -102,15 +338,16 @@ fn apply_event(
/// unmounted are applied; only a first-ever mount seeds from the server head.
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
/// only on fatal error.
///
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
/// to be applied to every mounted inode space, and the registry that enumerates
/// them lives on the `Core` (see [`apply_event`]).
pub(super) async fn run_event_sync(
client: ProtonDriveClient,
scope: DriveEventScopeId,
state: Arc<Mutex<State>>,
content: Arc<ContentCache>,
db: Arc<Db>,
pending: Arc<Mutex<HashMap<NodeUid, PendingRevision>>>,
notifier: Notifier,
core: Core,
) {
let db = core.db.clone();
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
// Resume: pick up exactly where the last run left off.
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
@@ -165,6 +402,11 @@ pub(super) async fn run_event_sync(
continue;
}
debug!(count = events.len(), "applying remote events");
// Folders whose listing this batch invalidates, applied once at the end
// rather than per event — see [`DirtyParents`]. Every `break` below falls
// through to that flush, so an interrupted batch still publishes what it
// did apply.
let mut dirty = DirtyParents::default();
for event in &events {
// Converge the SDK's own caches (folder keys, entity cache) on the
// server before applying the event to our tree. Without this, a node
@@ -175,17 +417,23 @@ pub(super) async fn run_event_sync(
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
break;
}
apply_event(&state, &content, &pending, &notifier, event);
let applied = event.id().clone();
if let Err(e) = db.set_event_cursor(applied.as_str()) {
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
break;
}
let applied = match acknowledge_applied_event(
&db,
event,
apply_event(&core, event, &mut dirty),
) {
Ok(applied) => applied,
Err(e) => {
warn!(error = %e, "event application or acknowledgment failed; retaining prior cursor for retry");
break;
}
};
// Advance only after this exact event is durably acknowledged. If a
// later event fails, polling resumes from here and replays nothing
// that was not already made visible locally.
cursor = Some(applied);
}
flush_dirty_parents(&core, &dirty);
}
}
@@ -274,3 +522,274 @@ fn scan_local_once(
}
indexing.store(false, Ordering::Relaxed);
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}
fn folder(volume: &str, link: &str, parent: Option<NodeUid>, name: &str) -> Node {
Node {
uid: NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
parent_uid: parent,
kind: NodeKind::Folder,
name: name.to_string(),
creation_time: 1,
modification_time: 1,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
#[test]
fn foreign_delete_invalidates_an_earlier_refresh_generation() {
let own_volume = VolumeId::from("own");
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("foreign-delete"),
node_uid: NodeUid::new(VolumeId::from("foreign"), LinkId::from("child")),
parent_node_uid: None,
};
let generation = AtomicU64::new(7);
let captured = generation.load(Ordering::SeqCst);
assert!(event_serializes_shared_publication(&event, &own_volume));
generation.fetch_add(1, Ordering::SeqCst);
assert!(!refresh_generation_is_current(
captured,
generation.load(Ordering::SeqCst)
));
let own_delete = DriveEvent::NodeDeleted {
id: DriveEventId::from("own-delete"),
node_uid: NodeUid::new(own_volume.clone(), LinkId::from("child")),
parent_node_uid: None,
};
assert!(!event_serializes_shared_publication(
&own_delete,
&own_volume
));
}
#[test]
fn foreign_delete_tombstones_subtree_and_advances_cursor_after_success() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = folder("own", "root", None, "My Files");
let deleted = folder(
"foreign",
"deleted",
Some(root.uid.clone()),
"Deleted foreign",
);
let deep = folder(
"foreign",
"deep",
Some(deleted.uid.clone()),
"Searchable descendant",
);
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
.unwrap();
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_RENAME.to_string(),
uid: deep.uid.to_string(),
parent_uid: Some(deleted.uid.to_string()),
name: Some("pending".to_string()),
blob_path: None,
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("foreign-delete-success"),
node_uid: deleted.uid.clone(),
parent_node_uid: deleted.parent_uid.clone(),
};
let denied = Cell::new(false);
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
assert!(denied.get());
assert_eq!(acknowledged.as_str(), "foreign-delete-success");
assert_eq!(
db.get_event_cursor().unwrap().as_deref(),
Some("foreign-delete-success")
);
for uid in [&deleted.uid, &deep.uid] {
assert!(db.node_by_uid(&uid.to_string()).unwrap().unwrap().trashed);
}
assert!(db.search("Searchable", 10).unwrap().is_empty());
assert_eq!(db.pending_ops().unwrap().len(), 1);
assert_eq!(db.share_access(&deleted.uid).unwrap(), Some(Access::Viewer));
}
#[test]
fn foreign_delete_db_failure_denies_memory_and_retains_prior_cursor() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = folder("own", "root", None, "My Files");
let deleted = folder(
"foreign",
"deleted",
Some(root.uid.clone()),
"Deleted foreign",
);
let deep = folder(
"foreign",
"deep",
Some(deleted.uid.clone()),
"Still searchable",
);
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
.unwrap();
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
db.set_event_cursor("prior-event").unwrap();
db.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_foreign_tombstone
BEFORE UPDATE OF trashed ON nodes
WHEN OLD.uid = 'foreign~deleted'
BEGIN SELECT RAISE(ABORT, 'forced tombstone failure'); END;",
)?;
Ok(())
})
.unwrap();
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("foreign-delete-failed"),
node_uid: deleted.uid.clone(),
parent_node_uid: deleted.parent_uid.clone(),
};
let denied = Cell::new(false);
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
assert!(applied.is_err());
assert!(
denied.get(),
"resident state must fail closed on DB failure"
);
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert_eq!(
db.get_event_cursor().unwrap().as_deref(),
Some("prior-event")
);
assert!(
!db.node_by_uid(&deleted.uid.to_string())
.unwrap()
.unwrap()
.trashed
);
assert_eq!(db.search("searchable", 10).unwrap().len(), 1);
assert_eq!(
db.share_access(&deleted.uid).unwrap(),
Some(Access::Editor),
"the failed atomic transaction must not partially publish"
);
}
#[test]
fn deleted_share_downgrade_failure_denies_memory_and_retains_cursor() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = uid("shared");
db.set_share_access(&root, Access::Editor).unwrap();
db.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_share_update
BEFORE UPDATE ON share_access
BEGIN SELECT RAISE(FAIL, 'forced downgrade failure'); END;",
)?;
Ok(())
})
.unwrap();
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("event-1"),
node_uid: root,
parent_node_uid: None,
};
let denied = Cell::new(false);
let applied = apply_access_downgrade(&db, &event, |root| {
denied.set(root.is_some());
});
assert!(applied.is_err());
assert!(denied.get(), "resident state must fail closed immediately");
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert!(
db.get_event_cursor().unwrap().is_none(),
"failed downgrade must leave the event unacknowledged"
);
}
#[test]
fn deleted_resident_root_without_share_row_is_denied_and_tombstoned() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = uid("resident-without-row");
assert!(db.share_access(&root).unwrap().is_none());
let resident = std::cell::RefCell::new(std::collections::HashMap::from([(
root.clone(),
Access::Editor,
)]));
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("event-missing-row"),
node_uid: root.clone(),
parent_node_uid: None,
};
let applied = apply_access_downgrade(&db, &event, |target| {
assert_eq!(target, Some(&root));
resident.borrow_mut().insert(root.clone(), Access::Viewer);
});
assert!(applied.is_ok());
assert_eq!(resident.borrow().get(&root), Some(&Access::Viewer));
assert_eq!(db.share_access(&root).unwrap(), Some(Access::Viewer));
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
assert_eq!(acknowledged.as_str(), "event-missing-row");
assert_eq!(
db.get_event_cursor().unwrap().as_deref(),
Some("event-missing-row")
);
}
#[test]
fn continuity_downgrade_failure_denies_memory_and_retains_cursor() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = uid("shared");
db.set_share_access(&root, Access::Editor).unwrap();
db.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_all_share_updates
BEFORE UPDATE ON share_access
BEGIN SELECT RAISE(FAIL, 'forced global downgrade failure'); END;",
)?;
Ok(())
})
.unwrap();
let event = DriveEvent::ContinuityLost {
id: DriveEventId::from("event-2"),
};
let denied_all = Cell::new(false);
let applied = apply_access_downgrade(&db, &event, |root| {
denied_all.set(root.is_none());
});
assert!(applied.is_err());
assert!(
denied_all.get(),
"all resident shared roots must fail closed immediately"
);
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert!(db.get_event_cursor().unwrap().is_none());
}
}
+78
View File
@@ -561,6 +561,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(items) => CtlResponse::Devices { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListLocations) => match core.list_locations() {
Ok(items) => CtlResponse::Locations { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) {
Ok(()) => CtlResponse::Ok {
message: format!("renamed device to {name}"),
@@ -692,6 +696,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ShareNodeByUid {
uid,
emails,
role,
message,
}) => match core.share_node_by_uid(&uid, &emails, &role, message.as_deref()) {
Ok((proton, external)) => {
core.log_activity(
ActivityKind::Share,
&uid,
format!("{} recipient(s) as {role}", proton + external),
true,
);
CtlResponse::Ok {
message: format!("invited {proton} Proton and {external} external user(s)"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Share, &uid, &e, false);
CtlResponse::error(e)
}
},
Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_share(&rel) {
Ok((entries, link)) => CtlResponse::Share { entries, link },
@@ -699,6 +725,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListShareByUid { uid }) => match core.list_share_by_uid(&uid) {
Ok((entries, link)) => CtlResponse::Share { entries, link },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UpdateShareRole {
path,
id,
@@ -713,6 +743,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UpdateShareRoleByUid {
uid,
id,
kind,
role,
}) => match core.update_share_role_by_uid(&uid, &id, kind, &role) {
Ok(()) => CtlResponse::Ok {
message: format!("role updated to {role}"),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => {
match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_share_entry(&rel, &id, kind) {
@@ -727,6 +768,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::RemoveShareEntryByUid { uid, id, kind }) => {
match core.remove_share_entry_by_uid(&uid, &id, kind) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, &uid, "access removed", true);
CtlResponse::Ok {
message: "removed".to_string(),
}
}
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::CreatePublicLink {
path,
role,
@@ -745,6 +797,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::CreatePublicLinkByUid {
uid,
role,
password,
expires,
}) => match core.create_public_link_by_uid(&uid, &role, password.as_deref(), expires) {
Ok(link) => {
core.log_activity(ActivityKind::PublicLink, &uid, "link created", true);
CtlResponse::PublicLink { link }
}
Err(e) => {
core.log_activity(ActivityKind::PublicLink, &uid, &e, false);
CtlResponse::error(e)
}
},
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_public_link(&rel, &id) {
Ok(()) => {
@@ -757,6 +824,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemovePublicLinkByUid { uid, id }) => {
match core.remove_public_link_by_uid(&uid, &id) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, &uid, "link removed", true);
CtlResponse::Ok {
message: "public link removed".to_string(),
}
}
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e),
+424 -117
View File
@@ -11,12 +11,13 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fuser::{Config, MountOption, Session};
use parking_lot::Mutex;
use pdfs_core::control::{
ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress,
ActivityKind, DeviceInfo, JobItem, MountKind, MountSpec, SyncFolderInfo, SyncPhase,
SyncProgress,
};
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
use pdfs_core::mounts::MountMode;
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
use proton_drive_rs::{DeviceType, Node};
@@ -25,11 +26,69 @@ use tracing::{info, warn};
use super::sync::{self, base_name};
use super::{
BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str,
dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount, now_secs, parse_uid,
sync_folder_info, this_hostname, umount_session_unblocked,
Core, SecondaryInsertRejection, SecondaryMount, State, SwitchBlocked, clear_stale_mount,
device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount,
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
};
/// A mode that this daemon is allowed to apply.
///
/// Persisted and control-protocol values first pass through [`MountMode`] so
/// forward-compatible unknown values never reach either destructive transition.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ApplicableMountMode {
Mirror,
OnDemand,
}
impl ApplicableMountMode {
fn parse(value: &str) -> Result<Self, String> {
match MountMode::from(value) {
MountMode::Mirror => Ok(Self::Mirror),
MountMode::OnDemand => Ok(Self::OnDemand),
MountMode::Unknown => Err(format!("unknown mode {value:?} (want mirror|ondemand)")),
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Mirror => "mirror",
Self::OnDemand => "ondemand",
}
}
}
fn select_mode_transition(
current: &str,
target: &str,
) -> Result<Option<ApplicableMountMode>, String> {
let current = ApplicableMountMode::parse(current)
.map_err(|error| format!("unsupported current mode: {error}"))?;
let target = ApplicableMountMode::parse(target)
.map_err(|error| format!("unsupported target mode: {error}"))?;
Ok((current != target).then_some(target))
}
fn restore_snapshot_matches(snapshot: &StoredSyncFolder, current: &StoredSyncFolder) -> bool {
current.id == snapshot.id
&& current.local_path == snapshot.local_path
&& current.remote_uid == snapshot.remote_uid
&& current.remote_share_id == snapshot.remote_share_id
&& current.mode == "ondemand"
}
fn pending_intent_matches(pending: Option<&str>, expected: Option<&str>) -> bool {
expected.is_none_or(|expected| pending == Some(expected))
}
fn commit_after_teardown<T, E>(
teardown: Result<(), E>,
commit: impl FnOnce() -> T,
) -> Result<T, E> {
teardown?;
Ok(commit())
}
/// The device uid this machine has been adopted into, from `config.json`.
///
/// Read fresh each time rather than cached on [`Core`]: adoption rewrites the
@@ -57,6 +116,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
impl Core {
// ---- devices ----------------------------------------------------------
/// Unified local-location listing for the control protocol.
pub(crate) fn list_locations(&self) -> CoreResult<Vec<MountSpec>> {
let progress = self.sync_progress.lock();
let mut locations = self
.db
.mount_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
for location in &mut locations {
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
if let MountKind::Device { sync_folder_id } = &location.kind {
location.progress = progress.get(sync_folder_id).cloned();
}
}
Ok(locations)
}
/// List the account's registered devices, flagging the one *this* machine
/// syncs to so a front-end can treat it as more than another computer in the
/// list — deleting it takes this machine's synced folders down with it.
@@ -439,6 +514,8 @@ impl Core {
/// Remove a synced folder from the sync set. `delete_remote` also deletes its
/// folder under the device root; otherwise the cloud copy is left in place.
pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> {
let lock = self.sync_lock(id);
let _guard = lock.lock();
let folder = self
.db
.sync_folder_get(id)
@@ -449,19 +526,16 @@ impl Core {
// folder the daemon no longer tracks, and nothing would ever unmount it.
// Tear it down first — including before trashing the remote tree it
// serves, which would otherwise leave it answering for deleted nodes.
if let Some((session, conn)) = self.mounts.lock().remove(&id) {
if let Err(e) = umount_session_unblocked(session, conn) {
warn!(id, error = %e, "unmount on-demand folder failed");
} else {
info!(id, path = %folder.local_path, "unmounted on-demand folder");
}
}
if delete_remote
&& let Some(uid) = parse_uid(&folder.remote_uid)
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
if let Some(mount) = self.mounts.lock().remove(&id)
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
{
warn!(id, error = %e, "delete remote device folder failed");
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
return Err(CoreError::internal(format!(
"cannot remove synced folder: unmount {} failed: {error}",
folder.local_path
)));
}
info!(id, path = %folder.local_path, "unmounted on-demand folder");
if !self
.db
.sync_folder_remove(id)
@@ -469,8 +543,15 @@ impl Core {
{
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
}
drop(_guard);
// Stop watching the folder we just dropped.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
if delete_remote
&& let Some(uid) = parse_uid(&folder.remote_uid)
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
{
warn!(id, error = %e, "delete remote device folder failed");
}
Ok(())
}
@@ -489,6 +570,8 @@ impl Core {
/// (devices.md Phase 3).
pub(crate) fn fork_state(&self) -> Core {
let mut fork = self.clone();
fork.primary = false;
let share_access = self.state.lock().share_access.clone();
fork.state = Arc::new(Mutex::new(State {
entries: HashMap::new(),
by_uid: HashMap::new(),
@@ -497,12 +580,15 @@ impl Core {
active_writes: HashMap::new(),
handles: HashMap::new(),
next_fh: 1,
access_changes: std::collections::HashSet::new(),
share_access,
db: self.db.clone(),
}));
// A fresh inode space needs a fresh notification channel: this fork's
// session is the only one that knows these inodes, so it must be the one
// notified about them. Filled in by `spawn_ondemand_mount`.
fork.notifier = Arc::new(OnceLock::new());
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
// Size upgrades are keyed by inode, which is per-fork too.
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
fork
@@ -520,49 +606,84 @@ impl Core {
/// running — which, seeing a queued `ondemand`, also stops pulling down files
/// it is about to evict ([`Core::push_pass`]).
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> {
if mode != "mirror" && mode != "ondemand" {
return Err(CoreError::invalid(format!(
"unknown mode {mode:?} (want mirror|ondemand)"
)));
}
// Preserve the existing control behavior: reject an unsupported request
// before looking up the folder, then validate the persisted source too.
let target_mode = ApplicableMountMode::parse(mode).map_err(CoreError::invalid)?;
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 == mode {
// Already there. A queued switch the other way is the user changing
// their mind back before it landed, so withdraw it.
if folder.pending_mode.is_some() {
self.db
.sync_folder_set_pending_mode(id, None)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
return Ok(format!("staying {mode}"));
let current_mode = ApplicableMountMode::parse(&folder.mode)
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
if current_mode == target_mode {
let mode_str = target_mode.as_str();
let lock = self.sync_lock(id);
match lock.try_lock() {
Some(_guard) => {
let current = 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}"))
})?;
let locked_mode =
ApplicableMountMode::parse(&current.mode).map_err(|error| {
CoreError::invalid(format!("unsupported current mode: {error}"))
})?;
if locked_mode == target_mode {
self.db
.sync_folder_set_pending_mode(id, None)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
return Ok(if current.pending_mode.is_some() {
format!("staying {mode_str}")
} else {
format!("already {mode_str}")
});
}
}
None => {
// A transition or reconcile owns the folder lock. Record
// this request instead of clearing the old target: if a
// queued switch already passed its recheck, its mode write
// preserves this opposite marker and the engine switches
// back next.
self.db
.sync_folder_set_pending_mode(id, Some(mode_str))
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
return Ok(format!("staying {mode_str}"));
}
}
return Ok(format!("already {mode}"));
}
let mode = target_mode;
let mode_str = target_mode.as_str();
match self.apply_sync_folder_mode(id, mode) {
match self.apply_sync_folder_mode(id, mode, None) {
Ok(message) => Ok(message),
// Not switchable this instant — remember the intent instead of making
// the user retry, and kick a pass to clear whatever is in the way.
Err(SwitchBlocked::NotNow) => {
self.db
.sync_folder_set_pending_mode(id, Some(mode))
.sync_folder_set_pending_mode(id, Some(mode_str))
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
Ok(match mode {
"ondemand" => format!(
ApplicableMountMode::OnDemand => format!(
"{} will go on-demand once its local changes are uploaded",
base_name(&folder.local_path)
),
_ => format!(
ApplicableMountMode::Mirror => format!(
"{} will start mirroring once the current sync finishes",
base_name(&folder.local_path)
),
})
}
Err(SwitchBlocked::Superseded) => Err(CoreError::internal(
"direct mode request was unexpectedly superseded",
)),
Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)),
}
}
@@ -576,27 +697,45 @@ impl Core {
let Ok(Some(folder)) = self.db.sync_folder_get(id) else {
return;
};
let Some(mode) = folder.pending_mode.as_deref() else {
let Some(expected_mode) = folder.pending_mode.as_deref() else {
return;
};
if folder.mode == mode {
let _ = self.db.sync_folder_set_pending_mode(id, None);
return;
}
match self.apply_sync_folder_mode(id, mode) {
let mode = match select_mode_transition(&folder.mode, expected_mode) {
Ok(Some(mode)) => mode,
Ok(None) => {
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
return;
}
Err(error) => {
warn!(
id,
current_mode = %folder.mode,
pending_mode = %expected_mode,
%error,
"withdrawing unsafe queued mode transition"
);
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
return;
}
};
let mode_str = mode.as_str();
match self.apply_sync_folder_mode(id, mode, Some(expected_mode)) {
Ok(message) => {
info!(id, mode, "applied queued mode switch");
info!(id, mode = mode_str, "applied queued mode switch");
self.log_activity(ActivityKind::Sync, &message, "", true);
}
// Still blocked: the pass could not get everything up, so the local copy
// is not safe to evict yet. Leave the request standing — the next pass
// (poll, or the retry the engine schedules) tries again.
Err(SwitchBlocked::NotNow) => {
info!(id, mode, "queued mode switch still waiting");
info!(id, mode = mode_str, "queued mode switch still waiting");
}
Err(SwitchBlocked::Superseded) => {
info!(id, mode = mode_str, "queued mode switch was replaced");
}
Err(SwitchBlocked::Failed(e)) => {
warn!(id, mode, error = %e, "queued mode switch failed; withdrawing");
let _ = self.db.sync_folder_set_pending_mode(id, None);
warn!(id, mode = mode_str, error = %e, "queued mode switch failed; withdrawing");
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
self.log_activity(
ActivityKind::Sync,
format!("couldn't switch {}", base_name(&folder.local_path)),
@@ -622,8 +761,10 @@ impl Core {
pub(crate) fn apply_sync_folder_mode(
&self,
id: i64,
mode: &str,
mode: ApplicableMountMode,
expected_pending: Option<&str>,
) -> Result<String, SwitchBlocked> {
let mode_str = mode.as_str();
// Hold the folder's lock for the whole switch so no reconcile pass can be
// running over the tree we are about to evict (or start while we mount over
// it). A pass in flight holds the lock for its full duration, so `try_lock`
@@ -641,13 +782,18 @@ impl Core {
.sync_folder_get(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
if folder.mode == mode {
return Ok(format!("already {mode}"));
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
return Err(SwitchBlocked::Superseded);
}
let current_mode = ApplicableMountMode::parse(&folder.mode)
.map_err(|e| SwitchBlocked::Failed(format!("unsupported current mode: {e}")))?;
if current_mode == mode {
return Ok(format!("already {mode_str}"));
}
let local = PathBuf::from(&folder.local_path);
match mode {
"ondemand" => {
ApplicableMountMode::OnDemand => {
// Only flip a folder that is fully in sync — a failed reconcile means
// local edits could still be un-uploaded, and we are about to delete
// the local copy. Not an error: a pass makes this true, and the queued
@@ -694,25 +840,28 @@ impl Core {
if let Err(e) = evict_dir_contents(&local) {
warn!(id, path = %local.display(), error = %e, "evict local dir contents failed");
}
let session = self.spawn_ondemand_mount(&local, root).map_err(|e| {
let mount = self.spawn_ondemand_mount(&local, root).map_err(|e| {
SwitchBlocked::Failed(format!(
"mount ondemand failed after local cache eviction; switch back to mirror to restore it: {e}"
))
})?;
self.mounts
.lock()
.insert(id, (session, fuse_connection_id(&local)));
self.publish_secondary_mount(id, mount)
.map_err(SwitchBlocked::Failed)?;
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
self.db.sync_folder_set_state(id, "idle", now_secs()).ok();
info!(id, path = %local.display(), "mounted sync folder on-demand");
Ok(format!("{} is now on-demand", local.display()))
}
_ => {
ApplicableMountMode::Mirror => {
// ondemand→mirror: tear down the secondary mount first.
if let Some((session, conn)) = self.mounts.lock().remove(&id)
&& let Err(e) = umount_session_unblocked(session, conn)
if let Some(mount) = self.mounts.lock().remove(&id)
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
{
warn!(id, error = %e, "unmount on-demand folder failed");
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
return Err(SwitchBlocked::Failed(format!(
"unmount {} failed; folder remains on-demand: {error}",
local.display()
)));
}
// The evicted local tree makes the old baseline lie ("everything
// deleted locally"); clear it so the reconcile is a pure download.
@@ -740,23 +889,52 @@ impl Core {
&self,
local: &Path,
root: Node,
) -> CoreResult<BackgroundSession> {
clear_stale_mount(local);
let mut config = Config::default();
config.mount_options = vec![
MountOption::FSName("protondrive".to_string()),
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
) -> CoreResult<SecondaryMount> {
let core = self.fork_state();
let fs = ProtonFs::new(core.clone(), root);
let session = Session::new(fs, local, &config)
.and_then(|s| s.spawn())
let session_live = core.session_live.clone();
// The fork owns this state/path registration. `spawn_session` only
// marks it live after FUSE has spawned successfully.
core.register_state(local);
let session = spawn_session(&core, local, root)
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))?;
// Hand this fork its own session's notification channel, so a background
// size upgrade can drop the kernel's cached attrs for these inodes.
let _ = core.notifier.set(session.notifier());
Ok(session)
Ok(SecondaryMount::new(
session,
fuse_connection_id(local),
session_live,
))
}
/// Publish one newly spawned secondary or explicitly tear it down when
/// shutdown has closed publication or another session already owns the id.
fn publish_secondary_mount(&self, id: i64, mount: SecondaryMount) -> Result<(), String> {
let rejected = self.mounts.lock().insert(id, mount);
let Err((reason, mount)) = rejected else {
return Ok(());
};
let reason = match reason {
SecondaryInsertRejection::Closed => "daemon is shutting down",
SecondaryInsertRejection::Duplicate => "folder already has a live mount",
};
match mount.teardown() {
Ok(()) => Err(reason.to_string()),
Err(error) => Err(format!("{reason}; rejected mount teardown failed: {error}")),
}
}
fn mark_restore_error_if_current(&self, snapshot: &StoredSyncFolder) {
let lock = self.sync_lock(snapshot.id);
let _guard = lock.lock();
if self
.db
.sync_folder_get(snapshot.id)
.ok()
.flatten()
.is_some_and(|current| restore_snapshot_matches(snapshot, &current))
{
let _ = self
.db
.sync_folder_set_state(snapshot.id, "error", now_secs());
}
}
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
@@ -777,14 +955,57 @@ impl Core {
if folder.mode != "ondemand" {
continue;
}
let local = PathBuf::from(&folder.local_path);
// A daemon killed before it could unmount (systemd's stop timeout,
// say) leaves this path attached to a dead FUSE connection: it still
// exists, but every stat on it fails with ENOTCONN — so `is_dir()`
// reads it as *missing* and the folder gets parked in `error` on
// every subsequent start, with no way back but a manual
// `fusermount -u`. Detach the corpse first; the directory
// underneath then restores normally.
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
warn!(id = folder.id, "restore on-demand: bad remote uid");
self.mark_restore_error_if_current(&folder);
continue;
};
let root = match self
.rt
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
{
Ok(v) => match v.into_iter().next() {
Some(n) => n,
None => {
warn!(id = folder.id, "restore on-demand: remote folder gone");
self.mark_restore_error_if_current(&folder);
continue;
}
},
Err(e) => {
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
self.mark_restore_error_if_current(&folder);
continue;
}
};
let lock = self.sync_lock(folder.id);
let _guard = lock.lock();
let current = match self.db.sync_folder_get(folder.id) {
Ok(Some(current)) if restore_snapshot_matches(&folder, &current) => current,
Ok(_) => {
info!(
id = folder.id,
"restore on-demand: folder changed while fetching"
);
continue;
}
Err(error) => {
warn!(
id = folder.id,
?error,
"restore on-demand: cannot re-read folder"
);
continue;
}
};
if !self.mounts.lock().is_accepting() {
info!(id = folder.id, "restore on-demand: shutdown in progress");
continue;
}
let local = PathBuf::from(&current.local_path);
// A daemon killed before it could unmount can leave a dead FUSE
// connection over the path. Clear it only after the row identity
// and mode have been revalidated under the folder lock.
if is_stale_mount(&local) {
warn!(id = folder.id, path = %local.display(), "restore on-demand: clearing stale mount");
clear_stale_mount(&local);
@@ -796,11 +1017,6 @@ impl Core {
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
// An `ondemand` folder's local dir is empty by construction — the switch
// evicts it. Finding files there means the row is lying (a switch that
// died between persisting the mode and evicting), and mounting over them
// would hide real local data behind the remote tree. Leave the files
// alone and let the user resolve it.
match dir_is_empty(&local) {
Ok(true) => {}
Ok(false) => {
@@ -822,40 +1038,16 @@ impl Core {
continue;
}
}
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
warn!(id = folder.id, "restore on-demand: bad remote uid");
continue;
};
let root = match self
.rt
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
{
Ok(v) => match v.into_iter().next() {
Some(n) => n,
None => {
warn!(id = folder.id, "restore on-demand: remote folder gone");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
match self.spawn_ondemand_mount(&local, root) {
Ok(mount) => match self.publish_secondary_mount(folder.id, mount) {
Ok(()) => {
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
}
Err(error) => {
warn!(id = folder.id, %error, "restore on-demand: mount rejected");
}
},
Err(e) => {
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
};
match self.spawn_ondemand_mount(&local, root) {
Ok(session) => {
self.mounts
.lock()
.insert(folder.id, (session, fuse_connection_id(&local)));
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
}
Err(e) => {
warn!(id = folder.id, error = %e, "restore on-demand: mount failed");
let _ = self
@@ -866,3 +1058,118 @@ impl Core {
}
}
}
#[cfg(test)]
mod tests {
use pdfs_core::db::StoredSyncFolder;
use super::{
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
restore_snapshot_matches, select_mode_transition,
};
#[test]
fn unknown_mode_cannot_select_a_destructive_transition() {
let mut unmounts = 0;
let mut baseline_clears = 0;
let mut mode_writes = 0;
if let Ok(mode) = ApplicableMountMode::parse("streaming") {
match mode {
ApplicableMountMode::Mirror => {
unmounts += 1;
baseline_clears += 1;
mode_writes += 1;
}
ApplicableMountMode::OnDemand => {
mode_writes += 1;
}
}
}
assert_eq!((unmounts, baseline_clears, mode_writes), (0, 0, 0));
assert_eq!(
ApplicableMountMode::parse("streaming"),
Err("unknown mode \"streaming\" (want mirror|ondemand)".to_string())
);
assert_eq!(
ApplicableMountMode::parse("mirror"),
Ok(ApplicableMountMode::Mirror)
);
assert_eq!(
ApplicableMountMode::parse("ondemand"),
Ok(ApplicableMountMode::OnDemand)
);
}
#[test]
fn unknown_source_mode_with_pending_mirror_cannot_select_a_transition() {
let mut destructive_helper_reached = false;
let selected = select_mode_transition("streaming", "mirror");
if let Ok(Some(_)) = selected {
destructive_helper_reached = true;
}
assert!(!destructive_helper_reached);
assert_eq!(
selected,
Err(
"unsupported current mode: unknown mode \"streaming\" (want mirror|ondemand)"
.to_string()
)
);
}
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
StoredSyncFolder {
id: 7,
local_path: local_path.to_string(),
remote_uid: remote_uid.to_string(),
remote_share_id: "share".to_string(),
mode: mode.to_string(),
pending_mode: None,
state: "idle".to_string(),
last_sync: 0,
}
}
#[test]
fn stale_restore_identity_or_mode_is_rejected() {
let snapshot = folder("/mnt/folder", "volume~node", "ondemand");
assert!(restore_snapshot_matches(
&snapshot,
&folder("/mnt/folder", "volume~node", "ondemand")
));
assert!(!restore_snapshot_matches(
&snapshot,
&folder("/mnt/replaced", "volume~node", "ondemand")
));
assert!(!restore_snapshot_matches(
&snapshot,
&folder("/mnt/folder", "volume~other", "ondemand")
));
assert!(!restore_snapshot_matches(
&snapshot,
&folder("/mnt/folder", "volume~node", "mirror")
));
}
#[test]
fn teardown_failure_prevents_follow_up_state_changes() {
let mut commits = 0;
let result = commit_after_teardown::<_, &str>(Err("busy"), || {
commits += 1;
});
assert_eq!(result, Err("busy"));
assert_eq!(commits, 0);
commit_after_teardown::<_, &str>(Ok(()), || commits += 1).unwrap();
assert_eq!(commits, 1);
}
#[test]
fn canceled_or_replaced_queued_intent_cannot_transition() {
assert!(pending_intent_matches(Some("ondemand"), Some("ondemand")));
assert!(!pending_intent_matches(None, Some("ondemand")));
assert!(!pending_intent_matches(Some("mirror"), Some("ondemand")));
assert!(pending_intent_matches(Some("mirror"), None));
}
}
+529 -55
View File
@@ -27,7 +27,7 @@ use std::time::Duration;
use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
use proton_drive_rs::Node;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, error, info, warn};
@@ -36,10 +36,82 @@ use super::state::Intervals;
use super::transfers::CountingReader;
use super::{
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, ROOT_INO, conflict_name,
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_size, now_millis, now_secs,
parse_node_uid,
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_revision_id, node_size,
now_millis, now_secs, parse_node_uid,
};
const DRAIN_ACCESS_RECHECK: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DrainDisposition {
Applied,
AccessDeferred,
}
fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::error::Error>> {
let uid = || -> Result<NodeUid, Box<dyn std::error::Error>> {
parse_node_uid(&op.uid)
.ok_or_else(|| Box::<dyn std::error::Error>::from("pending op has an unparseable uid"))
};
let parent = || -> Result<NodeUid, Box<dyn std::error::Error>> {
let parent = op
.parent_uid
.as_deref()
.ok_or("pending op has no parent authority")?;
parse_node_uid(parent).ok_or_else(|| {
Box::<dyn std::error::Error>::from("pending op has an unparseable parent")
})
};
match op.kind.as_str() {
OP_REVISION | OP_TRASH => Ok(vec![uid()?]),
OP_CREATE | OP_MKDIR => Ok(vec![parent()?]),
OP_RENAME => {
let mut authorities = vec![uid()?];
if let Some(json) = op.meta_json.as_deref() {
let meta: RenameMeta = serde_json::from_str(json)?;
authorities.push(
parse_node_uid(&meta.original_parent_uid)
.ok_or("rename op has an unparseable original parent")?,
);
} else {
// Rows written by 1.1.1 predate foreign shared mounts. Their
// source tree was necessarily owned, so the node plus desired
// destination are the complete available authority set.
}
authorities.push(parent()?);
authorities.dedup();
Ok(authorities)
}
other => Err(format!("unknown pending op kind {other:?}").into()),
}
}
fn run_authorized_drain(
op: &PendingOp,
mut require: impl FnMut(&NodeUid) -> Result<(), fuser::Errno>,
apply: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
) -> Result<DrainDisposition, Box<dyn std::error::Error>> {
// This check is the admission/linearization point. A downgrade that lands
// first defers the op; one observed afterwards does not revoke this admitted
// attempt. Every later queued operation rechecks. No permission lock is
// held across remote latency.
for authority in pending_op_authorities(op)? {
match require(&authority) {
Ok(()) => {}
Err(error) if error.code() == libc::EACCES => {
return Ok(DrainDisposition::AccessDeferred);
}
Err(error) => {
return Err(
format!("drain access check failed with errno {}", error.code()).into(),
);
}
}
}
apply()?;
Ok(DrainDisposition::Applied)
}
impl Core {
/// Drain the pending-op queue: the background half of every write
/// (offline.md Phase 3).
@@ -69,7 +141,30 @@ impl Core {
self.wait_for_drain_work_or_due();
continue;
};
if let Err(e) = self.drain_op(&op) {
let outcome = run_authorized_drain(
&op,
|uid| self.require_uid_writable(uid),
|| self.drain_op(&op),
);
if matches!(outcome, Ok(DrainDisposition::AccessDeferred)) {
let next_attempt_at = now_millis() + DRAIN_ACCESS_RECHECK.as_millis() as i64;
if let Err(error) = self.db.defer_op_without_attempt(op.id, next_attempt_at) {
error!(
uid = %op.uid,
%error,
"deferring access-blocked pending operation failed"
);
self.wait_for_drain_work();
} else {
debug!(
uid = %op.uid,
next_attempt_at,
"pending operation deferred until access is writable"
);
}
continue;
}
if let Err(e) = outcome {
let attempts = op.attempts + 1;
let err_str = e.to_string();
// Never infer that user data is disposable from an error string
@@ -229,6 +324,7 @@ impl Core {
}
}
self.db.delete_op(op.id)?;
self.note_self_change(&uid);
info!(%uid, name = %landed, "pending rename landed");
Ok(())
}
@@ -251,7 +347,8 @@ impl Core {
}
Err(e) => return Err(e.into()),
}
self.db.delete_op(op.id)?;
self.db.complete_trash_op(op.id, &uid)?;
self.own_sealed_revs.lock().remove(&uid);
self.invalidate_trash();
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
info!(%uid, name, "pending trash landed");
@@ -262,18 +359,19 @@ impl Core {
/// it away from the one the user asked for. Best effort: the event sync
/// would correct the tree anyway, but not before the user has looked at it.
pub(crate) fn adopt_drained_name(&self, uid: &NodeUid, name: &str) {
let mut st = self.state.lock();
let Some(&ino) = st.by_uid.get(uid) else {
return;
};
let Some(entry) = st.entries.get_mut(&ino) else {
return;
};
entry.node.name = name.to_string();
let node = entry.node.clone();
if let Err(e) = st.db.upsert_node(&node) {
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
}
self.for_each_state(|st| {
let Some(&ino) = st.by_uid.get(uid) else {
return;
};
let Some(entry) = st.entries.get_mut(&ino) else {
return;
};
entry.node.name = name.to_string();
let node = entry.node.clone();
if let Err(e) = st.db.upsert_node(&node) {
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
}
});
}
/// Make a node that so far exists only on this machine real, and adopt the
@@ -342,6 +440,9 @@ impl Core {
// sync, whereas a surviving op would create the file a second time.
self.db.delete_op(op.id)?;
self.adopt_real_uid(&local, &real)?;
// The feed will report this create back to us; the tree already has it
// under its real uid, so that event is ours to ignore (`Core::self_changes`).
self.note_self_change(&real);
if let Some(blob) = op.blob_path.as_deref() {
self.cache.discard_staged(Path::new(blob));
}
@@ -418,6 +519,14 @@ impl Core {
///
/// The inode is deliberately kept, so anything already holding the file open
/// keeps working across the drain.
///
/// Applied to **every** live inode space, not just this Core's. A node
/// created inside an on-demand sync folder lives in that fork's `state`,
/// while the drain that lands its create belongs to the primary mount — so
/// reaching for `self.state` here found nothing and left the fork's entry on
/// its `local~` uid, which reads as an empty file for as long as the daemon
/// runs (`docs/BUGS.md` B74). A uid is unique across mounts, so at most one
/// of them matches and the others are no-ops.
pub(crate) fn adopt_real_uid(
&self,
local: &NodeUid,
@@ -430,8 +539,10 @@ impl Core {
self.db
.remap_local_uid(&local.to_string(), &real.to_string())?;
let mut st = self.state.lock();
if let Some(ino) = st.by_uid.remove(local) {
self.for_each_state(|st| {
let Some(ino) = st.by_uid.remove(local) else {
return;
};
st.by_uid.insert(real.clone(), ino);
if let Some(e) = st.entries.get_mut(&ino) {
e.uid = real.clone();
@@ -459,8 +570,7 @@ impl Core {
kids.push(ino);
}
}
}
drop(st);
});
// Write the real node through, now that nothing points at the old uid.
if let Err(e) = self.db.upsert_node(&node) {
warn!(%real, error = %e, "db upsert_node failed after remap");
@@ -503,15 +613,23 @@ impl Core {
if node.trashed {
return Ok(Some("the file was trashed remotely".into()));
}
let (mtime, size) = (node.modification_time, node_size(&node));
if mtime != base.mtime || size != base.size {
return Ok(Some(format!(
"the remote revision changed under the queued write \
(expected {} bytes at mtime {}, found {size} at {mtime})",
base.size, base.mtime
)));
let Some(reason) = revision_changed(base, &node) else {
return Ok(None);
};
// The remote moved on from the baseline — normally a conflict. But if it
// sits at a revision *this daemon* sealed, no other device changed the
// file: it is a single-writer stall→resume (a browser that closed and
// reopened its download fd; B70 layer B). Chain onto it — supersede
// instead of forking — so the finished bytes win the name rather than
// being exiled to a `(sync-conflict)` copy.
let remote_rev = node_revision_id(&node);
let own_rev = self.own_sealed_revs.lock().get(uid).cloned();
if is_own_self_supersede(meta.complete, remote_rev.as_deref(), own_rev.as_deref()) {
debug!(%uid, ?remote_rev,
"queued write chains onto our own sealed revision; not a conflict");
return Ok(None);
}
Ok(None)
Ok(Some(reason))
}
/// Land a queued write that can no longer be applied to its own node as a
@@ -595,11 +713,11 @@ impl Core {
self.cache.evict(uid);
self.evict_reader(uid);
// The conflict copy is a node the tree has never seen.
let mut st = self.state.lock();
if let Some(&ino) = st.by_uid.get(&parent) {
st.invalidate_listing(ino);
}
drop(st);
self.for_each_state(|st| {
if let Some(&ino) = st.by_uid.get(&parent) {
st.invalidate_listing(ino);
}
});
self.log_activity(
ActivityKind::Upload,
&name,
@@ -653,13 +771,25 @@ impl Core {
/// A node's parent uid and name, as the tree currently has them.
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
{
let st = self.state.lock();
// Across every mount: the drain runs on the primary Core, but a node
// written inside a sync folder is interned in that fork's inode space,
// so asking `self.state` alone always missed it and fell through to the
// DB. That still gave the right answer — the fallback below exists for
// exactly this shape of miss — but it went to disk for something already
// in memory, and only *because* of the fallback was it not a bug.
let mut found = None;
self.for_each_state(|st| {
if found.is_some() {
return;
}
if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))
&& let Some(parent) = entry.node.parent_uid.clone()
{
return Some((parent, entry.node.name.clone()));
found = Some((parent, entry.node.name.clone()));
}
});
if found.is_some() {
return found;
}
// The in-memory tree only holds what has been walked to since the daemon
// started, and the drain routinely runs before anything has walked to
@@ -814,6 +944,11 @@ impl Core {
return;
}
};
// This function's whole job is to bring the tree level with the revision
// we just sealed, so the feed's report of that revision has nothing left
// to tell us. Claimed here rather than at the upload call so it is only
// recorded when the node was actually re-read (`Core::self_changes`).
self.note_self_change(uid);
// Rebase any *open* write handles targeting this node. A handle opened
// before this upload carries a stale base — its `base_mtime`/`base_size`
// name the revision we just replaced. Without this update,
@@ -826,15 +961,25 @@ impl Core {
{
let sealed_mtime = node.modification_time;
let sealed_size = node_size(&node);
let mut st = self.state.lock();
for aw in st.active_writes.values_mut() {
if aw.uid == *uid {
aw.base_mtime = sealed_mtime;
aw.base_size = sealed_size;
debug!(%uid, mtime = sealed_mtime, size = sealed_size,
"rebased open write handle onto the revision just uploaded");
}
let sealed_rev = node_revision_id(&node);
// Remember it as ours, so a queued write that opened over an earlier
// revision recognises this as a self-supersede rather than a foreign
// change and does not fork (B70 layer B; consulted in
// `revision_conflict`).
if let Some(rev) = sealed_rev.clone() {
self.own_sealed_revs.lock().insert(uid.clone(), rev);
}
self.for_each_state(|st| {
for aw in st.active_writes.values_mut() {
if aw.uid == *uid {
aw.base_mtime = sealed_mtime;
aw.base_size = sealed_size;
aw.base_revision_id = sealed_rev.clone();
debug!(%uid, mtime = sealed_mtime, size = sealed_size,
"rebased open write handle onto the revision just uploaded");
}
}
});
}
// Ordered so that a write queued *during* the fetch above is still
// caught: it took its baseline from the node's optimistic stamp, and
@@ -842,16 +987,17 @@ impl Core {
if self.rebaseline_pending(uid, &node) {
return;
}
let mut st = self.state.lock();
let Some(parent) = st
.by_uid
.get(uid)
.and_then(|ino| st.entries.get(ino))
.map(|e| e.parent)
else {
return;
};
st.intern(parent, node);
self.for_each_state(|st| {
let Some(parent) = st
.by_uid
.get(uid)
.and_then(|ino| st.entries.get(ino))
.map(|e| e.parent)
else {
return;
};
st.intern(parent, node.clone());
});
}
/// Point a still-queued write at the revision this upload just sealed, and
@@ -875,6 +1021,7 @@ impl Core {
mtime: sealed.modification_time,
size: node_size(sealed),
hash: None,
revision_id: node_revision_id(sealed),
};
let mut pending = self.pending.lock();
let Some(p) = pending.get_mut(uid) else {
@@ -896,3 +1043,330 @@ impl Core {
true
}
}
/// Whether the remote `node` moved on from the revision a queued write was
/// `base`d on — the reason string when it did, `None` when the write can still be
/// applied. Pure so it is testable without a live remote (the trashed/gone checks
/// stay in [`Core::revision_conflict`], which does the fetch).
///
/// The revision id is the authoritative identity: it advances iff a *new* revision
/// was sealed. When both sides carry one, it alone decides — the server re-stamps
/// the *same* revision's mtime, and reading that drift as a change is exactly what
/// diverted a write into a conflict copy of its own base (B16/B25). Only when
/// there is no id to compare (an old sidecar, or a surface that omits it) does it
/// fall back to the observable `(mtime, size)` tuple.
fn revision_changed(base: &Baseline, node: &Node) -> Option<String> {
if let (Some(base_rev), Some(remote_rev)) = (&base.revision_id, node_revision_id(node)) {
return (*base_rev != remote_rev).then(|| {
format!(
"the remote revision changed under the queued write \
(based on revision {base_rev}, remote now at {remote_rev})"
)
});
}
let (mtime, size) = (node.modification_time, node_size(node));
(mtime != base.mtime || size != base.size).then(|| {
format!(
"the remote revision changed under the queued write \
(expected {} bytes at mtime {}, found {size} at {mtime})",
base.size, base.mtime
)
})
}
/// Whether a queued write that found the remote moved on ([`revision_changed`]
/// fired) is nonetheless a single-writer self-supersede rather than a foreign
/// conflict — the remote sits at a revision this daemon sealed itself, so
/// chaining onto it is safe and forking would be wrong (B70 layer B).
///
/// `remote_rev` is the id the remote currently holds, `own_rev` this node's
/// latest own-sealed id. Requires a `complete` blob: an incomplete one's gaps
/// still refer to the stale base, so it must take the non-destructive
/// conflict-copy path instead of overwriting. A `None` remote id never matches
/// (there is nothing to prove it was ours), so absence falls through to a fork.
fn is_own_self_supersede(complete: bool, remote_rev: Option<&str>, own_rev: Option<&str>) -> bool {
complete && remote_rev.is_some() && remote_rev == own_rev
}
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::Access;
use pdfs_core::db::Db;
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
use std::cell::Cell;
fn pending(kind: &str) -> PendingOp {
PendingOp {
id: 0,
kind: kind.to_string(),
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("node")).to_string(),
parent_uid: Some(NodeUid::new(VolumeId::from("v"), LinkId::from("parent")).to_string()),
name: Some("name".to_string()),
blob_path: (kind == OP_REVISION).then(|| "/staging/blob".to_string()),
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
}
}
#[test]
fn drain_authorities_match_each_operation_kind() {
for kind in [OP_REVISION, OP_TRASH] {
assert_eq!(pending_op_authorities(&pending(kind)).unwrap().len(), 1);
}
for kind in [OP_CREATE, OP_MKDIR] {
let authorities = pending_op_authorities(&pending(kind)).unwrap();
assert_eq!(authorities.len(), 1);
assert_eq!(authorities[0].to_string(), "v~parent");
}
let authorities = pending_op_authorities(&pending(OP_RENAME)).unwrap();
assert_eq!(authorities.len(), 2);
assert_eq!(authorities[0].to_string(), "v~node");
assert_eq!(authorities[1].to_string(), "v~parent");
}
fn folder_node(link: &str, parent: Option<&str>) -> Node {
Node {
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(link)),
parent_uid: parent
.map(|parent| NodeUid::new(VolumeId::from("v"), LinkId::from(parent))),
kind: NodeKind::Folder,
name: link.to_string(),
creation_time: 0,
modification_time: 0,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
#[test]
fn queued_cross_parent_rename_retains_viewer_source_authority_after_local_move() {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"pdfs-drain-rename-access-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
let db = Db::open(&dir.join("cache.db")).unwrap();
let source = folder_node("source", None);
let destination = folder_node("destination", None);
let mut moved = file_node(1, 0, None);
moved.parent_uid = Some(destination.uid.clone());
for node in [&source, &destination, &moved] {
db.upsert_node(node).unwrap();
}
db.set_share_access(&source.uid, Access::Viewer).unwrap();
db.set_share_access(&destination.uid, Access::Editor)
.unwrap();
let mut op = pending(OP_RENAME);
op.uid = moved.uid.to_string();
op.parent_uid = Some(destination.uid.to_string());
op.meta_json = Some(
serde_json::to_string(&RenameMeta {
original_parent_uid: source.uid.to_string(),
})
.unwrap(),
);
let authorities = pending_op_authorities(&op).unwrap();
assert_eq!(authorities.len(), 3);
assert_eq!(authorities[1], source.uid);
let applied = Cell::new(false);
let disposition = run_authorized_drain(
&op,
|uid| {
db.effective_node_access(uid)
.unwrap()
.filter(|access| access.writable())
.map(|_| ())
.ok_or(fuser::Errno::EACCES)
},
|| {
applied.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(disposition, DrainDisposition::AccessDeferred);
assert!(!applied.get());
assert_eq!(
db.effective_node_access(&moved.uid).unwrap(),
Some(Access::Editor),
"the node's optimistic destination ancestry is writable"
);
drop(db);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn viewer_deferred_drain_never_applies_or_consumes_an_attempt() {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"pdfs-drain-access-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
let db = Db::open(&dir.join("cache.db")).unwrap();
let mut queued = pending(OP_REVISION);
queued.attempts = 2;
db.enqueue_op(&queued).unwrap();
let op = db.pending_ops().unwrap().remove(0);
let before_count = db.pending_op_counts().unwrap();
let applied = Cell::new(false);
let disposition = run_authorized_drain(
&op,
|_| Err(fuser::Errno::EACCES),
|| {
applied.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(disposition, DrainDisposition::AccessDeferred);
assert!(!applied.get(), "no API surrogate may run while read-only");
db.defer_op_without_attempt(op.id, 5_000).unwrap();
let retained = db.pending_ops().unwrap();
let after_count = db.pending_op_counts().unwrap();
assert_eq!(retained.len(), 1);
assert_eq!(retained[0].attempts, op.attempts);
assert_eq!(after_count.uploads, before_count.uploads);
assert_eq!(after_count.changes, before_count.changes);
drop(db);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn owner_and_editor_drain_authorities_apply_normally() {
for access in [Access::Owner, Access::Editor] {
let applied = Cell::new(false);
let disposition = run_authorized_drain(
&pending(OP_REVISION),
|_| access.writable().then_some(()).ok_or(fuser::Errno::EACCES),
|| {
applied.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(disposition, DrainDisposition::Applied);
assert!(applied.get());
}
}
fn file_node(mtime: i64, size: i64, rev: Option<&str>) -> Node {
Node {
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("l")),
parent_uid: None,
kind: NodeKind::File {
media_type: "text/plain".into(),
total_size_on_storage: size,
active_revision_state: None,
active_revision_id: rev.map(String::from),
claimed_size: Some(size),
claimed_modification_time: None,
content_sha1: None,
},
name: "f.txt".into(),
creation_time: 0,
modification_time: mtime,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
fn baseline(mtime: i64, size: u64, rev: Option<&str>) -> Baseline {
Baseline {
mtime,
size,
hash: None,
revision_id: rev.map(String::from),
}
}
#[test]
fn same_revision_id_is_not_a_conflict_despite_mtime_drift() {
// The exact shape of the reported bug: same revision, server re-stamped
// its mtime (1784897777 -> 1784897780), same size. Must NOT conflict.
let base = baseline(1_784_897_777, 87_438, Some("rev-A"));
let remote = file_node(1_784_897_780, 87_438, Some("rev-A"));
assert_eq!(revision_changed(&base, &remote), None);
}
#[test]
fn a_new_revision_id_is_a_conflict_even_at_the_same_size() {
// A same-size edit by another device advances the revision id: this is a
// real conflict the id catches where (mtime, size) alone could miss it.
let base = baseline(100, 40, Some("rev-A"));
let remote = file_node(100, 40, Some("rev-B"));
assert!(revision_changed(&base, &remote).is_some());
}
#[test]
fn without_ids_it_falls_back_to_mtime_and_size() {
// Old sidecar (no revision id): the (mtime, size) tuple still governs.
let base = baseline(100, 40, None);
assert_eq!(revision_changed(&base, &file_node(100, 40, None)), None);
assert!(revision_changed(&base, &file_node(101, 40, None)).is_some());
assert!(revision_changed(&base, &file_node(100, 41, None)).is_some());
}
#[test]
fn a_complete_write_over_our_own_sealed_revision_chains_not_forks() {
// B70 layer B: the partial we sealed ourselves (rev-mine) is what the
// remote holds; the resume rewrote the whole file. Not a foreign change.
assert!(is_own_self_supersede(
true,
Some("rev-mine"),
Some("rev-mine")
));
}
#[test]
fn a_move_to_a_revision_we_did_not_seal_still_forks() {
// Another device wrote rev-theirs; we only ever sealed rev-mine. Fork.
assert!(!is_own_self_supersede(
true,
Some("rev-theirs"),
Some("rev-mine")
));
}
#[test]
fn an_incomplete_blob_never_self_supersedes() {
// Its gaps still refer to the stale base; overwriting would mix
// revisions, so it must take the conflict-copy path even over our seal.
assert!(!is_own_self_supersede(
false,
Some("rev-mine"),
Some("rev-mine")
));
}
#[test]
fn a_missing_remote_id_never_self_supersedes() {
// No id to prove the revision was ours: fall through to a fork rather
// than matching None==None.
assert!(!is_own_self_supersede(true, None, None));
}
}
File diff suppressed because it is too large. Load diff
+2617 -122
View File
File diff suppressed because it is too large. Load diff
+327 -65
View File
@@ -28,10 +28,101 @@ pub(crate) fn is_stale_mount(path: &Path) -> bool {
)
}
/// A secondary (on-demand sync folder) FUSE session, paired with its FUSE
/// connection id so teardown can abort a mid-transfer connection rather than
/// block on it. See [`Core::mounts`] and [`umount_session_unblocked`].
pub(super) type SecondaryMount = (BackgroundSession, Option<u32>);
/// A secondary (on-demand sync folder) FUSE session and the exact liveness flag
/// published with its forked inode state.
///
/// Keeping these together makes every teardown mark the fork unroutable before
/// unmounting, including paths that can block on an in-flight FUSE request.
pub(super) struct SecondaryMount {
session: BackgroundSession,
conn: Option<u32>,
session_live: Arc<AtomicBool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SecondaryInsertRejection {
Closed,
Duplicate,
}
/// Secondary sessions and the daemon-wide gate controlling whether newly
/// spawned sessions may become visible. The gate and map share one lock so
/// shutdown cannot race an insertion between checking and publishing.
pub(super) struct SecondaryMountRegistry<T> {
accepting: bool,
entries: HashMap<i64, T>,
}
impl<T> Default for SecondaryMountRegistry<T> {
fn default() -> Self {
Self {
accepting: false,
entries: HashMap::new(),
}
}
}
impl<T> SecondaryMountRegistry<T> {
pub(super) fn open(&mut self) {
self.accepting = true;
}
pub(super) fn is_accepting(&self) -> bool {
self.accepting
}
pub(super) fn close(&mut self) {
self.accepting = false;
}
pub(super) fn insert(
&mut self,
id: i64,
mount: T,
) -> Result<(), (SecondaryInsertRejection, T)> {
if !self.accepting {
return Err((SecondaryInsertRejection::Closed, mount));
}
if self.entries.contains_key(&id) {
return Err((SecondaryInsertRejection::Duplicate, mount));
}
self.entries.insert(id, mount);
Ok(())
}
pub(super) fn remove(&mut self, id: &i64) -> Option<T> {
self.entries.remove(id)
}
pub(super) fn contains_key(&self, id: &i64) -> bool {
self.entries.contains_key(id)
}
pub(super) fn close_and_drain(&mut self) -> Vec<(i64, T)> {
self.close();
self.entries.drain().collect()
}
}
impl SecondaryMount {
pub(super) fn new(
session: BackgroundSession,
conn: Option<u32>,
session_live: Arc<AtomicBool>,
) -> Self {
Self {
session,
conn,
session_live,
}
}
pub(super) fn teardown(self) -> std::io::Result<()> {
teardown_session(&self.session_live, || {
umount_session_unblocked(self.session, self.conn)
})
}
}
/// The kernel's id for the FUSE connection backing the mount at `mountpoint` —
/// the directory name under `/sys/fs/fuse/connections`, which is the minor
@@ -143,6 +234,80 @@ fn fetch_or_recall_root(
}
}
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
/// caller, so `mount` itself never reads config or the environment.
pub struct MountOptions {
/// Proton account the session belongs to, for display and per-user paths.
pub username: String,
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
pub sweep_mode: SweepMode,
}
/// Spawn one FUSE session rooted at an arbitrary remote node.
///
/// Primary My Files and secondary on-demand device locations use this same
/// construction path so mount options, stale-mount recovery, registry
/// liveness, and notifier ownership cannot drift apart. Each caller owns its
/// state/path registration and performs it exactly once before calling here.
pub(super) fn spawn_session(
core: &Core,
mountpoint: &Path,
root: Node,
) -> std::io::Result<BackgroundSession> {
let mut config = Config::default();
config.mount_options = vec![
MountOption::FSName("protondrive".to_string()),
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
clear_stale_mount(mountpoint);
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
let fs = ProtonFs::new(core.clone(), root);
if core.primary {
// Only My Files rehydrates the daemon-wide node cache. A secondary
// inode space is rooted at one device subtree and must not import every
// unrelated persisted node.
core.hydrate();
}
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
let _ = core.notifier.set(session.notifier());
core.session_live.store(true, Ordering::Release);
Ok(session)
}
/// Make a session unroutable before beginning any teardown that may block.
fn teardown_session<T>(session_live: &AtomicBool, teardown: impl FnOnce() -> T) -> T {
session_live.store(false, Ordering::Release);
teardown()
}
impl Core {
/// Resolve the primary share id away from startup and publish it only if the
/// projected root has not changed while the request was in flight.
pub(crate) fn repair_primary_share_id(&self, root_uid: &NodeUid) {
match self.rt.block_on(self.client.context_share_id(root_uid)) {
Ok(share_id) => {
match self
.db
.mount_repair_my_files_share_id(&root_uid.to_string(), &share_id.to_string())
{
Ok(true) => debug!(%root_uid, "repaired My Files share id"),
Ok(false) => {
debug!(%root_uid, "discarded stale My Files share-id result");
}
Err(error) => {
warn!(%root_uid, error = %error, "persist My Files share id failed");
}
}
}
Err(error) => {
debug!(%root_uid, error = %error, "resolve My Files share id failed");
}
}
}
}
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
/// daemon is asked to stop.
///
@@ -162,10 +327,19 @@ pub fn mount(
cache: ContentCache,
control_socket: &Path,
db: Arc<Db>,
username: String,
options: MountOptions,
) -> std::io::Result<MountOutcome> {
let MountOptions {
username,
sweep_mode,
} = options;
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
let scope = root.tree_event_scope_id();
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
let share_access = db
.all_share_access()
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
// nudged over this channel; the sender lives in Core so control-socket
@@ -175,6 +349,8 @@ pub fn mount(
let core = Core {
client: client.clone(),
rt: rt.clone(),
primary_root_uid: root.uid.clone(),
primary: true,
state: Arc::new(Mutex::new(State {
entries: HashMap::new(),
by_uid: HashMap::new(),
@@ -183,6 +359,8 @@ pub fn mount(
active_writes: HashMap::new(),
handles: HashMap::new(),
next_fh: 1,
access_changes: HashSet::new(),
share_access,
db: db.clone(),
})),
cache: Arc::new(cache),
@@ -190,23 +368,36 @@ pub fn mount(
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
db,
shared_publication: Arc::new(Mutex::new(())),
shared_generation: Arc::new(AtomicU64::new(0)),
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
online: Arc::new(AtomicBool::new(online)),
pending: Arc::new(Mutex::new(HashMap::new())),
hidden: Arc::new(Mutex::new(HashSet::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
trash_progress: Arc::new(tokio::sync::Notify::new()),
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
sweep_mode,
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
self_changes: Arc::new(Mutex::new(HashMap::new())),
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
notifier: Arc::new(OnceLock::new()),
session_live: Arc::new(AtomicBool::new(false)),
transfers: TransferRegistry::new(),
indexing: Arc::new(AtomicBool::new(false)),
sync_progress: Arc::new(Mutex::new(HashMap::new())),
sync_tx,
mounts: Arc::new(Mutex::new(HashMap::new())),
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
sync_locks: Arc::new(Mutex::new(HashMap::new())),
states: Arc::new(StateRegistry::default()),
};
// Before anything can queue work against it: the drain thread below reaches
// every mount's inode space through this registry, not through `core.state`.
core.register_state(mountpoint);
// Writes queued by a previous run (or left behind by a crash) are still owed
// an upload, and reads must be served from their staged blobs until they land.
@@ -226,6 +417,21 @@ pub fn mount(
// remotes, and reconciles on its own thread — never in front of a FUSE call.
sync::spawn(core.clone(), sync_rx);
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
// out identical to the live file, surface the ones that genuinely diverge.
// Its own thread — it enumerates and trashes, and must never block a FUSE call.
// `Off` skips the thread entirely rather than starting an idle one, so the
// setting is verifiable from the outside (`ls /proc/<pid>/task/*/comm`).
if sweep_mode == SweepMode::Off {
info!("conflict sweep disabled by configuration");
} else {
info!(mode = ?sweep_mode, "conflict sweep enabled");
let core = core.clone();
std::thread::Builder::new()
.name("pdfs-conflict-sweep".into())
.spawn(move || core.run_conflict_sweep_loop())?;
}
// Mounted from the cache: watch for the network coming back so the mount can
// stop being read-only-ish without the user restarting the daemon.
if !online {
@@ -235,16 +441,6 @@ pub fn mount(
.spawn(move || core.run_online_probe())?;
}
// Re-establish on-demand mounts left over from a previous run (devices.md
// Phase 4). On its own thread: each remount fetches a remote node, and we
// must not block the main mount below on the network.
{
let core = core.clone();
std::thread::Builder::new()
.name("pdfs-restore-ondemand".into())
.spawn(move || core.restore_ondemand_mounts())?;
}
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
{
@@ -257,18 +453,6 @@ pub fn mount(
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
}
let mut config = Config::default();
config.mount_options = vec![
MountOption::FSName("protondrive".to_string()),
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
// A crashed previous run can leave the kernel mount in place, which makes
// the fresh mount below fail with EBUSY. Lazily detach any leftover first.
clear_stale_mount(mountpoint);
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
// Bind the control socket before the FUSE session takes over the thread. A
// stale socket file from a previous run would block the bind, so clear it.
let _ = std::fs::remove_file(control_socket);
@@ -285,40 +469,70 @@ pub fn mount(
"control socket permissions: {e}"
)));
}
{
let core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.to_path_buf();
std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || run_control_socket(core, username, mountpoint, listener))?;
}
let fs = ProtonFs::new(core.clone(), root);
// Warm the in-memory maps from the DB so a cold start serves previously
// seen metadata without re-hitting the API (plan.md P1).
core.hydrate();
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
// for the event task. `spawn` runs the session loop on its own background
// thread; we then wait here for either a stop signal or the mount ending.
let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
// Do not accept control requests until `spawn_session` has transitioned the
// primary registration to live. The listener may queue a connection during
// this short window, but its handler cannot observe `mounted = false`.
let bg = match spawn_session(&core, mountpoint, root) {
Ok(session) => session,
Err(error) => {
let _ = std::fs::remove_file(control_socket);
return Err(error);
}
};
core.mounts.lock().open();
// The connection id, captured now while the mount is live, so a stop signal
// mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`).
let main_conn = fuse_connection_id(mountpoint);
let notifier = bg.notifier();
// Same channel, kept on the `Core` so background work (a size upgrade, say)
// can invalidate kernel-cached metadata without threading a handle through.
let _ = core.notifier.set(notifier.clone());
rt.spawn(run_event_sync(
client,
scope,
core.state,
core.cache,
core.db,
core.pending,
notifier,
));
{
let control_core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.to_path_buf();
if let Err(error) = std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
{
let secondaries = core.mounts.lock().close_and_drain();
for (id, mount) in secondaries {
if let Err(unmount_error) = mount.teardown() {
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
}
}
if let Err(unmount_error) = teardown_session(&core.session_live, || {
umount_session_unblocked(bg, main_conn)
}) {
warn!(error = %unmount_error, "unmount after control startup failure failed");
}
let _ = std::fs::remove_file(control_socket);
return Err(error);
}
}
// Re-establish on-demand mounts only after the primary session is live.
// Keep the worker so shutdown can close publication, wait for any in-flight
// fetch to observe that closure, and then drain a stable registry.
let restore_worker = {
let restore_core = core.clone();
match std::thread::Builder::new()
.name("pdfs-restore-ondemand".into())
.spawn(move || restore_core.restore_ondemand_mounts())
{
Ok(worker) => Some(worker),
Err(error) => {
warn!(%error, "start on-demand restore worker failed");
None
}
}
};
rt.spawn(run_event_sync(client, scope, core.clone()));
if online {
let repair_core = core.clone();
let root_uid = core.primary_root_uid.clone();
if let Err(error) = std::thread::Builder::new()
.name("pdfs-share-id".into())
.spawn(move || repair_core.repair_primary_share_id(&root_uid))
{
warn!(%error, "start My Files share-id repair failed");
}
}
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
// are delivered onto the async runtime; bridge them onto a sync channel so
@@ -357,7 +571,10 @@ pub fn mount(
match sig_rx.recv_timeout(Duration::from_millis(500)) {
Ok(()) => {
info!("stop requested; unmounting");
if let Err(e) = umount_session_unblocked(bg, main_conn) {
core.mounts.lock().close();
if let Err(e) = teardown_session(&core.session_live, || {
umount_session_unblocked(bg, main_conn)
}) {
warn!(error = %e, "umount_and_join failed");
}
break MountOutcome::Shutdown;
@@ -365,7 +582,8 @@ pub fn mount(
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if bg.guard.is_finished() {
info!("mount ended externally");
if let Err(e) = bg.join() {
core.mounts.lock().close();
if let Err(e) = teardown_session(&core.session_live, || bg.join()) {
warn!(error = %e, "session join failed");
}
break MountOutcome::Unmounted;
@@ -373,7 +591,8 @@ pub fn mount(
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// Signal task gone (failed to install); fall back to join.
let _ = bg.join();
core.mounts.lock().close();
let _ = teardown_session(&core.session_live, || bg.join());
break MountOutcome::Unmounted;
}
}
@@ -381,9 +600,14 @@ pub fn mount(
// Unmount every on-demand sync folder too, or the kernel mounts linger as
// stale and the next start fails with EBUSY (devices.md Phase 3).
let secondaries: Vec<_> = core.mounts.lock().drain().collect();
for (id, (session, conn)) in secondaries {
if let Err(e) = umount_session_unblocked(session, conn) {
if let Some(worker) = restore_worker
&& worker.join().is_err()
{
warn!("on-demand restore worker panicked");
}
let secondaries = core.mounts.lock().close_and_drain();
for (id, mount) in secondaries {
if let Err(e) = mount.teardown() {
warn!(id, error = %e, "unmount on-demand folder failed");
}
}
@@ -391,3 +615,41 @@ pub fn mount(
let _ = std::fs::remove_file(control_socket);
Ok(outcome)
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::{SecondaryInsertRejection, SecondaryMountRegistry, teardown_session};
#[test]
fn session_is_unroutable_before_teardown_starts() {
let live = AtomicBool::new(true);
teardown_session(&live, || {
assert!(
!live.load(Ordering::Acquire),
"teardown must never run while the registry still reports mounted"
);
});
}
#[test]
fn secondary_registry_rejects_closed_and_duplicate_insertions() {
let mut mounts = SecondaryMountRegistry::default();
assert_eq!(
mounts.insert(7, "closed"),
Err((SecondaryInsertRejection::Closed, "closed"))
);
mounts.open();
assert_eq!(mounts.insert(7, "first"), Ok(()));
assert_eq!(
mounts.insert(7, "duplicate"),
Err((SecondaryInsertRejection::Duplicate, "duplicate"))
);
assert!(mounts.contains_key(&7));
assert_eq!(mounts.close_and_drain(), vec![(7, "first")]);
assert!(!mounts.is_accepting());
}
}
+27 -7
View File
@@ -12,6 +12,7 @@ use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use pdfs_core::control::RestorableFolder;
use pdfs_core::mounts::MountMode;
use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
@@ -133,7 +134,7 @@ impl Core {
local_path: f.local_path,
// A queued switch is where the user asked the folder to be, so
// that is what the next machine should set up.
mode: f.pending_mode.unwrap_or(f.mode),
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
})
.collect();
let pins = self
@@ -267,7 +268,7 @@ impl Core {
name: n.name,
local_path: proposed.to_string_lossy().to_string(),
mode: saved
.map(|f| f.mode.clone())
.map(|f| f.mode.to_string())
.unwrap_or_else(|| "mirror".into()),
}
})
@@ -319,10 +320,10 @@ impl Core {
skipped.push(format!("{}: {e}", item.local_path));
continue;
}
let mode = match item.mode.as_str() {
"mirror" | "ondemand" => item.mode.as_str(),
other => {
skipped.push(format!("{}: unknown mode {other}", item.local_path));
let mode = match restore_mode(&item.mode) {
Some(mode) => mode,
None => {
skipped.push(format!("{}: unknown mode {}", item.local_path, item.mode));
continue;
}
};
@@ -333,7 +334,7 @@ impl Core {
// `ondemand` is applied through the normal request path, which
// mounts the FUSE session and handles the folder being busy — the
// restore does not get its own copy of that logic.
if mode == "ondemand" {
if mode == MountMode::OnDemand {
let _ = self.request_sync_folder_mode(id, "ondemand");
}
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
@@ -350,6 +351,17 @@ impl Core {
}
}
/// Accept only modes this build can safely restore. `MountMode` itself is
/// tolerant so a future value does not reject the whole profile; the folder
/// carrying that value is skipped here with the message assembled above.
fn restore_mode(value: &str) -> Option<MountMode> {
match MountMode::from(value) {
MountMode::Mirror => Some(MountMode::Mirror),
MountMode::OnDemand => Some(MountMode::OnDemand),
MountMode::Unknown => None,
}
}
/// Whether a path recorded on another machine can be reused verbatim here.
///
/// Requires the *parent* to exist: `~/Documents` on a machine with the same
@@ -382,6 +394,14 @@ mod tests {
assert!(!path_is_usable_here(Path::new("relative/path")));
}
#[test]
fn unknown_profile_mode_is_skipped_without_rejecting_known_modes() {
assert_eq!(restore_mode("mirror"), Some(MountMode::Mirror));
assert_eq!(restore_mode("ondemand"), Some(MountMode::OnDemand));
assert_eq!(restore_mode("streaming"), None);
assert_eq!(restore_mode("unknown"), None);
}
/// A path whose parent exists but which does not itself is exactly the
/// restore case — the directory is about to be created.
#[test]
+155 -25
View File
@@ -57,11 +57,33 @@ impl Core {
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let (_ino, uid) = self.resolve(rel)?;
self.share_node_for_uid(&uid, emails, role, message)
}
/// [`Core::share_node`] for a node addressed through any daemon location.
pub(crate) fn share_node_by_uid(
&self,
uid: &str,
emails: &[String],
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let uid = self.resolve_anywhere(uid)?;
self.share_node_for_uid(&uid, emails, role, message)
}
fn share_node_for_uid(
&self,
uid: &NodeUid,
emails: &[String],
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let role = role_from_str(role)?;
let invitees: Vec<(String, MemberRole)> =
emails.iter().map(|e| (e.clone(), role)).collect();
self.rt
.block_on(self.client.invite_users(&uid, &invitees, message))
.block_on(self.client.invite_users(uid, &invitees, message))
.map_err(|e| CoreError::from_api(&e, "share"))
}
@@ -71,11 +93,26 @@ impl Core {
rel: &Path,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let (_ino, uid) = self.resolve(rel)?;
self.list_share_for_uid(&uid)
}
/// [`Core::list_share`] for a node addressed through any daemon location.
pub(crate) fn list_share_by_uid(
&self,
uid: &str,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let uid = self.resolve_anywhere(uid)?;
self.list_share_for_uid(&uid)
}
fn list_share_for_uid(
&self,
uid: &NodeUid,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let mut entries = Vec::new();
for m in self
.rt
.block_on(self.client.list_share_members(&uid))
.block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?
{
entries.push(ShareEntry {
@@ -87,7 +124,7 @@ impl Core {
}
for inv in self
.rt
.block_on(self.client.list_share_invitations(&uid))
.block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
{
entries.push(ShareEntry {
@@ -99,7 +136,7 @@ impl Core {
}
for ext in self
.rt
.block_on(self.client.list_external_invitations(&uid))
.block_on(self.client.list_external_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
{
entries.push(ShareEntry {
@@ -112,7 +149,7 @@ impl Core {
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?
.map(public_link_info);
@@ -129,12 +166,35 @@ impl Core {
role: &str,
) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
self.update_share_role_for_uid(&uid, id, kind, role)
}
/// [`Core::update_share_role`] for a node addressed through any daemon
/// location.
pub(crate) fn update_share_role_by_uid(
&self,
uid: &str,
id: &str,
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
self.update_share_role_for_uid(&uid, id, kind, role)
}
fn update_share_role_for_uid(
&self,
uid: &NodeUid,
id: &str,
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let role = role_from_str(role)?;
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(&uid))
.block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
@@ -146,7 +206,7 @@ impl Core {
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
.into_iter()
.find(|i| i.invitation_id == id)
@@ -170,11 +230,32 @@ impl Core {
kind: ShareEntryKind,
) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
self.remove_share_entry_for_uid(&uid, id, kind)
}
/// [`Core::remove_share_entry`] for a node addressed through any daemon
/// location.
pub(crate) fn remove_share_entry_by_uid(
&self,
uid: &str,
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
self.remove_share_entry_for_uid(&uid, id, kind)
}
fn remove_share_entry_for_uid(
&self,
uid: &NodeUid,
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(&uid))
.block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
@@ -186,7 +267,7 @@ impl Core {
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
.into_iter()
.find(|i| i.invitation_id == id)
@@ -198,7 +279,7 @@ impl Core {
ShareEntryKind::ExternalInvite => {
let ext = self
.rt
.block_on(self.client.list_external_invitations(&uid))
.block_on(self.client.list_external_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
.into_iter()
.find(|i| i.invitation_id == id)
@@ -219,13 +300,33 @@ impl Core {
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let (_ino, uid) = self.resolve(rel)?;
self.create_public_link_for_uid(&uid, role, password, expires)
}
/// [`Core::create_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn create_public_link_by_uid(
&self,
uid: &str,
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
self.create_public_link_for_uid(&uid, role, password, expires)
}
fn create_public_link_for_uid(
&self,
uid: &NodeUid,
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let role = role_from_str(role)?;
let link = self
.rt
.block_on(
self.client
.create_public_link(&uid, role, password, expires),
)
.block_on(self.client.create_public_link(uid, role, password, expires))
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
Ok(public_link_info(link))
}
@@ -233,9 +334,20 @@ impl Core {
/// Remove the public link `id` from the node at `rel`.
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
self.remove_public_link_for_uid(&uid, id)
}
/// [`Core::remove_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
self.remove_public_link_for_uid(&uid, id)
}
fn remove_public_link_for_uid(&self, uid: &NodeUid, id: &str) -> CoreResult<()> {
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.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"))?;
@@ -466,15 +578,33 @@ impl Core {
/// been seen through the mount (e.g. shared but not browsed to this session) —
/// the caller then leaves the path empty.
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
let st = self.state.lock();
let mut ino = *st.by_uid.get(uid)?;
let mut parts = Vec::new();
while ino != ROOT_INO {
let entry = st.entries.get(&ino)?;
parts.push(entry.node.name.clone());
ino = entry.parent;
}
parts.reverse();
Some(parts.join("/"))
// Searched across every mount: a node browsed through a sync folder is
// interned in that fork's inode space, so the primary state alone had no
// entry for it and the caller silently showed an empty path
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds
// it, which is the one the user reached it through.
let mut path = None;
self.for_each_state(|st| {
if path.is_some() {
return;
}
path = walk_to_root(st, uid);
});
path
}
}
/// Walk one inode space from `uid` up to its root, building the path. `None` if
/// that mount has never interned the node, or if the chain breaks partway —
/// a broken chain is a missing path, never a partial one.
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
let mut ino = *st.by_uid.get(uid)?;
let mut parts = Vec::new();
while ino != ROOT_INO {
let entry = st.entries.get(&ino)?;
parts.push(entry.node.name.clone());
ino = entry.parent;
}
parts.reverse();
Some(parts.join("/"))
}
File diff suppressed because it is too large. Load diff
+469
View File
@@ -0,0 +1,469 @@
//! Background conflict sweep.
//!
//! A `(sync-conflict <ts>)` copy is only ever a fallback: the drain and the sync
//! engine cut one when they cannot prove a queued write is safe to apply, so the
//! user never loses bytes. Most of those copies turn out to be byte-identical to
//! the live file — a spurious conflict from a re-stamped revision (B16/B25) or a
//! first-sync of an already-identical file. This sweep reconciles them after the
//! fact:
//!
//! - **Identical** to the live sibling (same size *and* same content SHA-1): the
//! copy is redundant, so it is trashed and logged as a [`ActivityKind::Trash`].
//! - **Divergent** (different bytes) or **orphaned** (no live sibling): left in
//! place and surfaced once as an [`ActivityKind::Conflict`], because only the
//! user can decide which version wins.
//!
//! Identity is proved without downloading anything: the SHA-1 comes from the
//! revision's decrypted extended attributes, which enumeration already reads. A
//! copy whose sibling has no recorded SHA-1 is treated as divergent — the sweep
//! never removes a file it cannot prove is a duplicate.
use std::collections::HashMap;
use std::time::Duration;
use pdfs_core::config::SweepMode;
use pdfs_core::control::ActivityKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, info, warn};
use super::{Core, node_content_sha1, node_revision_id, node_size};
/// Time between sweeps. Conflicts are rare and non-urgent; a slow cadence keeps
/// the sweep off the hot path while still cleaning up within a few minutes.
const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
/// Delay before the first sweep, so it does not compete with the burst of work a
/// fresh mount does at startup (hydrate, recover, initial enumeration).
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`,
/// returning the original file name it was a copy of. `None` when `name` is not a
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix`
/// formats, so the two halves of the product agree on what a conflict is.
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
const MARKER: &str = " (sync-conflict ";
let start = name.find(MARKER)?;
let after = start + MARKER.len();
let close_rel = name[after..].find(')')?;
let close = after + close_rel;
let inner = &name[after..close];
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
// file that merely happens to contain the phrase is not mistaken for a copy.
let mut parts = inner.splitn(2, '-');
let stamp = parts.next().unwrap_or_default();
let suffix = parts.next();
let is_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if !is_digits(stamp) || suffix.is_some_and(|s| !is_digits(s)) {
return None;
}
let mut base = String::with_capacity(start + name.len() - close);
base.push_str(&name[..start]);
base.push_str(&name[close + 1..]);
Some(base)
}
impl Core {
/// Sweep for stale conflict copies forever, on its own thread. See the module
/// docs for the identical/divergent policy.
pub(crate) fn run_conflict_sweep_loop(&self) {
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
loop {
self.sweep_conflicts_once();
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
}
}
/// One sweep pass over every node the daemon currently knows.
fn sweep_conflicts_once(&self) {
let stored = match self.db.load_all() {
Ok(s) => s,
Err(e) => {
warn!(error = ?e, "conflict sweep: load nodes failed");
return;
}
};
let nodes: Vec<_> = stored
.into_iter()
.map(|stored| stored.node)
.filter(|node| self.is_own_or_virtual(&node.uid))
.collect();
// Index live (non-trashed) files by (parent, name) for sibling lookup.
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
for node in &nodes {
if node.trashed || node.is_folder() {
continue;
}
let parent = node
.parent_uid
.as_ref()
.map(|u| u.to_string())
.unwrap_or_default();
by_parent_name.insert((parent, node.name.as_str()), node);
}
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
for node in &nodes {
if node.trashed || node.is_folder() {
continue;
}
let Some(base) = conflict_base_name(&node.name) else {
continue;
};
let parent = node
.parent_uid
.as_ref()
.map(|u| u.to_string())
.unwrap_or_default();
let sibling = by_parent_name.get(&(parent, base.as_str())).copied();
match decide(node, sibling, &base, self.sweep_mode) {
SweepAction::Remove => {
// Only while online — the remote trash is the point. Offline,
// it waits for a later pass.
if online {
self.remove_conflict_copy(node, &base);
}
}
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
}
}
}
/// Trash a conflict copy proven identical to its live sibling, and unhook it
/// locally — the same sequence the interactive unlink path uses after a trash.
///
/// `snapshot` is the node as the pass saw it, which by now is stale: the pass
/// enumerated every node up front and has been doing network I/O since. Before
/// touching anything remote this re-reads the node and refuses to act unless
/// it is *still* the same revision, still untrashed, still unwritten and still
/// unopened. Without that, a user edit landing inside the window is destroyed
/// — `discard_queued_ops` below drops the queued write *and its staged blob*,
/// which may hold the only copy of those bytes (`docs/BUGS.md` B71).
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
let uid = &snapshot.uid;
let name = snapshot.name.as_str();
if !self.duplicate_still_removable(snapshot) {
return;
}
match self
.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
{
Ok(()) => {}
Err(e) if super::is_gone(&e) => {}
Err(e) => {
warn!(%uid, name, error = %e, "conflict sweep: trashing duplicate failed");
return;
}
}
// Every mount, not just the primary: a conflict copy inside a sync
// folder lives in that fork's inode space, and leaving it hooked up
// there keeps a node we have just trashed remotely visible and
// readable there (`docs/BUGS.md` B74).
self.for_each_state(|st| {
st.forget_or_unlink(uid);
});
if let Err(error) = self.discard_queued_ops(uid) {
warn!(%uid, ?error, "conflict sweep: queued-op cleanup failed");
return;
}
self.cache.evict(uid);
self.evict_reader(uid);
if let Err(e) = self.db.delete_node(uid) {
warn!(%uid, error = ?e, "conflict sweep: db delete after trash failed");
}
self.invalidate_trash();
self.conflict_notified.lock().remove(uid);
self.log_activity(
ActivityKind::Trash,
name,
format!("auto-removed duplicate of {base}"),
true,
);
info!(%uid, name, base, "conflict sweep: removed identical conflict copy");
}
/// Re-check, immediately before the destructive call, everything the pass
/// decided from its stale snapshot. Any doubt means "leave it alone": a
/// conflict copy that survives to the next pass costs nothing, a wrongly
/// removed one costs the user data.
fn duplicate_still_removable(&self, snapshot: &proton_drive_rs::Node) -> bool {
let uid = &snapshot.uid;
let name = snapshot.name.as_str();
// A queued op means bytes are still owed an upload. `discard_queued_ops`
// would throw those away along with the staged blob holding them.
match self.db.has_any_op(&uid.to_string()) {
Ok(false) => {}
Ok(true) => {
debug!(%uid, name, "conflict sweep: skipping duplicate with queued ops");
return false;
}
// Cannot prove the queue is empty — assume it is not.
Err(e) => {
warn!(%uid, name, error = ?e, "conflict sweep: op check failed; skipping");
return false;
}
}
// Someone is writing to it, or holds it open. Trashing now would pull the
// node out from under a live handle.
if self.is_busy(uid) {
debug!(%uid, name, "conflict sweep: skipping duplicate that is open or being written");
return false;
}
// Finally, the node itself must not have moved on since the snapshot.
match self.db.node_by_uid(&uid.to_string()) {
Ok(Some(fresh)) => {
if fresh.trashed {
return false;
}
if fresh.name != snapshot.name || fresh.parent_uid != snapshot.parent_uid {
debug!(%uid, name, "conflict sweep: duplicate was renamed or moved; skipping");
return false;
}
if node_revision_id(&fresh) != node_revision_id(snapshot)
|| node_size(&fresh) != node_size(snapshot)
|| node_content_sha1(&fresh) != node_content_sha1(snapshot)
{
debug!(%uid, name, "conflict sweep: duplicate changed since the pass began");
return false;
}
true
}
// Already gone locally, or unreadable — either way, not ours to remove.
Ok(None) => false,
Err(e) => {
warn!(%uid, name, error = ?e, "conflict sweep: re-read failed; skipping");
false
}
}
}
/// Whether a node has a write in flight or a file handle open against it.
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
/// writer/holder check.
/// Asked across **every** mount. The sweep runs on the primary Core but
/// trashes nodes anywhere in the tree, and a file open inside a sync folder
/// is tracked in that fork's `State` — so consulting `self.state` alone
/// answered "idle" for every such file and let the sweep delete one with a
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
fn is_busy(&self, uid: &NodeUid) -> bool {
let mut busy = false;
self.for_each_state(|st| {
let Some(&ino) = st.by_uid.get(uid) else {
return;
};
busy |=
st.active_writes.contains_key(&ino) || st.handles.values().any(|&held| held == ino);
});
busy
}
/// Surface a conflict copy that needs the user's attention, at most once per
/// daemon run so the activity feed is not spammed each pass.
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
if !self.conflict_notified.lock().insert(uid.clone()) {
return;
}
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
self.log_activity(ActivityKind::Conflict, name, detail, false);
}
}
/// What the sweep concluded about one `(sync-conflict …)` copy.
#[derive(Debug, Clone, PartialEq, Eq)]
enum SweepAction {
/// Provably redundant: trash it, subject to the runtime interlocks in
/// [`Core::duplicate_still_removable`].
Remove,
/// Only the user can resolve this one; surface it with this detail.
Flag(String),
}
/// Decide what to do about conflict copy `node` given its live sibling (if any)
/// and the configured `mode`.
///
/// Pure, so the policy that governs deletion is testable without a mount, a
/// runtime, or an account — the same split used for `revision_changed` and
/// `is_own_self_supersede`. Everything that can fail or race lives in the caller.
fn decide(
node: &proton_drive_rs::Node,
sibling: Option<&proton_drive_rs::Node>,
base: &str,
mode: SweepMode,
) -> SweepAction {
match sibling {
Some(sib) if is_identical(node, sib) => {
if mode.is_enforcing() {
SweepAction::Remove
} else {
// Report-only: say what would happen, remove nothing. The point is
// to accumulate field evidence that the identity check is right
// before it is trusted with deletion (docs/BUGS.md B71).
SweepAction::Flag(format!(
"identical to {base}; would be removed (sweep is report-only)"
))
}
}
Some(_) => SweepAction::Flag(format!("differs from {base}")),
None => SweepAction::Flag(format!("no original {base} to reconcile against")),
}
}
/// Whether two file nodes provably hold the same bytes: equal size and equal,
/// present content SHA-1. A missing digest on either side is not proof, so the
/// sweep treats it as divergent rather than risk removing a real difference.
fn is_identical(a: &proton_drive_rs::Node, b: &proton_drive_rs::Node) -> bool {
if node_size(a) != node_size(b) {
return false;
}
match (node_content_sha1(a), node_content_sha1(b)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
/// A file node with the two fields the sweep's identity check reads.
fn file(name: &str, size: i64, sha1: Option<&str>) -> proton_drive_rs::Node {
proton_drive_rs::Node {
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(name)),
parent_uid: None,
kind: NodeKind::File {
media_type: "text/plain".into(),
total_size_on_storage: size,
active_revision_state: None,
active_revision_id: Some("rev".into()),
claimed_size: Some(size),
claimed_modification_time: None,
content_sha1: sha1.map(String::from),
},
name: name.into(),
creation_time: 0,
modification_time: 0,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
/// The only input shape that may ever produce a removal: same size, same
/// present digest, live sibling, enforcing mode.
#[test]
fn removes_only_a_provably_identical_copy_when_enforcing() {
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
let live = file("f.txt", 100, Some("aaa"));
assert_eq!(
decide(&copy, Some(&live), "f.txt", SweepMode::Enforce),
SweepAction::Remove
);
}
#[test]
fn report_only_never_removes_but_still_says_what_it_would_do() {
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
let live = file("f.txt", 100, Some("aaa"));
// Same inputs as the removal case above; only the mode differs.
let SweepAction::Flag(detail) = decide(&copy, Some(&live), "f.txt", SweepMode::Report)
else {
panic!("report mode must never remove");
};
assert!(detail.contains("would be removed"), "{detail}");
assert!(detail.contains("report-only"), "{detail}");
}
#[test]
fn a_differing_size_is_a_real_conflict() {
let copy = file("f (sync-conflict 1).txt", 101, Some("aaa"));
let live = file("f.txt", 100, Some("aaa"));
assert_eq!(
decide(&copy, Some(&live), "f.txt", SweepMode::Enforce),
SweepAction::Flag("differs from f.txt".into())
);
}
#[test]
fn a_missing_digest_is_never_proof_of_identity() {
// Equal sizes are not enough: two different 100-byte files are common.
// A copy the sweep cannot fingerprint must survive, in either direction.
for (a, b) in [(None, Some("aaa")), (Some("aaa"), None), (None, None)] {
let copy = file("f (sync-conflict 1).txt", 100, a);
let live = file("f.txt", 100, b);
assert_eq!(
decide(&copy, Some(&live), "f.txt", SweepMode::Enforce),
SweepAction::Flag("differs from f.txt".into()),
"{a:?} vs {b:?}"
);
}
}
#[test]
fn a_differing_digest_at_equal_size_is_a_real_conflict() {
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
let live = file("f.txt", 100, Some("bbb"));
assert_eq!(
decide(&copy, Some(&live), "f.txt", SweepMode::Enforce),
SweepAction::Flag("differs from f.txt".into())
);
}
#[test]
fn an_orphaned_copy_is_surfaced_not_removed() {
// No live original left to reconcile against — the copy may be the only
// surviving version of the file, so it is never the sweep's to remove.
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
assert_eq!(
decide(&copy, None, "f.txt", SweepMode::Enforce),
SweepAction::Flag("no original f.txt to reconcile against".into())
);
}
#[test]
fn identity_needs_both_size_and_digest_to_match() {
let a = file("a", 100, Some("aaa"));
assert!(is_identical(&a, &file("b", 100, Some("aaa"))));
assert!(!is_identical(&a, &file("b", 101, Some("aaa"))));
assert!(!is_identical(&a, &file("b", 100, Some("bbb"))));
assert!(!is_identical(&a, &file("b", 100, None)));
}
#[test]
fn parses_conflict_names_and_leaves_others_alone() {
assert_eq!(
conflict_base_name("test_export (sync-conflict 1784898786).xml").as_deref(),
Some("test_export.xml")
);
assert_eq!(
conflict_base_name("README (sync-conflict 42)").as_deref(),
Some("README")
);
// The sync engine's de-duplicated suffix form.
assert_eq!(
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
Some("notes.txt")
);
// Not a conflict copy.
assert_eq!(conflict_base_name("report.xml"), None);
// Looks close but the token is not a stamp.
assert_eq!(conflict_base_name("x (sync-conflict abc).txt"), None);
}
#[test]
fn a_dotted_stem_keeps_its_inner_dots() {
assert_eq!(
conflict_base_name("archive.tar (sync-conflict 7).gz").as_deref(),
Some("archive.tar.gz")
);
}
}
+21
View File
@@ -670,6 +670,27 @@ impl Core {
match plan_file(l, r, baseline.get(rel)) {
FilePlan::Unchanged => {}
FilePlan::AdoptBaseline => {
// First sync of an already-identical file: record the baseline
// in place so future divergence is detected, without any
// transfer or a spurious conflict copy.
let local = l.expect("adopt baseline requires a local file");
let remote = r.expect("adopt baseline requires a remote file");
if let Err(e) = self.db.sync_entry_upsert(
folder_id,
&StoredSyncEntry {
rel_path: rel.clone(),
remote_uid: Some(remote.uid.to_string()),
local_mtime: local.mtime,
local_size: local.size,
remote_rev: Some(remote.mtime.to_string()),
remote_hash: Some(remote.size.to_string()),
},
) {
warn!(rel, error = ?e, "sync: adopt baseline failed");
outcome.errors += 1;
}
}
FilePlan::Deferred => {
debug!(
rel,
+38 -1
View File
@@ -200,6 +200,12 @@ pub(super) enum FilePlan {
DeleteLocal,
DeleteRemote,
ForgetBaseline,
/// First sync of a file that already exists on both sides with identical
/// content: record the baseline without transferring anything. Without this,
/// a folder synced for the first time cuts a `(sync-conflict)` copy of every
/// pre-existing file, because a missing baseline makes both sides read as
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md).
AdoptBaseline,
}
pub(super) fn plan_file(
@@ -219,10 +225,16 @@ pub(super) fn plan_file(
});
match (local, remote) {
(Some(_), Some(_)) => match (local_changed, remote_changed) {
(Some(l), Some(r)) => match (local_changed, remote_changed) {
(false, false) => FilePlan::Unchanged,
(true, false) => FilePlan::UploadRevision,
(false, true) => FilePlan::Download,
// With a baseline, both-changed is a genuine concurrent edit and must
// be preserved as a conflict. Without one, "both changed" only means
// this is the first sync and there was nothing to compare to: adopt
// the baseline when the sizes already match rather than conflict-copy
// an already-identical file. Divergent sizes stay a real conflict.
(true, true) if baseline.is_none() && l.size == r.size => FilePlan::AdoptBaseline,
(true, true) => FilePlan::Conflict,
},
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
@@ -350,6 +362,31 @@ mod file_plan_tests {
}
}
#[test]
fn first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() {
// No baseline yet (first sync). Same size on both sides is a pre-existing
// identical file, not a concurrent edit: adopt it rather than cut a
// conflict copy of every file in the folder.
let local = local(10, 40);
let remote = remote(30, 40);
assert_eq!(
plan_file(Some(&local), Some(&remote), None),
FilePlan::AdoptBaseline
);
}
#[test]
fn first_sync_of_differing_sizes_is_still_a_conflict() {
// No baseline and the sizes disagree: a genuine both-sides divergence,
// preserved non-destructively as a conflict.
let local = local(10, 20);
let remote = remote(30, 40);
assert_eq!(
plan_file(Some(&local), Some(&remote), None),
FilePlan::Conflict
);
}
#[test]
fn a_missing_remote_signature_is_treated_as_changed() {
let local = local(10, 20);
+30 -11
View File
@@ -63,6 +63,12 @@ async fn run_uploads(
.transfers
.begin(&t.name, "", TransferDirection::Upload, t.size);
let reader = OwnedCountingReader::new(file, guard);
if let Err(error) = core.require_uid_writable(&t.parent_uid) {
return Err((
t.name,
format!("destination access denied: errno {}", error.code()),
));
}
match core
.client
.upload_file_from(
@@ -110,6 +116,8 @@ impl Core {
let (pino, parent_uid) = self.resolve(parent_rel)?;
self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?;
self.require_uid_writable(&parent_uid)
.map_err(|error| self.errno_error(error, "upload destination"))?;
// Phase 1 (sequential): build the remote folder skeleton and collect the
// flat list of files to upload. Folders must exist before their children,
@@ -170,6 +178,8 @@ impl Core {
{
kids.push(ino);
}
drop(st);
self.flush_access_changes();
}
// Uploaded fine but the metadata refresh failed; it will
// appear on the next directory enumeration regardless.
@@ -205,19 +215,24 @@ impl Core {
}
self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?;
{
let existing = {
let st = self.state.lock();
if let Some(kids) = st.children.get(&pino) {
for &ino in kids {
if let Some(e) = st.entries.get(&ino)
&& e.node.is_folder()
&& e.node.name == name
{
return Ok((ino, e.uid.clone()));
}
}
}
st.children.get(&pino).and_then(|kids| {
kids.iter().find_map(|ino| {
st.entries
.get(ino)
.filter(|entry| entry.node.is_folder() && entry.node.name == name)
.map(|entry| (*ino, entry.uid.clone()))
})
})
};
if let Some((ino, uid)) = existing {
self.require_uid_writable(&uid)
.map_err(|error| self.errno_error(error, "upload destination"))?;
return Ok((ino, uid));
}
self.require_uid_writable(parent_uid)
.map_err(|error| self.errno_error(error, "upload destination"))?;
let new_uid = self
.rt
.block_on(
@@ -235,6 +250,10 @@ impl Core {
{
kids.push(ino);
}
drop(st);
self.flush_access_changes();
self.require_uid_writable(&new_uid)
.map_err(|error| self.errno_error(error, "created upload destination"))?;
Ok((ino, new_uid))
}
+318
View File
@@ -0,0 +1,318 @@
//! Synthetic nodes and naming rules for the mounted shared tree.
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use proton_drive_rs::{Node, NodeKind};
pub(crate) const VIRTUAL_VOLUME: &str = "virtual";
pub(crate) const SHARED_WITH_ME_LINK: &str = "sharedwithme";
pub(crate) const SHARED_WITH_ME_BASE: &str = "Shared with me";
const MAX_COMPONENT_BYTES: usize = 255;
pub(crate) fn shared_with_me_uid() -> NodeUid {
NodeUid::new(
VolumeId::from(VIRTUAL_VOLUME),
LinkId::from(SHARED_WITH_ME_LINK),
)
}
pub(crate) fn is_virtual_uid(uid: &NodeUid) -> bool {
uid.volume_id.as_str() == VIRTUAL_VOLUME
}
pub(crate) fn is_own_or_virtual_uid(uid: &NodeUid, own_volume: &VolumeId) -> bool {
uid.volume_id == *own_volume || is_virtual_uid(uid)
}
pub(crate) fn is_primary_root_listing(
primary: bool,
folder_uid: &NodeUid,
primary_root_uid: &NodeUid,
) -> bool {
primary && folder_uid == primary_root_uid
}
pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) -> Node {
Node {
uid: shared_with_me_uid(),
parent_uid: Some(parent_uid),
name,
kind: NodeKind::Folder,
creation_time: timestamp,
modification_time: timestamp,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
/// Choose the initial synthetic-root name. Once persisted, `pinned` always wins;
/// a later real collision only controls whether the synthetic dentry is exposed.
pub(crate) fn virtual_root_name(
real_names: &HashSet<String>,
pinned: Option<&str>,
) -> (String, bool) {
if let Some(pinned) = pinned {
return (pinned.to_string(), !real_names.contains(pinned));
}
for n in 0usize.. {
let candidate = match n {
0 => SHARED_WITH_ME_BASE.to_string(),
1 => format!("{SHARED_WITH_ME_BASE} (Proton)"),
_ => format!("{SHARED_WITH_ME_BASE} (Proton {n})"),
};
if !real_names.contains(&candidate) {
return (candidate, true);
}
}
unreachable!("the numbered virtual-root namespace is unbounded")
}
/// Give every shared root a unique Linux component name, independent of API
/// ordering. Literal suffix-shaped names reserve their basename before duplicate
/// names are numbered, so `Photos (2)` is never stolen by a second `Photos`.
pub(crate) fn disambiguate_shared_names(nodes: &mut [Node]) {
nodes.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| a.uid.to_string().cmp(&b.uid.to_string()))
});
let reserved: HashSet<String> = nodes
.iter()
.map(|node| component_with_suffix(&node.name, ""))
.collect();
let mut used = HashSet::with_capacity(nodes.len());
for node in nodes {
let original = node.name.clone();
let basename = component_with_suffix(&original, "");
let chosen = if used.insert(basename.clone()) {
basename
} else {
let mut n = 2usize;
loop {
let suffix = format!(" ({n})");
let candidate = component_with_suffix(&original, &suffix);
if !reserved.contains(&candidate) && used.insert(candidate.clone()) {
break candidate;
}
n += 1;
}
};
node.name = chosen;
}
}
pub(crate) fn listing_needs_refresh(online: bool, stale: bool) -> bool {
online && stale
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SharedListingPlan {
Resident,
Persisted,
Refresh,
}
pub(crate) fn shared_listing_plan(resident: bool, online: bool, stale: bool) -> SharedListingPlan {
if listing_needs_refresh(online, stale) {
SharedListingPlan::Refresh
} else if resident {
SharedListingPlan::Resident
} else {
SharedListingPlan::Persisted
}
}
pub(crate) fn refresh_generation_is_current(started: u64, current: u64) -> bool {
started == current
}
#[derive(Default)]
pub(crate) struct SharedRefreshDeadlines {
deadlines: HashMap<String, Instant>,
}
impl SharedRefreshDeadlines {
pub(crate) fn is_fresh(&mut self, key: &str, now: Instant) -> bool {
self.deadlines.retain(|_, deadline| *deadline > now);
self.deadlines
.get(key)
.is_some_and(|deadline| *deadline > now)
}
pub(crate) fn mark(&mut self, key: &str, now: Instant, ttl: Duration) {
self.deadlines.insert(key.to_string(), now + ttl);
}
pub(crate) fn clear(&mut self) {
self.deadlines.clear();
}
}
fn component_with_suffix(name: &str, suffix: &str) -> String {
let keep = MAX_COMPONENT_BYTES.saturating_sub(suffix.len());
let mut boundary = name.len().min(keep);
while !name.is_char_boundary(boundary) {
boundary -= 1;
}
let mut out = String::with_capacity(boundary + suffix.len());
out.push_str(&name[..boundary]);
out.push_str(suffix);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(volume: &str, link: &str, name: &str) -> Node {
let mut node = virtual_node(
NodeUid::new(VolumeId::from("virtual"), LinkId::from("parent")),
name.to_string(),
1,
);
node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(link));
node
}
#[test]
fn virtual_uid_and_node_round_trip() {
let parent = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
let node = virtual_node(parent.clone(), SHARED_WITH_ME_BASE.into(), 42);
assert_eq!(node.uid.to_string(), "virtual~sharedwithme");
assert!(is_virtual_uid(&node.uid));
assert_eq!(node.parent_uid.as_ref(), Some(&parent));
assert!(node.is_folder());
let json = serde_json::to_string(&node).unwrap();
let restored: Node = serde_json::from_str(&json).unwrap();
assert_eq!(restored.uid, node.uid);
assert_eq!(restored.name, SHARED_WITH_ME_BASE);
}
#[test]
fn root_collision_ladder_and_pinned_collision_policy() {
let names = HashSet::from([
SHARED_WITH_ME_BASE.to_string(),
format!("{SHARED_WITH_ME_BASE} (Proton)"),
format!("{SHARED_WITH_ME_BASE} (Proton 2)"),
]);
assert_eq!(
virtual_root_name(&names, None),
(format!("{SHARED_WITH_ME_BASE} (Proton 3)"), true)
);
let (name, visible) = virtual_root_name(&names, Some(SHARED_WITH_ME_BASE));
assert_eq!(name, SHARED_WITH_ME_BASE);
assert!(!visible, "a real later collision suppresses the dentry");
let cleared = HashSet::new();
assert_eq!(
virtual_root_name(&cleared, Some(SHARED_WITH_ME_BASE)),
(SHARED_WITH_ME_BASE.to_string(), true),
"the pinned name returns when the collision clears"
);
}
#[test]
fn duplicate_names_are_stable_under_reordering_and_reserve_suffixes() {
let input = vec![
folder("b", "2", "Photos"),
folder("c", "3", "Photos (2)"),
folder("a", "1", "Photos"),
];
let mut forward = input.clone();
let mut reverse = input;
reverse.reverse();
disambiguate_shared_names(&mut forward);
disambiguate_shared_names(&mut reverse);
let summarize = |nodes: &[Node]| {
nodes
.iter()
.map(|node| (node.uid.to_string(), node.name.clone()))
.collect::<Vec<_>>()
};
assert_eq!(summarize(&forward), summarize(&reverse));
assert_eq!(
forward
.iter()
.map(|node| node.name.as_str())
.collect::<Vec<_>>(),
["Photos", "Photos (3)", "Photos (2)"]
);
}
#[test]
fn disambiguation_respects_utf8_component_limit() {
let long = "é".repeat(200);
let mut nodes = vec![folder("a", "1", &long), folder("b", "2", &long)];
disambiguate_shared_names(&mut nodes);
assert!(nodes.iter().all(|node| node.name.len() <= 255));
assert!(
nodes
.iter()
.all(|node| node.name.is_char_boundary(node.name.len()))
);
assert_ne!(nodes[0].name, nodes[1].name);
assert!(nodes[1].name.ends_with(" (2)"));
}
#[test]
fn ttl_refresh_only_happens_online() {
assert!(!listing_needs_refresh(false, true));
assert!(!listing_needs_refresh(true, false));
assert!(listing_needs_refresh(true, true));
}
#[test]
fn offline_snapshot_suppresses_the_api_and_memory_deadline_suppresses_retries() {
assert_eq!(
shared_listing_plan(false, false, true),
SharedListingPlan::Persisted
);
assert_eq!(
shared_listing_plan(true, true, false),
SharedListingPlan::Resident
);
let now = Instant::now();
let mut deadlines = SharedRefreshDeadlines::default();
deadlines.mark("shared", now, Duration::from_secs(60));
assert!(deadlines.is_fresh("shared", now + Duration::from_secs(30)));
deadlines.clear();
assert!(!deadlines.is_fresh("shared", now + Duration::from_secs(30)));
}
#[test]
fn generation_change_rejects_an_inflight_response() {
assert!(refresh_generation_is_current(7, 7));
assert!(!refresh_generation_is_current(7, 8));
}
#[test]
fn only_the_primary_inode_space_installs_the_synthetic_root() {
let root = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
assert!(is_primary_root_listing(true, &root, &root));
assert!(!is_primary_root_listing(false, &root, &root));
assert!(!is_primary_root_listing(
true,
&NodeUid::new(VolumeId::from("own"), LinkId::from("device")),
&root
));
}
#[test]
fn ownership_predicate_excludes_foreign_share_volumes() {
let own = VolumeId::from("own");
let own_uid = NodeUid::new(own.clone(), LinkId::from("file"));
let foreign = NodeUid::new(VolumeId::from("foreign"), LinkId::from("share"));
assert!(is_own_or_virtual_uid(&own_uid, &own));
assert!(is_own_or_virtual_uid(&shared_with_me_uid(), &own));
assert!(!is_own_or_virtual_uid(&foreign, &own));
}
}
@@ -281,6 +281,7 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
ActivityKind::Share => "Shared",
ActivityKind::PublicLink => "Linked",
ActivityKind::Unshare => "Unshared",
ActivityKind::Conflict => "Conflict",
}
}
@@ -299,6 +300,7 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
ActivityKind::Restore => "edit-undo-symbolic",
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
ActivityKind::Unshare => "action-unavailable-symbolic",
ActivityKind::Conflict => "dialog-warning-symbolic",
}
}
+1
View File
@@ -225,6 +225,7 @@ The background daemon relies on the following thread topology:
3. **IPC listener Thread:** Listens on the Unix socket and admits at most 64 concurrent, timeout-bound handlers for front-end status/configuration requests.
4. **Sync Engine Loop Thread:** Serializes sync runs. Wakes on debounced local inotify filesystem changes, remote polling intervals, or manual user requests.
5. **Drain Queue Worker Thread:** Processes staged writes (`PendingOp`) sequentially, uploading revisions and retrying with exponential backoff on failures.
6. **Conflict Sweep Thread (`pdfs-conflict-sweep`, optional):** Reconciles leftover `(sync-conflict …)` copies — 30 s warmup, then one pass every 5 minutes. A copy proven identical to its live sibling (equal size **and** equal `content_sha1`) is redundant; anything it cannot prove identical is surfaced to the activity feed and left alone. Governed by `AppConfig.conflict_sweep` / `PDFS_CONFLICT_SWEEP` (`SweepMode`): **report-only by default**, `off` skips the thread entirely, and only `enforce` lets it trash. Because it deletes, the enforcing path re-verifies revision id, size, digest, name, parent, queued ops and open handles immediately before acting rather than trusting its own pass-start snapshot. See `docs/BUGS.md` B69 and B71.
---
+583 -1
View File
@@ -1159,6 +1159,8 @@ retracted.
**Verified:** Clean build and 200/200 workspace tests passing.
**Residual (2026-07-24):** the two-part defense narrowed the window but did not close it — the baseline was still keyed on `(mtime, size)`, which drifts when the server re-stamps the *same* revision. Reproduced again on a plain application save (`test_export.xml`): base mtime `1784897777` vs sealed `1784897780`, identical size, no other device involved. The root cause is that mtime is not a revision identity. Fixed properly by **B69**.
---
## B17 — FUSE mount lock contention, missing next_attempt_at in DB enqueue, and ioctl ENOTTY fix
@@ -1430,12 +1432,22 @@ added later, but it must retain the row and blob.
## B34 — Writable POSIX Exposure of Read-Only Shared Folders (MED-07)
**Status:** Open
**Status:** Fixed in code by P2/P3 (2026-07-29); P3 now exposes the shared tree, while credentialed
second-account and live FUSE acceptance remain pending P7
**Found:** 2026-07-21, multi-agent permissions audit (`audit_bugs.md` MED-07)
**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sharing.rs`
**Cause:** Shared "Viewer" (read-only) folders are exposed via POSIX with write permissions (`0755`). Local writes succeed initially on the mount but fail perpetually during background online drain with `403 Forbidden` errors.
**Fix:** P2 persists share authority in schema V17 `share_access`, inherits it through resident
entries, exposes read-only POSIX modes, and gates mutations at handler, queue, and drain boundaries.
Downgrades fail closed and are not acknowledged past failed durable state updates.
**Verification:** The local workspace passes formatting, locked checks, clippy with warnings denied,
and all 347 workspace tests, including P3 virtual-tree materialization and access inheritance plus
mutation gating, queued-operation authorization, and downgrade failure paths. Credentialed
second-account and live FUSE acceptance have not been run and remain required in P7.
---
## B35 — IPC Unix Socket Creation Permission Race (MED-08)
@@ -2110,3 +2122,573 @@ the explicit remote-folder mapping. Repeated retries also add noisy warnings.
rather than directly at the device root, reuse that folder by stable identity,
and make backup health visible. Verify upload, replacement, restart restore, and
byte-identical fresh-state recovery on a dedicated account.
## B69 — Spurious `(sync-conflict)` copies from mtime-keyed revision identity (B16/B25 root fix)
**Status:** Fixed — offline-tested 2026-07-25, **live-validated 2026-07-26** (see below)
**Found:** 2026-07-24, user reported `test_export (sync-conflict 1784898786).xml` created after a normal save from an application into the on-demand mount. A full-mount scan turned up ~156 conflict copies; 151 were byte-identical to their live sibling.
**Where:** `crates/pdfs-fuse/src/{drain,filesystem,lib,state,sweep,sync,sync/planner}.rs`, `crates/pdfs-core/src/cache.rs`, `crates/pdfs-core/src/control.rs`; SDK `proton-drive-rs` `node.rs`/`client.rs`/`public_link.rs`.
**Cause:** Two independent false-positive sources, both from `(mtime, size)` being used as a revision identity (the weakness B25 flagged; the recurrence B16 could not fully close):
1. **Re-stamped mtime.** `revision_conflict` compared the queued write's baseline mtime against the remote's. The server stamps a sealed revision with its own commit time (`…780`), a few seconds off the optimistic time the client baselined at (`…777`). Same bytes, same size, drifted mtime → the drain diverted the write into a conflict copy of its own base.
2. **First-sync of pre-existing files.** `plan_file` classified `(local, remote, no-baseline)` as `Conflict`, so the *first* reconcile of a folder cut a conflict copy of every file that already existed identically on both sides — the ~151-file storm, all in one ~40s window.
**Fix:** Make conflict detection key on the server **revision id**, the true identity (it advances iff a new revision was sealed):
1. **SDK** exposes `active_revision_id` and `content_sha1` (plaintext SHA-1 from the revision's decrypted `XAttr`) on `NodeKind::File` — both already on the wire / already decrypted, no extra round trips.
2. **`Baseline`** gains `revision_id`, captured at write-open (`WriteHandle.base_revision_id`) and carried across supersede/rebaseline like the mtime. `revision_changed` (extracted, pure, unit-tested) trusts the id when both sides have it and only falls back to `(mtime, size)` for an old sidecar. A re-stamped mtime on the same id is no longer a conflict; a same-size edit by another device (new id) still is.
3. **Planner** adds `FilePlan::AdoptBaseline`: a no-baseline pair of equal size is recorded into the baseline instead of conflicted. Divergent sizes stay a real conflict.
4. **Auto-sweep** (`sweep.rs`, `pdfs-conflict-sweep` thread, 5-min cadence): removes conflict copies proven identical to their live sibling (equal size **and** equal `content_sha1`, trashing the copy remotely) and surfaces divergent/orphaned ones once as a new `ActivityKind::Conflict` entry. Never removes a copy it cannot prove is a duplicate.
**Verified:** `proton-sdk-rs` 50/61 lib tests + clippy clean; client `cargo fmt`/`clippy -D warnings`/`cargo test --workspace --locked` all green (258 tests, incl. new `revision_changed`, planner `AdoptBaseline`, and `conflict_base_name` cases). The 151 identical copies were removed manually during triage; the 5 divergent ones are left for the user.
**Live validation (2026-07-26):** 1.1.0 installed over the production account (6
FUSE mounts of real data), daemon restarted, sweep observed across its warmup pass
and a full 5-minute interval. Pre-run snapshot: exactly 2 `(sync-conflict)` copies
left on the account, both divergent in size *and* `content_sha1`, so the correct
behaviour was to remove nothing. Result: **nothing removed** — the conflict
manifest was byte-identical before and after, the activity feed gained zero
`auto-removed duplicate` entries, and the copies were flagged `differs from …` as
intended. (The 40 `trash` entries in the feed for that window are
`trashed from the mount` — a previous FUSE-acceptance run's queued cleanup ops
replaying through the drain, unrelated to the sweep.) The sweep's *removal* path
therefore remains unexercised against a real account; it is now report-only by
default (B71) precisely so that stays true until there is field evidence.
**Shipping note (resolved 2026-07-25):** the client's revision-id/sha1 use depends on new SDK surface (`proton-sdk`/`proton-drive-rs` **0.2.2**). That version is now **published to crates.io**; the client's workspace deps were bumped `"0.2"` → `"0.2.2"`, the `Cargo.lock` re-resolved to the registry crates, and the local `[patch.crates-io]` shim removed. No local patch remains. (Live validation of the fix against a real account is still pending.)
## B70 — Browser in-flight temp files uploaded and conflict-forked on the on-demand mount
**Status:** Layers A + B fixed in-tree 2026-07-25 — **live-validated 2026-07-26**.
Validation was blocked until B74 was fixed (its scenario ends in a rename, which
B74 made read back as empty); with that out of the way the acceptance case
`regression B70: transient download name is not sealed` passes live.
**Found:** triaging the divergent conflict copies B69 left behind. In `~/Downloads`
(an on-demand mount) the complete files had been forked into `(sync-conflict)`
copies while their truncated partials kept the canonical name — e.g.
`teamspeak.tar.gz` (30 MB partial live vs a 3.77 GB conflict copy) and `nils.zip`
(17 MB vs 3.18 GB). The conflict copies were byte-exact to the user's independent
gdrive backups, proving the *conflict copy* was the finished download and the
*live* file the stub. The smoking gun: four `Unconfirmed NNNNN (sync-conflict …).crdownload`
copies — Brave's in-flight temp files, forked *mid-download*. User confirmed the
trigger: downloading with Brave directly into the on-demand folder, where a stall
+ "resume" is routine.
**Where:** on-demand write path — `crates/pdfs-fuse/src/filesystem.rs` `release`
(`queue_revision`, ~line 780) and `rename` (~line 933); drain seal in
`crates/pdfs-fuse/src/drain.rs`. The existing ignore system
(`crates/pdfs-core/src/syncignore.rs`, `DEFAULT_IGNORE_PATTERNS`) is wired **only**
into mirror reconcile (`sync.rs`), so it never sees on-demand writes.
**Cause:** A browser downloading into the mount writes a growing temp file
(`*.crdownload`, or `*.part` for Firefox) and, on a stall, closes the fd. `release`
hands every closed write to `queue_revision`, so a *partial* revision gets sealed
as the canonical file. On resume the browser reopens and rewrites the full file;
its baseline revision has moved (the client sealed the partial itself), so the
drain diverts the finished bytes into a `(sync-conflict)` copy. Net: the stub wins
the name, the real file is exiled to a conflict copy, and every abandoned
`.crdownload` is uploaded as its own multi-hundred-MB node. Unlike B69 this is a
*genuine* two-revision divergence, so B69's revision-id detection correctly refuses
to auto-sweep it — the fix has to stop the fork from happening, not reconcile it
after.
**Fix — two layers, both without an SDK release (A stops transient names ever
sealing; B stops a single-writer resume forking against its own seal):**
- **A — transient names never seal a revision until finalized (done 2026-07-25).**
A new `syncignore::is_transient_name` recognises browser download temps
(`*.crdownload`/`*.part`/`*.partial`/`*.download`), generic scratch
(`*.tmp`/`*.temp`), editor swap/backup (`*.swp`/`*.swx`/`*~`), and office/lock
files (`.~lock.*`, `~$*`). On `create` (filesystem.rs) a transient name is kept
**purely local** — no empty remote node is minted — and its queued create is
**parked**: `next_attempt_at` is set to the new `PARK_UNTIL` sentinel so
`Db::next_due_op` never selects it. Written bytes still ride on that create via
the existing `attach_blob_to_create`, whose `next_attempt_at` reset now
*preserves* a park (SQL `CASE … >= PARK_UNTIL`), so a growing download attaches
revision after revision without ever waking the drain. The finalize `rename`
(`foo.crdownload → foo`) goes through the existing `is_local_uid` branch
(`rewrite_op_target` renames the queued create); when the old name was transient
and the new one is not, it calls `Db::set_create_hold(uid, false)` and wakes the
drain, so the *completed* file uploads exactly once. Net: no partial and no
abandoned temp ever reaches Drive, and nothing forks. Reuses the whole
create/attach/rename/drain pipeline — the only new surface is the predicate, the
`PARK_UNTIL` sentinel, `set_create_hold`, a `hold` arg on `queue_local_node`,
and the attach-preserves-park `CASE`. Tests: `syncignore` predicate cases +
`db::a_parked_transient_create_stays_off_the_drain_until_finalized`. No SDK
dependency.
- *Not yet covered by A:* apps that write the final name in place with **no**
temp suffix (rare), and Firefox multi-connection `.part` files that it may
rename per-segment — those still rely on B. An abandoned transient file (a
cancelled download left on disk) now stays local-only forever rather than
polluting Drive; a later janitor could reclaim its staged blob.
- **B — don't self-conflict a single-writer sequential rewrite (done 2026-07-25,
no SDK dep).** The safety net for names A does not recognise (an app writing the
final name in place, Firefox per-segment `.part` renames, or any resume the
in-memory rebase machinery missed). The daemon now records the server revision
id it *itself* seals per node (`Core.own_sealed_revs`, written in
`refresh_after_upload` where the sealed node is already fetched, dropped on
trash). When a queued write drains and `revision_changed` fires,
`Core::revision_conflict` checks whether the remote sits at one of *our own*
sealed revisions (pure `is_own_self_supersede`): if so, no other device touched
the file — it is a single-writer stall→resume — so it chains (supersedes)
instead of forking a `(sync-conflict)` copy. Gated on `meta.complete`: an
incomplete blob's gaps still refer to the stale base, so it keeps the
non-destructive conflict-copy path. This is a superset guard over the existing
`refresh_after_upload`/`rebaseline_pending` rebasers, which only fire while a
handle/op is live; B catches the windows they miss (fresh open during the
earlier upload's flight, a stale inherited baseline). Tests: `is_own_self_supersede`
cases in `drain.rs`. *Residual:* the map is in-memory only, so a daemon restart
between the partial seal and the resume drops the record and that one write can
still fork — narrow, and B69's revision-id keying already covers the mtime-drift
half. A persisted own-seal ledger would close it.
**Triage of the copies already made is done (2026-07-25):** all completed files
were promoted back to their canonical names (metadata-only rename, no re-upload)
and the abandoned `.crdownload` stubs trashed.
## B71 — Conflict sweep is not production-ready (auto-trash loop, CRIT-13)
**Status:** Blockers 1–4 fixed in-tree 2026-07-26; items 5–10 and the remaining
cleanup still open. The sweep is safe to leave running (it is report-only by
default and cannot trash anything until explicitly switched to `enforce`).
**Where:** `crates/pdfs-fuse/src/sweep.rs`, spawn site `crates/pdfs-fuse/src/mount.rs`,
config surface `crates/pdfs-core/src/config.rs`.
The B69 fix (see above) shipped a `pdfs-conflict-sweep` thread that **trashes user
files on the real Drive** from a background loop. The identity check itself is
conservative (equal size *and* equal `content_sha1`, missing digest treated as
divergent), but the machinery around it is not yet safe to run unattended. Review
found the following, ordered by severity. Each is a separate defect; they are
grouped because they gate the same feature.
**Blockers (all fixed 2026-07-26 — the sweep may not trash anything without them):**
1. **Data-loss race — trash-then-discard (CRIT).** `remove_conflict_copy`
(`sweep.rs:137-155`) trashes the node remotely and *then* calls
`discard_queued_ops(uid)`, which deletes the node's queued ops **and discards
their staged blobs** (`lib.rs:1544-1553`). The decision was made from the
`load_all()` snapshot taken at `sweep.rs:77`, with network round trips in
between. A user edit landing in that window is destroyed — the queued write is
dropped and its staged bytes, which may be the only copy, are discarded. This
violates the invariant that `staging/` is never purged. *Fix:* re-read the node
from the DB and re-verify `active_revision_id` immediately before trashing;
skip the node outright if `Core.pending` or the op table has any entry for it;
never let the sweep discard staged bytes.
2. **No kill-switch (CRIT).** `mount.rs:234-239` spawns the loop unconditionally.
There is no `AppConfig` field and no environment override, so the only way to
stop a destructive background loop is to uninstall. *Fix:* add
`AppConfig.conflict_sweep: Option<bool>` (same `Option` idiom as `cache_budget`
/ `ignore_patterns`), a `PDFS_CONFLICT_SWEEP=off` override for support, and a
Settings toggle.
3. **Open handles ignored (HIGH).** Nothing checks for a live file handle before
`forget_or_unlink` + `evict_reader`, so the sweep can trash a node a reader
currently has open. *Fix:* skip nodes with open handles.
4. **Enforcing on day one (HIGH, process).** The sweep exists because B69 proved
the previous revision-identity check was wrong; betting deletion on the
*replacement* check in the same release, with no field evidence, is the wrong
order. *Fix:* ship report-only (log `would-trash` plus the existing
`ActivityKind::Conflict`), collect a release cycle of real logs, then flip to
enforcing.
**Fix for 1–4 (landed 2026-07-26):**
- **`SweepMode`** (`pdfs-core/src/config.rs`) — `Off` / `Report` / `Enforce`,
serialised into `AppConfig.conflict_sweep` (`#[serde(default)]`, so configs
predating the field still load). `Default` is **`Report`**, which is blocker 4:
an existing install that has never heard of the setting cannot get the
enforcing behaviour on upgrade. `AppConfig::resolved_conflict_sweep` layers
`PDFS_CONFLICT_SWEEP` over the stored value; the precedence is a pure
`resolve_sweep_mode(env, stored)` so it is testable without mutating process
state. An unparseable value falls through to the config rather than guessing a
mode in either direction.
- **Spawn gate** (`mount.rs`) — `SweepMode::Off` skips the thread entirely rather
than starting an idle one, so the setting is verifiable from outside the
process (`ls /proc/<pid>/task/*/comm`). Both branches log the resolved mode at
startup. `mount` grew past clippy's argument limit, so `username` +
`sweep_mode` moved into a `MountOptions` struct resolved by the caller —
`mount` still reads neither config nor environment.
- **Interlocks** (`sweep.rs::duplicate_still_removable`) — re-checks, immediately
before the destructive call, everything the pass decided from its stale
snapshot: no queued op (new `Db::has_any_op`, a cheap `COUNT` — a queued op
means bytes are owed an upload and `discard_queued_ops` would throw away the
staged blob holding them), no open write handle or shared scratch file
(`Core::is_busy`, checking `active_writes` + `handles` under one `State` lock),
and the node still at the same revision id, size, digest, name, parent and
untrashed state. Any doubt — including a failed DB read — means leave it alone:
a copy that survives to the next pass costs nothing, a wrongly removed one
costs data. This is blockers 1 and 3.
- **Testability** — the decision moved into a pure
`decide(node, sibling, base, mode) -> SweepAction`, the same extraction used
for `revision_changed` and `is_own_self_supersede`; everything that can fail or
race stays in the caller. Tests now cover removal-when-enforcing,
report-mode-never-removes, size-differs, digest-differs, missing-digest in both
directions, and orphaned copies. Gate: `cargo fmt` / `clippy -D warnings` clean,
`cargo test --workspace` 282 passed (was 270).
**Should fix:**
5. **`load_all()` every pass (MED).** Each 300 s pass pulls the entire node table
into a `Vec<StoredNode>` and builds a full `(parent, name)` `HashMap`. *Fix:*
query only conflict-named rows (`name LIKE '% (sync-conflict %'`) and look up
siblings per parent — O(conflicts), not O(drive).
6. **No batching or per-pass cap (MED).** The B69 incident produced 151 copies;
that is 151 sequential `trash_nodes` calls in one pass with no cap and no
backoff. `trash_nodes` takes a slice — batch it and cap per pass.
7. **`online` sampled once per pass (MED).** Read at `sweep.rs:100`, ahead of a
loop that does network I/O; going offline mid-pass yields a long run of failing
calls. *Fix:* bail on the first transport error.
8. **Ambiguous sibling index (MED).** `by_parent_name.insert` (`sweep.rs:97`) is
last-write-wins, so with duplicate `(parent, name)` rows the surviving entry
depends on `load_all` row order. *Fix:* make the choice deterministic or skip
ambiguous parents.
9. **Trashed ancestors (LOW).** A copy under a trashed folder finds no live
sibling and is flagged "orphaned", spamming the activity feed. *Fix:* skip
nodes with a trashed ancestor.
10. **SHA-1 provenance unverified (MED).** `is_identical` (`sweep.rs:181`)
compares digests without asserting they belong to the current
`active_revision_id`. A stale `XAttr` digest from an earlier revision plus a
size collision would delete real data. (The *missing* digest case is already
handled correctly.)
**Also required to close:**
- **Tests.** The *policy* is now covered via the pure `decide` (see above). Still
untested are the *interlocks*, which need a `Core`: pending-op→skip,
open-handle→skip, revision-moved→skip, offline→skip. Those want a test harness
that can build a `Core` against a temp DB and a stub client.
- **`conflict_notified` is unbounded.** `HashSet<NodeUid>` that only grows except
on trash (`sweep.rs:157,170`).
- **Docs.** `docs/ARCHITECTURE.md` thread map does not list `pdfs-conflict-sweep`;
`docs/RECOVERY.md` needs a "the sweep trashed a file, recover it from Proton
trash" path.
**Mitigating context from the 2026-07-26 pre-run snapshot:** the account had only
2 conflict copies left, both divergent in size *and* `content_sha1`, so the
correct behaviour for the first live pass is to trash nothing.
## B72 — `pdfs trash` never returns
**Status:** Hardened in-tree 2026-07-26 — the hang is gone by construction; the
underlying slowness of the first refresh is still unmeasured (live-pending).
**Found:** capturing a trash baseline before the B69/B70 live validation. `pdfs trash`
produced no output and was still running when killed at 120 s. Daemon was healthy
and every other CLI call (`pdfs sync list`, `pdfs --version`) returned promptly.
**Where:** `Core::list_trash` (`crates/pdfs-fuse/src/lib.rs`). Not the CLI: the
client's 120 s `READ_TIMEOUT` (`pdfs-core/src/control.rs`) is what ends the call,
and the daemon never writes a response line.
**Root cause.** The trash of this account had never been fetched successfully —
`trash` table empty, no `trash_synced_ms` in `sync_state`. `list_trash` therefore
always took its "never fetched → `rt.block_on(refresh_trash())`" branch, which:
- materialized the *entire* trash in one `enumerate_nodes` call (one S2K unlock
per node) before persisting anything, so nothing was ever saved and no later
request could be served from the DB;
- had no single-flight guard on that branch (only `spawn_trash_refresh` did), so
every attempt started another full refresh;
- was never cancelled when the requester timed out, because the control handler
sits in `block_on` on its own thread and nothing signals it.
So each look at the trash added a permanent, full-cost refresh to the daemon.
Measured on the live daemon after a handful of GUI/CLI attempts: **11 stale
`pdfs-control` threads**, RSS 2.9 GB. Concurrent with a drain backlog that was
uploading conflict copies continuously (1991 `Conflict` activity rows, multi-GB
uploads), none of the refreshes ever finished.
**Fix (in-tree).** `list_trash` never blocks on the whole refresh: it kicks the
single-flighted background refresh, waits at most `TRASH_FIRST_WAIT` (20 s, well
under the front-end read timeout) on a `Notify`, and answers with whatever has
materialized. `refresh_trash` now materializes in `TRASH_MATERIALIZE_CHUNK` (150)
batches, persisting the cumulative listing after each one, and logs the uid count
plus per-batch and total elapsed at INFO/DEBUG — previously the whole path was
silent, which is why the stall could not be located from the journal.
**Still open:** *why* one refresh is slow enough to matter. The new INFO lines
(`trash refresh: enumerated` / `batch` / `done`) answer that on the next run:
whether `enumerate_trash_node_uids` returns at all, how many nodes the trash
holds, and the per-batch cost. If the count is large, materializing with
`enumerate_nodes_light` (skips the per-file node-key S2K; `size` would fall back
to on-storage size) is the next lever.
**Impact:** the trash listing is the user's recovery path after any destructive
operation, including the B71 sweep. It needs to work before the sweep is allowed
to trash anything.
## B73 — Unimplemented FUSE handlers log a WARN per probe
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the
expanded acceptance suite.
**Found:** `scripts/fuse-acceptance.sh --live` step "unsupported operations refuse
cleanly". Each probe produced a multi-line `fuser` warning in the daemon journal:
```
WARN fuser: [Not Implemented] symlink(parent: INodeNo(0x2d), link_name: "symlink", target: "link-target")
WARN fuser: [Not Implemented] link(ino: INodeNo(0x94), newparent: INodeNo(0x2d), newname: "hardlink")
WARN fuser: [Not Implemented] mknod(parent: INodeNo(0x2d), name: "fifo", mode: 4516, umask: 0x12, rdev: 0)
WARN fuser: [Not Implemented] setxattr(ino: INodeNo(0x91), name: "user.pdfs.probe", flags: 0x0, position: 0)
WARN fuser: [Not Implemented] lseek(ino: INodeNo(0x93), fh: 0, offset: 0, whence: 3)
```
**Cause:** `ProtonFs` does not implement these callbacks, so `fuser`'s default
trait method answers `ENOSYS` *and logs at WARN*. The refusal is correct — the
acceptance suite accepts every one of them as a clean refusal — but the logging
is not conditional on anything the user did wrong.
**Impact:** cosmetic, but these are not exotic calls: `git` probes symlinks,
`cp -a` probes hard links, `tar` and `cp --sparse` probe `SEEK_HOLE`/`SEEK_DATA`
(`whence: 3` above is `SEEK_DATA`), and any xattr-aware tool probes `setxattr`.
Warnings that look like faults bury real ones and make both `--journal-check`
and manual triage noisier.
**Measured** across two full acceptance runs against one daemon lifetime (PID
887441, mount never remounted):
| op | warnings | behaviour |
|---|---|---|
| `link` | 11 | repeats on every call |
| `symlink` | 4 | repeats on every call |
| `mknod` | 2 | repeats on every call |
| `setxattr` | 1 | kernel cached the `ENOSYS`; never asked again |
| `lseek` | 1 | cached |
| `copy_file_range` | 1 | cached |
| `fsyncdir` | 1 | cached |
So the FUSE kernel module remembers `ENOSYS` for the file-level operations and
stops issuing them for the lifetime of the mount, but not for the namespace
operations, which are re-issued forever.
`link` leads for a sharper reason than "tools probe it": git uses `link()` to
place **every loose object**, falling back to `rename()` when it fails. In one
workloads run, 9 of 10 `link` warnings carried a 38-hex-character name — the
`.git/objects/ab/<38 hex>` layout — from just 3 commits over 3 files. The volume
is therefore proportional to work done, not a fixed startup cost: a `git clone`
of a real repository emits one multi-line warning per object. The lone `symlink`
that is not the acceptance probe is git's `core.symlinks` detection at
`git init` (a random name pointing at `testing`).
That moves this from cosmetic to a genuine operational problem: ordinary use of
git inside a mount can bury every other log line.
**Fix (applied):** all seven callbacks are now implemented explicitly in
`crates/pdfs-fuse/src/filesystem.rs`, each answering with **the same errno
`fuser`'s default would** and nothing else — no WARN. Following `ioctl`
(which already used this pattern and says why in its comment), the errno
contract is unchanged, so the acceptance suite's clean-refusal assertions hold
byte for byte:
| op | errno | why that one |
|---|---|---|
| `symlink`, `link` | `EPERM` | meaningful operation, unrepresentable on Drive; git falls back to `rename()` |
| `mknod` | `ENOSYS` | no devices/FIFOs/sockets; regular files arrive via `create` |
| `setxattr` | `ENOSYS` | surfaces as `EOPNOTSUPP`; reads stay implemented for the thumbnail interface |
| `fsyncdir` | `ENOSYS` | kernel absorbs it and reports success; dir changes are already durable in SQLite |
| `lseek`, `copy_file_range` | `ENOSYS` | **must stay** `ENOSYS` — it is what makes the kernel emulate `SEEK_HOLE`/`SEEK_DATA` and coreutils fall back to read/write instead of failing |
Only the three namespace ops were strictly required (the other four silence
themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the
refusal explicit and documented at the call site rather than inherited.
**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
in the journal across the whole run (was 17, 10 of them `link`).
**Regression cover:** the acceptance suite's clean-refusal probes assert the
errno contract; they pass unchanged.
## B74 — A drained create never reaches a sync-folder mount's inode space, so the file reads as empty
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the
expanded acceptance suite (`regression B70` case), then isolated to a broader
trigger.
**Impact:** on a *sync folder* mount (not `~/ProtonDrive`), a file whose create
was still queued reads back as **0 bytes for as long as the daemon runs**, even
though the bytes are on Drive intact. This is the write-to-temp-then-rename
pattern used by every browser download, most editor saves, `rsync`, and `git`.
**Not data loss.** An earlier revision of this entry called it permanent data
loss; that was wrong and is corrected here. The uploaded revision is complete and
correct: for every "lost" file the node row carried the right `size` and
`active_revision_state: "Active"`, and a daemon restart made all of them read
back with the expected sha256. Nothing had to be recovered. The defect was
entirely local and entirely in the read path.
**Reproduction** (on an on-demand mount, per file: write, close, rename, wait for
`pdfs status` to report the queue drained, then read back):
| scenario | result |
|---|---|
| plain name, never renamed | content intact |
| plain name, renamed after close | **0 bytes until restart** |
| `.crdownload`, renamed after close | **0 bytes until restart** |
| `.crdownload`, never renamed | content intact |
| already-settled file, renamed | content intact |
| any of the above, after a daemon restart | content intact |
The gap between `close()` and `rename()` does not matter — 0 s, 2 s and 20 s all
behave the same. The boundary is not timing but state: the affected node is one
whose **create op was still queued**, on a mount that is not the primary one.
**Root cause — one drain thread, many inode spaces.** The daemon mounts
`~/ProtonDrive` plus one FUSE session per `ondemand` sync folder. Each of those
is a `Core::fork_state` clone with its **own** `State` — its own `entries`,
`by_uid`, `children`, `next_ino` — while sharing `db`, `cache`, `pending` and
`client`. There is exactly **one** `pdfs-drain` thread in the process, owned by
the primary mount, and it serves the whole shared `pending_op` table.
So when a create queued by a sync-folder mount lands, the drain calls
`adopt_real_uid` — which looked only at `self.state`, the *primary* mount's inode
space. The fork's entry keeps its `local~…` placeholder uid forever. And
`Core::read_range` (`crates/pdfs-fuse/src/reads.rs:314`) short-circuits a local
uid to an empty vec:
```rust
if is_local_uid(uid) { return Ok(Vec::new()); }
```
which is why the read is silent, instant, and zero-length. A restart rebuilds
every state from the DB, where `remap_local_uid` had already written the real
uid, so the file reads correctly from then on.
Confirmed live: `read handler uid=local~1785067300752-0 fsize=140000` three
seconds after `pending create landed`, and at the same moment
`adopt_real_uid … hit=None strays=[]` — the primary state had no entry for the
uid at all. `/proc/<pid>/task/*/comm` showed 1 `pdfs-drain` against 8 fuser
sessions.
**Fix:** `Core` gained a `states: Arc<StateRegistry>` — a `Vec` of weakly-held
mounts that every mount publishes itself into (`Core::register_state`, called
from `mount.rs` for the primary and from `fork_state` for each fork), plus
`Core::for_each_state`, which walks every live inode space one lock at a time.
A uid is unique across mounts, so at most one state matches and the rest are
no-ops. Five drain sites were converted: `adopt_real_uid`, `adopt_drained_name`,
the conflict-copy `invalidate_listing`, the `active_writes` baseline rebase in
`refresh_after_upload`, and that function's closing `intern`.
**It was completely silent.** No `ERROR`, no `WARN`, no failed op, no conflict
copy, no retry — `pdfs status` reported the queue drained, because it had. Any
diagnosis that relies on the journal or on queue state will not see this.
**Regression cover:** `scripts/fuse-acceptance.py`, case
`regression B74: rename after close keeps its content` — the narrowest form
(plain names, no overwrite, no transient suffix). It failed in two consecutive
live runs before the fix and passes after it. The pre-existing B70 case failed on
the same cause and now passes too.
**Audit of the rest of the daemon.** Every background-thread `state.lock()` was
reviewed for the same shape. The rule that came out of it: **a uid is unique
across mounts, so uid-keyed work must be broadcast; an inode number is only
meaningful to the mount that minted it, so inode-keyed work must not be.** What
changed:
| file | site | why |
|---|---|---|
| `background.rs` | `apply_event` | the read-side twin of this bug — remote deletes, trashes and renames only ever reached the primary mount's tree |
| `sweep.rs` | conflict-copy `forget_or_unlink` | a conflict copy inside a sync folder lives in that fork's inode space |
| `sweep.rs` | `is_busy` | answered "idle" for every file open on a fork, letting the sweep delete one with a writer attached |
| `sharing.rs` | `rel_path_for_uid` | a share of a node in a sync folder could not be named |
| `drain.rs` | `node_place` | fell through to a DB lookup for fork-resident nodes |
| `lib.rs` | `queue_trash`, `rename`, `remove_replaced` (×2), `drop_local`, `restore` | uid-keyed `forget`/invalidate |
Deliberately left on `self.state`: `upload.rs` (`parent_ino`/`pino` are inode
numbers), the size-upgrade path in `lib.rs`, and `drain.rs`'s `root_uid`, which
is correctly primary-only. The 38 `filesystem.rs` sites are FUSE callbacks —
they already run on the right mount's session thread. `parent_is_gone` never
touches state at all; an earlier note here saying it did was wrong.
The registry is now a `StateRegistry` type with a unit test
(`state_registry_walks_every_live_mount_and_reaps_the_rest`) covering the case
this bug was: a fork that fails to appear in the walk, or one that lingers after
unmount.
**One regression came out of the audit, and is fixed.** Broadcasting
`apply_event`'s parent-listing invalidation meant forks started receiving
invalidations they had never received — and it invalidated a folder once per
event, while a burst of our own uploads produces one event per file all naming
the *same* folder. Every following lookup in that folder then re-enumerated it
from the API: quadratic in the folder's size.
Two mitigations, both in `background.rs`. A batch is collapsed to one
invalidation per folder — `apply_event` records the parent in `DirtyParents` and
`flush_dirty_parents` publishes it once after the batch loop (every `break` path
falls through to it). And a change the daemon made *itself* is recognised as its
own echo rather than treated as foreign: the drain records it
(`Core::note_self_change`, consumed once within `SELF_CHANGE_TTL_MS`), which
stops the feed's report of our own upload from evicting the content blob we just
sent. Neither weakens the result — the state after a batch is what it always was.
**The hang that showed up alongside this was a different bug, and this entry
originally misattributed it.** `readdir stability` at 133.94 s and a concurrency
case that blew past its timeout were blamed here on the invalidation storm. They
were not: the mount was freezing because network work runs on fuser's single
dispatch thread, which is B75. Fixing that took the concurrency case from two
timeouts in three runs to 16.9 / 24.1 / 29.1 s and `readdir stability` to 62.9 s.
The coalescing above is still worth having — it is a real amplification — but it
was never the stall.
**Note on B70:** B70's own fix was never implicated. The `.crdownload` park/un-park
behaves correctly in isolation; the empty read arrived with the rename, on the
same path a plain temp file takes. B70's live validation was blocked on this and
is now unblocked — its acceptance case passes.
## B75 — Network work on fuser's single dispatch thread freezes the whole mount
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 while
chasing what was thought to be a B74 regression.
**Impact:** any burst of concurrent writes froze the entire mount — not the
files being written, the *mount*. `ls` on an unrelated directory of it timed out
for minutes. The daemon itself stayed healthy throughout: the primary mount and
the control socket answered normally while a sync-folder mount was unreachable.
**Root cause.** `mount.rs` and `devices.rs` both build their session from
`Config::default()`, which leaves `n_threads` unset, and fuser 0.17 defaults it
to 1 (`session.rs:254`). One dispatch thread per mount reads a request, runs the
handler to completion, and only then reads the next. Six handlers were handed to
the [`Workers`] pool — `lookup`, `getattr`, `readdir`, `read`, `getxattr` and the
slow `serve_lookup` — and everything else ran inline, including handlers that
block on the API:
| handler | blocking work |
|---|---|
| `create` | `upload_file` to mint the node, then `fetch_node` — two round trips |
| `mkdir` | `create_folder`, then `fetch_node` |
| `rename` | `rename_node`, then `move_node`, plus a trash on the replaced-destination path |
| `unlink` / `rmdir` | `trash_nodes`; `rmdir` enumerates the folder first |
| `setattr` (path truncate) | `queue_truncate` → `fill_gaps`, which reads the kept prefix from the remote |
So eight applications each creating a file served one create at a time, and
every `read`, `open` and `lookup` on that mount queued behind them.
**Measured**, acceptance suite's `independent and shared-file concurrency` (8
threads × 16 files), and a 3-second `ls` of the mount root sampled every 4 s
throughout:
| | before | after |
|---|---|---|
| concurrency case | 200 s watchdog ×2, 37 s ×1 | 16.9 / 24.1 / 29.1 s, all pass |
| `ls` of the mount during the run | 33 of 55 probes timed out | 0 of 85 |
| `readdir stability` | 148.8 s | 62.9 s |
| `namespace operations` | 31.3 s | 15.7 s |
**Fix:** each of those handlers now parses its arguments inline — cheap, and it
keeps the error paths prompt — and hands the body to the worker pool as a
`serve_*` method, the pattern `lookup`/`readdir` already used. Metadata work
takes `Lane::Meta`; the truncate path takes `Lane::Transfer` because it can pull
a whole file.
**`release` was deliberately left inline, and this is load-bearing.** Moving it
too made all three concurrency runs fail with `concurrent file 1 mismatch`. The
kernel does not wait for a `release` reply before letting `close(2)` return, so
the only thing sequencing the staging of the written bytes against the `open` +
`read` an application issues immediately afterwards is that both are served by
the same dispatch loop. From a worker, the read overtakes the staging and is
answered from the remote's older revision. Taking `release` off the loop — worth
doing, since `queue_revision` gap-fills a partial write from the network — needs
a per-node "staging in flight" barrier that reads wait on, not the dispatch
loop's accidental ordering.
**Not fixed here.** `n_threads` is still 1; with the blocking handlers moved off,
the loop is short enough that raising it is a separate question. Related findings
from the same audit, still open: cache/reader validity is keyed on
`(mtime, size)` while the revision identity is `active_revision_id`
(`cache.rs:84` — two same-size revisions within one mtime second collide, which
is what a SQLite page rewrite looks like); `local_finish_scan` holds the daemon's
one SQLite connection across a full FTS5 trigram rebuild (`db/local.rs:79`);
`earliest_due_at` has no `<= now` filter where `next_due_op` does, so the drain
busy-spins when offline with a due op (`db/ops.rs:385` vs `:421`); and
`drain_local_node` deletes its op before the fallible `adopt_real_uid`
(`drain.rs:346`).
+95 -3
View File
@@ -14,9 +14,12 @@ cargo clippy --workspace --all-targets -- -D warnings
The account-free suite exercises the filesystem syscall contract against a
temporary local directory. This validates the runner and covers creation/open
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
`sendfile`, durability barriers, namespace/error semantics, open-file lifetime,
names and enumeration, and concurrent I/O. It never reads application state or
contacts Proton:
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
under concurrent mutation, metadata updates, extended attributes, allocation and
punched holes, refusal semantics for unimplemented operations, concurrent I/O,
and real application workloads. It never reads application state or contacts
Proton:
```console
scripts/fuse-acceptance.sh --offline-only
@@ -34,6 +37,85 @@ Use a dedicated test account without irreplaceable data:
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
```
### The mount is diffed against an ordinary filesystem
The local reference run is not only a self-test of the runner. Every case
records the facts it established, and a live run is compared against those
recordings, so a case fails when the mount *differs* from an ordinary
filesystem — not only when it raises. The report names the exact key:
```
[DIVERGENCE] live FUSE /mnt/testmount does not match the reference filesystem:
! creation and open flags.truncated.size: live=6 reference=0
```
Facts are recorded in one of two ways, chosen in the test body:
- **compared** — a claim about filesystem semantics. The mount must agree.
- **noted** — context that legitimately differs: inode numbers, block counts,
reported mode bits, and which optional syscalls exist at all. Reported under
`[capabilities]`, never a failure.
Because the choice is made at each call site, the set of accepted divergences
is visible in the test that accepts it. There is no separate allowlist to drift
out of date. When adding a case, prefer `record`; reach for `note` only when an
ordinary filesystem can do something a network filesystem genuinely cannot.
### Operations the filesystem does not implement
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`,
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite
asserts that each either works or is *refused cleanly* — a recognised errno,
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
one that is dirty breaks software that never asked for the feature.
### Regressions from the bug ledger
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
renamed after close must keep its content) need a real mount and a reachable
daemon; they are skipped, not failed, without one.
B74 is currently **failing** — it is a live data-loss defect, not a regression
guard, and it was found by exactly this mechanism: the B70 case failed, and
narrowing it produced a smaller reproduction that has its own case now. Expect a
red run against a real mount until it is fixed.
A regression case asserts against the *remote* outcome, so it must wait for the
queue to drain before reading. Reading straight back through the mount proves
nothing: the local attribute and content caches will happily return the bytes
that were just written even when nothing reached Drive, which is precisely how
B74 stayed invisible.
### Timeouts, reports, and hung mounts
A wedged FUSE operation cannot be interrupted from Python, so each case runs
under a soft alarm (`--timeout`, default 180s) that reports the case and lets
cleanup run, backed by a hard stop that dumps every thread's stack and exits.
That backstop is the only way to learn *where* a mount hung. A timeout ends the
run: later cases against a wedged mount produce noise, not information.
```console
scripts/fuse-acceptance.sh --live /mnt/testmount \
--timeout 300 --report-junit results.xml --budget 30
```
`--report-json` also writes every recorded observation, which is what to attach
to a bug report. `--budget SECONDS` flags cases that pass but got slower —
B5 was a 186 ms-per-call regression that a pass/fail suite could not see.
`--list` prints every case and which targets it runs against, and `--fail-fast`
stops at the first failure.
`--journal-check` fails the run if `proton-drive.service` logged any error while
it ran. A suite can pass every assertion while the daemon logs failures behind
it; that has happened, and it was caught only by reading the journal by hand.
Abandoned `pdfs-acceptance-*` roots from a crashed run are removed at startup,
but only if they match the exact generated name and are over an hour old
(`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never disturbed.
### Automated mode matrix
The managed live runner accepts two existing, empty, unmounted directories. It
@@ -53,6 +135,16 @@ in `pdfs sync list`. Use `PDFS_ACCEPTANCE_SYNC_TIMEOUT` to extend transition
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
example `target/debug/pdfs`.
### Durability drill
`--durability` restarts `proton-drive.service` in the middle of the suite and
re-verifies the bytes written before it. `staging/` and `recovery/` can hold the
only copy of a file whose upload has not finished, so this is the case that
proves the queue is persistent rather than in-memory. It is opt-in because it
interrupts a running daemon; `PDFS_ACCEPTANCE_UNIT` selects a different unit.
### Selecting cases
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
release acceptance run must leave it unset so the complete contract executes.
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=1.0.0
pkgver=1.2.0
pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64')
+1438 -65
View File
File diff suppressed because it is too large. Load diff
+51 -16
View File
@@ -4,44 +4,79 @@ set -Eeuo pipefail
usage() {
cat >&2 <<'EOF'
usage: scripts/fuse-acceptance.sh [--offline-only]
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...]
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR
usage: scripts/fuse-acceptance.sh [--offline-only] [OPTION ...]
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...] [OPTION ...]
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR [OPTION ...]
scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility
The local reference suite always runs first and requires no account. --live then
runs the same contract on each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only
when all live mountpoints show the same remote folder.
The local reference suite always runs first and requires no account; the mount
is then diffed against its recorded behaviour. --live runs the same contract on
each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only when all live mountpoints
show the same remote folder.
Options are forwarded to the Python runner. The useful ones:
--list print every case and the targets it runs against
--timeout SECONDS per-case limit (default 180); a hung case dumps stacks
--fail-fast stop at the first failure instead of continuing
--report-json PATH machine-readable results and recorded observations
--report-junit PATH JUnit XML for CI
--budget SECONDS report any case slower than this
--journal-check fail if the daemon logs errors during the run
--durability restart the daemon mid-suite and re-verify bytes
EOF
exit 2
}
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
runner=(python3 -u "$script_dir/fuse-acceptance.py")
mountpoints=()
passthrough=()
# Paths come first and options after them, so the first dash-prefixed argument
# ends the path list and everything from there is forwarded untouched.
collect_paths() {
while (( $# > 0 )) && [[ "$1" != -* ]]; do
mountpoints+=("$1")
shift
done
passthrough=("$@")
}
case "${1:-}" in
-h|--help) usage ;;
--offline-only|"")
(( $# <= 1 )) || usage
exec python3 -u "$script_dir/fuse-acceptance.py"
shift || true
exec "${runner[@]}" "$@"
;;
--live)
shift
collect_paths "$@"
(( ${#mountpoints[@]} > 0 )) || usage
;;
--live) shift ; (( $# > 0 )) || usage ;;
--managed-live)
shift
(( $# == 2 )) || usage
exec python3 -u "$script_dir/fuse-acceptance.py" --managed-live "$@"
(( $# >= 2 )) || usage
first="$1"; second="$2"; shift 2
exec "${runner[@]}" --managed-live "$first" "$second" "$@"
;;
--*)
# Bare options with no mode: an offline run with those options.
exec "${runner[@]}" "$@"
;;
*)
collect_paths "$@"
;;
--*) usage ;;
esac
command -v findmnt >/dev/null || { echo "findmnt is required for --live" >&2; exit 2; }
for mountpoint in "$@"; do
for mountpoint in "${mountpoints[@]}"; do
[[ -d "$mountpoint" ]] || { echo "not a directory: $mountpoint" >&2; exit 2; }
done
fstype="$(findmnt -T "$1" -n -o FSTYPE | head -n 1)"
fstype="$(findmnt -T "${mountpoints[0]}" -n -o FSTYPE | head -n 1)"
[[ "$fstype" == fuse* ]] || {
echo "refusing primary non-FUSE path $1 (type: ${fstype:-unknown})" >&2
echo "refusing primary non-FUSE path ${mountpoints[0]} (type: ${fstype:-unknown})" >&2
exit 2
}
exec python3 -u "$script_dir/fuse-acceptance.py" --live "$@"
exec "${runner[@]}" --live "${mountpoints[@]}" "${passthrough[@]}"