mod common;
use std::io::Cursor;
use std::ops::ControlFlow;
use std::sync::{Arc, Mutex};
use common::{
FileBlock, snapshot_tree, sqpk_add_data_body, sqpk_addfile_body, sqpk_delete_file_body,
sqpk_expand_data_body, sqpk_target_info_body, wrap_patch,
};
use zipatch_rs::apply::InMemoryFs;
use zipatch_rs::index::{
FilesystemOp, PartExpected, PartSource, PatchRef, PatchSourceKind, Plan, Region, Target,
TargetPath,
};
use zipatch_rs::test_utils::MemoryPatchSource;
use zipatch_rs::test_utils::make_chunk;
use zipatch_rs::{
ApplyConfig, ApplyError, ApplyObserver, ChunkEvent, IndexApplier, Platform, ZiPatchReader,
};
fn synth_patch() -> Vec<u8> {
wrap_patch(vec![
make_chunk(
b"SQPK",
&sqpk_addfile_body(
"out/raw.dat",
0,
&[FileBlock {
is_compressed: false,
decompressed: b"raw block bytes".to_vec(),
}],
),
),
make_chunk(
b"SQPK",
&sqpk_addfile_body(
"out/zipped.dat",
0,
&[FileBlock {
is_compressed: true,
decompressed: vec![0xCDu8; 4096],
}],
),
),
])
}
#[test]
fn in_memory_apply_leaves_host_untouched_but_records_writes() {
let patch = synth_patch();
let tmp = tempfile::tempdir().unwrap();
let before = snapshot_tree(tmp.path());
assert!(before.is_empty(), "pre-condition: tempdir must start empty");
let fs = InMemoryFs::new();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(fs.clone())
.into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect("in-memory apply of a clean patch must succeed");
let after = snapshot_tree(tmp.path());
assert!(
after.is_empty(),
"in-memory vfs must leave the install tree untouched; found {after:?}"
);
let mem = fs.snapshot_files();
assert_eq!(mem.len(), 2, "two AddFile chunks => two in-memory files");
}
#[test]
fn in_memory_apply_propagates_crc_mismatch() {
let mut patch = synth_patch();
let last_idx = patch.len() - 2;
patch[last_idx] ^= 0xFF;
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.into_session();
let err = {
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect_err("CRC-corrupted patch must fail under in-memory apply");
assert!(
matches!(
err,
ApplyError::Parse(zipatch_rs::ParseError::ChecksumMismatch { .. })
),
"expected ChecksumMismatch, got {err:?}",
);
}
#[test]
fn in_memory_and_std_apply_both_succeed_on_clean_patch() {
let patch = synth_patch();
let wet_tmp = tempfile::tempdir().unwrap();
let mut wet_ctx = ApplyConfig::new(wet_tmp.path()).into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
wet_ctx.apply_patch(reader)
}
.expect("std apply must succeed");
let dry_tmp = tempfile::tempdir().unwrap();
let fs = InMemoryFs::new();
let mut dry_ctx = ApplyConfig::new(dry_tmp.path())
.with_vfs(fs.clone())
.into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
dry_ctx.apply_patch(reader)
}
.expect("in-memory apply must succeed for the same patch");
assert!(
!snapshot_tree(wet_tmp.path()).is_empty(),
"std apply must have produced files (sanity check)"
);
assert!(
snapshot_tree(dry_tmp.path()).is_empty(),
"in-memory apply must produce no host-side files"
);
assert!(
!fs.snapshot_files().is_empty(),
"in-memory apply must record the writes in the vfs"
);
}
fn sqpk_removeall_body(expansion_id: u16) -> Vec<u8> {
let mut cmd = Vec::new();
cmd.push(b'R');
cmd.extend_from_slice(&[0u8; 2]);
cmd.extend_from_slice(&0u64.to_be_bytes());
cmd.extend_from_slice(&0u64.to_be_bytes());
cmd.extend_from_slice(&0u32.to_be_bytes());
cmd.extend_from_slice(&expansion_id.to_be_bytes());
cmd.extend_from_slice(&[0u8; 2]);
let mut body = Vec::new();
let inner_size = (5 + cmd.len()) as i32;
body.extend_from_slice(&inner_size.to_be_bytes());
body.push(b'F');
body.extend_from_slice(&cmd);
body
}
#[test]
fn in_memory_removeall_against_missing_install_is_silent_ok() {
let patch = wrap_patch(vec![make_chunk(b"SQPK", &sqpk_removeall_body(0))]);
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect("RemoveAll against a missing in-memory install must return Ok");
}
#[test]
fn in_memory_delete_file_missing_requires_ignore_missing() {
let patch = wrap_patch(vec![make_chunk(
b"SQPK",
&sqpk_delete_file_body("does/not/exist.bin"),
)]);
let tmp = tempfile::tempdir().unwrap();
let ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.with_ignore_missing(true);
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect("DeleteFile missing with ignore_missing must succeed");
let tmp = tempfile::tempdir().unwrap();
let ctx = ApplyConfig::new(tmp.path()).with_vfs(InMemoryFs::new());
let err = {
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect_err("DeleteFile missing without ignore_missing must fail");
assert!(matches!(err, ApplyError::Io { .. }), "got {err:?}");
}
fn deld_body(name: &str) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&(name.len() as u32).to_be_bytes());
b.extend_from_slice(name.as_bytes());
b
}
#[test]
fn in_memory_delete_directory_missing_requires_ignore_missing() {
let patch = wrap_patch(vec![make_chunk(b"DELD", &deld_body("missing_dir"))]);
let tmp = tempfile::tempdir().unwrap();
let ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.with_ignore_missing(true);
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect("DeleteDirectory missing with ignore_missing must succeed");
let tmp = tempfile::tempdir().unwrap();
let ctx = ApplyConfig::new(tmp.path()).with_vfs(InMemoryFs::new());
let err = {
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect_err("DeleteDirectory missing without ignore_missing must fail");
assert!(matches!(err, ApplyError::Io { .. }), "got {err:?}");
}
#[derive(Default, Clone)]
struct RecordingObserver {
events: Arc<Mutex<Vec<ChunkEvent>>>,
}
impl ApplyObserver for RecordingObserver {
fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
self.events.lock().unwrap().push(ev);
ControlFlow::Continue(())
}
}
#[test]
fn in_memory_observer_callbacks_match_std_run() {
let patch = synth_patch();
let wet_events = Arc::new(Mutex::new(Vec::new()));
let wet_tmp = tempfile::tempdir().unwrap();
let wet_ctx = ApplyConfig::new(wet_tmp.path()).with_observer(RecordingObserver {
events: Arc::clone(&wet_events),
});
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
wet_ctx.apply_patch(reader)
}
.expect("std apply must succeed");
let dry_events = Arc::new(Mutex::new(Vec::new()));
let dry_tmp = tempfile::tempdir().unwrap();
let dry_ctx = ApplyConfig::new(dry_tmp.path())
.with_vfs(InMemoryFs::new())
.with_observer(RecordingObserver {
events: Arc::clone(&dry_events),
});
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
dry_ctx.apply_patch(reader)
}
.expect("in-memory apply must succeed");
let wet = wet_events.lock().unwrap().clone();
let dry = dry_events.lock().unwrap().clone();
assert_eq!(
wet, dry,
"observer event sequences must be identical between std and in-memory backings"
);
assert!(
!wet.is_empty(),
"sanity: observer must have received events"
);
}
fn generic_target(rel: &str, regions: Vec<Region>) -> Target {
let final_size = regions
.last()
.map_or(0, |r| r.target_offset + u64::from(r.length));
Target::new(TargetPath::Generic(rel.to_owned()), final_size, regions)
}
fn raw_region(target_offset: u64, len: u32, src_offset: u64) -> Region {
Region::new(
target_offset,
len,
PartSource::Patch {
patch_idx: zipatch_rs::newtypes::PatchIndex::new(0),
offset: src_offset,
kind: PatchSourceKind::Raw { len },
decoded_skip: 0,
},
PartExpected::SizeOnly,
)
}
#[test]
fn in_memory_indexed_apply_leaves_install_empty_but_writes_to_vfs() {
let mut src_buf = vec![0u8; 256];
src_buf[0..16].fill(0x11);
src_buf[100..116].fill(0x22);
let src = MemoryPatchSource::new(src_buf);
let targets = vec![
generic_target("a.bin", vec![raw_region(0, 16, 0)]),
generic_target("b.bin", vec![raw_region(0, 16, 100)]),
];
let plan = Plan::new(
Platform::Win32,
vec![PatchRef::new("synthetic", None)],
targets,
vec![],
);
let events = Arc::new(Mutex::new(Vec::new()));
let observer = RecordingObserver {
events: Arc::clone(&events),
};
let tmp = tempfile::tempdir().unwrap();
let fs = InMemoryFs::new();
IndexApplier::new(src, tmp.path())
.with_vfs(fs.clone())
.with_observer(observer)
.execute(&plan)
.expect("in-memory indexed apply must succeed");
assert!(
snapshot_tree(tmp.path()).is_empty(),
"in-memory indexed apply must leave the host tree empty"
);
assert_eq!(
fs.snapshot_files().len(),
2,
"two targets must produce two in-memory files"
);
assert!(
!events.lock().unwrap().is_empty(),
"in-memory indexed apply must still drive per-target observer events"
);
}
fn sqpk_delete_data_body_local(
main_id: u16,
sub_id: u16,
file_id: u32,
block_offset_raw: u32,
block_count: u32,
) -> Vec<u8> {
let mut cmd = Vec::new();
cmd.extend_from_slice(&[0u8; 3]);
cmd.extend_from_slice(&main_id.to_be_bytes());
cmd.extend_from_slice(&sub_id.to_be_bytes());
cmd.extend_from_slice(&file_id.to_be_bytes());
cmd.extend_from_slice(&block_offset_raw.to_be_bytes());
cmd.extend_from_slice(&block_count.to_be_bytes());
cmd.extend_from_slice(&[0u8; 4]);
let mut body = Vec::new();
let inner_size = (5 + cmd.len()) as i32;
body.extend_from_slice(&inner_size.to_be_bytes());
body.push(b'D');
body.extend_from_slice(&cmd);
body
}
fn sqpk_header_body_local(
file_kind: u8,
header_kind: u8,
main_id: u16,
sub_id: u16,
file_id: u32,
header_data: &[u8; 1024],
) -> Vec<u8> {
let mut cmd = Vec::new();
cmd.push(file_kind);
cmd.push(header_kind);
cmd.push(0);
cmd.extend_from_slice(&main_id.to_be_bytes());
cmd.extend_from_slice(&sub_id.to_be_bytes());
cmd.extend_from_slice(&file_id.to_be_bytes());
cmd.extend_from_slice(header_data);
let mut body = Vec::new();
let inner_size = (5 + cmd.len()) as i32;
body.extend_from_slice(&inner_size.to_be_bytes());
body.push(b'H');
body.extend_from_slice(&cmd);
body
}
fn sqpk_makedir_body_local(path: &str) -> Vec<u8> {
let mut path_bytes = path.as_bytes().to_vec();
path_bytes.push(0);
let mut cmd = Vec::new();
cmd.push(b'M');
cmd.extend_from_slice(&[0u8; 2]);
cmd.extend_from_slice(&0u64.to_be_bytes());
cmd.extend_from_slice(&0u64.to_be_bytes());
cmd.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
cmd.extend_from_slice(&0u16.to_be_bytes());
cmd.extend_from_slice(&[0u8; 2]);
cmd.extend_from_slice(&path_bytes);
let mut body = Vec::new();
let inner_size = (5 + cmd.len()) as i32;
body.extend_from_slice(&inner_size.to_be_bytes());
body.push(b'F');
body.extend_from_slice(&cmd);
body
}
#[test]
fn in_memory_add_data_does_not_touch_host() {
let payload = vec![0u8; 128];
let patch = wrap_patch(vec![
make_chunk(b"SQPK", &sqpk_target_info_body(0)),
make_chunk(b"SQPK", &sqpk_add_data_body(0, 0, 0, 0, &payload, 0)),
]);
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect("AddData in-memory must succeed");
assert!(
snapshot_tree(tmp.path()).is_empty(),
"AddData in-memory must not create host files"
);
}
#[test]
fn in_memory_delete_data_does_not_touch_host() {
let patch = wrap_patch(vec![
make_chunk(b"SQPK", &sqpk_target_info_body(0)),
make_chunk(b"SQPK", &sqpk_delete_data_body_local(0, 0, 0, 0, 1)),
]);
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect("DeleteData in-memory must succeed");
assert!(snapshot_tree(tmp.path()).is_empty());
}
#[test]
fn in_memory_expand_data_does_not_touch_host() {
let patch = wrap_patch(vec![
make_chunk(b"SQPK", &sqpk_target_info_body(0)),
make_chunk(b"SQPK", &sqpk_expand_data_body(0, 0, 0, 0, 1)),
]);
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect("ExpandData in-memory must succeed");
assert!(snapshot_tree(tmp.path()).is_empty());
}
#[test]
fn in_memory_header_does_not_touch_host() {
let patch = wrap_patch(vec![
make_chunk(b"SQPK", &sqpk_target_info_body(0)),
make_chunk(
b"SQPK",
&sqpk_header_body_local(b'D', b'V', 0, 0, 0, &[0u8; 1024]),
),
]);
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect("Header in-memory must succeed");
assert!(snapshot_tree(tmp.path()).is_empty());
}
#[test]
fn in_memory_makedirtree_does_not_create_host_directories() {
let patch = wrap_patch(vec![make_chunk(
b"SQPK",
&sqpk_makedir_body_local("sqpack/ex1/0a0000"),
)]);
let tmp = tempfile::tempdir().unwrap();
let fs = InMemoryFs::new();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(fs.clone())
.into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect("MakeDirTree in-memory must succeed");
assert!(
snapshot_tree(tmp.path()).is_empty(),
"MakeDirTree must not create host directories"
);
assert!(
!tmp.path().join("sqpack").exists(),
"MakeDirTree must not reach through to the host fs"
);
let dirs = fs.snapshot_dirs();
assert!(
dirs.iter().any(|d| d.ends_with("sqpack/ex1/0a0000")),
"in-memory vfs must have recorded the directory tree; got {dirs:?}"
);
}
#[test]
fn in_memory_unsupported_platform_surfaces_error() {
let payload = vec![0u8; 128];
let patch = wrap_patch(vec![
make_chunk(b"SQPK", &sqpk_target_info_body(99)),
make_chunk(b"SQPK", &sqpk_add_data_body(0, 0, 0, 0, &payload, 0)),
]);
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.into_session();
let err = {
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect_err("Unknown platform must surface UnsupportedPlatform");
assert!(
matches!(err, ApplyError::UnsupportedPlatform(_)),
"expected UnsupportedPlatform, got {err:?}"
);
}
fn sqpk_addfile_body_with_bad_deflate(path: &str) -> Vec<u8> {
let mut path_bytes = path.as_bytes().to_vec();
path_bytes.push(0);
let decompressed_size: i32 = 256;
let garbage: Vec<u8> = vec![0xFF, 0xFE, 0xFD, 0x00, 0x01, 0x02, 0x03, 0x04];
let header_size: i32 = 16;
let compressed_size: i32 = garbage.len() as i32;
let block_len = ((garbage.len() as u32 + 16 + 143) & !127) as usize;
let pad = block_len - 16 - garbage.len();
let mut cmd = Vec::new();
cmd.push(b'A');
cmd.extend_from_slice(&[0u8; 2]);
cmd.extend_from_slice(&0u64.to_be_bytes());
cmd.extend_from_slice(&(decompressed_size as u64).to_be_bytes());
cmd.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
cmd.extend_from_slice(&0u16.to_be_bytes());
cmd.extend_from_slice(&[0u8; 2]);
cmd.extend_from_slice(&path_bytes);
cmd.extend_from_slice(&header_size.to_le_bytes());
cmd.extend_from_slice(&0u32.to_le_bytes());
cmd.extend_from_slice(&compressed_size.to_le_bytes());
cmd.extend_from_slice(&decompressed_size.to_le_bytes());
cmd.extend_from_slice(&garbage);
cmd.extend_from_slice(&vec![0u8; pad]);
let mut body = Vec::new();
let inner_size = (5 + cmd.len()) as i32;
body.extend_from_slice(&inner_size.to_be_bytes());
body.push(b'F');
body.extend_from_slice(&cmd);
body
}
#[test]
fn in_memory_corrupted_deflate_block_surfaces_decompress_error() {
let patch = wrap_patch(vec![make_chunk(
b"SQPK",
&sqpk_addfile_body_with_bad_deflate("out/bad.dat"),
)]);
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyConfig::new(tmp.path())
.with_vfs(InMemoryFs::new())
.into_session();
let err = {
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
ctx.apply_patch(reader)
}
.expect_err("Corrupted DEFLATE block must surface an error");
assert!(
matches!(
err,
ApplyError::Parse(zipatch_rs::ParseError::Decompress { .. })
),
"expected Decompress error, got {err:?}"
);
}
#[test]
fn in_memory_indexed_apply_delete_file_missing_surfaces_not_found() {
let src = MemoryPatchSource::new(Vec::new());
let plan = Plan::new(
Platform::Win32,
vec![PatchRef::new("synthetic", None)],
vec![],
vec![FilesystemOp::DeleteFile("does/not/exist.bin".into())],
);
let tmp = tempfile::tempdir().unwrap();
IndexApplier::new(src, tmp.path())
.with_vfs(InMemoryFs::new())
.execute(&plan)
.expect("indexed DeleteFile fs_op must demote NotFound to a no-op");
}
#[test]
fn in_memory_indexed_apply_delete_dir_missing_propagates() {
let src = MemoryPatchSource::new(Vec::new());
let plan = Plan::new(
Platform::Win32,
vec![PatchRef::new("synthetic", None)],
vec![],
vec![FilesystemOp::DeleteDir("does/not/exist".into())],
);
let tmp = tempfile::tempdir().unwrap();
let err = IndexApplier::new(src, tmp.path())
.with_vfs(InMemoryFs::new())
.execute(&plan)
.expect_err("indexed DeleteDir against missing dir must fail");
assert!(
matches!(err, zipatch_rs::IndexError::Io { .. }),
"got {err:?}"
);
}
#[test]
fn in_memory_indexed_apply_with_remove_all_fs_op_against_missing_expansion_is_ok() {
let src = MemoryPatchSource::new(Vec::new());
let plan = Plan::new(
Platform::Win32,
vec![PatchRef::new("synthetic", None)],
vec![],
vec![FilesystemOp::RemoveAllInExpansion(0)],
);
let tmp = tempfile::tempdir().unwrap();
IndexApplier::new(src, tmp.path())
.with_vfs(InMemoryFs::new())
.execute(&plan)
.expect("RemoveAllInExpansion against missing expansion must return Ok");
}
#[test]
fn in_memory_indexed_apply_make_dir_tree_records_in_vfs() {
let src = MemoryPatchSource::new(Vec::new());
let plan = Plan::new(
Platform::Win32,
vec![PatchRef::new("synthetic", None)],
vec![],
vec![FilesystemOp::MakeDirTree("sqpack/ex1".into())],
);
let tmp = tempfile::tempdir().unwrap();
let fs = InMemoryFs::new();
IndexApplier::new(src, tmp.path())
.with_vfs(fs.clone())
.execute(&plan)
.expect("MakeDirTree fs_op must succeed");
assert!(
!tmp.path().join("sqpack").exists(),
"in-memory vfs must not reach through to the host"
);
let dirs = fs.snapshot_dirs();
assert!(
dirs.iter().any(|d| d.ends_with("sqpack/ex1")),
"vfs must record the directory tree; got {dirs:?}"
);
}
#[test]
fn in_memory_apply_produces_same_bytes_as_std_apply() {
let patch = synth_patch();
let std_tmp = tempfile::tempdir().unwrap();
let mut std_ctx = ApplyConfig::new(std_tmp.path()).into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
std_ctx.apply_patch(reader)
}
.expect("std apply must succeed");
let mem_tmp = tempfile::tempdir().unwrap();
let fs = InMemoryFs::new();
let mut mem_ctx = ApplyConfig::new(mem_tmp.path())
.with_vfs(fs.clone())
.into_session();
{
let reader = ZiPatchReader::new(Cursor::new(&patch)).unwrap();
mem_ctx.apply_patch(reader)
}
.expect("in-memory apply must succeed");
let mem_files = fs.snapshot_files();
let mut compared = 0usize;
for entry in walkdir(std_tmp.path()) {
let rel = entry.strip_prefix(std_tmp.path()).unwrap();
let mem_path = mem_tmp.path().join(rel);
let std_bytes = std::fs::read(&entry).unwrap();
let mem_bytes = mem_files
.get(&mem_path)
.unwrap_or_else(|| panic!("in-memory vfs missing {mem_path:?}"));
assert_eq!(
&std_bytes, mem_bytes,
"std and in-memory bytes must match for {rel:?}"
);
compared += 1;
}
assert!(compared > 0, "sanity: must have compared at least one file");
}
fn walkdir(root: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.is_file() {
out.push(path);
}
}
}
out
}