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)]
296pub enum IssueCategory {
297 PathTraversal,
299
300 SymlinkEscape,
302
303 HardlinkEscape,
305
306 ZipBomb,
308
309 InvalidPermissions,
311
312 QuotaExceeded,
314
315 InvalidArchive,
317
318 SuspiciousPath,
320
321 ExecutableFile,
323}
324
325impl std::fmt::Display for IssueCategory {
326 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327 match self {
328 Self::PathTraversal => write!(f, "Path Traversal"),
329 Self::SymlinkEscape => write!(f, "Symlink Escape"),
330 Self::HardlinkEscape => write!(f, "Hardlink Escape"),
331 Self::ZipBomb => write!(f, "Zip Bomb"),
332 Self::InvalidPermissions => write!(f, "Invalid Permissions"),
333 Self::QuotaExceeded => write!(f, "Quota Exceeded"),
334 Self::InvalidArchive => write!(f, "Invalid Archive"),
335 Self::SuspiciousPath => write!(f, "Suspicious Path"),
336 Self::ExecutableFile => write!(f, "Executable File"),
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use std::io;
345
346 #[test]
347 fn test_verification_status_display() {
348 assert_eq!(VerificationStatus::Pass.to_string(), "PASS");
349 assert_eq!(VerificationStatus::Fail.to_string(), "FAIL");
350 assert_eq!(VerificationStatus::Warning.to_string(), "WARNING");
351 }
352
353 #[test]
354 fn test_check_status_display() {
355 assert_eq!(CheckStatus::Pass.to_string(), "OK");
356 assert_eq!(CheckStatus::Fail.to_string(), "FAILED");
357 assert_eq!(CheckStatus::Warning.to_string(), "WARNING");
358 assert_eq!(CheckStatus::Skipped.to_string(), "SKIPPED");
359 }
360
361 #[test]
362 fn test_issue_severity_display() {
363 assert_eq!(IssueSeverity::Critical.to_string(), "CRITICAL");
364 assert_eq!(IssueSeverity::High.to_string(), "HIGH");
365 assert_eq!(IssueSeverity::Medium.to_string(), "MEDIUM");
366 assert_eq!(IssueSeverity::Low.to_string(), "LOW");
367 assert_eq!(IssueSeverity::Info.to_string(), "INFO");
368 }
369
370 #[test]
371 fn test_issue_severity_ordering() {
372 assert!(IssueSeverity::Critical > IssueSeverity::High);
373 assert!(IssueSeverity::High > IssueSeverity::Medium);
374 assert!(IssueSeverity::Medium > IssueSeverity::Low);
375 assert!(IssueSeverity::Low > IssueSeverity::Info);
376 }
377
378 #[test]
379 fn test_issue_category_display() {
380 assert_eq!(IssueCategory::PathTraversal.to_string(), "Path Traversal");
381 assert_eq!(IssueCategory::SymlinkEscape.to_string(), "Symlink Escape");
382 }
383
384 #[test]
385 fn test_verification_issue_from_path_traversal() {
386 let error = ArchiveError::PathTraversal {
387 path: PathBuf::from("../../etc/passwd"),
388 };
389 let issue = VerificationIssue::from_error(&error, None);
390
391 assert_eq!(issue.severity, IssueSeverity::Critical);
392 assert_eq!(issue.category, IssueCategory::PathTraversal);
393 assert!(issue.message.contains("Path traversal"));
394 }
395
396 #[test]
397 fn test_verification_issue_from_symlink_escape() {
398 let error = ArchiveError::SymlinkEscape {
399 path: PathBuf::from("link"),
400 };
401 let issue = VerificationIssue::from_error(&error, Some(PathBuf::from("link")));
402
403 assert_eq!(issue.severity, IssueSeverity::Critical);
404 assert_eq!(issue.category, IssueCategory::SymlinkEscape);
405 assert!(issue.message.contains("Symlink escape"));
406 }
407
408 #[test]
409 fn test_verification_issue_from_zip_bomb() {
410 let error = ArchiveError::ZipBomb {
411 compressed: 1000,
412 uncompressed: 1_000_000,
413 ratio: 1000.0,
414 };
415 let issue = VerificationIssue::from_error(&error, None);
416
417 assert_eq!(issue.severity, IssueSeverity::Critical);
418 assert_eq!(issue.category, IssueCategory::ZipBomb);
419 assert!(issue.message.contains("zip bomb"));
420 }
421
422 #[test]
423 fn test_verification_issue_from_io_error() {
424 let error = ArchiveError::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
425 let issue = VerificationIssue::from_error(&error, None);
426
427 assert_eq!(issue.severity, IssueSeverity::High);
428 assert_eq!(issue.category, IssueCategory::InvalidArchive);
429 }
430
431 #[test]
432 fn test_verification_report_is_safe() {
433 let report = VerificationReport {
434 status: VerificationStatus::Pass,
435 integrity_status: CheckStatus::Pass,
436 security_status: CheckStatus::Pass,
437 issues: Vec::new(),
438 total_entries: 10,
439 suspicious_entries: 0,
440 total_size: 1024,
441 format: ArchiveType::TarGz,
442 };
443
444 assert!(report.is_safe());
445 }
446
447 #[test]
448 fn test_verification_report_not_safe() {
449 let report = VerificationReport {
450 status: VerificationStatus::Fail,
451 integrity_status: CheckStatus::Pass,
452 security_status: CheckStatus::Fail,
453 issues: vec![VerificationIssue {
454 severity: IssueSeverity::Critical,
455 category: IssueCategory::PathTraversal,
456 entry_path: None,
457 message: "Test issue".to_string(),
458 context: None,
459 }],
460 total_entries: 10,
461 suspicious_entries: 1,
462 total_size: 1024,
463 format: ArchiveType::TarGz,
464 };
465
466 assert!(!report.is_safe());
467 assert!(report.has_critical_issues());
468 }
469
470 #[test]
471 fn test_verification_issue_from_hardlink_escape() {
472 let error = ArchiveError::HardlinkEscape {
473 path: PathBuf::from("link"),
474 };
475 let issue = VerificationIssue::from_error(&error, None);
476 assert_eq!(issue.severity, IssueSeverity::Critical);
477 assert_eq!(issue.category, IssueCategory::HardlinkEscape);
478 assert!(issue.message.contains("Hardlink escape"));
479 }
480
481 #[test]
482 fn test_verification_issue_from_quota_exceeded() {
483 let error = ArchiveError::QuotaExceeded {
484 resource: crate::error::QuotaResource::FileCount {
485 current: 11,
486 max: 10,
487 },
488 };
489 let issue = VerificationIssue::from_error(&error, None);
490 assert_eq!(issue.severity, IssueSeverity::High);
491 assert_eq!(issue.category, IssueCategory::QuotaExceeded);
492 }
493
494 #[test]
495 fn test_verification_issue_from_invalid_permissions() {
496 let error = ArchiveError::InvalidPermissions {
497 path: PathBuf::from("file.txt"),
498 mode: 0o777,
499 };
500 let issue = VerificationIssue::from_error(&error, None);
501 assert_eq!(issue.severity, IssueSeverity::Medium);
502 assert_eq!(issue.category, IssueCategory::InvalidPermissions);
503 assert!(issue.message.contains("Invalid permissions"));
504 }
505
506 #[test]
507 fn test_verification_issue_from_invalid_archive() {
508 let error = ArchiveError::InvalidArchive("bad header".into());
509 let issue = VerificationIssue::from_error(&error, None);
510 assert_eq!(issue.severity, IssueSeverity::High);
511 assert_eq!(issue.category, IssueCategory::InvalidArchive);
512 assert!(issue.message.contains("Invalid archive"));
513 }
514
515 #[test]
516 fn test_verification_issue_from_security_violation() {
517 let error = ArchiveError::SecurityViolation {
518 reason: "encrypted".into(),
519 };
520 let issue = VerificationIssue::from_error(&error, None);
521 assert_eq!(issue.severity, IssueSeverity::High);
522 assert_eq!(issue.category, IssueCategory::SuspiciousPath);
523 assert!(issue.message.contains("Security violation"));
524 }
525
526 #[test]
527 fn test_verification_issue_from_source_not_found() {
528 let error = ArchiveError::SourceNotFound {
529 path: PathBuf::from("/missing"),
530 };
531 let issue = VerificationIssue::from_error(&error, None);
532 assert_eq!(issue.severity, IssueSeverity::High);
533 assert_eq!(issue.category, IssueCategory::InvalidArchive);
534 assert!(issue.message.contains("Source not found"));
535 }
536
537 #[test]
538 fn test_verification_issue_from_source_not_accessible() {
539 let error = ArchiveError::SourceNotAccessible {
540 path: PathBuf::from("/restricted"),
541 };
542 let issue = VerificationIssue::from_error(&error, None);
543 assert_eq!(issue.severity, IssueSeverity::High);
544 assert_eq!(issue.category, IssueCategory::InvalidArchive);
545 assert!(issue.message.contains("Source not accessible"));
546 }
547
548 #[test]
549 fn test_verification_issue_from_output_exists() {
550 let error = ArchiveError::OutputExists {
551 path: PathBuf::from("out/"),
552 };
553 let issue = VerificationIssue::from_error(&error, None);
554 assert_eq!(issue.severity, IssueSeverity::Medium);
555 assert_eq!(issue.category, IssueCategory::InvalidArchive);
556 assert!(issue.message.contains("Output already exists"));
557 }
558
559 #[test]
560 fn test_verification_issue_from_invalid_compression_level() {
561 let error = ArchiveError::InvalidCompressionLevel { level: 0 };
562 let issue = VerificationIssue::from_error(&error, None);
563 assert_eq!(issue.severity, IssueSeverity::Medium);
564 assert_eq!(issue.category, IssueCategory::InvalidArchive);
565 assert!(issue.message.contains("Invalid compression level"));
566 }
567
568 #[test]
569 fn test_verification_issue_from_unknown_format() {
570 let error = ArchiveError::UnknownFormat {
571 path: PathBuf::from("archive.rar"),
572 };
573 let issue = VerificationIssue::from_error(&error, None);
574 assert_eq!(issue.severity, IssueSeverity::High);
575 assert_eq!(issue.category, IssueCategory::InvalidArchive);
576 assert!(issue.message.contains("Unknown format"));
577 }
578
579 #[test]
580 fn test_verification_issue_from_invalid_configuration() {
581 let error = ArchiveError::InvalidConfiguration {
582 reason: "bad config".into(),
583 };
584 let issue = VerificationIssue::from_error(&error, None);
585 assert_eq!(issue.severity, IssueSeverity::High);
586 assert_eq!(issue.category, IssueCategory::InvalidArchive);
587 assert!(issue.message.contains("Invalid configuration"));
588 }
589
590 #[test]
591 fn test_verification_report_issues_by_severity() {
592 let report = VerificationReport {
593 status: VerificationStatus::Warning,
594 integrity_status: CheckStatus::Pass,
595 security_status: CheckStatus::Warning,
596 issues: vec![
597 VerificationIssue {
598 severity: IssueSeverity::Critical,
599 category: IssueCategory::PathTraversal,
600 entry_path: None,
601 message: "Critical issue".to_string(),
602 context: None,
603 },
604 VerificationIssue {
605 severity: IssueSeverity::Low,
606 category: IssueCategory::ExecutableFile,
607 entry_path: None,
608 message: "Low issue".to_string(),
609 context: None,
610 },
611 ],
612 total_entries: 10,
613 suspicious_entries: 2,
614 total_size: 1024,
615 format: ArchiveType::TarGz,
616 };
617
618 let critical_issues = report.issues_by_severity(IssueSeverity::Critical);
619 assert_eq!(critical_issues.len(), 1);
620
621 let low_issues = report.issues_by_severity(IssueSeverity::Low);
622 assert_eq!(low_issues.len(), 1);
623 }
624}