zarust 0.2.1

Rust implementation of the ZArchive format
Documentation
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());
}

/// A scratch directory that cleans itself up.
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"))
        // Keep assertions free of escape sequences regardless of the test host.
        .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");

    // A directory packs, and the archive lands beside it as `<name>.zar`.
    assert!(zarust(&[input.as_ref()]).status.success());
    let archive = scratch.path("input.zar");
    assert!(archive.is_file(), "expected {}", archive.display());

    // An archive extracts.
    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);

    // `zarust <dir>` then `zarust <dir>.zar` must both succeed in place: the
    // default extract target is suffixed so it cannot land on the source.
    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());
    // The source directory is untouched.
    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");

    // Usage errors exit 2 and never touch the filesystem.
    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:?}");
    }

    // A real archive problem exits 1.
    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:"));

    // Help and version succeed.
    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()
    );
}