xzip 0.4.0

ZIP CLI with explicit filename encoding for pack and unpack workflows.
Documentation
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;

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

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

#[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>,
}

/// Memory-mapped zip source shared across parallel readers.
pub struct MappedArchive {
    mmap: Arc<Mmap>,
}

#[derive(Clone)]
pub struct MmapReader {
    mmap: Arc<Mmap>,
    pos: u64,
}

impl MmapReader {
    fn new(mmap: Arc<Mmap>) -> Self {
        Self { mmap, pos: 0 }
    }
}

impl Read for MmapReader {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        let start = usize::try_from(self.pos).map_err(|_| {
            io::Error::new(io::ErrorKind::InvalidInput, "archive offset out of range")
        })?;
        if start >= self.mmap.len() {
            return Ok(0);
        }
        let end = start.saturating_add(buf.len()).min(self.mmap.len());
        let count = end - start;
        buf[..count].copy_from_slice(&self.mmap[start..end]);
        self.pos += count as u64;
        Ok(count)
    }
}

impl Seek for MmapReader {
    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
        let len = self.mmap.len() as u64;
        let new_pos = match pos {
            SeekFrom::Start(offset) => offset,
            SeekFrom::End(offset) => {
                if offset >= 0 {
                    len.saturating_add(offset as u64)
                } else {
                    let magnitude = (-offset) as u64;
                    len.saturating_sub(magnitude)
                }
            }
            SeekFrom::Current(offset) => {
                if offset >= 0 {
                    self.pos.saturating_add(offset as u64)
                } else {
                    self.pos.saturating_sub((-offset) as u64)
                }
            }
        };
        self.pos = new_pos;
        Ok(new_pos)
    }
}

impl MappedArchive {
    pub fn open(path: &Path) -> Result<Self, XzipError> {
        let file = fs_util::open_zip_file(path)?;
        let mmap = unsafe { Mmap::map(&file)? };
        Ok(Self {
            mmap: Arc::new(mmap),
        })
    }

    pub fn open_archive(&self) -> Result<ZipArchive<MmapReader>, XzipError> {
        Ok(ZipArchive::new(MmapReader::new(self.mmap.clone()))?)
    }
}

pub fn open_archive(path: &Path) -> Result<ZipArchive<File>, XzipError> {
    let file = fs_util::open_zip_file(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());
    }
}