xzip 0.3.0

ZIP CLI with explicit filename encoding for pack and unpack workflows.
Documentation
use std::fs::File;
use std::path::{Component, Path, PathBuf};

use zip::{CompressionMethod, DateTime, ZipArchive};

use crate::codec::EncodingKind;
use crate::error::XzipError;
use crate::filter::PathFilter;

#[derive(Debug, Clone)]
pub struct ArchiveEntry {
    pub name: String,
    pub is_dir: bool,
    pub size: u64,
    pub compressed_size: u64,
    pub compression: CompressionMethod,
    pub modified: Option<DateTime>,
}

pub fn open_archive(path: &Path) -> Result<ZipArchive<File>, XzipError> {
    let file = File::open(path)?;
    Ok(ZipArchive::new(file)?)
}

pub fn collect_filtered_entries(
    archive: &mut ZipArchive<File>,
    encoding: EncodingKind,
    filter: &PathFilter,
) -> Result<Vec<ArchiveEntry>, XzipError> {
    let mut entries = Vec::new();
    for i in 0..archive.len() {
        let entry = archive.by_index(i)?;
        let raw_name = entry.name_raw();
        let decoded_name = encoding.decode(raw_name)?;
        if !filter.allows(&decoded_name) {
            continue;
        }
        let display_name = if entry.is_dir() && !decoded_name.ends_with('/') {
            format!("{decoded_name}/")
        } else {
            decoded_name
        };
        entries.push(ArchiveEntry {
            name: display_name,
            is_dir: entry.is_dir(),
            size: entry.size(),
            compressed_size: entry.compressed_size(),
            compression: entry.compression(),
            modified: entry.last_modified(),
        });
    }
    Ok(entries)
}

pub fn sanitize_relative_path(path: &str) -> Result<PathBuf, XzipError> {
    let candidate = Path::new(path);
    let mut safe = PathBuf::new();
    for comp in candidate.components() {
        match comp {
            Component::Normal(part) => safe.push(part),
            Component::CurDir => {}
            Component::ParentDir | Component::Prefix(_) | Component::RootDir => {
                return Err(XzipError::UnsafePath(path.to_string()));
            }
        }
    }
    if safe.as_os_str().is_empty() {
        return Err(XzipError::UnsafePath(path.to_string()));
    }
    Ok(safe)
}

pub fn compression_label(method: CompressionMethod) -> &'static str {
    match method {
        CompressionMethod::Stored => "stored",
        CompressionMethod::Deflated => "deflated",
        _ => "unknown",
    }
}

pub fn format_datetime(modified: Option<DateTime>) -> (String, String) {
    match modified {
        Some(dt) => (
            format!("{:04}-{:02}-{:02}", dt.year(), dt.month(), dt.day()),
            format!("{:02}:{:02}", dt.hour(), dt.minute()),
        ),
        None => ("----------".to_string(), "-----".to_string()),
    }
}

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

    #[test]
    fn rejects_parent_dir() {
        assert!(sanitize_relative_path("../evil.txt").is_err());
    }
}