archmeld 1.3.0

Secure, memory-safe, type-safe CLI for multi-format archive extraction, inspection and decompression
Documentation
//! Compact Pro archive reading (inspired by `cyco/cpt-rs`).
//!
//! Parses `.cpt` (Compact Pro) archive headers and entry metadata.
//! Compact Pro is a classic Mac OS archive format using RLE and LZH compression.
//!
//! # File Format
//!
//! The archive starts with an 8-byte header:
//! - Byte 0: File identifier (always `0x01`)
//! - Byte 1: Volume number (`0x01` for single-volume)
//! - Bytes 2–3: Cross-volume magic number
//! - Bytes 4–7: Offset to file/directory headers from start of file
//!
//! The header area (at the specified offset) contains:
//! - Bytes 0–3: CRC-32 of the header
//! - Bytes 4–5: Total number of files and directories
//! - Byte 6: Comment length
//! - Bytes 7–N: Comment text
//!
// Binary parser: indexing, arithmetic, and numeric casts are
// fundamental to format parsing. Safety is ensured by fuzzing.
#![allow(clippy::indexing_slicing)]
#![allow(clippy::arithmetic_side_effects)]
#![allow(clippy::as_conversions)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::cast_sign_loss)]

use crate::error::{Error, Result};

/// Compact Pro archive header.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptHeader {
    /// Volume number.
    pub volume_number: u8,
    /// Cross-volume magic.
    pub cross_volume_magic: u16,
    /// Offset to the entry headers from the start of the file.
    pub header_offset: u32,
    /// CRC-32 of the header area.
    pub header_crc32: u32,
    /// Total number of files and directories.
    pub total_entries: u16,
    /// Archive comment (if any).
    pub comment: Option<String>,
}

/// A file entry in a Compact Pro archive.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptFileEntry {
    /// File name.
    pub name: String,
    /// Volume number.
    pub volume_number: u8,
    /// Offset to file data from start of archive.
    pub data_offset: u32,
    /// Mac OS file type (4-char code).
    pub file_type: String,
    /// Mac OS creator code (4-char code).
    pub creator_code: String,
    /// Resource fork uncompressed size.
    pub rsrc_uncompressed_size: u32,
    /// Data fork uncompressed size.
    pub data_uncompressed_size: u32,
    /// Resource fork compressed size.
    pub rsrc_compressed_size: u32,
    /// Data fork compressed size.
    pub data_compressed_size: u32,
    /// Whether resource fork uses LZH compression.
    pub rsrc_lzh: bool,
    /// Whether data fork uses LZH compression.
    pub data_lzh: bool,
    /// Whether the file is encrypted.
    pub is_encrypted: bool,
    /// CRC-32 of uncompressed data (data + resource forks concatenated).
    pub crc32: u32,
}

/// A directory entry in a Compact Pro archive.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptDirEntry {
    /// Directory name.
    pub name: String,
    /// Total number of files and directories inside (including subdirs).
    pub total_children: u16,
}

/// A parsed entry (file or directory).
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "type")]
pub enum CptEntry {
    /// A file entry, carrying its own size and compression metadata.
    File(CptFileEntry),
    /// A directory entry; holds no payload.
    Directory(CptDirEntry),
}

impl CptEntry {
    /// Get the entry name.
    #[must_use]
    #[allow(dead_code)]
    pub fn name(&self) -> &str {
        match self {
            Self::File(f) => &f.name,
            Self::Directory(d) => &d.name,
        }
    }

    /// Whether this entry is a directory.
    #[must_use]
    #[allow(dead_code)]
    pub const fn is_directory(&self) -> bool {
        matches!(self, Self::Directory(_))
    }
}

/// Complete Compact Pro archive analysis.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptAnalysis {
    /// Parsed archive header.
    pub header: CptHeader,
    /// Entries in the order the archive lists them.
    pub entries: Vec<CptEntry>,
}

/// Compact Pro file identifier byte.
const CPT_IDENTIFIER: u8 = 0x01;

/// Probe whether data looks like a Compact Pro archive.
#[must_use]
pub fn probe(data: &[u8]) -> bool {
    // First byte must be 0x01, second byte volume number (usually 0x01)
    data.len() >= 8 && data[0] == CPT_IDENTIFIER && data[1] == 0x01
}

/// Parse a Compact Pro archive and extract metadata.
///
/// # Errors
///
/// Returns error if the archive header is invalid.
pub fn analyze(data: &[u8]) -> Result<CptAnalysis> {
    if !probe(data) {
        return Err(Error::InvalidCompactPro("not a Compact Pro archive".into()));
    }

    let header = parse_header(data)?;
    let entries = parse_entries(data, &header)?;

    Ok(CptAnalysis { header, entries })
}

/// Verify the CRC-32 of the header area.
///
/// # Errors
///
/// Returns error on CRC mismatch.
pub fn verify(data: &[u8]) -> Result<bool> {
    let header = parse_header(data)?;
    let offset = header.header_offset as usize;

    if offset + 6 >= data.len() {
        return Err(Error::InvalidCompactPro(
            "header offset out of bounds".into(),
        ));
    }

    // CRC-32 covers bytes after the CRC field itself
    let crc_start = offset + 4;
    let crc_data = &data[crc_start..];
    let computed = crc32fast::hash(crc_data);

    Ok(computed == header.header_crc32)
}

