Skip to main content

exarch_core/inspection/
report.rs

1//! Verification report types for security checks.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::path::PathBuf;
6
7use crate::closed_enum::closed_enum;
8use crate::error::ArchiveError;
9use crate::error::ZipInconsistency;
10use crate::formats::detect::ArchiveType;
11use crate::inspection::manifest::ArchiveEntry;
12use crate::inspection::manifest::ArchiveManifest;
13
14/// Result of archive verification.
15///
16/// Generated by `verify_archive()`, contains the result of the security checks
17/// performed without extracting files to disk. Payload integrity is checked
18/// only when requested through
19/// [`VerificationOptions`](crate::VerificationOptions); otherwise
20/// `integrity_status()` is [`IntegrityStatus::Skipped`]. Fields are private:
21/// the overall, security and integrity statuses are derived from the issue
22/// list, so a report can never contradict its own issues.
23///
24/// # Examples
25///
26/// ```no_run
27/// use exarch_core::SecurityConfig;
28/// use exarch_core::VerificationStatus;
29/// use exarch_core::verify_archive;
30///
31/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
32/// let config = SecurityConfig::default();
33/// let report = verify_archive("archive.tar.gz", &config)?;
34///
35/// if report.status() == VerificationStatus::Pass {
36///     println!("Archive is safe to extract");
37/// } else {
38///     eprintln!("Security issues found:");
39///     for issue in report.issues() {
40///         eprintln!("  [{}] {}", issue.severity, issue.message);
41///     }
42/// }
43/// # Ok(())
44/// # }
45/// ```
46#[derive(Debug, Clone)]
47pub struct VerificationReport {
48    issues: Vec<VerificationIssue>,
49    integrity: IntegrityScope,
50    total_entries: usize,
51    suspicious_entries: usize,
52    total_size: u64,
53    format: ArchiveType,
54}
55
56impl VerificationReport {
57    /// Builds a report by running `issues_for` over every manifest entry.
58    ///
59    /// Totals and format come from the manifest. An entry counts as
60    /// suspicious when it has at least one issue of [`IssueSeverity::Medium`]
61    /// or higher. `status() == Pass` holds exactly when no issue is Medium or
62    /// higher. Issues are sorted from most to least severe.
63    ///
64    /// # Examples
65    ///
66    /// ```
67    /// use std::path::PathBuf;
68    ///
69    /// use exarch_core::ArchiveEntry;
70    /// use exarch_core::ArchiveManifest;
71    /// use exarch_core::ManifestEntryKind;
72    /// use exarch_core::VerificationReport;
73    /// use exarch_core::VerificationStatus;
74    /// use exarch_core::formats::detect::ArchiveType;
75    ///
76    /// let mut manifest = ArchiveManifest::new(ArchiveType::Tar);
77    /// let entry = ArchiveEntry::new(PathBuf::from("a.txt"), ManifestEntryKind::File, 1);
78    /// manifest.add_entry(entry)?;
79    ///
80    /// let report = VerificationReport::from_entry_issues(&manifest, |_| Vec::new());
81    /// assert_eq!(report.status(), VerificationStatus::Pass);
82    /// assert_eq!(report.total_entries(), 1);
83    /// # Ok::<(), exarch_core::ArchiveError>(())
84    /// ```
85    #[must_use]
86    pub fn from_entry_issues<F>(manifest: &ArchiveManifest, issues_for: F) -> Self
87    where
88        F: FnMut(&ArchiveEntry) -> Vec<VerificationIssue>,
89    {
90        let lists = manifest.entries().iter().map(issues_for).collect();
91        Self::from_entry_issue_lists(
92            manifest,
93            lists,
94            Vec::new(),
95            &ArchiveStructure::of_manifest(manifest),
96            IntegrityScope::NotRequested,
97        )
98    }
99
100    /// Builds a report from one precomputed issue list per manifest entry,
101    /// issues that belong to the archive as a whole, and the structure of the
102    /// archive the manifest came from.
103    ///
104    /// `lists` holds one list per manifest entry, optionally followed by
105    /// lists of archive-level issues. A structural finding bound to an entry
106    /// joins that entry's list, so an entry with several issues still counts
107    /// once; any other finding is an archive-level list. Only entries count
108    /// as suspicious, so `suspicious_entries()` never exceeds the manifest,
109    /// while archive-level issues still decide `status()`.
110    pub(crate) fn from_entry_issue_lists(
111        manifest: &ArchiveManifest,
112        mut lists: Vec<Vec<VerificationIssue>>,
113        archive_issues: Vec<VerificationIssue>,
114        structure: &ArchiveStructure<'_>,
115        integrity: IntegrityScope,
116    ) -> Self {
117        for finding in structure.findings {
118            let bound_entry = match finding {
119                ZipInconsistency::HeaderMismatch { entry, .. }
120                | ZipInconsistency::HeaderUncomparable { entry, .. }
121                | ZipInconsistency::DescriptorOverrun { entry, .. }
122                | ZipInconsistency::XlTypeDivergence { entry, .. } => {
123                    manifest.position_of_zip_entry(*entry)
124                }
125                ZipInconsistency::DuplicateName { .. }
126                | ZipInconsistency::OverlappingEntries
127                | ZipInconsistency::HiddenLocalHeader { .. }
128                | ZipInconsistency::IgnoredEndRecord { .. }
129                | ZipInconsistency::StrayCentralRecord { .. }
130                | ZipInconsistency::StrayEndRecord { .. }
131                | ZipInconsistency::UncountedCentralRecords { .. } => None,
132            };
133            let mut issue = VerificationIssue::from_error(
134                &ArchiveError::ZipInconsistency(finding.clone()),
135                None,
136            );
137            match bound_entry {
138                Some(position) => {
139                    issue.entry_path = Some(manifest.entries()[position].path.clone());
140                    lists[position].push(issue);
141                }
142                None => lists.push(vec![issue]),
143            }
144        }
145
146        if structure.omitted > 0 {
147            lists.push(vec![VerificationIssue::new(
148                IssueSeverity::Info,
149                IssueCategory::InvalidArchive,
150                format!(
151                    "{} further ZIP structure findings are not listed",
152                    structure.omitted
153                ),
154            )]);
155        }
156
157        let mut issues = archive_issues;
158        let mut suspicious_entries = 0;
159
160        for (index, entry_issues) in lists.into_iter().enumerate() {
161            if index < manifest.total_entries()
162                && entry_issues
163                    .iter()
164                    .any(|i| i.severity >= IssueSeverity::Medium)
165            {
166                suspicious_entries += 1;
167            }
168            issues.extend(entry_issues);
169        }
170
171        issues.sort_by(|a, b| a.severity.cmp(&b.severity).reverse());
172
173        Self {
174            issues,
175            integrity,
176            total_entries: structure.total_entries,
177            suspicious_entries,
178            total_size: manifest.total_size(),
179            format: manifest.format(),
180        }
181    }
182
183    /// Returns the overall status, derived from the issue severities.
184    #[must_use]
185    pub fn status(&self) -> VerificationStatus {
186        determine_status(&self.issues)
187    }
188
189    /// Returns the security check result, derived from security-category
190    /// issues. A [`IssueCategory::NameCollision`] of Low severity (two
191    /// directories without contents merging) is reported but not counted.
192    #[must_use]
193    pub fn security_status(&self) -> CheckStatus {
194        determine_security_status(&self.issues)
195    }
196
197    /// Returns the integrity check result, derived from the issue list.
198    ///
199    /// Any [`IssueCategory::CorruptData`] issue gives
200    /// [`IntegrityStatus::Fail`]. Otherwise a report built without an
201    /// integrity request is [`IntegrityStatus::Skipped`], and a requested check
202    /// with an [`IssueCategory::IntegrityIncomplete`] issue is
203    /// [`IntegrityStatus::Incomplete`]: some payload was not verified. Only a
204    /// requested check with neither is [`IntegrityStatus::Pass`], which for
205    /// formats without payload checksums (plain TAR, 7z entries without CRC)
206    /// means the data was decodable and complete, not checksum-verified.
207    #[must_use]
208    pub fn integrity_status(&self) -> IntegrityStatus {
209        determine_integrity_status(&self.issues, &self.integrity)
210    }
211
212    /// What the requested integrity check left unverified, or `None` when no
213    /// integrity check was requested.
214    ///
215    /// A requested check that verified everything returns an empty
216    /// [`IntegrityGaps`].
217    #[must_use]
218    pub const fn integrity_gaps(&self) -> Option<&IntegrityGaps> {
219        match &self.integrity {
220            IntegrityScope::Requested(gaps) => Some(gaps),
221            IntegrityScope::NotRequested => None,
222        }
223    }
224
225    /// Returns all issues found, sorted from most to least severe.
226    #[must_use]
227    pub fn issues(&self) -> &[VerificationIssue] {
228        &self.issues
229    }
230
231    /// Consumes the report and returns its issues.
232    #[must_use]
233    pub fn into_issues(self) -> Vec<VerificationIssue> {
234        self.issues
235    }
236
237    /// Returns the total number of entries scanned.
238    #[must_use]
239    pub const fn total_entries(&self) -> usize {
240        self.total_entries
241    }
242
243    /// Returns the number of entries with a Medium-or-higher issue. Issues
244    /// that belong to the archive as a whole (a hidden ZIP header, a repeated
245    /// name) are not entries and are not counted here, but they fail
246    /// [`status`](Self::status).
247    #[must_use]
248    pub const fn suspicious_entries(&self) -> usize {
249        self.suspicious_entries
250    }
251
252    /// Returns the total uncompressed size in bytes.
253    #[must_use]
254    pub const fn total_size(&self) -> u64 {
255        self.total_size
256    }
257
258    /// Returns the archive format.
259    #[must_use]
260    pub const fn format(&self) -> ArchiveType {
261        self.format
262    }
263
264    /// Returns true if the archive is safe (no Medium-or-higher severity
265    /// issues).
266    #[must_use]
267    pub fn is_safe(&self) -> bool {
268        self.status() == VerificationStatus::Pass
269    }
270
271    /// Returns true if there are any critical severity issues.
272    #[must_use]
273    pub fn has_critical_issues(&self) -> bool {
274        self.issues
275            .iter()
276            .any(|i| i.severity == IssueSeverity::Critical)
277    }
278
279    /// Returns issues of a specific severity level.
280    #[must_use]
281    pub fn issues_by_severity(&self, severity: IssueSeverity) -> Vec<&VerificationIssue> {
282        self.issues
283            .iter()
284            .filter(|i| i.severity == severity)
285            .collect()
286    }
287}
288
289/// What an archive's format reports about its own structure, beyond the
290/// manifest of the entries it lists.
291#[derive(Debug, Clone, Copy)]
292pub(crate) struct ArchiveStructure<'a> {
293    total_entries: usize,
294    findings: &'a [ZipInconsistency],
295    omitted: usize,
296    refusals: &'a [VerificationIssue],
297}
298
299impl<'a> ArchiveStructure<'a> {
300    /// A structure with `total_entries` records and the findings to report.
301    pub(crate) const fn new(
302        total_entries: usize,
303        findings: &'a [ZipInconsistency],
304        omitted: usize,
305    ) -> Self {
306        Self {
307            total_entries,
308            findings,
309            omitted,
310            refusals: &[],
311        }
312    }
313
314    /// Adds archive-level issues for what extraction refuses in the archive.
315    pub(crate) const fn with_refusals(mut self, refusals: &'a [VerificationIssue]) -> Self {
316        self.refusals = refusals;
317        self
318    }
319
320    /// Archive-level issues for what extraction refuses in the archive.
321    pub(crate) const fn refusals(&self) -> &'a [VerificationIssue] {
322        self.refusals
323    }
324
325    /// The structure of a format whose manifest is its whole content.
326    pub(crate) fn of_manifest(manifest: &ArchiveManifest) -> Self {
327        Self::new(manifest.total_entries(), &[], 0)
328    }
329}
330
331fn determine_status(issues: &[VerificationIssue]) -> VerificationStatus {
332    let has_critical = issues.iter().any(|i| i.severity == IssueSeverity::Critical);
333    let has_high = issues.iter().any(|i| i.severity == IssueSeverity::High);
334    let has_medium = issues.iter().any(|i| i.severity == IssueSeverity::Medium);
335
336    if has_critical || has_high {
337        VerificationStatus::Fail
338    } else if has_medium {
339        VerificationStatus::Warning
340    } else {
341        VerificationStatus::Pass
342    }
343}
344
345fn determine_integrity_status(
346    issues: &[VerificationIssue],
347    scope: &IntegrityScope,
348) -> IntegrityStatus {
349    let has = |category| issues.iter().any(|i| i.category == category);
350    if has(IssueCategory::CorruptData) {
351        IntegrityStatus::Fail
352    } else if matches!(scope, IntegrityScope::NotRequested) {
353        IntegrityStatus::Skipped
354    } else if has(IssueCategory::IntegrityIncomplete) {
355        IntegrityStatus::Incomplete
356    } else {
357        IntegrityStatus::Pass
358    }
359}
360
361/// Whether the caller asked for a payload integrity check, and what the check
362/// left unverified.
363#[derive(Debug, Clone, PartialEq, Eq)]
364pub(crate) enum IntegrityScope {
365    NotRequested,
366    Requested(IntegrityGaps),
367}
368
369closed_enum! {
370    /// Why part of the payload was not verified by the integrity check.
371    ///
372    /// `#[non_exhaustive]` so a future reason is not a breaking change for
373    /// downstream matches. The string form is stable and shared by the CLI
374    /// JSON output and the language bindings.
375    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
376    #[non_exhaustive]
377    pub enum UnverifiedReason {
378        /// The decompression budget could not cover it.
379        OverBudget => "over_budget",
380
381        /// Quota, ratio or decoder-memory admission refused it.
382        ResourceLimit => "resource_limit",
383
384        /// A solid 7z block while `allow_solid_archives` is off.
385        SolidPolicy => "solid_policy",
386
387        /// The stream outgrew its declared size.
388        SizeCeiling => "size_ceiling",
389
390        /// A 7z block checksum that the entries do not cover.
391        UncoveredBlockCrc => "uncovered_block_crc",
392
393        /// An unsupported or unparseable 7z coder.
394        NotDecodable => "not_decodable",
395
396        /// A TAR stream that ends without an end-of-archive marker.
397        NoEndMarker => "no_end_marker",
398
399        /// An entry dropped from the manifest because its path exceeds the
400        /// host limits.
401        PathTooLong => "path_too_long",
402
403        /// The data-descriptor tail of a ZIP entry was not searched.
404        DescriptorTail => "descriptor_tail",
405    }
406}
407
408impl UnverifiedReason {
409    /// A short human-readable description, used in report messages.
410    #[must_use]
411    pub const fn description(self) -> &'static str {
412        match self {
413            Self::OverBudget => "over budget",
414            Self::ResourceLimit => "resource limit",
415            Self::SolidPolicy => "solid archive with allow_solid_archives off",
416            Self::SizeCeiling => "declared size exceeded",
417            Self::UncoveredBlockCrc => "block checksum not reachable",
418            Self::NotDecodable => "not decodable",
419            Self::NoEndMarker => "no end-of-archive marker",
420            Self::PathTooLong => "path too long for the host",
421            Self::DescriptorTail => {
422                "data-descriptor tail not searched (budget, or raw decode disagrees with the zip crate)"
423            }
424        }
425    }
426}
427
428/// Most unverified entries an [`IntegrityGaps`] lists by path; the rest are
429/// only counted.
430pub(crate) const MAX_LISTED_UNVERIFIED: usize = 256;
431
432/// One entry the integrity check could not verify, and why.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct UnverifiedEntry {
435    path: PathBuf,
436    reason: UnverifiedReason,
437}
438
439impl UnverifiedEntry {
440    /// The entry's path inside the archive.
441    #[must_use]
442    pub fn path(&self) -> &Path {
443        &self.path
444    }
445
446    /// Why the entry was not verified.
447    #[must_use]
448    pub const fn reason(&self) -> UnverifiedReason {
449        self.reason
450    }
451}
452
453/// What a requested integrity check left unverified.
454///
455/// The counts, the listed entries and the `IntegrityIncomplete` issue message
456/// all come from this one value, so they cannot disagree. Items that belong to
457/// no single entry (a TAR stream without an end marker) are only counted;
458/// entries past a fixed cap (256 today; rely on [`omitted`](Self::omitted), not
459/// the number) are counted in [`omitted`](Self::omitted) instead of listed. The
460/// `counts` shape differs per surface: an iterator in Rust, a map in the CLI
461/// JSON, a dict in Python and an array of `{reason, count}` in Node.
462///
463/// # Examples
464///
465/// ```no_run
466/// use exarch_core::DecompressionBudget;
467/// use exarch_core::IntegrityCheck;
468/// use exarch_core::SecurityConfig;
469/// use exarch_core::VerificationOptions;
470/// use exarch_core::verify_archive_with_options;
471///
472/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
473/// let options = VerificationOptions::default()
474///     .with_integrity(IntegrityCheck::DecodeToEof(DecompressionBudget::DEFAULT));
475/// let report = verify_archive_with_options("a.7z", &SecurityConfig::default(), &options)?;
476/// if let Some(gaps) = report.integrity_gaps() {
477///     for (reason, count) in gaps.counts() {
478///         println!("{reason}: {count}");
479///     }
480/// }
481/// # Ok(())
482/// # }
483/// ```
484#[derive(Debug, Clone, Default, PartialEq, Eq)]
485pub struct IntegrityGaps {
486    counts: BTreeMap<UnverifiedReason, u64>,
487    entries: Vec<UnverifiedEntry>,
488    omitted: u64,
489}
490
491impl IntegrityGaps {
492    /// Counts of unverified items per reason, in reason order; reasons with no
493    /// items are absent.
494    pub fn counts(&self) -> impl Iterator<Item = (UnverifiedReason, u64)> + '_ {
495        self.counts.iter().map(|(&reason, &count)| (reason, count))
496    }
497
498    /// Number of items left unverified for `reason`.
499    #[must_use]
500    pub fn count(&self, reason: UnverifiedReason) -> u64 {
501        self.counts.get(&reason).copied().unwrap_or_default()
502    }
503
504    /// Total number of unverified items.
505    #[must_use]
506    pub fn total(&self) -> u64 {
507        self.counts.values().sum()
508    }
509
510    /// Returns true when nothing was left unverified.
511    #[must_use]
512    pub fn is_empty(&self) -> bool {
513        self.counts.is_empty()
514    }
515
516    /// The unverified entries that could be listed by path, up to a fixed cap;
517    /// further ones are counted in [`omitted`](Self::omitted).
518    #[must_use]
519    pub fn entries(&self) -> &[UnverifiedEntry] {
520        &self.entries
521    }
522
523    /// Number of unverified entries that had a path but were not listed
524    /// because the list was full.
525    #[must_use]
526    pub const fn omitted(&self) -> u64 {
527        self.omitted
528    }
529
530    pub(crate) fn count_items(&mut self, reason: UnverifiedReason, items: u64) {
531        if items > 0 {
532            *self.counts.entry(reason).or_default() += items;
533        }
534    }
535
536    pub(crate) fn mark_entry(&mut self, reason: UnverifiedReason, path: &Path) {
537        self.count_items(reason, 1);
538        if self.entries.len() < MAX_LISTED_UNVERIFIED {
539            self.entries.push(UnverifiedEntry {
540                path: path.to_path_buf(),
541                reason,
542            });
543        } else {
544            self.omitted += 1;
545        }
546    }
547
548    /// The `IntegrityIncomplete` issue message; `None` when nothing is
549    /// unverified.
550    pub(crate) fn message(&self) -> Option<String> {
551        if self.is_empty() {
552            return None;
553        }
554        let detail = self
555            .counts()
556            .map(|(reason, count)| format!("{}: {count}", reason.description()))
557            .collect::<Vec<_>>()
558            .join(", ");
559        Some(format!(
560            "Integrity check incomplete: {} item(s) not verified ({detail})",
561            self.total()
562        ))
563    }
564}
565
566fn determine_security_status(issues: &[VerificationIssue]) -> CheckStatus {
567    let mut security_issues = issues
568        .iter()
569        .filter(|i| i.category.is_security() && !i.is_harmless_collision())
570        .peekable();
571
572    if security_issues.peek().is_none() {
573        return CheckStatus::Pass;
574    }
575
576    let fails = security_issues
577        .any(|i| i.severity == IssueSeverity::Critical || i.severity == IssueSeverity::High);
578
579    if fails {
580        CheckStatus::Fail
581    } else {
582        CheckStatus::Warning
583    }
584}
585
586closed_enum! {
587    /// Overall verification status.
588    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
589    pub enum VerificationStatus {
590        /// All checks passed
591        Pass => "PASS",
592
593        /// One or more checks failed
594        Fail => "FAIL",
595
596        /// Checks completed with warnings
597        Warning => "WARNING",
598    }
599}
600
601closed_enum! {
602    /// Status of a specific check category.
603    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
604    pub enum CheckStatus {
605        /// Check passed
606        Pass => "OK",
607
608        /// Check failed
609        Fail => "FAILED",
610
611        /// Check completed with warnings
612        Warning => "WARNING",
613
614        /// Check was skipped
615        Skipped => "SKIPPED",
616    }
617}
618
619closed_enum! {
620    /// Result of the payload integrity check.
621    ///
622    /// `#[non_exhaustive]` so a future outcome is not a breaking change for
623    /// downstream matches.
624    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
625    #[non_exhaustive]
626    pub enum IntegrityStatus {
627        /// Every payload byte was decoded without error. For formats with
628        /// payload checksums this includes the checksum; plain TAR, 7z entries
629        /// without CRC and zstd frames without a content checksum carry none.
630        Pass => "OK",
631
632        /// A checksum or decoder error proved the payload corrupt.
633        Fail => "FAILED",
634
635        /// The check ran but some payload was not verified (budget, resource
636        /// limit, solid policy, missing end marker).
637        Incomplete => "INCOMPLETE",
638
639        /// The check was not requested.
640        Skipped => "SKIPPED",
641    }
642}
643
644/// Single verification issue.
645///
646/// Construct with [`VerificationIssue::new`] or
647/// [`VerificationIssue::from_error`].
648#[derive(Debug, Clone)]
649#[non_exhaustive]
650pub struct VerificationIssue {
651    /// Issue severity level
652    pub severity: IssueSeverity,
653
654    /// Issue category
655    pub category: IssueCategory,
656
657    /// Entry path that triggered issue (if applicable)
658    pub entry_path: Option<PathBuf>,
659
660    /// Human-readable description
661    pub message: String,
662
663    /// Optional context (compression ratio, target path, etc.)
664    pub context: Option<String>,
665}
666
667impl VerificationIssue {
668    /// The one Low-severity security finding: two directories that merge
669    /// without either holding an entry, which changes nothing on disk.
670    fn is_harmless_collision(&self) -> bool {
671        self.category == IssueCategory::NameCollision && self.severity == IssueSeverity::Low
672    }
673
674    /// Creates an issue with no entry path or context attached.
675    #[must_use]
676    pub const fn new(severity: IssueSeverity, category: IssueCategory, message: String) -> Self {
677        Self {
678            severity,
679            category,
680            entry_path: None,
681            message,
682            context: None,
683        }
684    }
685
686    /// Creates a verification issue from an extraction error.
687    #[must_use]
688    pub fn from_error(error: &ArchiveError, entry_path: Option<PathBuf>) -> Self {
689        let (severity, category, message) = match error {
690            ArchiveError::PathTraversal { path } => (
691                IssueSeverity::Critical,
692                IssueCategory::PathTraversal,
693                format!("Path traversal detected: {}", path.display()),
694            ),
695            ArchiveError::SymlinkEscape { path } => (
696                IssueSeverity::Critical,
697                IssueCategory::SymlinkEscape,
698                format!("Symlink escape: {}", path.display()),
699            ),
700            ArchiveError::HardlinkEscape { path } => (
701                IssueSeverity::Critical,
702                IssueCategory::HardlinkEscape,
703                format!("Hardlink escape: {}", path.display()),
704            ),
705            ArchiveError::ZipBomb(details) => (
706                IssueSeverity::Critical,
707                IssueCategory::ZipBomb,
708                format!("Potential zip bomb: {details}"),
709            ),
710            ArchiveError::ZipInconsistency(finding) => (
711                IssueSeverity::High,
712                IssueCategory::from_zip_inconsistency(finding),
713                error.to_string(),
714            ),
715            ArchiveError::QuotaExceeded { resource } => (
716                IssueSeverity::High,
717                IssueCategory::QuotaExceeded,
718                resource.to_string(),
719            ),
720            ArchiveError::InvalidPermissions { path, mode } => (
721                IssueSeverity::Medium,
722                IssueCategory::InvalidPermissions,
723                format!(
724                    "Invalid permissions: {} (mode: {:#o})",
725                    path.display(),
726                    mode
727                ),
728            ),
729            ArchiveError::Io(io_err) => (
730                IssueSeverity::High,
731                IssueCategory::InvalidArchive,
732                format!("I/O error: {io_err}"),
733            ),
734            ArchiveError::InvalidArchive(msg) => (
735                IssueSeverity::High,
736                IssueCategory::InvalidArchive,
737                format!("Invalid archive: {msg}"),
738            ),
739            ArchiveError::SecurityViolation { reason, .. } => (
740                IssueSeverity::High,
741                IssueCategory::SecurityPolicy,
742                format!("Security violation: {reason}"),
743            ),
744            ArchiveError::SourceNotFound { path } => (
745                IssueSeverity::High,
746                IssueCategory::InvalidArchive,
747                format!("Source not found: {}", path.display()),
748            ),
749            ArchiveError::SourceNotAccessible { path } => (
750                IssueSeverity::High,
751                IssueCategory::InvalidArchive,
752                format!("Source not accessible: {}", path.display()),
753            ),
754            ArchiveError::OutputExists { path } => (
755                IssueSeverity::Medium,
756                IssueCategory::InvalidArchive,
757                format!("Output already exists: {}", path.display()),
758            ),
759            ArchiveError::InvalidCompressionLevel { level } => (
760                IssueSeverity::Medium,
761                IssueCategory::InvalidArchive,
762                format!("Invalid compression level: {level}"),
763            ),
764            ArchiveError::UnknownFormat { path } => (
765                IssueSeverity::High,
766                IssueCategory::InvalidArchive,
767                format!("Unknown format: {}", path.display()),
768            ),
769            ArchiveError::InvalidConfiguration { reason } => (
770                IssueSeverity::High,
771                IssueCategory::InvalidArchive,
772                format!("Invalid configuration: {reason}"),
773            ),
774            ArchiveError::PartialExtraction { source, .. } => {
775                return Self::from_error(source, entry_path);
776            }
777        };
778
779        Self {
780            severity,
781            category,
782            entry_path,
783            message,
784            context: None,
785        }
786    }
787}
788
789closed_enum! {
790    /// Issue severity levels (ordered from least to most severe for Ord).
791    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
792    pub enum IssueSeverity {
793        /// Informational
794        Info => "INFO",
795
796        /// Policy violation
797        Low => "LOW",
798
799        /// Security concern
800        Medium => "MEDIUM",
801
802        /// Exploitable security issue
803        High => "HIGH",
804
805        /// CVE-level vulnerability
806        Critical => "CRITICAL",
807    }
808}
809
810closed_enum! {
811    /// Issue categories (maps to security checks).
812    ///
813    /// `#[non_exhaustive]` so a future security check is not a breaking change
814    /// for downstream matches.
815    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
816    #[non_exhaustive]
817    pub enum IssueCategory {
818        /// Path traversal attack
819        PathTraversal => "Path Traversal",
820
821        /// Symlink escape attack
822        SymlinkEscape => "Symlink Escape",
823
824        /// Hardlink escape attack
825        HardlinkEscape => "Hardlink Escape",
826
827        /// Zip bomb (excessive compression)
828        ZipBomb => "Zip Bomb",
829
830        /// Invalid or unsafe permissions
831        InvalidPermissions => "Invalid Permissions",
832
833        /// Quota exceeded (file count or size)
834        QuotaExceeded => "Quota Exceeded",
835
836        /// Entry rejected by the security policy (links disallowed, NUL bytes,
837        /// banned component, ...)
838        SecurityPolicy => "Security Policy",
839
840        /// Invalid or corrupted archive
841        InvalidArchive => "Invalid Archive",
842
843        /// Suspicious path or filename
844        SuspiciousPath => "Suspicious Path",
845
846        /// Executable file detected
847        ExecutableFile => "Executable File",
848
849        /// A checksum or decoder error proved the payload corrupt
850        CorruptData => "Corrupt Data",
851
852        /// Integrity check could not cover part of the payload
853        IntegrityIncomplete => "Integrity Incomplete",
854
855        /// ZIP local header disagrees with its central directory record
856        HeaderMismatch => "Header Mismatch",
857
858        /// ZIP central directory repeats an entry name
859        DuplicateEntry => "Duplicate Entry",
860
861        /// ZIP entry data ranges intersect
862        OverlappingEntries => "Overlapping Entries",
863
864        /// ZIP entry that the central directory does not account for
865        HiddenEntry => "Hidden Entry",
866
867        /// Two entry names the host filesystem treats as one
868        NameCollision => "Name Collision",
869
870        /// Informational note that needs no action, such as 7z anti-items that
871        /// are skipped
872        ArchiveNote => "Archive Note",
873    }
874}
875
876impl IssueCategory {
877    /// Category under which a ZIP structure finding is reported.
878    pub(crate) const fn from_zip_inconsistency(finding: &ZipInconsistency) -> Self {
879        match finding {
880            ZipInconsistency::HeaderMismatch { .. }
881            | ZipInconsistency::HeaderUncomparable { .. } => Self::HeaderMismatch,
882            ZipInconsistency::DuplicateName { .. } => Self::DuplicateEntry,
883            ZipInconsistency::OverlappingEntries => Self::OverlappingEntries,
884            ZipInconsistency::HiddenLocalHeader { .. }
885            | ZipInconsistency::StrayCentralRecord { .. }
886            | ZipInconsistency::StrayEndRecord { .. }
887            | ZipInconsistency::DescriptorOverrun { .. }
888            | ZipInconsistency::XlTypeDivergence { .. }
889            | ZipInconsistency::UncountedCentralRecords { .. }
890            | ZipInconsistency::IgnoredEndRecord { .. } => Self::HiddenEntry,
891        }
892    }
893
894    /// Whether issues of this category come from a security check, and so
895    /// count towards [`VerificationReport::security_status`].
896    ///
897    /// The match is exhaustive on purpose: a new category must be classified.
898    #[must_use]
899    pub const fn is_security(self) -> bool {
900        match self {
901            Self::PathTraversal
902            | Self::SymlinkEscape
903            | Self::HardlinkEscape
904            | Self::ZipBomb
905            | Self::InvalidPermissions
906            | Self::QuotaExceeded
907            | Self::SecurityPolicy
908            | Self::HeaderMismatch
909            | Self::DuplicateEntry
910            | Self::OverlappingEntries
911            | Self::HiddenEntry
912            | Self::NameCollision => true,
913            Self::InvalidArchive
914            | Self::SuspiciousPath
915            | Self::ExecutableFile
916            | Self::CorruptData
917            | Self::IntegrityIncomplete
918            | Self::ArchiveNote => false,
919        }
920    }
921}
922
923#[cfg(test)]
924#[allow(clippy::unwrap_used)]
925mod tests {
926    use super::*;
927    use crate::ZipBombDetails;
928    use crate::ZipBombScope;
929    use std::io;
930
931    use crate::inspection::manifest::ManifestEntryKind;
932
933    fn spellings<T: Copy>(all: &[T], text: impl Fn(T) -> &'static str) -> Vec<&'static str> {
934        all.iter().map(|v| text(*v)).collect()
935    }
936
937    #[test]
938    fn test_enum_as_str_spellings_and_display() {
939        assert_eq!(
940            spellings(VerificationStatus::ALL, VerificationStatus::as_str),
941            ["PASS", "FAIL", "WARNING"]
942        );
943        assert_eq!(
944            spellings(CheckStatus::ALL, CheckStatus::as_str),
945            ["OK", "FAILED", "WARNING", "SKIPPED"]
946        );
947        assert_eq!(
948            spellings(IssueSeverity::ALL, IssueSeverity::as_str),
949            ["INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"]
950        );
951        assert_eq!(
952            spellings(IssueCategory::ALL, IssueCategory::as_str),
953            [
954                "Path Traversal",
955                "Symlink Escape",
956                "Hardlink Escape",
957                "Zip Bomb",
958                "Invalid Permissions",
959                "Quota Exceeded",
960                "Security Policy",
961                "Invalid Archive",
962                "Suspicious Path",
963                "Executable File",
964                "Corrupt Data",
965                "Integrity Incomplete",
966                "Header Mismatch",
967                "Duplicate Entry",
968                "Overlapping Entries",
969                "Hidden Entry",
970                "Name Collision",
971                "Archive Note",
972            ]
973        );
974        assert_eq!(
975            spellings(IntegrityStatus::ALL, IntegrityStatus::as_str),
976            ["OK", "FAILED", "INCOMPLETE", "SKIPPED"]
977        );
978        for category in IssueCategory::ALL {
979            assert_eq!(category.to_string(), category.as_str());
980        }
981        for severity in IssueSeverity::ALL {
982            assert_eq!(severity.to_string(), severity.as_str());
983        }
984    }
985
986    #[test]
987    fn test_zip_inconsistency_maps_to_security_issue_categories() {
988        use crate::CentralRecordOrdinal;
989        use crate::ZipEntryIndex;
990        use crate::ZipHeaderField;
991
992        let cases = [
993            (
994                ZipInconsistency::HeaderMismatch {
995                    entry: ZipEntryIndex::new(0),
996                    field: ZipHeaderField::FileName,
997                    name: "a".to_string(),
998                },
999                IssueCategory::HeaderMismatch,
1000            ),
1001            (
1002                ZipInconsistency::HeaderUncomparable {
1003                    entry: ZipEntryIndex::new(0),
1004                    name: "a".to_string(),
1005                    reason: crate::ZipUncomparable::Unparseable,
1006                },
1007                IssueCategory::HeaderMismatch,
1008            ),
1009            (
1010                ZipInconsistency::DuplicateName {
1011                    record: CentralRecordOrdinal::new(1),
1012                    name: "a".to_string(),
1013                },
1014                IssueCategory::DuplicateEntry,
1015            ),
1016            (
1017                ZipInconsistency::OverlappingEntries,
1018                IssueCategory::OverlappingEntries,
1019            ),
1020            (
1021                ZipInconsistency::HiddenLocalHeader {
1022                    offset: 9,
1023                    region: crate::HiddenRegion::Gap,
1024                },
1025                IssueCategory::HiddenEntry,
1026            ),
1027            (
1028                ZipInconsistency::IgnoredEndRecord { offset: 9 },
1029                IssueCategory::HiddenEntry,
1030            ),
1031            (
1032                ZipInconsistency::StrayCentralRecord { offset: 9 },
1033                IssueCategory::HiddenEntry,
1034            ),
1035            (
1036                ZipInconsistency::StrayEndRecord {
1037                    offset: 9,
1038                    kind: crate::EndRecordKind::Classic,
1039                },
1040                IssueCategory::HiddenEntry,
1041            ),
1042            (
1043                ZipInconsistency::DescriptorOverrun {
1044                    entry: ZipEntryIndex::new(0),
1045                    offset: 9,
1046                },
1047                IssueCategory::HiddenEntry,
1048            ),
1049            (
1050                ZipInconsistency::XlTypeDivergence {
1051                    entry: ZipEntryIndex::new(0),
1052                    offset: 9,
1053                },
1054                IssueCategory::HiddenEntry,
1055            ),
1056            (
1057                ZipInconsistency::UncountedCentralRecords {
1058                    counted: 1,
1059                    found: 2,
1060                },
1061                IssueCategory::HiddenEntry,
1062            ),
1063        ];
1064        for (finding, category) in cases {
1065            let issue =
1066                VerificationIssue::from_error(&ArchiveError::ZipInconsistency(finding), None);
1067            assert_eq!(issue.category, category);
1068            assert_eq!(issue.severity, IssueSeverity::High);
1069            assert!(issue.category.is_security());
1070        }
1071    }
1072
1073    #[test]
1074    fn test_low_severity_issue_of_another_security_category_still_counts() {
1075        let low = VerificationIssue::new(
1076            IssueSeverity::Low,
1077            IssueCategory::SecurityPolicy,
1078            "policy".to_string(),
1079        );
1080        assert_eq!(determine_security_status(&[low]), CheckStatus::Warning);
1081    }
1082
1083    #[test]
1084    fn test_low_severity_security_issue_does_not_taint_security_status() {
1085        let low = VerificationIssue::new(
1086            IssueSeverity::Low,
1087            IssueCategory::NameCollision,
1088            "directories merge".to_string(),
1089        );
1090        assert_eq!(
1091            determine_security_status(std::slice::from_ref(&low)),
1092            CheckStatus::Pass
1093        );
1094        let medium = VerificationIssue::new(
1095            IssueSeverity::Medium,
1096            IssueCategory::NameCollision,
1097            "files collide".to_string(),
1098        );
1099        assert_eq!(
1100            determine_security_status(&[low, medium]),
1101            CheckStatus::Warning
1102        );
1103    }
1104
1105    #[test]
1106    fn test_severity_all_is_ascending() {
1107        assert!(IssueSeverity::ALL.is_sorted());
1108    }
1109
1110    #[test]
1111    fn test_determine_status_pass() {
1112        let issues = vec![];
1113        assert_eq!(determine_status(&issues), VerificationStatus::Pass);
1114    }
1115
1116    #[test]
1117    fn test_determine_status_fail_critical() {
1118        let issues = vec![VerificationIssue {
1119            severity: IssueSeverity::Critical,
1120            category: IssueCategory::PathTraversal,
1121            entry_path: None,
1122            message: "Test".to_string(),
1123            context: None,
1124        }];
1125        assert_eq!(determine_status(&issues), VerificationStatus::Fail);
1126    }
1127
1128    #[test]
1129    fn test_determine_status_fail_high() {
1130        let issues = vec![VerificationIssue {
1131            severity: IssueSeverity::High,
1132            category: IssueCategory::QuotaExceeded,
1133            entry_path: None,
1134            message: "Test".to_string(),
1135            context: None,
1136        }];
1137        assert_eq!(determine_status(&issues), VerificationStatus::Fail);
1138    }
1139
1140    #[test]
1141    fn test_determine_status_warning() {
1142        let issues = vec![VerificationIssue {
1143            severity: IssueSeverity::Medium,
1144            category: IssueCategory::InvalidPermissions,
1145            entry_path: None,
1146            message: "Test".to_string(),
1147            context: None,
1148        }];
1149        assert_eq!(determine_status(&issues), VerificationStatus::Warning);
1150    }
1151
1152    #[test]
1153    fn test_verification_issue_from_path_traversal() {
1154        let error = ArchiveError::PathTraversal {
1155            path: PathBuf::from("../../etc/passwd"),
1156        };
1157        let issue = VerificationIssue::from_error(&error, None);
1158
1159        assert_eq!(issue.severity, IssueSeverity::Critical);
1160        assert_eq!(issue.category, IssueCategory::PathTraversal);
1161        assert!(issue.message.contains("Path traversal"));
1162    }
1163
1164    #[test]
1165    fn test_verification_issue_from_symlink_escape() {
1166        let error = ArchiveError::SymlinkEscape {
1167            path: PathBuf::from("link"),
1168        };
1169        let issue = VerificationIssue::from_error(&error, Some(PathBuf::from("link")));
1170
1171        assert_eq!(issue.severity, IssueSeverity::Critical);
1172        assert_eq!(issue.category, IssueCategory::SymlinkEscape);
1173        assert!(issue.message.contains("Symlink escape"));
1174    }
1175
1176    #[test]
1177    fn test_verification_issue_from_zip_bomb() {
1178        let error =
1179            ArchiveError::ZipBomb(ZipBombDetails::new(ZipBombScope::Entry, 1000, 1_000_000));
1180        let issue = VerificationIssue::from_error(&error, None);
1181
1182        assert_eq!(issue.severity, IssueSeverity::Critical);
1183        assert_eq!(issue.category, IssueCategory::ZipBomb);
1184        assert!(issue.message.contains("zip bomb"));
1185    }
1186
1187    #[test]
1188    fn test_verification_issue_from_io_error() {
1189        let error = ArchiveError::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
1190        let issue = VerificationIssue::from_error(&error, None);
1191
1192        assert_eq!(issue.severity, IssueSeverity::High);
1193        assert_eq!(issue.category, IssueCategory::InvalidArchive);
1194    }
1195
1196    fn issue(severity: IssueSeverity, category: IssueCategory) -> VerificationIssue {
1197        VerificationIssue::new(severity, category, "Test".to_string())
1198    }
1199
1200    fn report_with(issues: Vec<VerificationIssue>) -> VerificationReport {
1201        let mut manifest = ArchiveManifest::new(ArchiveType::TarGz);
1202        let entry = ArchiveEntry::new(PathBuf::from("a"), ManifestEntryKind::File, 1024);
1203        manifest.add_entry(entry).unwrap();
1204        let mut issues = Some(issues);
1205        VerificationReport::from_entry_issues(&manifest, |_| issues.take().unwrap_or_default())
1206    }
1207
1208    #[test]
1209    fn test_verification_report_is_safe() {
1210        let report = report_with(Vec::new());
1211
1212        assert!(report.is_safe());
1213        assert_eq!(report.total_entries(), 1);
1214        assert_eq!(report.total_size(), 1024);
1215        assert_eq!(report.format(), ArchiveType::TarGz);
1216        assert_eq!(report.suspicious_entries(), 0);
1217    }
1218
1219    #[test]
1220    fn test_verification_report_not_safe() {
1221        let report = report_with(vec![issue(
1222            IssueSeverity::Critical,
1223            IssueCategory::PathTraversal,
1224        )]);
1225
1226        assert!(!report.is_safe());
1227        assert!(report.has_critical_issues());
1228        assert_eq!(report.status(), VerificationStatus::Fail);
1229        assert_eq!(report.security_status(), CheckStatus::Fail);
1230        assert_eq!(report.suspicious_entries(), 1);
1231    }
1232
1233    #[test]
1234    fn test_status_pass_iff_no_suspicious_entries() {
1235        for severity in IssueSeverity::ALL {
1236            let report = report_with(vec![issue(*severity, IssueCategory::SuspiciousPath)]);
1237            assert_eq!(
1238                report.status() == VerificationStatus::Pass,
1239                report.suspicious_entries() == 0,
1240                "{severity}"
1241            );
1242        }
1243    }
1244
1245    #[test]
1246    fn test_issues_sorted_most_severe_first() {
1247        let report = report_with(vec![
1248            issue(IssueSeverity::Low, IssueCategory::ExecutableFile),
1249            issue(IssueSeverity::Critical, IssueCategory::PathTraversal),
1250            issue(IssueSeverity::Medium, IssueCategory::InvalidPermissions),
1251        ]);
1252        let severities: Vec<_> = report.issues().iter().map(|i| i.severity).collect();
1253        assert_eq!(
1254            severities,
1255            [
1256                IssueSeverity::Critical,
1257                IssueSeverity::Medium,
1258                IssueSeverity::Low
1259            ]
1260        );
1261    }
1262
1263    #[test]
1264    fn test_verification_issue_from_hardlink_escape() {
1265        let error = ArchiveError::HardlinkEscape {
1266            path: PathBuf::from("link"),
1267        };
1268        let issue = VerificationIssue::from_error(&error, None);
1269        assert_eq!(issue.severity, IssueSeverity::Critical);
1270        assert_eq!(issue.category, IssueCategory::HardlinkEscape);
1271        assert!(issue.message.contains("Hardlink escape"));
1272    }
1273
1274    #[test]
1275    fn test_verification_issue_from_quota_exceeded() {
1276        let error = ArchiveError::QuotaExceeded {
1277            resource: crate::error::QuotaResource::FileCount {
1278                current: 11,
1279                max: 10,
1280            },
1281        };
1282        let issue = VerificationIssue::from_error(&error, None);
1283        assert_eq!(issue.severity, IssueSeverity::High);
1284        assert_eq!(issue.category, IssueCategory::QuotaExceeded);
1285    }
1286
1287    #[test]
1288    fn test_verification_issue_from_invalid_permissions() {
1289        let error = ArchiveError::InvalidPermissions {
1290            path: PathBuf::from("file.txt"),
1291            mode: 0o777,
1292        };
1293        let issue = VerificationIssue::from_error(&error, None);
1294        assert_eq!(issue.severity, IssueSeverity::Medium);
1295        assert_eq!(issue.category, IssueCategory::InvalidPermissions);
1296        assert!(issue.message.contains("Invalid permissions"));
1297    }
1298
1299    #[test]
1300    fn test_verification_issue_from_invalid_archive() {
1301        let error = ArchiveError::InvalidArchive("bad header".into());
1302        let issue = VerificationIssue::from_error(&error, None);
1303        assert_eq!(issue.severity, IssueSeverity::High);
1304        assert_eq!(issue.category, IssueCategory::InvalidArchive);
1305        assert!(issue.message.contains("Invalid archive"));
1306    }
1307
1308    #[test]
1309    fn test_verification_issue_from_security_violation() {
1310        let error = ArchiveError::violation("encrypted".into());
1311        let issue = VerificationIssue::from_error(&error, None);
1312        assert_eq!(issue.severity, IssueSeverity::High);
1313        assert_eq!(issue.category, IssueCategory::SecurityPolicy);
1314        assert!(issue.message.contains("Security violation"));
1315    }
1316
1317    #[test]
1318    fn test_security_violation_fails_security_status() {
1319        let error = ArchiveError::violation("symlinks not allowed".into());
1320        let report = report_with(vec![VerificationIssue::from_error(&error, None)]);
1321        assert_eq!(report.status(), VerificationStatus::Fail);
1322        assert_eq!(report.security_status(), CheckStatus::Fail);
1323    }
1324
1325    #[test]
1326    fn test_is_security_classification() {
1327        let non_security: Vec<_> = IssueCategory::ALL
1328            .iter()
1329            .copied()
1330            .filter(|c| !c.is_security())
1331            .collect();
1332        assert_eq!(
1333            non_security,
1334            [
1335                IssueCategory::InvalidArchive,
1336                IssueCategory::SuspiciousPath,
1337                IssueCategory::ExecutableFile,
1338                IssueCategory::CorruptData,
1339                IssueCategory::IntegrityIncomplete,
1340                IssueCategory::ArchiveNote,
1341            ]
1342        );
1343    }
1344
1345    fn integrity_report(
1346        issues: Vec<VerificationIssue>,
1347        scope: IntegrityScope,
1348    ) -> VerificationReport {
1349        let mut manifest = ArchiveManifest::new(ArchiveType::TarGz);
1350        let entry = ArchiveEntry::new(PathBuf::from("a"), ManifestEntryKind::File, 1024);
1351        manifest.add_entry(entry).unwrap();
1352        VerificationReport::from_entry_issue_lists(
1353            &manifest,
1354            vec![Vec::new()],
1355            issues,
1356            &ArchiveStructure::of_manifest(&manifest),
1357            scope,
1358        )
1359    }
1360
1361    #[test]
1362    fn test_integrity_not_requested_is_skipped() {
1363        let report = integrity_report(Vec::new(), IntegrityScope::NotRequested);
1364        assert_eq!(report.integrity_status(), IntegrityStatus::Skipped);
1365    }
1366
1367    #[test]
1368    fn test_integrity_requested_without_issues_is_pass() {
1369        let report = integrity_report(
1370            Vec::new(),
1371            IntegrityScope::Requested(IntegrityGaps::default()),
1372        );
1373        assert_eq!(report.integrity_status(), IntegrityStatus::Pass);
1374    }
1375
1376    #[test]
1377    fn test_corrupt_data_fails_even_when_not_requested() {
1378        let issues = vec![issue(IssueSeverity::High, IssueCategory::CorruptData)];
1379        let report = integrity_report(issues, IntegrityScope::NotRequested);
1380        assert_eq!(report.integrity_status(), IntegrityStatus::Fail);
1381    }
1382
1383    #[test]
1384    fn test_corrupt_data_wins_over_incomplete() {
1385        let issues = vec![
1386            issue(IssueSeverity::Medium, IssueCategory::IntegrityIncomplete),
1387            issue(IssueSeverity::High, IssueCategory::CorruptData),
1388        ];
1389        let report = integrity_report(issues, IntegrityScope::Requested(IntegrityGaps::default()));
1390        assert_eq!(report.integrity_status(), IntegrityStatus::Fail);
1391    }
1392
1393    #[test]
1394    fn test_incomplete_issue_gives_incomplete_and_warning() {
1395        let issues = vec![issue(
1396            IssueSeverity::Medium,
1397            IssueCategory::IntegrityIncomplete,
1398        )];
1399        let report = integrity_report(issues, IntegrityScope::Requested(IntegrityGaps::default()));
1400        assert_eq!(report.integrity_status(), IntegrityStatus::Incomplete);
1401        assert_eq!(report.status(), VerificationStatus::Warning);
1402        assert_eq!(report.security_status(), CheckStatus::Pass);
1403    }
1404
1405    #[test]
1406    fn test_archive_level_issues_do_not_count_as_suspicious_entries() {
1407        let issues = vec![issue(IssueSeverity::High, IssueCategory::CorruptData)];
1408        let report = integrity_report(issues, IntegrityScope::Requested(IntegrityGaps::default()));
1409        assert_eq!(report.suspicious_entries(), 0);
1410        assert_eq!(report.status(), VerificationStatus::Fail);
1411    }
1412
1413    fn two_file_manifest() -> ArchiveManifest {
1414        let mut manifest = ArchiveManifest::new(ArchiveType::Zip);
1415        for name in ["a.txt", "b.txt"] {
1416            let entry = ArchiveEntry::new(PathBuf::from(name), ManifestEntryKind::File, 1);
1417            manifest.add_entry(entry).unwrap();
1418        }
1419        manifest
1420    }
1421
1422    fn report_of_findings(findings: &[ZipInconsistency]) -> VerificationReport {
1423        let manifest = two_file_manifest();
1424        VerificationReport::from_entry_issue_lists(
1425            &manifest,
1426            vec![Vec::new(), Vec::new()],
1427            Vec::new(),
1428            &ArchiveStructure::new(2, findings, 0),
1429            IntegrityScope::NotRequested,
1430        )
1431    }
1432
1433    #[test]
1434    fn test_entry_bound_findings_name_their_entry_and_count_it_once() {
1435        use crate::ZipEntryIndex;
1436
1437        let findings = [
1438            ZipInconsistency::XlTypeDivergence {
1439                entry: ZipEntryIndex::new(1),
1440                offset: 40,
1441            },
1442            ZipInconsistency::DescriptorOverrun {
1443                entry: ZipEntryIndex::new(1),
1444                offset: 60,
1445            },
1446        ];
1447        let report = report_of_findings(&findings);
1448        assert_eq!(report.suspicious_entries(), 1);
1449        assert_eq!(report.issues().len(), 2);
1450        for issue in report.issues() {
1451            assert_eq!(issue.entry_path.as_deref(), Some(Path::new("b.txt")));
1452            assert_eq!(issue.category, IssueCategory::HiddenEntry);
1453        }
1454    }
1455
1456    fn overrun_of(index: usize) -> [ZipInconsistency; 1] {
1457        [ZipInconsistency::DescriptorOverrun {
1458            entry: crate::ZipEntryIndex::new(index),
1459            offset: 9,
1460        }]
1461    }
1462
1463    fn report_after_dropped(
1464        dropped_before: usize,
1465        findings: &[ZipInconsistency],
1466    ) -> VerificationReport {
1467        let mut manifest = ArchiveManifest::new(ArchiveType::Zip);
1468        for _ in 0..dropped_before {
1469            manifest.add_oversized(300);
1470        }
1471        for name in ["a.txt", "b.txt"] {
1472            let entry = ArchiveEntry::new(PathBuf::from(name), ManifestEntryKind::File, 1);
1473            manifest.add_entry(entry).unwrap();
1474        }
1475        VerificationReport::from_entry_issue_lists(
1476            &manifest,
1477            vec![Vec::new(), Vec::new()],
1478            Vec::new(),
1479            &ArchiveStructure::new(2 + dropped_before, findings, 0),
1480            IntegrityScope::NotRequested,
1481        )
1482    }
1483
1484    #[test]
1485    fn test_finding_index_skips_entries_dropped_before_it() {
1486        let report = report_after_dropped(1, &overrun_of(2));
1487        assert_eq!(report.suspicious_entries(), 1);
1488        assert_eq!(
1489            report.issues()[0].entry_path.as_deref(),
1490            Some(Path::new("b.txt"))
1491        );
1492    }
1493
1494    #[test]
1495    fn test_finding_of_a_dropped_entry_is_archive_level() {
1496        let report = report_after_dropped(1, &overrun_of(0));
1497        assert_eq!(report.suspicious_entries(), 0);
1498        assert_eq!(report.issues().len(), 1);
1499        assert!(report.issues()[0].entry_path.is_none());
1500    }
1501
1502    #[test]
1503    fn test_archive_level_findings_stay_unbound() {
1504        let findings = [
1505            ZipInconsistency::OverlappingEntries,
1506            ZipInconsistency::StrayCentralRecord { offset: 9 },
1507        ];
1508        let report = report_of_findings(&findings);
1509        assert_eq!(report.suspicious_entries(), 0);
1510        assert_eq!(report.issues().len(), 2);
1511        assert!(report.issues().iter().all(|i| i.entry_path.is_none()));
1512    }
1513
1514    #[test]
1515    fn test_verification_issue_from_source_not_found() {
1516        let error = ArchiveError::SourceNotFound {
1517            path: PathBuf::from("/missing"),
1518        };
1519        let issue = VerificationIssue::from_error(&error, None);
1520        assert_eq!(issue.severity, IssueSeverity::High);
1521        assert_eq!(issue.category, IssueCategory::InvalidArchive);
1522        assert!(issue.message.contains("Source not found"));
1523    }
1524
1525    #[test]
1526    fn test_verification_issue_from_source_not_accessible() {
1527        let error = ArchiveError::SourceNotAccessible {
1528            path: PathBuf::from("/restricted"),
1529        };
1530        let issue = VerificationIssue::from_error(&error, None);
1531        assert_eq!(issue.severity, IssueSeverity::High);
1532        assert_eq!(issue.category, IssueCategory::InvalidArchive);
1533        assert!(issue.message.contains("Source not accessible"));
1534    }
1535
1536    #[test]
1537    fn test_verification_issue_from_output_exists() {
1538        let error = ArchiveError::OutputExists {
1539            path: PathBuf::from("out/"),
1540        };
1541        let issue = VerificationIssue::from_error(&error, None);
1542        assert_eq!(issue.severity, IssueSeverity::Medium);
1543        assert_eq!(issue.category, IssueCategory::InvalidArchive);
1544        assert!(issue.message.contains("Output already exists"));
1545    }
1546
1547    #[test]
1548    fn test_verification_issue_from_invalid_compression_level() {
1549        let error = ArchiveError::InvalidCompressionLevel { level: 0 };
1550        let issue = VerificationIssue::from_error(&error, None);
1551        assert_eq!(issue.severity, IssueSeverity::Medium);
1552        assert_eq!(issue.category, IssueCategory::InvalidArchive);
1553        assert!(issue.message.contains("Invalid compression level"));
1554    }
1555
1556    #[test]
1557    fn test_verification_issue_from_unknown_format() {
1558        let error = ArchiveError::UnknownFormat {
1559            path: PathBuf::from("archive.rar"),
1560        };
1561        let issue = VerificationIssue::from_error(&error, None);
1562        assert_eq!(issue.severity, IssueSeverity::High);
1563        assert_eq!(issue.category, IssueCategory::InvalidArchive);
1564        assert!(issue.message.contains("Unknown format"));
1565    }
1566
1567    #[test]
1568    fn test_verification_issue_from_invalid_configuration() {
1569        let error = ArchiveError::InvalidConfiguration {
1570            reason: "bad config".into(),
1571        };
1572        let issue = VerificationIssue::from_error(&error, None);
1573        assert_eq!(issue.severity, IssueSeverity::High);
1574        assert_eq!(issue.category, IssueCategory::InvalidArchive);
1575        assert!(issue.message.contains("Invalid configuration"));
1576    }
1577
1578    #[test]
1579    fn test_verification_report_issues_by_severity() {
1580        let report = report_with(vec![
1581            issue(IssueSeverity::Critical, IssueCategory::PathTraversal),
1582            issue(IssueSeverity::Low, IssueCategory::ExecutableFile),
1583        ]);
1584
1585        assert_eq!(report.issues_by_severity(IssueSeverity::Critical).len(), 1);
1586        assert_eq!(report.issues_by_severity(IssueSeverity::Low).len(), 1);
1587        assert_eq!(report.status(), VerificationStatus::Fail);
1588    }
1589
1590    proptest::proptest! {
1591        /// The pass reports every unverified item as a Medium issue, so
1592        /// INCOMPLETE can never sit next to an overall PASS.
1593        #[test]
1594        fn prop_incomplete_integrity_never_coexists_with_overall_pass(
1595            raw in proptest::collection::vec(
1596                (0..IssueSeverity::ALL.len(), 0..IssueCategory::ALL.len()),
1597                0..8,
1598            ),
1599            requested in proptest::bool::ANY,
1600        ) {
1601            let issues = raw
1602                .into_iter()
1603                .map(|(severity, category)| {
1604                    let category = IssueCategory::ALL[category];
1605                    let severity = IssueSeverity::ALL[severity];
1606                    let severity = if category == IssueCategory::IntegrityIncomplete {
1607                        severity.max(IssueSeverity::Medium)
1608                    } else {
1609                        severity
1610                    };
1611                    issue(severity, category)
1612                })
1613                .collect();
1614            let scope = if requested {
1615                IntegrityScope::Requested(IntegrityGaps::default())
1616            } else {
1617                IntegrityScope::NotRequested
1618            };
1619
1620            let report = integrity_report(issues, scope);
1621            if report.integrity_status() == IntegrityStatus::Incomplete {
1622                proptest::prop_assert_ne!(report.status(), VerificationStatus::Pass);
1623            }
1624        }
1625    }
1626}