1use 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#[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 #[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 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 #[must_use]
185 pub fn status(&self) -> VerificationStatus {
186 determine_status(&self.issues)
187 }
188
189 #[must_use]
193 pub fn security_status(&self) -> CheckStatus {
194 determine_security_status(&self.issues)
195 }
196
197 #[must_use]
208 pub fn integrity_status(&self) -> IntegrityStatus {
209 determine_integrity_status(&self.issues, &self.integrity)
210 }
211
212 #[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 #[must_use]
227 pub fn issues(&self) -> &[VerificationIssue] {
228 &self.issues
229 }
230
231 #[must_use]
233 pub fn into_issues(self) -> Vec<VerificationIssue> {
234 self.issues
235 }
236
237 #[must_use]
239 pub const fn total_entries(&self) -> usize {
240 self.total_entries
241 }
242
243 #[must_use]
248 pub const fn suspicious_entries(&self) -> usize {
249 self.suspicious_entries
250 }
251
252 #[must_use]
254 pub const fn total_size(&self) -> u64 {
255 self.total_size
256 }
257
258 #[must_use]
260 pub const fn format(&self) -> ArchiveType {
261 self.format
262 }
263
264 #[must_use]
267 pub fn is_safe(&self) -> bool {
268 self.status() == VerificationStatus::Pass
269 }
270
271 #[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 #[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#[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 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 pub(crate) const fn with_refusals(mut self, refusals: &'a [VerificationIssue]) -> Self {
316 self.refusals = refusals;
317 self
318 }
319
320 pub(crate) const fn refusals(&self) -> &'a [VerificationIssue] {
322 self.refusals
323 }
324
325 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#[derive(Debug, Clone, PartialEq, Eq)]
364pub(crate) enum IntegrityScope {
365 NotRequested,
366 Requested(IntegrityGaps),
367}
368
369closed_enum! {
370 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
376 #[non_exhaustive]
377 pub enum UnverifiedReason {
378 OverBudget => "over_budget",
380
381 ResourceLimit => "resource_limit",
383
384 SolidPolicy => "solid_policy",
386
387 SizeCeiling => "size_ceiling",
389
390 UncoveredBlockCrc => "uncovered_block_crc",
392
393 NotDecodable => "not_decodable",
395
396 NoEndMarker => "no_end_marker",
398
399 PathTooLong => "path_too_long",
402
403 DescriptorTail => "descriptor_tail",
405 }
406}
407
408impl UnverifiedReason {
409 #[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
428pub(crate) const MAX_LISTED_UNVERIFIED: usize = 256;
431
432#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct UnverifiedEntry {
435 path: PathBuf,
436 reason: UnverifiedReason,
437}
438
439impl UnverifiedEntry {
440 #[must_use]
442 pub fn path(&self) -> &Path {
443 &self.path
444 }
445
446 #[must_use]
448 pub const fn reason(&self) -> UnverifiedReason {
449 self.reason
450 }
451}
452
453#[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 pub fn counts(&self) -> impl Iterator<Item = (UnverifiedReason, u64)> + '_ {
495 self.counts.iter().map(|(&reason, &count)| (reason, count))
496 }
497
498 #[must_use]
500 pub fn count(&self, reason: UnverifiedReason) -> u64 {
501 self.counts.get(&reason).copied().unwrap_or_default()
502 }
503
504 #[must_use]
506 pub fn total(&self) -> u64 {
507 self.counts.values().sum()
508 }
509
510 #[must_use]
512 pub fn is_empty(&self) -> bool {
513 self.counts.is_empty()
514 }
515
516 #[must_use]
519 pub fn entries(&self) -> &[UnverifiedEntry] {
520 &self.entries
521 }
522
523 #[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 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 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
589 pub enum VerificationStatus {
590 Pass => "PASS",
592
593 Fail => "FAIL",
595
596 Warning => "WARNING",
598 }
599}
600
601closed_enum! {
602 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
604 pub enum CheckStatus {
605 Pass => "OK",
607
608 Fail => "FAILED",
610
611 Warning => "WARNING",
613
614 Skipped => "SKIPPED",
616 }
617}
618
619closed_enum! {
620 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
625 #[non_exhaustive]
626 pub enum IntegrityStatus {
627 Pass => "OK",
631
632 Fail => "FAILED",
634
635 Incomplete => "INCOMPLETE",
638
639 Skipped => "SKIPPED",
641 }
642}
643
644#[derive(Debug, Clone)]
649#[non_exhaustive]
650pub struct VerificationIssue {
651 pub severity: IssueSeverity,
653
654 pub category: IssueCategory,
656
657 pub entry_path: Option<PathBuf>,
659
660 pub message: String,
662
663 pub context: Option<String>,
665}
666
667impl VerificationIssue {
668 fn is_harmless_collision(&self) -> bool {
671 self.category == IssueCategory::NameCollision && self.severity == IssueSeverity::Low
672 }
673
674 #[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 #[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 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
792 pub enum IssueSeverity {
793 Info => "INFO",
795
796 Low => "LOW",
798
799 Medium => "MEDIUM",
801
802 High => "HIGH",
804
805 Critical => "CRITICAL",
807 }
808}
809
810closed_enum! {
811 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
816 #[non_exhaustive]
817 pub enum IssueCategory {
818 PathTraversal => "Path Traversal",
820
821 SymlinkEscape => "Symlink Escape",
823
824 HardlinkEscape => "Hardlink Escape",
826
827 ZipBomb => "Zip Bomb",
829
830 InvalidPermissions => "Invalid Permissions",
832
833 QuotaExceeded => "Quota Exceeded",
835
836 SecurityPolicy => "Security Policy",
839
840 InvalidArchive => "Invalid Archive",
842
843 SuspiciousPath => "Suspicious Path",
845
846 ExecutableFile => "Executable File",
848
849 CorruptData => "Corrupt Data",
851
852 IntegrityIncomplete => "Integrity Incomplete",
854
855 HeaderMismatch => "Header Mismatch",
857
858 DuplicateEntry => "Duplicate Entry",
860
861 OverlappingEntries => "Overlapping Entries",
863
864 HiddenEntry => "Hidden Entry",
866
867 NameCollision => "Name Collision",
869
870 ArchiveNote => "Archive Note",
873 }
874}
875
876impl IssueCategory {
877 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 #[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 #[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}