1use std::path::PathBuf;
4
5use crate::error::ArchiveError;
6use crate::formats::detect::ArchiveType;
7
8#[derive(Debug, Clone)]
36pub struct VerificationReport {
37 pub status: VerificationStatus,
39
40 pub integrity_status: CheckStatus,
42
43 pub security_status: CheckStatus,
45
46 pub issues: Vec<VerificationIssue>,
48
49 pub total_entries: usize,
51
52 pub suspicious_entries: usize,
54
55 pub total_size: u64,
57
58 pub format: ArchiveType,
60}
61
62impl VerificationReport {
63 #[must_use]
66 pub fn is_safe(&self) -> bool {
67 self.status == VerificationStatus::Pass
68 }
69
70 #[must_use]
72 pub fn has_critical_issues(&self) -> bool {
73 self.issues
74 .iter()
75 .any(|i| i.severity == IssueSeverity::Critical)
76 }
77
78 #[must_use]
80 pub fn issues_by_severity(&self, severity: IssueSeverity) -> Vec<&VerificationIssue> {
81 self.issues
82 .iter()
83 .filter(|i| i.severity == severity)
84 .collect()
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum VerificationStatus {
91 Pass,
93
94 Fail,
96
97 Warning,
99}
100
101impl std::fmt::Display for VerificationStatus {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 match self {
104 Self::Pass => write!(f, "PASS"),
105 Self::Fail => write!(f, "FAIL"),
106 Self::Warning => write!(f, "WARNING"),
107 }
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum CheckStatus {
114 Pass,
116
117 Fail,
119
120 Warning,
122
123 Skipped,
125}
126
127impl std::fmt::Display for CheckStatus {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 match self {
130 Self::Pass => write!(f, "OK"),
131 Self::Fail => write!(f, "FAILED"),
132 Self::Warning => write!(f, "WARNING"),
133 Self::Skipped => write!(f, "SKIPPED"),
134 }
135 }
136}
137
138#[derive(Debug, Clone)]
140pub struct VerificationIssue {
141 pub severity: IssueSeverity,
143
144 pub category: IssueCategory,
146
147 pub entry_path: Option<PathBuf>,
149
150 pub message: String,
152
153 pub context: Option<String>,
155}
156
157impl VerificationIssue {
158 #[must_use]
160 #[allow(clippy::too_many_lines)]
161 pub fn from_error(error: &ArchiveError, entry_path: Option<PathBuf>) -> Self {
162 let (severity, category, message) = match error {
163 ArchiveError::PathTraversal { path } => (
164 IssueSeverity::Critical,
165 IssueCategory::PathTraversal,
166 format!("Path traversal detected: {}", path.display()),
167 ),
168 ArchiveError::SymlinkEscape { path } => (
169 IssueSeverity::Critical,
170 IssueCategory::SymlinkEscape,
171 format!("Symlink escape: {}", path.display()),
172 ),
173 ArchiveError::HardlinkEscape { path } => (
174 IssueSeverity::Critical,
175 IssueCategory::HardlinkEscape,
176 format!("Hardlink escape: {}", path.display()),
177 ),
178 ArchiveError::ZipBomb {
179 compressed,
180 uncompressed,
181 ratio,
182 } => (
183 IssueSeverity::Critical,
184 IssueCategory::ZipBomb,
185 format!(
186 "Potential zip bomb: {ratio:.1}x compression ratio (compressed={compressed}, uncompressed={uncompressed})"
187 ),
188 ),
189 ArchiveError::QuotaExceeded { resource } => (
190 IssueSeverity::High,
191 IssueCategory::QuotaExceeded,
192 format!("{resource}"),
193 ),
194 ArchiveError::InvalidPermissions { path, mode } => (
195 IssueSeverity::Medium,
196 IssueCategory::InvalidPermissions,
197 format!(
198 "Invalid permissions: {} (mode: {:#o})",
199 path.display(),
200 mode
201 ),
202 ),
203 ArchiveError::Io(io_err) => (
204 IssueSeverity::High,
205 IssueCategory::InvalidArchive,
206 format!("I/O error: {io_err}"),
207 ),
208 ArchiveError::InvalidArchive(msg) => (
209 IssueSeverity::High,
210 IssueCategory::InvalidArchive,
211 format!("Invalid archive: {msg}"),
212 ),
213 ArchiveError::SecurityViolation { reason } => (
214 IssueSeverity::High,
215 IssueCategory::SuspiciousPath,
216 format!("Security violation: {reason}"),
217 ),
218 ArchiveError::SourceNotFound { path } => (
219 IssueSeverity::High,
220 IssueCategory::InvalidArchive,
221 format!("Source not found: {}", path.display()),
222 ),
223 ArchiveError::SourceNotAccessible { path } => (
224 IssueSeverity::High,
225 IssueCategory::InvalidArchive,
226 format!("Source not accessible: {}", path.display()),
227 ),
228 ArchiveError::OutputExists { path } => (
229 IssueSeverity::Medium,
230 IssueCategory::InvalidArchive,
231 format!("Output already exists: {}", path.display()),
232 ),
233 ArchiveError::InvalidCompressionLevel { level } => (
234 IssueSeverity::Medium,
235 IssueCategory::InvalidArchive,
236 format!("Invalid compression level: {level}"),
237 ),
238 ArchiveError::UnknownFormat { path } => (
239 IssueSeverity::High,
240 IssueCategory::InvalidArchive,
241 format!("Unknown format: {}", path.display()),
242 ),
243 ArchiveError::InvalidConfiguration { reason } => (
244 IssueSeverity::High,
245 IssueCategory::InvalidArchive,
246 format!("Invalid configuration: {reason}"),
247 ),
248 ArchiveError::PartialExtraction { source, .. } => {
249 return Self::from_error(source, entry_path);
250 }
251 };
252
253 Self {
254 severity,
255 category,
256 entry_path,
257 message,
258 context: None,
259 }
260 }
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
265pub enum IssueSeverity {
266 Info,
268
269 Low,
271
272 Medium,
274
275 High,
277
278 Critical,
280}
281
282impl std::fmt::Display for IssueSeverity {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 match self {
285 Self::Critical => write!(f, "CRITICAL"),
286 Self::High => write!(f, "HIGH"),
287 Self::Medium => write!(f, "MEDIUM"),
288 Self::Low => write!(f, "LOW"),
289 Self::Info => write!(f, "INFO"),
290 }
291 }
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299#[non_exhaustive]
300pub enum IssueCategory {
301 PathTraversal,
303
304 SymlinkEscape,
306
307 HardlinkEscape,
309
310 ZipBomb,
312
313 InvalidPermissions,
315
316 QuotaExceeded,
318
319 InvalidArchive,
321
322 SuspiciousPath,
324
325 ExecutableFile,
327}
328
329impl std::fmt::Display for IssueCategory {
330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331 match self {
332 Self::PathTraversal => write!(f, "Path Traversal"),
333 Self::SymlinkEscape => write!(f, "Symlink Escape"),
334 Self::HardlinkEscape => write!(f, "Hardlink Escape"),
335 Self::ZipBomb => write!(f, "Zip Bomb"),
336 Self::InvalidPermissions => write!(f, "Invalid Permissions"),
337 Self::QuotaExceeded => write!(f, "Quota Exceeded"),
338 Self::InvalidArchive => write!(f, "Invalid Archive"),
339 Self::SuspiciousPath => write!(f, "Suspicious Path"),
340 Self::ExecutableFile => write!(f, "Executable File"),
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use std::io;
349
350 #[test]
351 fn test_verification_status_display() {
352 assert_eq!(VerificationStatus::Pass.to_string(), "PASS");
353 assert_eq!(VerificationStatus::Fail.to_string(), "FAIL");
354 assert_eq!(VerificationStatus::Warning.to_string(), "WARNING");
355 }
356
357 #[test]
358 fn test_check_status_display() {
359 assert_eq!(CheckStatus::Pass.to_string(), "OK");
360 assert_eq!(CheckStatus::Fail.to_string(), "FAILED");
361 assert_eq!(CheckStatus::Warning.to_string(), "WARNING");
362 assert_eq!(CheckStatus::Skipped.to_string(), "SKIPPED");
363 }
364
365 #[test]
366 fn test_issue_severity_display() {
367 assert_eq!(IssueSeverity::Critical.to_string(), "CRITICAL");
368 assert_eq!(IssueSeverity::High.to_string(), "HIGH");
369 assert_eq!(IssueSeverity::Medium.to_string(), "MEDIUM");
370 assert_eq!(IssueSeverity::Low.to_string(), "LOW");
371 assert_eq!(IssueSeverity::Info.to_string(), "INFO");
372 }
373
374 #[test]
375 fn test_issue_severity_ordering() {
376 assert!(IssueSeverity::Critical > IssueSeverity::High);
377 assert!(IssueSeverity::High > IssueSeverity::Medium);
378 assert!(IssueSeverity::Medium > IssueSeverity::Low);
379 assert!(IssueSeverity::Low > IssueSeverity::Info);
380 }
381
382 #[test]
383 fn test_issue_category_display() {
384 assert_eq!(IssueCategory::PathTraversal.to_string(), "Path Traversal");
385 assert_eq!(IssueCategory::SymlinkEscape.to_string(), "Symlink Escape");
386 }
387
388 #[test]
389 fn test_verification_issue_from_path_traversal() {
390 let error = ArchiveError::PathTraversal {
391 path: PathBuf::from("../../etc/passwd"),
392 };
393 let issue = VerificationIssue::from_error(&error, None);
394
395 assert_eq!(issue.severity, IssueSeverity::Critical);
396 assert_eq!(issue.category, IssueCategory::PathTraversal);
397 assert!(issue.message.contains("Path traversal"));
398 }
399
400 #[test]
401 fn test_verification_issue_from_symlink_escape() {
402 let error = ArchiveError::SymlinkEscape {
403 path: PathBuf::from("link"),
404 };
405 let issue = VerificationIssue::from_error(&error, Some(PathBuf::from("link")));
406
407 assert_eq!(issue.severity, IssueSeverity::Critical);
408 assert_eq!(issue.category, IssueCategory::SymlinkEscape);
409 assert!(issue.message.contains("Symlink escape"));
410 }
411
412 #[test]
413 fn test_verification_issue_from_zip_bomb() {
414 let error = ArchiveError::ZipBomb {
415 compressed: 1000,
416 uncompressed: 1_000_000,
417 ratio: 1000.0,
418 };
419 let issue = VerificationIssue::from_error(&error, None);
420
421 assert_eq!(issue.severity, IssueSeverity::Critical);
422 assert_eq!(issue.category, IssueCategory::ZipBomb);
423 assert!(issue.message.contains("zip bomb"));
424 }
425
426 #[test]
427 fn test_verification_issue_from_io_error() {
428 let error = ArchiveError::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
429 let issue = VerificationIssue::from_error(&error, None);
430
431 assert_eq!(issue.severity, IssueSeverity::High);
432 assert_eq!(issue.category, IssueCategory::InvalidArchive);
433 }
434
435 #[test]
436 fn test_verification_report_is_safe() {
437 let report = VerificationReport {
438 status: VerificationStatus::Pass,
439 integrity_status: CheckStatus::Pass,
440 security_status: CheckStatus::Pass,
441 issues: Vec::new(),
442 total_entries: 10,
443 suspicious_entries: 0,
444 total_size: 1024,
445 format: ArchiveType::TarGz,
446 };
447
448 assert!(report.is_safe());
449 }
450
451 #[test]
452 fn test_verification_report_not_safe() {
453 let report = VerificationReport {
454 status: VerificationStatus::Fail,
455 integrity_status: CheckStatus::Pass,
456 security_status: CheckStatus::Fail,
457 issues: vec![VerificationIssue {
458 severity: IssueSeverity::Critical,
459 category: IssueCategory::PathTraversal,
460 entry_path: None,
461 message: "Test issue".to_string(),
462 context: None,
463 }],
464 total_entries: 10,
465 suspicious_entries: 1,
466 total_size: 1024,
467 format: ArchiveType::TarGz,
468 };
469
470 assert!(!report.is_safe());
471 assert!(report.has_critical_issues());
472 }
473
474 #[test]
475 fn test_verification_issue_from_hardlink_escape() {
476 let error = ArchiveError::HardlinkEscape {
477 path: PathBuf::from("link"),
478 };
479 let issue = VerificationIssue::from_error(&error, None);
480 assert_eq!(issue.severity, IssueSeverity::Critical);
481 assert_eq!(issue.category, IssueCategory::HardlinkEscape);
482 assert!(issue.message.contains("Hardlink escape"));
483 }
484
485 #[test]
486 fn test_verification_issue_from_quota_exceeded() {
487 let error = ArchiveError::QuotaExceeded {
488 resource: crate::error::QuotaResource::FileCount {
489 current: 11,
490 max: 10,
491 },
492 };
493 let issue = VerificationIssue::from_error(&error, None);
494 assert_eq!(issue.severity, IssueSeverity::High);
495 assert_eq!(issue.category, IssueCategory::QuotaExceeded);
496 }
497
498 #[test]
499 fn test_verification_issue_from_invalid_permissions() {
500 let error = ArchiveError::InvalidPermissions {
501 path: PathBuf::from("file.txt"),
502 mode: 0o777,
503 };
504 let issue = VerificationIssue::from_error(&error, None);
505 assert_eq!(issue.severity, IssueSeverity::Medium);
506 assert_eq!(issue.category, IssueCategory::InvalidPermissions);
507 assert!(issue.message.contains("Invalid permissions"));
508 }
509
510 #[test]
511 fn test_verification_issue_from_invalid_archive() {
512 let error = ArchiveError::InvalidArchive("bad header".into());
513 let issue = VerificationIssue::from_error(&error, None);
514 assert_eq!(issue.severity, IssueSeverity::High);
515 assert_eq!(issue.category, IssueCategory::InvalidArchive);
516 assert!(issue.message.contains("Invalid archive"));
517 }
518
519 #[test]
520 fn test_verification_issue_from_security_violation() {
521 let error = ArchiveError::SecurityViolation {
522 reason: "encrypted".into(),
523 };
524 let issue = VerificationIssue::from_error(&error, None);
525 assert_eq!(issue.severity, IssueSeverity::High);
526 assert_eq!(issue.category, IssueCategory::SuspiciousPath);
527 assert!(issue.message.contains("Security violation"));
528 }
529
530 #[test]
531 fn test_verification_issue_from_source_not_found() {
532 let error = ArchiveError::SourceNotFound {
533 path: PathBuf::from("/missing"),
534 };
535 let issue = VerificationIssue::from_error(&error, None);
536 assert_eq!(issue.severity, IssueSeverity::High);
537 assert_eq!(issue.category, IssueCategory::InvalidArchive);
538 assert!(issue.message.contains("Source not found"));
539 }
540
541 #[test]
542 fn test_verification_issue_from_source_not_accessible() {
543 let error = ArchiveError::SourceNotAccessible {
544 path: PathBuf::from("/restricted"),
545 };
546 let issue = VerificationIssue::from_error(&error, None);
547 assert_eq!(issue.severity, IssueSeverity::High);
548 assert_eq!(issue.category, IssueCategory::InvalidArchive);
549 assert!(issue.message.contains("Source not accessible"));
550 }
551
552 #[test]
553 fn test_verification_issue_from_output_exists() {
554 let error = ArchiveError::OutputExists {
555 path: PathBuf::from("out/"),
556 };
557 let issue = VerificationIssue::from_error(&error, None);
558 assert_eq!(issue.severity, IssueSeverity::Medium);
559 assert_eq!(issue.category, IssueCategory::InvalidArchive);
560 assert!(issue.message.contains("Output already exists"));
561 }
562
563 #[test]
564 fn test_verification_issue_from_invalid_compression_level() {
565 let error = ArchiveError::InvalidCompressionLevel { level: 0 };
566 let issue = VerificationIssue::from_error(&error, None);
567 assert_eq!(issue.severity, IssueSeverity::Medium);
568 assert_eq!(issue.category, IssueCategory::InvalidArchive);
569 assert!(issue.message.contains("Invalid compression level"));
570 }
571
572 #[test]
573 fn test_verification_issue_from_unknown_format() {
574 let error = ArchiveError::UnknownFormat {
575 path: PathBuf::from("archive.rar"),
576 };
577 let issue = VerificationIssue::from_error(&error, None);
578 assert_eq!(issue.severity, IssueSeverity::High);
579 assert_eq!(issue.category, IssueCategory::InvalidArchive);
580 assert!(issue.message.contains("Unknown format"));
581 }
582
583 #[test]
584 fn test_verification_issue_from_invalid_configuration() {
585 let error = ArchiveError::InvalidConfiguration {
586 reason: "bad config".into(),
587 };
588 let issue = VerificationIssue::from_error(&error, None);
589 assert_eq!(issue.severity, IssueSeverity::High);
590 assert_eq!(issue.category, IssueCategory::InvalidArchive);
591 assert!(issue.message.contains("Invalid configuration"));
592 }
593
594 #[test]
595 fn test_verification_report_issues_by_severity() {
596 let report = VerificationReport {
597 status: VerificationStatus::Warning,
598 integrity_status: CheckStatus::Pass,
599 security_status: CheckStatus::Warning,
600 issues: vec![
601 VerificationIssue {
602 severity: IssueSeverity::Critical,
603 category: IssueCategory::PathTraversal,
604 entry_path: None,
605 message: "Critical issue".to_string(),
606 context: None,
607 },
608 VerificationIssue {
609 severity: IssueSeverity::Low,
610 category: IssueCategory::ExecutableFile,
611 entry_path: None,
612 message: "Low issue".to_string(),
613 context: None,
614 },
615 ],
616 total_entries: 10,
617 suspicious_entries: 2,
618 total_size: 1024,
619 format: ArchiveType::TarGz,
620 };
621
622 let critical_issues = report.issues_by_severity(IssueSeverity::Critical);
623 assert_eq!(critical_issues.len(), 1);
624
625 let low_issues = report.issues_by_severity(IssueSeverity::Low);
626 assert_eq!(low_issues.len(), 1);
627 }
628}