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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum IssueCategory {
297    /// Path traversal attack
298    PathTraversal,
299
300    /// Symlink escape attack
301    SymlinkEscape,
302
303    /// Hardlink escape attack
304    HardlinkEscape,
305
306    /// Zip bomb (excessive compression)
307    ZipBomb,
308
309    /// Invalid or unsafe permissions
310    InvalidPermissions,
311
312    /// Quota exceeded (file count or size)
313    QuotaExceeded,
314
315    /// Invalid or corrupted archive
316    InvalidArchive,
317
318    /// Suspicious path or filename
319    SuspiciousPath,
320
321    /// Executable file detected
322    ExecutableFile,
323}
324
325impl std::fmt::Display for IssueCategory {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        match self {
328            Self::PathTraversal => write!(f, "Path Traversal"),
329            Self::SymlinkEscape => write!(f, "Symlink Escape"),
330            Self::HardlinkEscape => write!(f, "Hardlink Escape"),
331            Self::ZipBomb => write!(f, "Zip Bomb"),
332            Self::InvalidPermissions => write!(f, "Invalid Permissions"),
333            Self::QuotaExceeded => write!(f, "Quota Exceeded"),
334            Self::InvalidArchive => write!(f, "Invalid Archive"),
335            Self::SuspiciousPath => write!(f, "Suspicious Path"),
336            Self::ExecutableFile => write!(f, "Executable File"),
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::io;
345
346    #[test]
347    fn test_verification_status_display() {
348        assert_eq!(VerificationStatus::Pass.to_string(), "PASS");
349        assert_eq!(VerificationStatus::Fail.to_string(), "FAIL");
350        assert_eq!(VerificationStatus::Warning.to_string(), "WARNING");
351    }
352
353    #[test]
354    fn test_check_status_display() {
355        assert_eq!(CheckStatus::Pass.to_string(), "OK");
356        assert_eq!(CheckStatus::Fail.to_string(), "FAILED");
357        assert_eq!(CheckStatus::Warning.to_string(), "WARNING");
358        assert_eq!(CheckStatus::Skipped.to_string(), "SKIPPED");
359    }
360
361    #[test]
362    fn test_issue_severity_display() {
363        assert_eq!(IssueSeverity::Critical.to_string(), "CRITICAL");
364        assert_eq!(IssueSeverity::High.to_string(), "HIGH");
365        assert_eq!(IssueSeverity::Medium.to_string(), "MEDIUM");
366        assert_eq!(IssueSeverity::Low.to_string(), "LOW");
367        assert_eq!(IssueSeverity::Info.to_string(), "INFO");
368    }
369
370    #[test]
371    fn test_issue_severity_ordering() {
372        assert!(IssueSeverity::Critical > IssueSeverity::High);
373        assert!(IssueSeverity::High > IssueSeverity::Medium);
374        assert!(IssueSeverity::Medium > IssueSeverity::Low);
375        assert!(IssueSeverity::Low > IssueSeverity::Info);
376    }
377
378    #[test]
379    fn test_issue_category_display() {
380        assert_eq!(IssueCategory::PathTraversal.to_string(), "Path Traversal");
381        assert_eq!(IssueCategory::SymlinkEscape.to_string(), "Symlink Escape");
382    }
383
384    #[test]
385    fn test_verification_issue_from_path_traversal() {
386        let error = ArchiveError::PathTraversal {
387            path: PathBuf::from("../../etc/passwd"),
388        };
389        let issue = VerificationIssue::from_error(&error, None);
390
391        assert_eq!(issue.severity, IssueSeverity::Critical);
392        assert_eq!(issue.category, IssueCategory::PathTraversal);
393        assert!(issue.message.contains("Path traversal"));
394    }
395
396    #[test]
397    fn test_verification_issue_from_symlink_escape() {
398        let error = ArchiveError::SymlinkEscape {
399            path: PathBuf::from("link"),
400        };
401        let issue = VerificationIssue::from_error(&error, Some(PathBuf::from("link")));
402
403        assert_eq!(issue.severity, IssueSeverity::Critical);
404        assert_eq!(issue.category, IssueCategory::SymlinkEscape);
405        assert!(issue.message.contains("Symlink escape"));
406    }
407
408    #[test]
409    fn test_verification_issue_from_zip_bomb() {
410        let error = ArchiveError::ZipBomb {
411            compressed: 1000,
412            uncompressed: 1_000_000,
413            ratio: 1000.0,
414        };
415        let issue = VerificationIssue::from_error(&error, None);
416
417        assert_eq!(issue.severity, IssueSeverity::Critical);
418        assert_eq!(issue.category, IssueCategory::ZipBomb);
419        assert!(issue.message.contains("zip bomb"));
420    }
421
422    #[test]
423    fn test_verification_issue_from_io_error() {
424        let error = ArchiveError::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
425        let issue = VerificationIssue::from_error(&error, None);
426
427        assert_eq!(issue.severity, IssueSeverity::High);
428        assert_eq!(issue.category, IssueCategory::InvalidArchive);
429    }
430
431    #[test]
432    fn test_verification_report_is_safe() {
433        let report = VerificationReport {
434            status: VerificationStatus::Pass,
435            integrity_status: CheckStatus::Pass,
436            security_status: CheckStatus::Pass,
437            issues: Vec::new(),
438            total_entries: 10,
439            suspicious_entries: 0,
440            total_size: 1024,
441            format: ArchiveType::TarGz,
442        };
443
444        assert!(report.is_safe());
445    }
446
447    #[test]
448    fn test_verification_report_not_safe() {
449        let report = VerificationReport {
450            status: VerificationStatus::Fail,
451            integrity_status: CheckStatus::Pass,
452            security_status: CheckStatus::Fail,
453            issues: vec![VerificationIssue {
454                severity: IssueSeverity::Critical,
455                category: IssueCategory::PathTraversal,
456                entry_path: None,
457                message: "Test issue".to_string(),
458                context: None,
459            }],
460            total_entries: 10,
461            suspicious_entries: 1,
462            total_size: 1024,
463            format: ArchiveType::TarGz,
464        };
465
466        assert!(!report.is_safe());
467        assert!(report.has_critical_issues());
468    }
469
470    #[test]
471    fn test_verification_issue_from_hardlink_escape() {
472        let error = ArchiveError::HardlinkEscape {
473            path: PathBuf::from("link"),
474        };
475        let issue = VerificationIssue::from_error(&error, None);
476        assert_eq!(issue.severity, IssueSeverity::Critical);
477        assert_eq!(issue.category, IssueCategory::HardlinkEscape);
478        assert!(issue.message.contains("Hardlink escape"));
479    }
480
481    #[test]
482    fn test_verification_issue_from_quota_exceeded() {
483        let error = ArchiveError::QuotaExceeded {
484            resource: crate::error::QuotaResource::FileCount {
485                current: 11,
486                max: 10,
487            },
488        };
489        let issue = VerificationIssue::from_error(&error, None);
490        assert_eq!(issue.severity, IssueSeverity::High);
491        assert_eq!(issue.category, IssueCategory::QuotaExceeded);
492    }
493
494    #[test]
495    fn test_verification_issue_from_invalid_permissions() {
496        let error = ArchiveError::InvalidPermissions {
497            path: PathBuf::from("file.txt"),
498            mode: 0o777,
499        };
500        let issue = VerificationIssue::from_error(&error, None);
501        assert_eq!(issue.severity, IssueSeverity::Medium);
502        assert_eq!(issue.category, IssueCategory::InvalidPermissions);
503        assert!(issue.message.contains("Invalid permissions"));
504    }
505
506    #[test]
507    fn test_verification_issue_from_invalid_archive() {
508        let error = ArchiveError::InvalidArchive("bad header".into());
509        let issue = VerificationIssue::from_error(&error, None);
510        assert_eq!(issue.severity, IssueSeverity::High);
511        assert_eq!(issue.category, IssueCategory::InvalidArchive);
512        assert!(issue.message.contains("Invalid archive"));
513    }
514
515    #[test]
516    fn test_verification_issue_from_security_violation() {
517        let error = ArchiveError::SecurityViolation {
518            reason: "encrypted".into(),
519        };
520        let issue = VerificationIssue::from_error(&error, None);
521        assert_eq!(issue.severity, IssueSeverity::High);
522        assert_eq!(issue.category, IssueCategory::SuspiciousPath);
523        assert!(issue.message.contains("Security violation"));
524    }
525
526    #[test]
527    fn test_verification_issue_from_source_not_found() {
528        let error = ArchiveError::SourceNotFound {
529            path: PathBuf::from("/missing"),
530        };
531        let issue = VerificationIssue::from_error(&error, None);
532        assert_eq!(issue.severity, IssueSeverity::High);
533        assert_eq!(issue.category, IssueCategory::InvalidArchive);
534        assert!(issue.message.contains("Source not found"));
535    }
536
537    #[test]
538    fn test_verification_issue_from_source_not_accessible() {
539        let error = ArchiveError::SourceNotAccessible {
540            path: PathBuf::from("/restricted"),
541        };
542        let issue = VerificationIssue::from_error(&error, None);
543        assert_eq!(issue.severity, IssueSeverity::High);
544        assert_eq!(issue.category, IssueCategory::InvalidArchive);
545        assert!(issue.message.contains("Source not accessible"));
546    }
547
548    #[test]
549    fn test_verification_issue_from_output_exists() {
550        let error = ArchiveError::OutputExists {
551            path: PathBuf::from("out/"),
552        };
553        let issue = VerificationIssue::from_error(&error, None);
554        assert_eq!(issue.severity, IssueSeverity::Medium);
555        assert_eq!(issue.category, IssueCategory::InvalidArchive);
556        assert!(issue.message.contains("Output already exists"));
557    }
558
559    #[test]
560    fn test_verification_issue_from_invalid_compression_level() {
561        let error = ArchiveError::InvalidCompressionLevel { level: 0 };
562        let issue = VerificationIssue::from_error(&error, None);
563        assert_eq!(issue.severity, IssueSeverity::Medium);
564        assert_eq!(issue.category, IssueCategory::InvalidArchive);
565        assert!(issue.message.contains("Invalid compression level"));
566    }
567
568    #[test]
569    fn test_verification_issue_from_unknown_format() {
570        let error = ArchiveError::UnknownFormat {
571            path: PathBuf::from("archive.rar"),
572        };
573        let issue = VerificationIssue::from_error(&error, None);
574        assert_eq!(issue.severity, IssueSeverity::High);
575        assert_eq!(issue.category, IssueCategory::InvalidArchive);
576        assert!(issue.message.contains("Unknown format"));
577    }
578
579    #[test]
580    fn test_verification_issue_from_invalid_configuration() {
581        let error = ArchiveError::InvalidConfiguration {
582            reason: "bad config".into(),
583        };
584        let issue = VerificationIssue::from_error(&error, None);
585        assert_eq!(issue.severity, IssueSeverity::High);
586        assert_eq!(issue.category, IssueCategory::InvalidArchive);
587        assert!(issue.message.contains("Invalid configuration"));
588    }
589
590    #[test]
591    fn test_verification_report_issues_by_severity() {
592        let report = VerificationReport {
593            status: VerificationStatus::Warning,
594            integrity_status: CheckStatus::Pass,
595            security_status: CheckStatus::Warning,
596            issues: vec![
597                VerificationIssue {
598                    severity: IssueSeverity::Critical,
599                    category: IssueCategory::PathTraversal,
600                    entry_path: None,
601                    message: "Critical issue".to_string(),
602                    context: None,
603                },
604                VerificationIssue {
605                    severity: IssueSeverity::Low,
606                    category: IssueCategory::ExecutableFile,
607                    entry_path: None,
608                    message: "Low issue".to_string(),
609                    context: None,
610                },
611            ],
612            total_entries: 10,
613            suspicious_entries: 2,
614            total_size: 1024,
615            format: ArchiveType::TarGz,
616        };
617
618        let critical_issues = report.issues_by_severity(IssueSeverity::Critical);
619        assert_eq!(critical_issues.len(), 1);
620
621        let low_issues = report.issues_by_severity(IssueSeverity::Low);
622        assert_eq!(low_issues.len(), 1);
623    }
624}