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> {
293 use std::path::Component;
294
295 if path.components().any(|c| c == Component::ParentDir) {
301 return Ok(false);
302 }
303
304 if !path.exists() {
305 return Ok(true); }
307
308 let _canonical = path.canonicalize().with_path(path)?;
312
313 Ok(true)
314}
315
316pub fn is_path_within_root(path: &Path, root: &Path) -> Result<bool, SsgError> {
348 commons::path::is_within(root, path).map_err(|source| SsgError::Io {
349 path: if root.exists() { path } else { root }.to_path_buf(),
351 source,
352 })
353}
354
355pub fn verify_file_safety(path: &Path) -> Result<(), SsgError> {
409 const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; let symlink_metadata = path.symlink_metadata().with_path(path)?;
413
414 if symlink_metadata.file_type().is_symlink() {
416 return Err(SsgError::SymlinkForbidden {
417 path: path.to_path_buf(),
418 });
419 }
420
421 if symlink_metadata.file_type().is_file()
423 && symlink_metadata.len() > MAX_FILE_SIZE
424 {
425 return Err(SsgError::Validation {
426 field: "file_size".to_string(),
427 message: format!(
428 "File exceeds maximum allowed size of {} bytes: {}",
429 MAX_FILE_SIZE,
430 path.display()
431 ),
432 });
433 }
434
435 Ok(())
436}
437
438pub fn collect_files_recursive(
480 dir: &Path,
481 files: &mut Vec<PathBuf>,
482) -> Result<(), SsgError> {
483 let mut stack = vec![(dir.to_path_buf(), 0usize)];
485
486 while let Some((current_dir, depth)) = stack.pop() {
487 if depth >= MAX_DIR_DEPTH {
488 return Err(SsgError::Validation {
489 field: "directory_depth".to_string(),
490 message: format!(
491 "Directory nesting exceeds maximum depth of {}: {}",
492 MAX_DIR_DEPTH,
493 current_dir.display()
494 ),
495 });
496 }
497
498 for entry in fs::read_dir(¤t_dir).with_path(¤t_dir)? {
499 let path = entry.with_path(¤t_dir)?.path();
500
501 if path.is_dir() {
502 stack.push((path, depth + 1));
503 } else {
504 files.push(path);
505 }
506 }
507 }
508 Ok(())
509}
510
511pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), SsgError> {
551 fs::create_dir_all(dst).with_path(dst)?;
552
553 let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
555
556 while let Some((src_dir, dst_dir, depth)) = stack.pop() {
557 if depth >= MAX_DIR_DEPTH {
558 return Err(SsgError::Validation {
559 field: "directory_depth".to_string(),
560 message: format!(
561 "Directory nesting exceeds maximum depth of {}: {}",
562 MAX_DIR_DEPTH,
563 src_dir.display()
564 ),
565 });
566 }
567
568 let entries: Vec<_> = fs::read_dir(&src_dir)
569 .with_path(&src_dir)?
570 .collect::<std::io::Result<Vec<_>>>()
571 .with_path(&src_dir)?;
572
573 let (files, subdirs) = partition_entries(&entries, &dst_dir);
574
575 copy_files_maybe_parallel(&files, &dst_dir)?;
576
577 for (sub_src, sub_dst) in subdirs {
578 fs::create_dir_all(&sub_dst).with_path(&sub_dst)?;
579 stack.push((sub_src, sub_dst, depth + 1));
580 }
581 }
582
583 Ok(())
584}
585
586fn partition_entries<'a>(
588 entries: &'a [fs::DirEntry],
589 dst_dir: &Path,
590) -> (Vec<&'a fs::DirEntry>, Vec<(PathBuf, PathBuf)>) {
591 let mut subdirs = Vec::new();
592 let files: Vec<_> = entries
593 .iter()
594 .filter(|entry| {
595 let path = entry.path();
596 if path.is_dir() {
597 subdirs.push((path, dst_dir.join(entry.file_name())));
598 false
599 } else {
600 true
601 }
602 })
603 .collect();
604 (files, subdirs)
605}
606
607fn copy_files_maybe_parallel(
609 files: &[&fs::DirEntry],
610 dst_dir: &Path,
611) -> Result<(), SsgError> {
612 let copy_file = |entry: &&fs::DirEntry| -> Result<(), SsgError> {
613 let src_path = entry.path();
614 let dst_path = dst_dir.join(entry.file_name());
615 verify_file_safety(&src_path)?;
616 _ = fs::copy(&src_path, &dst_path).with_path(&dst_path)?;
617 Ok(())
618 };
619
620 if files.len() >= PARALLEL_THRESHOLD {
621 files.par_iter().try_for_each(copy_file)?;
622 } else {
623 files.iter().try_for_each(copy_file)?;
624 }
625 Ok(())
626}
627
628pub fn copy_dir_all_async(src: &Path, dst: &Path) -> Result<(), SsgError> {
662 internal_copy_dir_async(src, dst)
663}
664
665fn internal_copy_dir_async(src: &Path, dst: &Path) -> Result<(), SsgError> {
666 fs::create_dir_all(dst).with_path(dst)?;
667
668 let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
670
671 while let Some((src_path, dst_path, depth)) = stack.pop() {
672 if depth >= MAX_DIR_DEPTH {
673 return Err(SsgError::Validation {
674 field: "directory_depth".to_string(),
675 message: format!(
676 "Directory nesting exceeds maximum depth of {}: {}",
677 MAX_DIR_DEPTH,
678 src_path.display()
679 ),
680 });
681 }
682
683 for entry in fs::read_dir(&src_path).with_path(&src_path)? {
684 let entry = entry.with_path(&src_path)?;
685 let src_entry = entry.path();
686 let dst_entry = dst_path.join(entry.file_name());
687
688 if src_entry.is_dir() {
689 fs::create_dir_all(&dst_entry).with_path(&dst_entry)?;
690 stack.push((src_entry, dst_entry, depth + 1));
691 } else {
692 verify_file_safety(&src_entry)?;
693 _ = fs::copy(&src_entry, &dst_entry).with_path(&dst_entry)?;
694 }
695 }
696 }
697
698 Ok(())
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use tempfile::tempdir;
705
706 #[test]
707 fn copy_dir_all_copies_files() {
708 let src = tempdir().unwrap();
709 let dst = tempdir().unwrap();
710 fs::write(src.path().join("a.txt"), "hello").unwrap();
711 fs::write(src.path().join("b.txt"), "world").unwrap();
712
713 copy_dir_all(src.path(), dst.path()).unwrap();
714
715 assert_eq!(
716 fs::read_to_string(dst.path().join("a.txt")).unwrap(),
717 "hello"
718 );
719 assert_eq!(
720 fs::read_to_string(dst.path().join("b.txt")).unwrap(),
721 "world"
722 );
723 }
724
725 #[test]
726 fn copy_dir_all_nested_preserves_structure() {
727 let src = tempdir().unwrap();
728 let dst = tempdir().unwrap();
729 let nested = src.path().join("sub").join("deep");
730 fs::create_dir_all(&nested).unwrap();
731 fs::write(nested.join("file.txt"), "nested content").unwrap();
732 fs::write(src.path().join("root.txt"), "root").unwrap();
733
734 copy_dir_all(src.path(), dst.path()).unwrap();
735
736 assert_eq!(
737 fs::read_to_string(dst.path().join("sub/deep/file.txt")).unwrap(),
738 "nested content"
739 );
740 assert_eq!(
741 fs::read_to_string(dst.path().join("root.txt")).unwrap(),
742 "root"
743 );
744 }
745
746 #[test]
747 fn copy_dir_all_nonexistent_src_returns_error() {
748 let dst = tempdir().unwrap();
749 let fake_src = dst.path().join("does_not_exist");
750
751 let result = copy_dir_all(&fake_src, dst.path());
752 assert!(result.is_err());
753 }
754
755 #[test]
756 fn is_safe_path_normal_relative() {
757 let tmp = tempdir().unwrap();
758 let file = tmp.path().join("safe.txt");
759 fs::write(&file, "ok").unwrap();
760
761 assert!(is_safe_path(&file).unwrap());
762 }
763
764 #[test]
765 fn is_safe_path_with_dotdot_nonexistent() {
766 let path = Path::new("some/../../../etc/passwd");
767 assert!(!is_safe_path(path).unwrap());
768 }
769
770 #[test]
771 fn is_safe_path_with_dotdot_existing_is_now_rejected() {
772 let tmp = tempdir().unwrap();
787 let safe = tmp.path().join("a");
788 fs::create_dir_all(&safe).unwrap();
789 let dotdot_path = safe.join("..");
790 assert!(!is_safe_path(&dotdot_path).unwrap());
791 assert!(is_safe_path(&dotdot_path.canonicalize().unwrap()).unwrap());
794 }
795
796 #[test]
803 fn is_safe_path_existing_traversal_to_real_file_is_rejected() {
804 let existing_via_traversal = Path::new("../etc");
812 if existing_via_traversal.exists() {
817 assert!(!is_safe_path(existing_via_traversal).unwrap());
818 }
819 let tmp = tempdir().unwrap();
825 let real_dir = tmp.path().join("a");
826 fs::create_dir_all(real_dir.join("subdir")).unwrap();
827 let traversal_to_real_dir = real_dir.join("subdir").join("..");
828 assert!(traversal_to_real_dir.exists());
829 assert!(!is_safe_path(&traversal_to_real_dir).unwrap());
830 }
831
832 #[test]
833 fn is_safe_path_rejects_literal_dotdot_in_filename_false_positive_check() {
834 let tmp = tempdir().unwrap();
840 let odd_name = tmp.path().join("notes..final.md");
841 fs::write(&odd_name, "content").unwrap();
842 assert!(is_safe_path(&odd_name).unwrap());
843 }
844
845 #[test]
846 fn is_safe_path_absolute_existing() {
847 let tmp = tempdir().unwrap();
848 let file = tmp.path().join("abs.txt");
849 fs::write(&file, "data").unwrap();
850 assert!(is_safe_path(&file).unwrap());
852 }
853
854 #[test]
860 fn is_path_within_root_accepts_direct_child() {
861 let tmp = tempdir().unwrap();
862 let child = tmp.path().join("content");
863 fs::create_dir_all(&child).unwrap();
864 assert!(is_path_within_root(&child, tmp.path()).unwrap());
865 }
866
867 #[test]
868 fn is_path_within_root_accepts_root_itself() {
869 let tmp = tempdir().unwrap();
870 assert!(is_path_within_root(tmp.path(), tmp.path()).unwrap());
871 }
872
873 #[test]
874 fn is_path_within_root_accepts_deeply_nested_child() {
875 let tmp = tempdir().unwrap();
876 let nested = tmp.path().join("a").join("b").join("c");
877 fs::create_dir_all(&nested).unwrap();
878 assert!(is_path_within_root(&nested, tmp.path()).unwrap());
879 }
880
881 #[test]
882 fn is_path_within_root_rejects_sibling_directory() {
883 let tmp = tempdir().unwrap();
884 let root = tmp.path().join("root");
885 let sibling = tmp.path().join("sibling");
886 fs::create_dir_all(&root).unwrap();
887 fs::create_dir_all(&sibling).unwrap();
888 assert!(!is_path_within_root(&sibling, &root).unwrap());
889 }
890
891 #[cfg(unix)]
892 #[test]
893 fn is_path_within_root_rejects_symlink_escape() {
894 use std::os::unix::fs::symlink;
900
901 let tmp = tempdir().unwrap();
902 let root = tmp.path().join("root");
903 let outside = tmp.path().join("outside");
904 fs::create_dir_all(&root).unwrap();
905 fs::create_dir_all(&outside).unwrap();
906
907 let escape_link = root.join("content");
908 symlink(&outside, &escape_link).unwrap();
909
910 assert!(
911 !is_path_within_root(&escape_link, &root).unwrap(),
912 "a symlink pointing outside root must not be reported as contained"
913 );
914 }
915
916 #[test]
917 fn is_path_within_root_errors_on_nonexistent_path() {
918 let tmp = tempdir().unwrap();
919 let missing = tmp.path().join("does-not-exist-yet");
920 let err = is_path_within_root(&missing, tmp.path()).unwrap_err();
921 assert!(
922 matches!(&err, SsgError::Io { path, .. } if *path == missing),
923 "{err:?}"
924 );
925 }
926
927 #[test]
928 fn is_path_within_root_errors_on_nonexistent_root() {
929 let tmp = tempdir().unwrap();
930 let existing = tmp.path().join("child");
931 fs::create_dir_all(&existing).unwrap();
932 let missing_root = tmp.path().join("no-such-root");
933 let err = is_path_within_root(&existing, &missing_root).unwrap_err();
934 assert!(
935 matches!(&err, SsgError::Io { path, .. } if *path == missing_root),
936 "{err:?}"
937 );
938 }
939
940 #[test]
941 fn verify_file_safety_valid_file() {
942 let tmp = tempdir().unwrap();
943 let file = tmp.path().join("ok.txt");
944 fs::write(&file, "small file").unwrap();
945
946 assert!(verify_file_safety(&file).is_ok());
947 }
948
949 #[test]
950 fn verify_file_safety_nonexistent() {
951 let tmp = tempdir().unwrap();
952 let missing = tmp.path().join("nope.txt");
953
954 assert!(verify_file_safety(&missing).is_err());
956 }
957
958 #[test]
959 fn verify_file_safety_directory() {
960 let tmp = tempdir().unwrap();
961 assert!(verify_file_safety(tmp.path()).is_ok());
963 }
964
965 #[test]
966 fn collect_files_recursive_finds_all() {
967 let tmp = tempdir().unwrap();
968 let sub = tmp.path().join("sub");
969 fs::create_dir_all(&sub).unwrap();
970 fs::write(tmp.path().join("a.md"), "").unwrap();
971 fs::write(sub.join("b.md"), "").unwrap();
972 fs::write(sub.join("c.txt"), "").unwrap();
973
974 let mut files = Vec::new();
975 collect_files_recursive(tmp.path(), &mut files).unwrap();
976
977 assert_eq!(files.len(), 3);
978 }
979
980 #[test]
981 fn collect_files_recursive_empty_dir() {
982 let tmp = tempdir().unwrap();
983
984 let mut files = Vec::new();
985 collect_files_recursive(tmp.path(), &mut files).unwrap();
986
987 assert!(files.is_empty(), "{:?}", files);
988 }
989
990 #[test]
991 fn collect_files_recursive_only_files_not_dirs() {
992 let tmp = tempdir().unwrap();
993 let sub = tmp.path().join("subdir");
994 fs::create_dir_all(&sub).unwrap();
995 fs::write(sub.join("only.txt"), "data").unwrap();
996
997 let mut files = Vec::new();
998 collect_files_recursive(tmp.path(), &mut files).unwrap();
999
1000 assert_eq!(files.len(), 1);
1001 assert!(files[0].ends_with("only.txt"));
1002 }
1003
1004 #[test]
1005 fn collect_files_recursive_nonexistent_dir_returns_error() {
1006 let tmp = tempdir().unwrap();
1013 let missing = tmp.path().join("does-not-exist");
1014
1015 let mut files = Vec::new();
1016 let result = collect_files_recursive(&missing, &mut files);
1017 assert!(result.is_err());
1018 }
1019
1020 #[test]
1021 fn verify_and_copy_files_end_to_end() {
1022 let src = tempdir().unwrap();
1023 let dst = tempdir().unwrap();
1024 let target = dst.path().join("output");
1025 fs::write(src.path().join("page.html"), "<h1>Hi</h1>").unwrap();
1026
1027 verify_and_copy_files(src.path(), &target).unwrap();
1028
1029 assert_eq!(
1030 fs::read_to_string(target.join("page.html")).unwrap(),
1031 "<h1>Hi</h1>"
1032 );
1033 }
1034
1035 #[test]
1036 fn copy_dir_with_progress_smoke() {
1037 let src = tempdir().unwrap();
1038 let dst = tempdir().unwrap();
1039 fs::write(src.path().join("f.txt"), "data").unwrap();
1040
1041 copy_dir_with_progress(src.path(), &dst.path().join("out")).unwrap();
1043 }
1044
1045 #[test]
1046 fn copy_dir_with_progress_nonexistent_src() {
1047 let tmp = tempdir().unwrap();
1048 let fake = tmp.path().join("missing");
1049
1050 let result = copy_dir_with_progress(&fake, tmp.path());
1051 assert!(result.is_err());
1052 }
1053
1054 #[test]
1055 fn copy_dir_with_progress_src_is_file_fails_at_read_dir() {
1056 let tmp = tempdir().unwrap();
1063 let file_src = tmp.path().join("plain.txt");
1064 fs::write(&file_src, "x").unwrap();
1065
1066 let result = copy_dir_with_progress(&file_src, &tmp.path().join("dst"));
1067 assert!(result.is_err());
1068 }
1069
1070 #[test]
1075 fn verify_and_copy_files_rejects_traversal_path() {
1076 let dst = tempdir().unwrap();
1077 let err = verify_and_copy_files(
1079 Path::new("../nonexistent-ssg-traversal"),
1080 dst.path(),
1081 )
1082 .unwrap_err();
1083 assert!(
1084 err.to_string().contains("directory traversal"),
1085 "got: {err}"
1086 );
1087 }
1088
1089 #[test]
1090 fn verify_and_copy_files_missing_src_is_validation_error() {
1091 let tmp = tempdir().unwrap();
1092 let err = verify_and_copy_files(
1093 &tmp.path().join("no-such-src"),
1094 &tmp.path().join("dst"),
1095 )
1096 .unwrap_err();
1097 assert!(err.to_string().contains("does not exist"), "got: {err}");
1098 }
1099
1100 #[test]
1101 fn verify_and_copy_files_rejects_oversized_source_file() {
1102 let tmp = tempdir().unwrap();
1105 let big = tmp.path().join("big.bin");
1106 let f = fs::File::create(&big).unwrap();
1107 f.set_len(10 * 1024 * 1024 + 1).unwrap();
1108
1109 let err =
1110 verify_and_copy_files(&big, &tmp.path().join("dst")).unwrap_err();
1111 assert!(
1112 err.to_string().contains("exceeds maximum allowed size"),
1113 "got: {err}"
1114 );
1115 }
1116
1117 #[test]
1118 fn verify_and_copy_files_dst_under_file_fails() {
1119 let src = tempdir().unwrap();
1120 let tmp = tempdir().unwrap();
1121 fs::write(src.path().join("a.txt"), "x").unwrap();
1122 let blocker = tmp.path().join("blocker");
1123 fs::write(&blocker, "file").unwrap();
1124
1125 let result = verify_and_copy_files(src.path(), &blocker.join("dst"));
1126 assert!(result.is_err());
1127 }
1128
1129 #[test]
1130 fn verify_and_copy_files_small_file_src_fails_in_copy_stage() {
1131 let tmp = tempdir().unwrap();
1134 let file_src = tmp.path().join("plain.txt");
1135 fs::write(&file_src, "small").unwrap();
1136
1137 let result = verify_and_copy_files(&file_src, &tmp.path().join("dst"));
1138 assert!(result.is_err());
1139 }
1140
1141 #[test]
1146 fn copy_dir_all_uses_parallel_path_at_threshold() {
1147 let src = tempdir().unwrap();
1148 let dst = tempdir().unwrap();
1149 for i in 0..PARALLEL_THRESHOLD {
1150 fs::write(src.path().join(format!("f{i}.txt")), format!("{i}"))
1151 .unwrap();
1152 }
1153
1154 copy_dir_all(src.path(), dst.path()).unwrap();
1155 for i in 0..PARALLEL_THRESHOLD {
1156 assert_eq!(
1157 fs::read_to_string(dst.path().join(format!("f{i}.txt")))
1158 .unwrap(),
1159 format!("{i}")
1160 );
1161 }
1162 }
1163
1164 #[cfg(unix)]
1165 #[test]
1166 fn copy_dir_all_sequential_rejects_symlink() {
1167 let src = tempdir().unwrap();
1168 let dst = tempdir().unwrap();
1169 fs::write(src.path().join("ok.txt"), "x").unwrap();
1170 std::os::unix::fs::symlink(
1171 src.path().join("ok.txt"),
1172 src.path().join("link.txt"),
1173 )
1174 .unwrap();
1175
1176 let err = copy_dir_all(src.path(), dst.path()).unwrap_err();
1177 assert!(err.to_string().contains("symlink"), "got: {err}");
1178 }
1179
1180 #[cfg(unix)]
1181 #[test]
1182 fn copy_dir_all_parallel_rejects_symlink() {
1183 let src = tempdir().unwrap();
1184 let dst = tempdir().unwrap();
1185 for i in 0..PARALLEL_THRESHOLD {
1186 fs::write(src.path().join(format!("f{i}.txt")), "x").unwrap();
1187 }
1188 std::os::unix::fs::symlink(
1189 src.path().join("f0.txt"),
1190 src.path().join("link.txt"),
1191 )
1192 .unwrap();
1193
1194 let err = copy_dir_all(src.path(), dst.path()).unwrap_err();
1195 assert!(err.to_string().contains("symlink"), "got: {err}");
1196 }
1197
1198 #[test]
1199 fn copy_dir_all_subdir_blocked_by_file_in_dst() {
1200 let src = tempdir().unwrap();
1201 let dst = tempdir().unwrap();
1202 fs::create_dir_all(src.path().join("sub")).unwrap();
1203 fs::write(src.path().join("sub/x.txt"), "x").unwrap();
1204 fs::write(dst.path().join("sub"), "blocking file").unwrap();
1206
1207 let result = copy_dir_all(src.path(), dst.path());
1208 assert!(result.is_err());
1209 }
1210
1211 #[test]
1212 fn copy_dir_all_top_level_dst_under_file_fails() {
1213 let src = tempdir().unwrap();
1219 let tmp = tempdir().unwrap();
1220 fs::write(src.path().join("a.txt"), "x").unwrap();
1221 let blocker = tmp.path().join("blocker");
1222 fs::write(&blocker, "file").unwrap();
1223
1224 let result = copy_dir_all(src.path(), &blocker.join("dst"));
1225 assert!(result.is_err());
1226 }
1227
1228 #[test]
1229 fn copy_dir_all_file_copy_onto_directory_fails() {
1230 let src = tempdir().unwrap();
1238 let dst = tempdir().unwrap();
1239 fs::write(src.path().join("x.txt"), "x").unwrap();
1240 fs::create_dir_all(dst.path().join("x.txt")).unwrap();
1241
1242 let result = copy_dir_all(src.path(), dst.path());
1243 assert!(result.is_err());
1244 }
1245
1246 #[test]
1251 fn verify_and_copy_files_async_happy_path_nested() {
1252 let src = tempdir().unwrap();
1253 let dst = tempdir().unwrap();
1254 fs::create_dir_all(src.path().join("sub")).unwrap();
1255 fs::write(src.path().join("root.txt"), "r").unwrap();
1256 fs::write(src.path().join("sub/leaf.txt"), "l").unwrap();
1257
1258 verify_and_copy_files_async(src.path(), dst.path()).unwrap();
1259 assert_eq!(
1260 fs::read_to_string(dst.path().join("sub/leaf.txt")).unwrap(),
1261 "l"
1262 );
1263 }
1264
1265 #[test]
1266 fn verify_and_copy_files_async_missing_src_is_validation_error() {
1267 let tmp = tempdir().unwrap();
1268 let err = verify_and_copy_files_async(
1269 &tmp.path().join("gone"),
1270 &tmp.path().join("dst"),
1271 )
1272 .unwrap_err();
1273 assert!(err.to_string().contains("does not exist"), "got: {err}");
1274 }
1275
1276 #[test]
1277 fn verify_and_copy_files_async_src_file_fails_at_read_dir() {
1278 let tmp = tempdir().unwrap();
1279 let file_src = tmp.path().join("plain.txt");
1280 fs::write(&file_src, "x").unwrap();
1281
1282 let result =
1283 verify_and_copy_files_async(&file_src, &tmp.path().join("dst"));
1284 assert!(result.is_err());
1285 }
1286
1287 #[cfg(unix)]
1288 #[test]
1289 fn copy_directory_recursive_rejects_symlink_entry() {
1290 let src = tempdir().unwrap();
1291 let dst = tempdir().unwrap();
1292 fs::write(src.path().join("real.txt"), "x").unwrap();
1293 std::os::unix::fs::symlink(
1294 src.path().join("real.txt"),
1295 src.path().join("link.txt"),
1296 )
1297 .unwrap();
1298
1299 let err = copy_directory_recursive(src.path(), dst.path()).unwrap_err();
1300 assert!(err.to_string().contains("symlink"), "got: {err}");
1301 }
1302
1303 #[test]
1304 fn copy_directory_recursive_subdir_blocked_by_file() {
1305 let src = tempdir().unwrap();
1306 let dst = tempdir().unwrap();
1307 fs::create_dir_all(src.path().join("sub")).unwrap();
1308 fs::write(src.path().join("sub/x.txt"), "x").unwrap();
1309 fs::write(dst.path().join("sub"), "blocking file").unwrap();
1310
1311 let result = copy_directory_recursive(src.path(), dst.path());
1312 assert!(result.is_err());
1313 }
1314
1315 #[test]
1316 fn copy_directory_recursive_copy_onto_directory_fails() {
1317 let src = tempdir().unwrap();
1318 let dst = tempdir().unwrap();
1319 fs::write(src.path().join("x.txt"), "x").unwrap();
1320 fs::create_dir_all(dst.path().join("x.txt")).unwrap();
1322
1323 let result = copy_directory_recursive(src.path(), dst.path());
1324 assert!(result.is_err());
1325 }
1326
1327 #[test]
1332 fn copy_dir_all_async_nested_happy_path() {
1333 let src = tempdir().unwrap();
1334 let dst = tempdir().unwrap();
1335 fs::create_dir_all(src.path().join("deep/deeper")).unwrap();
1336 fs::write(src.path().join("deep/deeper/f.txt"), "d").unwrap();
1337
1338 copy_dir_all_async(src.path(), dst.path()).unwrap();
1339 assert_eq!(
1340 fs::read_to_string(dst.path().join("deep/deeper/f.txt")).unwrap(),
1341 "d"
1342 );
1343 }
1344
1345 #[test]
1346 fn copy_dir_all_async_dst_under_file_fails() {
1347 let src = tempdir().unwrap();
1348 let tmp = tempdir().unwrap();
1349 let blocker = tmp.path().join("blocker");
1350 fs::write(&blocker, "file").unwrap();
1351
1352 let result = copy_dir_all_async(src.path(), &blocker.join("dst"));
1353 assert!(result.is_err());
1354 }
1355
1356 #[test]
1357 fn copy_dir_all_async_src_file_fails_at_read_dir() {
1358 let tmp = tempdir().unwrap();
1359 let file_src = tmp.path().join("plain.txt");
1360 fs::write(&file_src, "x").unwrap();
1361
1362 let result = copy_dir_all_async(&file_src, &tmp.path().join("dst"));
1363 assert!(result.is_err());
1364 }
1365
1366 #[cfg(unix)]
1367 #[test]
1368 fn copy_dir_all_async_rejects_symlink() {
1369 let src = tempdir().unwrap();
1370 let dst = tempdir().unwrap();
1371 fs::write(src.path().join("real.txt"), "x").unwrap();
1372 std::os::unix::fs::symlink(
1373 src.path().join("real.txt"),
1374 src.path().join("link.txt"),
1375 )
1376 .unwrap();
1377
1378 let err = copy_dir_all_async(src.path(), dst.path()).unwrap_err();
1379 assert!(err.to_string().contains("symlink"), "got: {err}");
1380 }
1381
1382 #[test]
1383 fn copy_dir_all_async_subdir_blocked_by_file() {
1384 let src = tempdir().unwrap();
1385 let dst = tempdir().unwrap();
1386 fs::create_dir_all(src.path().join("sub")).unwrap();
1387 fs::write(src.path().join("sub/x.txt"), "x").unwrap();
1388 fs::write(dst.path().join("sub"), "blocking file").unwrap();
1389
1390 let result = copy_dir_all_async(src.path(), dst.path());
1391 assert!(result.is_err());
1392 }
1393
1394 #[test]
1395 fn copy_dir_all_async_copy_onto_directory_fails() {
1396 let src = tempdir().unwrap();
1397 let dst = tempdir().unwrap();
1398 fs::write(src.path().join("x.txt"), "x").unwrap();
1399 fs::create_dir_all(dst.path().join("x.txt")).unwrap();
1400
1401 let result = copy_dir_all_async(src.path(), dst.path());
1402 assert!(result.is_err());
1403 }
1404
1405 #[test]
1410 fn copy_dir_with_progress_subdir_blocked_by_file() {
1411 let src = tempdir().unwrap();
1412 let dst = tempdir().unwrap();
1413 fs::create_dir_all(src.path().join("sub")).unwrap();
1414 fs::write(src.path().join("sub/x.txt"), "x").unwrap();
1415 fs::write(dst.path().join("sub"), "blocking file").unwrap();
1416
1417 let result = copy_dir_with_progress(src.path(), dst.path());
1418 assert!(result.is_err());
1419 }
1420
1421 #[test]
1422 fn copy_dir_with_progress_copy_onto_directory_fails() {
1423 let src = tempdir().unwrap();
1424 let dst = tempdir().unwrap();
1425 fs::write(src.path().join("x.txt"), "x").unwrap();
1426 fs::create_dir_all(dst.path().join("x.txt")).unwrap();
1427
1428 let result = copy_dir_with_progress(src.path(), dst.path());
1429 assert!(result.is_err());
1430 }
1431}