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    pub fn from_error(error: &ExtractionError, entry_path: Option<PathBuf>) -> Self {
161        let (severity, category, message) = match error {
162            ExtractionError::PathTraversal { path } => (
163                IssueSeverity::Critical,
164                IssueCategory::PathTraversal,
165                format!("Path traversal detected: {}", path.display()),
166            ),
167            ExtractionError::SymlinkEscape { path } => (
168                IssueSeverity::Critical,
169                IssueCategory::SymlinkEscape,
170                format!("Symlink escape: {}", path.display()),
171            ),
172            ExtractionError::HardlinkEscape { path } => (
173                IssueSeverity::Critical,
174                IssueCategory::HardlinkEscape,
175                format!("Hardlink escape: {}", path.display()),
176            ),
177            ExtractionError::ZipBomb {
178                compressed,
179                uncompressed,
180                ratio,
181            } => (
182                IssueSeverity::Critical,
183                IssueCategory::ZipBomb,
184                format!(
185                    "Potential zip bomb: {ratio:.1}x compression ratio (compressed={compressed}, uncompressed={uncompressed})"
186                ),
187            ),
188            ExtractionError::QuotaExceeded { resource } => (
189                IssueSeverity::High,
190                IssueCategory::QuotaExceeded,
191                format!("{resource}"),
192            ),
193            ExtractionError::InvalidPermissions { path, mode } => (
194                IssueSeverity::Medium,
195                IssueCategory::InvalidPermissions,
196                format!(
197                    "Invalid permissions: {} (mode: {:#o})",
198                    path.display(),
199                    mode
200                ),
201            ),
202            ExtractionError::Io(io_err) => (
203                IssueSeverity::High,
204                IssueCategory::InvalidArchive,
205                format!("I/O error: {io_err}"),
206            ),
207            ExtractionError::UnsupportedFormat => (
208                IssueSeverity::High,
209                IssueCategory::InvalidArchive,
210                "Unsupported archive format".to_string(),
211            ),
212            ExtractionError::InvalidArchive(msg) => (
213                IssueSeverity::High,
214                IssueCategory::InvalidArchive,
215                format!("Invalid archive: {msg}"),
216            ),
217            ExtractionError::SecurityViolation { reason } => (
218                IssueSeverity::High,
219                IssueCategory::SuspiciousPath,
220                format!("Security violation: {reason}"),
221            ),
222            ExtractionError::SourceNotFound { path } => (
223                IssueSeverity::High,
224                IssueCategory::InvalidArchive,
225                format!("Source not found: {}", path.display()),
226            ),
227            ExtractionError::SourceNotAccessible { path } => (
228                IssueSeverity::High,
229                IssueCategory::InvalidArchive,
230                format!("Source not accessible: {}", path.display()),
231            ),
232            ExtractionError::OutputExists { path } => (
233                IssueSeverity::Medium,
234                IssueCategory::InvalidArchive,
235                format!("Output already exists: {}", path.display()),
236            ),
237            ExtractionError::InvalidCompressionLevel { level } => (
238                IssueSeverity::Medium,
239                IssueCategory::InvalidArchive,
240                format!("Invalid compression level: {level}"),
241            ),
242            ExtractionError::UnknownFormat { path } => (
243                IssueSeverity::High,
244                IssueCategory::InvalidArchive,
245                format!("Unknown format: {}", path.display()),
246            ),
247            ExtractionError::InvalidConfiguration { reason } => (
248                IssueSeverity::High,
249                IssueCategory::InvalidArchive,
250                format!("Invalid configuration: {reason}"),
251            ),
252        };
253
254        Self {
255            severity,
256            category,
257            entry_path,
258            message,
259            context: None,
260        }
261    }
262}
263
264/// Issue severity levels (ordered from least to most severe for Ord).
265#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
266pub enum IssueSeverity {
267    /// Informational
268    Info,
269
270    /// Policy violation
271    Low,
272
273    /// Security concern
274    Medium,
275
276    /// Exploitable security issue
277    High,
278
279    /// CVE-level vulnerability
280    Critical,
281}
282
283impl std::fmt::Display for IssueSeverity {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        match self {
286            Self::Critical => write!(f, "CRITICAL"),
287            Self::High => write!(f, "HIGH"),
288            Self::Medium => write!(f, "MEDIUM"),
289            Self::Low => write!(f, "LOW"),
290            Self::Info => write!(f, "INFO"),
291        }
292    }
293}
294
295/// Issue categories (maps to security checks).
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum IssueCategory {
298    /// Path traversal attack
299    PathTraversal,
300
301    /// Symlink escape attack
302    SymlinkEscape,
303
304    /// Hardlink escape attack
305    HardlinkEscape,
306
307    /// Zip bomb (excessive compression)
308    ZipBomb,
309
310    /// Invalid or unsafe permissions
311    InvalidPermissions,
312
313    /// Quota exceeded (file count or size)
314    QuotaExceeded,
315
316    /// Invalid or corrupted archive
317    InvalidArchive,
318
319    /// Suspicious path or filename
320    SuspiciousPath,
321
322    /// Executable file detected
323    ExecutableFile,
324}
325
326impl std::fmt::Display for IssueCategory {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        match self {
329            Self::PathTraversal => write!(f, "Path Traversal"),
330            Self::SymlinkEscape => write!(f, "Symlink Escape"),
331            Self::HardlinkEscape => write!(f, "Hardlink Escape"),
332            Self::ZipBomb => write!(f, "Zip Bomb"),
333            Self::InvalidPermissions => write!(f, "Invalid Permissions"),
334            Self::QuotaExceeded => write!(f, "Quota Exceeded"),
335            Self::InvalidArchive => write!(f, "Invalid Archive"),
336            Self::SuspiciousPath => write!(f, "Suspicious Path"),
337            Self::ExecutableFile => write!(f, "Executable File"),
338        }
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::io;
346
347    #[test]
348    fn test_verification_status_display() {
349        assert_eq!(VerificationStatus::Pass.to_string(), "PASS");
350        assert_eq!(VerificationStatus::Fail.to_string(), "FAIL");
351        assert_eq!(VerificationStatus::Warning.to_string(), "WARNING");
352    }
353
354    #[test]
355    fn test_check_status_display() {
356        assert_eq!(CheckStatus::Pass.to_string(), "OK");
357        assert_eq!(CheckStatus::Fail.to_string(), "FAILED");
358        assert_eq!(CheckStatus::Warning.to_string(), "WARNING");
359        assert_eq!(CheckStatus::Skipped.to_string(), "SKIPPED");
360    }
361
362    #[test]
363    fn test_issue_severity_display() {
364        assert_eq!(IssueSeverity::Critical.to_string(), "CRITICAL");
365        assert_eq!(IssueSeverity::High.to_string(), "HIGH");
366        assert_eq!(IssueSeverity::Medium.to_string(), "MEDIUM");
367        assert_eq!(IssueSeverity::Low.to_string(), "LOW");
368        assert_eq!(IssueSeverity::Info.to_string(), "INFO");
369    }
370
371    #[test]
372    fn test_issue_severity_ordering() {
373        assert!(IssueSeverity::Critical > IssueSeverity::High);
374        assert!(IssueSeverity::High > IssueSeverity::Medium);
375        assert!(IssueSeverity::Medium > IssueSeverity::Low);
376        assert!(IssueSeverity::Low > IssueSeverity::Info);
377    }
378
379    #[test]
380    fn test_issue_category_display() {
381        assert_eq!(IssueCategory::PathTraversal.to_string(), "Path Traversal");
382        assert_eq!(IssueCategory::SymlinkEscape.to_string(), "Symlink Escape");
383    }
384
385    #[test]
386    fn test_verification_issue_from_path_traversal() {
387        let error = ExtractionError::PathTraversal {
388            path: PathBuf::from("../../etc/passwd"),
389        };
390        let issue = VerificationIssue::from_error(&error, None);
391
392        assert_eq!(issue.severity, IssueSeverity::Critical);
393        assert_eq!(issue.category, IssueCategory::PathTraversal);
394        assert!(issue.message.contains("Path traversal"));
395    }
396
397    #[test]
398    fn test_verification_issue_from_symlink_escape() {
399        let error = ExtractionError::SymlinkEscape {
400            path: PathBuf::from("link"),
401        };
402        let issue = VerificationIssue::from_error(&error, Some(PathBuf::from("link")));
403
404        assert_eq!(issue.severity, IssueSeverity::Critical);
405        assert_eq!(issue.category, IssueCategory::SymlinkEscape);
406        assert!(issue.message.contains("Symlink escape"));
407    }
408
409    #[test]
410    fn test_verification_issue_from_zip_bomb() {
411        let error = ExtractionError::ZipBomb {
412            compressed: 1000,
413            uncompressed: 1_000_000,
414            ratio: 1000.0,
415        };
416        let issue = VerificationIssue::from_error(&error, None);
417
418        assert_eq!(issue.severity, IssueSeverity::Critical);
419        assert_eq!(issue.category, IssueCategory::ZipBomb);
420        assert!(issue.message.contains("zip bomb"));
421    }
422
423    #[test]
424    fn test_verification_issue_from_io_error() {
425        let error = ExtractionError::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
426        let issue = VerificationIssue::from_error(&error, None);
427
428        assert_eq!(issue.severity, IssueSeverity::High);
429        assert_eq!(issue.category, IssueCategory::InvalidArchive);
430    }
431
432    #[test]
433    fn test_verification_report_is_safe() {
434        let report = VerificationReport {
435            status: VerificationStatus::Pass,
436            integrity_status: CheckStatus::Pass,
437            security_status: CheckStatus::Pass,
438            issues: Vec::new(),
439            total_entries: 10,
440            suspicious_entries: 0,
441            total_size: 1024,
442            format: ArchiveType::TarGz,
443        };
444
445        assert!(report.is_safe());
446    }
447
448    #[test]
449    fn test_verification_report_not_safe() {
450        let report = VerificationReport {
451            status: VerificationStatus::Fail,
452            integrity_status: CheckStatus::Pass,
453            security_status: CheckStatus::Fail,
454            issues: vec![VerificationIssue {
455                severity: IssueSeverity::Critical,
456                category: IssueCategory::PathTraversal,
457                entry_path: None,
458                message: "Test issue".to_string(),
459                context: None,
460            }],
461            total_entries: 10,
462            suspicious_entries: 1,
463            total_size: 1024,
464            format: ArchiveType::TarGz,
465        };
466
467        assert!(!report.is_safe());
468        assert!(report.has_critical_issues());
469    }
470
471    #[test]
472    fn test_verification_report_issues_by_severity() {
473        let report = VerificationReport {
474            status: VerificationStatus::Warning,
475            integrity_status: CheckStatus::Pass,
476            security_status: CheckStatus::Warning,
477            issues: vec![
478                VerificationIssue {
479                    severity: IssueSeverity::Critical,
480                    category: IssueCategory::PathTraversal,
481                    entry_path: None,
482                    message: "Critical issue".to_string(),
483                    context: None,
484                },
485                VerificationIssue {
486                    severity: IssueSeverity::Low,
487                    category: IssueCategory::ExecutableFile,
488                    entry_path: None,
489                    message: "Low issue".to_string(),
490                    context: None,
491                },
492            ],
493            total_entries: 10,
494            suspicious_entries: 2,
495            total_size: 1024,
496            format: ArchiveType::TarGz,
497        };
498
499        let critical_issues = report.issues_by_severity(IssueSeverity::Critical);
500        assert_eq!(critical_issues.len(), 1);
501
502        let low_issues = report.issues_by_severity(IssueSeverity::Low);
503        assert_eq!(low_issues.len(), 1);
504    }
505}