zarust 0.2.1

Rust implementation of the ZArchive format
Documentation
//! Conversion between archive entry names and platform paths.
//!
//! The three legs of one concept — encoding a path into an archive, decoding a
//! name back out safely, and rendering a path for display — live together so
//! every `cfg` decision about separators and legal names is in one file.

use std::{ffi::OsString, path::Path};

use crate::cli::Failure;

/// Renders a path for display with forward slashes, so a single line never
/// mixes separators after joining a user-supplied path with a generated name.
pub fn display(path: &Path) -> String {
    let text = path.to_string_lossy();
    if cfg!(windows) {
        text.replace('\\', "/")
    } else {
        text.into_owned()
    }
}

/// Converts a relative filesystem path into the archive's stored form.
///
/// Separators are left as-is: the format treats `/` and `\` as equivalent when
/// splitting components, so rewriting them here would only cost an allocation.
pub fn to_archive(path: &Path) -> Result<Vec<u8>, Failure> {
    #[cfg(unix)]
    {
        use std::os::unix::ffi::OsStrExt;
        Ok(path.as_os_str().as_bytes().to_vec())
    }
    #[cfg(not(unix))]
    {
        path.to_str()
            .map(|text| text.as_bytes().to_vec())
            .ok_or_else(|| Failure::new(format!("`{}` is not valid Unicode", display(path))))
    }
}

/// Decodes one archive entry name into a filesystem name, rejecting anything
/// that would escape the output directory or resolve to a different file than
/// the archive names.
pub fn from_archive(name: &[u8]) -> Result<OsString, Failure> {
    let traversal = name.is_empty()
        || name == b"."
        || name == b".."
        || name.contains(&b'/')
        || name.contains(&b'\\')
        || name.contains(&0);
    // On Windows `file:stream` opens an alternate data stream, and a trailing
    // dot or space is silently stripped, aliasing another entry.
    let reserved = cfg!(windows)
        && (name.contains(&b':') || name.last().is_some_and(|byte| matches!(byte, b'.' | b' ')));
    if traversal || reserved {
        return Err(Failure::new(format!(
            "archive contains an unsafe entry name: `{}`",
            String::from_utf8_lossy(name)
        )));
    }

    #[cfg(unix)]
    {
        use std::os::unix::ffi::OsStringExt;
        Ok(OsString::from_vec(name.to_vec()))
    }
    #[cfg(not(unix))]
    Ok(OsString::from(String::from_utf8_lossy(name).into_owned()))
}

/// Joins `name` beside `base`, used to derive default output paths.
pub fn sibling(base: &Path, name: OsString) -> std::path::PathBuf {
    base.parent().unwrap_or_else(|| Path::new("")).join(name)
}

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

    #[test]
    fn rejects_traversal_and_reserved_names() {
        for name in [&b""[..], b".", b"..", b"a/b", b"a\\b", b"a\0b"] {
            assert!(from_archive(name).is_err(), "accepted {name:?}");
        }
        assert!(from_archive(b"ok.txt").is_ok());
    }

    #[test]
    #[cfg(windows)]
    fn rejects_windows_stream_and_trailing_dot_names() {
        for name in [&b"file:stream"[..], b"trailing.", b"trailing "] {
            assert!(from_archive(name).is_err(), "accepted {name:?}");
        }
    }

    #[test]
    fn display_never_mixes_separators() {
        let joined = Path::new("a/b").join("c.zar");
        assert!(!display(&joined).contains('\\'));
    }
}