1use std::path::PathBuf;
4
5use crate::error::ExtractionError;
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: &ExtractionError, entry_path: Option<PathBuf>) -> Self {
162 let (severity, category, message) = match error {
163 ExtractionError::PathTraversal { path } => (
164 IssueSeverity::Critical,
165 IssueCategory::PathTraversal,
166 format!("Path traversal detected: {}", path.display()),
167 ),
168 ExtractionError::SymlinkEscape { path } => (
169 IssueSeverity::Critical,
170 IssueCategory::SymlinkEscape,
171 format!("Symlink escape: {}", path.display()),
172 ),
173 ExtractionError::HardlinkEscape { path } => (
174 IssueSeverity::Critical,
175 IssueCategory::HardlinkEscape,
176 format!("Hardlink escape: {}", path.display()),
177 ),
178 ExtractionError::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 ExtractionError::QuotaExceeded { resource } => (
190 IssueSeverity::High,
191 IssueCategory::QuotaExceeded,
192 format!("{resource}"),
193 ),
194 ExtractionError::InvalidPermissions { path, mode } => (
195 IssueSeverity::Medium,
196 IssueCategory::InvalidPermissions,
197 format!(
198 "Invalid permissions: {} (mode: {:#o})",
199 path.display(),
200 mode
201 ),
202 ),
203 ExtractionError::Io(io_err) => (
204 IssueSeverity::High,
205 IssueCategory::InvalidArchive,
206 format!("I/O error: {io_err}"),
207 ),
208 ExtractionError::UnsupportedFormat => (
209 IssueSeverity::High,
210 IssueCategory::InvalidArchive,
211 "Unsupported archive format".to_string(),
212 ),
213 ExtractionError::InvalidArchive(msg) => (
214 IssueSeverity::High,
215 IssueCategory::InvalidArchive,
216 format!("Invalid archive: {msg}"),
217 ),
218 ExtractionError::SecurityViolation { reason } => (
219 IssueSeverity::High,
220 IssueCategory::SuspiciousPath,
221 format!("Security violation: {reason}"),
222 ),
223 ExtractionError::SourceNotFound { path } => (
224 IssueSeverity::High,
225 IssueCategory::InvalidArchive,
226 format!("Source not found: {}", path.display()),
227 ),
228 ExtractionError::SourceNotAccessible { path } => (
229 IssueSeverity::High,
230 IssueCategory::InvalidArchive,
231 format!("Source not accessible: {}", path.display()),
232 ),
233 ExtractionError::OutputExists { path } => (
234 IssueSeverity::Medium,
235 IssueCategory::InvalidArchive,
236 format!("Output already exists: {}", path.display()),
237 ),
238 ExtractionError::InvalidCompressionLevel { level } => (
239 IssueSeverity::Medium,
240 IssueCategory::InvalidArchive,
241 format!("Invalid compression level: {level}"),
242 ),
243 ExtractionError::UnknownFormat { path } => (
244 IssueSeverity::High,
245 IssueCategory::InvalidArchive,
246 format!("Unknown format: {}", path.display()),
247 ),
248 ExtractionError::InvalidConfiguration { reason } => (
249 IssueSeverity::High,
250 IssueCategory::InvalidArchive,
251 format!("Invalid configuration: {reason}"),
252 ),
253 ExtractionError::PartialExtraction { source, .. } => {
254 return Self::from_error(source, entry_path);
255 }
256 };
257
258 Self {
259 severity,
260 category,
261 entry_path,
262 message,
263 context: None,
264 }
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
270pub enum IssueSeverity {
271 Info,
273
274 Low,
276
277 Medium,
279
280 High,
282
283 Critical,
285}
286
287impl std::fmt::Display for IssueSeverity {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 match self {
290 Self::Critical => write!(f, "CRITICAL"),
291 Self::High => write!(f, "HIGH"),
292 Self::Medium => write!(f, "MEDIUM"),
293 Self::Low => write!(f, "LOW"),
294 Self::Info => write!(f, "INFO"),
295 }
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum IssueCategory {
302 PathTraversal,
304
305 SymlinkEscape,
307
308 HardlinkEscape,
310
311 ZipBomb,
313
314 InvalidPermissions,
316
317 QuotaExceeded,
319
320 InvalidArchive,
322
323 SuspiciousPath,
325
326 ExecutableFile,
328}
329
330impl std::fmt::Display for IssueCategory {
331 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332 match self {
333 Self::PathTraversal => write!(f, "Path Traversal"),
334 Self::SymlinkEscape => write!(f, "Symlink Escape"),
335 Self::HardlinkEscape => write!(f, "Hardlink Escape"),
336 Self::ZipBomb => write!(f, "Zip Bomb"),
337 Self::InvalidPermissions => write!(f, "Invalid Permissions"),
338 Self::QuotaExceeded => write!(f, "Quota Exceeded"),
339 Self::InvalidArchive => write!(f, "Invalid Archive"),
340 Self::SuspiciousPath => write!(f, "Suspicious Path"),
341 Self::ExecutableFile => write!(f, "Executable File"),
342 }
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use std::io;
350
351 #[test]
352 fn test_verification_status_display() {
353 assert_eq!(VerificationStatus::Pass.to_string(), "PASS");
354 assert_eq!(VerificationStatus::Fail.to_string(), "FAIL");
355 assert_eq!(VerificationStatus::Warning.to_string(), "WARNING");
356 }
357
358 #[test]
359 fn test_check_status_display() {
360 assert_eq!(CheckStatus::Pass.to_string(), "OK");
361 assert_eq!(CheckStatus::Fail.to_string(), "FAILED");
362 assert_eq!(CheckStatus::Warning.to_string(), "WARNING");
363 assert_eq!(CheckStatus::Skipped.to_string(), "SKIPPED");
364 }
365
366 #[test]
367 fn test_issue_severity_display() {
368 assert_eq!(IssueSeverity::Critical.to_string(), "CRITICAL");
369 assert_eq!(IssueSeverity::High.to_string(), "HIGH");
370 assert_eq!(IssueSeverity::Medium.to_string(), "MEDIUM");
371 assert_eq!(IssueSeverity::Low.to_string(), "LOW");
372 assert_eq!(IssueSeverity::Info.to_string(), "INFO");
373 }
374
375 #[test]
376 fn test_issue_severity_ordering() {
377 assert!(IssueSeverity::Critical > IssueSeverity::High);
378 assert!(IssueSeverity::High > IssueSeverity::Medium);
379 assert!(IssueSeverity::Medium > IssueSeverity::Low);
380 assert!(IssueSeverity::Low > IssueSeverity::Info);
381 }
382
383 #[test]
384 fn test_issue_category_display() {
385 assert_eq!(IssueCategory::PathTraversal.to_string(), "Path Traversal");
386 assert_eq!(IssueCategory::SymlinkEscape.to_string(), "Symlink Escape");
387 }
388
389 #[test]
390 fn test_verification_issue_from_path_traversal() {
391 let error = ExtractionError::PathTraversal {
392 path: PathBuf::from("../../etc/passwd"),
393 };
394 let issue = VerificationIssue::from_error(&error, None);
395
396 assert_eq!(issue.severity, IssueSeverity::Critical);
397 assert_eq!(issue.category, IssueCategory::PathTraversal);
398 assert!(issue.message.contains("Path traversal"));
399 }
400
401 #[test]
402 fn test_verification_issue_from_symlink_escape() {
403 let error = ExtractionError::SymlinkEscape {
404 path: PathBuf::from("link"),
405 };
406 let issue = VerificationIssue::from_error(&error, Some(PathBuf::from("link")));
407
408 assert_eq!(issue.severity, IssueSeverity::Critical);
409 assert_eq!(issue.category, IssueCategory::SymlinkEscape);
410 assert!(issue.message.contains("Symlink escape"));
411 }
412
413 #[test]
414 fn test_verification_issue_from_zip_bomb() {
415 let error = ExtractionError::ZipBomb {
416 compressed: 1000,
417 uncompressed: 1_000_000,
418 ratio: 1000.0,
419 };
420 let issue = VerificationIssue::from_error(&error, None);
421
422 assert_eq!(issue.severity, IssueSeverity::Critical);
423 assert_eq!(issue.category, IssueCategory::ZipBomb);
424 assert!(issue.message.contains("zip bomb"));
425 }
426
427 #[test]
428 fn test_verification_issue_from_io_error() {
429 let error = ExtractionError::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
430 let issue = VerificationIssue::from_error(&error, None);
431
432 assert_eq!(issue.severity, IssueSeverity::High);
433 assert_eq!(issue.category, IssueCategory::InvalidArchive);
434 }
435
436 #[test]
437 fn test_verification_report_is_safe() {
438 let report = VerificationReport {
439 status: VerificationStatus::Pass,
440 integrity_status: CheckStatus::Pass,
441 security_status: CheckStatus::Pass,
442 issues: Vec::new(),
443 total_entries: 10,
444 suspicious_entries: 0,
445 total_size: 1024,
446 format: ArchiveType::TarGz,
447 };
448
449 assert!(report.is_safe());
450 }
451
452 #[test]
453 fn test_verification_report_not_safe() {
454 let report = VerificationReport {
455 status: VerificationStatus::Fail,
456 integrity_status: CheckStatus::Pass,
457 security_status: CheckStatus::Fail,
458 issues: vec![VerificationIssue {
459 severity: IssueSeverity::Critical,
460 category: IssueCategory::PathTraversal,
461 entry_path: None,
462 message: "Test issue".to_string(),
463 context: None,
464 }],
465 total_entries: 10,
466 suspicious_entries: 1,
467 total_size: 1024,
468 format: ArchiveType::TarGz,
469 };
470
471 assert!(!report.is_safe());
472 assert!(report.has_critical_issues());
473 }
474
475 #[test]
476 fn test_verification_issue_from_hardlink_escape() {
477 let error = ExtractionError::HardlinkEscape {
478 path: PathBuf::from("link"),
479 };
480 let issue = VerificationIssue::from_error(&error, None);
481 assert_eq!(issue.severity, IssueSeverity::Critical);
482 assert_eq!(issue.category, IssueCategory::HardlinkEscape);
483 assert!(issue.message.contains("Hardlink escape"));
484 }
485
486 #[test]
487 fn test_verification_issue_from_quota_exceeded() {
488 let error = ExtractionError::QuotaExceeded {
489 resource: crate::error::QuotaResource::FileCount {
490 current: 11,
491 max: 10,
492 },
493 };
494 let issue = VerificationIssue::from_error(&error, None);
495 assert_eq!(issue.severity, IssueSeverity::High);
496 assert_eq!(issue.category, IssueCategory::QuotaExceeded);
497 }
498
499 #[test]
500 fn test_verification_issue_from_invalid_permissions() {
501 let error = ExtractionError::InvalidPermissions {
502 path: PathBuf::from("file.txt"),
503 mode: 0o777,
504 };
505 let issue = VerificationIssue::from_error(&error, None);
506 assert_eq!(issue.severity, IssueSeverity::Medium);
507 assert_eq!(issue.category, IssueCategory::InvalidPermissions);
508 assert!(issue.message.contains("Invalid permissions"));
509 }
510
511 #[test]
512 fn test_verification_issue_from_unsupported_format() {
513 let error = ExtractionError::UnsupportedFormat;
514 let issue = VerificationIssue::from_error(&error, None);
515 assert_eq!(issue.severity, IssueSeverity::High);
516 assert_eq!(issue.category, IssueCategory::InvalidArchive);
517 assert!(issue.message.contains("Unsupported archive format"));
518 }
519
520 #[test]
521 fn test_verification_issue_from_invalid_archive() {
522 let error = ExtractionError::InvalidArchive("bad header".into());
523 let issue = VerificationIssue::from_error(&error, None);
524 assert_eq!(issue.severity, IssueSeverity::High);
525 assert_eq!(issue.category, IssueCategory::InvalidArchive);
526 assert!(issue.message.contains("Invalid archive"));
527 }
528
529 #[test]
530 fn test_verification_issue_from_security_violation() {
531 let error = ExtractionError::SecurityViolation {
532 reason: "encrypted".into(),
533 };
534 let issue = VerificationIssue::from_error(&error, None);
535 assert_eq!(issue.severity, IssueSeverity::High);
536 assert_eq!(issue.category, IssueCategory::SuspiciousPath);
537 assert!(issue.message.contains("Security violation"));
538 }
539
540 #[test]
541 fn test_verification_issue_from_source_not_found() {
542 let error = ExtractionError::SourceNotFound {
543 path: PathBuf::from("/missing"),
544 };
545 let issue = VerificationIssue::from_error(&error, None);
546 assert_eq!(issue.severity, IssueSeverity::High);
547 assert_eq!(issue.category, IssueCategory::InvalidArchive);
548 assert!(issue.message.contains("Source not found"));
549 }
550
551 #[test]
552 fn test_verification_issue_from_source_not_accessible() {
553 let error = ExtractionError::SourceNotAccessible {
554 path: PathBuf::from("/restricted"),
555 };
556 let issue = VerificationIssue::from_error(&error, None);
557 assert_eq!(issue.severity, IssueSeverity::High);
558 assert_eq!(issue.category, IssueCategory::InvalidArchive);
559 assert!(issue.message.contains("Source not accessible"));
560 }
561
562 #[test]
563 fn test_verification_issue_from_output_exists() {
564 let error = ExtractionError::OutputExists {
565 path: PathBuf::from("out/"),
566 };
567 let issue = VerificationIssue::from_error(&error, None);
568 assert_eq!(issue.severity, IssueSeverity::Medium);
569 assert_eq!(issue.category, IssueCategory::InvalidArchive);
570 assert!(issue.message.contains("Output already exists"));
571 }
572
573 #[test]
574 fn test_verification_issue_from_invalid_compression_level() {
575 let error = ExtractionError::InvalidCompressionLevel { level: 0 };
576 let issue = VerificationIssue::from_error(&error, None);
577 assert_eq!(issue.severity, IssueSeverity::Medium);
578 assert_eq!(issue.category, IssueCategory::InvalidArchive);
579 assert!(issue.message.contains("Invalid compression level"));
580 }
581
582 #[test]
583 fn test_verification_issue_from_unknown_format() {
584 let error = ExtractionError::UnknownFormat {
585 path: PathBuf::from("archive.rar"),
586 };
587 let issue = VerificationIssue::from_error(&error, None);
588 assert_eq!(issue.severity, IssueSeverity::High);
589 assert_eq!(issue.category, IssueCategory::InvalidArchive);
590 assert!(issue.message.contains("Unknown format"));
591 }
592
593 #[test]
594 fn test_verification_issue_from_invalid_configuration() {
595 let error = ExtractionError::InvalidConfiguration {
596 reason: "bad config".into(),
597 };
598 let issue = VerificationIssue::from_error(&error, None);
599 assert_eq!(issue.severity, IssueSeverity::High);
600 assert_eq!(issue.category, IssueCategory::InvalidArchive);
601 assert!(issue.message.contains("Invalid configuration"));
602 }
603
604 #[test]
605 fn test_verification_report_issues_by_severity() {
606 let report = VerificationReport {
607 status: VerificationStatus::Warning,
608 integrity_status: CheckStatus::Pass,
609 security_status: CheckStatus::Warning,
610 issues: vec![
611 VerificationIssue {
612 severity: IssueSeverity::Critical,
613 category: IssueCategory::PathTraversal,
614 entry_path: None,
615 message: "Critical issue".to_string(),
616 context: None,
617 },
618 VerificationIssue {
619 severity: IssueSeverity::Low,
620 category: IssueCategory::ExecutableFile,
621 entry_path: None,
622 message: "Low issue".to_string(),
623 context: None,
624 },
625 ],
626 total_entries: 10,
627 suspicious_entries: 2,
628 total_size: 1024,
629 format: ArchiveType::TarGz,
630 };
631
632 let critical_issues = report.issues_by_severity(IssueSeverity::Critical);
633 assert_eq!(critical_issues.len(), 1);
634
635 let low_issues = report.issues_by_severity(IssueSeverity::Low);
636 assert_eq!(low_issues.len(), 1);
637 }
638}