exarch_core/
error.rs

1//! Error types for archive extraction operations.
2
3use std::path::PathBuf;
4use thiserror::Error;
5
6/// Result type alias using `ExtractionError`.
7pub type Result<T> = std::result::Result<T, ExtractionError>;
8
9/// Represents a specific quota resource that was exceeded.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum QuotaResource {
12    /// File count quota exceeded.
13    FileCount {
14        /// Current file count.
15        current: usize,
16        /// Maximum allowed file count.
17        max: usize,
18    },
19    /// Total size quota exceeded.
20    TotalSize {
21        /// Current total size in bytes.
22        current: u64,
23        /// Maximum allowed total size in bytes.
24        max: u64,
25    },
26    /// Single file size quota exceeded.
27    FileSize {
28        /// File size in bytes.
29        size: u64,
30        /// Maximum allowed file size in bytes.
31        max: u64,
32    },
33    /// Integer overflow detected in quota tracking.
34    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/// Errors that can occur during archive extraction.
57#[derive(Error, Debug)]
58pub enum ExtractionError {
59    /// I/O operation failed.
60    #[error("I/O error: {0}")]
61    Io(#[from] std::io::Error),
62
63    /// Archive format is unsupported or unrecognized.
64    #[error("unsupported archive format")]
65    UnsupportedFormat,
66
67    /// Archive is corrupted or invalid.
68    #[error("invalid archive: {0}")]
69    InvalidArchive(String),
70
71    /// Path traversal attempt detected.
72    #[error("path traversal detected: {path}")]
73    PathTraversal {
74        /// The path that attempted traversal.
75        path: PathBuf,
76    },
77
78    /// Symlink points outside extraction directory.
79    #[error("symlink target outside extraction directory: {path}")]
80    SymlinkEscape {
81        /// The symlink path.
82        path: PathBuf,
83    },
84
85    /// Hardlink target not in extraction directory.
86    #[error("hardlink target outside extraction directory: {path}")]
87    HardlinkEscape {
88        /// The hardlink path.
89        path: PathBuf,
90    },
91
92    /// Potential zip bomb detected.
93    #[error(
94        "potential zip bomb: compressed={compressed} bytes, uncompressed={uncompressed} bytes (ratio: {ratio:.2})"
95    )]
96    ZipBomb {
97        /// Compressed size in bytes.
98        compressed: u64,
99        /// Uncompressed size in bytes.
100        uncompressed: u64,
101        /// Compression ratio.
102        ratio: f64,
103    },
104
105    /// File permissions are invalid or unsafe.
106    #[error("invalid permissions for {path}: {mode:#o}")]
107    InvalidPermissions {
108        /// The file path.
109        path: PathBuf,
110        /// The permission mode.
111        mode: u32,
112    },
113
114    /// Extraction quota exceeded.
115    #[error("{resource}")]
116    QuotaExceeded {
117        /// Description of the exceeded resource.
118        resource: QuotaResource,
119    },
120
121    /// Operation not permitted by security policy.
122    #[error("operation denied by security policy: {reason}")]
123    SecurityViolation {
124        /// Reason for the violation.
125        reason: String,
126    },
127
128    /// Source path not found.
129    #[error("source path not found: {path}")]
130    SourceNotFound {
131        /// The source path.
132        path: PathBuf,
133    },
134
135    /// Source path is not accessible.
136    #[error("source path is not accessible: {path}")]
137    SourceNotAccessible {
138        /// The source path.
139        path: PathBuf,
140    },
141
142    /// Output file already exists.
143    #[error("output file already exists: {path}")]
144    OutputExists {
145        /// The output path.
146        path: PathBuf,
147    },
148
149    /// Invalid compression level.
150    #[error("invalid compression level {level}, must be 1-9")]
151    InvalidCompressionLevel {
152        /// The invalid compression level.
153        level: u8,
154    },
155
156    /// Cannot determine archive format.
157    #[error("cannot determine archive format from: {path}")]
158    UnknownFormat {
159        /// The path with unknown format.
160        path: PathBuf,
161    },
162
163    /// Invalid configuration provided.
164    #[error("invalid configuration: {reason}")]
165    InvalidConfiguration {
166        /// Reason for the configuration error.
167        reason: String,
168    },
169}
170
171impl ExtractionError {
172    /// Returns `true` if this error represents a security violation.
173    ///
174    /// Security violations include:
175    /// - Path traversal attempts
176    /// - Symlink escapes
177    /// - Hardlink escapes
178    /// - Zip bombs
179    /// - Invalid permissions
180    /// - Quota exceeded
181    /// - General security policy violations
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// use exarch_core::ExtractionError;
187    /// use std::path::PathBuf;
188    ///
189    /// let err = ExtractionError::PathTraversal {
190    ///     path: PathBuf::from("../etc/passwd"),
191    /// };
192    /// assert!(err.is_security_violation());
193    ///
194    /// let err = ExtractionError::UnsupportedFormat;
195    /// assert!(!err.is_security_violation());
196    /// ```
197    #[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    /// Returns `true` if this error is potentially recoverable.
212    ///
213    /// Recoverable errors are those where extraction might continue
214    /// with different inputs or configurations. Non-recoverable errors
215    /// typically indicate fundamental issues with the archive format.
216    ///
217    /// # Examples
218    ///
219    /// ```
220    /// use exarch_core::ExtractionError;
221    /// use std::path::PathBuf;
222    ///
223    /// let err = ExtractionError::PathTraversal {
224    ///     path: PathBuf::from("../etc/passwd"),
225    /// };
226    /// assert!(err.is_recoverable()); // Could skip this entry
227    ///
228    /// let err = ExtractionError::InvalidArchive("corrupted header".to_string());
229    /// assert!(!err.is_recoverable()); // Cannot continue
230    /// ```
231    #[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    /// Returns a context string for this error, if available.
244    ///
245    /// The context provides additional information about what operation
246    /// was being performed when the error occurred.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use exarch_core::ExtractionError;
252    ///
253    /// let err = ExtractionError::InvalidArchive("bad header".to_string());
254    /// assert_eq!(err.context(), Some("bad header"));
255    ///
256    /// let err = ExtractionError::UnsupportedFormat;
257    /// assert_eq!(err.context(), None);
258    /// ```
259    #[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    /// Returns the quota resource that was exceeded, if applicable.
269    #[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        // Security violations
318        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        // Not security violations
341        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        // Recoverable errors
351        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        // Non-recoverable errors
362        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        // Test quota_resource accessor
446        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    // L-10: Error source chain test
458    #[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        // Verify source chain works
466        if let ExtractionError::Io(ref inner) = err {
467            // IO error may or may not have a source
468            let _source = inner.source();
469        }
470    }
471
472    // L-11: ZipBomb edge case tests
473    #[test]
474    fn test_zip_bomb_edge_cases() {
475        // Zero compressed size (would cause division by zero in ratio calc)
476        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        // Equal sizes (ratio = 1.0)
486        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}