zarust 0.1.0

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());
}

#[test]
fn cli_packs_and_extracts_with_the_original_command_shape() {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let root = std::env::temp_dir().join(format!("zarust-cli-{}-{unique}", std::process::id()));
    let input = root.join("input");
    let nested = input.join("nested");
    let archive = root.join("output.zar");
    let extracted = root.join("extracted");
    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();

    let command = env!("CARGO_BIN_EXE_zarust");
    let pack = Command::new(command)
        .arg(&input)
        .arg(&archive)
        .output()
        .unwrap();
    assert!(
        pack.status.success(),
        "{}",
        String::from_utf8_lossy(&pack.stderr)
    );
    let progress = String::from_utf8_lossy(&pack.stderr);
    assert!(progress.contains("100.0%"));
    assert!(progress.contains("processed"));
    assert!(progress.contains("speed"));
    assert!(progress.contains("elapsed"));
    assert!(progress.contains("ETA"));

    let extract = Command::new(command)
        .arg(&archive)
        .arg(&extracted)
        .output()
        .unwrap();
    assert!(
        extract.status.success(),
        "{}",
        String::from_utf8_lossy(&extract.stderr)
    );
    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<_>>()
    );

    fs::remove_dir_all(root).unwrap();
}