use-archive-path 0.1.0

Archive-internal path safety checks for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

//! Archive-internal path safety checks for `RustUse`.

/// Issues found in an archive-internal path.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ArchivePathIssue {
    /// The path is absolute or root-anchored.
    AbsolutePath,
    /// The path contains a parent directory traversal component.
    ParentTraversal,
    /// The path is empty or contains an empty component.
    EmptyComponent,
    /// The path starts with a Windows drive prefix such as `C:`.
    WindowsDrivePrefix,
    /// The path starts with a Windows UNC prefix.
    WindowsUncPrefix,
    /// The path contains a separator that is suspicious in archive-internal paths.
    SuspiciousSeparator,
}

impl ArchivePathIssue {
    /// Returns a stable lowercase label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::AbsolutePath => "absolute-path",
            Self::ParentTraversal => "parent-traversal",
            Self::EmptyComponent => "empty-component",
            Self::WindowsDrivePrefix => "windows-drive-prefix",
            Self::WindowsUncPrefix => "windows-unc-prefix",
            Self::SuspiciousSeparator => "suspicious-separator",
        }
    }
}

/// Returns all detected safety issues for an archive-internal path.
#[must_use]
pub fn archive_path_issues(path: &str) -> Vec<ArchivePathIssue> {
    let mut issues = Vec::new();

    if path.is_empty() {
        push_issue(&mut issues, ArchivePathIssue::EmptyComponent);
    }

    if path.starts_with('/') || path.starts_with('\\') {
        push_issue(&mut issues, ArchivePathIssue::AbsolutePath);
    }

    if has_windows_drive_prefix(path) {
        push_issue(&mut issues, ArchivePathIssue::WindowsDrivePrefix);
    }

    if path.starts_with("\\\\") || path.starts_with("//") {
        push_issue(&mut issues, ArchivePathIssue::WindowsUncPrefix);
    }

    if path.contains('\\') {
        push_issue(&mut issues, ArchivePathIssue::SuspiciousSeparator);
    }

    for component in path.split(['/', '\\']) {
        if component.is_empty() {
            push_issue(&mut issues, ArchivePathIssue::EmptyComponent);
        } else if component == ".." {
            push_issue(&mut issues, ArchivePathIssue::ParentTraversal);
        }
    }

    issues
}

/// Returns whether an archive-internal path is safe and relative.
#[must_use]
pub fn is_safe_relative_archive_path(path: &str) -> bool {
    archive_path_issues(path).is_empty()
}

fn has_windows_drive_prefix(path: &str) -> bool {
    let bytes = path.as_bytes();

    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}

fn push_issue(issues: &mut Vec<ArchivePathIssue>, issue: ArchivePathIssue) {
    if !issues.contains(&issue) {
        issues.push(issue);
    }
}

#[cfg(test)]
mod tests {
    use super::{ArchivePathIssue, archive_path_issues, is_safe_relative_archive_path};

    #[test]
    fn accepts_safe_relative_archive_paths() {
        assert!(is_safe_relative_archive_path("docs/readme.md"));
        assert!(is_safe_relative_archive_path("assets/images/logo.png"));
    }

    #[test]
    fn rejects_traversal_and_absolute_paths() {
        assert!(archive_path_issues("../secrets.env").contains(&ArchivePathIssue::ParentTraversal));
        assert!(archive_path_issues("/etc/passwd").contains(&ArchivePathIssue::AbsolutePath));
    }

    #[test]
    fn rejects_windows_paths_and_unc_prefixes() {
        let drive_issues = archive_path_issues(r"C:\Users\name\secret.txt");
        let unc_issues = archive_path_issues(r"\\server\share\file.txt");

        assert!(drive_issues.contains(&ArchivePathIssue::WindowsDrivePrefix));
        assert!(drive_issues.contains(&ArchivePathIssue::SuspiciousSeparator));
        assert!(unc_issues.contains(&ArchivePathIssue::WindowsUncPrefix));
    }

    #[test]
    fn reports_empty_components() {
        assert!(archive_path_issues("docs//readme.md").contains(&ArchivePathIssue::EmptyComponent));
    }
}