zarust 0.2.1

Rust implementation of the ZArchive format
Documentation
//! `zarust extract` — write an archive's contents back to the filesystem.

use std::{
    ffi::OsString,
    fs::{self, File},
    io::{Read, Seek, Write},
    path::{Path, PathBuf},
    time::Instant,
};

use zarust::{ArchiveReader, EntryKind, NodeHandle, ROOT_NODE};

use crate::cli::{
    Failure, Outcome,
    args::Options,
    path,
    ui::{Reporter, format_duration},
    walk::Summary,
};

/// Matches the format's block size, so each read drains whole blocks.
const WRITE_BUFFER: usize = 64 * 1024;

pub fn run(
    reporter: &mut Reporter,
    archive: &Path,
    output: Option<PathBuf>,
    options: &Options,
) -> Outcome {
    let output = output.unwrap_or_else(|| default_output(archive));
    if output.exists() && !output.is_dir() {
        return Err(Failure::new(format!(
            "`{}` exists and is not a directory",
            path::display(&output)
        )));
    }
    if !options.force
        && output
            .read_dir()
            .is_ok_and(|mut listing| listing.next().is_some())
    {
        return Err(Failure::new(format!(
            "`{}` is not empty (use --force to extract into it)",
            path::display(&output)
        )));
    }

    let started = Instant::now();
    let mut reader = super::open_archive(archive)?;

    if options.check {
        verify_first(reporter, &mut reader, archive)?;
    }

    let summary = Summary::of(&reader, ROOT_NODE)?;
    fs::create_dir_all(&output).map_err(|error| Failure::path("create", &output, error))?;

    reporter.start_bar("extracting", summary.bytes);
    let mut buffer = vec![0_u8; WRITE_BUFFER];
    // One buffer walked with push/pop, rather than a fresh PathBuf per entry.
    let mut path_buffer = output.clone();
    let result = extract_directory(
        reporter,
        &mut reader,
        ROOT_NODE,
        &mut path_buffer,
        &mut buffer,
    );
    reporter.finish_bar();
    result?;

    reporter.success(
        "extracted",
        &output,
        &format!(
            "{} in {}",
            summary.describe(),
            format_duration(Some(started.elapsed()))
        ),
    )
}

fn verify_first<R: Read + Seek>(
    reporter: &mut Reporter,
    reader: &mut ArchiveReader<R>,
    archive: &Path,
) -> Outcome {
    reporter.start_bar("checking", reader.archive_size());
    let intact = reader.verify_integrity_with(|read| reporter.advance(read));
    reporter.finish_bar();
    if intact.map_err(|error| Failure::path("read", archive, error))? {
        return Ok(());
    }
    Err(Failure::new(format!(
        "`{}` failed its integrity check; refusing to extract",
        path::display(archive)
    )))
}

/// The directory `extract` uses when none is given.
///
/// Suffixed rather than bare so that packing a folder and extracting the result
/// side by side does not collide with the original: `game/` packs to `game.zar`,
/// which extracts to `game_extracted/`. This matches the original ZArchive tool.
fn default_output(archive: &Path) -> PathBuf {
    let mut name = archive.file_stem().unwrap_or_default().to_os_string();
    if name.is_empty() {
        name = OsString::from("archive");
    }
    name.push("_extracted");
    path::sibling(archive, name)
}

fn extract_directory<R: Read + Seek>(
    reporter: &mut Reporter,
    reader: &mut ArchiveReader<R>,
    directory: NodeHandle,
    output: &mut PathBuf,
    buffer: &mut [u8],
) -> Result<(), Failure> {
    // Names are copied out of the reader's borrow so it is free to decompress.
    let children = reader
        .directory_entries(directory)?
        .into_iter()
        .map(|entry| Ok((path::from_archive(entry.name)?, entry.kind, entry.handle)))
        .collect::<Result<Vec<_>, Failure>>()?;

    for (name, kind, handle) in children {
        output.push(&name);
        let result = match kind {
            EntryKind::Directory => fs::create_dir_all(&output)
                .map_err(|error| Failure::path("create", output, error))
                .and_then(|()| extract_directory(reporter, reader, handle, output, buffer)),
            EntryKind::File => {
                if reporter.is_verbose() {
                    reporter.detail(format_args!("{}", path::display(output)))?;
                }
                extract_file(reporter, reader, handle, output, buffer)
            }
        };
        output.pop();
        result?;
    }
    Ok(())
}

fn extract_file<R: Read + Seek>(
    reporter: &mut Reporter,
    reader: &mut ArchiveReader<R>,
    handle: NodeHandle,
    path: &Path,
    buffer: &mut [u8],
) -> Result<(), Failure> {
    let expected = reader.file_size(handle)?;
    // Data already arrives in block-sized chunks, so a BufWriter would only add
    // a copy; measured identically either way. Write straight through.
    let mut file = File::create(path).map_err(|error| Failure::path("create", path, error))?;
    let mut written = 0_u64;
    loop {
        let read = reader
            .read_file(handle, written, buffer)
            .map_err(|error| Failure::path("read", path, error))?;
        if read == 0 {
            break;
        }
        file.write_all(&buffer[..read])
            .map_err(|error| Failure::path("write", path, error))?;
        written += read as u64;
        reporter.advance(read as u64);
    }
    if written != expected {
        return Err(Failure::new(format!(
            "`{}` ended after {written} of {expected} bytes",
            super::path::display(path)
        )));
    }
    Ok(())
}