mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Merge pull request #72 from narrrl/fix/wipe-guard-majority
fix(fuse): stop a sync pass when nearly all of a large folder vanishes
This commit is contained in:
5 files changed
+102
-23
No files matched your search
@@ -2867,7 +2867,7 @@ mod tests {
|
||||
/// from being read as "the user deleted everything" and trashing the folder
|
||||
/// on Drive.
|
||||
#[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"]
|
||||
.iter()
|
||||
.map(|r| ((*r).to_string(), ()))
|
||||
@@ -2881,7 +2881,7 @@ mod tests {
|
||||
);
|
||||
assert!(
|
||||
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
|
||||
// unavailable just as easily as a larger tree can.
|
||||
|
||||
@@ -180,18 +180,42 @@ pub(super) fn local_deletions<B, L>(
|
||||
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
|
||||
/// mount or unreadable folder rather than a deliberate whole-tree deletion.
|
||||
/// One surviving path is enough to clear the guard.
|
||||
/// When the synced paths are all or nearly all absent locally, the likely
|
||||
/// cause is an unavailable mount or unreadable folder rather than a deliberate
|
||||
/// deletion of the whole tree.
|
||||
pub(super) fn guard_local_wipe<B, L>(
|
||||
baseline: &HashMap<String, B>,
|
||||
local: &HashMap<String, L>,
|
||||
) -> 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!(
|
||||
"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 again, sync the folder from scratch.",
|
||||
baseline.len()
|
||||
@@ -200,20 +224,24 @@ pub(super) fn guard_local_wipe<B, L>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse to run a pass whose remote side has vanished in its entirety: the
|
||||
/// remote twin of [`guard_local_wipe`].
|
||||
/// Refuse to run a pass whose remote side has vanished in its entirety, or
|
||||
/// nearly so: the remote twin of [`guard_local_wipe`].
|
||||
///
|
||||
/// 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
|
||||
/// one synced path is left on Drive, a failed listing is far likelier than a
|
||||
/// deliberate wipe of the whole folder, so the pass stops instead.
|
||||
/// everything", which a pass answers by deleting the local copies. When all or
|
||||
/// nearly all synced paths are gone from Drive at once, a failed listing is far
|
||||
/// likelier than a deliberate wipe of the folder, so the pass stops instead.
|
||||
pub(super) fn guard_remote_wipe<B, R>(
|
||||
baseline: &HashMap<String, B>,
|
||||
remote: &HashMap<String, R>,
|
||||
) -> 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!(
|
||||
"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; \
|
||||
to upload them again, sync the folder from scratch.",
|
||||
baseline.len()
|
||||
@@ -816,7 +844,7 @@ mod file_plan_tests {
|
||||
/// An empty or failed remote listing must not read as "Drive deleted
|
||||
/// everything" and delete the local copies.
|
||||
#[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 none: HashMap<String, RemoteItem> = HashMap::new();
|
||||
assert!(guard_remote_wipe(&base, &none).is_err());
|
||||
@@ -826,6 +854,37 @@ mod file_plan_tests {
|
||||
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
|
||||
/// listing did not return is left out, and nothing else.
|
||||
#[test]
|
||||
|
||||
+16
-4
@@ -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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
@@ -84,6 +83,7 @@ original wording.
|
||||
|
||||
| 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 |
|
||||
| [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 |
|
||||
@@ -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
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Partly fixed
|
||||
**Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder
|
||||
several times.
|
||||
**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.
|
||||
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
|
||||
choice to the user: delete on the other side, or sync from scratch.
|
||||
**Fix.** Both guards also refuse a pass when nine in ten or more of the tracked paths of a
|
||||
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`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
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**
|
||||
(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
|
||||
|
||||
@@ -248,9 +248,10 @@ Check that:
|
||||
|
||||
### A synced folder stays in error and nothing downloads
|
||||
|
||||
The log says `every one of the N synced paths is missing locally; refusing to trash them on
|
||||
Drive`. The folder synced before, and now none of its files are on this computer. The sync
|
||||
stops so that it does not delete them on Proton Drive too. Check that the folder is the right
|
||||
The log says `M of the N synced paths are missing locally; refusing to trash them on Drive`.
|
||||
The folder synced before, and now all or nearly all of its files are gone from this computer:
|
||||
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
|
||||
folder's menu on **Sync → Folders**, or run:
|
||||
|
||||
|
||||
Reference in new issue
Block a user