1use std::marker::PhantomData;
4
5use crate::ArchiveError;
6use crate::Result;
7use crate::SecurityConfig;
8use crate::config::Validated;
9
10#[derive(Debug)]
71#[must_use]
72pub struct QuotaPermit(PhantomData<()>);
73
74#[derive(Debug)]
125#[must_use]
126pub struct EntryCount(PhantomData<()>);
127
128#[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 #[must_use]
148 pub fn new() -> Self {
149 Self::default()
150 }
151
152 #[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 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 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 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 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 #[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 #[must_use]
331 pub fn files_extracted(&self) -> usize {
332 self.files_extracted
333 }
334
335 #[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 #[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 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 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 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 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 assert!(
478 tracker.reserve(5000, &config).is_ok(),
479 "file exactly at limit should succeed"
480 );
481
482 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 assert!(tracker.reserve(100, &config).is_ok());
507
508 let result = tracker.reserve(100, &config);
510 assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
511 }
512
513 #[test]
515 fn test_quota_fast_path_unlimited() {
516 let mut tracker = QuotaTracker::new();
517 let mut config = SecurityConfig::default();
518 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 #[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 tracker.bytes_written = u64::MAX - 100;
547
548 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}