openpack 0.2.2

Safe archive-reader for ZIP-derived formats (ZIP, CRX, JAR, APK, IPA) with BOM-safe checks.
Documentation
use std::collections::BTreeSet;
use std::path::{Component, Path};

use percent_encoding::percent_decode_str;
use zip::read::ZipFile;

use crate::types::{ArchiveEntry, Limits, OpenPackError};

pub(crate) fn check_entry_limits(
    limits: &Limits,
    entry: &ArchiveEntry,
    total_uncompressed: &mut u64,
) -> Result<(), OpenPackError> {
    // Do not skip checks for directory entries. An attacker may mark a file
    // as a directory to bypass size and ratio limits.
    if entry.uncompressed_size > limits.max_entry_uncompressed_size {
        return Err(OpenPackError::LimitExceeded(format!(
            "entry '{}' exceeds uncompressed size limit",
            entry.name
        )));
    }

    let ratio = if entry.compressed_size == 0 {
        if entry.uncompressed_size == 0 {
            0.0
        } else {
            f64::INFINITY
        }
    } else {
        entry.uncompressed_size as f64 / entry.compressed_size as f64
    };

    if ratio > limits.max_compression_ratio {
        return Err(OpenPackError::LimitExceeded(format!(
            "entry '{}' exceeds compression ratio limit",
            entry.name
        )));
    }

    *total_uncompressed = total_uncompressed.saturating_add(entry.uncompressed_size);
    if *total_uncompressed > limits.max_total_uncompressed_size {
        return Err(OpenPackError::LimitExceeded(
            "total uncompressed size exceeds limit".into(),
        ));
    }
    Ok(())
}

pub(crate) fn enforce_entry_count_limit(
    entry_count: usize,
    limits: &Limits,
) -> Result<(), OpenPackError> {
    if entry_count > limits.max_entries {
        Err(OpenPackError::LimitExceeded(
            "entry count exceeds limit".into(),
        ))
    } else {
        Ok(())
    }
}

pub(crate) fn validate_entry_name(name: &str) -> Result<(), OpenPackError> {
    if name.is_empty() {
        return Err(OpenPackError::InvalidArchive("empty entry name".into()));
    }

    let decoded = fully_percent_decode(name)?;

    if name.contains('\\') || decoded.contains('\\') {
        return Err(OpenPackError::InvalidArchive(
            "backslash in entry name".into(),
        ));
    }

    if contains_parent_traversal(name) || contains_parent_traversal(&decoded) {
        return Err(OpenPackError::ZipSlip(name.to_string()));
    }

    if Path::new(&decoded).components().any(|component| {
        matches!(
            component,
            Component::Prefix(_) | Component::RootDir | Component::ParentDir
        )
    }) {
        return Err(OpenPackError::ZipSlip(name.to_string()));
    }

    if is_windows_absolute(&decoded) {
        return Err(OpenPackError::ZipSlip(name.to_string()));
    }

    Ok(())
}

pub(crate) fn reject_duplicate_entry_name(
    names: &mut BTreeSet<String>,
    name: &str,
) -> Result<(), OpenPackError> {
    if names.insert(name.to_string()) {
        Ok(())
    } else {
        Err(OpenPackError::InvalidArchive("duplicate entry name".into()))
    }
}

pub(crate) fn entry_meta(file: &mut ZipFile<'_>) -> Result<ArchiveEntry, OpenPackError> {
    reject_special_file_entry(file)?;
    Ok(ArchiveEntry {
        name: file.name().to_string(),
        compressed_size: file.compressed_size(),
        uncompressed_size: file.size(),
        crc: file.crc32(),
        is_dir: file.is_dir(),
    })
}

pub(crate) fn reject_special_file_entry(file: &ZipFile<'_>) -> Result<(), OpenPackError> {
    const S_IFMT: u32 = 0o170000;
    const S_IFLNK: u32 = 0o120000;
    const S_IFBLK: u32 = 0o060000;
    const S_IFCHR: u32 = 0o020000;
    const S_IFIFO: u32 = 0o010000;
    const S_IFSOCK: u32 = 0o140000;

    if let Some(mode) = file.unix_mode() {
        let file_type = mode & S_IFMT;
        if file_type == S_IFLNK {
            return Err(OpenPackError::InvalidArchive(format!(
                "symlink entry `{}` is not supported",
                file.name()
            )));
        }
        if matches!(file_type, S_IFBLK | S_IFCHR | S_IFIFO | S_IFSOCK) {
            return Err(OpenPackError::InvalidArchive(format!(
                "special file entry `{}` is not supported",
                file.name()
            )));
        }
    }

    Ok(())
}

fn fully_percent_decode(value: &str) -> Result<String, OpenPackError> {
    // Repeated percent-decoding is applied iteratively until the value stabilizes.
    // Limit the number of iterations to avoid pathological or adversarially-encoded inputs.
    const MAX_DECODE_ITER: usize = 10;
    let mut current = value.to_string();
    for _ in 0..MAX_DECODE_ITER {
        let decoded = percent_decode_str(&current)
            .decode_utf8_lossy()
            .into_owned();
        if decoded == current {
            return Ok(current);
        }
        current = decoded;
    }
    Err(OpenPackError::ZipSlip(
        "path contains excessively encoded percent sequences".into(),
    ))
}

fn contains_parent_traversal(value: &str) -> bool {
    value.contains("../") || value.ends_with("/..") || value == ".."
}

fn is_windows_absolute(value: &str) -> bool {
    value.len() >= 2
        && value.as_bytes()[0].is_ascii_alphabetic()
        && value.as_bytes()[1] == b':'
}