mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +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
|
/// 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.
|
||||||
|
|||||||
@@ -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
@@ -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`.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user