exarch_core/security/
quota.rs1use std::marker::PhantomData;
4
5use crate::ArchiveError;
6use crate::Result;
7use crate::SecurityConfig;
8use crate::config::Validated;
9
10#[derive(Debug)]
70#[must_use]
71pub struct QuotaPermit(PhantomData<()>);
72
73#[derive(Debug, Default)]
75pub struct QuotaTracker {
76 files_extracted: usize,
77 bytes_written: u64,
78}
79
80impl QuotaTracker {
81 #[must_use]
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 #[inline]
100 pub fn reserve(
101 &mut self,
102 size: u64,
103 config: &SecurityConfig<Validated>,
104 ) -> Result<QuotaPermit> {
105 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 #[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 #[must_use]
187 pub fn files_extracted(&self) -> usize {
188 self.files_extracted
189 }
190
191 #[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 #[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 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 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 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 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 assert!(
334 tracker.reserve(5000, &config).is_ok(),
335 "file exactly at limit should succeed"
336 );
337
338 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 assert!(tracker.reserve(100, &config).is_ok());
363
364 let result = tracker.reserve(100, &config);
366 assert_matches!(result, Err(ArchiveError::QuotaExceeded { .. }));
367 }
368
369 #[test]
371 fn test_quota_fast_path_unlimited() {
372 let mut tracker = QuotaTracker::new();
373 let mut config = SecurityConfig::default();
374 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 #[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 tracker.bytes_written = u64::MAX - 100;
403
404 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}