#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ArchivePathIssue {
AbsolutePath,
ParentTraversal,
EmptyComponent,
WindowsDrivePrefix,
WindowsUncPrefix,
SuspiciousSeparator,
}
impl ArchivePathIssue {
#[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",
}
}
}
#[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
}
#[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));
}
}