xzip 0.1.0

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

use zip::ZipArchive;

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

pub fn unpack_archive(
    input_zip: &Path,
    output_dir: &Path,
    encoding: EncodingKind,
    include: &[String],
    exclude: &[String],
) -> Result<(), XzipError> {
    let file = File::open(input_zip)?;
    let mut archive = ZipArchive::new(file)?;
    fs::create_dir_all(output_dir)?;
    let filter = PathFilter::new(include, exclude)?;

    for i in 0..archive.len() {
        let mut 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 safe_rel = sanitize_relative_path(&decoded_name)?;
        let outpath = output_dir.join(&safe_rel);

        if entry.is_dir() {
            fs::create_dir_all(&outpath)?;
            continue;
        }

        if let Some(parent) = outpath.parent() {
            fs::create_dir_all(parent)?;
        }

        let mut outfile = File::create(&outpath)?;
        io::copy(&mut entry, &mut outfile)?;
        outfile.flush()?;
    }

    Ok(())
}

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)
}

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

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