1use std::path::Path;
7
8use crate::ArchiveError;
9use crate::Result;
10use crate::SecurityConfig;
11use crate::config::Validated;
12use crate::formats::common::DirCache;
13use crate::security::context::ValidationContext;
14use crate::security::hardlink::HardlinkTracker;
15use crate::security::hardlink::require_hardlinks_allowed;
16use crate::security::permissions::SanitizedMode;
17use crate::security::permissions::sanitize_permissions;
18use crate::security::quota::EntryCount;
19use crate::security::quota::QuotaPermit;
20use crate::security::quota::QuotaTracker;
21use crate::security::symlink::validate_symlink;
22use crate::security::zipbomb::validate_compression_ratio;
23use crate::types::DestDir;
24use crate::types::EntryType;
25use crate::types::SafePath;
26use crate::types::SafeSymlink;
27use crate::types::safe_path::EMPTY_PATH_REASON;
28use crate::types::safe_symlink::require_symlinks_allowed;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub(crate) enum NodeKind {
33 Directory,
36 NonDirectory,
38}
39
40impl NodeKind {
41 pub(crate) const fn directory_if(is_directory: bool) -> Self {
44 if is_directory {
45 Self::Directory
46 } else {
47 Self::NonDirectory
48 }
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub(crate) enum EntryPath<P> {
60 Root(P),
62 Child(P),
64}
65
66impl<'a> EntryPath<&'a Path> {
67 pub(crate) fn of(path: &'a Path) -> Self {
70 if path == Path::new(".") {
71 Self::Root(path)
72 } else {
73 Self::Child(path)
74 }
75 }
76}
77
78impl<P> EntryPath<P> {
79 pub(crate) const fn is_root(&self) -> bool {
82 matches!(self, Self::Root(_))
83 }
84
85 pub(crate) fn into_inner(self) -> P {
87 match self {
88 Self::Root(path) | Self::Child(path) => path,
89 }
90 }
91
92 pub(crate) fn require_directory_root(self, kind: NodeKind) -> Result<Self> {
98 if self.is_root() && kind == NodeKind::NonDirectory {
99 return Err(ArchiveError::violation(
100 "archive root entry '.' must be a directory".into(),
101 ));
102 }
103 Ok(self)
104 }
105}
106
107pub(crate) fn check_root_entry_is_directory(path: &Path, kind: NodeKind) -> Result<()> {
110 EntryPath::of(path).require_directory_root(kind).map(drop)
111}
112
113pub(crate) fn require_non_empty_name(path: &Path) -> Result<()> {
116 if path.is_empty() {
117 return Err(ArchiveError::violation(EMPTY_PATH_REASON.into()));
118 }
119 Ok(())
120}
121
122#[derive(Debug)]
132pub struct ValidatedEntry {
133 safe_path: SafePath,
134 entry_type: ValidatedEntryType,
135 mode: Option<SanitizedMode>,
136}
137
138impl ValidatedEntry {
139 pub(crate) fn new(
145 safe_path: SafePath,
146 entry_type: ValidatedEntryType,
147 mode: Option<SanitizedMode>,
148 ) -> Self {
149 Self {
150 safe_path,
151 entry_type,
152 mode,
153 }
154 }
155
156 #[inline]
158 #[must_use]
159 pub fn safe_path(&self) -> &SafePath {
160 &self.safe_path
161 }
162
163 #[inline]
165 #[must_use]
166 pub fn entry_type(&self) -> &ValidatedEntryType {
167 &self.entry_type
168 }
169
170 #[inline]
172 #[must_use]
173 pub fn mode(&self) -> Option<SanitizedMode> {
174 self.mode
175 }
176
177 #[inline]
187 #[must_use]
188 pub(crate) fn into_parts(self) -> (SafePath, ValidatedEntryType, Option<SanitizedMode>) {
189 (self.safe_path, self.entry_type, self.mode)
190 }
191}
192
193#[derive(Debug)]
219#[non_exhaustive]
220pub enum ValidatedEntryType {
221 File(QuotaPermit),
224
225 Directory,
227
228 Symlink(SafeSymlink),
230
231 #[non_exhaustive]
233 Hardlink {
234 target: SafePath,
236 counted: EntryCount,
239 },
240}
241
242pub struct EntryValidator<'a> {
290 config: &'a SecurityConfig<Validated>,
291 dest: &'a DestDir,
292 quota_tracker: QuotaTracker,
293 hardlink_tracker: HardlinkTracker,
294 symlink_seen: bool,
295}
296
297impl<'a> EntryValidator<'a> {
298 #[must_use]
300 pub fn new(config: &'a SecurityConfig<Validated>, dest: &'a DestDir) -> Self {
301 Self {
302 config,
303 dest,
304 quota_tracker: QuotaTracker::new(),
305 hardlink_tracker: HardlinkTracker::new(),
306 symlink_seen: false,
307 }
308 }
309
310 #[inline]
337 pub fn validate_entry(
338 &mut self,
339 path: &Path,
340 entry_type: &EntryType,
341 uncompressed_size: u64,
342 compressed_size: Option<u64>,
343 mode: Option<u32>,
344 dir_cache: Option<&DirCache>,
345 ) -> Result<ValidatedEntry> {
346 let kind = NodeKind::directory_if(matches!(entry_type, EntryType::Directory));
347 let safe_path = self
348 .validate_entry_path(path, kind, dir_cache)?
349 .into_inner();
350
351 let (validated_type, sanitized_mode) = match entry_type {
352 EntryType::File => {
353 let permit = self.quota_tracker.reserve(uncompressed_size, self.config)?;
354 self.check_ratio(compressed_size, uncompressed_size)?;
355 let sanitized = mode.map(|m| sanitize_permissions(m, self.config));
356 (ValidatedEntryType::File(permit), sanitized)
357 }
358
359 EntryType::Directory => {
360 self.check_ratio(compressed_size, uncompressed_size)?;
361 (ValidatedEntryType::Directory, None)
362 }
363
364 EntryType::Symlink { target } => {
365 self.check_ratio(compressed_size, uncompressed_size)?;
366 require_symlinks_allowed(self.config)?;
367 self.reserve_symlink(uncompressed_size)?;
368 let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
369 self.symlink_seen = true;
370 (ValidatedEntryType::Symlink(safe_symlink), None)
371 }
372
373 EntryType::Hardlink { target } => {
374 self.check_ratio(compressed_size, uncompressed_size)?;
375 require_hardlinks_allowed(self.config)?;
376 let counted = self.quota_tracker.reserve_count(self.config)?;
377
378 self.hardlink_tracker.validate_hardlink(
381 &safe_path,
382 target,
383 self.dest,
384 self.config,
385 )?;
386
387 let target_safe = SafePath::new_unchecked(target.clone());
390
391 (
392 ValidatedEntryType::Hardlink {
393 target: target_safe,
394 counted,
395 },
396 None,
397 )
398 }
399 };
400
401 Ok(ValidatedEntry::new(
402 safe_path,
403 validated_type,
404 sanitized_mode,
405 ))
406 }
407
408 pub(crate) fn validate_entry_path(
429 &self,
430 path: &Path,
431 kind: NodeKind,
432 dir_cache: Option<&DirCache>,
433 ) -> Result<EntryPath<SafePath>> {
434 let entry_path = EntryPath::of(path).require_directory_root(kind)?;
435 let mut ctx = ValidationContext::new(self.config.allowed.symlinks);
436 if let Some(cache) = dir_cache {
437 ctx = ctx.with_dir_cache(cache);
438 }
439 if self.symlink_seen {
440 ctx.mark_symlink_seen();
441 }
442
443 let validate = |path| SafePath::validate_with_context(path, self.dest, self.config, &ctx);
444 match entry_path {
445 EntryPath::Root(path) => validate(path).map(EntryPath::Root),
446 EntryPath::Child(path) => validate(path).map(EntryPath::Child),
447 }
448 }
449
450 pub(crate) fn reserve_file(&mut self, size: u64) -> Result<QuotaPermit> {
467 self.quota_tracker.reserve(size, self.config)
468 }
469
470 #[inline]
483 pub(crate) fn check_ratio(
484 &self,
485 compressed_size: Option<u64>,
486 uncompressed_size: u64,
487 ) -> Result<()> {
488 if let Some(compressed) = compressed_size {
489 validate_compression_ratio(compressed, uncompressed_size, self.config)?;
490 }
491 Ok(())
492 }
493
494 pub(crate) fn reserve_symlink(&mut self, size: u64) -> Result<()> {
506 self.quota_tracker.reserve(size, self.config).map(drop)
507 }
508
509 pub(crate) fn charge_hardlink(
524 &mut self,
525 counted: EntryCount,
526 size: u64,
527 ) -> Result<QuotaPermit> {
528 self.quota_tracker.charge(counted, size, self.config)
529 }
530
531 #[must_use]
536 pub fn finish(self) -> ValidationReport {
537 ValidationReport {
538 files_validated: self.quota_tracker.files_extracted(),
539 total_bytes: self.quota_tracker.bytes_written(),
540 hardlinks_tracked: self.hardlink_tracker.count(),
541 }
542 }
543}
544
545#[derive(Debug)]
547pub struct ValidationReport {
548 pub files_validated: usize,
551
552 pub total_bytes: u64,
554
555 pub hardlinks_tracked: usize,
557}
558
559#[cfg(test)]
560#[allow(
561 clippy::unwrap_used,
562 clippy::expect_used,
563 clippy::field_reassign_with_default
564)]
565mod tests {
566 use super::*;
567 use std::assert_matches;
568 use std::path::PathBuf;
569 use tempfile::TempDir;
570
571 #[test]
572 fn test_entry_path_of_splits_root_from_child() {
573 for root in [".", "./", "./."] {
574 assert!(EntryPath::of(Path::new(root)).is_root(), "{root:?}");
575 }
576 assert_eq!(
577 EntryPath::of(Path::new("a/b")),
578 EntryPath::Child(Path::new("a/b"))
579 );
580 }
581
582 #[test]
583 fn test_root_requires_directory() {
584 let root = EntryPath::of(Path::new("."));
585 assert!(root.require_directory_root(NodeKind::Directory).is_ok());
586 assert_matches!(
587 root.require_directory_root(NodeKind::NonDirectory),
588 Err(ArchiveError::SecurityViolation { .. })
589 );
590 let child = EntryPath::of(Path::new("a"));
591 assert!(child.require_directory_root(NodeKind::NonDirectory).is_ok());
592 }
593
594 #[test]
595 fn test_validate_entry_path_returns_root_for_directory_and_rejects_file() {
596 let temp = TempDir::new().expect("failed to create temp dir");
597 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
598 let config = SecurityConfig::default().validate().expect("valid config");
599 let validator = EntryValidator::new(&config, &dest);
600
601 let root = validator
602 .validate_entry_path(Path::new("."), NodeKind::Directory, None)
603 .expect("root directory");
604 assert!(root.is_root());
605 assert_matches!(
606 validator.validate_entry_path(Path::new("."), NodeKind::NonDirectory, None),
607 Err(ArchiveError::SecurityViolation { .. })
608 );
609 let child = validator
610 .validate_entry_path(Path::new("a.txt"), NodeKind::NonDirectory, None)
611 .expect("child");
612 assert!(!child.is_root());
613 }
614
615 #[test]
616 fn test_entry_validator_new() {
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 validator = EntryValidator::new(&config, &dest);
621 let report = validator.finish();
622 assert_eq!(report.files_validated, 0);
623 assert_eq!(report.total_bytes, 0);
624 assert_eq!(report.hardlinks_tracked, 0);
625 }
626
627 #[test]
628 fn test_validate_file_entry() {
629 let temp = TempDir::new().expect("failed to create temp dir");
630 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
631 let config = SecurityConfig::default().validate().expect("valid config");
632 let mut validator = EntryValidator::new(&config, &dest);
633
634 let result = validator.validate_entry(
635 Path::new("file.txt"),
636 &EntryType::File,
637 1024,
638 None,
639 Some(0o644),
640 None,
641 );
642
643 assert!(result.is_ok());
644 let entry = result.unwrap();
645 assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
646 assert_matches!(entry.entry_type, ValidatedEntryType::File(_));
647 assert_eq!(entry.mode.map(SanitizedMode::as_u32), Some(0o644));
648 }
649
650 #[test]
651 fn test_validate_directory_entry() {
652 let temp = TempDir::new().expect("failed to create temp dir");
653 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
654 let config = SecurityConfig::default().validate().expect("valid config");
655 let mut validator = EntryValidator::new(&config, &dest);
656
657 let result =
658 validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None);
659
660 assert!(result.is_ok());
661 let entry = result.unwrap();
662 assert_matches!(entry.entry_type, ValidatedEntryType::Directory);
663 assert!(entry.mode.is_none());
664 }
665
666 #[test]
667 fn test_validate_path_traversal_rejected() {
668 let temp = TempDir::new().expect("failed to create temp dir");
669 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
670 let config = SecurityConfig::default().validate().expect("valid config");
671 let mut validator = EntryValidator::new(&config, &dest);
672
673 let result = validator.validate_entry(
674 Path::new("../etc/passwd"),
675 &EntryType::File,
676 1024,
677 None,
678 Some(0o644),
679 None,
680 );
681
682 assert!(result.is_err());
683 }
684
685 #[test]
686 fn test_quota_exceeded_file_size() {
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.max_file_size = 100;
691 let config = config.validate().expect("valid config");
692 let mut validator = EntryValidator::new(&config, &dest);
693
694 let result = validator.validate_entry(
695 Path::new("large.txt"),
696 &EntryType::File,
697 1000,
698 None,
699 Some(0o644),
700 None,
701 );
702
703 assert!(result.is_err());
704 }
705
706 #[test]
707 fn test_quota_exceeded_file_count() {
708 let temp = TempDir::new().unwrap();
709 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
710 let mut config = SecurityConfig::default();
711 config.max_file_count = 2;
712 let config = config.validate().expect("valid config");
713 let mut validator = EntryValidator::new(&config, &dest);
714
715 assert!(
716 validator
717 .validate_entry(
718 Path::new("file1.txt"),
719 &EntryType::File,
720 100,
721 None,
722 Some(0o644),
723 None,
724 )
725 .is_ok()
726 );
727 assert!(
728 validator
729 .validate_entry(
730 Path::new("file2.txt"),
731 &EntryType::File,
732 100,
733 None,
734 Some(0o644),
735 None,
736 )
737 .is_ok()
738 );
739
740 let result = validator.validate_entry(
741 Path::new("file3.txt"),
742 &EntryType::File,
743 100,
744 None,
745 Some(0o644),
746 None,
747 );
748 assert!(result.is_err());
749 }
750
751 #[test]
752 fn test_zip_bomb_detected() {
753 let temp = TempDir::new().expect("failed to create temp dir");
754 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
755 let config = SecurityConfig::default().validate().expect("valid config");
756 let mut validator = EntryValidator::new(&config, &dest);
757
758 let result = validator.validate_entry(
759 Path::new("bomb.txt"),
760 &EntryType::File,
761 1_000_000,
762 Some(100),
763 Some(0o644),
764 None,
765 );
766
767 assert!(result.is_err());
768 }
769
770 #[test]
771 fn test_validation_report() {
772 let temp = TempDir::new().expect("failed to create temp dir");
773 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
774 let config = SecurityConfig::default().validate().expect("valid config");
775 let mut validator = EntryValidator::new(&config, &dest);
776
777 validator
778 .validate_entry(
779 Path::new("file1.txt"),
780 &EntryType::File,
781 1024,
782 None,
783 Some(0o644),
784 None,
785 )
786 .unwrap();
787
788 validator
789 .validate_entry(
790 Path::new("file2.txt"),
791 &EntryType::File,
792 2048,
793 None,
794 Some(0o644),
795 None,
796 )
797 .unwrap();
798
799 let report = validator.finish();
800 assert_eq!(report.files_validated, 2);
801 assert_eq!(report.total_bytes, 1024 + 2048);
802 }
803
804 #[test]
805 fn test_sanitize_permissions_setuid() {
806 let temp = TempDir::new().expect("failed to create temp dir");
807 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
808 let config = SecurityConfig::default().validate().expect("valid config");
809 let mut validator = EntryValidator::new(&config, &dest);
810
811 let result = validator.validate_entry(
812 Path::new("file.txt"),
813 &EntryType::File,
814 1024,
815 None,
816 Some(0o4755),
817 None,
818 );
819
820 assert!(result.is_ok());
821 let entry = result.unwrap();
822 assert_eq!(entry.mode.map(SanitizedMode::as_u32), Some(0o755)); }
824
825 #[test]
826 fn test_symlink_validation() {
827 let temp = TempDir::new().unwrap();
828 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
829 let mut config = SecurityConfig::default();
830 config.allowed.symlinks = true;
831 let config = config.validate().expect("valid config");
832 let mut validator = EntryValidator::new(&config, &dest);
833
834 let result = validator.validate_entry(
835 Path::new("link"),
836 &EntryType::Symlink {
837 target: PathBuf::from("target.txt"),
838 },
839 0,
840 None,
841 None,
842 None,
843 );
844
845 assert!(result.is_ok());
846 let entry = result.unwrap();
847 assert_matches!(entry.entry_type, ValidatedEntryType::Symlink(_));
848 assert!(validator.symlink_seen);
849 }
850
851 #[test]
852 fn test_hardlink_validation() {
853 let temp = TempDir::new().unwrap();
854 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
855 let mut config = SecurityConfig::default();
856 config.allowed.hardlinks = true;
857 let config = config.validate().expect("valid config");
858 let mut validator = EntryValidator::new(&config, &dest);
859
860 let result = validator.validate_entry(
861 Path::new("link"),
862 &EntryType::Hardlink {
863 target: PathBuf::from("target.txt"),
864 },
865 0,
866 None,
867 None,
868 None,
869 );
870
871 assert!(result.is_ok());
872 let entry = result.unwrap();
873 assert_matches!(entry.entry_type, ValidatedEntryType::Hardlink { .. });
874 }
875
876 #[test]
877 fn test_multiple_entries_with_report() {
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.hardlinks = true;
882 let config = config.validate().expect("valid config");
883 let mut validator = EntryValidator::new(&config, &dest);
884
885 validator
887 .validate_entry(
888 Path::new("file1.txt"),
889 &EntryType::File,
890 1024,
891 None,
892 Some(0o644),
893 None,
894 )
895 .unwrap();
896
897 validator
898 .validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None)
899 .unwrap();
900
901 validator
902 .validate_entry(
903 Path::new("hardlink"),
904 &EntryType::Hardlink {
905 target: PathBuf::from("file1.txt"),
906 },
907 0,
908 None,
909 None,
910 None,
911 )
912 .unwrap();
913
914 let report = validator.finish();
915 assert_eq!(report.files_validated, 2); assert_eq!(report.total_bytes, 1024);
917 assert_eq!(report.hardlinks_tracked, 1);
918 }
919
920 #[test]
922 fn test_empty_directory_validation() {
923 let temp = TempDir::new().expect("failed to create temp dir");
924 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
925 let config = SecurityConfig::default().validate().expect("valid config");
926 let mut validator = EntryValidator::new(&config, &dest);
927
928 let result = validator.validate_entry(
930 Path::new("empty_dir/"),
931 &EntryType::Directory,
932 0,
933 None,
934 None,
935 None,
936 );
937
938 assert!(result.is_ok(), "empty directory should be valid");
939 let entry = result.unwrap();
940 assert_matches!(
941 entry.entry_type,
942 ValidatedEntryType::Directory,
943 "should be directory type"
944 );
945 assert!(entry.mode.is_none(), "directory should not have mode set");
946 }
947
948 #[test]
949 fn test_nested_empty_directories() {
950 let temp = TempDir::new().expect("failed to create temp dir");
951 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
952 let config = SecurityConfig::default().validate().expect("valid config");
953 let mut validator = EntryValidator::new(&config, &dest);
954
955 let dirs = ["a/", "a/b/", "a/b/c/"];
957 for dir in &dirs {
958 let result = validator.validate_entry(
959 Path::new(dir),
960 &EntryType::Directory,
961 0,
962 None,
963 None,
964 None,
965 );
966 assert!(result.is_ok(), "nested directory {dir} should be valid");
967 }
968
969 let report = validator.finish();
970 assert_eq!(
971 report.files_validated, 0,
972 "directories are not counted as files"
973 );
974 }
975
976 #[test]
978 fn test_validator_uses_references() {
979 let temp = TempDir::new().unwrap();
980 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
981 let config = SecurityConfig::default().validate().expect("valid config");
982
983 let validator = EntryValidator::new(&config, &dest);
985
986 assert_eq!(
988 config.max_file_size,
989 SecurityConfig::default().max_file_size
990 );
991 let _ = dest.as_path();
994
995 drop(validator);
997 }
998
999 #[test]
1001 fn test_multiple_validators_share_config() {
1002 let temp1 = TempDir::new().unwrap();
1003 let temp2 = TempDir::new().unwrap();
1004 let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
1005 let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
1006 let config = SecurityConfig::default().validate().expect("valid config");
1007
1008 let mut validator1 = EntryValidator::new(&config, &dest1);
1010 let mut validator2 = EntryValidator::new(&config, &dest2);
1011
1012 let result1 = validator1.validate_entry(
1014 Path::new("file1.txt"),
1015 &EntryType::File,
1016 1024,
1017 None,
1018 Some(0o644),
1019 None,
1020 );
1021 assert!(result1.is_ok());
1022
1023 let result2 = validator2.validate_entry(
1024 Path::new("file2.txt"),
1025 &EntryType::File,
1026 2048,
1027 None,
1028 Some(0o644),
1029 None,
1030 );
1031 assert!(result2.is_ok());
1032
1033 assert_eq!(
1035 config.max_file_size,
1036 SecurityConfig::default().max_file_size
1037 );
1038 }
1039
1040 #[test]
1041 fn test_validate_entry_with_dir_cache() {
1042 let temp = TempDir::new().expect("failed to create temp dir");
1043 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
1044 let config = SecurityConfig::default().validate().expect("valid config");
1045 let mut validator = EntryValidator::new(&config, &dest);
1046
1047 let sub = dest.as_path().join("subdir");
1048 let mut dir_cache = DirCache::new();
1049 dir_cache.ensure_dir(&sub).expect("should create dir");
1050
1051 let result = validator.validate_entry(
1052 Path::new("subdir/file.txt"),
1053 &EntryType::File,
1054 100,
1055 None,
1056 Some(0o644),
1057 Some(&dir_cache),
1058 );
1059 assert!(
1060 result.is_ok(),
1061 "entry with dir_cache should validate: {result:?}"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_symlink_seen_flag_propagates() {
1067 let temp = TempDir::new().unwrap();
1068 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1069 let mut config = SecurityConfig::default();
1070 config.allowed.symlinks = true;
1071 let config = config.validate().expect("valid config");
1072 let mut validator = EntryValidator::new(&config, &dest);
1073
1074 assert!(!validator.symlink_seen);
1075
1076 validator
1078 .validate_entry(
1079 Path::new("link"),
1080 &EntryType::Symlink {
1081 target: PathBuf::from("target.txt"),
1082 },
1083 0,
1084 None,
1085 None,
1086 None,
1087 )
1088 .unwrap();
1089
1090 assert!(validator.symlink_seen);
1091 }
1092
1093 fn validate_hardlink_entry(
1096 validator: &mut EntryValidator<'_>,
1097 name: &str,
1098 ) -> crate::Result<EntryCount> {
1099 let entry = validator.validate_entry(
1100 Path::new(name),
1101 &EntryType::Hardlink {
1102 target: PathBuf::from("target.txt"),
1103 },
1104 0,
1105 None,
1106 None,
1107 None,
1108 )?;
1109 match entry.into_parts() {
1110 (_, ValidatedEntryType::Hardlink { counted, .. }, _) => Ok(counted),
1111 other => panic!("expected a hardlink, got {other:?}"),
1112 }
1113 }
1114
1115 fn validate_symlink_entry(
1116 validator: &mut EntryValidator<'_>,
1117 name: &str,
1118 ) -> crate::Result<ValidatedEntry> {
1119 validator.validate_entry(
1120 Path::new(name),
1121 &EntryType::Symlink {
1122 target: PathBuf::from("target.txt"),
1123 },
1124 0,
1125 None,
1126 None,
1127 None,
1128 )
1129 }
1130
1131 #[test]
1132 fn test_charge_hardlink_exceeding_max_file_size_rejected() {
1133 let temp = TempDir::new().unwrap();
1134 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1135 let mut config = SecurityConfig::default();
1136 config.allowed.hardlinks = true;
1137 config.max_file_size = 100;
1138 let config = config.validate().expect("valid config");
1139 let mut validator = EntryValidator::new(&config, &dest);
1140
1141 let counted = validate_hardlink_entry(&mut validator, "link").unwrap();
1142 let result = validator.charge_hardlink(counted, 1_000);
1143
1144 assert_matches!(
1145 result,
1146 Err(crate::ArchiveError::QuotaExceeded {
1147 resource: crate::QuotaResource::FileSize { .. }
1148 }),
1149 "hardlink exceeding max_file_size must be rejected, got: {result:?}"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_charge_hardlink_shares_quota_tracker_with_files() {
1155 let temp = TempDir::new().unwrap();
1156 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1157 let mut config = SecurityConfig::default();
1158 config.allowed.hardlinks = true;
1159 config.max_total_size = 250;
1160 let config = config.validate().expect("valid config");
1161 let mut validator = EntryValidator::new(&config, &dest);
1162
1163 validator
1164 .validate_entry(
1165 Path::new("file1.txt"),
1166 &EntryType::File,
1167 100,
1168 None,
1169 Some(0o644),
1170 None,
1171 )
1172 .unwrap();
1173
1174 let first = validate_hardlink_entry(&mut validator, "link1").unwrap();
1175 let second = validate_hardlink_entry(&mut validator, "link2").unwrap();
1176 assert!(validator.charge_hardlink(first, 100).is_ok());
1177
1178 let result = validator.charge_hardlink(second, 100);
1179 assert_matches!(
1180 result,
1181 Err(crate::ArchiveError::QuotaExceeded {
1182 resource: crate::QuotaResource::TotalSize { .. }
1183 }),
1184 "hardlink bytes must accumulate on the same tracker as file bytes \
1185 (200 already recorded + 100 more exceeds the 250 budget), got: {result:?}"
1186 );
1187 }
1188
1189 #[test]
1190 fn test_hardlink_counted_once_in_pass_one() {
1191 let temp = TempDir::new().unwrap();
1192 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1193 let mut config = SecurityConfig::default();
1194 config.allowed.hardlinks = true;
1195 config.max_file_count = 2;
1196 let config = config.validate().expect("valid config");
1197 let mut validator = EntryValidator::new(&config, &dest);
1198
1199 let first = validate_hardlink_entry(&mut validator, "link1").unwrap();
1200 let second = validate_hardlink_entry(&mut validator, "link2").unwrap();
1201
1202 let result = validate_hardlink_entry(&mut validator, "link3");
1203 assert_matches!(
1204 result,
1205 Err(crate::ArchiveError::QuotaExceeded {
1206 resource: crate::QuotaResource::FileCount { .. }
1207 }),
1208 "the third hardlink must exceed max_file_count in pass one, got: {result:?}"
1209 );
1210
1211 assert!(validator.charge_hardlink(first, 1).is_ok());
1212 assert!(validator.charge_hardlink(second, 1).is_ok());
1213 assert_eq!(validator.finish().files_validated, 3);
1214 }
1215
1216 #[test]
1217 fn test_symlink_exceeding_max_file_count_rejected() {
1218 let temp = TempDir::new().unwrap();
1219 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1220 let mut config = SecurityConfig::default();
1221 config.allowed.symlinks = true;
1222 config.max_file_count = 2;
1223 let config = config.validate().expect("valid config");
1224 let mut validator = EntryValidator::new(&config, &dest);
1225
1226 assert!(validate_symlink_entry(&mut validator, "l1").is_ok());
1227 assert!(validate_symlink_entry(&mut validator, "l2").is_ok());
1228
1229 let result = validate_symlink_entry(&mut validator, "l3");
1230 assert_matches!(
1231 result,
1232 Err(crate::ArchiveError::QuotaExceeded {
1233 resource: crate::QuotaResource::FileCount { .. }
1234 }),
1235 "symlinks must count against max_file_count, got: {result:?}"
1236 );
1237 }
1238
1239 #[test]
1240 fn test_symlink_policy_error_wins_over_file_count() {
1241 let temp = TempDir::new().unwrap();
1242 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1243 let mut config = SecurityConfig::default();
1244 config.max_file_count = 1;
1245 let config = config.validate().expect("valid config");
1246 let mut validator = EntryValidator::new(&config, &dest);
1247 validator
1248 .validate_entry(Path::new("f"), &EntryType::File, 1, None, None, None)
1249 .unwrap();
1250
1251 let result = validate_symlink_entry(&mut validator, "l1");
1252 assert_matches!(
1253 result,
1254 Err(crate::ArchiveError::SecurityViolation { ref reason, .. })
1255 if reason == "symlinks not allowed",
1256 "got: {result:?}"
1257 );
1258 }
1259
1260 #[test]
1261 fn test_hardlink_policy_error_wins_over_file_count() {
1262 let temp = TempDir::new().unwrap();
1263 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1264 let mut config = SecurityConfig::default();
1265 config.max_file_count = 1;
1266 let config = config.validate().expect("valid config");
1267 let mut validator = EntryValidator::new(&config, &dest);
1268 validator
1269 .validate_entry(Path::new("f"), &EntryType::File, 1, None, None, None)
1270 .unwrap();
1271
1272 let result = validate_hardlink_entry(&mut validator, "l1");
1273 assert_matches!(
1274 result,
1275 Err(crate::ArchiveError::SecurityViolation { ref reason, .. })
1276 if reason == "hardlinks not allowed",
1277 "got: {result:?}"
1278 );
1279 }
1280}