Skip to main content

use_archive_policy/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Archive safety policy primitives for `RustUse`.
5
6use use_archive_entry::{ArchiveEntry, ArchiveEntryKind};
7use use_archive_path::{ArchivePathIssue, archive_path_issues};
8
9/// Policy primitives for safe extraction planning.
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct ArchivePolicy {
12    /// Whether absolute or root-anchored paths are allowed.
13    pub allow_absolute_paths: bool,
14    /// Whether parent traversal components are allowed.
15    pub allow_parent_traversal: bool,
16    /// Whether symbolic link entries are allowed.
17    pub allow_symlinks: bool,
18    /// Maximum single entry payload size in bytes.
19    pub max_entry_size: Option<u64>,
20    /// Maximum total known payload size in bytes.
21    pub max_total_size: Option<u64>,
22    /// Maximum number of archive entries.
23    pub max_entries: Option<usize>,
24}
25
26impl ArchivePolicy {
27    /// Returns a strict extraction-oriented policy.
28    #[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    /// Returns a permissive policy for trusted archive metadata.
41    #[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    /// Returns a policy suitable for listing-only workflows.
54    #[must_use]
55    pub const fn list_only() -> Self {
56        Self::permissive()
57    }
58
59    /// Adds a maximum single entry size.
60    #[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    /// Adds a maximum total known size.
67    #[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    /// Adds a maximum entry count.
74    #[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    /// Returns whether a path is allowed by this policy.
81    #[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    /// Returns policy issues for a single entry.
89    #[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    /// Returns whether a single entry is allowed by this policy.
113    #[must_use]
114    pub fn allows_entry(&self, entry: &ArchiveEntry) -> bool {
115        self.entry_issues(entry).is_empty()
116    }
117
118    /// Returns policy issues for a complete entry listing.
119    #[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    /// Returns whether a complete entry listing is allowed by this policy.
150    #[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/// Policy violations detected for archive entries or entry lists.
175#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
176pub enum ArchivePolicyIssue {
177    /// The entry path violates the configured path policy.
178    PathIssue(ArchivePathIssue),
179    /// Symbolic links are disallowed by the policy.
180    SymlinkNotAllowed,
181    /// A single entry is larger than the configured maximum.
182    EntryTooLarge {
183        /// Observed entry size in bytes.
184        size: u64,
185        /// Configured maximum entry size in bytes.
186        max: u64,
187    },
188    /// Total known entry size is larger than the configured maximum.
189    TotalSizeTooLarge {
190        /// Observed total known size in bytes.
191        total: u64,
192        /// Configured maximum total size in bytes.
193        max: u64,
194    },
195    /// Entry count is larger than the configured maximum.
196    TooManyEntries {
197        /// Observed entry count.
198        entries: usize,
199        /// Configured maximum entry count.
200        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}