use_archive_policy/
lib.rs1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use use_archive_entry::{ArchiveEntry, ArchiveEntryKind};
7use use_archive_path::{ArchivePathIssue, archive_path_issues};
8
9#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct ArchivePolicy {
12 pub allow_absolute_paths: bool,
14 pub allow_parent_traversal: bool,
16 pub allow_symlinks: bool,
18 pub max_entry_size: Option<u64>,
20 pub max_total_size: Option<u64>,
22 pub max_entries: Option<usize>,
24}
25
26impl ArchivePolicy {
27 #[must_use]
29 pub const fn strict() -> Self {
30 Self {
31 allow_absolute_paths: false,
32 allow_parent_traversal: false,
33 allow_symlinks: false,
34 max_entry_size: None,
35 max_total_size: None,
36 max_entries: None,
37 }
38 }
39
40 #[must_use]
42 pub const fn permissive() -> Self {
43 Self {
44 allow_absolute_paths: true,
45 allow_parent_traversal: true,
46 allow_symlinks: true,
47 max_entry_size: None,
48 max_total_size: None,
49 max_entries: None,
50 }
51 }
52
53 #[must_use]
55 pub const fn list_only() -> Self {
56 Self::permissive()
57 }
58
59 #[must_use]
61 pub const fn with_max_entry_size(mut self, max_entry_size: u64) -> Self {
62 self.max_entry_size = Some(max_entry_size);
63 self
64 }
65
66 #[must_use]
68 pub const fn with_max_total_size(mut self, max_total_size: u64) -> Self {
69 self.max_total_size = Some(max_total_size);
70 self
71 }
72
73 #[must_use]
75 pub const fn with_max_entries(mut self, max_entries: usize) -> Self {
76 self.max_entries = Some(max_entries);
77 self
78 }
79
80 #[must_use]
82 pub fn allows_path(&self, path: &str) -> bool {
83 archive_path_issues(path)
84 .into_iter()
85 .all(|issue| self.path_issue_allowed(issue))
86 }
87
88 #[must_use]
90 pub fn entry_issues(&self, entry: &ArchiveEntry) -> Vec<ArchivePolicyIssue> {
91 let mut issues = Vec::new();
92
93 for path_issue in archive_path_issues(entry.path()) {
94 if !self.path_issue_allowed(path_issue) {
95 issues.push(ArchivePolicyIssue::PathIssue(path_issue));
96 }
97 }
98
99 if !self.allow_symlinks && entry.kind() == ArchiveEntryKind::Symlink {
100 issues.push(ArchivePolicyIssue::SymlinkNotAllowed);
101 }
102
103 if let (Some(size), Some(max)) = (entry.size(), self.max_entry_size)
104 && size > max
105 {
106 issues.push(ArchivePolicyIssue::EntryTooLarge { size, max });
107 }
108
109 issues
110 }
111
112 #[must_use]
114 pub fn allows_entry(&self, entry: &ArchiveEntry) -> bool {
115 self.entry_issues(entry).is_empty()
116 }
117
118 #[must_use]
120 pub fn entries_issues(&self, entries: &[ArchiveEntry]) -> Vec<ArchivePolicyIssue> {
121 let mut issues = Vec::new();
122
123 for entry in entries {
124 issues.extend(self.entry_issues(entry));
125 }
126
127 if let Some(max_entries) = self.max_entries
128 && entries.len() > max_entries
129 {
130 issues.push(ArchivePolicyIssue::TooManyEntries {
131 entries: entries.len(),
132 max: max_entries,
133 });
134 }
135
136 if let Some(max_total_size) = self.max_total_size {
137 let total = entries.iter().filter_map(ArchiveEntry::size).sum();
138 if total > max_total_size {
139 issues.push(ArchivePolicyIssue::TotalSizeTooLarge {
140 total,
141 max: max_total_size,
142 });
143 }
144 }
145
146 issues
147 }
148
149 #[must_use]
151 pub fn allows_entries(&self, entries: &[ArchiveEntry]) -> bool {
152 self.entries_issues(entries).is_empty()
153 }
154
155 fn path_issue_allowed(&self, issue: ArchivePathIssue) -> bool {
156 match issue {
157 ArchivePathIssue::AbsolutePath
158 | ArchivePathIssue::WindowsDrivePrefix
159 | ArchivePathIssue::WindowsUncPrefix => self.allow_absolute_paths,
160 ArchivePathIssue::ParentTraversal => self.allow_parent_traversal,
161 ArchivePathIssue::EmptyComponent | ArchivePathIssue::SuspiciousSeparator => {
162 self.allow_absolute_paths && self.allow_parent_traversal
163 },
164 }
165 }
166}
167
168impl Default for ArchivePolicy {
169 fn default() -> Self {
170 Self::strict()
171 }
172}
173
174#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
176pub enum ArchivePolicyIssue {
177 PathIssue(ArchivePathIssue),
179 SymlinkNotAllowed,
181 EntryTooLarge {
183 size: u64,
185 max: u64,
187 },
188 TotalSizeTooLarge {
190 total: u64,
192 max: u64,
194 },
195 TooManyEntries {
197 entries: usize,
199 max: usize,
201 },
202}
203
204#[cfg(test)]
205mod tests {
206 use super::{ArchivePolicy, ArchivePolicyIssue};
207 use use_archive_entry::{ArchiveEntry, ArchiveEntryKind};
208 use use_archive_path::ArchivePathIssue;
209
210 #[test]
211 fn strict_policy_rejects_unsafe_paths_and_symlinks() {
212 let policy = ArchivePolicy::strict();
213 let unsafe_entry = ArchiveEntry::new("../secrets.env", ArchiveEntryKind::File);
214 let symlink = ArchiveEntry::new("link", ArchiveEntryKind::Symlink);
215
216 assert!(
217 policy
218 .entry_issues(&unsafe_entry)
219 .contains(&ArchivePolicyIssue::PathIssue(
220 ArchivePathIssue::ParentTraversal
221 ))
222 );
223 assert!(
224 policy
225 .entry_issues(&symlink)
226 .contains(&ArchivePolicyIssue::SymlinkNotAllowed)
227 );
228 }
229
230 #[test]
231 fn enforces_size_and_count_limits() {
232 let policy = ArchivePolicy::strict()
233 .with_max_entry_size(10)
234 .with_max_total_size(20)
235 .with_max_entries(1);
236 let entries = vec![
237 ArchiveEntry::file("one.txt").with_size(15),
238 ArchiveEntry::file("two.txt").with_size(15),
239 ];
240
241 let issues = policy.entries_issues(&entries);
242
243 assert!(issues.contains(&ArchivePolicyIssue::EntryTooLarge { size: 15, max: 10 }));
244 assert!(issues.contains(&ArchivePolicyIssue::TotalSizeTooLarge { total: 30, max: 20 }));
245 assert!(issues.contains(&ArchivePolicyIssue::TooManyEntries { entries: 2, max: 1 }));
246 }
247
248 #[test]
249 fn permissive_policy_allows_listing_paths() {
250 let policy = ArchivePolicy::list_only();
251
252 assert!(policy.allows_path("../secrets.env"));
253 assert!(policy.allows_path(r"C:\Users\name\secret.txt"));
254 }
255}