1use std::path::PathBuf;
4use thiserror::Error;
5
6pub type Result<T> = std::result::Result<T, ExtractionError>;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum QuotaResource {
12 FileCount {
14 current: usize,
16 max: usize,
18 },
19 TotalSize {
21 current: u64,
23 max: u64,
25 },
26 FileSize {
28 size: u64,
30 max: u64,
32 },
33 IntegerOverflow,
35}
36
37impl std::fmt::Display for QuotaResource {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Self::FileCount { current, max } => {
41 write!(f, "quota exceeded: file count ({current} > {max})")
42 }
43 Self::TotalSize { current, max } => {
44 write!(f, "quota exceeded: total size ({current} > {max})")
45 }
46 Self::FileSize { size, max } => {
47 write!(f, "quota exceeded: single file size ({size} > {max})")
48 }
49 Self::IntegerOverflow => {
50 write!(f, "quota exceeded: integer overflow in quota tracking")
51 }
52 }
53 }
54}
55
56#[derive(Error, Debug)]
58pub enum ExtractionError {
59 #[error("I/O error: {0}")]
61 Io(#[from] std::io::Error),
62
63 #[error("unsupported archive format")]
65 UnsupportedFormat,
66
67 #[error("invalid archive: {0}")]
69 InvalidArchive(String),
70
71 #[error("path traversal detected: {path}")]
73 PathTraversal {
74 path: PathBuf,
76 },
77
78 #[error("symlink target outside extraction directory: {path}")]
80 SymlinkEscape {
81 path: PathBuf,
83 },
84
85 #[error("hardlink target outside extraction directory: {path}")]
87 HardlinkEscape {
88 path: PathBuf,
90 },
91
92 #[error(
94 "potential zip bomb: compressed={compressed} bytes, uncompressed={uncompressed} bytes (ratio: {ratio:.2})"
95 )]
96 ZipBomb {
97 compressed: u64,
99 uncompressed: u64,
101 ratio: f64,
103 },
104
105 #[error("invalid permissions for {path}: {mode:#o}")]
107 InvalidPermissions {
108 path: PathBuf,
110 mode: u32,
112 },
113
114 #[error("{resource}")]
116 QuotaExceeded {
117 resource: QuotaResource,
119 },
120
121 #[error("operation denied by security policy: {reason}")]
123 SecurityViolation {
124 reason: String,
126 },
127
128 #[error("source path not found: {path}")]
130 SourceNotFound {
131 path: PathBuf,
133 },
134
135 #[error("source path is not accessible: {path}")]
137 SourceNotAccessible {
138 path: PathBuf,
140 },
141
142 #[error("output file already exists: {path}")]
144 OutputExists {
145 path: PathBuf,
147 },
148
149 #[error("invalid compression level {level}, must be 1-9")]
151 InvalidCompressionLevel {
152 level: u8,
154 },
155
156 #[error("cannot determine archive format from: {path}")]
158 UnknownFormat {
159 path: PathBuf,
161 },
162
163 #[error("invalid configuration: {reason}")]
165 InvalidConfiguration {
166 reason: String,
168 },
169}
170
171impl ExtractionError {
172 #[must_use]
198 pub const fn is_security_violation(&self) -> bool {
199 matches!(
200 self,
201 Self::PathTraversal { .. }
202 | Self::SymlinkEscape { .. }
203 | Self::HardlinkEscape { .. }
204 | Self::ZipBomb { .. }
205 | Self::InvalidPermissions { .. }
206 | Self::QuotaExceeded { .. }
207 | Self::SecurityViolation { .. }
208 )
209 }
210
211 #[must_use]
232 pub const fn is_recoverable(&self) -> bool {
233 matches!(
234 self,
235 Self::PathTraversal { .. }
236 | Self::SymlinkEscape { .. }
237 | Self::HardlinkEscape { .. }
238 | Self::InvalidPermissions { .. }
239 | Self::SecurityViolation { .. }
240 )
241 }
242
243 #[must_use]
260 pub fn context(&self) -> Option<&str> {
261 match self {
262 Self::InvalidArchive(msg) => Some(msg),
263 Self::SecurityViolation { reason } => Some(reason),
264 _ => None,
265 }
266 }
267
268 #[must_use]
270 pub const fn quota_resource(&self) -> Option<&QuotaResource> {
271 match self {
272 Self::QuotaExceeded { resource } => Some(resource),
273 _ => None,
274 }
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_error_display() {
284 let err = ExtractionError::UnsupportedFormat;
285 assert_eq!(err.to_string(), "unsupported archive format");
286 }
287
288 #[test]
289 fn test_path_traversal_error() {
290 let err = ExtractionError::PathTraversal {
291 path: PathBuf::from("../etc/passwd"),
292 };
293 assert!(err.to_string().contains("path traversal"));
294 assert!(err.to_string().contains("../etc/passwd"));
295 }
296
297 #[test]
298 fn test_zip_bomb_error() {
299 let err = ExtractionError::ZipBomb {
300 compressed: 1000,
301 uncompressed: 1_000_000,
302 ratio: 1000.0,
303 };
304 assert!(err.to_string().contains("zip bomb"));
305 assert!(err.to_string().contains("1000"));
306 }
307
308 #[test]
309 fn test_io_error_conversion() {
310 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
311 let err: ExtractionError = io_err.into();
312 assert!(matches!(err, ExtractionError::Io(_)));
313 }
314
315 #[test]
316 fn test_is_security_violation() {
317 let err = ExtractionError::PathTraversal {
319 path: PathBuf::from("../etc/passwd"),
320 };
321 assert!(err.is_security_violation());
322
323 let err = ExtractionError::SymlinkEscape {
324 path: PathBuf::from("link"),
325 };
326 assert!(err.is_security_violation());
327
328 let err = ExtractionError::ZipBomb {
329 compressed: 1000,
330 uncompressed: 1_000_000,
331 ratio: 1000.0,
332 };
333 assert!(err.is_security_violation());
334
335 let err = ExtractionError::SecurityViolation {
336 reason: "test".into(),
337 };
338 assert!(err.is_security_violation());
339
340 let err = ExtractionError::UnsupportedFormat;
342 assert!(!err.is_security_violation());
343
344 let err = ExtractionError::InvalidArchive("bad".into());
345 assert!(!err.is_security_violation());
346 }
347
348 #[test]
349 fn test_is_recoverable() {
350 let err = ExtractionError::PathTraversal {
352 path: PathBuf::from("../etc/passwd"),
353 };
354 assert!(err.is_recoverable());
355
356 let err = ExtractionError::SecurityViolation {
357 reason: "test".into(),
358 };
359 assert!(err.is_recoverable());
360
361 let err = ExtractionError::InvalidArchive("corrupted".into());
363 assert!(!err.is_recoverable());
364
365 let err = ExtractionError::UnsupportedFormat;
366 assert!(!err.is_recoverable());
367
368 let err = ExtractionError::ZipBomb {
369 compressed: 1000,
370 uncompressed: 1_000_000,
371 ratio: 1000.0,
372 };
373 assert!(!err.is_recoverable());
374 }
375
376 #[test]
377 fn test_context() {
378 let err = ExtractionError::InvalidArchive("bad header".into());
379 assert_eq!(err.context(), Some("bad header"));
380
381 let err = ExtractionError::SecurityViolation {
382 reason: "not allowed".into(),
383 };
384 assert_eq!(err.context(), Some("not allowed"));
385
386 let err = ExtractionError::UnsupportedFormat;
387 assert_eq!(err.context(), None);
388
389 let err = ExtractionError::PathTraversal {
390 path: PathBuf::from("../etc/passwd"),
391 };
392 assert_eq!(err.context(), None);
393 }
394
395 #[test]
396 fn test_symlink_escape_error() {
397 let err = ExtractionError::SymlinkEscape {
398 path: PathBuf::from("malicious/link"),
399 };
400 let display = err.to_string();
401 assert!(display.contains("symlink target outside"));
402 assert!(display.contains("malicious/link"));
403 assert!(err.is_security_violation());
404 }
405
406 #[test]
407 fn test_hardlink_escape_error() {
408 let err = ExtractionError::HardlinkEscape {
409 path: PathBuf::from("malicious/hardlink"),
410 };
411 let display = err.to_string();
412 assert!(display.contains("hardlink target outside"));
413 assert!(display.contains("malicious/hardlink"));
414 assert!(err.is_security_violation());
415 }
416
417 #[test]
418 fn test_invalid_permissions_error() {
419 let err = ExtractionError::InvalidPermissions {
420 path: PathBuf::from("file.txt"),
421 mode: 0o777,
422 };
423 let display = err.to_string();
424 assert!(display.contains("invalid permissions"));
425 assert!(display.contains("file.txt"));
426 assert!(display.contains("0o777"));
427 assert!(err.is_security_violation());
428 }
429
430 #[test]
431 fn test_quota_exceeded_error() {
432 let err = ExtractionError::QuotaExceeded {
433 resource: QuotaResource::FileCount {
434 current: 11,
435 max: 10,
436 },
437 };
438 let display = err.to_string();
439 assert!(display.contains("quota exceeded"));
440 assert!(display.contains("file count"));
441 assert!(display.contains("11"));
442 assert!(display.contains("10"));
443 assert!(err.is_security_violation());
444
445 let quota = err.quota_resource();
447 assert!(quota.is_some());
448 assert_eq!(
449 quota,
450 Some(&QuotaResource::FileCount {
451 current: 11,
452 max: 10
453 })
454 );
455 }
456
457 #[test]
459 fn test_error_source_chain() {
460 use std::error::Error;
461
462 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "inner error");
463 let err: ExtractionError = io_err.into();
464
465 if let ExtractionError::Io(ref inner) = err {
467 let _source = inner.source();
469 }
470 }
471
472 #[test]
474 fn test_zip_bomb_edge_cases() {
475 let err = ExtractionError::ZipBomb {
477 compressed: 0,
478 uncompressed: 1000,
479 ratio: f64::INFINITY,
480 };
481 assert!(err.is_security_violation());
482 let display = err.to_string();
483 assert!(display.contains("zip bomb"));
484
485 let err = ExtractionError::ZipBomb {
487 compressed: 1000,
488 uncompressed: 1000,
489 ratio: 1.0,
490 };
491 let display = err.to_string();
492 assert!(display.contains("1.00") || display.contains("1.0"));
493 }
494
495 #[test]
496 fn test_source_not_found_error() {
497 let err = ExtractionError::SourceNotFound {
498 path: PathBuf::from("/nonexistent/path"),
499 };
500 let display = err.to_string();
501 assert!(display.contains("source path not found"));
502 assert!(display.contains("/nonexistent/path"));
503 assert!(!err.is_security_violation());
504 }
505
506 #[test]
507 fn test_source_not_accessible_error() {
508 let err = ExtractionError::SourceNotAccessible {
509 path: PathBuf::from("/restricted/path"),
510 };
511 let display = err.to_string();
512 assert!(display.contains("source path is not accessible"));
513 assert!(display.contains("/restricted/path"));
514 assert!(!err.is_security_violation());
515 }
516
517 #[test]
518 fn test_output_exists_error() {
519 let err = ExtractionError::OutputExists {
520 path: PathBuf::from("output.tar.gz"),
521 };
522 let display = err.to_string();
523 assert!(display.contains("output file already exists"));
524 assert!(display.contains("output.tar.gz"));
525 assert!(!err.is_security_violation());
526 }
527
528 #[test]
529 fn test_invalid_compression_level_error() {
530 let err = ExtractionError::InvalidCompressionLevel { level: 0 };
531 let display = err.to_string();
532 assert!(display.contains("invalid compression level"));
533 assert!(display.contains('0'));
534 assert!(display.contains("must be 1-9"));
535 assert!(!err.is_security_violation());
536
537 let err = ExtractionError::InvalidCompressionLevel { level: 10 };
538 let display = err.to_string();
539 assert!(display.contains("10"));
540 }
541
542 #[test]
543 fn test_unknown_format_error() {
544 let err = ExtractionError::UnknownFormat {
545 path: PathBuf::from("archive.rar"),
546 };
547 let display = err.to_string();
548 assert!(display.contains("cannot determine archive format"));
549 assert!(display.contains("archive.rar"));
550 assert!(!err.is_security_violation());
551 }
552
553 #[test]
554 fn test_invalid_configuration_error() {
555 let err = ExtractionError::InvalidConfiguration {
556 reason: "output path not set".to_string(),
557 };
558 let display = err.to_string();
559 assert!(display.contains("invalid configuration"));
560 assert!(display.contains("output path not set"));
561 assert!(!err.is_security_violation());
562 }
563}