Skip to main content

use_archive_path/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Archive-internal path safety checks for `RustUse`.
5
6/// Issues found in an archive-internal path.
7#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
8pub enum ArchivePathIssue {
9    /// The path is absolute or root-anchored.
10    AbsolutePath,
11    /// The path contains a parent directory traversal component.
12    ParentTraversal,
13    /// The path is empty or contains an empty component.
14    EmptyComponent,
15    /// The path starts with a Windows drive prefix such as `C:`.
16    WindowsDrivePrefix,
17    /// The path starts with a Windows UNC prefix.
18    WindowsUncPrefix,
19    /// The path contains a separator that is suspicious in archive-internal paths.
20    SuspiciousSeparator,
21}
22
23impl ArchivePathIssue {
24    /// Returns a stable lowercase label.
25    #[must_use]
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::AbsolutePath => "absolute-path",
29            Self::ParentTraversal => "parent-traversal",
30            Self::EmptyComponent => "empty-component",
31            Self::WindowsDrivePrefix => "windows-drive-prefix",
32            Self::WindowsUncPrefix => "windows-unc-prefix",
33            Self::SuspiciousSeparator => "suspicious-separator",
34        }
35    }
36}
37
38/// Returns all detected safety issues for an archive-internal path.
39#[must_use]
40pub fn archive_path_issues(path: &str) -> Vec<ArchivePathIssue> {
41    let mut issues = Vec::new();
42
43    if path.is_empty() {
44        push_issue(&mut issues, ArchivePathIssue::EmptyComponent);
45    }
46
47    if path.starts_with('/') || path.starts_with('\\') {
48        push_issue(&mut issues, ArchivePathIssue::AbsolutePath);
49    }
50
51    if has_windows_drive_prefix(path) {
52        push_issue(&mut issues, ArchivePathIssue::WindowsDrivePrefix);
53    }
54
55    if path.starts_with("\\\\") || path.starts_with("//") {
56        push_issue(&mut issues, ArchivePathIssue::WindowsUncPrefix);
57    }
58
59    if path.contains('\\') {
60        push_issue(&mut issues, ArchivePathIssue::SuspiciousSeparator);
61    }
62
63    for component in path.split(['/', '\\']) {
64        if component.is_empty() {
65            push_issue(&mut issues, ArchivePathIssue::EmptyComponent);
66        } else if component == ".." {
67            push_issue(&mut issues, ArchivePathIssue::ParentTraversal);
68        }
69    }
70
71    issues
72}
73
74/// Returns whether an archive-internal path is safe and relative.
75#[must_use]
76pub fn is_safe_relative_archive_path(path: &str) -> bool {
77    archive_path_issues(path).is_empty()
78}
79
80fn has_windows_drive_prefix(path: &str) -> bool {
81    let bytes = path.as_bytes();
82
83    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
84}
85
86fn push_issue(issues: &mut Vec<ArchivePathIssue>, issue: ArchivePathIssue) {
87    if !issues.contains(&issue) {
88        issues.push(issue);
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::{ArchivePathIssue, archive_path_issues, is_safe_relative_archive_path};
95
96    #[test]
97    fn accepts_safe_relative_archive_paths() {
98        assert!(is_safe_relative_archive_path("docs/readme.md"));
99        assert!(is_safe_relative_archive_path("assets/images/logo.png"));
100    }
101
102    #[test]
103    fn rejects_traversal_and_absolute_paths() {
104        assert!(archive_path_issues("../secrets.env").contains(&ArchivePathIssue::ParentTraversal));
105        assert!(archive_path_issues("/etc/passwd").contains(&ArchivePathIssue::AbsolutePath));
106    }
107
108    #[test]
109    fn rejects_windows_paths_and_unc_prefixes() {
110        let drive_issues = archive_path_issues(r"C:\Users\name\secret.txt");
111        let unc_issues = archive_path_issues(r"\\server\share\file.txt");
112
113        assert!(drive_issues.contains(&ArchivePathIssue::WindowsDrivePrefix));
114        assert!(drive_issues.contains(&ArchivePathIssue::SuspiciousSeparator));
115        assert!(unc_issues.contains(&ArchivePathIssue::WindowsUncPrefix));
116    }
117
118    #[test]
119    fn reports_empty_components() {
120        assert!(archive_path_issues("docs//readme.md").contains(&ArchivePathIssue::EmptyComponent));
121    }
122}