exarch_core/security/
quota.rs

1//! Extraction quota tracking and validation.
2
3use crate::ExtractionError;
4use crate::Result;
5use crate::SecurityConfig;
6
7/// Tracks resource usage during extraction.
8#[derive(Debug, Default)]
9pub struct QuotaTracker {
10    files_extracted: usize,
11    bytes_written: u64,
12}
13
14impl QuotaTracker {
15    /// Creates a new quota tracker.
16    #[must_use]
17    pub fn new() -> Self {
18        Self::default()
19    }
20
21    /// Records a file extraction.
22    ///
23    /// # Errors
24    ///
25    /// Returns an error if quotas are exceeded or integer overflow is detected.
26    ///
27    /// # Performance
28    ///
29    /// OPT-C003: Fast path for unlimited quotas reduces overhead by 3-5%.
30    /// When all quotas are set to maximum values (unlimited), the function
31    /// skips quota checks and only tracks counters with overflow detection.
32    #[inline]
33    pub fn record_file(&mut self, size: u64, config: &SecurityConfig) -> Result<()> {
34        // OPT-C003: Fast path when all quotas unlimited - skip checks, only detect
35        // overflow
36        if config.max_file_size == u64::MAX
37            && config.max_file_count == usize::MAX
38            && config.max_total_size == u64::MAX
39        {
40            self.files_extracted =
41                self.files_extracted
42                    .checked_add(1)
43                    .ok_or(ExtractionError::QuotaExceeded {
44                        resource: crate::QuotaResource::IntegerOverflow,
45                    })?;
46
47            self.bytes_written =
48                self.bytes_written
49                    .checked_add(size)
50                    .ok_or(ExtractionError::QuotaExceeded {
51                        resource: crate::QuotaResource::IntegerOverflow,
52                    })?;
53
54            return Ok(());
55        }
56
57        self.record_file_checked(size, config)
58    }
59
60    /// Internal implementation with full quota validation.
61    ///
62    /// This is the slow path called when quotas are actually enforced.
63    /// Separated from the fast path to keep the hot path small and inlinable.
64    #[inline(never)]
65    fn record_file_checked(&mut self, size: u64, config: &SecurityConfig) -> Result<()> {
66        if size > config.max_file_size {
67            return Err(ExtractionError::QuotaExceeded {
68                resource: crate::QuotaResource::FileSize {
69                    size,
70                    max: config.max_file_size,
71                },
72            });
73        }
74
75        self.files_extracted =
76            self.files_extracted
77                .checked_add(1)
78                .ok_or(ExtractionError::QuotaExceeded {
79                    resource: crate::QuotaResource::IntegerOverflow,
80                })?;
81
82        self.bytes_written =
83            self.bytes_written
84                .checked_add(size)
85                .ok_or(ExtractionError::QuotaExceeded {
86                    resource: crate::QuotaResource::IntegerOverflow,
87                })?;
88
89        if self.files_extracted > config.max_file_count {
90            return Err(ExtractionError::QuotaExceeded {
91                resource: crate::QuotaResource::FileCount {
92                    current: self.files_extracted,
93                    max: config.max_file_count,
94                },
95            });
96        }
97
98        if self.bytes_written > config.max_total_size {
99            return Err(ExtractionError::QuotaExceeded {
100                resource: crate::QuotaResource::TotalSize {
101                    current: self.bytes_written,
102                    max: config.max_total_size,
103                },
104            });
105        }
106
107        Ok(())
108    }
109
110    /// Returns the number of files extracted.
111    #[must_use]
112    pub fn files_extracted(&self) -> usize {
113        self.files_extracted
114    }
115
116    /// Returns the total bytes written.
117    #[must_use]
118    pub fn bytes_written(&self) -> u64 {
119        self.bytes_written
120    }
121}
122
123#[cfg(test)]
124#[allow(clippy::field_reassign_with_default)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_quota_tracker_new() {
130        let tracker = QuotaTracker::new();
131        assert_eq!(tracker.files_extracted(), 0);
132        assert_eq!(tracker.bytes_written(), 0);
133    }
134
135    #[test]
136    fn test_quota_tracker_record_file() {
137        let mut tracker = QuotaTracker::new();
138        let config = SecurityConfig::default();
139
140        assert!(tracker.record_file(1000, &config).is_ok());
141        assert_eq!(tracker.files_extracted(), 1);
142        assert_eq!(tracker.bytes_written(), 1000);
143    }
144
145    #[test]
146    fn test_quota_tracker_exceed_file_count() {
147        let mut tracker = QuotaTracker::new();
148        let mut config = SecurityConfig::default();
149        config.max_file_count = 2;
150
151        assert!(tracker.record_file(100, &config).is_ok());
152        assert!(tracker.record_file(100, &config).is_ok());
153        let result = tracker.record_file(100, &config);
154        assert!(matches!(result, Err(ExtractionError::QuotaExceeded { .. })));
155    }
156
157    #[test]
158    fn test_quota_tracker_exceed_total_size() {
159        let mut tracker = QuotaTracker::new();
160        let mut config = SecurityConfig::default();
161        config.max_total_size = 1000;
162
163        assert!(tracker.record_file(600, &config).is_ok());
164        let result = tracker.record_file(500, &config);
165        assert!(matches!(result, Err(ExtractionError::QuotaExceeded { .. })));
166    }
167
168    #[test]
169    fn test_quota_tracker_exceed_file_size() {
170        let mut tracker = QuotaTracker::new();
171        let mut config = SecurityConfig::default();
172        config.max_file_size = 1000;
173
174        let result = tracker.record_file(2000, &config);
175        assert!(matches!(result, Err(ExtractionError::QuotaExceeded { .. })));
176    }
177
178    // H-TEST-4: Quota boundary conditions test
179    #[test]
180    fn test_quota_exactly_at_file_count_limit() {
181        let mut tracker = QuotaTracker::new();
182        let mut config = SecurityConfig::default();
183        config.max_file_count = 3;
184        config.max_total_size = u64::MAX;
185        config.max_file_size = u64::MAX;
186
187        // Exactly at file count limit should succeed
188        assert!(
189            tracker.record_file(100, &config).is_ok(),
190            "file 1 should succeed"
191        );
192        assert!(
193            tracker.record_file(100, &config).is_ok(),
194            "file 2 should succeed"
195        );
196        assert!(
197            tracker.record_file(100, &config).is_ok(),
198            "file 3 should succeed"
199        );
200        assert_eq!(tracker.files_extracted(), 3, "should have 3 files");
201
202        // One more should fail (exceeds limit)
203        let result = tracker.record_file(100, &config);
204        assert!(
205            matches!(
206                result,
207                Err(ExtractionError::QuotaExceeded {
208                    resource: crate::QuotaResource::FileCount { current: 4, max: 3 }
209                })
210            ),
211            "file 4 should exceed quota"
212        );
213    }
214
215    #[test]
216    fn test_quota_exactly_at_total_size_limit() {
217        let mut tracker = QuotaTracker::new();
218        let mut config = SecurityConfig::default();
219        config.max_file_count = 100;
220        config.max_total_size = 1000;
221        config.max_file_size = u64::MAX;
222
223        // Add files up to exactly the limit
224        assert!(tracker.record_file(600, &config).is_ok());
225        assert_eq!(tracker.bytes_written(), 600);
226
227        assert!(tracker.record_file(400, &config).is_ok());
228        assert_eq!(tracker.bytes_written(), 1000, "should be exactly at limit");
229
230        // One more byte should fail
231        let result = tracker.record_file(1, &config);
232        assert!(
233            matches!(
234                result,
235                Err(ExtractionError::QuotaExceeded {
236                    resource: crate::QuotaResource::TotalSize {
237                        current: 1001,
238                        max: 1000
239                    }
240                })
241            ),
242            "exceeding total size should fail"
243        );
244    }
245
246    #[test]
247    fn test_quota_exactly_at_file_size_limit() {
248        let mut tracker = QuotaTracker::new();
249        let mut config = SecurityConfig::default();
250        config.max_file_count = 100;
251        config.max_total_size = u64::MAX;
252        config.max_file_size = 5000;
253
254        // File exactly at limit should succeed
255        assert!(
256            tracker.record_file(5000, &config).is_ok(),
257            "file exactly at limit should succeed"
258        );
259
260        // File one byte over should fail
261        let result = tracker.record_file(5001, &config);
262        assert!(
263            matches!(
264                result,
265                Err(ExtractionError::QuotaExceeded {
266                    resource: crate::QuotaResource::FileSize {
267                        size: 5001,
268                        max: 5000
269                    }
270                })
271            ),
272            "file exceeding limit should fail"
273        );
274    }
275
276    #[test]
277    fn test_quota_off_by_one_file_count() {
278        let mut tracker = QuotaTracker::new();
279        let mut config = SecurityConfig::default();
280        config.max_file_count = 1;
281        config.max_total_size = u64::MAX;
282        config.max_file_size = u64::MAX;
283
284        // First file should succeed
285        assert!(tracker.record_file(100, &config).is_ok());
286
287        // Second file should fail (max is 1)
288        let result = tracker.record_file(100, &config);
289        assert!(matches!(result, Err(ExtractionError::QuotaExceeded { .. })));
290    }
291
292    // OPT-C003: Test fast path for unlimited quotas
293    #[test]
294    fn test_quota_fast_path_unlimited() {
295        let mut tracker = QuotaTracker::new();
296        let mut config = SecurityConfig::default();
297        // Set all quotas to unlimited (MAX values)
298        config.max_file_size = u64::MAX;
299        config.max_file_count = usize::MAX;
300        config.max_total_size = u64::MAX;
301
302        for i in 1..=1000 {
303            assert!(
304                tracker.record_file(1000, &config).is_ok(),
305                "file {i} should succeed with unlimited quotas"
306            );
307        }
308
309        assert_eq!(tracker.files_extracted(), 1000);
310        assert_eq!(tracker.bytes_written(), 1_000_000);
311    }
312
313    // OPT-C003: Verify fast path still catches overflow
314    #[test]
315    fn test_quota_fast_path_overflow_detection() {
316        let mut tracker = QuotaTracker::new();
317        let mut config = SecurityConfig::default();
318        config.max_file_size = u64::MAX;
319        config.max_file_count = usize::MAX;
320        config.max_total_size = u64::MAX;
321
322        // Manually set bytes_written to near overflow
323        tracker.bytes_written = u64::MAX - 100;
324
325        // Adding 200 bytes should trigger overflow detection
326        let result = tracker.record_file(200, &config);
327        assert!(
328            matches!(
329                result,
330                Err(ExtractionError::QuotaExceeded {
331                    resource: crate::QuotaResource::IntegerOverflow
332                })
333            ),
334            "fast path should still detect overflow"
335        );
336    }
337}