use std::{
fs,
io::Cursor,
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
use zarust::{ArchiveReader, ArchiveWriter, EntryKind, ROOT_NODE};
fn sample_archive() -> Vec<u8> {
let mut writer = ArchiveWriter::new(Cursor::new(Vec::new()));
writer.make_dir("Games", false).unwrap();
writer.make_dir("Games/Empty", false).unwrap();
writer
.add_file("Games/readme.txt", Cursor::new(b"hello ZArchive"))
.unwrap();
let large = (0..150_000).map(|value| value as u8).collect::<Vec<_>>();
writer
.add_file("Games/data.bin", Cursor::new(&large))
.unwrap();
writer.finish().unwrap().into_inner()
}
#[test]
fn round_trips_directories_files_and_random_reads() {
let archive = sample_archive();
let mut reader = ArchiveReader::new(Cursor::new(archive)).unwrap();
assert_eq!(reader.directory_len(ROOT_NODE).unwrap(), 1);
let games = reader
.lookup_kind("games", Some(EntryKind::Directory))
.unwrap();
let readme = reader
.lookup_kind("GAMES\\README.TXT", Some(EntryKind::File))
.unwrap();
assert_eq!(reader.read_file_to_end(readme).unwrap(), b"hello ZArchive");
let data = reader.lookup("Games/data.bin").unwrap();
let mut slice = vec![0; 80_000];
assert_eq!(reader.read_file(data, 60_000, &mut slice).unwrap(), 80_000);
let expected = (60_000..140_000)
.map(|value| value as u8)
.collect::<Vec<_>>();
assert_eq!(slice, expected);
assert_eq!(reader.directory_len(games).unwrap(), 3);
assert!(reader.verify_integrity().unwrap());
}
#[test]
fn supports_extended_name_headers() {
let name = "n".repeat(200);
let mut writer = ArchiveWriter::new(Cursor::new(Vec::new()));
writer.make_dir(name.as_bytes(), false).unwrap();
let archive = writer.finish().unwrap().into_inner();
let reader = ArchiveReader::new(Cursor::new(archive)).unwrap();
let entry = reader.directory_entry(ROOT_NODE, 0).unwrap();
assert_eq!(entry.name, name.as_bytes());
}
#[test]
fn empty_archives_remain_readable() {
let archive = ArchiveWriter::new(Cursor::new(Vec::new()))
.finish()
.unwrap()
.into_inner();
let mut reader = ArchiveReader::new(Cursor::new(archive)).unwrap();
assert_eq!(reader.directory_len(ROOT_NODE).unwrap(), 0);
assert!(reader.verify_integrity().unwrap());
}
#[test]
fn integrity_check_detects_modified_data() {
let mut archive = sample_archive();
archive[0] ^= 0x80;
let mut reader = ArchiveReader::new(Cursor::new(archive)).unwrap();
assert!(!reader.verify_integrity().unwrap());
}
#[test]
fn rejects_cyclic_file_trees() {
let mut archive = sample_archive();
let footer = archive.len() - 144;
let tree_offset = u64::from_be_bytes(archive[footer + 48..footer + 56].try_into().unwrap());
let root = tree_offset as usize;
archive[root + 4..root + 8].copy_from_slice(&0_u32.to_be_bytes());
archive[root + 8..root + 12].copy_from_slice(&1_u32.to_be_bytes());
assert!(ArchiveReader::new(Cursor::new(archive)).is_err());
}
#[test]
fn rejects_case_insensitive_duplicates() {
let mut writer = ArchiveWriter::new(Cursor::new(Vec::new()));
writer.make_dir("Folder", false).unwrap();
assert!(writer.make_dir("folder", false).is_err());
}
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(label: &str) -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root =
std::env::temp_dir().join(format!("zarust-{label}-{}-{unique}", std::process::id()));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn path(&self, name: &str) -> std::path::PathBuf {
self.0.join(name)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn zarust(args: &[&std::ffi::OsStr]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_zarust"))
.args(["--color", "never"])
.args(args)
.output()
.unwrap()
}
fn sample_tree(scratch: &Scratch) -> std::path::PathBuf {
let input = scratch.path("input");
let nested = input.join("nested");
fs::create_dir_all(&nested).unwrap();
fs::write(input.join("hello.txt"), b"hello from the CLI").unwrap();
fs::write(nested.join("data.bin"), (0..=255).collect::<Vec<_>>()).unwrap();
input
}
#[test]
fn cli_round_trips_through_pack_and_extract() {
let scratch = Scratch::new("roundtrip");
let input = sample_tree(&scratch);
let archive = scratch.path("output.zar");
let extracted = scratch.path("extracted");
let packed = zarust(&["pack".as_ref(), input.as_ref(), archive.as_ref()]);
assert!(packed.status.success(), "{:?}", packed);
let summary = String::from_utf8_lossy(&packed.stdout);
assert!(summary.contains("packed"), "{summary}");
assert!(summary.contains("2 files"), "{summary}");
assert!(summary.contains("1 directory"), "{summary}");
let extraction = zarust(&["extract".as_ref(), archive.as_ref(), extracted.as_ref()]);
assert!(extraction.status.success(), "{:?}", extraction);
assert!(String::from_utf8_lossy(&extraction.stdout).contains("extracted"));
assert_eq!(
fs::read(extracted.join("hello.txt")).unwrap(),
b"hello from the CLI"
);
assert_eq!(
fs::read(extracted.join("nested/data.bin")).unwrap(),
(0..=255).collect::<Vec<_>>()
);
}
#[test]
fn cli_still_infers_the_mode_from_a_bare_path() {
let scratch = Scratch::new("legacy");
let input = sample_tree(&scratch);
let extracted = scratch.path("extracted");
assert!(zarust(&[input.as_ref()]).status.success());
let archive = scratch.path("input.zar");
assert!(archive.is_file(), "expected {}", archive.display());
assert!(
zarust(&[archive.as_ref(), extracted.as_ref()])
.status
.success()
);
assert!(extracted.join("hello.txt").is_file());
}
#[test]
fn bare_path_round_trip_does_not_collide_with_the_source() {
let scratch = Scratch::new("collide");
let input = sample_tree(&scratch);
assert!(zarust(&[input.as_ref()]).status.success());
let archive = scratch.path("input.zar");
let extracted = zarust(&[archive.as_ref()]);
assert!(extracted.status.success(), "{extracted:?}");
assert!(scratch.path("input_extracted").join("hello.txt").is_file());
assert!(input.join("hello.txt").is_file());
}
#[test]
fn cli_lists_verifies_and_reports_archive_info() {
let scratch = Scratch::new("inspect");
let input = sample_tree(&scratch);
let archive = scratch.path("output.zar");
assert!(
zarust(&["pack".as_ref(), input.as_ref(), archive.as_ref()])
.status
.success()
);
let listed = zarust(&["list".as_ref(), archive.as_ref()]);
assert!(listed.status.success());
let listing = String::from_utf8_lossy(&listed.stdout);
assert!(listing.contains("hello.txt"), "{listing}");
assert!(listing.contains("nested/data.bin"), "{listing}");
let described = zarust(&["info".as_ref(), archive.as_ref()]);
assert!(described.status.success());
assert!(String::from_utf8_lossy(&described.stdout).contains("compressed"));
let verified = zarust(&["verify".as_ref(), archive.as_ref()]);
assert!(verified.status.success());
assert!(String::from_utf8_lossy(&verified.stdout).contains("ok"));
}
#[test]
fn cli_separates_usage_errors_from_runtime_failures() {
let scratch = Scratch::new("exits");
let missing = scratch.path("missing.zar");
for args in [
vec!["--not-an-option".as_ref()],
vec!["pack".as_ref()],
vec!["frobnicate".as_ref(), missing.as_ref()],
] {
let output = zarust(&args);
assert_eq!(output.status.code(), Some(2), "{args:?} -> {output:?}");
}
let output = zarust(&["verify".as_ref(), missing.as_ref()]);
assert_eq!(output.status.code(), Some(1), "{output:?}");
assert!(String::from_utf8_lossy(&output.stderr).starts_with("error:"));
for flag in ["--help", "-V"] {
let output = zarust(&[flag.as_ref()]);
assert!(output.status.success(), "{flag} -> {output:?}");
assert!(!output.stdout.is_empty());
}
}
#[test]
fn cli_refuses_to_clobber_without_force() {
let scratch = Scratch::new("force");
let input = sample_tree(&scratch);
let archive = scratch.path("output.zar");
assert!(
zarust(&["pack".as_ref(), input.as_ref(), archive.as_ref()])
.status
.success()
);
let again = zarust(&["pack".as_ref(), input.as_ref(), archive.as_ref()]);
assert_eq!(again.status.code(), Some(1));
assert!(String::from_utf8_lossy(&again.stderr).contains("--force"));
assert!(
zarust(&[
"pack".as_ref(),
input.as_ref(),
archive.as_ref(),
"--force".as_ref()
])
.status
.success()
);
}