Skip to main content

exarch_core/security/
quota.rs

1//! Extraction quota tracking and validation.
2
3use std::marker::PhantomData;
4
5use crate::ArchiveError;
6use crate::Result;
7use crate::SecurityConfig;
8use crate::config::Validated;
9
10/// Proof that a file's size was successfully reserved against a
11/// [`QuotaTracker`].
12///
13/// This is a capability token, not a data type: it carries no payload and
14/// exists only so that code holding one can prove a tracker actually
15/// authorized the charge. Its only producer is [`QuotaTracker::reserve`] —
16/// the private field means no other code, in or out of this crate, can
17/// assemble one directly. `EntryValidator` exposes three ways to obtain one,
18/// all funneling through that same `reserve` call:
19/// [`EntryValidator::validate_entry`] embeds it in
20/// [`ValidatedEntryType::File`] for the common case where path validation and
21/// quota reservation happen together; its crate-private `reserve_hardlink`
22/// and `reserve_file` methods return it standalone for callers that must
23/// decouple the two — hardlinks (target size only known in a second pass)
24/// and 7z's duplicate-skip check (quota must not be reserved for an entry
25/// that turns out to be a skipped duplicate), respectively.
26///
27/// Deliberately not `Clone`/`Copy`/`Default`: a permit represents a single
28/// reservation and must not be duplicated or spent more than once. Zero-sized
29/// (`PhantomData`-based), so wrapping it in `Result` costs nothing over
30/// `Result<()>`.
31///
32/// [`ValidatedEntryType::File`]: crate::security::validator::ValidatedEntryType::File
33/// [`EntryValidator::validate_entry`]: crate::security::validator::EntryValidator::validate_entry
34///
35/// # Examples
36///
37/// The common case: a `QuotaPermit` arrives already embedded in a validated
38/// file entry, via [`EntryValidator::validate_entry`]:
39///
40/// ```no_run
41/// use exarch_core::SecurityConfig;
42/// use exarch_core::security::EntryValidator;
43/// use exarch_core::security::ValidatedEntryType;
44/// use exarch_core::types::DestDir;
45/// use exarch_core::types::EntryType;
46/// use std::path::Path;
47/// use std::path::PathBuf;
48///
49/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
50/// let dest = DestDir::new(PathBuf::from("/tmp"))?;
51/// let config = SecurityConfig::default().validate()?;
52/// let mut validator = EntryValidator::new(&config, &dest);
53///
54/// let entry = validator.validate_entry(
55///     Path::new("file.txt"),
56///     &EntryType::File,
57///     1024, // uncompressed size
58///     None, // compressed size
59///     None, // mode
60///     None, // dir_cache
61/// )?;
62///
63/// if let ValidatedEntryType::File(permit) = entry.entry_type() {
64///     println!("{permit:?}");
65/// }
66/// # Ok(())
67/// # }
68/// ```
69#[derive(Debug)]
70#[must_use]
71pub struct QuotaPermit(PhantomData<()>);
72
73/// Tracks resource usage during extraction.
74#[derive(Debug, Default)]
75pub struct QuotaTracker {
76    files_extracted: usize,
77    bytes_written: u64,
78}
79
80impl QuotaTracker {
81    /// Creates a new quota tracker.
82    #[must_use]
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Reserves quota capacity for a file extraction, returning a capability
88    /// token that proves the reservation succeeded.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if quotas are exceeded or integer overflow is detected.
93    ///
94    /// # Performance
95    ///
96    /// OPT-C003: Fast path for unlimited quotas reduces overhead by 3-5%.
97    /// When all quotas are set to maximum values (unlimited), the function
98    /// skips quota checks and only tracks counters with overflow detection.
99    #[inline]
100    pub fn reserve(
101        &mut self,
102        size: u64,
103        config: &SecurityConfig<Validated>,
104    ) -> Result<QuotaPermit> {
105        // OPT-C003: Fast path when all quotas unlimited - skip checks, only detect
106        // overflow
107        if config.max_file_size == u64::MAX
108            && config.max_file_count == usize::MAX
109            && config.max_total_size == u64::MAX
110        {
111            self.files_extracted = self.files_extracted.checked_add(1).ok_or_else(|| {
112                core::hint::cold_path();
113                ArchiveError::QuotaExceeded {
114                    resource: crate::QuotaResource::IntegerOverflow,
115                }
116            })?;
117
118            self.bytes_written = self.bytes_written.checked_add(size).ok_or_else(|| {
119                core::hint::cold_path();
120                ArchiveError::QuotaExceeded {
121                    resource: crate::QuotaResource::IntegerOverflow,
122                }
123            })?;
124
125            return Ok(QuotaPermit(PhantomData));
126        }
127
128        self.record_file_checked(size, config)?;
129        Ok(QuotaPermit(PhantomData))
130    }
131
132    /// Internal implementation with full quota validation.
133    ///
134    /// This is the slow path called when quotas are actually enforced.
135    /// Separated from the fast path to keep the hot path small and inlinable.
136    #[inline(never)]
137    fn record_file_checked(&mut self, size: u64, config: &SecurityConfig<Validated>) -> Result<()> {
138        if size > config.max_file_size {
139            core::hint::cold_path();
140            return Err(ArchiveError::QuotaExceeded {
141                resource: crate::QuotaResource::FileSize {
142                    size,
143                    max: config.max_file_size,
144                },
145            });
146        }
147
148        self.files_extracted = self.files_extracted.checked_add(1).ok_or_else(|| {
149            core::hint::cold_path();
150            ArchiveError::QuotaExceeded {
151                resource: crate::QuotaResource::IntegerOverflow,
152            }
153        })?;
154
155        self.bytes_written = self.bytes_written.checked_add(size).ok_or_else(|| {
156            core::hint::cold_path();
157            ArchiveError::QuotaExceeded {
158                resource: crate::QuotaResource::IntegerOverflow,
159            }
160        })?;
161
162        if self.files_extracted > config.max_file_count {
163            core::hint::cold_path();
164            return Err(ArchiveError::QuotaExceeded {
165                resource: crate::QuotaResource::FileCount {
166                    current: self.files_extracted,
167                    max: config.max_file_count,
168                },
169            });
170        }
171
172        if self.bytes_written > config.max_total_size {
173            core::hint::cold_path();
174            return Err(ArchiveError::QuotaExceeded {
175                resource: crate::QuotaResource::TotalSize {
176                    current: self.bytes_written,
177                    max: config.max_total_size,
178                },
179            });
180        }
181
182        Ok(())
183    }
184
185    /// Returns the number of files extracted.
186    #[must_use]
187    pub fn files_extracted(&self) -> usize {
188        self.files_extracted
189    }
190
191    /// Returns the total bytes written.
192    #[must_use]
193    pub fn bytes_written(&self) -> u64 {
194        self.bytes_written
195    }
196}
197
198#[cfg(test)]
199#[allow(clippy::field_reassign_with_default, clippy::expect_used)]
200mod tests {
201    use super::*;
202    use std::assert_matches;
203
204    #[test]
205    fn test_quota_tracker_new() {
206        let tracker = QuotaTracker::new();
207        assert_eq!(tracker.files_extracted(), 0);
208        assert_eq!(tracker.bytes_written(), 0);
209    }
210
211    #[test]
212    fn test_quota_tracker_reserve() {
213        let mut tracker = QuotaTracker::new();
214        let config = SecurityConfig::default().validate().expect("valid config");
215
216        assert!(tracker.reserve(1000, &config).is_ok());
217        assert_eq!(tracker.files_extracted(), 1);
218        assert_eq!(tracker.bytes_written(), 1000);
219    }
220
221    #[test]
222    fn test_quota_tracker_exceed_file_count() {
223        let mut tracker = QuotaTracker::new();
224        let mut config = SecurityConfig::default();
225        config.max_file_count = 2;
226        let config = config.validate().expect("valid config");
227
228        assert!(tracker.reserve(100, &config).is_ok());
229        assert!(tracker.reserve(100, &config).is_ok());
230        let result = tracker.reserve(100, &config);
231        assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
232    }
233
234    #[test]
235    fn test_quota_tracker_exceed_total_size() {
236        let mut tracker = QuotaTracker::new();
237        let mut config = SecurityConfig::default();
238        config.max_total_size = 1000;
239        let config = config.validate().expect("valid config");
240
241        assert!(tracker.reserve(600, &config).is_ok());
242        let result = tracker.reserve(500, &config);
243        assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
244    }
245
246    #[test]
247    fn test_quota_tracker_exceed_file_size() {
248        let mut tracker = QuotaTracker::new();
249        let mut config = SecurityConfig::default();
250        config.max_file_size = 1000;
251        let config = config.validate().expect("valid config");
252
253        let result = tracker.reserve(2000, &config);
254        assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
255    }
256
257    // H-TEST-4: Quota boundary conditions test
258    #[test]
259    fn test_quota_exactly_at_file_count_limit() {
260        let mut tracker = QuotaTracker::new();
261        let mut config = SecurityConfig::default();
262        config.max_file_count = 3;
263        config.max_total_size = u64::MAX;
264        config.max_file_size = u64::MAX;
265        let config = config.validate().expect("valid config");
266
267        // Exactly at file count limit should succeed
268        assert!(
269            tracker.reserve(100, &config).is_ok(),
270            "file 1 should succeed"
271        );
272        assert!(
273            tracker.reserve(100, &config).is_ok(),
274            "file 2 should succeed"
275        );
276        assert!(
277            tracker.reserve(100, &config).is_ok(),
278            "file 3 should succeed"
279        );
280        assert_eq!(tracker.files_extracted(), 3, "should have 3 files");
281
282        // One more should fail (exceeds limit)
283        let result = tracker.reserve(100, &config);
284        assert_matches!(
285            result,
286            Err(ArchiveError::QuotaExceeded {
287                resource: crate::QuotaResource::FileCount { current: 4, max: 3 }
288            }),
289            "file 4 should exceed quota"
290        );
291    }
292
293    #[test]
294    fn test_quota_exactly_at_total_size_limit() {
295        let mut tracker = QuotaTracker::new();
296        let mut config = SecurityConfig::default();
297        config.max_file_count = 100;
298        config.max_total_size = 1000;
299        config.max_file_size = u64::MAX;
300        let config = config.validate().expect("valid config");
301
302        // Add files up to exactly the limit
303        assert!(tracker.reserve(600, &config).is_ok());
304        assert_eq!(tracker.bytes_written(), 600);
305
306        assert!(tracker.reserve(400, &config).is_ok());
307        assert_eq!(tracker.bytes_written(), 1000, "should be exactly at limit");
308
309        // One more byte should fail
310        let result = tracker.reserve(1, &config);
311        assert_matches!(
312            result,
313            Err(ArchiveError::QuotaExceeded {
314                resource: crate::QuotaResource::TotalSize {
315                    current: 1001,
316                    max: 1000
317                }
318            }),
319            "exceeding total size should fail"
320        );
321    }
322
323    #[test]
324    fn test_quota_exactly_at_file_size_limit() {
325        let mut tracker = QuotaTracker::new();
326        let mut config = SecurityConfig::default();
327        config.max_file_count = 100;
328        config.max_total_size = u64::MAX;
329        config.max_file_size = 5000;
330        let config = config.validate().expect("valid config");
331
332        // File exactly at limit should succeed
333        assert!(
334            tracker.reserve(5000, &config).is_ok(),
335            "file exactly at limit should succeed"
336        );
337
338        // File one byte over should fail
339        let result = tracker.reserve(5001, &config);
340        assert_matches!(
341            result,
342            Err(ArchiveError::QuotaExceeded {
343                resource: crate::QuotaResource::FileSize {
344                    size: 5001,
345                    max: 5000
346                }
347            }),
348            "file exceeding limit should fail"
349        );
350    }
351
352    #[test]
353    fn test_quota_off_by_one_file_count() {
354        let mut tracker = QuotaTracker::new();
355        let mut config = SecurityConfig::default();
356        config.max_file_count = 1;
357        config.max_total_size = u64::MAX;
358        config.max_file_size = u64::MAX;
359        let config = config.validate().expect("valid config");
360
361        // First file should succeed
362        assert!(tracker.reserve(100, &config).is_ok());
363
364        // Second file should fail (max is 1)
365        let result = tracker.reserve(100, &config);
366        assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
367    }
368
369    // OPT-C003: Test fast path for unlimited quotas
370    #[test]
371    fn test_quota_fast_path_unlimited() {
372        let mut tracker = QuotaTracker::new();
373        let mut config = SecurityConfig::default();
374        // Set all quotas to unlimited (MAX values)
375        config.max_file_size = u64::MAX;
376        config.max_file_count = usize::MAX;
377        config.max_total_size = u64::MAX;
378        let config = config.validate().expect("valid config");
379
380        for i in 1..=1000 {
381            assert!(
382                tracker.reserve(1000, &config).is_ok(),
383                "file {i} should succeed with unlimited quotas"
384            );
385        }
386
387        assert_eq!(tracker.files_extracted(), 1000);
388        assert_eq!(tracker.bytes_written(), 1_000_000);
389    }
390
391    // OPT-C003: Verify fast path still catches overflow
392    #[test]
393    fn test_quota_fast_path_overflow_detection() {
394        let mut tracker = QuotaTracker::new();
395        let mut config = SecurityConfig::default();
396        config.max_file_size = u64::MAX;
397        config.max_file_count = usize::MAX;
398        config.max_total_size = u64::MAX;
399        let config = config.validate().expect("valid config");
400
401        // Manually set bytes_written to near overflow
402        tracker.bytes_written = u64::MAX - 100;
403
404        // Adding 200 bytes should trigger overflow detection
405        let result = tracker.reserve(200, &config);
406        assert_matches!(
407            result,
408            Err(ArchiveError::QuotaExceeded {
409                resource: crate::QuotaResource::IntegerOverflow
410            }),
411            "fast path should still detect overflow"
412        );
413    }
414}