1use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::{PathErrorExt, SsgError};
10use rayon::prelude::*;
11
12use crate::MAX_DIR_DEPTH;
13
14pub(crate) const PARALLEL_THRESHOLD: usize = 16;
16
17pub fn verify_and_copy_files(src: &Path, dst: &Path) -> Result<(), SsgError> {
57 if !is_safe_path(src)? {
58 return Err(SsgError::PathTraversal {
59 path: src.to_path_buf(),
60 });
61 }
62
63 if !src.exists() {
64 return Err(SsgError::Validation {
65 field: "src".to_string(),
66 message: format!(
67 "Source directory does not exist: {}",
68 src.display()
69 ),
70 });
71 }
72
73 if src.is_file() {
75 verify_file_safety(src)?;
76 }
77
78 fs::create_dir_all(dst).with_path(dst)?;
80
81 copy_dir_all(src, dst)?;
83
84 Ok(())
85}
86
87pub fn verify_and_copy_files_async(
106 src: &Path,
107 dst: &Path,
108) -> Result<(), SsgError> {
109 if !src.exists() {
110 return Err(SsgError::Validation {
111 field: "src".to_string(),
112 message: format!(
113 "Source directory does not exist: {}",
114 src.display()
115 ),
116 });
117 }
118
119 fs::create_dir_all(dst).with_path(dst)?;
120
121 copy_directory_recursive(src, dst)
122}
123
124fn copy_directory_recursive(src: &Path, dst: &Path) -> Result<(), SsgError> {
126 let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
127
128 while let Some((src_dir, dst_dir, depth)) = stack.pop() {
129 if depth >= MAX_DIR_DEPTH {
130 return Err(SsgError::Validation {
131 field: "directory_depth".to_string(),
132 message: format!(
133 "Directory nesting exceeds maximum depth of {}: {}",
134 MAX_DIR_DEPTH,
135 src_dir.display()
136 ),
137 });
138 }
139
140 for entry in fs::read_dir(&src_dir).with_path(&src_dir)? {
141 let entry = entry.with_path(&src_dir)?;
142 copy_entry(&entry, &dst_dir, depth, &mut stack)?;
143 }
144 }
145
146 Ok(())
147}
148
149fn copy_entry(
151 entry: &fs::DirEntry,
152 dst_dir: &Path,
153 depth: usize,
154 stack: &mut Vec<(PathBuf, PathBuf, usize)>,
155) -> Result<(), SsgError> {
156 let src_path = entry.path();
157 let dst_path = dst_dir.join(entry.file_name());
158
159 if src_path.is_dir() {
160 fs::create_dir_all(&dst_path).with_path(&dst_path)?;
161 stack.push((src_path, dst_path, depth + 1));
162 } else {
163 verify_file_safety(&src_path)?;
164 _ = fs::copy(&src_path, &dst_path).with_path(&dst_path)?;
165 }
166 Ok(())
167}
168
169pub fn copy_dir_with_progress(src: &Path, dst: &Path) -> Result<(), SsgError> {
188 if !src.exists() {
189 return Err(SsgError::Validation {
190 field: "src".to_string(),
191 message: format!(
192 "Source directory does not exist: {}",
193 src.display()
194 ),
195 });
196 }
197
198 fs::create_dir_all(dst).with_path(dst)?;
199
200 let mut file_count: u64 = 0;
201
202 let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
204
205 while let Some((src_dir, dst_dir, depth)) = stack.pop() {
206 if depth >= MAX_DIR_DEPTH {
207 return Err(SsgError::Validation {
208 field: "directory_depth".to_string(),
209 message: format!(
210 "Directory nesting exceeds maximum depth of {}: {}",
211 MAX_DIR_DEPTH,
212 src_dir.display()
213 ),
214 });
215 }
216
217 let entries: Vec<_> = fs::read_dir(&src_dir)
218 .with_path(&src_dir)?
219 .collect::<std::io::Result<Vec<_>>>()
220 .with_path(&src_dir)?;
221
222 for entry in &entries {
223 let src_path = entry.path();
224 let dst_path = dst_dir.join(entry.file_name());
225
226 if src_path.is_dir() {
227 fs::create_dir_all(&dst_path).with_path(&dst_path)?;
228 stack.push((src_path, dst_path, depth + 1));
229 } else {
230 _ = fs::copy(&src_path, &dst_path).with_path(&dst_path)?;
231 }
232 file_count += 1;
233 }
234 }
235
236 eprintln!("Copied {file_count} files");
237 Ok(())
238}
239
240pub fn is_safe_path(path: &Path) -> Result<bool, SsgError> {
288 use std::path::Component;
289
290 if path.components().any(|c| c == Component::ParentDir) {
296 return Ok(false);
297 }
298
299 if !path.exists() {
300 return Ok(true); }
302
303 let _canonical = path.canonicalize().with_path(path)?;
307
308 Ok(true)
309}
310
311pub fn is_path_within_root(path: &Path, root: &Path) -> Result<bool, SsgError> {
343 let canonical_path = path.canonicalize().with_path(path)?;
344 let canonical_root = root.canonicalize().with_path(root)?;
345 Ok(canonical_path.starts_with(&canonical_root))
346}
347
348pub fn verify_file_safety(path: &Path) -> Result<(), SsgError> {
402 const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; let symlink_metadata = path.symlink_metadata().with_path(path)?;
406
407 if symlink_metadata.file_type().is_symlink() {
409 return Err(SsgError::SymlinkForbidden {
410 path: path.to_path_buf(),
411 });
412 }
413
414 if symlink_metadata.file_type().is_file()
416 && symlink_metadata.len() > MAX_FILE_SIZE
417 {
418 return Err(SsgError::Validation {
419 field: "file_size".to_string(),
420 message: format!(
421 "File exceeds maximum allowed size of {} bytes: {}",
422 MAX_FILE_SIZE,
423 path.display()
424 ),
425 });
426 }
427
428 Ok(())
429}
430
431pub fn collect_files_recursive(
473 dir: &Path,
474 files: &mut Vec<PathBuf>,
475) -> Result<(), SsgError> {
476 let mut stack = vec![(dir.to_path_buf(), 0usize)];
478
479 while let Some((current_dir, depth)) = stack.pop() {
480 if depth >= MAX_DIR_DEPTH {
481 return Err(SsgError::Validation {
482 field: "directory_depth".to_string(),
483 message: format!(
484 "Directory nesting exceeds maximum depth of {}: {}",
485 MAX_DIR_DEPTH,
486 current_dir.display()
487 ),
488 });
489 }
490
491 for entry in fs::read_dir(¤t_dir).with_path(¤t_dir)? {
492 let path = entry.with_path(¤t_dir)?.path();
493
494 if path.is_dir() {
495 stack.push((path, depth + 1));
496 } else {
497 files.push(path);
498 }
499 }
500 }
501 Ok(())
502}
503
504pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), SsgError> {
544 fs::create_dir_all(dst).with_path(dst)?;
545
546 let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
548
549 while let Some((src_dir, dst_dir, depth)) = stack.pop() {
550 if depth >= MAX_DIR_DEPTH {
551 return Err(SsgError::Validation {
552 field: "directory_depth".to_string(),
553 message: format!(
554 "Directory nesting exceeds maximum depth of {}: {}",
555 MAX_DIR_DEPTH,
556 src_dir.display()
557 ),
558 });
559 }
560
561 let entries: Vec<_> = fs::read_dir(&src_dir)
562 .with_path(&src_dir)?
563 .collect::<std::io::Result<Vec<_>>>()
564 .with_path(&src_dir)?;
565
566 let (files, subdirs) = partition_entries(&entries, &dst_dir);
567
568 copy_files_maybe_parallel(&files, &dst_dir)?;
569
570 for (sub_src, sub_dst) in subdirs {
571 fs::create_dir_all(&sub_dst).with_path(&sub_dst)?;
572 stack.push((sub_src, sub_dst, depth + 1));
573 }
574 }
575
576 Ok(())
577}
578
579fn partition_entries<'a>(
581 entries: &'a [fs::DirEntry],
582 dst_dir: &Path,
583) -> (Vec<&'a fs::DirEntry>, Vec<(PathBuf, PathBuf)>) {
584 let mut subdirs = Vec::new();
585 let files: Vec<_> = entries
586 .iter()
587 .filter(|entry| {
588 let path = entry.path();
589 if path.is_dir() {
590 subdirs.push((path, dst_dir.join(entry.file_name())));
591 false
592 } else {
593 true
594 }
595 })
596 .collect();
597 (files, subdirs)
598}
599
600fn copy_files_maybe_parallel(
602 files: &[&fs::DirEntry],
603 dst_dir: &Path,
604) -> Result<(), SsgError> {
605 let copy_file = |entry: &&fs::DirEntry| -> Result<(), SsgError> {
606 let src_path = entry.path();
607 let dst_path = dst_dir.join(entry.file_name());
608 verify_file_safety(&src_path)?;
609 _ = fs::copy(&src_path, &dst_path).with_path(&dst_path)?;
610 Ok(())
611 };
612
613 if files.len() >= PARALLEL_THRESHOLD {
614 files.par_iter().try_for_each(copy_file)?;
615 } else {
616 files.iter().try_for_each(copy_file)?;
617 }
618 Ok(())
619}
620
621pub fn copy_dir_all_async(src: &Path, dst: &Path) -> Result<(), SsgError> {
655 internal_copy_dir_async(src, dst)
656}
657
658fn internal_copy_dir_async(src: &Path, dst: &Path) -> Result<(), SsgError> {
659 fs::create_dir_all(dst).with_path(dst)?;
660
661 let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
663
664 while let Some((src_path, dst_path, depth)) = stack.pop() {
665 if depth >= MAX_DIR_DEPTH {
666 return Err(SsgError::Validation {
667 field: "directory_depth".to_string(),
668 message: format!(
669 "Directory nesting exceeds maximum depth of {}: {}",
670 MAX_DIR_DEPTH,
671 src_path.display()
672 ),
673 });
674 }
675
676 for entry in fs::read_dir(&src_path).with_path(&src_path)? {
677 let entry = entry.with_path(&src_path)?;
678 let src_entry = entry.path();
679 let dst_entry = dst_path.join(entry.file_name());
680
681 if src_entry.is_dir() {
682 fs::create_dir_all(&dst_entry).with_path(&dst_entry)?;
683 stack.push((src_entry, dst_entry, depth + 1));
684 } else {
685 verify_file_safety(&src_entry)?;
686 _ = fs::copy(&src_entry, &dst_entry).with_path(&dst_entry)?;
687 }
688 }
689 }
690
691 Ok(())
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697 use tempfile::tempdir;
698
699 #[test]
700 fn copy_dir_all_copies_files() {
701 let src = tempdir().unwrap();
702 let dst = tempdir().unwrap();
703 fs::write(src.path().join("a.txt"), "hello").unwrap();
704 fs::write(src.path().join("b.txt"), "world").unwrap();
705
706 copy_dir_all(src.path(), dst.path()).unwrap();
707
708 assert_eq!(
709 fs::read_to_string(dst.path().join("a.txt")).unwrap(),
710 "hello"
711 );
712 assert_eq!(
713 fs::read_to_string(dst.path().join("b.txt")).unwrap(),
714 "world"
715 );
716 }
717
718 #[test]
719 fn copy_dir_all_nested_preserves_structure() {
720 let src = tempdir().unwrap();
721 let dst = tempdir().unwrap();
722 let nested = src.path().join("sub").join("deep");
723 fs::create_dir_all(&nested).unwrap();
724 fs::write(nested.join("file.txt"), "nested content").unwrap();
725 fs::write(src.path().join("root.txt"), "root").unwrap();
726
727 copy_dir_all(src.path(), dst.path()).unwrap();
728
729 assert_eq!(
730 fs::read_to_string(dst.path().join("sub/deep/file.txt")).unwrap(),
731 "nested content"
732 );
733 assert_eq!(
734 fs::read_to_string(dst.path().join("root.txt")).unwrap(),
735 "root"
736 );
737 }
738
739 #[test]
740 fn copy_dir_all_nonexistent_src_returns_error() {
741 let dst = tempdir().unwrap();
742 let fake_src = dst.path().join("does_not_exist");
743
744 let result = copy_dir_all(&fake_src, dst.path());
745 assert!(result.is_err());
746 }
747
748 #[test]
749 fn is_safe_path_normal_relative() {
750 let tmp = tempdir().unwrap();
751 let file = tmp.path().join("safe.txt");
752 fs::write(&file, "ok").unwrap();
753
754 assert!(is_safe_path(&file).unwrap());
755 }
756
757 #[test]
758 fn is_safe_path_with_dotdot_nonexistent() {
759 let path = Path::new("some/../../../etc/passwd");
760 assert!(!is_safe_path(path).unwrap());
761 }
762
763 #[test]
764 fn is_safe_path_with_dotdot_existing_is_now_rejected() {
765 let tmp = tempdir().unwrap();
780 let safe = tmp.path().join("a");
781 fs::create_dir_all(&safe).unwrap();
782 let dotdot_path = safe.join("..");
783 assert!(!is_safe_path(&dotdot_path).unwrap());
784 assert!(is_safe_path(&dotdot_path.canonicalize().unwrap()).unwrap());
787 }
788
789 #[test]
796 fn is_safe_path_existing_traversal_to_real_file_is_rejected() {
797 let existing_via_traversal = Path::new("../etc");
805 if existing_via_traversal.exists() {
810 assert!(!is_safe_path(existing_via_traversal).unwrap());
811 }
812 let tmp = tempdir().unwrap();
818 let real_dir = tmp.path().join("a");
819 fs::create_dir_all(real_dir.join("subdir")).unwrap();
820 let traversal_to_real_dir = real_dir.join("subdir").join("..");
821 assert!(traversal_to_real_dir.exists());
822 assert!(!is_safe_path(&traversal_to_real_dir).unwrap());
823 }
824
825 #[test]
826 fn is_safe_path_rejects_literal_dotdot_in_filename_false_positive_check() {
827 let tmp = tempdir().unwrap();
833 let odd_name = tmp.path().join("notes..final.md");
834 fs::write(&odd_name, "content").unwrap();
835 assert!(is_safe_path(&odd_name).unwrap());
836 }
837
838 #[test]
839 fn is_safe_path_absolute_existing() {
840 let tmp = tempdir().unwrap();
841 let file = tmp.path().join("abs.txt");
842 fs::write(&file, "data").unwrap();
843 assert!(is_safe_path(&file).unwrap());
845 }
846
847 #[test]
853 fn is_path_within_root_accepts_direct_child() {
854 let tmp = tempdir().unwrap();
855 let child = tmp.path().join("content");
856 fs::create_dir_all(&child).unwrap();
857 assert!(is_path_within_root(&child, tmp.path()).unwrap());
858 }
859
860 #[test]
861 fn is_path_within_root_accepts_root_itself() {
862 let tmp = tempdir().unwrap();
863 assert!(is_path_within_root(tmp.path(), tmp.path()).unwrap());
864 }
865
866 #[test]
867 fn is_path_within_root_accepts_deeply_nested_child() {
868 let tmp = tempdir().unwrap();
869 let nested = tmp.path().join("a").join("b").join("c");
870 fs::create_dir_all(&nested).unwrap();
871 assert!(is_path_within_root(&nested, tmp.path()).unwrap());
872 }
873
874 #[test]
875 fn is_path_within_root_rejects_sibling_directory() {
876 let tmp = tempdir().unwrap();
877 let root = tmp.path().join("root");
878 let sibling = tmp.path().join("sibling");
879 fs::create_dir_all(&root).unwrap();
880 fs::create_dir_all(&sibling).unwrap();
881 assert!(!is_path_within_root(&sibling, &root).unwrap());
882 }
883
884 #[cfg(unix)]
885 #[test]
886 fn is_path_within_root_rejects_symlink_escape() {
887 use std::os::unix::fs::symlink;
893
894 let tmp = tempdir().unwrap();
895 let root = tmp.path().join("root");
896 let outside = tmp.path().join("outside");
897 fs::create_dir_all(&root).unwrap();
898 fs::create_dir_all(&outside).unwrap();
899
900 let escape_link = root.join("content");
901 symlink(&outside, &escape_link).unwrap();
902
903 assert!(
904 !is_path_within_root(&escape_link, &root).unwrap(),
905 "a symlink pointing outside root must not be reported as contained"
906 );
907 }
908
909 #[test]
910 fn is_path_within_root_errors_on_nonexistent_path() {
911 let tmp = tempdir().unwrap();
912 let missing = tmp.path().join("does-not-exist-yet");
913 assert!(is_path_within_root(&missing, tmp.path()).is_err());
914 }
915
916 #[test]
917 fn is_path_within_root_errors_on_nonexistent_root() {
918 let tmp = tempdir().unwrap();
919 let existing = tmp.path().join("child");
920 fs::create_dir_all(&existing).unwrap();
921 let missing_root = tmp.path().join("no-such-root");
922 assert!(is_path_within_root(&existing, &missing_root).is_err());
923 }
924
925 #[test]
926 fn verify_file_safety_valid_file() {
927 let tmp = tempdir().unwrap();
928 let file = tmp.path().join("ok.txt");
929 fs::write(&file, "small file").unwrap();
930
931 assert!(verify_file_safety(&file).is_ok());
932 }
933
934 #[test]
935 fn verify_file_safety_nonexistent() {
936 let tmp = tempdir().unwrap();
937 let missing = tmp.path().join("nope.txt");
938
939 assert!(verify_file_safety(&missing).is_err());
941 }
942
943 #[test]
944 fn verify_file_safety_directory() {
945 let tmp = tempdir().unwrap();
946 assert!(verify_file_safety(tmp.path()).is_ok());
948 }
949
950 #[test]
951 fn collect_files_recursive_finds_all() {
952 let tmp = tempdir().unwrap();
953 let sub = tmp.path().join("sub");
954 fs::create_dir_all(&sub).unwrap();
955 fs::write(tmp.path().join("a.md"), "").unwrap();
956 fs::write(sub.join("b.md"), "").unwrap();
957 fs::write(sub.join("c.txt"), "").unwrap();
958
959 let mut files = Vec::new();
960 collect_files_recursive(tmp.path(), &mut files).unwrap();
961
962 assert_eq!(files.len(), 3);
963 }
964
965 #[test]
966 fn collect_files_recursive_empty_dir() {
967 let tmp = tempdir().unwrap();
968
969 let mut files = Vec::new();
970 collect_files_recursive(tmp.path(), &mut files).unwrap();
971
972 assert!(files.is_empty());
973 }
974
975 #[test]
976 fn collect_files_recursive_only_files_not_dirs() {
977 let tmp = tempdir().unwrap();
978 let sub = tmp.path().join("subdir");
979 fs::create_dir_all(&sub).unwrap();
980 fs::write(sub.join("only.txt"), "data").unwrap();
981
982 let mut files = Vec::new();
983 collect_files_recursive(tmp.path(), &mut files).unwrap();
984
985 assert_eq!(files.len(), 1);
986 assert!(files[0].ends_with("only.txt"));
987 }
988
989 #[test]
990 fn collect_files_recursive_nonexistent_dir_returns_error() {
991 let tmp = tempdir().unwrap();
998 let missing = tmp.path().join("does-not-exist");
999
1000 let mut files = Vec::new();
1001 let result = collect_files_recursive(&missing, &mut files);
1002 assert!(result.is_err());
1003 }
1004
1005 #[test]
1006 fn verify_and_copy_files_end_to_end() {
1007 let src = tempdir().unwrap();
1008 let dst = tempdir().unwrap();
1009 let target = dst.path().join("output");
1010 fs::write(src.path().join("page.html"), "<h1>Hi</h1>").unwrap();
1011
1012 verify_and_copy_files(src.path(), &target).unwrap();
1013
1014 assert_eq!(
1015 fs::read_to_string(target.join("page.html")).unwrap(),
1016 "<h1>Hi</h1>"
1017 );
1018 }
1019
1020 #[test]
1021 fn copy_dir_with_progress_smoke() {
1022 let src = tempdir().unwrap();
1023 let dst = tempdir().unwrap();
1024 fs::write(src.path().join("f.txt"), "data").unwrap();
1025
1026 copy_dir_with_progress(src.path(), &dst.path().join("out")).unwrap();
1028 }
1029
1030 #[test]
1031 fn copy_dir_with_progress_nonexistent_src() {
1032 let tmp = tempdir().unwrap();
1033 let fake = tmp.path().join("missing");
1034
1035 let result = copy_dir_with_progress(&fake, tmp.path());
1036 assert!(result.is_err());
1037 }
1038
1039 #[test]
1040 fn copy_dir_with_progress_src_is_file_fails_at_read_dir() {
1041 let tmp = tempdir().unwrap();
1048 let file_src = tmp.path().join("plain.txt");
1049 fs::write(&file_src, "x").unwrap();
1050
1051 let result = copy_dir_with_progress(&file_src, &tmp.path().join("dst"));
1052 assert!(result.is_err());
1053 }
1054
1055 #[test]
1060 fn verify_and_copy_files_rejects_traversal_path() {
1061 let dst = tempdir().unwrap();
1062 let err = verify_and_copy_files(
1064 Path::new("../nonexistent-ssg-traversal"),
1065 dst.path(),
1066 )
1067 .unwrap_err();
1068 assert!(
1069 err.to_string().contains("directory traversal"),
1070 "got: {err}"
1071 );
1072 }
1073
1074 #[test]
1075 fn verify_and_copy_files_missing_src_is_validation_error() {
1076 let tmp = tempdir().unwrap();
1077 let err = verify_and_copy_files(
1078 &tmp.path().join("no-such-src"),
1079 &tmp.path().join("dst"),
1080 )
1081 .unwrap_err();
1082 assert!(err.to_string().contains("does not exist"), "got: {err}");
1083 }
1084
1085 #[test]
1086 fn verify_and_copy_files_rejects_oversized_source_file() {
1087 let tmp = tempdir().unwrap();
1090 let big = tmp.path().join("big.bin");
1091 let f = fs::File::create(&big).unwrap();
1092 f.set_len(10 * 1024 * 1024 + 1).unwrap();
1093
1094 let err =
1095 verify_and_copy_files(&big, &tmp.path().join("dst")).unwrap_err();
1096 assert!(
1097 err.to_string().contains("exceeds maximum allowed size"),
1098 "got: {err}"
1099 );
1100 }
1101
1102 #[test]
1103 fn verify_and_copy_files_dst_under_file_fails() {
1104 let src = tempdir().unwrap();
1105 let tmp = tempdir().unwrap();
1106 fs::write(src.path().join("a.txt"), "x").unwrap();
1107 let blocker = tmp.path().join("blocker");
1108 fs::write(&blocker, "file").unwrap();
1109
1110 let result = verify_and_copy_files(src.path(), &blocker.join("dst"));
1111 assert!(result.is_err());
1112 }
1113
1114 #[test]
1115 fn verify_and_copy_files_small_file_src_fails_in_copy_stage() {
1116 let tmp = tempdir().unwrap();
1119 let file_src = tmp.path().join("plain.txt");
1120 fs::write(&file_src, "small").unwrap();
1121
1122 let result = verify_and_copy_files(&file_src, &tmp.path().join("dst"));
1123 assert!(result.is_err());
1124 }
1125
1126 #[test]
1131 fn copy_dir_all_uses_parallel_path_at_threshold() {
1132 let src = tempdir().unwrap();
1133 let dst = tempdir().unwrap();
1134 for i in 0..PARALLEL_THRESHOLD {
1135 fs::write(src.path().join(format!("f{i}.txt")), format!("{i}"))
1136 .unwrap();
1137 }
1138
1139 copy_dir_all(src.path(), dst.path()).unwrap();
1140 for i in 0..PARALLEL_THRESHOLD {
1141 assert_eq!(
1142 fs::read_to_string(dst.path().join(format!("f{i}.txt")))
1143 .unwrap(),
1144 format!("{i}")
1145 );
1146 }
1147 }
1148
1149 #[cfg(unix)]
1150 #[test]
1151 fn copy_dir_all_sequential_rejects_symlink() {
1152 let src = tempdir().unwrap();
1153 let dst = tempdir().unwrap();
1154 fs::write(src.path().join("ok.txt"), "x").unwrap();
1155 std::os::unix::fs::symlink(
1156 src.path().join("ok.txt"),
1157 src.path().join("link.txt"),
1158 )
1159 .unwrap();
1160
1161 let err = copy_dir_all(src.path(), dst.path()).unwrap_err();
1162 assert!(err.to_string().contains("symlink"), "got: {err}");
1163 }
1164
1165 #[cfg(unix)]
1166 #[test]
1167 fn copy_dir_all_parallel_rejects_symlink() {
1168 let src = tempdir().unwrap();
1169 let dst = tempdir().unwrap();
1170 for i in 0..PARALLEL_THRESHOLD {
1171 fs::write(src.path().join(format!("f{i}.txt")), "x").unwrap();
1172 }
1173 std::os::unix::fs::symlink(
1174 src.path().join("f0.txt"),
1175 src.path().join("link.txt"),
1176 )
1177 .unwrap();
1178
1179 let err = copy_dir_all(src.path(), dst.path()).unwrap_err();
1180 assert!(err.to_string().contains("symlink"), "got: {err}");
1181 }
1182
1183 #[test]
1184 fn copy_dir_all_subdir_blocked_by_file_in_dst() {
1185 let src = tempdir().unwrap();
1186 let dst = tempdir().unwrap();
1187 fs::create_dir_all(src.path().join("sub")).unwrap();
1188 fs::write(src.path().join("sub/x.txt"), "x").unwrap();
1189 fs::write(dst.path().join("sub"), "blocking file").unwrap();
1191
1192 let result = copy_dir_all(src.path(), dst.path());
1193 assert!(result.is_err());
1194 }
1195
1196 #[test]
1197 fn copy_dir_all_top_level_dst_under_file_fails() {
1198 let src = tempdir().unwrap();
1204 let tmp = tempdir().unwrap();
1205 fs::write(src.path().join("a.txt"), "x").unwrap();
1206 let blocker = tmp.path().join("blocker");
1207 fs::write(&blocker, "file").unwrap();
1208
1209 let result = copy_dir_all(src.path(), &blocker.join("dst"));
1210 assert!(result.is_err());
1211 }
1212
1213 #[test]
1214 fn copy_dir_all_file_copy_onto_directory_fails() {
1215 let src = tempdir().unwrap();
1223 let dst = tempdir().unwrap();
1224 fs::write(src.path().join("x.txt"), "x").unwrap();
1225 fs::create_dir_all(dst.path().join("x.txt")).unwrap();
1226
1227 let result = copy_dir_all(src.path(), dst.path());
1228 assert!(result.is_err());
1229 }
1230
1231 #[test]
1236 fn verify_and_copy_files_async_happy_path_nested() {
1237 let src = tempdir().unwrap();
1238 let dst = tempdir().unwrap();
1239 fs::create_dir_all(src.path().join("sub")).unwrap();
1240 fs::write(src.path().join("root.txt"), "r").unwrap();
1241 fs::write(src.path().join("sub/leaf.txt"), "l").unwrap();
1242
1243 verify_and_copy_files_async(src.path(), dst.path()).unwrap();
1244 assert_eq!(
1245 fs::read_to_string(dst.path().join("sub/leaf.txt")).unwrap(),
1246 "l"
1247 );
1248 }
1249
1250 #[test]
1251 fn verify_and_copy_files_async_missing_src_is_validation_error() {
1252 let tmp = tempdir().unwrap();
1253 let err = verify_and_copy_files_async(
1254 &tmp.path().join("gone"),
1255 &tmp.path().join("dst"),
1256 )
1257 .unwrap_err();
1258 assert!(err.to_string().contains("does not exist"), "got: {err}");
1259 }
1260
1261 #[test]
1262 fn verify_and_copy_files_async_src_file_fails_at_read_dir() {
1263 let tmp = tempdir().unwrap();
1264 let file_src = tmp.path().join("plain.txt");
1265 fs::write(&file_src, "x").unwrap();
1266
1267 let result =
1268 verify_and_copy_files_async(&file_src, &tmp.path().join("dst"));
1269 assert!(result.is_err());
1270 }
1271
1272 #[cfg(unix)]
1273 #[test]
1274 fn copy_directory_recursive_rejects_symlink_entry() {
1275 let src = tempdir().unwrap();
1276 let dst = tempdir().unwrap();
1277 fs::write(src.path().join("real.txt"), "x").unwrap();
1278 std::os::unix::fs::symlink(
1279 src.path().join("real.txt"),
1280 src.path().join("link.txt"),
1281 )
1282 .unwrap();
1283
1284 let err = copy_directory_recursive(src.path(), dst.path()).unwrap_err();
1285 assert!(err.to_string().contains("symlink"), "got: {err}");
1286 }
1287
1288 #[test]
1289 fn copy_directory_recursive_subdir_blocked_by_file() {
1290 let src = tempdir().unwrap();
1291 let dst = tempdir().unwrap();
1292 fs::create_dir_all(src.path().join("sub")).unwrap();
1293 fs::write(src.path().join("sub/x.txt"), "x").unwrap();
1294 fs::write(dst.path().join("sub"), "blocking file").unwrap();
1295
1296 let result = copy_directory_recursive(src.path(), dst.path());
1297 assert!(result.is_err());
1298 }
1299
1300 #[test]
1301 fn copy_directory_recursive_copy_onto_directory_fails() {
1302 let src = tempdir().unwrap();
1303 let dst = tempdir().unwrap();
1304 fs::write(src.path().join("x.txt"), "x").unwrap();
1305 fs::create_dir_all(dst.path().join("x.txt")).unwrap();
1307
1308 let result = copy_directory_recursive(src.path(), dst.path());
1309 assert!(result.is_err());
1310 }
1311
1312 #[test]
1317 fn copy_dir_all_async_nested_happy_path() {
1318 let src = tempdir().unwrap();
1319 let dst = tempdir().unwrap();
1320 fs::create_dir_all(src.path().join("deep/deeper")).unwrap();
1321 fs::write(src.path().join("deep/deeper/f.txt"), "d").unwrap();
1322
1323 copy_dir_all_async(src.path(), dst.path()).unwrap();
1324 assert_eq!(
1325 fs::read_to_string(dst.path().join("deep/deeper/f.txt")).unwrap(),
1326 "d"
1327 );
1328 }
1329
1330 #[test]
1331 fn copy_dir_all_async_dst_under_file_fails() {
1332 let src = tempdir().unwrap();
1333 let tmp = tempdir().unwrap();
1334 let blocker = tmp.path().join("blocker");
1335 fs::write(&blocker, "file").unwrap();
1336
1337 let result = copy_dir_all_async(src.path(), &blocker.join("dst"));
1338 assert!(result.is_err());
1339 }
1340
1341 #[test]
1342 fn copy_dir_all_async_src_file_fails_at_read_dir() {
1343 let tmp = tempdir().unwrap();
1344 let file_src = tmp.path().join("plain.txt");
1345 fs::write(&file_src, "x").unwrap();
1346
1347 let result = copy_dir_all_async(&file_src, &tmp.path().join("dst"));
1348 assert!(result.is_err());
1349 }
1350
1351 #[cfg(unix)]
1352 #[test]
1353 fn copy_dir_all_async_rejects_symlink() {
1354 let src = tempdir().unwrap();
1355 let dst = tempdir().unwrap();
1356 fs::write(src.path().join("real.txt"), "x").unwrap();
1357 std::os::unix::fs::symlink(
1358 src.path().join("real.txt"),
1359 src.path().join("link.txt"),
1360 )
1361 .unwrap();
1362
1363 let err = copy_dir_all_async(src.path(), dst.path()).unwrap_err();
1364 assert!(err.to_string().contains("symlink"), "got: {err}");
1365 }
1366
1367 #[test]
1368 fn copy_dir_all_async_subdir_blocked_by_file() {
1369 let src = tempdir().unwrap();
1370 let dst = tempdir().unwrap();
1371 fs::create_dir_all(src.path().join("sub")).unwrap();
1372 fs::write(src.path().join("sub/x.txt"), "x").unwrap();
1373 fs::write(dst.path().join("sub"), "blocking file").unwrap();
1374
1375 let result = copy_dir_all_async(src.path(), dst.path());
1376 assert!(result.is_err());
1377 }
1378
1379 #[test]
1380 fn copy_dir_all_async_copy_onto_directory_fails() {
1381 let src = tempdir().unwrap();
1382 let dst = tempdir().unwrap();
1383 fs::write(src.path().join("x.txt"), "x").unwrap();
1384 fs::create_dir_all(dst.path().join("x.txt")).unwrap();
1385
1386 let result = copy_dir_all_async(src.path(), dst.path());
1387 assert!(result.is_err());
1388 }
1389
1390 #[test]
1395 fn copy_dir_with_progress_subdir_blocked_by_file() {
1396 let src = tempdir().unwrap();
1397 let dst = tempdir().unwrap();
1398 fs::create_dir_all(src.path().join("sub")).unwrap();
1399 fs::write(src.path().join("sub/x.txt"), "x").unwrap();
1400 fs::write(dst.path().join("sub"), "blocking file").unwrap();
1401
1402 let result = copy_dir_with_progress(src.path(), dst.path());
1403 assert!(result.is_err());
1404 }
1405
1406 #[test]
1407 fn copy_dir_with_progress_copy_onto_directory_fails() {
1408 let src = tempdir().unwrap();
1409 let dst = tempdir().unwrap();
1410 fs::write(src.path().join("x.txt"), "x").unwrap();
1411 fs::create_dir_all(dst.path().join("x.txt")).unwrap();
1412
1413 let result = copy_dir_with_progress(src.path(), dst.path());
1414 assert!(result.is_err());
1415 }
1416}