safe_unzip/
policy.rs

1//! Security policies for archive extraction.
2//!
3//! Policies validate entries before they are extracted, providing
4//! protection against various archive-based attacks.
5
6use std::path::{Component, Path, PathBuf};
7
8use path_jail::Jail;
9
10use crate::entry::{EntryInfo, EntryKind};
11use crate::error::Error;
12
13/// State tracked during extraction for cumulative limit checks.
14#[derive(Debug, Clone, Default)]
15pub struct ExtractionState {
16    /// Number of files extracted so far.
17    pub files_extracted: usize,
18    /// Number of directories created.
19    pub dirs_created: usize,
20    /// Total bytes written so far.
21    pub bytes_written: u64,
22    /// Entries skipped (symlinks, filtered, etc.).
23    pub entries_skipped: usize,
24}
25
26/// A security policy that validates entries before extraction.
27pub trait Policy: Send + Sync {
28    /// Validate an entry against this policy.
29    ///
30    /// Returns `Ok(())` if the entry passes, or an error if it violates the policy.
31    fn check(&self, entry: &EntryInfo, state: &ExtractionState) -> Result<(), Error>;
32}
33
34/// A chain of policies that all must pass.
35pub struct PolicyChain {
36    policies: Vec<Box<dyn Policy>>,
37}
38
39impl PolicyChain {
40    /// Create a new empty policy chain.
41    pub fn new() -> Self {
42        Self {
43            policies: Vec::new(),
44        }
45    }
46
47    /// Add a policy to the chain.
48    pub fn with<P: Policy + 'static>(mut self, policy: P) -> Self {
49        self.policies.push(Box::new(policy));
50        self
51    }
52
53    /// Check all policies against an entry.
54    pub fn check_all(&self, entry: &EntryInfo, state: &ExtractionState) -> Result<(), Error> {
55        for policy in &self.policies {
56            policy.check(entry, state)?;
57        }
58        Ok(())
59    }
60}
61
62impl Default for PolicyChain {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68// ============================================================================
69// Path Security Policy
70// ============================================================================
71
72/// Policy that prevents path traversal attacks (Zip Slip).
73pub struct PathPolicy {
74    jail: Jail,
75}
76
77impl PathPolicy {
78    /// Create a new path policy for the given destination.
79    pub fn new(destination: &Path) -> Result<Self, Error> {
80        let jail = Jail::new(destination).map_err(|e| Error::PathEscape {
81            entry: destination.display().to_string(),
82            detail: e.to_string(),
83        })?;
84        Ok(Self { jail })
85    }
86
87    /// Validate a filename for security issues.
88    fn validate_filename(name: &str) -> Result<(), &'static str> {
89        // Reject empty names
90        if name.is_empty() {
91            return Err("empty filename");
92        }
93
94        // Reject control characters (includes null bytes)
95        if name.chars().any(|c| c.is_control()) {
96            return Err("contains control characters");
97        }
98
99        // Reject backslashes (Windows path separator could bypass Unix checks)
100        if name.contains('\\') {
101            return Err("contains backslash");
102        }
103
104        // Reject extremely long filenames
105        if name.len() > 1024 {
106            return Err("path too long (>1024 bytes)");
107        }
108
109        if name.split('/').any(|component| component.len() > 255) {
110            return Err("path component too long (>255 bytes)");
111        }
112
113        // Reject Windows reserved names
114        for component in Path::new(name).components() {
115            if let Component::Normal(s) = component {
116                if let Some(s) = s.to_str() {
117                    let s_upper = s.to_ascii_uppercase();
118                    let file_stem = s_upper.split('.').next().unwrap_or(&s_upper);
119
120                    match file_stem {
121                        "CON" | "PRN" | "AUX" | "NUL" | "COM1" | "COM2" | "COM3" | "COM4"
122                        | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" | "LPT1" | "LPT2" | "LPT3"
123                        | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9" => {
124                            return Err("Windows reserved name");
125                        }
126                        _ => {}
127                    }
128                }
129            }
130        }
131
132        Ok(())
133    }
134}
135
136impl Policy for PathPolicy {
137    fn check(&self, entry: &EntryInfo, _state: &ExtractionState) -> Result<(), Error> {
138        // Validate filename syntax
139        if let Err(reason) = Self::validate_filename(&entry.name) {
140            return Err(Error::InvalidFilename {
141                entry: entry.name.clone(),
142                reason: reason.to_string(),
143            });
144        }
145
146        // Check path jail (prevents traversal)
147        self.jail.join(&entry.name).map_err(|e| Error::PathEscape {
148            entry: entry.name.clone(),
149            detail: e.to_string(),
150        })?;
151
152        Ok(())
153    }
154}
155
156// ============================================================================
157// Size Limits Policy
158// ============================================================================
159
160/// Policy that enforces size limits to prevent zip bombs.
161pub struct SizePolicy {
162    /// Maximum size of a single file.
163    pub max_single_file: u64,
164    /// Maximum total bytes across all files.
165    pub max_total: u64,
166}
167
168impl SizePolicy {
169    /// Create a new size policy with the given limits.
170    pub fn new(max_single_file: u64, max_total: u64) -> Self {
171        Self {
172            max_single_file,
173            max_total,
174        }
175    }
176}
177
178impl Policy for SizePolicy {
179    fn check(&self, entry: &EntryInfo, state: &ExtractionState) -> Result<(), Error> {
180        // Check single file limit
181        if entry.size > self.max_single_file {
182            return Err(Error::FileTooLarge {
183                entry: entry.name.clone(),
184                limit: self.max_single_file,
185                size: entry.size,
186            });
187        }
188
189        // Check total size limit
190        if state.bytes_written + entry.size > self.max_total {
191            return Err(Error::TotalSizeExceeded {
192                limit: self.max_total,
193                would_be: state.bytes_written + entry.size,
194            });
195        }
196
197        Ok(())
198    }
199}
200
201// ============================================================================
202// File Count Policy
203// ============================================================================
204
205/// Policy that enforces a maximum file count.
206pub struct CountPolicy {
207    /// Maximum number of files.
208    pub max_files: usize,
209}
210
211impl CountPolicy {
212    /// Create a new count policy.
213    pub fn new(max_files: usize) -> Self {
214        Self { max_files }
215    }
216}
217
218impl Policy for CountPolicy {
219    fn check(&self, _entry: &EntryInfo, state: &ExtractionState) -> Result<(), Error> {
220        if state.files_extracted >= self.max_files {
221            return Err(Error::FileCountExceeded {
222                limit: self.max_files,
223                attempted: state.files_extracted + 1,
224            });
225        }
226        Ok(())
227    }
228}
229
230// ============================================================================
231// Path Depth Policy
232// ============================================================================
233
234/// Policy that enforces a maximum path depth.
235pub struct DepthPolicy {
236    /// Maximum directory depth.
237    pub max_depth: usize,
238}
239
240impl DepthPolicy {
241    /// Create a new depth policy.
242    pub fn new(max_depth: usize) -> Self {
243        Self { max_depth }
244    }
245}
246
247impl Policy for DepthPolicy {
248    fn check(&self, entry: &EntryInfo, _state: &ExtractionState) -> Result<(), Error> {
249        let depth = Path::new(&entry.name).components().count();
250        if depth > self.max_depth {
251            return Err(Error::PathTooDeep {
252                entry: entry.name.clone(),
253                depth,
254                limit: self.max_depth,
255            });
256        }
257        Ok(())
258    }
259}
260
261// ============================================================================
262// Symlink Policy
263// ============================================================================
264
265/// What to do when encountering a symlink.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
267pub enum SymlinkBehavior {
268    /// Skip symlinks silently.
269    #[default]
270    Skip,
271    /// Return an error if a symlink is encountered.
272    Error,
273}
274
275/// Policy that handles symlinks in archives.
276pub struct SymlinkPolicy {
277    /// What to do with symlinks.
278    pub behavior: SymlinkBehavior,
279}
280
281impl SymlinkPolicy {
282    /// Create a new symlink policy.
283    pub fn new(behavior: SymlinkBehavior) -> Self {
284        Self { behavior }
285    }
286}
287
288impl Policy for SymlinkPolicy {
289    fn check(&self, entry: &EntryInfo, _state: &ExtractionState) -> Result<(), Error> {
290        if let EntryKind::Symlink { target } = &entry.kind {
291            match self.behavior {
292                SymlinkBehavior::Skip => {
293                    // This will be handled by the extractor by skipping
294                    // We don't error here, just let the extractor know to skip
295                }
296                SymlinkBehavior::Error => {
297                    return Err(Error::SymlinkNotAllowed {
298                        entry: entry.name.clone(),
299                        target: target.clone(),
300                    });
301                }
302            }
303        }
304        Ok(())
305    }
306}
307
308// ============================================================================
309// Default Policy Chain Builder
310// ============================================================================
311
312/// Configuration for building a default policy chain.
313#[derive(Debug, Clone)]
314pub struct PolicyConfig {
315    pub destination: PathBuf,
316    pub max_single_file: u64,
317    pub max_total: u64,
318    pub max_files: usize,
319    pub max_depth: usize,
320    pub symlink_behavior: SymlinkBehavior,
321}
322
323impl PolicyConfig {
324    /// Build a policy chain from this configuration.
325    pub fn build(&self) -> Result<PolicyChain, Error> {
326        Ok(PolicyChain::new()
327            .with(PathPolicy::new(&self.destination)?)
328            .with(SizePolicy::new(self.max_single_file, self.max_total))
329            .with(CountPolicy::new(self.max_files))
330            .with(DepthPolicy::new(self.max_depth))
331            .with(SymlinkPolicy::new(self.symlink_behavior)))
332    }
333}