fix: gate native cleanup before deletion

This commit is contained in:
Kurt Himebauch committed 2026-10-28 18:12:57 -04:00
1 parent 48ad5506b6
commit 3df06f48b7
5 files changed
+251 -21

No files matched your search

@@ -248,8 +248,10 @@ fn cleanup_obsolete(
old: &[GameFile],
new: &[GameFile],
cancel: &AtomicBool,
gate: &ReplaceGate,
ev: &dyn Events,
log: &dyn Fn(&str),
) -> bool {
) -> Result<bool, Stop> {
let key = |path: &str| -> Option<String> {
let normalized = manifest::check_path(path).ok()?;
Some(normalized.to_lowercase())
@@ -265,23 +267,23 @@ fn cleanup_obsolete(
};
let Some(old_keys) = inventory(old) else {
log("obsolete Amazon files preserved: old inventory contains unsafe or case-colliding paths");
return cancel.load(Ordering::Relaxed);
return Ok(cancel.load(Ordering::Relaxed));
};
let Some(new_keys) = inventory(new) else {
log("obsolete Amazon files preserved: target inventory contains unsafe or case-colliding paths");
return cancel.load(Ordering::Relaxed);
return Ok(cancel.load(Ordering::Relaxed));
};
let _ = old_keys;
let Ok(root) = game_dir.canonicalize() else {
log("obsolete Amazon files preserved: install folder could not be resolved");
return cancel.load(Ordering::Relaxed);
return Ok(cancel.load(Ordering::Relaxed));
};
let mut removed = 0usize;
let mut preserved = 0usize;
for file in old {
if cancel.load(Ordering::Relaxed) {
log("obsolete Amazon cleanup cancelled; remaining files were preserved");
return true;
return Ok(true);
}
let Some(k) = key(&file.path) else { preserved += 1; continue };
if new_keys.contains(&k) { continue; }
@@ -308,10 +310,17 @@ fn cleanup_obsolete(
preserved += 1;
continue;
}
if !gate.allow(ev) {
return Err(Stop::Failed(REPLACE_REFUSED.into()));
}
if cancel.load(Ordering::Relaxed) {
log("obsolete Amazon cleanup cancelled; remaining files were preserved");
return Ok(true);
}
if fs::remove_file(&canonical).is_ok() { removed += 1; } else { preserved += 1; }
}
log(&format!("obsolete Amazon cleanup removed={removed} preserved={preserved}"));
cancel.load(Ordering::Relaxed)
Ok(cancel.load(Ordering::Relaxed))
}
pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finished, Stop> {
@@ -466,7 +475,7 @@ pub fn run_download(
let stats = result?;
let cleanup_cancelled = if let Some(installed) = installed_manifest.as_ref() {
cleanup_obsolete(&req.game_dir, &installed.files, &manifest.files, cancel, &log)
cleanup_obsolete(&req.game_dir, &installed.files, &manifest.files, cancel, &gate, ev, &log)?
} else {
ev.log("obsolete Amazon files preserved: no retained prior manifest is available");
cancel.load(Ordering::Relaxed)
@@ -350,7 +350,7 @@ fn obsolete_cleanup_removes_only_unchanged_prior_manifest_files() {
];
let new = vec![GameFile { path: "current.bin".into(), size: 0, algorithm: 0, digest: vec![] }];
let cancel = AtomicBool::new(false);
assert!(!cleanup_obsolete(&game, &old, &new, &cancel, &|_| {}));
assert!(!cleanup_obsolete(&game, &old, &new, &cancel, &ReplaceGate::new(), &Recorder::default(), &|_| {}).unwrap());
assert!(!game.join("old.bin").exists());
assert_eq!(fs::read(game.join("modified.bin")).unwrap(), b"user edit");
assert_eq!(fs::read(game.join("untracked.bin")).unwrap(), b"untracked");
@@ -365,11 +365,48 @@ fn obsolete_cleanup_cancellation_preserves_the_old_inventory() {
let old = vec![GameFile { path: "old.bin".into(), size: 3, algorithm: 0, digest: Sha256::digest(b"old").to_vec() }];
let new = vec![GameFile { path: "current.bin".into(), size: 0, algorithm: 0, digest: vec![] }];
let cancel = AtomicBool::new(true);
assert!(cleanup_obsolete(&game, &old, &new, &cancel, &|_| {}));
assert!(cleanup_obsolete(&game, &old, &new, &cancel, &ReplaceGate::new(), &Recorder::default(), &|_| {}).unwrap());
assert_eq!(fs::read(game.join("old.bin")).unwrap(), b"old");
let _ = fs::remove_dir_all(&game);
}
#[test]
fn refused_obsolete_cleanup_preserves_file_and_installed_manifest() {
let fx = fixture();
let game = temp_game("obsolete-refused");
fs::create_dir_all(&game).unwrap();
for (path, bytes) in &fx.expected {
let path = game.join(path.replace('\\', "/"));
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
let metadata = game.join(".droiddeck-maintenance/amazon");
fs::create_dir_all(&metadata).unwrap();
let old_digest = Sha256::digest(b"old").to_vec();
let old_manifest = frame(
&message(&[vec![Entry { path: "obsolete.bin", size: 3, algorithm: Some(0), digest: &old_digest }]]),
b"\x08\x01\x12\x00",
true,
);
fs::write(game.join("obsolete.bin"), b"old").unwrap();
fs::write(metadata.join("installed.manifest"), &old_manifest).unwrap();
let target_manifest = fx.server.files.lock().unwrap()["/v/abc/manifest.proto"].clone();
let target_path = metadata.join("target.manifest");
fs::write(&target_path, target_manifest).unwrap();
let mut req = request(&game);
req.target_manifest_path = Some(target_path);
req.target_version_id = Some("pinned".into());
let cancel = AtomicBool::new(false);
let ev = Recorder { refuse: true, ..Default::default() };
let result = run_download(&req, &download(&fx), &cancel, &ev);
assert_eq!(result.unwrap_err(), Stop::Failed(REPLACE_REFUSED.into()));
assert_eq!(ev.gate_calls.load(Ordering::SeqCst), 1);
assert_eq!(fs::read(game.join("obsolete.bin")).unwrap(), b"old");
assert_eq!(fs::read(metadata.join("installed.manifest")).unwrap(), old_manifest);
assert!(!game.join(COMPLETE_MARKER).exists());
let _ = fs::remove_dir_all(&game);
}
#[test]
fn unsupported_hash_does_not_trust_size_or_replace_existing_content() {
let fx = unsupported_hash_fixture();
@@ -134,6 +134,7 @@ pub fn prepare(
struct Rebuilt {
bytes: Vec<u8>,
covered: Vec<bool>,
uncovered: usize,
valid: bool,
}
@@ -147,6 +148,25 @@ pub fn seed_from_verified_installed(
budget: u64,
cancel: &AtomicBool,
) -> (Vec<u32>, u64) {
seed_from_verified_installed_with_check(
dir, game_dir, installed, target, needed, cached, budget, cancel, super::delta::check,
)
}
fn seed_from_verified_installed_with_check<F>(
dir: &Path,
game_dir: &Path,
installed: &Manifest,
target: &Manifest,
needed: &HashSet<u32>,
cached: &[u32],
budget: u64,
cancel: &AtomicBool,
mut check: F,
) -> (Vec<u32>, u64)
where
F: FnMut(&Path, u64, Option<[u8; 20]>, &AtomicBool) -> super::delta::Check,
{
const MAX_WINDOW: u32 = 64 * 1024 * 1024;
const MAX_REBUILD_MEMORY: u64 = 32 * 1024 * 1024;
let cached: HashSet<u32> = cached.iter().copied().collect();
@@ -157,10 +177,46 @@ pub fn seed_from_verified_installed(
if cancel.load(Ordering::Relaxed) {
break;
}
if !rebuilt.is_empty()
&& rebuilt.values().all(|chunk| !chunk.valid || chunk.uncovered == 0)
{
break;
}
let Some(expected) = file.sha1 else { continue };
if !super::safe_relative(&file.path) {
continue;
}
let useful = file.parts.iter().any(|part| {
if part.size == 0 {
return false;
}
let Some(&target_index) = target.chunk_index.get(&part.guid) else {
return false;
};
let target_index = target_index as u32;
if !needed.contains(&target_index) || cached.contains(&target_index) {
return false;
}
let Some(info) = target.chunks.get(target_index as usize) else {
return false;
};
if !info.window_exact
|| info.window > MAX_WINDOW
|| part.offset.saturating_add(part.size) > info.window
{
return false;
}
if let Some(chunk) = rebuilt.get(&target_index) {
let start = part.offset as usize;
let end = start + part.size as usize;
chunk.valid && chunk.covered[start..end].iter().any(|present| !present)
} else {
reserved.saturating_add(info.window as u64) <= budget
}
});
if !useful {
continue;
}
let path = game_dir.join(&file.path);
let mut safe = game_dir.to_path_buf();
let mut no_links = true;
@@ -183,7 +239,7 @@ pub fn seed_from_verified_installed(
if !meta.is_file() || meta.file_type().is_symlink() {
continue;
}
match super::delta::check(&path, file.size, Some(expected), cancel) {
match check(&path, file.size, Some(expected), cancel) {
super::delta::Check::Cancelled => break,
super::delta::Check::UpToDate => {}
super::delta::Check::Pending { .. } => continue,
@@ -223,6 +279,7 @@ pub fn seed_from_verified_installed(
Rebuilt {
bytes: vec![0; info.window as usize],
covered: vec![false; info.window as usize],
uncovered: info.window as usize,
valid: true,
},
);
@@ -250,6 +307,9 @@ pub fn seed_from_verified_installed(
chunk.valid = false;
break;
}
if !chunk.covered[at] {
chunk.uncovered -= 1;
}
chunk.bytes[at] = byte;
chunk.covered[at] = true;
}
@@ -263,7 +323,7 @@ pub fn seed_from_verified_installed(
if cancel.load(Ordering::Relaxed) {
break;
}
if !chunk.valid || chunk.covered.iter().any(|present| !present) {
if !chunk.valid || chunk.uncovered != 0 {
continue;
}
if target.chunks[index as usize]
@@ -389,4 +449,77 @@ mod tests {
assert!(!cache.join(guid_text(&info.guid)).exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn skips_verified_installed_files_that_cannot_seed_needed_chunks() {
let root = std::env::temp_dir().join(format!("dd-epic-cache-filter-{}", std::process::id()));
let game = root.join("game");
let cache = root.join("cache");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&game).unwrap();
let needed_chunk = chunk(51, 4);
let unrelated_chunk = chunk(52, 4);
let installed_bytes = build(&TestManifest {
version: 21,
build: "installed".into(),
exe: "Game.exe".into(),
prereq: None,
chunks: vec![needed_chunk.clone(), unrelated_chunk.clone()],
files: vec![
TestFile {
path: "source.bin".into(),
sha1: Sha1::digest(b"abcd").into(),
tags: Vec::new(),
parts: vec![PartRef { guid: needed_chunk.guid, offset: 0, size: 4 }],
},
TestFile {
path: "irrelevant.bin".into(),
sha1: Sha1::digest(b"wxyz").into(),
tags: Vec::new(),
parts: vec![PartRef { guid: unrelated_chunk.guid, offset: 0, size: 4 }],
},
],
compress: false,
meta_tail: Vec::new(),
});
let target_bytes = build(&TestManifest {
version: 21,
build: "target".into(),
exe: "Game.exe".into(),
prereq: None,
chunks: vec![needed_chunk.clone()],
files: vec![TestFile {
path: "source.bin".into(),
sha1: Sha1::digest(b"abcd").into(),
tags: Vec::new(),
parts: vec![PartRef { guid: needed_chunk.guid, offset: 0, size: 4 }],
}],
compress: false,
meta_tail: Vec::new(),
});
let installed = crate::epic::manifest::parse(&installed_bytes).unwrap();
let target = crate::epic::manifest::parse(&target_bytes).unwrap();
fs::write(game.join("source.bin"), b"abcd").unwrap();
fs::write(game.join("irrelevant.bin"), b"wxyz").unwrap();
let checked = std::cell::RefCell::new(Vec::new());
let cancel = AtomicBool::new(false);
let (seeded, written) = seed_from_verified_installed_with_check(
&cache,
&game,
&installed,
&target,
&HashSet::from([0]),
&[],
4,
&cancel,
|path, size, sha1, cancel| {
checked.borrow_mut().push(path.file_name().unwrap().to_owned());
super::super::delta::check(path, size, sha1, cancel)
},
);
assert_eq!(seeded, vec![0]);
assert_eq!(written, 4);
assert_eq!(*checked.borrow(), vec![std::ffi::OsString::from("source.bin")]);
let _ = fs::remove_dir_all(&root);
}
}
@@ -246,8 +246,9 @@ fn cleanup_obsolete(
new: &[FileEntry],
new_tags: &[String],
cancel: &AtomicBool,
ev: &dyn Events,
log: &dyn Fn(&str),
) -> bool {
) -> Result<bool, Stop> {
let old_selected = locale::select(old, Some(old_tags));
let new_selected = locale::select(new, Some(new_tags));
let key = |path: &str| -> Option<String> {
@@ -267,23 +268,23 @@ fn cleanup_obsolete(
};
let Some(old_keys) = inventory(old, &old_selected) else {
log("obsolete Epic files preserved: old inventory contains unsafe or case-colliding paths");
return cancel.load(Ordering::Relaxed);
return Ok(cancel.load(Ordering::Relaxed));
};
let Some(new_keys) = inventory(new, &new_selected) else {
log("obsolete Epic files preserved: target inventory contains unsafe or case-colliding paths");
return cancel.load(Ordering::Relaxed);
return Ok(cancel.load(Ordering::Relaxed));
};
let _ = old_keys;
let Ok(root) = game_dir.canonicalize() else {
log("obsolete Epic files preserved: install folder could not be resolved");
return cancel.load(Ordering::Relaxed);
return Ok(cancel.load(Ordering::Relaxed));
};
let mut removed = 0usize;
let mut preserved = 0usize;
for i in old_selected {
if cancel.load(Ordering::Relaxed) {
log("obsolete Epic cleanup cancelled; remaining files were preserved");
return true;
return Ok(true);
}
let file = &old[i];
let Some(k) = key(&file.path) else { preserved += 1; continue };
@@ -308,10 +309,17 @@ fn cleanup_obsolete(
preserved += 1;
continue;
}
if !ev.before_replace() {
return Err(Stop::Failed("The install record could not be switched to updating; nothing was replaced".into()));
}
if cancel.load(Ordering::Relaxed) {
log("obsolete Epic cleanup cancelled; remaining files were preserved");
return Ok(true);
}
if fs::remove_file(&canonical).is_ok() { removed += 1; } else { preserved += 1; }
}
log(&format!("obsolete Epic cleanup removed={removed} preserved={preserved}"));
cancel.load(Ordering::Relaxed)
Ok(cancel.load(Ordering::Relaxed))
}
fn resume_from(stage: &Path, parts: &[PlanPart], size: u64) -> Option<HashSet<u64>> {
@@ -430,8 +438,9 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish
&manifest.files,
&req.tags,
cancel,
ev,
&log,
)
)?
} else {
ev.log("obsolete Epic files preserved: no retained prior manifest is available");
cancel.load(Ordering::Relaxed)
@@ -635,8 +644,9 @@ cache_budget_mib={} overlap_mib={} free_game={free_game} workers={} process_work
&manifest.files,
&req.tags,
cancel,
ev,
&log,
)
)?
} else {
ev.log("obsolete Epic files preserved: no retained prior manifest is available");
cancel.load(Ordering::Relaxed)
@@ -334,7 +334,7 @@ fn obsolete_cleanup_removes_only_unchanged_prior_manifest_files() {
];
let new = vec![FileEntry { path: "current.bin".into(), sha1: None, tags: vec![], parts: vec![], size: 0 }];
let cancel = AtomicBool::new(false);
assert!(!cleanup_obsolete(&game, &old, &[], &new, &[], &cancel, &|_| {}));
assert!(!cleanup_obsolete(&game, &old, &[], &new, &[], &cancel, &Recorder::default(), &|_| {}).unwrap());
assert!(!game.join("old.bin").exists());
assert_eq!(fs::read(game.join("modified.bin")).unwrap(), b"user edit");
assert_eq!(fs::read(game.join("untracked.bin")).unwrap(), b"untracked");
@@ -349,11 +349,52 @@ fn obsolete_cleanup_cancellation_preserves_the_old_inventory() {
let old = vec![FileEntry { path: "old.bin".into(), sha1: Some(Sha1::digest(b"old").into()), tags: vec![], parts: vec![], size: 3 }];
let new = vec![FileEntry { path: "current.bin".into(), sha1: None, tags: vec![], parts: vec![], size: 0 }];
let cancel = AtomicBool::new(true);
assert!(cleanup_obsolete(&game, &old, &[], &new, &[], &cancel, &|_| {}));
assert!(cleanup_obsolete(&game, &old, &[], &new, &[], &cancel, &Recorder::default(), &|_| {}).unwrap());
assert_eq!(fs::read(game.join("old.bin")).unwrap(), b"old");
let _ = fs::remove_dir_all(&game);
}
#[test]
fn refused_obsolete_cleanup_preserves_file_and_installed_manifest() {
let fx = fixture();
let game = temp_game("obsolete-refused");
fs::create_dir_all(&game).unwrap();
let metadata = game.join(".droiddeck-maintenance/epic");
fs::create_dir_all(&metadata).unwrap();
let old_bytes = b"old";
let old_chunk = chunk(90, old_bytes.len() as u32);
let old_manifest = build(&TestManifest {
version: 21,
build: "old".into(),
exe: "Old.exe".into(),
prereq: None,
chunks: vec![old_chunk.clone()],
files: vec![TestFile {
path: "obsolete.bin".into(),
sha1: Sha1::digest(old_bytes).into(),
tags: Vec::new(),
parts: vec![PartRef { guid: old_chunk.guid, offset: 0, size: old_bytes.len() as u32 }],
}],
compress: true,
meta_tail: Vec::new(),
});
fs::write(game.join("obsolete.bin"), old_bytes).unwrap();
fs::write(metadata.join("installed.manifest"), &old_manifest).unwrap();
let target_manifest = br#"{"ManifestFileVersion":"20","ChunkHashList":{},"FileManifestList":[]}"#;
let target_path = metadata.join("target.manifest");
fs::write(&target_path, target_manifest).unwrap();
let mut req = request(&fx, &game);
req.target_manifest_path = Some(target_path);
let cancel = AtomicBool::new(false);
let ev = Recorder { refuse: true, ..Default::default() };
let result = run(&req, &cancel, &ev);
assert_eq!(result.unwrap_err(), Stop::Failed("The install record could not be switched to updating; nothing was replaced".into()));
assert_eq!(ev.replaced.load(Ordering::SeqCst), 1);
assert_eq!(fs::read(game.join("obsolete.bin")).unwrap(), old_bytes);
assert_eq!(fs::read(metadata.join("installed.manifest")).unwrap(), old_manifest);
let _ = fs::remove_dir_all(&game);
}
#[test]
fn a_cancelled_run_reports_cancel() {
let fx = fixture();