archmeld 1.3.0

Secure, memory-safe, type-safe CLI for multi-format archive extraction, inspection and decompression
Documentation
//! Error types for archmeld operations.

use std::path::PathBuf;

/// All errors that can occur during archive operations.
///
/// Every variant carries enough context to identify *which* archive entry
/// failed, because archmeld's callers act on untrusted input and a bare
/// "extraction failed" is not actionable in an audit log.
///
/// The variants split into three groups, and the distinction matters when
/// deciding whether to retry, reject, or alert:
///
/// - **Transport / backend** ([`Io`](Self::Io), [`Zip`](Self::Zip),
///   [`Lzma`](Self::Lzma), …) — a decoder or the filesystem said no.
/// - **Malformed input** ([`InvalidArchive`](Self::InvalidArchive),
///   [`ChecksumMismatch`](Self::ChecksumMismatch), …) — the bytes are not a
///   valid archive of the claimed shape.
/// - **Refused by policy** ([`PathTraversal`](Self::PathTraversal),
///   [`FileTooLarge`](Self::FileTooLarge),
///   [`CompressionRatioExceeded`](Self::CompressionRatioExceeded), …) — the
///   archive is well-formed but hostile. These are the ones worth alerting on:
///   each corresponds to an attack archmeld exists to stop.
#[derive(Debug, thiserror::Error)]
#[allow(dead_code)]
pub enum Error {
    /// An underlying filesystem or stream operation failed.
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    /// The ZIP backend rejected the archive or one of its entries.
    #[error("ZIP error: {0}")]
    Zip(String),

    /// The 7-Zip backend rejected the archive or one of its entries.
    #[error("7-Zip error: {0}")]
    SevenZip(String),

    /// The LZMA decoder failed on the stream.
    #[error("LZMA error: {0}")]
    Lzma(#[from] lzma_rs::error::Error),

    /// LZ4 decompression failed — truncated, corrupt, or not LZ4 at all.
    #[error("LZ4 decompression error: {0}")]
    Lz4(String),

    /// The LHA/LZH backend rejected the archive or one of its entries.
    #[error("LHA error: {0}")]
    Lha(String),

    /// The XAR (macOS PKG) backend rejected the archive or its table of
    /// contents.
    #[error("XAR archive error: {0}")]
    Xar(String),

    /// XZ decompression failed — truncated, corrupt, or not XZ at all.
    #[error("XZ decompression error: {0}")]
    Xz(String),

    /// The magic bytes matched no format archmeld can handle.
    #[error("Unsupported archive format: {0}")]
    UnsupportedFormat(String),

    /// The container is structurally invalid: a header, offset or length does
    /// not describe a coherent archive.
    #[error("Invalid archive: {0}")]
    InvalidArchive(String),

    /// A single entry declares or expands to more than the per-file limit.
    ///
    /// Refused by policy — this is the per-entry half of decompression-bomb
    /// defence.
    #[error("File too large: {size} bytes exceeds limit of {limit} bytes")]
    FileTooLarge {
        /// Declared or observed size of the offending entry, in bytes.
        size: u64,
        /// The configured per-file ceiling that was exceeded, in bytes.
        limit: u64,
    },

    /// The archive's entries sum to more than the whole-archive limit.
    ///
    /// Refused by policy — the aggregate half of decompression-bomb defence,
    /// which catches many small entries that individually pass
    /// [`FileTooLarge`](Self::FileTooLarge).
    #[error("Total extraction size exceeds limit of {limit} bytes")]
    TotalSizeLimitExceeded {
        /// The configured whole-archive ceiling that was exceeded, in bytes.
        limit: u64,
    },

    /// An entry name would have written outside the destination directory.
    ///
    /// Refused by policy — covers `../` components, absolute paths, and
    /// symlinks pointing out of the tree (Zip Slip / tar traversal).
    #[error("Path traversal detected in archive entry: {0}")]
    PathTraversal(String),

    /// The requested archive or member does not exist.
    #[error("File not found: {}", .0.display())]
    FileNotFound(PathBuf),

    /// The gzip member header is malformed or declares an unsupported
    /// combination of flags.
    #[error("Invalid gzip header: {0}")]
    InvalidGzipHeader(String),

    /// The `StuffIt` container is structurally invalid.
    #[error("Invalid StuffIt archive: {0}")]
    InvalidStuffIt(String),

    /// The Compact Pro container is structurally invalid.
    #[error("Invalid Compact Pro archive: {0}")]
    InvalidCompactPro(String),

    /// Decompressed data did not match the checksum stored in the archive.
    ///
    /// Treat as integrity failure, not as a transient error: retrying the same
    /// bytes produces the same mismatch.
    #[error("Checksum mismatch: expected {expected}, got {actual}")]
    ChecksumMismatch {
        /// The digest recorded in the archive.
        expected: String,
        /// The digest computed from the decompressed bytes.
        actual: String,
    },

    /// The archive uses encryption. archmeld deliberately does not decrypt.
    #[error("Encrypted archive entries are not supported")]
    EncryptedNotSupported,

    /// A size argument (a CLI limit, a config value) could not be parsed.
    #[error("Invalid size specification: {0}")]
    InvalidSize(String),

    /// The `exarch-core` extraction backend reported a failure.
    #[error("Extraction error: {0}")]
    ExarchExtraction(String),

    /// The archive holds more entries than the configured limit.
    ///
    /// Refused by policy — bounds the *count* dimension, which size limits
    /// alone do not: millions of zero-byte entries exhaust inodes and
    /// directory-entry memory without adding a byte of payload.
    #[error(
        "Max files limit exceeded: {count} files \
         exceeds limit of {limit}"
    )]
    MaxFilesExceeded {
        /// Number of entries found in the archive.
        count: usize,
        /// The configured entry-count ceiling that was exceeded.
        limit: usize,
    },

    /// Decompressed size divided by compressed size exceeds the limit.
    ///
    /// Refused by policy — catches the classic nested "zip bomb", where each
    /// individual entry looks reasonable but the expansion factor does not.
    #[error(
        "Compression ratio {ratio:.1} exceeds \
         limit of {limit}"
    )]
    CompressionRatioExceeded {
        /// Observed decompressed-to-compressed ratio.
        ratio: f64,
        /// The configured ratio ceiling that was exceeded.
        limit: u64,
    },
}

/// Result alias for archive operations, defaulting the error to [`Error`].
pub type Result<T> = std::result::Result<T, Error>;