use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use crate::closed_enum::closed_enum;
use crate::error::ArchiveError;
use crate::error::ZipInconsistency;
use crate::formats::detect::ArchiveType;
use crate::inspection::manifest::ArchiveEntry;
use crate::inspection::manifest::ArchiveManifest;
#[derive(Debug, Clone)]
pub struct VerificationReport {
issues: Vec<VerificationIssue>,
integrity: IntegrityScope,
total_entries: usize,
suspicious_entries: usize,
total_size: u64,
format: ArchiveType,
}
impl VerificationReport {
#[must_use]
pub fn from_entry_issues<F>(manifest: &ArchiveManifest, issues_for: F) -> Self
where
F: FnMut(&ArchiveEntry) -> Vec<VerificationIssue>,
{
let lists = manifest.entries().iter().map(issues_for).collect();
Self::from_entry_issue_lists(
manifest,
lists,
Vec::new(),
&ArchiveStructure::of_manifest(manifest),
IntegrityScope::NotRequested,
)
}
pub(crate) fn from_entry_issue_lists(
manifest: &ArchiveManifest,
mut lists: Vec<Vec<VerificationIssue>>,
archive_issues: Vec<VerificationIssue>,
structure: &ArchiveStructure<'_>,
integrity: IntegrityScope,
) -> Self {
for finding in structure.findings {
let bound_entry = match finding {
ZipInconsistency::HeaderMismatch { entry, .. }
| ZipInconsistency::HeaderUncomparable { entry, .. }
| ZipInconsistency::DescriptorOverrun { entry, .. }
| ZipInconsistency::XlTypeDivergence { entry, .. } => {
manifest.position_of_zip_entry(*entry)
}
ZipInconsistency::DuplicateName { .. }
| ZipInconsistency::OverlappingEntries
| ZipInconsistency::HiddenLocalHeader { .. }
| ZipInconsistency::IgnoredEndRecord { .. }
| ZipInconsistency::StrayCentralRecord { .. }
| ZipInconsistency::StrayEndRecord { .. }
| ZipInconsistency::UncountedCentralRecords { .. } => None,
};
let mut issue = VerificationIssue::from_error(
&ArchiveError::ZipInconsistency(finding.clone()),
None,
);
match bound_entry {
Some(position) => {
issue.entry_path = Some(manifest.entries()[position].path.clone());
lists[position].push(issue);
}
None => lists.push(vec![issue]),
}
}
if structure.omitted > 0 {
lists.push(vec![VerificationIssue::new(
IssueSeverity::Info,
IssueCategory::InvalidArchive,
format!(
"{} further ZIP structure findings are not listed",
structure.omitted
),
)]);
}
let mut issues = archive_issues;
let mut suspicious_entries = 0;
for (index, entry_issues) in lists.into_iter().enumerate() {
if index < manifest.total_entries()
&& entry_issues
.iter()
.any(|i| i.severity >= IssueSeverity::Medium)
{
suspicious_entries += 1;
}
issues.extend(entry_issues);
}
issues.sort_by(|a, b| a.severity.cmp(&b.severity).reverse());
Self {
issues,
integrity,
total_entries: structure.total_entries,
suspicious_entries,
total_size: manifest.total_size(),
format: manifest.format(),
}
}
#[must_use]
pub fn status(&self) -> VerificationStatus {
determine_status(&self.issues)
}
#[must_use]
pub fn security_status(&self) -> CheckStatus {
determine_security_status(&self.issues)
}
#[must_use]
pub fn integrity_status(&self) -> IntegrityStatus {
determine_integrity_status(&self.issues, &self.integrity)
}
#[must_use]
pub const fn integrity_gaps(&self) -> Option<&IntegrityGaps> {
match &self.integrity {
IntegrityScope::Requested(gaps) => Some(gaps),
IntegrityScope::NotRequested => None,
}
}
#[must_use]
pub fn issues(&self) -> &[VerificationIssue] {
&self.issues
}
#[must_use]
pub fn into_issues(self) -> Vec<VerificationIssue> {
self.issues
}
#[must_use]
pub const fn total_entries(&self) -> usize {
self.total_entries
}
#[must_use]
pub const fn suspicious_entries(&self) -> usize {
self.suspicious_entries
}
#[must_use]
pub const fn total_size(&self) -> u64 {
self.total_size
}
#[must_use]
pub const fn format(&self) -> ArchiveType {
self.format
}
#[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)]
pub(crate) struct ArchiveStructure<'a> {
total_entries: usize,
findings: &'a [ZipInconsistency],
omitted: usize,
refusals: &'a [VerificationIssue],
}
impl<'a> ArchiveStructure<'a> {
pub(crate) const fn new(
total_entries: usize,
findings: &'a [ZipInconsistency],
omitted: usize,
) -> Self {
Self {
total_entries,
findings,
omitted,
refusals: &[],
}
}
pub(crate) const fn with_refusals(mut self, refusals: &'a [VerificationIssue]) -> Self {
self.refusals = refusals;
self
}
pub(crate) const fn refusals(&self) -> &'a [VerificationIssue] {
self.refusals
}
pub(crate) fn of_manifest(manifest: &ArchiveManifest) -> Self {
Self::new(manifest.total_entries(), &[], 0)
}
}
fn determine_status(issues: &[VerificationIssue]) -> VerificationStatus {
let has_critical = issues.iter().any(|i| i.severity == IssueSeverity::Critical);
let has_high = issues.iter().any(|i| i.severity == IssueSeverity::High);
let has_medium = issues.iter().any(|i| i.severity == IssueSeverity::Medium);
if has_critical || has_high {
VerificationStatus::Fail
} else if has_medium {
VerificationStatus::Warning
} else {
VerificationStatus::Pass
}
}
fn determine_integrity_status(
issues: &[VerificationIssue],
scope: &IntegrityScope,
) -> IntegrityStatus {
let has = |category| issues.iter().any(|i| i.category == category);
if has(IssueCategory::CorruptData) {
IntegrityStatus::Fail
} else if matches!(scope, IntegrityScope::NotRequested) {
IntegrityStatus::Skipped
} else if has(IssueCategory::IntegrityIncomplete) {
IntegrityStatus::Incomplete
} else {
IntegrityStatus::Pass
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum IntegrityScope {
NotRequested,
Requested(IntegrityGaps),
}
closed_enum! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum UnverifiedReason {
OverBudget => "over_budget",
ResourceLimit => "resource_limit",
SolidPolicy => "solid_policy",
SizeCeiling => "size_ceiling",
UncoveredBlockCrc => "uncovered_block_crc",
NotDecodable => "not_decodable",
NoEndMarker => "no_end_marker",
PathTooLong => "path_too_long",
DescriptorTail => "descriptor_tail",
}
}
impl UnverifiedReason {
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::OverBudget => "over budget",
Self::ResourceLimit => "resource limit",
Self::SolidPolicy => "solid archive with allow_solid_archives off",
Self::SizeCeiling => "declared size exceeded",
Self::UncoveredBlockCrc => "block checksum not reachable",
Self::NotDecodable => "not decodable",
Self::NoEndMarker => "no end-of-archive marker",
Self::PathTooLong => "path too long for the host",
Self::DescriptorTail => {
"data-descriptor tail not searched (budget, or raw decode disagrees with the zip crate)"
}
}
}
}
pub(crate) const MAX_LISTED_UNVERIFIED: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnverifiedEntry {
path: PathBuf,
reason: UnverifiedReason,
}
impl UnverifiedEntry {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub const fn reason(&self) -> UnverifiedReason {
self.reason
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IntegrityGaps {
counts: BTreeMap<UnverifiedReason, u64>,
entries: Vec<UnverifiedEntry>,
omitted: u64,
}
impl IntegrityGaps {
pub fn counts(&self) -> impl Iterator<Item = (UnverifiedReason, u64)> + '_ {
self.counts.iter().map(|(&reason, &count)| (reason, count))
}
#[must_use]
pub fn count(&self, reason: UnverifiedReason) -> u64 {
self.counts.get(&reason).copied().unwrap_or_default()
}
#[must_use]
pub fn total(&self) -> u64 {
self.counts.values().sum()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.counts.is_empty()
}
#[must_use]
pub fn entries(&self) -> &[UnverifiedEntry] {
&self.entries
}
#[must_use]
pub const fn omitted(&self) -> u64 {
self.omitted
}
pub(crate) fn count_items(&mut self, reason: UnverifiedReason, items: u64) {
if items > 0 {
*self.counts.entry(reason).or_default() += items;
}
}
pub(crate) fn mark_entry(&mut self, reason: UnverifiedReason, path: &Path) {
self.count_items(reason, 1);
if self.entries.len() < MAX_LISTED_UNVERIFIED {
self.entries.push(UnverifiedEntry {
path: path.to_path_buf(),
reason,
});
} else {
self.omitted += 1;
}
}
pub(crate) fn message(&self) -> Option<String> {
if self.is_empty() {
return None;
}
let detail = self
.counts()
.map(|(reason, count)| format!("{}: {count}", reason.description()))
.collect::<Vec<_>>()
.join(", ");
Some(format!(
"Integrity check incomplete: {} item(s) not verified ({detail})",
self.total()
))
}
}
fn determine_security_status(issues: &[VerificationIssue]) -> CheckStatus {
let mut security_issues = issues
.iter()
.filter(|i| i.category.is_security() && !i.is_harmless_collision())
.peekable();
if security_issues.peek().is_none() {
return CheckStatus::Pass;
}
let fails = security_issues
.any(|i| i.severity == IssueSeverity::Critical || i.severity == IssueSeverity::High);
if fails {
CheckStatus::Fail
} else {
CheckStatus::Warning
}
}
closed_enum! {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerificationStatus {
Pass => "PASS",
Fail => "FAIL",
Warning => "WARNING",
}
}
closed_enum! {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStatus {
Pass => "OK",
Fail => "FAILED",
Warning => "WARNING",
Skipped => "SKIPPED",
}
}
closed_enum! {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IntegrityStatus {
Pass => "OK",
Fail => "FAILED",
Incomplete => "INCOMPLETE",
Skipped => "SKIPPED",
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct VerificationIssue {
pub severity: IssueSeverity,
pub category: IssueCategory,
pub entry_path: Option<PathBuf>,
pub message: String,
pub context: Option<String>,
}
impl VerificationIssue {
fn is_harmless_collision(&self) -> bool {
self.category == IssueCategory::NameCollision && self.severity == IssueSeverity::Low
}
#[must_use]
pub const fn new(severity: IssueSeverity, category: IssueCategory, message: String) -> Self {
Self {
severity,
category,
entry_path: None,
message,
context: None,
}
}
#[must_use]
pub fn from_error(error: &ArchiveError, entry_path: Option<PathBuf>) -> Self {
let (severity, category, message) = match error {
ArchiveError::PathTraversal { path } => (
IssueSeverity::Critical,
IssueCategory::PathTraversal,
format!("Path traversal detected: {}", path.display()),
),
ArchiveError::SymlinkEscape { path } => (
IssueSeverity::Critical,
IssueCategory::SymlinkEscape,
format!("Symlink escape: {}", path.display()),
),
ArchiveError::HardlinkEscape { path } => (
IssueSeverity::Critical,
IssueCategory::HardlinkEscape,
format!("Hardlink escape: {}", path.display()),
),
ArchiveError::ZipBomb(details) => (
IssueSeverity::Critical,
IssueCategory::ZipBomb,
format!("Potential zip bomb: {details}"),
),
ArchiveError::ZipInconsistency(finding) => (
IssueSeverity::High,
IssueCategory::from_zip_inconsistency(finding),
error.to_string(),
),
ArchiveError::QuotaExceeded { resource } => (
IssueSeverity::High,
IssueCategory::QuotaExceeded,
resource.to_string(),
),
ArchiveError::InvalidPermissions { path, mode } => (
IssueSeverity::Medium,
IssueCategory::InvalidPermissions,
format!(
"Invalid permissions: {} (mode: {:#o})",
path.display(),
mode
),
),
ArchiveError::Io(io_err) => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("I/O error: {io_err}"),
),
ArchiveError::InvalidArchive(msg) => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Invalid archive: {msg}"),
),
ArchiveError::SecurityViolation { reason, .. } => (
IssueSeverity::High,
IssueCategory::SecurityPolicy,
format!("Security violation: {reason}"),
),
ArchiveError::SourceNotFound { path } => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Source not found: {}", path.display()),
),
ArchiveError::SourceNotAccessible { path } => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Source not accessible: {}", path.display()),
),
ArchiveError::OutputExists { path } => (
IssueSeverity::Medium,
IssueCategory::InvalidArchive,
format!("Output already exists: {}", path.display()),
),
ArchiveError::InvalidCompressionLevel { level } => (
IssueSeverity::Medium,
IssueCategory::InvalidArchive,
format!("Invalid compression level: {level}"),
),
ArchiveError::UnknownFormat { path } => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Unknown format: {}", path.display()),
),
ArchiveError::InvalidConfiguration { reason } => (
IssueSeverity::High,
IssueCategory::InvalidArchive,
format!("Invalid configuration: {reason}"),
),
ArchiveError::PartialExtraction { source, .. } => {
return Self::from_error(source, entry_path);
}
};
Self {
severity,
category,
entry_path,
message,
context: None,
}
}
}
closed_enum! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum IssueSeverity {
Info => "INFO",
Low => "LOW",
Medium => "MEDIUM",
High => "HIGH",
Critical => "CRITICAL",
}
}
closed_enum! {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IssueCategory {
PathTraversal => "Path Traversal",
SymlinkEscape => "Symlink Escape",
HardlinkEscape => "Hardlink Escape",
ZipBomb => "Zip Bomb",
InvalidPermissions => "Invalid Permissions",
QuotaExceeded => "Quota Exceeded",
SecurityPolicy => "Security Policy",
InvalidArchive => "Invalid Archive",
SuspiciousPath => "Suspicious Path",
ExecutableFile => "Executable File",
CorruptData => "Corrupt Data",
IntegrityIncomplete => "Integrity Incomplete",
HeaderMismatch => "Header Mismatch",
DuplicateEntry => "Duplicate Entry",
OverlappingEntries => "Overlapping Entries",
HiddenEntry => "Hidden Entry",
NameCollision => "Name Collision",
ArchiveNote => "Archive Note",
}
}
impl IssueCategory {
pub(crate) const fn from_zip_inconsistency(finding: &ZipInconsistency) -> Self {
match finding {
ZipInconsistency::HeaderMismatch { .. }
| ZipInconsistency::HeaderUncomparable { .. } => Self::HeaderMismatch,
ZipInconsistency::DuplicateName { .. } => Self::DuplicateEntry,
ZipInconsistency::OverlappingEntries => Self::OverlappingEntries,
ZipInconsistency::HiddenLocalHeader { .. }
| ZipInconsistency::StrayCentralRecord { .. }
| ZipInconsistency::StrayEndRecord { .. }
| ZipInconsistency::DescriptorOverrun { .. }
| ZipInconsistency::XlTypeDivergence { .. }
| ZipInconsistency::UncountedCentralRecords { .. }
| ZipInconsistency::IgnoredEndRecord { .. } => Self::HiddenEntry,
}
}
#[must_use]
pub const fn is_security(self) -> bool {
match self {
Self::PathTraversal
| Self::SymlinkEscape
| Self::HardlinkEscape
| Self::ZipBomb
| Self::InvalidPermissions
| Self::QuotaExceeded
| Self::SecurityPolicy
| Self::HeaderMismatch
| Self::DuplicateEntry
| Self::OverlappingEntries
| Self::HiddenEntry
| Self::NameCollision => true,
Self::InvalidArchive
| Self::SuspiciousPath
| Self::ExecutableFile
| Self::CorruptData
| Self::IntegrityIncomplete
| Self::ArchiveNote => false,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::ZipBombDetails;
use crate::ZipBombScope;
use std::io;
use crate::inspection::manifest::ManifestEntryKind;
fn spellings<T: Copy>(all: &[T], text: impl Fn(T) -> &'static str) -> Vec<&'static str> {
all.iter().map(|v| text(*v)).collect()
}
#[test]
fn test_enum_as_str_spellings_and_display() {
assert_eq!(
spellings(VerificationStatus::ALL, VerificationStatus::as_str),
["PASS", "FAIL", "WARNING"]
);
assert_eq!(
spellings(CheckStatus::ALL, CheckStatus::as_str),
["OK", "FAILED", "WARNING", "SKIPPED"]
);
assert_eq!(
spellings(IssueSeverity::ALL, IssueSeverity::as_str),
["INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"]
);
assert_eq!(
spellings(IssueCategory::ALL, IssueCategory::as_str),
[
"Path Traversal",
"Symlink Escape",
"Hardlink Escape",
"Zip Bomb",
"Invalid Permissions",
"Quota Exceeded",
"Security Policy",
"Invalid Archive",
"Suspicious Path",
"Executable File",
"Corrupt Data",
"Integrity Incomplete",
"Header Mismatch",
"Duplicate Entry",
"Overlapping Entries",
"Hidden Entry",
"Name Collision",
"Archive Note",
]
);
assert_eq!(
spellings(IntegrityStatus::ALL, IntegrityStatus::as_str),
["OK", "FAILED", "INCOMPLETE", "SKIPPED"]
);
for category in IssueCategory::ALL {
assert_eq!(category.to_string(), category.as_str());
}
for severity in IssueSeverity::ALL {
assert_eq!(severity.to_string(), severity.as_str());
}
}
#[test]
fn test_zip_inconsistency_maps_to_security_issue_categories() {
use crate::CentralRecordOrdinal;
use crate::ZipEntryIndex;
use crate::ZipHeaderField;
let cases = [
(
ZipInconsistency::HeaderMismatch {
entry: ZipEntryIndex::new(0),
field: ZipHeaderField::FileName,
name: "a".to_string(),
},
IssueCategory::HeaderMismatch,
),
(
ZipInconsistency::HeaderUncomparable {
entry: ZipEntryIndex::new(0),
name: "a".to_string(),
reason: crate::ZipUncomparable::Unparseable,
},
IssueCategory::HeaderMismatch,
),
(
ZipInconsistency::DuplicateName {
record: CentralRecordOrdinal::new(1),
name: "a".to_string(),
},
IssueCategory::DuplicateEntry,
),
(
ZipInconsistency::OverlappingEntries,
IssueCategory::OverlappingEntries,
),
(
ZipInconsistency::HiddenLocalHeader {
offset: 9,
region: crate::HiddenRegion::Gap,
},
IssueCategory::HiddenEntry,
),
(
ZipInconsistency::IgnoredEndRecord { offset: 9 },
IssueCategory::HiddenEntry,
),
(
ZipInconsistency::StrayCentralRecord { offset: 9 },
IssueCategory::HiddenEntry,
),
(
ZipInconsistency::StrayEndRecord {
offset: 9,
kind: crate::EndRecordKind::Classic,
},
IssueCategory::HiddenEntry,
),
(
ZipInconsistency::DescriptorOverrun {
entry: ZipEntryIndex::new(0),
offset: 9,
},
IssueCategory::HiddenEntry,
),
(
ZipInconsistency::XlTypeDivergence {
entry: ZipEntryIndex::new(0),
offset: 9,
},
IssueCategory::HiddenEntry,
),
(
ZipInconsistency::UncountedCentralRecords {
counted: 1,
found: 2,
},
IssueCategory::HiddenEntry,
),
];
for (finding, category) in cases {
let issue =
VerificationIssue::from_error(&ArchiveError::ZipInconsistency(finding), None);
assert_eq!(issue.category, category);
assert_eq!(issue.severity, IssueSeverity::High);
assert!(issue.category.is_security());
}
}
#[test]
fn test_low_severity_issue_of_another_security_category_still_counts() {
let low = VerificationIssue::new(
IssueSeverity::Low,
IssueCategory::SecurityPolicy,
"policy".to_string(),
);
assert_eq!(determine_security_status(&[low]), CheckStatus::Warning);
}
#[test]
fn test_low_severity_security_issue_does_not_taint_security_status() {
let low = VerificationIssue::new(
IssueSeverity::Low,
IssueCategory::NameCollision,
"directories merge".to_string(),
);
assert_eq!(
determine_security_status(std::slice::from_ref(&low)),
CheckStatus::Pass
);
let medium = VerificationIssue::new(
IssueSeverity::Medium,
IssueCategory::NameCollision,
"files collide".to_string(),
);
assert_eq!(
determine_security_status(&[low, medium]),
CheckStatus::Warning
);
}
#[test]
fn test_severity_all_is_ascending() {
assert!(IssueSeverity::ALL.is_sorted());
}
#[test]
fn test_determine_status_pass() {
let issues = vec![];
assert_eq!(determine_status(&issues), VerificationStatus::Pass);
}
#[test]
fn test_determine_status_fail_critical() {
let issues = vec![VerificationIssue {
severity: IssueSeverity::Critical,
category: IssueCategory::PathTraversal,
entry_path: None,
message: "Test".to_string(),
context: None,
}];
assert_eq!(determine_status(&issues), VerificationStatus::Fail);
}
#[test]
fn test_determine_status_fail_high() {
let issues = vec![VerificationIssue {
severity: IssueSeverity::High,
category: IssueCategory::QuotaExceeded,
entry_path: None,
message: "Test".to_string(),
context: None,
}];
assert_eq!(determine_status(&issues), VerificationStatus::Fail);
}
#[test]
fn test_determine_status_warning() {
let issues = vec![VerificationIssue {
severity: IssueSeverity::Medium,
category: IssueCategory::InvalidPermissions,
entry_path: None,
message: "Test".to_string(),
context: None,
}];
assert_eq!(determine_status(&issues), VerificationStatus::Warning);
}
#[test]
fn test_verification_issue_from_path_traversal() {
let error = ArchiveError::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 = ArchiveError::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 =
ArchiveError::ZipBomb(ZipBombDetails::new(ZipBombScope::Entry, 1000, 1_000_000));
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 = ArchiveError::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);
}
fn issue(severity: IssueSeverity, category: IssueCategory) -> VerificationIssue {
VerificationIssue::new(severity, category, "Test".to_string())
}
fn report_with(issues: Vec<VerificationIssue>) -> VerificationReport {
let mut manifest = ArchiveManifest::new(ArchiveType::TarGz);
let entry = ArchiveEntry::new(PathBuf::from("a"), ManifestEntryKind::File, 1024);
manifest.add_entry(entry).unwrap();
let mut issues = Some(issues);
VerificationReport::from_entry_issues(&manifest, |_| issues.take().unwrap_or_default())
}
#[test]
fn test_verification_report_is_safe() {
let report = report_with(Vec::new());
assert!(report.is_safe());
assert_eq!(report.total_entries(), 1);
assert_eq!(report.total_size(), 1024);
assert_eq!(report.format(), ArchiveType::TarGz);
assert_eq!(report.suspicious_entries(), 0);
}
#[test]
fn test_verification_report_not_safe() {
let report = report_with(vec![issue(
IssueSeverity::Critical,
IssueCategory::PathTraversal,
)]);
assert!(!report.is_safe());
assert!(report.has_critical_issues());
assert_eq!(report.status(), VerificationStatus::Fail);
assert_eq!(report.security_status(), CheckStatus::Fail);
assert_eq!(report.suspicious_entries(), 1);
}
#[test]
fn test_status_pass_iff_no_suspicious_entries() {
for severity in IssueSeverity::ALL {
let report = report_with(vec![issue(*severity, IssueCategory::SuspiciousPath)]);
assert_eq!(
report.status() == VerificationStatus::Pass,
report.suspicious_entries() == 0,
"{severity}"
);
}
}
#[test]
fn test_issues_sorted_most_severe_first() {
let report = report_with(vec![
issue(IssueSeverity::Low, IssueCategory::ExecutableFile),
issue(IssueSeverity::Critical, IssueCategory::PathTraversal),
issue(IssueSeverity::Medium, IssueCategory::InvalidPermissions),
]);
let severities: Vec<_> = report.issues().iter().map(|i| i.severity).collect();
assert_eq!(
severities,
[
IssueSeverity::Critical,
IssueSeverity::Medium,
IssueSeverity::Low
]
);
}
#[test]
fn test_verification_issue_from_hardlink_escape() {
let error = ArchiveError::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 = ArchiveError::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 = ArchiveError::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_invalid_archive() {
let error = ArchiveError::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 = ArchiveError::violation("encrypted".into());
let issue = VerificationIssue::from_error(&error, None);
assert_eq!(issue.severity, IssueSeverity::High);
assert_eq!(issue.category, IssueCategory::SecurityPolicy);
assert!(issue.message.contains("Security violation"));
}
#[test]
fn test_security_violation_fails_security_status() {
let error = ArchiveError::violation("symlinks not allowed".into());
let report = report_with(vec![VerificationIssue::from_error(&error, None)]);
assert_eq!(report.status(), VerificationStatus::Fail);
assert_eq!(report.security_status(), CheckStatus::Fail);
}
#[test]
fn test_is_security_classification() {
let non_security: Vec<_> = IssueCategory::ALL
.iter()
.copied()
.filter(|c| !c.is_security())
.collect();
assert_eq!(
non_security,
[
IssueCategory::InvalidArchive,
IssueCategory::SuspiciousPath,
IssueCategory::ExecutableFile,
IssueCategory::CorruptData,
IssueCategory::IntegrityIncomplete,
IssueCategory::ArchiveNote,
]
);
}
fn integrity_report(
issues: Vec<VerificationIssue>,
scope: IntegrityScope,
) -> VerificationReport {
let mut manifest = ArchiveManifest::new(ArchiveType::TarGz);
let entry = ArchiveEntry::new(PathBuf::from("a"), ManifestEntryKind::File, 1024);
manifest.add_entry(entry).unwrap();
VerificationReport::from_entry_issue_lists(
&manifest,
vec![Vec::new()],
issues,
&ArchiveStructure::of_manifest(&manifest),
scope,
)
}
#[test]
fn test_integrity_not_requested_is_skipped() {
let report = integrity_report(Vec::new(), IntegrityScope::NotRequested);
assert_eq!(report.integrity_status(), IntegrityStatus::Skipped);
}
#[test]
fn test_integrity_requested_without_issues_is_pass() {
let report = integrity_report(
Vec::new(),
IntegrityScope::Requested(IntegrityGaps::default()),
);
assert_eq!(report.integrity_status(), IntegrityStatus::Pass);
}
#[test]
fn test_corrupt_data_fails_even_when_not_requested() {
let issues = vec![issue(IssueSeverity::High, IssueCategory::CorruptData)];
let report = integrity_report(issues, IntegrityScope::NotRequested);
assert_eq!(report.integrity_status(), IntegrityStatus::Fail);
}
#[test]
fn test_corrupt_data_wins_over_incomplete() {
let issues = vec![
issue(IssueSeverity::Medium, IssueCategory::IntegrityIncomplete),
issue(IssueSeverity::High, IssueCategory::CorruptData),
];
let report = integrity_report(issues, IntegrityScope::Requested(IntegrityGaps::default()));
assert_eq!(report.integrity_status(), IntegrityStatus::Fail);
}
#[test]
fn test_incomplete_issue_gives_incomplete_and_warning() {
let issues = vec![issue(
IssueSeverity::Medium,
IssueCategory::IntegrityIncomplete,
)];
let report = integrity_report(issues, IntegrityScope::Requested(IntegrityGaps::default()));
assert_eq!(report.integrity_status(), IntegrityStatus::Incomplete);
assert_eq!(report.status(), VerificationStatus::Warning);
assert_eq!(report.security_status(), CheckStatus::Pass);
}
#[test]
fn test_archive_level_issues_do_not_count_as_suspicious_entries() {
let issues = vec![issue(IssueSeverity::High, IssueCategory::CorruptData)];
let report = integrity_report(issues, IntegrityScope::Requested(IntegrityGaps::default()));
assert_eq!(report.suspicious_entries(), 0);
assert_eq!(report.status(), VerificationStatus::Fail);
}
fn two_file_manifest() -> ArchiveManifest {
let mut manifest = ArchiveManifest::new(ArchiveType::Zip);
for name in ["a.txt", "b.txt"] {
let entry = ArchiveEntry::new(PathBuf::from(name), ManifestEntryKind::File, 1);
manifest.add_entry(entry).unwrap();
}
manifest
}
fn report_of_findings(findings: &[ZipInconsistency]) -> VerificationReport {
let manifest = two_file_manifest();
VerificationReport::from_entry_issue_lists(
&manifest,
vec![Vec::new(), Vec::new()],
Vec::new(),
&ArchiveStructure::new(2, findings, 0),
IntegrityScope::NotRequested,
)
}
#[test]
fn test_entry_bound_findings_name_their_entry_and_count_it_once() {
use crate::ZipEntryIndex;
let findings = [
ZipInconsistency::XlTypeDivergence {
entry: ZipEntryIndex::new(1),
offset: 40,
},
ZipInconsistency::DescriptorOverrun {
entry: ZipEntryIndex::new(1),
offset: 60,
},
];
let report = report_of_findings(&findings);
assert_eq!(report.suspicious_entries(), 1);
assert_eq!(report.issues().len(), 2);
for issue in report.issues() {
assert_eq!(issue.entry_path.as_deref(), Some(Path::new("b.txt")));
assert_eq!(issue.category, IssueCategory::HiddenEntry);
}
}
fn overrun_of(index: usize) -> [ZipInconsistency; 1] {
[ZipInconsistency::DescriptorOverrun {
entry: crate::ZipEntryIndex::new(index),
offset: 9,
}]
}
fn report_after_dropped(
dropped_before: usize,
findings: &[ZipInconsistency],
) -> VerificationReport {
let mut manifest = ArchiveManifest::new(ArchiveType::Zip);
for _ in 0..dropped_before {
manifest.add_oversized(300);
}
for name in ["a.txt", "b.txt"] {
let entry = ArchiveEntry::new(PathBuf::from(name), ManifestEntryKind::File, 1);
manifest.add_entry(entry).unwrap();
}
VerificationReport::from_entry_issue_lists(
&manifest,
vec![Vec::new(), Vec::new()],
Vec::new(),
&ArchiveStructure::new(2 + dropped_before, findings, 0),
IntegrityScope::NotRequested,
)
}
#[test]
fn test_finding_index_skips_entries_dropped_before_it() {
let report = report_after_dropped(1, &overrun_of(2));
assert_eq!(report.suspicious_entries(), 1);
assert_eq!(
report.issues()[0].entry_path.as_deref(),
Some(Path::new("b.txt"))
);
}
#[test]
fn test_finding_of_a_dropped_entry_is_archive_level() {
let report = report_after_dropped(1, &overrun_of(0));
assert_eq!(report.suspicious_entries(), 0);
assert_eq!(report.issues().len(), 1);
assert!(report.issues()[0].entry_path.is_none());
}
#[test]
fn test_archive_level_findings_stay_unbound() {
let findings = [
ZipInconsistency::OverlappingEntries,
ZipInconsistency::StrayCentralRecord { offset: 9 },
];
let report = report_of_findings(&findings);
assert_eq!(report.suspicious_entries(), 0);
assert_eq!(report.issues().len(), 2);
assert!(report.issues().iter().all(|i| i.entry_path.is_none()));
}
#[test]
fn test_verification_issue_from_source_not_found() {
let error = ArchiveError::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 = ArchiveError::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 = ArchiveError::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 = ArchiveError::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 = ArchiveError::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 = ArchiveError::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 = report_with(vec![
issue(IssueSeverity::Critical, IssueCategory::PathTraversal),
issue(IssueSeverity::Low, IssueCategory::ExecutableFile),
]);
assert_eq!(report.issues_by_severity(IssueSeverity::Critical).len(), 1);
assert_eq!(report.issues_by_severity(IssueSeverity::Low).len(), 1);
assert_eq!(report.status(), VerificationStatus::Fail);
}
proptest::proptest! {
#[test]
fn prop_incomplete_integrity_never_coexists_with_overall_pass(
raw in proptest::collection::vec(
(0..IssueSeverity::ALL.len(), 0..IssueCategory::ALL.len()),
0..8,
),
requested in proptest::bool::ANY,
) {
let issues = raw
.into_iter()
.map(|(severity, category)| {
let category = IssueCategory::ALL[category];
let severity = IssueSeverity::ALL[severity];
let severity = if category == IssueCategory::IntegrityIncomplete {
severity.max(IssueSeverity::Medium)
} else {
severity
};
issue(severity, category)
})
.collect();
let scope = if requested {
IntegrityScope::Requested(IntegrityGaps::default())
} else {
IntegrityScope::NotRequested
};
let report = integrity_report(issues, scope);
if report.integrity_status() == IntegrityStatus::Incomplete {
proptest::prop_assert_ne!(report.status(), VerificationStatus::Pass);
}
}
}
}