use std::path::PathBuf;
use crate::error::ExtractionError;
use crate::formats::detect::ArchiveType;
#[derive(Debug, Clone)]
pub struct VerificationReport {
pub status: VerificationStatus,
pub integrity_status: CheckStatus,
pub security_status: CheckStatus,
pub issues: Vec<VerificationIssue>,
pub total_entries: usize,
pub suspicious_entries: usize,
pub total_size: u64,
pub format: ArchiveType,
}
impl VerificationReport {
#[must_use]
pub fn is_safe(&self) -> bool {
self.status == VerificationStatus::Pass
}
#[must_use]
pub fn has_critical_issues(&self) -> bool {
self.issues
.iter()
.any(|i| i.severity == IssueSeverity::Critical)
}
#[must_use]
pub fn issues_by_severity(&self, severity: IssueSeverity) -> Vec<&VerificationIssue> {
self.issues
.iter()
.filter(|i| i.severity == severity)
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerificationStatus {
Pass,
Fail,
Warning,
}
impl std::fmt::Display for VerificationStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pass => write!(f, "PASS"),
Self::Fail => write!(f, "FAIL"),
Self::Warning => write!(f, "WARNING"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStatus {
Pass,
Fail,
Warning,
Skipped,
}
impl std::fmt::Display for CheckStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pass => write!(f, "OK"),
Self::Fail => write!(f, "FAILED"),
Self::Warning => write!(f, "WARNING"),
Self::Skipped => write!(f, "SKIPPED"),
}
}
}
#[derive(Debug, Clone)]
pub struct VerificationIssue {
pub severity: IssueSeverity,
pub category: IssueCategory,
pub entry_path: Option<PathBuf>,
pub message: String,
pub context: Option<String>,
}
impl VerificationIssue {
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn from_error(error: &ExtractionError, entry_path: Option<PathBuf>) -> Self {
let (severity, category, message) = match error {
ExtractionError::PathTraversal { path } => (
IssueSeverity::Critical,
IssueCategory::PathTraversal,
format!("Path traversal detected: {}", path.display()),
),
ExtractionError::SymlinkEscape { path } => (
IssueSeverity::Critical,
IssueCategory::SymlinkEscape,
format!("Symlink escape: {}", path.display()),
),
ExtractionError::HardlinkEscape { path } => (
IssueSeverity::Critical,
IssueCategory::HardlinkEscape,
format!("Hardlink escape: {}", path.display()),
),
ExtractionError::ZipBomb {
compressed,
uncompressed,
ratio,
} => (
IssueSeverity::Critical,
IssueCategory::ZipBomb,
format!(
"Potential zip bomb: {ratio:.1}x compression ratio (compressed={compressed}, uncompressed={uncompressed})"
),
),
ExtractionError::QuotaExceeded { resource } => (
IssueSeverity::High,
IssueCategory::QuotaExceeded,
format!("{resource}"),
),
ExtractionError::InvalidPermissions { path, mode } => (
IssueSeverity::Medium,
IssueCategory::InvalidPermissions,
format!(
"Invalid permissions: {} (mode: {:#o})",
path.display(),
mode
),
),
ExtractionError::Io(io_err) => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("I/O error: {io_err}"),
),
ExtractionError::UnsupportedFormat => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
"Unsupported archive format".to_string(),
),
ExtractionError::InvalidArchive(msg) => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Invalid archive: {msg}"),
),
ExtractionError::SecurityViolation { reason } => (
IssueSeverity::High,
IssueCategory::SuspiciousPath,
format!("Security violation: {reason}"),
),
ExtractionError::SourceNotFound { path } => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Source not found: {}", path.display()),
),
ExtractionError::SourceNotAccessible { path } => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Source not accessible: {}", path.display()),
),
ExtractionError::OutputExists { path } => (
IssueSeverity::Medium,
IssueCategory::InvalidArchive,
format!("Output already exists: {}", path.display()),
),
ExtractionError::InvalidCompressionLevel { level } => (
IssueSeverity::Medium,
IssueCategory::InvalidArchive,
format!("Invalid compression level: {level}"),
),
ExtractionError::UnknownFormat { path } => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Unknown format: {}", path.display()),
),
ExtractionError::InvalidConfiguration { reason } => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Invalid configuration: {reason}"),
),
ExtractionError::PartialExtraction { source, .. } => {
return Self::from_error(source, entry_path);
}
};
Self {
severity,
category,
entry_path,
message,
context: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum IssueSeverity {
Info,
Low,
Medium,
High,
Critical,
}
impl std::fmt::Display for IssueSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Critical => write!(f, "CRITICAL"),
Self::High => write!(f, "HIGH"),
Self::Medium => write!(f, "MEDIUM"),
Self::Low => write!(f, "LOW"),
Self::Info => write!(f, "INFO"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueCategory {
PathTraversal,
SymlinkEscape,
HardlinkEscape,
ZipBomb,
InvalidPermissions,
QuotaExceeded,
InvalidArchive,
SuspiciousPath,
ExecutableFile,
}
impl std::fmt::Display for IssueCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PathTraversal => write!(f, "Path Traversal"),
Self::SymlinkEscape => write!(f, "Symlink Escape"),
Self::HardlinkEscape => write!(f, "Hardlink Escape"),
Self::ZipBomb => write!(f, "Zip Bomb"),
Self::InvalidPermissions => write!(f, "Invalid Permissions"),
Self::QuotaExceeded => write!(f, "Quota Exceeded"),
Self::InvalidArchive => write!(f, "Invalid Archive"),
Self::SuspiciousPath => write!(f, "Suspicious Path"),
Self::ExecutableFile => write!(f, "Executable File"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[test]
fn test_verification_status_display() {
assert_eq!(VerificationStatus::Pass.to_string(), "PASS");
assert_eq!(VerificationStatus::Fail.to_string(), "FAIL");
assert_eq!(VerificationStatus::Warning.to_string(), "WARNING");
}
#[test]
fn test_check_status_display() {
assert_eq!(CheckStatus::Pass.to_string(), "OK");
assert_eq!(CheckStatus::Fail.to_string(), "FAILED");
assert_eq!(CheckStatus::Warning.to_string(), "WARNING");
assert_eq!(CheckStatus::Skipped.to_string(), "SKIPPED");
}
#[test]
fn test_issue_severity_display() {
assert_eq!(IssueSeverity::Critical.to_string(), "CRITICAL");
assert_eq!(IssueSeverity::High.to_string(), "HIGH");
assert_eq!(IssueSeverity::Medium.to_string(), "MEDIUM");
assert_eq!(IssueSeverity::Low.to_string(), "LOW");
assert_eq!(IssueSeverity::Info.to_string(), "INFO");
}
#[test]
fn test_issue_severity_ordering() {
assert!(IssueSeverity::Critical > IssueSeverity::High);
assert!(IssueSeverity::High > IssueSeverity::Medium);
assert!(IssueSeverity::Medium > IssueSeverity::Low);
assert!(IssueSeverity::Low > IssueSeverity::Info);
}
#[test]
fn test_issue_category_display() {
assert_eq!(IssueCategory::PathTraversal.to_string(), "Path Traversal");
assert_eq!(IssueCategory::SymlinkEscape.to_string(), "Symlink Escape");
}
#[test]
fn test_verification_issue_from_path_traversal() {
let error = ExtractionError::PathTraversal {
path: PathBuf::from("../../etc/passwd"),
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::Critical);
assert_eq!(issue.category, IssueCategory::PathTraversal);
assert!(issue.message.contains("Path traversal"));
}
#[test]
fn test_verification_issue_from_symlink_escape() {
let error = ExtractionError::SymlinkEscape {
path: PathBuf::from("link"),
};
let issue = VerificationIssue::from_error(&error, Some(PathBuf::from("link")));
assert_eq!(issue.severity, IssueSeverity::Critical);
assert_eq!(issue.category, IssueCategory::SymlinkEscape);
assert!(issue.message.contains("Symlink escape"));
}
#[test]
fn test_verification_issue_from_zip_bomb() {
let error = ExtractionError::ZipBomb {
compressed: 1000,
uncompressed: 1_000_000,
ratio: 1000.0,
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::Critical);
assert_eq!(issue.category, IssueCategory::ZipBomb);
assert!(issue.message.contains("zip bomb"));
}
#[test]
fn test_verification_issue_from_io_error() {
let error = ExtractionError::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::InvalidArchive);
}
#[test]
fn test_verification_report_is_safe() {
let report = VerificationReport {
status: VerificationStatus::Pass,
integrity_status: CheckStatus::Pass,
security_status: CheckStatus::Pass,
issues: Vec::new(),
total_entries: 10,
suspicious_entries: 0,
total_size: 1024,
format: ArchiveType::TarGz,
};
assert!(report.is_safe());
}
#[test]
fn test_verification_report_not_safe() {
let report = VerificationReport {
status: VerificationStatus::Fail,
integrity_status: CheckStatus::Pass,
security_status: CheckStatus::Fail,
issues: vec![VerificationIssue {
severity: IssueSeverity::Critical,
category: IssueCategory::PathTraversal,
entry_path: None,
message: "Test issue".to_string(),
context: None,
}],
total_entries: 10,
suspicious_entries: 1,
total_size: 1024,
format: ArchiveType::TarGz,
};
assert!(!report.is_safe());
assert!(report.has_critical_issues());
}
#[test]
fn test_verification_issue_from_hardlink_escape() {
let error = ExtractionError::HardlinkEscape {
path: PathBuf::from("link"),
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::Critical);
assert_eq!(issue.category, IssueCategory::HardlinkEscape);
assert!(issue.message.contains("Hardlink escape"));
}
#[test]
fn test_verification_issue_from_quota_exceeded() {
let error = ExtractionError::QuotaExceeded {
resource: crate::error::QuotaResource::FileCount {
current: 11,
max: 10,
},
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::QuotaExceeded);
}
#[test]
fn test_verification_issue_from_invalid_permissions() {
let error = ExtractionError::InvalidPermissions {
path: PathBuf::from("file.txt"),
mode: 0o777,
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::Medium);
assert_eq!(issue.category, IssueCategory::InvalidPermissions);
assert!(issue.message.contains("Invalid permissions"));
}
#[test]
fn test_verification_issue_from_unsupported_format() {
let error = ExtractionError::UnsupportedFormat;
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::InvalidArchive);
assert!(issue.message.contains("Unsupported archive format"));
}
#[test]
fn test_verification_issue_from_invalid_archive() {
let error = ExtractionError::InvalidArchive("bad header".into());
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::InvalidArchive);
assert!(issue.message.contains("Invalid archive"));
}
#[test]
fn test_verification_issue_from_security_violation() {
let error = ExtractionError::SecurityViolation {
reason: "encrypted".into(),
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::SuspiciousPath);
assert!(issue.message.contains("Security violation"));
}
#[test]
fn test_verification_issue_from_source_not_found() {
let error = ExtractionError::SourceNotFound {
path: PathBuf::from("/missing"),
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::InvalidArchive);
assert!(issue.message.contains("Source not found"));
}
#[test]
fn test_verification_issue_from_source_not_accessible() {
let error = ExtractionError::SourceNotAccessible {
path: PathBuf::from("/restricted"),
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::InvalidArchive);
assert!(issue.message.contains("Source not accessible"));
}
#[test]
fn test_verification_issue_from_output_exists() {
let error = ExtractionError::OutputExists {
path: PathBuf::from("out/"),
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::Medium);
assert_eq!(issue.category, IssueCategory::InvalidArchive);
assert!(issue.message.contains("Output already exists"));
}
#[test]
fn test_verification_issue_from_invalid_compression_level() {
let error = ExtractionError::InvalidCompressionLevel { level: 0 };
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::Medium);
assert_eq!(issue.category, IssueCategory::InvalidArchive);
assert!(issue.message.contains("Invalid compression level"));
}
#[test]
fn test_verification_issue_from_unknown_format() {
let error = ExtractionError::UnknownFormat {
path: PathBuf::from("archive.rar"),
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::InvalidArchive);
assert!(issue.message.contains("Unknown format"));
}
#[test]
fn test_verification_issue_from_invalid_configuration() {
let error = ExtractionError::InvalidConfiguration {
reason: "bad config".into(),
};
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::InvalidArchive);
assert!(issue.message.contains("Invalid configuration"));
}
#[test]
fn test_verification_report_issues_by_severity() {
let report = VerificationReport {
status: VerificationStatus::Warning,
integrity_status: CheckStatus::Pass,
security_status: CheckStatus::Warning,
issues: vec![
VerificationIssue {
severity: IssueSeverity::Critical,
category: IssueCategory::PathTraversal,
entry_path: None,
message: "Critical issue".to_string(),
context: None,
},
VerificationIssue {
severity: IssueSeverity::Low,
category: IssueCategory::ExecutableFile,
entry_path: None,
message: "Low issue".to_string(),
context: None,
},
],
total_entries: 10,
suspicious_entries: 2,
total_size: 1024,
format: ArchiveType::TarGz,
};
let critical_issues = report.issues_by_severity(IssueSeverity::Critical);
assert_eq!(critical_issues.len(), 1);
let low_issues = report.issues_by_severity(IssueSeverity::Low);
assert_eq!(low_issues.len(), 1);
}
}