diff --git a/app/src/main/rust/stores/src/amazon/install/mod.rs b/app/src/main/rust/stores/src/amazon/install/mod.rs index 113814145..ff35d45e5 100644 --- a/app/src/main/rust/stores/src/amazon/install/mod.rs +++ b/app/src/main/rust/stores/src/amazon/install/mod.rs @@ -248,8 +248,10 @@ fn cleanup_obsolete( old: &[GameFile], new: &[GameFile], cancel: &AtomicBool, + gate: &ReplaceGate, + ev: &dyn Events, log: &dyn Fn(&str), -) -> bool { +) -> Result { let key = |path: &str| -> Option { 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 { @@ -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) diff --git a/app/src/main/rust/stores/src/amazon/install/tests.rs b/app/src/main/rust/stores/src/amazon/install/tests.rs index 2e40ce79b..e4a0d2419 100644 --- a/app/src/main/rust/stores/src/amazon/install/tests.rs +++ b/app/src/main/rust/stores/src/amazon/install/tests.rs @@ -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(); diff --git a/app/src/main/rust/stores/src/epic/install/cache.rs b/app/src/main/rust/stores/src/epic/install/cache.rs index 4ca950d38..1c890010a 100644 --- a/app/src/main/rust/stores/src/epic/install/cache.rs +++ b/app/src/main/rust/stores/src/epic/install/cache.rs @@ -134,6 +134,7 @@ pub fn prepare( struct Rebuilt { bytes: Vec, covered: Vec, + uncovered: usize, valid: bool, } @@ -147,6 +148,25 @@ pub fn seed_from_verified_installed( budget: u64, cancel: &AtomicBool, ) -> (Vec, 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( + dir: &Path, + game_dir: &Path, + installed: &Manifest, + target: &Manifest, + needed: &HashSet, + cached: &[u32], + budget: u64, + cancel: &AtomicBool, + mut check: F, +) -> (Vec, 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 = 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); + } } diff --git a/app/src/main/rust/stores/src/epic/install/mod.rs b/app/src/main/rust/stores/src/epic/install/mod.rs index 56633d488..86a05cd76 100644 --- a/app/src/main/rust/stores/src/epic/install/mod.rs +++ b/app/src/main/rust/stores/src/epic/install/mod.rs @@ -246,8 +246,9 @@ fn cleanup_obsolete( new: &[FileEntry], new_tags: &[String], cancel: &AtomicBool, + ev: &dyn Events, log: &dyn Fn(&str), -) -> bool { +) -> Result { let old_selected = locale::select(old, Some(old_tags)); let new_selected = locale::select(new, Some(new_tags)); let key = |path: &str| -> Option { @@ -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> { @@ -430,8 +438,9 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result