1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
8pub enum ArchivePathIssue {
9 AbsolutePath,
11 ParentTraversal,
13 EmptyComponent,
15 WindowsDrivePrefix,
17 WindowsUncPrefix,
19 SuspiciousSeparator,
21}
22
23impl ArchivePathIssue {
24 #[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#[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#[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}