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 producers are [`QuotaTracker::reserve`] and
16/// the crate-private `QuotaTracker::charge` — the private field means no
17/// other code, in or out of this crate, can assemble one directly.
18/// `EntryValidator` exposes three ways to obtain one:
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 `charge_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/// after [`EntryCount`] counted them in the first) and 7z's duplicate-skip
25/// check (quota must not be reserved for an entry that turns out to be a
26/// skipped duplicate), respectively.
27///
28/// Deliberately not `Clone`/`Copy`/`Default`: a permit represents a single
29/// reservation and must not be duplicated or spent more than once. Zero-sized
30/// (`PhantomData`-based), so wrapping it in `Result` costs nothing over
31/// `Result<()>`.
32///
33/// [`ValidatedEntryType::File`]: crate::security::validator::ValidatedEntryType::File
34/// [`EntryValidator::validate_entry`]: crate::security::validator::EntryValidator::validate_entry
35///
36/// # Examples
37///
38/// The common case: a `QuotaPermit` arrives already embedded in a validated
39/// file entry, via [`EntryValidator::validate_entry`]:
40///
41/// ```no_run
42/// use exarch_core::SecurityConfig;
43/// use exarch_core::security::EntryValidator;
44/// use exarch_core::security::ValidatedEntryType;
45/// use exarch_core::types::DestDir;
46/// use exarch_core::types::EntryType;
47/// use std::path::Path;
48/// use std::path::PathBuf;
49///
50/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
51/// let dest = DestDir::new(PathBuf::from("/tmp"))?;
52/// let config = SecurityConfig::default().validate()?;
53/// let mut validator = EntryValidator::new(&config, &dest);
54///
55/// let entry = validator.validate_entry(
56///     Path::new("file.txt"),
57///     &EntryType::File,
58///     1024, // uncompressed size
59///     None, // compressed size
60///     None, // mode
61///     None, // dir_cache
62/// )?;
63///
64/// if let ValidatedEntryType::File(permit) = entry.entry_type() {
65///     println!("{permit:?}");
66/// }
67/// # Ok(())
68/// # }
69/// ```
70#[derive(Debug)]
71#[must_use]
72pub struct QuotaPermit(PhantomData<()>);
73
74/// Proof that one entry was counted against `max_file_count` while its bytes
75/// are not yet charged.
76///
77/// A hardlink's size is only known in the second extraction pass, but the
78/// number of entries must be bounded in the first, before anything is
79/// buffered or copied. The first pass takes an `EntryCount`; the second spends
80/// it in `QuotaTracker::charge`, which adds the bytes without counting the
81/// entry again. Holding one therefore makes counting an entry twice, or never,
82/// unrepresentable.
83///
84/// Deliberately not `Clone`/`Copy`/`Default`, and only producible by
85/// `QuotaTracker::reserve_count`. Zero-sized.
86///
87/// # Examples
88///
89/// A validated hardlink carries its `EntryCount`:
90///
91/// ```no_run
92/// use exarch_core::SecurityConfig;
93/// use exarch_core::security::EntryValidator;
94/// use exarch_core::security::ValidatedEntryType;
95/// use exarch_core::types::DestDir;
96/// use exarch_core::types::EntryType;
97/// use std::path::Path;
98/// use std::path::PathBuf;
99///
100/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
101/// let dest = DestDir::new(PathBuf::from("/tmp"))?;
102/// let config = SecurityConfig::default()
103///     .with_allow_hardlinks(true)
104///     .validate()?;
105/// let mut validator = EntryValidator::new(&config, &dest);
106///
107/// let entry = validator.validate_entry(
108///     Path::new("link"),
109///     &EntryType::Hardlink {
110///         target: PathBuf::from("target.txt"),
111///     },
112///     0,
113///     None,
114///     None,
115///     None,
116/// )?;
117///
118/// if let ValidatedEntryType::Hardlink { counted, .. } = entry.entry_type() {
119///     println!("{counted:?}");
120/// }
121/// # Ok(())
122/// # }
123/// ```
124#[derive(Debug)]
125#[must_use]
126pub struct EntryCount(PhantomData<()>);
127
128/// Tracks resource usage during extraction.
129#[derive(Debug, Default)]
130pub struct QuotaTracker {
131    files_extracted: usize,
132    bytes_written: u64,
133}
134
135fn check_file_size(size: u64, max: u64) -> Result<()> {
136    if size > max {
137        core::hint::cold_path();
138        return Err(ArchiveError::QuotaExceeded {
139            resource: crate::QuotaResource::FileSize { size, max },
140        });
141    }
142    Ok(())
143}
144
145impl QuotaTracker {
146    /// Creates a new quota tracker.
147    #[must_use]
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Reserves quota capacity for a file extraction, returning a capability
153    /// token that proves the reservation succeeded.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if quotas are exceeded or integer overflow is detected.
158    ///
159    /// # Performance
160    ///
161    /// OPT-C003: Fast path for unlimited quotas reduces overhead by 3-5%.
162    /// When all quotas are set to maximum values (unlimited), the function
163    /// skips quota checks and only tracks counters with overflow detection.
164    #[inline]
165    pub fn reserve(
166        &mut self,
167        size: u64,
168        config: &SecurityConfig<Validated>,
169    ) -> Result<QuotaPermit> {
170        self.reserve_within(size, config.max_file_size, config)
171    }
172
173    /// Counts one entry against `max_file_count` without charging any bytes,
174    /// returning the token [`charge`](Self::charge) later spends.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`ArchiveError::QuotaExceeded`] if the file count limit is
179    /// exceeded or the counter overflows.
180    pub(crate) fn reserve_count(
181        &mut self,
182        config: &SecurityConfig<Validated>,
183    ) -> Result<EntryCount> {
184        self.bump_files()?;
185        self.check_file_count(config)?;
186
187        Ok(EntryCount(PhantomData))
188    }
189
190    /// Charges `size` bytes for an entry already counted by
191    /// [`reserve_count`](Self::reserve_count), without counting it again.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`ArchiveError::QuotaExceeded`] if `size` exceeds
196    /// `max_file_size`, the running total exceeds `max_total_size`, or the
197    /// byte counter overflows.
198    pub(crate) fn charge(
199        &mut self,
200        count: EntryCount,
201        size: u64,
202        config: &SecurityConfig<Validated>,
203    ) -> Result<QuotaPermit> {
204        drop(count);
205
206        check_file_size(size, config.max_file_size)?;
207        self.add_bytes(size)?;
208        self.check_total_size(config)?;
209
210        Ok(QuotaPermit(PhantomData))
211    }
212
213    /// Reserves quota capacity without enforcing the per-file size limit.
214    ///
215    /// For `verify`'s listing pre-flight only, which must see oversized
216    /// entries so `verify_entry` can report them. The file count and total
217    /// size limits still apply; extraction must use [`reserve`](Self::reserve).
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the file count or total size quota is exceeded or
222    /// integer overflow is detected.
223    pub(crate) fn reserve_without_file_limit(
224        &mut self,
225        size: u64,
226        config: &SecurityConfig<Validated>,
227    ) -> Result<QuotaPermit> {
228        self.reserve_within(size, u64::MAX, config)
229    }
230
231    #[inline]
232    fn reserve_within(
233        &mut self,
234        size: u64,
235        max_file_size: u64,
236        config: &SecurityConfig<Validated>,
237    ) -> Result<QuotaPermit> {
238        // OPT-C003: Fast path when all quotas unlimited - skip checks, only
239        // detect overflow
240        if max_file_size == u64::MAX
241            && config.max_file_count == usize::MAX
242            && config.max_total_size == u64::MAX
243        {
244            self.files_extracted = self.files_extracted.checked_add(1).ok_or_else(|| {
245                core::hint::cold_path();
246                ArchiveError::QuotaExceeded {
247                    resource: crate::QuotaResource::IntegerOverflow,
248                }
249            })?;
250
251            self.bytes_written = self.bytes_written.checked_add(size).ok_or_else(|| {
252                core::hint::cold_path();
253                ArchiveError::QuotaExceeded {
254                    resource: crate::QuotaResource::IntegerOverflow,
255                }
256            })?;
257
258            return Ok(QuotaPermit(PhantomData));
259        }
260
261        self.record_file_checked(size, max_file_size, config)?;
262        Ok(QuotaPermit(PhantomData))
263    }
264
265    /// Internal implementation with full quota validation.
266    ///
267    /// This is the slow path called when quotas are actually enforced.
268    /// Separated from the fast path to keep the hot path small and inlinable.
269    #[inline(never)]
270    fn record_file_checked(
271        &mut self,
272        size: u64,
273        max_file_size: u64,
274        config: &SecurityConfig<Validated>,
275    ) -> Result<()> {
276        check_file_size(size, max_file_size)?;
277        self.bump_files()?;
278        self.add_bytes(size)?;
279        self.check_file_count(config)?;
280        self.check_total_size(config)
281    }
282
283    fn bump_files(&mut self) -> Result<()> {
284        self.files_extracted = self.files_extracted.checked_add(1).ok_or_else(|| {
285            core::hint::cold_path();
286            ArchiveError::QuotaExceeded {
287                resource: crate::QuotaResource::IntegerOverflow,
288            }
289        })?;
290        Ok(())
291    }
292
293    fn add_bytes(&mut self, size: u64) -> Result<()> {
294        self.bytes_written = self.bytes_written.checked_add(size).ok_or_else(|| {
295            core::hint::cold_path();
296            ArchiveError::QuotaExceeded {
297                resource: crate::QuotaResource::IntegerOverflow,
298            }
299        })?;
300        Ok(())
301    }
302
303    fn check_file_count(&self, config: &SecurityConfig<Validated>) -> Result<()> {
304        if self.files_extracted > config.max_file_count {
305            core::hint::cold_path();
306            return Err(ArchiveError::QuotaExceeded {
307                resource: crate::QuotaResource::FileCount {
308                    current: self.files_extracted,
309                    max: config.max_file_count,
310                },
311            });
312        }
313        Ok(())
314    }
315
316    fn check_total_size(&self, config: &SecurityConfig<Validated>) -> Result<()> {
317        if self.bytes_written > config.max_total_size {
318            core::hint::cold_path();
319            return Err(ArchiveError::QuotaExceeded {
320                resource: crate::QuotaResource::TotalSize {
321                    current: self.bytes_written,
322                    max: config.max_total_size,
323                },
324            });
325        }
326        Ok(())
327    }
328
329    /// Returns the number of files extracted.
330    #[must_use]
331    pub fn files_extracted(&self) -> usize {
332        self.files_extracted
333    }
334
335    /// Returns the total bytes written.
336    #[must_use]
337    pub fn bytes_written(&self) -> u64 {
338        self.bytes_written
339    }
340}
341
342#[cfg(test)]
343#[allow(clippy::field_reassign_with_default, clippy::expect_used)]
344mod tests {
345    use super::*;
346    use std::assert_matches;
347
348    #[test]
349    fn test_quota_tracker_new() {
350        let tracker = QuotaTracker::new();
351        assert_eq!(tracker.files_extracted(), 0);
352        assert_eq!(tracker.bytes_written(), 0);
353    }
354
355    #[test]
356    fn test_quota_tracker_reserve() {
357        let mut tracker = QuotaTracker::new();
358        let config = SecurityConfig::default().validate().expect("valid config");
359
360        assert!(tracker.reserve(1000, &config).is_ok());
361        assert_eq!(tracker.files_extracted(), 1);
362        assert_eq!(tracker.bytes_written(), 1000);
363    }
364
365    #[test]
366    fn test_quota_tracker_exceed_file_count() {
367        let mut tracker = QuotaTracker::new();
368        let mut config = SecurityConfig::default();
369        config.max_file_count = 2;
370        let config = config.validate().expect("valid config");
371
372        assert!(tracker.reserve(100, &config).is_ok());
373        assert!(tracker.reserve(100, &config).is_ok());
374        let result = tracker.reserve(100, &config);
375        assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
376    }
377
378    #[test]
379    fn test_quota_tracker_exceed_total_size() {
380        let mut tracker = QuotaTracker::new();
381        let mut config = SecurityConfig::default();
382        config.max_total_size = 1000;
383        let config = config.validate().expect("valid config");
384
385        assert!(tracker.reserve(600, &config).is_ok());
386        let result = tracker.reserve(500, &config);
387        assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
388    }
389
390    #[test]
391    fn test_quota_tracker_exceed_file_size() {
392        let mut tracker = QuotaTracker::new();
393        let mut config = SecurityConfig::default();
394        config.max_file_size = 1000;
395        let config = config.validate().expect("valid config");
396
397        let result = tracker.reserve(2000, &config);
398        assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
399    }
400
401    // H-TEST-4: Quota boundary conditions test
402    #[test]
403    fn test_quota_exactly_at_file_count_limit() {
404        let mut tracker = QuotaTracker::new();
405        let mut config = SecurityConfig::default();
406        config.max_file_count = 3;
407        config.max_total_size = u64::MAX;
408        config.max_file_size = u64::MAX;
409        let config = config.validate().expect("valid config");
410
411        // Exactly at file count limit should succeed
412        assert!(
413            tracker.reserve(100, &config).is_ok(),
414            "file 1 should succeed"
415        );
416        assert!(
417            tracker.reserve(100, &config).is_ok(),
418            "file 2 should succeed"
419        );
420        assert!(
421            tracker.reserve(100, &config).is_ok(),
422            "file 3 should succeed"
423        );
424        assert_eq!(tracker.files_extracted(), 3, "should have 3 files");
425
426        // One more should fail (exceeds limit)
427        let result = tracker.reserve(100, &config);
428        assert_matches!(
429            result,
430            Err(ArchiveError::QuotaExceeded {
431                resource: crate::QuotaResource::FileCount { current: 4, max: 3 }
432            }),
433            "file 4 should exceed quota"
434        );
435    }
436
437    #[test]
438    fn test_quota_exactly_at_total_size_limit() {
439        let mut tracker = QuotaTracker::new();
440        let mut config = SecurityConfig::default();
441        config.max_file_count = 100;
442        config.max_total_size = 1000;
443        config.max_file_size = u64::MAX;
444        let config = config.validate().expect("valid config");
445
446        // Add files up to exactly the limit
447        assert!(tracker.reserve(600, &config).is_ok());
448        assert_eq!(tracker.bytes_written(), 600);
449
450        assert!(tracker.reserve(400, &config).is_ok());
451        assert_eq!(tracker.bytes_written(), 1000, "should be exactly at limit");
452
453        // One more byte should fail
454        let result = tracker.reserve(1, &config);
455        assert_matches!(
456            result,
457            Err(ArchiveError::QuotaExceeded {
458                resource: crate::QuotaResource::TotalSize {
459                    current: 1001,
460                    max: 1000
461                }
462            }),
463            "exceeding total size should fail"
464        );
465    }
466
467    #[test]
468    fn test_quota_exactly_at_file_size_limit() {
469        let mut tracker = QuotaTracker::new();
470        let mut config = SecurityConfig::default();
471        config.max_file_count = 100;
472        config.max_total_size = u64::MAX;
473        config.max_file_size = 5000;
474        let config = config.validate().expect("valid config");
475
476        // File exactly at limit should succeed
477        assert!(
478            tracker.reserve(5000, &config).is_ok(),
479            "file exactly at limit should succeed"
480        );
481
482        // File one byte over should fail
483        let result = tracker.reserve(5001, &config);
484        assert_matches!(
485            result,
486            Err(ArchiveError::QuotaExceeded {
487                resource: crate::QuotaResource::FileSize {
488                    size: 5001,
489                    max: 5000
490                }
491            }),
492            "file exceeding limit should fail"
493        );
494    }
495
496    #[test]
497    fn test_quota_off_by_one_file_count() {
498        let mut tracker = QuotaTracker::new();
499        let mut config = SecurityConfig::default();
500        config.max_file_count = 1;
501        config.max_total_size = u64::MAX;
502        config.max_file_size = u64::MAX;
503        let config = config.validate().expect("valid config");
504
505        // First file should succeed
506        assert!(tracker.reserve(100, &config).is_ok());
507
508        // Second file should fail (max is 1)
509        let result = tracker.reserve(100, &config);
510        assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
511    }
512
513    // OPT-C003: Test fast path for unlimited quotas
514    #[test]
515    fn test_quota_fast_path_unlimited() {
516        let mut tracker = QuotaTracker::new();
517        let mut config = SecurityConfig::default();
518        // Set all quotas to unlimited (MAX values)
519        config.max_file_size = u64::MAX;
520        config.max_file_count = usize::MAX;
521        config.max_total_size = u64::MAX;
522        let config = config.validate().expect("valid config");
523
524        for i in 1..=1000 {
525            assert!(
526                tracker.reserve(1000, &config).is_ok(),
527                "file {i} should succeed with unlimited quotas"
528            );
529        }
530
531        assert_eq!(tracker.files_extracted(), 1000);
532        assert_eq!(tracker.bytes_written(), 1_000_000);
533    }
534
535    // OPT-C003: Verify fast path still catches overflow
536    #[test]
537    fn test_quota_fast_path_overflow_detection() {
538        let mut tracker = QuotaTracker::new();
539        let mut config = SecurityConfig::default();
540        config.max_file_size = u64::MAX;
541        config.max_file_count = usize::MAX;
542        config.max_total_size = u64::MAX;
543        let config = config.validate().expect("valid config");
544
545        // Manually set bytes_written to near overflow
546        tracker.bytes_written = u64::MAX - 100;
547
548        // Adding 200 bytes should trigger overflow detection
549        let result = tracker.reserve(200, &config);
550        assert_matches!(
551            result,
552            Err(ArchiveError::QuotaExceeded {
553                resource: crate::QuotaResource::IntegerOverflow
554            }),
555            "fast path should still detect overflow"
556        );
557    }
558
559    #[test]
560    fn test_reserve_without_file_limit_skips_only_file_size() {
561        let mut tracker = QuotaTracker::new();
562        let mut config = SecurityConfig::default();
563        config.max_file_size = 10;
564        config.max_file_count = 2;
565        config.max_total_size = 1000;
566        let config = config.validate().expect("valid config");
567
568        assert_matches!(
569            tracker.reserve(500, &config),
570            Err(ArchiveError::QuotaExceeded {
571                resource: crate::QuotaResource::FileSize { size: 500, max: 10 }
572            })
573        );
574        assert!(tracker.reserve_without_file_limit(500, &config).is_ok());
575        assert_matches!(
576            tracker.reserve_without_file_limit(501, &config),
577            Err(ArchiveError::QuotaExceeded {
578                resource: crate::QuotaResource::TotalSize {
579                    current: 1001,
580                    max: 1000
581                }
582            })
583        );
584    }
585
586    #[test]
587    fn test_reserve_without_file_limit_enforces_file_count() {
588        let mut tracker = QuotaTracker::new();
589        let mut config = SecurityConfig::default();
590        config.max_file_size = 10;
591        config.max_file_count = 1;
592        let config = config.validate().expect("valid config");
593
594        assert!(tracker.reserve_without_file_limit(500, &config).is_ok());
595        assert_matches!(
596            tracker.reserve_without_file_limit(500, &config),
597            Err(ArchiveError::QuotaExceeded {
598                resource: crate::QuotaResource::FileCount { current: 2, max: 1 }
599            })
600        );
601    }
602
603    #[test]
604    fn test_reserve_without_file_limit_fast_path_accepts_oversized_file() {
605        let mut tracker = QuotaTracker::new();
606        let mut config = SecurityConfig::default();
607        config.max_file_size = 10;
608        config.max_file_count = usize::MAX;
609        config.max_total_size = u64::MAX;
610        let config = config.validate().expect("valid config");
611
612        assert!(tracker.reserve(500, &config).is_err());
613        assert!(tracker.reserve_without_file_limit(500, &config).is_ok());
614        assert_eq!(tracker.files_extracted(), 1);
615        assert_eq!(tracker.bytes_written(), 500);
616    }
617
618    #[test]
619    fn test_reserve_without_file_limit_fast_path_detects_overflow() {
620        let mut tracker = QuotaTracker::new();
621        let mut config = SecurityConfig::default();
622        config.max_file_size = 10;
623        config.max_file_count = usize::MAX;
624        config.max_total_size = u64::MAX;
625        let config = config.validate().expect("valid config");
626
627        tracker.bytes_written = u64::MAX - 100;
628        assert_matches!(
629            tracker.reserve_without_file_limit(200, &config),
630            Err(ArchiveError::QuotaExceeded {
631                resource: crate::QuotaResource::IntegerOverflow
632            })
633        );
634    }
635
636    #[test]
637    fn test_charge_adds_bytes_without_counting_again() {
638        let mut tracker = QuotaTracker::new();
639        let config = SecurityConfig::default().validate().expect("valid config");
640
641        let count = tracker.reserve_count(&config).expect("count");
642        assert_eq!(tracker.files_extracted(), 1);
643        assert_eq!(tracker.bytes_written(), 0);
644
645        drop(tracker.charge(count, 40, &config).expect("charge"));
646        assert_eq!(tracker.files_extracted(), 1);
647        assert_eq!(tracker.bytes_written(), 40);
648    }
649
650    #[test]
651    fn test_reserve_count_exceeds_file_count() {
652        let mut tracker = QuotaTracker::new();
653        let mut config = SecurityConfig::default();
654        config.max_file_count = 1;
655        let config = config.validate().expect("valid config");
656
657        let _first = tracker.reserve_count(&config).expect("first");
658        assert_matches!(
659            tracker.reserve_count(&config),
660            Err(ArchiveError::QuotaExceeded {
661                resource: crate::QuotaResource::FileCount { current: 2, max: 1 }
662            })
663        );
664    }
665
666    #[test]
667    fn test_charge_enforces_file_and_total_size() {
668        let mut tracker = QuotaTracker::new();
669        let mut config = SecurityConfig::default();
670        config.max_file_size = 100;
671        config.max_total_size = 150;
672        let config = config.validate().expect("valid config");
673
674        let a = tracker.reserve_count(&config).expect("a");
675        let b = tracker.reserve_count(&config).expect("b");
676        let c = tracker.reserve_count(&config).expect("c");
677
678        assert_matches!(
679            tracker.charge(a, 101, &config),
680            Err(ArchiveError::QuotaExceeded {
681                resource: crate::QuotaResource::FileSize { .. }
682            })
683        );
684        drop(tracker.charge(b, 100, &config).expect("within budget"));
685        assert_matches!(
686            tracker.charge(c, 100, &config),
687            Err(ArchiveError::QuotaExceeded {
688                resource: crate::QuotaResource::TotalSize { .. }
689            })
690        );
691    }
692}