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