Skip to main content

exarch_core/inspection/
report.rs

1//! Verification report types for security checks.
2
3use std::path::PathBuf;
4
5use crate::error::ExtractionError;
6use crate::formats::detect::ArchiveType;
7
8/// Result of archive verification.
9///
10/// Generated by `verify_archive()`, contains security and integrity checks
11/// performed without extracting files to disk.
12///
13/// # Examples
14///
15/// ```no_run
16/// use exarch_core::SecurityConfig;
17/// use exarch_core::VerificationStatus;
18/// use exarch_core::verify_archive;
19///
20/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
21/// let config = SecurityConfig::default();
22/// let report = verify_archive("archive.tar.gz", &config)?;
23///
24/// if report.status == VerificationStatus::Pass {
25///     println!("Archive is safe to extract");
26/// } else {
27///     eprintln!("Security issues found:");
28///     for issue in report.issues {
29///         eprintln!("  [{}] {}", issue.severity, issue.message);
30///     }
31/// }
32/// # Ok(())
33/// # }
34/// ```
35#[derive(Debug, Clone)]
36pub struct VerificationReport {
37    /// Overall verification status
38    pub status: VerificationStatus,
39
40    /// Integrity check result
41    pub integrity_status: CheckStatus,
42
43    /// Security check result
44    pub security_status: CheckStatus,
45
46    /// List of all issues found (sorted by severity)
47    pub issues: Vec<VerificationIssue>,
48
49    /// Total entries scanned
50    pub total_entries: usize,
51
52    /// Entries flagged as suspicious
53    pub suspicious_entries: usize,
54
55    /// Total uncompressed size
56    pub total_size: u64,
57
58    /// Archive format
59    pub format: ArchiveType,
60}
61
62impl VerificationReport {
63    /// Returns true if the archive is safe (no critical or high severity
64    /// issues).
65    #[must_use]
66    pub fn is_safe(&self) -> bool {
67        self.status == VerificationStatus::Pass
68    }
69
70    /// Returns true if there are any critical severity issues.
71    #[must_use]
72    pub fn has_critical_issues(&self) -> bool {
73        self.issues
74            .iter()
75            .any(|i| i.severity == IssueSeverity::Critical)
76    }
77
78    /// Returns issues of a specific severity level.
79    #[must_use]
80    pub fn issues_by_severity(&self, severity: IssueSeverity) -> Vec<&VerificationIssue> {
81        self.issues
82            .iter()
83            .filter(|i| i.severity == severity)
84            .collect()
85    }
86}
87
88/// Overall verification status.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum VerificationStatus {
91    /// All checks passed
92    Pass,
93
94    /// One or more checks failed
95    Fail,
96
97    /// Checks completed with warnings
98    Warning,
99}
100
101impl std::fmt::Display for VerificationStatus {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            Self::Pass => write!(f, "PASS"),
105            Self::Fail => write!(f, "FAIL"),
106            Self::Warning => write!(f, "WARNING"),
107        }
108    }
109}
110
111/// Status of a specific check category.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum CheckStatus {
114    /// Check passed
115    Pass,
116
117    /// Check failed
118    Fail,
119
120    /// Check completed with warnings
121    Warning,
122
123    /// Check was skipped
124    Skipped,
125}
126
127impl std::fmt::Display for CheckStatus {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            Self::Pass => write!(f, "OK"),
131            Self::Fail => write!(f, "FAILED"),
132            Self::Warning => write!(f, "WARNING"),
133            Self::Skipped => write!(f, "SKIPPED"),
134        }
135    }
136}
137
138/// Single verification issue.
139#[derive(Debug, Clone)]
140pub struct VerificationIssue {
141    /// Issue severity level
142    pub severity: IssueSeverity,
143
144    /// Issue category
145    pub category: IssueCategory,
146
147    /// Entry path that triggered issue (if applicable)
148    pub entry_path: Option<PathBuf>,
149
150    /// Human-readable description
151    pub message: String,
152
153    /// Optional context (compression ratio, target path, etc.)
154    pub context: Option<String>,
155}
156
157impl VerificationIssue {
158    /// Creates a verification issue from an extraction error.
159    #[must_use]
160    #[allow(clippy::too_many_lines)]
161    pub fn from_error(error: &ExtractionError, entry_path: Option<PathBuf>) -> Self {
162        let (severity, category, message) = match error {
163            ExtractionError::PathTraversal { path } => (
164                IssueSeverity::Critical,
165                IssueCategory::PathTraversal,
166                format!("Path traversal detected: {}", path.display()),
167            ),
168            ExtractionError::SymlinkEscape { path } => (
169                IssueSeverity::Critical,
170                IssueCategory::SymlinkEscape,
171                format!("Symlink escape: {}", path.display()),
172            ),
173            ExtractionError::HardlinkEscape { path } => (
174                IssueSeverity::Critical,
175                IssueCategory::HardlinkEscape,
176                format!("Hardlink escape: {}", path.display()),
177            ),
178            ExtractionError::ZipBomb {
179                compressed,
180                uncompressed,
181                ratio,
182            } => (
183                IssueSeverity::Critical,
184                IssueCategory::ZipBomb,
185                format!(
186                    "Potential zip bomb: {ratio:.1}x compression ratio (compressed={compressed}, uncompressed={uncompressed})"
187                ),
188            ),
189            ExtractionError::QuotaExceeded { resource } => (
190                IssueSeverity::High,
191                IssueCategory::QuotaExceeded,
192                format!("{resource}"),
193            ),
194            ExtractionError::InvalidPermissions { path, mode } => (
195                IssueSeverity::Medium,
196                IssueCategory::InvalidPermissions,
197                format!(
198                    "Invalid permissions: {} (mode: {:#o})",
199                    path.display(),
200                    mode
201                ),
202            ),
203            ExtractionError::Io(io_err) => (
204                IssueSeverity::High,
205                IssueCategory::InvalidArchive,
206                format!("I/O error: {io_err}"),
207            ),
208            ExtractionError::UnsupportedFormat => (
209                IssueSeverity::High,
210                IssueCategory::InvalidArchive,
211                "Unsupported archive format".to_string(),
212            ),
213            ExtractionError::InvalidArchive(msg) => (
214                IssueSeverity::High,
215                IssueCategory::InvalidArchive,
216                format!("Invalid archive: {msg}"),
217            ),
218            ExtractionError::SecurityViolation { reason } => (
219                IssueSeverity::High,
220                IssueCategory::SuspiciousPath,
221                format!("Security violation: {reason}"),
222            ),
223            ExtractionError::SourceNotFound { path } => (
224                IssueSeverity::High,
225                IssueCategory::InvalidArchive,
226                format!("Source not found: {}", path.display()),
227            ),
228            ExtractionError::SourceNotAccessible { path } => (
229                IssueSeverity::High,
230                IssueCategory::InvalidArchive,
231                format!("Source not accessible: {}", path.display()),
232            ),
233            ExtractionError::OutputExists { path } => (
234                IssueSeverity::Medium,
235                IssueCategory::InvalidArchive,
236                format!("Output already exists: {}", path.display()),
237            ),
238            ExtractionError::InvalidCompressionLevel { level } => (
239                IssueSeverity::Medium,
240                IssueCategory::InvalidArchive,
241                format!("Invalid compression level: {level}"),
242            ),
243            ExtractionError::UnknownFormat { path } => (
244                IssueSeverity::High,
245                IssueCategory::InvalidArchive,
246                format!("Unknown format: {}", path.display()),
247            ),
248            ExtractionError::InvalidConfiguration { reason } => (
249                IssueSeverity::High,
250                IssueCategory::InvalidArchive,
251                format!("Invalid configuration: {reason}"),
252            ),
253            ExtractionError::PartialExtraction { source, .. } => {
254                return Self::from_error(source, entry_path);
255            }
256        };
257
258        Self {
259            severity,
260            category,
261            entry_path,
262            message,
263            context: None,
264        }
265    }
266}
267
268/// Issue severity levels (ordered from least to most severe for Ord).
269#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
270pub enum IssueSeverity {
271    /// Informational
272    Info,
273
274    /// Policy violation
275    Low,
276
277    /// Security concern
278    Medium,
279
280    /// Exploitable security issue
281    High,
282
283    /// CVE-level vulnerability
284    Critical,
285}
286
287impl std::fmt::Display for IssueSeverity {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match self {
290            Self::Critical => write!(f, "CRITICAL"),
291            Self::High => write!(f, "HIGH"),
292            Self::Medium => write!(f, "MEDIUM"),
293            Self::Low => write!(f, "LOW"),
294            Self::Info => write!(f, "INFO"),
295        }
296    }
297}
298
299/// Issue categories (maps to security checks).
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum IssueCategory {
302    /// Path traversal attack
303    PathTraversal,
304
305    /// Symlink escape attack
306    SymlinkEscape,
307
308    /// Hardlink escape attack
309    HardlinkEscape,
310
311    /// Zip bomb (excessive compression)
312    ZipBomb,
313
314    /// Invalid or unsafe permissions
315    InvalidPermissions,
316
317    /// Quota exceeded (file count or size)
318    QuotaExceeded,
319
320    /// Invalid or corrupted archive
321    InvalidArchive,
322
323    /// Suspicious path or filename
324    SuspiciousPath,
325
326    /// Executable file detected
327    ExecutableFile,
328}
329
330impl std::fmt::Display for IssueCategory {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        match self {
333            Self::PathTraversal => write!(f, "Path Traversal"),
334            Self::SymlinkEscape => write!(f, "Symlink Escape"),
335            Self::HardlinkEscape => write!(f, "Hardlink Escape"),
336            Self::ZipBomb => write!(f, "Zip Bomb"),
337            Self::InvalidPermissions => write!(f, "Invalid Permissions"),
338            Self::QuotaExceeded => write!(f, "Quota Exceeded"),
339            Self::InvalidArchive => write!(f, "Invalid Archive"),
340            Self::SuspiciousPath => write!(f, "Suspicious Path"),
341            Self::ExecutableFile => write!(f, "Executable File"),
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use std::io;
350
351    #[test]
352    fn test_verification_status_display() {
353        assert_eq!(VerificationStatus::Pass.to_string(), "PASS");
354        assert_eq!(VerificationStatus::Fail.to_string(), "FAIL");
355        assert_eq!(VerificationStatus::Warning.to_string(), "WARNING");
356    }
357
358    #[test]
359    fn test_check_status_display() {
360        assert_eq!(CheckStatus::Pass.to_string(), "OK");
361        assert_eq!(CheckStatus::Fail.to_string(), "FAILED");
362        assert_eq!(CheckStatus::Warning.to_string(), "WARNING");
363        assert_eq!(CheckStatus::Skipped.to_string(), "SKIPPED");
364    }
365
366    #[test]
367    fn test_issue_severity_display() {
368        assert_eq!(IssueSeverity::Critical.to_string(), "CRITICAL");
369        assert_eq!(IssueSeverity::High.to_string(), "HIGH");
370        assert_eq!(IssueSeverity::Medium.to_string(), "MEDIUM");
371        assert_eq!(IssueSeverity::Low.to_string(), "LOW");
372        assert_eq!(IssueSeverity::Info.to_string(), "INFO");
373    }
374
375    #[test]
376    fn test_issue_severity_ordering() {
377        assert!(IssueSeverity::Critical > IssueSeverity::High);
378        assert!(IssueSeverity::High > IssueSeverity::Medium);
379        assert!(IssueSeverity::Medium > IssueSeverity::Low);
380        assert!(IssueSeverity::Low > IssueSeverity::Info);
381    }
382
383    #[test]
384    fn test_issue_category_display() {
385        assert_eq!(IssueCategory::PathTraversal.to_string(), "Path Traversal");
386        assert_eq!(IssueCategory::SymlinkEscape.to_string(), "Symlink Escape");
387    }
388
389    #[test]
390    fn test_verification_issue_from_path_traversal() {
391        let error = ExtractionError::PathTraversal {
392            path: PathBuf::from("../../etc/passwd"),
393        };
394        let issue = VerificationIssue::from_error(&error, None);
395
396        assert_eq!(issue.severity, IssueSeverity::Critical);
397        assert_eq!(issue.category, IssueCategory::PathTraversal);
398        assert!(issue.message.contains("Path traversal"));
399    }
400
401    #[test]
402    fn test_verification_issue_from_symlink_escape() {
403        let error = ExtractionError::SymlinkEscape {
404            path: PathBuf::from("link"),
405        };
406        let issue = VerificationIssue::from_error(&error, Some(PathBuf::from("link")));
407
408        assert_eq!(issue.severity, IssueSeverity::Critical);
409        assert_eq!(issue.category, IssueCategory::SymlinkEscape);
410        assert!(issue.message.contains("Symlink escape"));
411    }
412
413    #[test]
414    fn test_verification_issue_from_zip_bomb() {
415        let error = ExtractionError::ZipBomb {
416            compressed: 1000,
417            uncompressed: 1_000_000,
418            ratio: 1000.0,
419        };
420        let issue = VerificationIssue::from_error(&error, None);
421
422        assert_eq!(issue.severity, IssueSeverity::Critical);
423        assert_eq!(issue.category, IssueCategory::ZipBomb);
424        assert!(issue.message.contains("zip bomb"));
425    }
426
427    #[test]
428    fn test_verification_issue_from_io_error() {
429        let error = ExtractionError::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
430        let issue = VerificationIssue::from_error(&error, None);
431
432        assert_eq!(issue.severity, IssueSeverity::High);
433        assert_eq!(issue.category, IssueCategory::InvalidArchive);
434    }
435
436    #[test]
437    fn test_verification_report_is_safe() {
438        let report = VerificationReport {
439            status: VerificationStatus::Pass,
440            integrity_status: CheckStatus::Pass,
441            security_status: CheckStatus::Pass,
442            issues: Vec::new(),
443            total_entries: 10,
444            suspicious_entries: 0,
445            total_size: 1024,
446            format: ArchiveType::TarGz,
447        };
448
449        assert!(report.is_safe());
450    }
451
452    #[test]
453    fn test_verification_report_not_safe() {
454        let report = VerificationReport {
455            status: VerificationStatus::Fail,
456            integrity_status: CheckStatus::Pass,
457            security_status: CheckStatus::Fail,
458            issues: vec![VerificationIssue {
459                severity: IssueSeverity::Critical,
460                category: IssueCategory::PathTraversal,
461                entry_path: None,
462                message: "Test issue".to_string(),
463                context: None,
464            }],
465            total_entries: 10,
466            suspicious_entries: 1,
467            total_size: 1024,
468            format: ArchiveType::TarGz,
469        };
470
471        assert!(!report.is_safe());
472        assert!(report.has_critical_issues());
473    }
474
475    #[test]
476    fn test_verification_issue_from_hardlink_escape() {
477        let error = ExtractionError::HardlinkEscape {
478            path: PathBuf::from("link"),
479        };
480        let issue = VerificationIssue::from_error(&error, None);
481        assert_eq!(issue.severity, IssueSeverity::Critical);
482        assert_eq!(issue.category, IssueCategory::HardlinkEscape);
483        assert!(issue.message.contains("Hardlink escape"));
484    }
485
486    #[test]
487    fn test_verification_issue_from_quota_exceeded() {
488        let error = ExtractionError::QuotaExceeded {
489            resource: crate::error::QuotaResource::FileCount {
490                current: 11,
491                max: 10,
492            },
493        };
494        let issue = VerificationIssue::from_error(&error, None);
495        assert_eq!(issue.severity, IssueSeverity::High);
496        assert_eq!(issue.category, IssueCategory::QuotaExceeded);
497    }
498
499    #[test]
500    fn test_verification_issue_from_invalid_permissions() {
501        let error = ExtractionError::InvalidPermissions {
502            path: PathBuf::from("file.txt"),
503            mode: 0o777,
504        };
505        let issue = VerificationIssue::from_error(&error, None);
506        assert_eq!(issue.severity, IssueSeverity::Medium);
507        assert_eq!(issue.category, IssueCategory::InvalidPermissions);
508        assert!(issue.message.contains("Invalid permissions"));
509    }
510
511    #[test]
512    fn test_verification_issue_from_unsupported_format() {
513        let error = ExtractionError::UnsupportedFormat;
514        let issue = VerificationIssue::from_error(&error, None);
515        assert_eq!(issue.severity, IssueSeverity::High);
516        assert_eq!(issue.category, IssueCategory::InvalidArchive);
517        assert!(issue.message.contains("Unsupported archive format"));
518    }
519
520    #[test]
521    fn test_verification_issue_from_invalid_archive() {
522        let error = ExtractionError::InvalidArchive("bad header".into());
523        let issue = VerificationIssue::from_error(&error, None);
524        assert_eq!(issue.severity, IssueSeverity::High);
525        assert_eq!(issue.category, IssueCategory::InvalidArchive);
526        assert!(issue.message.contains("Invalid archive"));
527    }
528
529    #[test]
530    fn test_verification_issue_from_security_violation() {
531        let error = ExtractionError::SecurityViolation {
532            reason: "encrypted".into(),
533        };
534        let issue = VerificationIssue::from_error(&error, None);
535        assert_eq!(issue.severity, IssueSeverity::High);
536        assert_eq!(issue.category, IssueCategory::SuspiciousPath);
537        assert!(issue.message.contains("Security violation"));
538    }
539
540    #[test]
541    fn test_verification_issue_from_source_not_found() {
542        let error = ExtractionError::SourceNotFound {
543            path: PathBuf::from("/missing"),
544        };
545        let issue = VerificationIssue::from_error(&error, None);
546        assert_eq!(issue.severity, IssueSeverity::High);
547        assert_eq!(issue.category, IssueCategory::InvalidArchive);
548        assert!(issue.message.contains("Source not found"));
549    }
550
551    #[test]
552    fn test_verification_issue_from_source_not_accessible() {
553        let error = ExtractionError::SourceNotAccessible {
554            path: PathBuf::from("/restricted"),
555        };
556        let issue = VerificationIssue::from_error(&error, None);
557        assert_eq!(issue.severity, IssueSeverity::High);
558        assert_eq!(issue.category, IssueCategory::InvalidArchive);
559        assert!(issue.message.contains("Source not accessible"));
560    }
561
562    #[test]
563    fn test_verification_issue_from_output_exists() {
564        let error = ExtractionError::OutputExists {
565            path: PathBuf::from("out/"),
566        };
567        let issue = VerificationIssue::from_error(&error, None);
568        assert_eq!(issue.severity, IssueSeverity::Medium);
569        assert_eq!(issue.category, IssueCategory::InvalidArchive);
570        assert!(issue.message.contains("Output already exists"));
571    }
572
573    #[test]
574    fn test_verification_issue_from_invalid_compression_level() {
575        let error = ExtractionError::InvalidCompressionLevel { level: 0 };
576        let issue = VerificationIssue::from_error(&error, None);
577        assert_eq!(issue.severity, IssueSeverity::Medium);
578        assert_eq!(issue.category, IssueCategory::InvalidArchive);
579        assert!(issue.message.contains("Invalid compression level"));
580    }
581
582    #[test]
583    fn test_verification_issue_from_unknown_format() {
584        let error = ExtractionError::UnknownFormat {
585            path: PathBuf::from("archive.rar"),
586        };
587        let issue = VerificationIssue::from_error(&error, None);
588        assert_eq!(issue.severity, IssueSeverity::High);
589        assert_eq!(issue.category, IssueCategory::InvalidArchive);
590        assert!(issue.message.contains("Unknown format"));
591    }
592
593    #[test]
594    fn test_verification_issue_from_invalid_configuration() {
595        let error = ExtractionError::InvalidConfiguration {
596            reason: "bad config".into(),
597        };
598        let issue = VerificationIssue::from_error(&error, None);
599        assert_eq!(issue.severity, IssueSeverity::High);
600        assert_eq!(issue.category, IssueCategory::InvalidArchive);
601        assert!(issue.message.contains("Invalid configuration"));
602    }
603
604    #[test]
605    fn test_verification_report_issues_by_severity() {
606        let report = VerificationReport {
607            status: VerificationStatus::Warning,
608            integrity_status: CheckStatus::Pass,
609            security_status: CheckStatus::Warning,
610            issues: vec![
611                VerificationIssue {
612                    severity: IssueSeverity::Critical,
613                    category: IssueCategory::PathTraversal,
614                    entry_path: None,
615                    message: "Critical issue".to_string(),
616                    context: None,
617                },
618                VerificationIssue {
619                    severity: IssueSeverity::Low,
620                    category: IssueCategory::ExecutableFile,
621                    entry_path: None,
622                    message: "Low issue".to_string(),
623                    context: None,
624                },
625            ],
626            total_entries: 10,
627            suspicious_entries: 2,
628            total_size: 1024,
629            format: ArchiveType::TarGz,
630        };
631
632        let critical_issues = report.issues_by_severity(IssueSeverity::Critical);
633        assert_eq!(critical_issues.len(), 1);
634
635        let low_issues = report.issues_by_severity(IssueSeverity::Low);
636        assert_eq!(low_issues.len(), 1);
637    }
638}