arcthis 0.5.0

An agent-native CLI for accessing and manipulating compressed files
use std::io::{self, Read};

use super::backend::ArchiveSource;
use super::codec::{StreamCompression, decoder};
use crate::error::{ArcthisError, Result};
use crate::model::ArchiveFormat;

const ZIP_LOCAL_FILE: &[u8; 4] = b"PK\x03\x04";
const ZIP_EMPTY: &[u8; 4] = b"PK\x05\x06";
const ZIP_SPANNED: &[u8; 4] = b"PK\x07\x08";
const SEVEN_Z: &[u8; 6] = b"7z\xBC\xAF\x27\x1C";
const RAR4: &[u8; 7] = b"Rar!\x1A\x07\x00";
const RAR5: &[u8; 8] = b"Rar!\x1A\x07\x01\x00";
const XZ: &[u8; 6] = b"\xFD7zXZ\0";
const ZSTD: &[u8; 4] = b"\x28\xB5\x2F\xFD";

pub fn detect(source: &ArchiveSource) -> Result<ArchiveFormat> {
    let mut file = source.reader()?;
    let mut prefix = [0_u8; 512];
    let read = read_prefix(&mut file, &mut prefix)
        .map_err(|error| ArcthisError::io("reading archive signature", error))?;

    if read >= 4
        && (&prefix[..4] == ZIP_LOCAL_FILE
            || &prefix[..4] == ZIP_EMPTY
            || &prefix[..4] == ZIP_SPANNED)
    {
        return Ok(ArchiveFormat::Zip);
    }

    if read >= SEVEN_Z.len() && &prefix[..SEVEN_Z.len()] == SEVEN_Z {
        return Ok(ArchiveFormat::SevenZip);
    }

    if (read >= RAR4.len() && &prefix[..RAR4.len()] == RAR4)
        || (read >= RAR5.len() && &prefix[..RAR5.len()] == RAR5)
    {
        return Ok(ArchiveFormat::Rar);
    }

    if read >= 2 && prefix[..2] == [0x1f, 0x8b] {
        return detect_compressed(source, StreamCompression::Gzip);
    }

    if read >= 3 && &prefix[..3] == b"BZh" {
        return detect_compressed(source, StreamCompression::Bzip2);
    }

    if read >= XZ.len() && &prefix[..XZ.len()] == XZ {
        return detect_compressed(source, StreamCompression::Xz);
    }

    if read >= ZSTD.len() && &prefix[..ZSTD.len()] == ZSTD {
        return detect_compressed(source, StreamCompression::Zstd);
    }

    if read == 512 && is_tar_header(&prefix) {
        return Ok(ArchiveFormat::Tar);
    }

    Err(ArcthisError::UnsupportedFormat {
        path: source.name().to_path_buf(),
    })
}

fn detect_compressed(
    source: &ArchiveSource,
    compression: StreamCompression,
) -> Result<ArchiveFormat> {
    let mut reader = decoder(source.reader()?, compression)?;
    let mut header = [0_u8; 512];
    let decompressed =
        read_prefix(&mut reader, &mut header).map_err(|error| ArcthisError::InvalidArchive {
            message: format!("invalid compressed stream: {error}"),
        })?;
    let is_tar = decompressed == header.len() && is_tar_header(&header);
    Ok(match (compression, is_tar) {
        (StreamCompression::Gzip, true) => ArchiveFormat::TarGzip,
        (StreamCompression::Bzip2, true) => ArchiveFormat::TarBzip2,
        (StreamCompression::Xz, true) => ArchiveFormat::TarXz,
        (StreamCompression::Zstd, true) => ArchiveFormat::TarZstd,
        (StreamCompression::Gzip, false) => ArchiveFormat::Gzip,
        (StreamCompression::Bzip2, false) => ArchiveFormat::Bzip2,
        (StreamCompression::Xz, false) => ArchiveFormat::Xz,
        (StreamCompression::Zstd, false) => ArchiveFormat::Zstd,
    })
}

fn read_prefix(reader: &mut impl Read, buffer: &mut [u8]) -> io::Result<usize> {
    let mut total = 0;
    while total < buffer.len() {
        match reader.read(&mut buffer[total..])? {
            0 => break,
            count => total += count,
        }
    }
    Ok(total)
}

fn is_tar_header(header: &[u8; 512]) -> bool {
    if header.iter().all(|byte| *byte == 0) {
        return true;
    }

    let stored = parse_octal(&header[148..156]);
    let Some(stored) = stored else {
        return false;
    };
    let computed: u64 = header
        .iter()
        .enumerate()
        .map(|(index, byte)| {
            if (148..156).contains(&index) {
                u64::from(b' ')
            } else {
                u64::from(*byte)
            }
        })
        .sum();
    stored == computed
}

fn parse_octal(field: &[u8]) -> Option<u64> {
    let text = field
        .iter()
        .copied()
        .skip_while(|byte| *byte == b' ' || *byte == 0)
        .take_while(|byte| (b'0'..=b'7').contains(byte))
        .collect::<Vec<_>>();
    if text.is_empty() {
        return Some(0);
    }
    std::str::from_utf8(&text)
        .ok()
        .and_then(|value| u64::from_str_radix(value, 8).ok())
}

#[cfg(test)]
mod tests {
    use super::is_tar_header;

    #[test]
    fn zero_block_is_a_valid_empty_tar_header() {
        assert!(is_tar_header(&[0_u8; 512]));
    }

    #[test]
    fn arbitrary_data_is_not_a_tar_header() {
        assert!(!is_tar_header(&[b'x'; 512]));
    }
}