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::ArchiveError;
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: &ArchiveError, entry_path: Option<PathBuf>) -> Self {
162        let (severity, category, message) = match error {
163            ArchiveError::PathTraversal { path } => (
164                IssueSeverity::Critical,
165                IssueCategory::PathTraversal,
166                format!("Path traversal detected: {}", path.display()),
167            ),
168            ArchiveError::SymlinkEscape { path } => (
169                IssueSeverity::Critical,
170                IssueCategory::SymlinkEscape,
171                format!("Symlink escape: {}", path.display()),
172            ),
173            ArchiveError::HardlinkEscape { path } => (
174                IssueSeverity::Critical,
175                IssueCategory::HardlinkEscape,
176                format!("Hardlink escape: {}", path.display()),
177            ),
178            ArchiveError::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            ArchiveError::QuotaExceeded { resource } => (
190                IssueSeverity::High,
191                IssueCategory::QuotaExceeded,
192                format!("{resource}"),
193            ),
194            ArchiveError::InvalidPermissions { path, mode } => (
195                IssueSeverity::Medium,
196                IssueCategory::InvalidPermissions,
197                format!(
198                    "Invalid permissions: {} (mode: {:#o})",
199                    path.display(),
200                    mode
201                ),
202            ),
203            ArchiveError::Io(io_err) => (
204                IssueSeverity::High,
205                IssueCategory::InvalidArchive,
206                format!("I/O error: {io_err}"),
207            ),
208            ArchiveError::InvalidArchive(msg) => (
209                IssueSeverity::High,
210                IssueCategory::InvalidArchive,
211                format!("Invalid archive: {msg}"),
212            ),
213            ArchiveError::SecurityViolation { reason } => (
214                IssueSeverity::High,
215                IssueCategory::SuspiciousPath,
216                format!("Security violation: {reason}"),
217            ),
218            ArchiveError::SourceNotFound { path } => (
219                IssueSeverity::High,
220                IssueCategory::InvalidArchive,
221                format!("Source not found: {}", path.display()),
222            ),
223            ArchiveError::SourceNotAccessible { path } => (
224                IssueSeverity::High,
225                IssueCategory::InvalidArchive,
226                format!("Source not accessible: {}", path.display()),
227            ),
228            ArchiveError::OutputExists { path } => (
229                IssueSeverity::Medium,
230                IssueCategory::InvalidArchive,
231                format!("Output already exists: {}", path.display()),
232            ),
233            ArchiveError::InvalidCompressionLevel { level } => (
234                IssueSeverity::Medium,
235                IssueCategory::InvalidArchive,
236                format!("Invalid compression level: {level}"),
237            ),
238            ArchiveError::UnknownFormat { path } => (
239                IssueSeverity::High,
240                IssueCategory::InvalidArchive,
241                format!("Unknown format: {}", path.display()),
242            ),
243            ArchiveError::InvalidConfiguration { reason } => (
244                IssueSeverity::High,
245                IssueCategory::InvalidArchive,
246                format!("Invalid configuration: {reason}"),
247            ),
248            ArchiveError::PartialExtraction { source, .. } => {
249                return Self::from_error(source, entry_path);
250            }
251        };
252
253        Self {
254            severity,
255            category,
256            entry_path,
257            message,
258            context: None,
259        }
260    }
261}
262
263/// Issue severity levels (ordered from least to most severe for Ord).
264#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
265pub enum IssueSeverity {
266    /// Informational
267    Info,
268
269    /// Policy violation
270    Low,
271
272    /// Security concern
273    Medium,
274
275    /// Exploitable security issue
276    High,
277
278    /// CVE-level vulnerability
279    Critical,
280}
281
282impl std::fmt::Display for IssueSeverity {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            Self::Critical => write!(f, "CRITICAL"),
286            Self::High => write!(f, "HIGH"),
287            Self::Medium => write!(f, "MEDIUM"),
288            Self::Low => write!(f, "LOW"),
289            Self::Info => write!(f, "INFO"),
290        }
291    }
292}
293
294/// Issue categories (maps to security checks).
295///
296/// `#[non_exhaustive]` so a future security check is not a breaking change
297/// for downstream matches.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299#[non_exhaustive]
300pub enum IssueCategory {
301    /// Path traversal attack
302    PathTraversal,
303
304    /// Symlink escape attack
305    SymlinkEscape,
306
307    /// Hardlink escape attack
308    HardlinkEscape,
309
310    /// Zip bomb (excessive compression)
311    ZipBomb,
312
313    /// Invalid or unsafe permissions
314    InvalidPermissions,
315
316    /// Quota exceeded (file count or size)
317    QuotaExceeded,
318
319    /// Invalid or corrupted archive
320    InvalidArchive,
321
322    /// Suspicious path or filename
323    SuspiciousPath,
324
325    /// Executable file detected
326    ExecutableFile,
327}
328
329impl std::fmt::Display for IssueCategory {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        match self {
332            Self::PathTraversal => write!(f, "Path Traversal"),
333            Self::SymlinkEscape => write!(f, "Symlink Escape"),
334            Self::HardlinkEscape => write!(f, "Hardlink Escape"),
335            Self::ZipBomb => write!(f, "Zip Bomb"),
336            Self::InvalidPermissions => write!(f, "Invalid Permissions"),
337            Self::QuotaExceeded => write!(f, "Quota Exceeded"),
338            Self::InvalidArchive => write!(f, "Invalid Archive"),
339            Self::SuspiciousPath => write!(f, "Suspicious Path"),
340            Self::ExecutableFile => write!(f, "Executable File"),
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::io;
349
350    #[test]
351    fn test_verification_status_display() {
352        assert_eq!(VerificationStatus::Pass.to_string(), "PASS");
353        assert_eq!(VerificationStatus::Fail.to_string(), "FAIL");
354        assert_eq!(VerificationStatus::Warning.to_string(), "WARNING");
355    }
356
357    #[test]
358    fn test_check_status_display() {
359        assert_eq!(CheckStatus::Pass.to_string(), "OK");
360        assert_eq!(CheckStatus::Fail.to_string(), "FAILED");
361        assert_eq!(CheckStatus::Warning.to_string(), "WARNING");
362        assert_eq!(CheckStatus::Skipped.to_string(), "SKIPPED");
363    }
364
365    #[test]
366    fn test_issue_severity_display() {
367        assert_eq!(IssueSeverity::Critical.to_string(), "CRITICAL");
368        assert_eq!(IssueSeverity::High.to_string(), "HIGH");
369        assert_eq!(IssueSeverity::Medium.to_string(), "MEDIUM");
370        assert_eq!(IssueSeverity::Low.to_string(), "LOW");
371        assert_eq!(IssueSeverity::Info.to_string(), "INFO");
372    }
373
374    #[test]
375    fn test_issue_severity_ordering() {
376        assert!(IssueSeverity::Critical > IssueSeverity::High);
377        assert!(IssueSeverity::High > IssueSeverity::Medium);
378        assert!(IssueSeverity::Medium > IssueSeverity::Low);
379        assert!(IssueSeverity::Low > IssueSeverity::Info);
380    }
381
382    #[test]
383    fn test_issue_category_display() {
384        assert_eq!(IssueCategory::PathTraversal.to_string(), "Path Traversal");
385        assert_eq!(IssueCategory::SymlinkEscape.to_string(), "Symlink Escape");
386    }
387
388    #[test]
389    fn test_verification_issue_from_path_traversal() {
390        let error = ArchiveError::PathTraversal {
391            path: PathBuf::from("../../etc/passwd"),
392        };
393        let issue = VerificationIssue::from_error(&error, None);
394
395        assert_eq!(issue.severity, IssueSeverity::Critical);
396        assert_eq!(issue.category, IssueCategory::PathTraversal);
397        assert!(issue.message.contains("Path traversal"));
398    }
399
400    #[test]
401    fn test_verification_issue_from_symlink_escape() {
402        let error = ArchiveError::SymlinkEscape {
403            path: PathBuf::from("link"),
404        };
405        let issue = VerificationIssue::from_error(&error, Some(PathBuf::from("link")));
406
407        assert_eq!(issue.severity, IssueSeverity::Critical);
408        assert_eq!(issue.category, IssueCategory::SymlinkEscape);
409        assert!(issue.message.contains("Symlink escape"));
410    }
411
412    #[test]
413    fn test_verification_issue_from_zip_bomb() {
414        let error = ArchiveError::ZipBomb {
415            compressed: 1000,
416            uncompressed: 1_000_000,
417            ratio: 1000.0,
418        };
419        let issue = VerificationIssue::from_error(&error, None);
420
421        assert_eq!(issue.severity, IssueSeverity::Critical);
422        assert_eq!(issue.category, IssueCategory::ZipBomb);
423        assert!(issue.message.contains("zip bomb"));
424    }
425
426    #[test]
427    fn test_verification_issue_from_io_error() {
428        let error = ArchiveError::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
429        let issue = VerificationIssue::from_error(&error, None);
430
431        assert_eq!(issue.severity, IssueSeverity::High);
432        assert_eq!(issue.category, IssueCategory::InvalidArchive);
433    }
434
435    #[test]
436    fn test_verification_report_is_safe() {
437        let report = VerificationReport {
438            status: VerificationStatus::Pass,
439            integrity_status: CheckStatus::Pass,
440            security_status: CheckStatus::Pass,
441            issues: Vec::new(),
442            total_entries: 10,
443            suspicious_entries: 0,
444            total_size: 1024,
445            format: ArchiveType::TarGz,
446        };
447
448        assert!(report.is_safe());
449    }
450
451    #[test]
452    fn test_verification_report_not_safe() {
453        let report = VerificationReport {
454            status: VerificationStatus::Fail,
455            integrity_status: CheckStatus::Pass,
456            security_status: CheckStatus::Fail,
457            issues: vec![VerificationIssue {
458                severity: IssueSeverity::Critical,
459                category: IssueCategory::PathTraversal,
460                entry_path: None,
461                message: "Test issue".to_string(),
462                context: None,
463            }],
464            total_entries: 10,
465            suspicious_entries: 1,
466            total_size: 1024,
467            format: ArchiveType::TarGz,
468        };
469
470        assert!(!report.is_safe());
471        assert!(report.has_critical_issues());
472    }
473
474    #[test]
475    fn test_verification_issue_from_hardlink_escape() {
476        let error = ArchiveError::HardlinkEscape {
477            path: PathBuf::from("link"),
478        };
479        let issue = VerificationIssue::from_error(&error, None);
480        assert_eq!(issue.severity, IssueSeverity::Critical);
481        assert_eq!(issue.category, IssueCategory::HardlinkEscape);
482        assert!(issue.message.contains("Hardlink escape"));
483    }
484
485    #[test]
486    fn test_verification_issue_from_quota_exceeded() {
487        let error = ArchiveError::QuotaExceeded {
488            resource: crate::error::QuotaResource::FileCount {
489                current: 11,
490                max: 10,
491            },
492        };
493        let issue = VerificationIssue::from_error(&error, None);
494        assert_eq!(issue.severity, IssueSeverity::High);
495        assert_eq!(issue.category, IssueCategory::QuotaExceeded);
496    }
497
498    #[test]
499    fn test_verification_issue_from_invalid_permissions() {
500        let error = ArchiveError::InvalidPermissions {
501            path: PathBuf::from("file.txt"),
502            mode: 0o777,
503        };
504        let issue = VerificationIssue::from_error(&error, None);
505        assert_eq!(issue.severity, IssueSeverity::Medium);
506        assert_eq!(issue.category, IssueCategory::InvalidPermissions);
507        assert!(issue.message.contains("Invalid permissions"));
508    }
509
510    #[test]
511    fn test_verification_issue_from_invalid_archive() {
512        let error = ArchiveError::InvalidArchive("bad header".into());
513        let issue = VerificationIssue::from_error(&error, None);
514        assert_eq!(issue.severity, IssueSeverity::High);
515        assert_eq!(issue.category, IssueCategory::InvalidArchive);
516        assert!(issue.message.contains("Invalid archive"));
517    }
518
519    #[test]
520    fn test_verification_issue_from_security_violation() {
521        let error = ArchiveError::SecurityViolation {
522            reason: "encrypted".into(),
523        };
524        let issue = VerificationIssue::from_error(&error, None);
525        assert_eq!(issue.severity, IssueSeverity::High);
526        assert_eq!(issue.category, IssueCategory::SuspiciousPath);
527        assert!(issue.message.contains("Security violation"));
528    }
529
530    #[test]
531    fn test_verification_issue_from_source_not_found() {
532        let error = ArchiveError::SourceNotFound {
533            path: PathBuf::from("/missing"),
534        };
535        let issue = VerificationIssue::from_error(&error, None);
536        assert_eq!(issue.severity, IssueSeverity::High);
537        assert_eq!(issue.category, IssueCategory::InvalidArchive);
538        assert!(issue.message.contains("Source not found"));
539    }
540
541    #[test]
542    fn test_verification_issue_from_source_not_accessible() {
543        let error = ArchiveError::SourceNotAccessible {
544            path: PathBuf::from("/restricted"),
545        };
546        let issue = VerificationIssue::from_error(&error, None);
547        assert_eq!(issue.severity, IssueSeverity::High);
548        assert_eq!(issue.category, IssueCategory::InvalidArchive);
549        assert!(issue.message.contains("Source not accessible"));
550    }
551
552    #[test]
553    fn test_verification_issue_from_output_exists() {
554        let error = ArchiveError::OutputExists {
555            path: PathBuf::from("out/"),
556        };
557        let issue = VerificationIssue::from_error(&error, None);
558        assert_eq!(issue.severity, IssueSeverity::Medium);
559        assert_eq!(issue.category, IssueCategory::InvalidArchive);
560        assert!(issue.message.contains("Output already exists"));
561    }
562
563    #[test]
564    fn test_verification_issue_from_invalid_compression_level() {
565        let error = ArchiveError::InvalidCompressionLevel { level: 0 };
566        let issue = VerificationIssue::from_error(&error, None);
567        assert_eq!(issue.severity, IssueSeverity::Medium);
568        assert_eq!(issue.category, IssueCategory::InvalidArchive);
569        assert!(issue.message.contains("Invalid compression level"));
570    }
571
572    #[test]
573    fn test_verification_issue_from_unknown_format() {
574        let error = ArchiveError::UnknownFormat {
575            path: PathBuf::from("archive.rar"),
576        };
577        let issue = VerificationIssue::from_error(&error, None);
578        assert_eq!(issue.severity, IssueSeverity::High);
579        assert_eq!(issue.category, IssueCategory::InvalidArchive);
580        assert!(issue.message.contains("Unknown format"));
581    }
582
583    #[test]
584    fn test_verification_issue_from_invalid_configuration() {
585        let error = ArchiveError::InvalidConfiguration {
586            reason: "bad config".into(),
587        };
588        let issue = VerificationIssue::from_error(&error, None);
589        assert_eq!(issue.severity, IssueSeverity::High);
590        assert_eq!(issue.category, IssueCategory::InvalidArchive);
591        assert!(issue.message.contains("Invalid configuration"));
592    }
593
594    #[test]
595    fn test_verification_report_issues_by_severity() {
596        let report = VerificationReport {
597            status: VerificationStatus::Warning,
598            integrity_status: CheckStatus::Pass,
599            security_status: CheckStatus::Warning,
600            issues: vec![
601                VerificationIssue {
602                    severity: IssueSeverity::Critical,
603                    category: IssueCategory::PathTraversal,
604                    entry_path: None,
605                    message: "Critical issue".to_string(),
606                    context: None,
607                },
608                VerificationIssue {
609                    severity: IssueSeverity::Low,
610                    category: IssueCategory::ExecutableFile,
611                    entry_path: None,
612                    message: "Low issue".to_string(),
613                    context: None,
614                },
615            ],
616            total_entries: 10,
617            suspicious_entries: 2,
618            total_size: 1024,
619            format: ArchiveType::TarGz,
620        };
621
622        let critical_issues = report.issues_by_severity(IssueSeverity::Critical);
623        assert_eq!(critical_issues.len(), 1);
624
625        let low_issues = report.issues_by_severity(IssueSeverity::Low);
626        assert_eq!(low_issues.len(), 1);
627    }
628}