fix(fuse): stop a sync pass when nearly all of a large folder vanishes

The wipe guards refused a pass only when every synced path was missing
on one side. One surviving file cleared them, so a half-mounted disk,
a restored backup that brought back one file, or a script that emptied
the folder and wrote one back made the next pass trash everything else
on Drive, and on every computer syncing the folder (B270).

Both guards now also refuse when nine in ten or more of the tracked
paths of a folder with at least 20 are missing at once. A smaller
folder still stops only on a total loss, since an ordinary cleanup can
delete most of it. The reason now gives the count.

B270 stays partly fixed: a smaller loss still goes through, and the
app does not yet offer to confirm the deletions.
This commit is contained in:
nvrl committed 2026-11-01 16:06:08 +01:00
1 parent 506a610273
commit 129ad1a50d
5 files changed
+102 -23

No files matched your search

+2 -2
View File
@@ -2867,7 +2867,7 @@ mod tests {
/// from being read as "the user deleted everything" and trashing the folder /// from being read as "the user deleted everything" and trashing the folder
/// on Drive. /// on Drive.
#[test] #[test]
fn guard_local_wipe_blocks_only_a_total_disappearance() { fn guard_local_wipe_blocks_a_total_disappearance() {
let base: HashMap<String, ()> = ["a.txt", "b.txt", "d/c.txt"] let base: HashMap<String, ()> = ["a.txt", "b.txt", "d/c.txt"]
.iter() .iter()
.map(|r| ((*r).to_string(), ())) .map(|r| ((*r).to_string(), ()))
@@ -2881,7 +2881,7 @@ mod tests {
); );
assert!( assert!(
guard_local_wipe(&base, &one).is_ok(), guard_local_wipe(&base, &one).is_ok(),
"one survivor means the tree is readable; the rest are real deletions" "in a small folder, one survivor means the tree is readable; the rest are real deletions"
); );
// A one-entry folder can disappear because its mount or parent became // A one-entry folder can disappear because its mount or parent became
// unavailable just as easily as a larger tree can. // unavailable just as easily as a larger tree can.
+73 -14
View File
@@ -180,18 +180,42 @@ pub(super) fn local_deletions<B, L>(
missing missing
} }
/// Refuse to run a pass whose local side has vanished in its entirety. /// How many tracked paths a folder needs before a mass disappearance, short of
/// a total one, trips a wipe guard. Below this, a few survivors say little.
const MASS_LOSS_MIN_PATHS: usize = 20;
/// The share of a large folder's tracked paths that may vanish in one pass, in
/// tenths, before a wipe guard stops it: 9 of 10 and up is refused.
const MASS_LOSS_TENTHS: usize = 9;
/// Whether `missing` of `total` tracked paths gone at once looks like a lost
/// tree rather than deletions. All of them always does. Most of a large
/// folder does too: one surviving file used to clear the guard, so a
/// half-mounted disk, or a script that emptied the folder and wrote one file
/// back, trashed everything else on Drive (B270).
fn looks_like_a_wipe(missing: usize, total: usize) -> bool {
total > 0
&& (missing == total
|| (total >= MASS_LOSS_MIN_PATHS && missing * 10 >= total * MASS_LOSS_TENTHS))
}
/// Refuse to run a pass whose local side has vanished in its entirety, or
/// nearly so ([`looks_like_a_wipe`]).
/// ///
/// When every baseline path is absent locally, the likely cause is an unavailable /// When the synced paths are all or nearly all absent locally, the likely
/// mount or unreadable folder rather than a deliberate whole-tree deletion. /// cause is an unavailable mount or unreadable folder rather than a deliberate
/// One surviving path is enough to clear the guard. /// deletion of the whole tree.
pub(super) fn guard_local_wipe<B, L>( pub(super) fn guard_local_wipe<B, L>(
baseline: &HashMap<String, B>, baseline: &HashMap<String, B>,
local: &HashMap<String, L>, local: &HashMap<String, L>,
) -> Result<(), String> { ) -> Result<(), String> {
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) { let missing = baseline
.keys()
.filter(|rel| !local.contains_key(*rel))
.count();
if looks_like_a_wipe(missing, baseline.len()) {
return Err(format!( return Err(format!(
"every one of the {} synced paths is missing locally; refusing to trash \ "{missing} of the {} synced paths are missing locally; refusing to trash \
them on Drive. Check that the folder is mounted and readable; to download \ them on Drive. Check that the folder is mounted and readable; to download \
them again, sync the folder from scratch.", them again, sync the folder from scratch.",
baseline.len() baseline.len()
@@ -200,20 +224,24 @@ pub(super) fn guard_local_wipe<B, L>(
Ok(()) Ok(())
} }
/// Refuse to run a pass whose remote side has vanished in its entirety: the /// Refuse to run a pass whose remote side has vanished in its entirety, or
/// remote twin of [`guard_local_wipe`]. /// nearly so: the remote twin of [`guard_local_wipe`].
/// ///
/// An empty or truncated remote walk reads to the planner as "Drive deleted /// An empty or truncated remote walk reads to the planner as "Drive deleted
/// everything", which a pass answers by deleting the local copies. When not /// everything", which a pass answers by deleting the local copies. When all or
/// one synced path is left on Drive, a failed listing is far likelier than a /// nearly all synced paths are gone from Drive at once, a failed listing is far
/// deliberate wipe of the whole folder, so the pass stops instead. /// likelier than a deliberate wipe of the folder, so the pass stops instead.
pub(super) fn guard_remote_wipe<B, R>( pub(super) fn guard_remote_wipe<B, R>(
baseline: &HashMap<String, B>, baseline: &HashMap<String, B>,
remote: &HashMap<String, R>, remote: &HashMap<String, R>,
) -> Result<(), String> { ) -> Result<(), String> {
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) { let missing = baseline
.keys()
.filter(|rel| !remote.contains_key(*rel))
.count();
if looks_like_a_wipe(missing, baseline.len()) {
return Err(format!( return Err(format!(
"every one of the {} synced paths is missing on Proton Drive; refusing to \ "{missing} of the {} synced paths are missing on Proton Drive; refusing to \
delete them here. If they were deleted on purpose, remove the folder from sync; \ delete them here. If they were deleted on purpose, remove the folder from sync; \
to upload them again, sync the folder from scratch.", to upload them again, sync the folder from scratch.",
baseline.len() baseline.len()
@@ -816,7 +844,7 @@ mod file_plan_tests {
/// An empty or failed remote listing must not read as "Drive deleted /// An empty or failed remote listing must not read as "Drive deleted
/// everything" and delete the local copies. /// everything" and delete the local copies.
#[test] #[test]
fn the_remote_wipe_guard_blocks_only_a_total_disappearance() { fn the_remote_wipe_guard_blocks_a_total_disappearance() {
let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]); let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]);
let none: HashMap<String, RemoteItem> = HashMap::new(); let none: HashMap<String, RemoteItem> = HashMap::new();
assert!(guard_remote_wipe(&base, &none).is_err()); assert!(guard_remote_wipe(&base, &none).is_err());
@@ -826,6 +854,37 @@ mod file_plan_tests {
assert!(guard_remote_wipe(&empty, &none).is_ok()); assert!(guard_remote_wipe(&empty, &none).is_ok());
} }
/// One path left of a large folder no longer clears either guard: most
/// of it gone at once is a lost tree, not a cleanup (B270).
#[test]
fn one_survivor_of_a_large_folder_does_not_clear_the_wipe_guards() {
let base: HashMap<String, ()> = (0..40).map(|n| (format!("f{n}.txt"), ())).collect();
let survivors = |n: usize| -> HashMap<String, ()> {
(0..n).map(|n| (format!("f{n}.txt"), ())).collect()
};
assert!(guard_local_wipe(&base, &survivors(1)).is_err());
assert!(guard_remote_wipe(&base, &survivors(1)).is_err());
// 36 of 40 gone is nine tenths: still refused.
assert!(guard_local_wipe(&base, &survivors(4)).is_err());
// 35 of 40 gone is under nine tenths: a pass may delete that much.
assert!(guard_local_wipe(&base, &survivors(5)).is_ok());
assert!(guard_remote_wipe(&base, &survivors(5)).is_ok());
let reason = guard_local_wipe(&base, &survivors(1)).unwrap_err();
assert!(reason.starts_with("39 of the 40 synced paths"), "{reason}");
}
/// A small folder still loses most of its files to an ordinary cleanup, so
/// only a total loss stops it.
#[test]
fn a_small_folder_trips_the_wipe_guards_only_when_everything_is_gone() {
let base: HashMap<String, ()> = (0..19).map(|n| (format!("f{n}.txt"), ())).collect();
let one: HashMap<String, ()> = [("f0.txt".to_string(), ())].into_iter().collect();
assert!(guard_local_wipe(&base, &one).is_ok());
assert!(guard_remote_wipe(&base, &one).is_ok());
let none: HashMap<String, ()> = HashMap::new();
assert!(guard_local_wipe(&base, &none).is_err());
}
/// A node that failed to decrypt has no name, so everything a short /// A node that failed to decrypt has no name, so everything a short
/// listing did not return is left out, and nothing else. /// listing did not return is left out, and nothing else.
#[test] #[test]
+16 -4
View File
@@ -70,7 +70,6 @@ original wording.
|---|---|---| |---|---|---|
| [B275](#b275--on-a-slow-link-a-files-last-write-can-land-only-in-a-conflict-copy) | On a slow link, a file's last write can land only in a conflict copy | Cause not found; seen once in CI | | [B275](#b275--on-a-slow-link-a-files-last-write-can-land-only-in-a-conflict-copy) | On a slow link, a file's last write can land only in a conflict copy | Cause not found; seen once in CI |
| [B273](#b273--a-restore-proposes-another-computers-paths-when-the-two-share-a-hostname) | A restore proposes another computer's paths when the two share a hostname | Not fixed | | [B273](#b273--a-restore-proposes-another-computers-paths-when-the-two-share-a-hostname) | A restore proposes another computer's paths when the two share a hostname | Not fixed |
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | Not fixed |
| [B267](#b267--a-key-failure-at-a-start-from-a-warm-cache-shows-as-offline-for-good) | A key failure at a start from a warm cache shows as offline for good | Not fixed | | [B267](#b267--a-key-failure-at-a-start-from-a-warm-cache-shows-as-offline-for-good) | A key failure at a start from a warm cache shows as offline for good | Not fixed |
| [B134](#b134--a-file-deleted-online-while-the-drain-checks-its-write-is-kept-as-a-conflict-copy) | A file deleted online while the drain checks its write is kept as a conflict copy | Only with `local_first` off | | [B134](#b134--a-file-deleted-online-while-the-drain-checks-its-write-is-kept-as-a-conflict-copy) | A file deleted online while the drain checks its write is kept as a conflict copy | Only with `local_first` off |
| [B91](#b91--the-daemon-hung-overnight-and-nothing-could-say-what-it-was-waiting-on) | The daemon hung overnight, and nothing could say what it was waiting on | The cause is not diagnosed; the daemon now reports where it is stuck and is restarted | | [B91](#b91--the-daemon-hung-overnight-and-nothing-could-say-what-it-was-waiting-on) | The daemon hung overnight, and nothing could say what it was waiting on | The cause is not diagnosed; the daemon now reports where it is stuck and is restarted |
@@ -84,6 +83,7 @@ original wording.
| Entry | Title | What is left | | Entry | Title | What is left |
|---|---|---| |---|---|---|
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | A loss under nine in ten still goes through; no choice offered in the app |
| [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | The app shows the result only in the activity feed; no offer to replace or reset the folder in the way | | [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | The app shows the result only in the activity feed; no offer to replace or reset the folder in the way |
| [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | The sync engine's state is not checked | | [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | The sync engine's state is not checked |
| [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Errors no retry fixes back off like network errors | | [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Errors no retry fixes back off like network errors |
@@ -600,7 +600,7 @@ nothing left to apply. The progress count inside one large folder is unchanged.
### B270 — One file left in a mirror folder clears the wipe guard for all the others ### B270 — One file left in a mirror folder clears the wipe guard for all the others
**Status:** Open **Status:** Partly fixed
**Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder **Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder
several times. several times.
**Where:** `guard_local_wipe` and `guard_remote_wipe` in `crates/pdfs-fuse/src/sync/planner.rs` **Where:** `guard_local_wipe` and `guard_remote_wipe` in `crates/pdfs-fuse/src/sync/planner.rs`
@@ -614,8 +614,20 @@ that two computers sync, the other computer then deletes its copies too.
path is enough to clear it, so it guards against an empty folder and nothing short of that. path is enough to clear it, so it guards against an empty folder and nothing short of that.
The remote guard has the same shape. The remote guard has the same shape.
**Fix.** Not done. Also refuse when most of a large baseline is missing at once, and leave the **Fix.** Both guards also refuse a pass when nine in ten or more of the tracked paths of a
choice to the user: delete on the other side, or sync from scratch. folder with at least 20 are missing at once (`looks_like_a_wipe`). The folder goes into error with
the count in its reason, `pdfs diagnose` reports it, and **Sync From Scratch…** brings the files
back. A smaller folder still stops only on a total loss, since an ordinary cleanup can delete most
of it.
**What is left.** A loss under nine in ten, such as a disk that is half there, still goes
through. The app does not offer the choice yet: confirm the deletions on the other side, or sync
from scratch.
**Test:** `one_survivor_of_a_large_folder_does_not_clear_the_wipe_guards`,
`a_small_folder_trips_the_wipe_guards_only_when_everything_is_gone` in
`crates/pdfs-fuse/src/sync/planner.rs`.
--- ---
+7
View File
@@ -20,6 +20,13 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
files in it. **After upgrading:** if your `~/ProtonDrive` has files underneath, for example files in it. **After upgrading:** if your `~/ProtonDrive` has files underneath, for example
saved there while the service was stopped, My files does not open until you move them out. saved there while the service was stopped, My files does not open until you move them out.
- **A synced folder stops instead of deleting when nearly all of it vanishes at once** (B270).
The guard against a lost folder, such as a disk that is not mounted, stopped a pass only when
every synced file was gone. One file left over was enough to clear it, and the next pass
trashed everything else on Proton Drive, and on every other computer syncing the folder. A
folder with 20 or more files and folders now also stops when nine in ten of them are missing,
on this computer or on Proton Drive. **Sync From Scratch…** brings them back.
- **A new file reads without the network as soon as `pdfs status` shows nothing queued** - **A new file reads without the network as soon as `pdfs status` shows nothing queued**
(B257). A new file's upload counted as done before its bytes were cached, so a script that (B257). A new file's upload counted as done before its bytes were cached, so a script that
waited for an empty queue and then went offline could not read it. A new file deleted while waited for an empty queue and then went offline could not read it. A new file deleted while
+4 -3
View File
@@ -248,9 +248,10 @@ Check that:
### A synced folder stays in error and nothing downloads ### A synced folder stays in error and nothing downloads
The log says `every one of the N synced paths is missing locally; refusing to trash them on The log says `M of the N synced paths are missing locally; refusing to trash them on Drive`.
Drive`. The folder synced before, and now none of its files are on this computer. The sync The folder synced before, and now all or nearly all of its files are gone from this computer:
stops so that it does not delete them on Proton Drive too. Check that the folder is the right everything, or nine in ten of a folder with 20 or more files and folders. The sync stops so that
it does not delete them on Proton Drive too. Check that the folder is the right
one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the
folder's menu on **Sync → Folders**, or run: folder's menu on **Sync → Folders**, or run: