mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
fix: gate native cleanup before deletion
This commit is contained in:
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();
|
||||
|
||||
Reference in new issue
Block a user