fn parse_header(data: &[u8]) -> Result<CptHeader> {
    if data.len() < 8 {
        return Err(Error::InvalidCompactPro("data too short for header".into()));
    }

    let volume_number = data[1];
    let cross_volume_magic = u16::from_be_bytes([data[2], data[3]]);
    let header_offset = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);

    let offset = header_offset as usize;
    if offset + 7 > data.len() {
        return Err(Error::InvalidCompactPro(
            "header offset points beyond file end".into(),
        ));
    }

    let header_crc32 = u32::from_be_bytes([
        data[offset],
        data[offset + 1],
        data[offset + 2],
        data[offset + 3],
    ]);
    let total_entries = u16::from_be_bytes([data[offset + 4], data[offset + 5]]);
    let comment_len = data[offset + 6] as usize;

    let comment = if comment_len > 0 && offset + 7 + comment_len <= data.len() {
        Some(String::from_utf8_lossy(&data[offset + 7..offset + 7 + comment_len]).into_owned())
    } else {
        None
    };

    Ok(CptHeader {
        volume_number,
        cross_volume_magic,
        header_offset,
        header_crc32,
        total_entries,
        comment,
    })
}

fn parse_entries(data: &[u8], header: &CptHeader) -> Result<Vec<CptEntry>> {
    let offset = header.header_offset as usize;
    let comment_len = data.get(offset + 6).copied().unwrap_or(0) as usize;
    let mut pos = offset + 7 + comment_len;
    let mut entries = Vec::new();

    for _ in 0..header.total_entries {
        if pos >= data.len() {
            break;
        }

        let name_len_and_type = data[pos];
        let is_directory = (name_len_and_type & 0x80) != 0;
        let name_len = (name_len_and_type & 0x7F) as usize;

        if name_len == 0 || pos + 1 + name_len > data.len() {
            break;
        }

        let name = String::from_utf8_lossy(&data[pos + 1..pos + 1 + name_len]).into_owned();
        pos += 1 + name_len;

        if is_directory {
            // Directory: 2 bytes for total children count
            if pos + 2 > data.len() {
                break;
            }
            let total_children = u16::from_be_bytes([data[pos], data[pos + 1]]);
            pos += 2;
            entries.push(CptEntry::Directory(CptDirEntry {
                name,
                total_children,
            }));
        } else {
            // File entry: 49 bytes of fixed fields
            // 1 (vol) + 4 (offset) + 4 (type) +
            // 4 (creator) + 14 (dates/flags) +
            // 4 (crc32) + 2 (flags) +
            // 4×4 (sizes) = 49
            if pos + 49 > data.len() {
                break;
            }

            let volume_number = data[pos];
            pos += 1;
            let data_offset =
                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
            pos += 4;
            let file_type = String::from_utf8_lossy(&data[pos..pos + 4]).into_owned();
            pos += 4;
            let creator_code = String::from_utf8_lossy(&data[pos..pos + 4]).into_owned();
            pos += 4;
            // Skip creation/modification dates and finder flags (14 bytes)
            pos += 14;
            let crc32 =
                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
            pos += 4;
            let flags = u16::from_be_bytes([data[pos], data[pos + 1]]);
            pos += 2;
            let is_encrypted = (flags & 0x01) != 0;
            let rsrc_lzh = (flags & 0x02) != 0;
            let data_lzh = (flags & 0x04) != 0;

            let rsrc_uncompressed_size =
                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
            pos += 4;
            let data_uncompressed_size =
                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
            pos += 4;
            let rsrc_compressed_size =
                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
            pos += 4;
            let data_compressed_size =
                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
            pos += 4;

            entries.push(CptEntry::File(CptFileEntry {
                name,
                volume_number,
                data_offset,
                file_type,
                creator_code,
                rsrc_uncompressed_size,
                data_uncompressed_size,
                rsrc_compressed_size,
                data_compressed_size,
                rsrc_lzh,
                data_lzh,
                is_encrypted,
                crc32,
            }));
        }
    }

    Ok(entries)
}

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

    fn make_minimal_cpt() -> Vec<u8> {
        let mut data = vec![0u8; 256];
        data[0] = 0x01; // identifier
        data[1] = 0x01; // volume
        data[2] = 0x00; // cross-volume magic high
        data[3] = 0x00; // cross-volume magic low

        // Header offset = 100
        let offset: u32 = 100;
        data[4..8].copy_from_slice(&offset.to_be_bytes());

        // At offset 100: header area
        let o = 100;
        data[o..o + 4].copy_from_slice(&0u32.to_be_bytes()); // CRC
        data[o + 4..o + 6].copy_from_slice(&0u16.to_be_bytes()); // 0 entries
        data[o + 6] = 0; // no comment

        data
    }

    #[test]
    fn test_probe_valid() {
        let data = make_minimal_cpt();
        assert!(probe(&data));
    }

    #[test]
    fn test_probe_invalid() {
        let data = [0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08];
        assert!(!probe(&data));
    }

    #[test]
    fn test_analyze_minimal() {
        let data = make_minimal_cpt();
        let result = analyze(&data);
        assert!(result.is_ok());
        let analysis = result.expect("should parse");
        assert_eq!(analysis.header.total_entries, 0);
        assert!(analysis.entries.is_empty());
    }

    #[test]
    fn test_analyze_not_cpt() {
        let data = b"PK\x03\x04notcpt";
        assert!(analyze(data).is_err());
    }

    #[test]
    fn test_cpt_entry_name() {
        let dir = CptEntry::Directory(CptDirEntry {
            name: "TestDir".into(),
            total_children: 3,
        });
        assert_eq!(dir.name(), "TestDir");
        assert!(dir.is_directory());
    }
}