1use std::path::Path;
7
8use crate::Result;
9use crate::SecurityConfig;
10use crate::config::Validated;
11use crate::formats::common::DirCache;
12use crate::security::context::ValidationContext;
13use crate::security::hardlink::HardlinkTracker;
14use crate::security::permissions::sanitize_permissions;
15use crate::security::quota::QuotaPermit;
16use crate::security::quota::QuotaTracker;
17use crate::security::symlink::validate_symlink;
18use crate::security::zipbomb::validate_compression_ratio;
19use crate::types::DestDir;
20use crate::types::EntryType;
21use crate::types::SafePath;
22use crate::types::SafeSymlink;
23
24#[derive(Debug)]
34pub struct ValidatedEntry {
35 safe_path: SafePath,
36 entry_type: ValidatedEntryType,
37 mode: Option<u32>,
38}
39
40impl ValidatedEntry {
41 pub(crate) fn new(
47 safe_path: SafePath,
48 entry_type: ValidatedEntryType,
49 mode: Option<u32>,
50 ) -> Self {
51 Self {
52 safe_path,
53 entry_type,
54 mode,
55 }
56 }
57
58 #[inline]
60 #[must_use]
61 pub fn safe_path(&self) -> &SafePath {
62 &self.safe_path
63 }
64
65 #[inline]
67 #[must_use]
68 pub fn entry_type(&self) -> &ValidatedEntryType {
69 &self.entry_type
70 }
71
72 #[inline]
74 #[must_use]
75 pub fn mode(&self) -> Option<u32> {
76 self.mode
77 }
78
79 #[inline]
89 #[must_use]
90 pub(crate) fn into_parts(self) -> (SafePath, ValidatedEntryType, Option<u32>) {
91 (self.safe_path, self.entry_type, self.mode)
92 }
93}
94
95#[derive(Debug)]
121#[non_exhaustive]
122pub enum ValidatedEntryType {
123 File(QuotaPermit),
126
127 Directory,
129
130 Symlink(SafeSymlink),
132
133 Hardlink {
135 target: SafePath,
137 },
138}
139
140pub struct EntryValidator<'a> {
188 config: &'a SecurityConfig<Validated>,
189 dest: &'a DestDir,
190 quota_tracker: QuotaTracker,
191 hardlink_tracker: HardlinkTracker,
192 symlink_seen: bool,
193}
194
195impl<'a> EntryValidator<'a> {
196 #[must_use]
198 pub fn new(config: &'a SecurityConfig<Validated>, dest: &'a DestDir) -> Self {
199 Self {
200 config,
201 dest,
202 quota_tracker: QuotaTracker::new(),
203 hardlink_tracker: HardlinkTracker::new(),
204 symlink_seen: false,
205 }
206 }
207
208 #[inline]
230 pub fn validate_entry(
231 &mut self,
232 path: &Path,
233 entry_type: &EntryType,
234 uncompressed_size: u64,
235 compressed_size: Option<u64>,
236 mode: Option<u32>,
237 dir_cache: Option<&DirCache>,
238 ) -> Result<ValidatedEntry> {
239 let safe_path = self.validate_entry_path(path, dir_cache)?;
240
241 let (validated_type, sanitized_mode) = match entry_type {
242 EntryType::File => {
243 let permit = self.quota_tracker.reserve(uncompressed_size, self.config)?;
244 self.check_ratio(compressed_size, uncompressed_size)?;
245 let sanitized = mode.map(|m| sanitize_permissions(m, self.config));
246 (ValidatedEntryType::File(permit), sanitized)
247 }
248
249 EntryType::Directory => {
250 self.check_ratio(compressed_size, uncompressed_size)?;
251 (ValidatedEntryType::Directory, None)
252 }
253
254 EntryType::Symlink { target } => {
255 self.check_ratio(compressed_size, uncompressed_size)?;
256 let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
257 self.symlink_seen = true;
258 (ValidatedEntryType::Symlink(safe_symlink), None)
259 }
260
261 EntryType::Hardlink { target } => {
262 self.check_ratio(compressed_size, uncompressed_size)?;
263
264 self.hardlink_tracker.validate_hardlink(
266 &safe_path,
267 target,
268 self.dest,
269 self.config,
270 )?;
271
272 let target_safe = SafePath::new_unchecked(target.clone());
275
276 (
277 ValidatedEntryType::Hardlink {
278 target: target_safe,
279 },
280 None,
281 )
282 }
283 };
284
285 Ok(ValidatedEntry::new(
286 safe_path,
287 validated_type,
288 sanitized_mode,
289 ))
290 }
291
292 pub(crate) fn validate_entry_path(
311 &self,
312 path: &Path,
313 dir_cache: Option<&DirCache>,
314 ) -> Result<SafePath> {
315 let mut ctx = ValidationContext::new(self.config.allowed.symlinks);
316 if let Some(cache) = dir_cache {
317 ctx = ctx.with_dir_cache(cache);
318 }
319 if self.symlink_seen {
320 ctx.mark_symlink_seen();
321 }
322
323 SafePath::validate_with_context(path, self.dest, self.config, &ctx)
324 }
325
326 pub(crate) fn reserve_file(&mut self, size: u64) -> Result<QuotaPermit> {
343 self.quota_tracker.reserve(size, self.config)
344 }
345
346 #[inline]
359 fn check_ratio(&self, compressed_size: Option<u64>, uncompressed_size: u64) -> Result<()> {
360 if let Some(compressed) = compressed_size {
361 validate_compression_ratio(compressed, uncompressed_size, self.config)?;
362 }
363 Ok(())
364 }
365
366 pub(crate) fn reserve_hardlink(&mut self, size: u64) -> Result<QuotaPermit> {
382 self.quota_tracker.reserve(size, self.config)
383 }
384
385 #[must_use]
390 pub fn finish(self) -> ValidationReport {
391 ValidationReport {
392 files_validated: self.quota_tracker.files_extracted(),
393 total_bytes: self.quota_tracker.bytes_written(),
394 hardlinks_tracked: self.hardlink_tracker.count(),
395 }
396 }
397}
398
399#[derive(Debug)]
401pub struct ValidationReport {
402 pub files_validated: usize,
404
405 pub total_bytes: u64,
407
408 pub hardlinks_tracked: usize,
410}
411
412#[cfg(test)]
413#[allow(
414 clippy::unwrap_used,
415 clippy::expect_used,
416 clippy::field_reassign_with_default
417)]
418mod tests {
419 use super::*;
420 use std::assert_matches;
421 use std::path::PathBuf;
422 use tempfile::TempDir;
423
424 #[test]
425 fn test_entry_validator_new() {
426 let temp = TempDir::new().expect("failed to create temp dir");
427 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
428 let config = SecurityConfig::default().validate().expect("valid config");
429 let validator = EntryValidator::new(&config, &dest);
430 let report = validator.finish();
431 assert_eq!(report.files_validated, 0);
432 assert_eq!(report.total_bytes, 0);
433 assert_eq!(report.hardlinks_tracked, 0);
434 }
435
436 #[test]
437 fn test_validate_file_entry() {
438 let temp = TempDir::new().expect("failed to create temp dir");
439 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
440 let config = SecurityConfig::default().validate().expect("valid config");
441 let mut validator = EntryValidator::new(&config, &dest);
442
443 let result = validator.validate_entry(
444 Path::new("file.txt"),
445 &EntryType::File,
446 1024,
447 None,
448 Some(0o644),
449 None,
450 );
451
452 assert!(result.is_ok());
453 let entry = result.unwrap();
454 assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
455 assert_matches!(entry.entry_type, ValidatedEntryType::File(_));
456 assert_eq!(entry.mode, Some(0o644));
457 }
458
459 #[test]
460 fn test_validate_directory_entry() {
461 let temp = TempDir::new().expect("failed to create temp dir");
462 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
463 let config = SecurityConfig::default().validate().expect("valid config");
464 let mut validator = EntryValidator::new(&config, &dest);
465
466 let result =
467 validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None);
468
469 assert!(result.is_ok());
470 let entry = result.unwrap();
471 assert_matches!(entry.entry_type, ValidatedEntryType::Directory);
472 assert!(entry.mode.is_none());
473 }
474
475 #[test]
476 fn test_validate_path_traversal_rejected() {
477 let temp = TempDir::new().expect("failed to create temp dir");
478 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
479 let config = SecurityConfig::default().validate().expect("valid config");
480 let mut validator = EntryValidator::new(&config, &dest);
481
482 let result = validator.validate_entry(
483 Path::new("../etc/passwd"),
484 &EntryType::File,
485 1024,
486 None,
487 Some(0o644),
488 None,
489 );
490
491 assert!(result.is_err());
492 }
493
494 #[test]
495 fn test_quota_exceeded_file_size() {
496 let temp = TempDir::new().unwrap();
497 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
498 let mut config = SecurityConfig::default();
499 config.max_file_size = 100;
500 let config = config.validate().expect("valid config");
501 let mut validator = EntryValidator::new(&config, &dest);
502
503 let result = validator.validate_entry(
504 Path::new("large.txt"),
505 &EntryType::File,
506 1000,
507 None,
508 Some(0o644),
509 None,
510 );
511
512 assert!(result.is_err());
513 }
514
515 #[test]
516 fn test_quota_exceeded_file_count() {
517 let temp = TempDir::new().unwrap();
518 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
519 let mut config = SecurityConfig::default();
520 config.max_file_count = 2;
521 let config = config.validate().expect("valid config");
522 let mut validator = EntryValidator::new(&config, &dest);
523
524 assert!(
525 validator
526 .validate_entry(
527 Path::new("file1.txt"),
528 &EntryType::File,
529 100,
530 None,
531 Some(0o644),
532 None,
533 )
534 .is_ok()
535 );
536 assert!(
537 validator
538 .validate_entry(
539 Path::new("file2.txt"),
540 &EntryType::File,
541 100,
542 None,
543 Some(0o644),
544 None,
545 )
546 .is_ok()
547 );
548
549 let result = validator.validate_entry(
550 Path::new("file3.txt"),
551 &EntryType::File,
552 100,
553 None,
554 Some(0o644),
555 None,
556 );
557 assert!(result.is_err());
558 }
559
560 #[test]
561 fn test_zip_bomb_detected() {
562 let temp = TempDir::new().expect("failed to create temp dir");
563 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
564 let config = SecurityConfig::default().validate().expect("valid config");
565 let mut validator = EntryValidator::new(&config, &dest);
566
567 let result = validator.validate_entry(
568 Path::new("bomb.txt"),
569 &EntryType::File,
570 1_000_000,
571 Some(100),
572 Some(0o644),
573 None,
574 );
575
576 assert!(result.is_err());
577 }
578
579 #[test]
580 fn test_validation_report() {
581 let temp = TempDir::new().expect("failed to create temp dir");
582 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
583 let config = SecurityConfig::default().validate().expect("valid config");
584 let mut validator = EntryValidator::new(&config, &dest);
585
586 validator
587 .validate_entry(
588 Path::new("file1.txt"),
589 &EntryType::File,
590 1024,
591 None,
592 Some(0o644),
593 None,
594 )
595 .unwrap();
596
597 validator
598 .validate_entry(
599 Path::new("file2.txt"),
600 &EntryType::File,
601 2048,
602 None,
603 Some(0o644),
604 None,
605 )
606 .unwrap();
607
608 let report = validator.finish();
609 assert_eq!(report.files_validated, 2);
610 assert_eq!(report.total_bytes, 1024 + 2048);
611 }
612
613 #[test]
614 fn test_sanitize_permissions_setuid() {
615 let temp = TempDir::new().expect("failed to create temp dir");
616 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
617 let config = SecurityConfig::default().validate().expect("valid config");
618 let mut validator = EntryValidator::new(&config, &dest);
619
620 let result = validator.validate_entry(
621 Path::new("file.txt"),
622 &EntryType::File,
623 1024,
624 None,
625 Some(0o4755),
626 None,
627 );
628
629 assert!(result.is_ok());
630 let entry = result.unwrap();
631 assert_eq!(entry.mode, Some(0o755)); }
633
634 #[test]
635 fn test_symlink_validation() {
636 let temp = TempDir::new().unwrap();
637 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
638 let mut config = SecurityConfig::default();
639 config.allowed.symlinks = true;
640 let config = config.validate().expect("valid config");
641 let mut validator = EntryValidator::new(&config, &dest);
642
643 let result = validator.validate_entry(
644 Path::new("link"),
645 &EntryType::Symlink {
646 target: PathBuf::from("target.txt"),
647 },
648 0,
649 None,
650 None,
651 None,
652 );
653
654 assert!(result.is_ok());
655 let entry = result.unwrap();
656 assert_matches!(entry.entry_type, ValidatedEntryType::Symlink(_));
657 assert!(validator.symlink_seen);
658 }
659
660 #[test]
661 fn test_hardlink_validation() {
662 let temp = TempDir::new().unwrap();
663 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
664 let mut config = SecurityConfig::default();
665 config.allowed.hardlinks = true;
666 let config = config.validate().expect("valid config");
667 let mut validator = EntryValidator::new(&config, &dest);
668
669 let result = validator.validate_entry(
670 Path::new("link"),
671 &EntryType::Hardlink {
672 target: PathBuf::from("target.txt"),
673 },
674 0,
675 None,
676 None,
677 None,
678 );
679
680 assert!(result.is_ok());
681 let entry = result.unwrap();
682 assert_matches!(entry.entry_type, ValidatedEntryType::Hardlink { .. });
683 }
684
685 #[test]
686 fn test_multiple_entries_with_report() {
687 let temp = TempDir::new().unwrap();
688 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
689 let mut config = SecurityConfig::default();
690 config.allowed.hardlinks = true;
691 let config = config.validate().expect("valid config");
692 let mut validator = EntryValidator::new(&config, &dest);
693
694 validator
696 .validate_entry(
697 Path::new("file1.txt"),
698 &EntryType::File,
699 1024,
700 None,
701 Some(0o644),
702 None,
703 )
704 .unwrap();
705
706 validator
707 .validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None)
708 .unwrap();
709
710 validator
711 .validate_entry(
712 Path::new("hardlink"),
713 &EntryType::Hardlink {
714 target: PathBuf::from("file1.txt"),
715 },
716 0,
717 None,
718 None,
719 None,
720 )
721 .unwrap();
722
723 let report = validator.finish();
724 assert_eq!(report.files_validated, 1); assert_eq!(report.total_bytes, 1024);
726 assert_eq!(report.hardlinks_tracked, 1);
727 }
728
729 #[test]
731 fn test_empty_directory_validation() {
732 let temp = TempDir::new().expect("failed to create temp dir");
733 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
734 let config = SecurityConfig::default().validate().expect("valid config");
735 let mut validator = EntryValidator::new(&config, &dest);
736
737 let result = validator.validate_entry(
739 Path::new("empty_dir/"),
740 &EntryType::Directory,
741 0,
742 None,
743 None,
744 None,
745 );
746
747 assert!(result.is_ok(), "empty directory should be valid");
748 let entry = result.unwrap();
749 assert_matches!(
750 entry.entry_type,
751 ValidatedEntryType::Directory,
752 "should be directory type"
753 );
754 assert!(entry.mode.is_none(), "directory should not have mode set");
755 }
756
757 #[test]
758 fn test_nested_empty_directories() {
759 let temp = TempDir::new().expect("failed to create temp dir");
760 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
761 let config = SecurityConfig::default().validate().expect("valid config");
762 let mut validator = EntryValidator::new(&config, &dest);
763
764 let dirs = ["a/", "a/b/", "a/b/c/"];
766 for dir in &dirs {
767 let result = validator.validate_entry(
768 Path::new(dir),
769 &EntryType::Directory,
770 0,
771 None,
772 None,
773 None,
774 );
775 assert!(result.is_ok(), "nested directory {dir} should be valid");
776 }
777
778 let report = validator.finish();
779 assert_eq!(
780 report.files_validated, 0,
781 "directories are not counted as files"
782 );
783 }
784
785 #[test]
787 fn test_validator_uses_references() {
788 let temp = TempDir::new().unwrap();
789 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
790 let config = SecurityConfig::default().validate().expect("valid config");
791
792 let validator = EntryValidator::new(&config, &dest);
794
795 assert_eq!(
797 config.max_file_size,
798 SecurityConfig::default().max_file_size
799 );
800 let _ = dest.as_path();
803
804 drop(validator);
806 }
807
808 #[test]
810 fn test_multiple_validators_share_config() {
811 let temp1 = TempDir::new().unwrap();
812 let temp2 = TempDir::new().unwrap();
813 let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
814 let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
815 let config = SecurityConfig::default().validate().expect("valid config");
816
817 let mut validator1 = EntryValidator::new(&config, &dest1);
819 let mut validator2 = EntryValidator::new(&config, &dest2);
820
821 let result1 = validator1.validate_entry(
823 Path::new("file1.txt"),
824 &EntryType::File,
825 1024,
826 None,
827 Some(0o644),
828 None,
829 );
830 assert!(result1.is_ok());
831
832 let result2 = validator2.validate_entry(
833 Path::new("file2.txt"),
834 &EntryType::File,
835 2048,
836 None,
837 Some(0o644),
838 None,
839 );
840 assert!(result2.is_ok());
841
842 assert_eq!(
844 config.max_file_size,
845 SecurityConfig::default().max_file_size
846 );
847 }
848
849 #[test]
850 fn test_validate_entry_with_dir_cache() {
851 let temp = TempDir::new().expect("failed to create temp dir");
852 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
853 let config = SecurityConfig::default().validate().expect("valid config");
854 let mut validator = EntryValidator::new(&config, &dest);
855
856 let sub = dest.as_path().join("subdir");
857 let mut dir_cache = DirCache::new();
858 dir_cache.ensure_dir(&sub).expect("should create dir");
859
860 let result = validator.validate_entry(
861 Path::new("subdir/file.txt"),
862 &EntryType::File,
863 100,
864 None,
865 Some(0o644),
866 Some(&dir_cache),
867 );
868 assert!(
869 result.is_ok(),
870 "entry with dir_cache should validate: {result:?}"
871 );
872 }
873
874 #[test]
875 fn test_symlink_seen_flag_propagates() {
876 let temp = TempDir::new().unwrap();
877 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
878 let mut config = SecurityConfig::default();
879 config.allowed.symlinks = true;
880 let config = config.validate().expect("valid config");
881 let mut validator = EntryValidator::new(&config, &dest);
882
883 assert!(!validator.symlink_seen);
884
885 validator
887 .validate_entry(
888 Path::new("link"),
889 &EntryType::Symlink {
890 target: PathBuf::from("target.txt"),
891 },
892 0,
893 None,
894 None,
895 None,
896 )
897 .unwrap();
898
899 assert!(validator.symlink_seen);
900 }
901
902 #[test]
905 fn test_reserve_hardlink_exceeding_max_file_size_rejected() {
906 let temp = TempDir::new().unwrap();
907 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
908 let mut config = SecurityConfig::default();
909 config.max_file_size = 100;
910 let config = config.validate().expect("valid config");
911 let mut validator = EntryValidator::new(&config, &dest);
912
913 let result = validator.reserve_hardlink(1_000);
917
918 assert_matches!(
919 result,
920 Err(crate::ArchiveError::QuotaExceeded {
921 resource: crate::QuotaResource::FileSize { .. }
922 }),
923 "hardlink exceeding max_file_size must be rejected, got: {result:?}"
924 );
925 }
926
927 #[test]
928 fn test_reserve_hardlink_shares_quota_tracker_with_files() {
929 let temp = TempDir::new().unwrap();
930 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
931 let mut config = SecurityConfig::default();
932 config.allowed.hardlinks = true;
933 config.max_total_size = 250;
934 let config = config.validate().expect("valid config");
935 let mut validator = EntryValidator::new(&config, &dest);
936
937 validator
939 .validate_entry(
940 Path::new("file1.txt"),
941 &EntryType::File,
942 100,
943 None,
944 Some(0o644),
945 None,
946 )
947 .unwrap();
948
949 assert!(validator.reserve_hardlink(100).is_ok());
952
953 let result = validator.reserve_hardlink(100);
954 assert_matches!(
955 result,
956 Err(crate::ArchiveError::QuotaExceeded {
957 resource: crate::QuotaResource::TotalSize { .. }
958 }),
959 "hardlink bytes must accumulate on the same tracker as file bytes \
960 (200 already recorded + 100 more exceeds the 250 budget), got: {result:?}"
961 );
962 }
963
964 #[test]
965 fn test_reserve_hardlink_exceeding_max_file_count() {
966 let temp = TempDir::new().unwrap();
967 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
968 let mut config = SecurityConfig::default();
969 config.max_file_count = 2;
970 let config = config.validate().expect("valid config");
971 let mut validator = EntryValidator::new(&config, &dest);
972
973 assert!(validator.reserve_hardlink(1).is_ok());
974 assert!(validator.reserve_hardlink(1).is_ok());
975
976 let result = validator.reserve_hardlink(1);
977 assert_matches!(
978 result,
979 Err(crate::ArchiveError::QuotaExceeded {
980 resource: crate::QuotaResource::FileCount { .. }
981 }),
982 "hardlink count alone exceeding max_file_count must be rejected, got: {result:?}"
983 );
984 }
985}