use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{PathErrorExt, SsgError};
use rayon::prelude::*;
use crate::MAX_DIR_DEPTH;
pub(crate) const PARALLEL_THRESHOLD: usize = 16;
pub fn verify_and_copy_files(src: &Path, dst: &Path) -> Result<(), SsgError> {
if !is_safe_path(src)? {
return Err(SsgError::PathTraversal {
path: src.to_path_buf(),
});
}
if !src.exists() {
return Err(SsgError::Validation {
field: "src".to_string(),
message: format!(
"Source directory does not exist: {}",
src.display()
),
});
}
if src.is_file() {
verify_file_safety(src)?;
}
fs::create_dir_all(dst).with_path(dst)?;
copy_dir_all(src, dst)?;
Ok(())
}
pub fn verify_and_copy_files_async(
src: &Path,
dst: &Path,
) -> Result<(), SsgError> {
if !src.exists() {
return Err(SsgError::Validation {
field: "src".to_string(),
message: format!(
"Source directory does not exist: {}",
src.display()
),
});
}
fs::create_dir_all(dst).with_path(dst)?;
copy_directory_recursive(src, dst)
}
fn copy_directory_recursive(src: &Path, dst: &Path) -> Result<(), SsgError> {
let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
while let Some((src_dir, dst_dir, depth)) = stack.pop() {
if depth >= MAX_DIR_DEPTH {
return Err(SsgError::Validation {
field: "directory_depth".to_string(),
message: format!(
"Directory nesting exceeds maximum depth of {}: {}",
MAX_DIR_DEPTH,
src_dir.display()
),
});
}
for entry in fs::read_dir(&src_dir).with_path(&src_dir)? {
let entry = entry.with_path(&src_dir)?;
copy_entry(&entry, &dst_dir, depth, &mut stack)?;
}
}
Ok(())
}
fn copy_entry(
entry: &fs::DirEntry,
dst_dir: &Path,
depth: usize,
stack: &mut Vec<(PathBuf, PathBuf, usize)>,
) -> Result<(), SsgError> {
let src_path = entry.path();
let dst_path = dst_dir.join(entry.file_name());
if src_path.is_dir() {
fs::create_dir_all(&dst_path).with_path(&dst_path)?;
stack.push((src_path, dst_path, depth + 1));
} else {
verify_file_safety(&src_path)?;
_ = fs::copy(&src_path, &dst_path).with_path(&dst_path)?;
}
Ok(())
}
pub fn copy_dir_with_progress(src: &Path, dst: &Path) -> Result<(), SsgError> {
if !src.exists() {
return Err(SsgError::Validation {
field: "src".to_string(),
message: format!(
"Source directory does not exist: {}",
src.display()
),
});
}
fs::create_dir_all(dst).with_path(dst)?;
let mut file_count: u64 = 0;
let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
while let Some((src_dir, dst_dir, depth)) = stack.pop() {
if depth >= MAX_DIR_DEPTH {
return Err(SsgError::Validation {
field: "directory_depth".to_string(),
message: format!(
"Directory nesting exceeds maximum depth of {}: {}",
MAX_DIR_DEPTH,
src_dir.display()
),
});
}
let entries: Vec<_> = fs::read_dir(&src_dir)
.with_path(&src_dir)?
.collect::<std::io::Result<Vec<_>>>()
.with_path(&src_dir)?;
for entry in &entries {
let src_path = entry.path();
let dst_path = dst_dir.join(entry.file_name());
if src_path.is_dir() {
fs::create_dir_all(&dst_path).with_path(&dst_path)?;
stack.push((src_path, dst_path, depth + 1));
} else {
_ = fs::copy(&src_path, &dst_path).with_path(&dst_path)?;
}
file_count += 1;
}
}
eprintln!("Copied {file_count} files");
Ok(())
}
pub fn is_safe_path(path: &Path) -> Result<bool, SsgError> {
use std::path::Component;
if path.components().any(|c| c == Component::ParentDir) {
return Ok(false);
}
if !path.exists() {
return Ok(true); }
let _canonical = path.canonicalize().with_path(path)?;
Ok(true)
}
pub fn is_path_within_root(path: &Path, root: &Path) -> Result<bool, SsgError> {
let canonical_path = path.canonicalize().with_path(path)?;
let canonical_root = root.canonicalize().with_path(root)?;
Ok(canonical_path.starts_with(&canonical_root))
}
pub fn verify_file_safety(path: &Path) -> Result<(), SsgError> {
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
let symlink_metadata = path.symlink_metadata().with_path(path)?;
if symlink_metadata.file_type().is_symlink() {
return Err(SsgError::SymlinkForbidden {
path: path.to_path_buf(),
});
}
if symlink_metadata.file_type().is_file()
&& symlink_metadata.len() > MAX_FILE_SIZE
{
return Err(SsgError::Validation {
field: "file_size".to_string(),
message: format!(
"File exceeds maximum allowed size of {} bytes: {}",
MAX_FILE_SIZE,
path.display()
),
});
}
Ok(())
}
pub fn collect_files_recursive(
dir: &Path,
files: &mut Vec<PathBuf>,
) -> Result<(), SsgError> {
let mut stack = vec![(dir.to_path_buf(), 0usize)];
while let Some((current_dir, depth)) = stack.pop() {
if depth >= MAX_DIR_DEPTH {
return Err(SsgError::Validation {
field: "directory_depth".to_string(),
message: format!(
"Directory nesting exceeds maximum depth of {}: {}",
MAX_DIR_DEPTH,
current_dir.display()
),
});
}
for entry in fs::read_dir(¤t_dir).with_path(¤t_dir)? {
let path = entry.with_path(¤t_dir)?.path();
if path.is_dir() {
stack.push((path, depth + 1));
} else {
files.push(path);
}
}
}
Ok(())
}
pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), SsgError> {
fs::create_dir_all(dst).with_path(dst)?;
let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
while let Some((src_dir, dst_dir, depth)) = stack.pop() {
if depth >= MAX_DIR_DEPTH {
return Err(SsgError::Validation {
field: "directory_depth".to_string(),
message: format!(
"Directory nesting exceeds maximum depth of {}: {}",
MAX_DIR_DEPTH,
src_dir.display()
),
});
}
let entries: Vec<_> = fs::read_dir(&src_dir)
.with_path(&src_dir)?
.collect::<std::io::Result<Vec<_>>>()
.with_path(&src_dir)?;
let (files, subdirs) = partition_entries(&entries, &dst_dir);
copy_files_maybe_parallel(&files, &dst_dir)?;
for (sub_src, sub_dst) in subdirs {
fs::create_dir_all(&sub_dst).with_path(&sub_dst)?;
stack.push((sub_src, sub_dst, depth + 1));
}
}
Ok(())
}
fn partition_entries<'a>(
entries: &'a [fs::DirEntry],
dst_dir: &Path,
) -> (Vec<&'a fs::DirEntry>, Vec<(PathBuf, PathBuf)>) {
let mut subdirs = Vec::new();
let files: Vec<_> = entries
.iter()
.filter(|entry| {
let path = entry.path();
if path.is_dir() {
subdirs.push((path, dst_dir.join(entry.file_name())));
false
} else {
true
}
})
.collect();
(files, subdirs)
}
fn copy_files_maybe_parallel(
files: &[&fs::DirEntry],
dst_dir: &Path,
) -> Result<(), SsgError> {
let copy_file = |entry: &&fs::DirEntry| -> Result<(), SsgError> {
let src_path = entry.path();
let dst_path = dst_dir.join(entry.file_name());
verify_file_safety(&src_path)?;
_ = fs::copy(&src_path, &dst_path).with_path(&dst_path)?;
Ok(())
};
if files.len() >= PARALLEL_THRESHOLD {
files.par_iter().try_for_each(copy_file)?;
} else {
files.iter().try_for_each(copy_file)?;
}
Ok(())
}
pub fn copy_dir_all_async(src: &Path, dst: &Path) -> Result<(), SsgError> {
internal_copy_dir_async(src, dst)
}
fn internal_copy_dir_async(src: &Path, dst: &Path) -> Result<(), SsgError> {
fs::create_dir_all(dst).with_path(dst)?;
let mut stack = vec![(src.to_path_buf(), dst.to_path_buf(), 0usize)];
while let Some((src_path, dst_path, depth)) = stack.pop() {
if depth >= MAX_DIR_DEPTH {
return Err(SsgError::Validation {
field: "directory_depth".to_string(),
message: format!(
"Directory nesting exceeds maximum depth of {}: {}",
MAX_DIR_DEPTH,
src_path.display()
),
});
}
for entry in fs::read_dir(&src_path).with_path(&src_path)? {
let entry = entry.with_path(&src_path)?;
let src_entry = entry.path();
let dst_entry = dst_path.join(entry.file_name());
if src_entry.is_dir() {
fs::create_dir_all(&dst_entry).with_path(&dst_entry)?;
stack.push((src_entry, dst_entry, depth + 1));
} else {
verify_file_safety(&src_entry)?;
_ = fs::copy(&src_entry, &dst_entry).with_path(&dst_entry)?;
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn copy_dir_all_copies_files() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::write(src.path().join("a.txt"), "hello").unwrap();
fs::write(src.path().join("b.txt"), "world").unwrap();
copy_dir_all(src.path(), dst.path()).unwrap();
assert_eq!(
fs::read_to_string(dst.path().join("a.txt")).unwrap(),
"hello"
);
assert_eq!(
fs::read_to_string(dst.path().join("b.txt")).unwrap(),
"world"
);
}
#[test]
fn copy_dir_all_nested_preserves_structure() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
let nested = src.path().join("sub").join("deep");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("file.txt"), "nested content").unwrap();
fs::write(src.path().join("root.txt"), "root").unwrap();
copy_dir_all(src.path(), dst.path()).unwrap();
assert_eq!(
fs::read_to_string(dst.path().join("sub/deep/file.txt")).unwrap(),
"nested content"
);
assert_eq!(
fs::read_to_string(dst.path().join("root.txt")).unwrap(),
"root"
);
}
#[test]
fn copy_dir_all_nonexistent_src_returns_error() {
let dst = tempdir().unwrap();
let fake_src = dst.path().join("does_not_exist");
let result = copy_dir_all(&fake_src, dst.path());
assert!(result.is_err());
}
#[test]
fn is_safe_path_normal_relative() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("safe.txt");
fs::write(&file, "ok").unwrap();
assert!(is_safe_path(&file).unwrap());
}
#[test]
fn is_safe_path_with_dotdot_nonexistent() {
let path = Path::new("some/../../../etc/passwd");
assert!(!is_safe_path(path).unwrap());
}
#[test]
fn is_safe_path_with_dotdot_existing_is_now_rejected() {
let tmp = tempdir().unwrap();
let safe = tmp.path().join("a");
fs::create_dir_all(&safe).unwrap();
let dotdot_path = safe.join("..");
assert!(!is_safe_path(&dotdot_path).unwrap());
assert!(is_safe_path(&dotdot_path.canonicalize().unwrap()).unwrap());
}
#[test]
fn is_safe_path_existing_traversal_to_real_file_is_rejected() {
let existing_via_traversal = Path::new("../etc");
if existing_via_traversal.exists() {
assert!(!is_safe_path(existing_via_traversal).unwrap());
}
let tmp = tempdir().unwrap();
let real_dir = tmp.path().join("a");
fs::create_dir_all(real_dir.join("subdir")).unwrap();
let traversal_to_real_dir = real_dir.join("subdir").join("..");
assert!(traversal_to_real_dir.exists());
assert!(!is_safe_path(&traversal_to_real_dir).unwrap());
}
#[test]
fn is_safe_path_rejects_literal_dotdot_in_filename_false_positive_check() {
let tmp = tempdir().unwrap();
let odd_name = tmp.path().join("notes..final.md");
fs::write(&odd_name, "content").unwrap();
assert!(is_safe_path(&odd_name).unwrap());
}
#[test]
fn is_safe_path_absolute_existing() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("abs.txt");
fs::write(&file, "data").unwrap();
assert!(is_safe_path(&file).unwrap());
}
#[test]
fn is_path_within_root_accepts_direct_child() {
let tmp = tempdir().unwrap();
let child = tmp.path().join("content");
fs::create_dir_all(&child).unwrap();
assert!(is_path_within_root(&child, tmp.path()).unwrap());
}
#[test]
fn is_path_within_root_accepts_root_itself() {
let tmp = tempdir().unwrap();
assert!(is_path_within_root(tmp.path(), tmp.path()).unwrap());
}
#[test]
fn is_path_within_root_accepts_deeply_nested_child() {
let tmp = tempdir().unwrap();
let nested = tmp.path().join("a").join("b").join("c");
fs::create_dir_all(&nested).unwrap();
assert!(is_path_within_root(&nested, tmp.path()).unwrap());
}
#[test]
fn is_path_within_root_rejects_sibling_directory() {
let tmp = tempdir().unwrap();
let root = tmp.path().join("root");
let sibling = tmp.path().join("sibling");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&sibling).unwrap();
assert!(!is_path_within_root(&sibling, &root).unwrap());
}
#[cfg(unix)]
#[test]
fn is_path_within_root_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let tmp = tempdir().unwrap();
let root = tmp.path().join("root");
let outside = tmp.path().join("outside");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&outside).unwrap();
let escape_link = root.join("content");
symlink(&outside, &escape_link).unwrap();
assert!(
!is_path_within_root(&escape_link, &root).unwrap(),
"a symlink pointing outside root must not be reported as contained"
);
}
#[test]
fn is_path_within_root_errors_on_nonexistent_path() {
let tmp = tempdir().unwrap();
let missing = tmp.path().join("does-not-exist-yet");
assert!(is_path_within_root(&missing, tmp.path()).is_err());
}
#[test]
fn is_path_within_root_errors_on_nonexistent_root() {
let tmp = tempdir().unwrap();
let existing = tmp.path().join("child");
fs::create_dir_all(&existing).unwrap();
let missing_root = tmp.path().join("no-such-root");
assert!(is_path_within_root(&existing, &missing_root).is_err());
}
#[test]
fn verify_file_safety_valid_file() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("ok.txt");
fs::write(&file, "small file").unwrap();
assert!(verify_file_safety(&file).is_ok());
}
#[test]
fn verify_file_safety_nonexistent() {
let tmp = tempdir().unwrap();
let missing = tmp.path().join("nope.txt");
assert!(verify_file_safety(&missing).is_err());
}
#[test]
fn verify_file_safety_directory() {
let tmp = tempdir().unwrap();
assert!(verify_file_safety(tmp.path()).is_ok());
}
#[test]
fn collect_files_recursive_finds_all() {
let tmp = tempdir().unwrap();
let sub = tmp.path().join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(tmp.path().join("a.md"), "").unwrap();
fs::write(sub.join("b.md"), "").unwrap();
fs::write(sub.join("c.txt"), "").unwrap();
let mut files = Vec::new();
collect_files_recursive(tmp.path(), &mut files).unwrap();
assert_eq!(files.len(), 3);
}
#[test]
fn collect_files_recursive_empty_dir() {
let tmp = tempdir().unwrap();
let mut files = Vec::new();
collect_files_recursive(tmp.path(), &mut files).unwrap();
assert!(files.is_empty());
}
#[test]
fn collect_files_recursive_only_files_not_dirs() {
let tmp = tempdir().unwrap();
let sub = tmp.path().join("subdir");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("only.txt"), "data").unwrap();
let mut files = Vec::new();
collect_files_recursive(tmp.path(), &mut files).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("only.txt"));
}
#[test]
fn collect_files_recursive_nonexistent_dir_returns_error() {
let tmp = tempdir().unwrap();
let missing = tmp.path().join("does-not-exist");
let mut files = Vec::new();
let result = collect_files_recursive(&missing, &mut files);
assert!(result.is_err());
}
#[test]
fn verify_and_copy_files_end_to_end() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
let target = dst.path().join("output");
fs::write(src.path().join("page.html"), "<h1>Hi</h1>").unwrap();
verify_and_copy_files(src.path(), &target).unwrap();
assert_eq!(
fs::read_to_string(target.join("page.html")).unwrap(),
"<h1>Hi</h1>"
);
}
#[test]
fn copy_dir_with_progress_smoke() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::write(src.path().join("f.txt"), "data").unwrap();
copy_dir_with_progress(src.path(), &dst.path().join("out")).unwrap();
}
#[test]
fn copy_dir_with_progress_nonexistent_src() {
let tmp = tempdir().unwrap();
let fake = tmp.path().join("missing");
let result = copy_dir_with_progress(&fake, tmp.path());
assert!(result.is_err());
}
#[test]
fn copy_dir_with_progress_src_is_file_fails_at_read_dir() {
let tmp = tempdir().unwrap();
let file_src = tmp.path().join("plain.txt");
fs::write(&file_src, "x").unwrap();
let result = copy_dir_with_progress(&file_src, &tmp.path().join("dst"));
assert!(result.is_err());
}
#[test]
fn verify_and_copy_files_rejects_traversal_path() {
let dst = tempdir().unwrap();
let err = verify_and_copy_files(
Path::new("../nonexistent-ssg-traversal"),
dst.path(),
)
.unwrap_err();
assert!(
err.to_string().contains("directory traversal"),
"got: {err}"
);
}
#[test]
fn verify_and_copy_files_missing_src_is_validation_error() {
let tmp = tempdir().unwrap();
let err = verify_and_copy_files(
&tmp.path().join("no-such-src"),
&tmp.path().join("dst"),
)
.unwrap_err();
assert!(err.to_string().contains("does not exist"), "got: {err}");
}
#[test]
fn verify_and_copy_files_rejects_oversized_source_file() {
let tmp = tempdir().unwrap();
let big = tmp.path().join("big.bin");
let f = fs::File::create(&big).unwrap();
f.set_len(10 * 1024 * 1024 + 1).unwrap();
let err =
verify_and_copy_files(&big, &tmp.path().join("dst")).unwrap_err();
assert!(
err.to_string().contains("exceeds maximum allowed size"),
"got: {err}"
);
}
#[test]
fn verify_and_copy_files_dst_under_file_fails() {
let src = tempdir().unwrap();
let tmp = tempdir().unwrap();
fs::write(src.path().join("a.txt"), "x").unwrap();
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "file").unwrap();
let result = verify_and_copy_files(src.path(), &blocker.join("dst"));
assert!(result.is_err());
}
#[test]
fn verify_and_copy_files_small_file_src_fails_in_copy_stage() {
let tmp = tempdir().unwrap();
let file_src = tmp.path().join("plain.txt");
fs::write(&file_src, "small").unwrap();
let result = verify_and_copy_files(&file_src, &tmp.path().join("dst"));
assert!(result.is_err());
}
#[test]
fn copy_dir_all_uses_parallel_path_at_threshold() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
for i in 0..PARALLEL_THRESHOLD {
fs::write(src.path().join(format!("f{i}.txt")), format!("{i}"))
.unwrap();
}
copy_dir_all(src.path(), dst.path()).unwrap();
for i in 0..PARALLEL_THRESHOLD {
assert_eq!(
fs::read_to_string(dst.path().join(format!("f{i}.txt")))
.unwrap(),
format!("{i}")
);
}
}
#[cfg(unix)]
#[test]
fn copy_dir_all_sequential_rejects_symlink() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::write(src.path().join("ok.txt"), "x").unwrap();
std::os::unix::fs::symlink(
src.path().join("ok.txt"),
src.path().join("link.txt"),
)
.unwrap();
let err = copy_dir_all(src.path(), dst.path()).unwrap_err();
assert!(err.to_string().contains("symlink"), "got: {err}");
}
#[cfg(unix)]
#[test]
fn copy_dir_all_parallel_rejects_symlink() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
for i in 0..PARALLEL_THRESHOLD {
fs::write(src.path().join(format!("f{i}.txt")), "x").unwrap();
}
std::os::unix::fs::symlink(
src.path().join("f0.txt"),
src.path().join("link.txt"),
)
.unwrap();
let err = copy_dir_all(src.path(), dst.path()).unwrap_err();
assert!(err.to_string().contains("symlink"), "got: {err}");
}
#[test]
fn copy_dir_all_subdir_blocked_by_file_in_dst() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::create_dir_all(src.path().join("sub")).unwrap();
fs::write(src.path().join("sub/x.txt"), "x").unwrap();
fs::write(dst.path().join("sub"), "blocking file").unwrap();
let result = copy_dir_all(src.path(), dst.path());
assert!(result.is_err());
}
#[test]
fn copy_dir_all_top_level_dst_under_file_fails() {
let src = tempdir().unwrap();
let tmp = tempdir().unwrap();
fs::write(src.path().join("a.txt"), "x").unwrap();
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "file").unwrap();
let result = copy_dir_all(src.path(), &blocker.join("dst"));
assert!(result.is_err());
}
#[test]
fn copy_dir_all_file_copy_onto_directory_fails() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::write(src.path().join("x.txt"), "x").unwrap();
fs::create_dir_all(dst.path().join("x.txt")).unwrap();
let result = copy_dir_all(src.path(), dst.path());
assert!(result.is_err());
}
#[test]
fn verify_and_copy_files_async_happy_path_nested() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::create_dir_all(src.path().join("sub")).unwrap();
fs::write(src.path().join("root.txt"), "r").unwrap();
fs::write(src.path().join("sub/leaf.txt"), "l").unwrap();
verify_and_copy_files_async(src.path(), dst.path()).unwrap();
assert_eq!(
fs::read_to_string(dst.path().join("sub/leaf.txt")).unwrap(),
"l"
);
}
#[test]
fn verify_and_copy_files_async_missing_src_is_validation_error() {
let tmp = tempdir().unwrap();
let err = verify_and_copy_files_async(
&tmp.path().join("gone"),
&tmp.path().join("dst"),
)
.unwrap_err();
assert!(err.to_string().contains("does not exist"), "got: {err}");
}
#[test]
fn verify_and_copy_files_async_src_file_fails_at_read_dir() {
let tmp = tempdir().unwrap();
let file_src = tmp.path().join("plain.txt");
fs::write(&file_src, "x").unwrap();
let result =
verify_and_copy_files_async(&file_src, &tmp.path().join("dst"));
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn copy_directory_recursive_rejects_symlink_entry() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::write(src.path().join("real.txt"), "x").unwrap();
std::os::unix::fs::symlink(
src.path().join("real.txt"),
src.path().join("link.txt"),
)
.unwrap();
let err = copy_directory_recursive(src.path(), dst.path()).unwrap_err();
assert!(err.to_string().contains("symlink"), "got: {err}");
}
#[test]
fn copy_directory_recursive_subdir_blocked_by_file() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::create_dir_all(src.path().join("sub")).unwrap();
fs::write(src.path().join("sub/x.txt"), "x").unwrap();
fs::write(dst.path().join("sub"), "blocking file").unwrap();
let result = copy_directory_recursive(src.path(), dst.path());
assert!(result.is_err());
}
#[test]
fn copy_directory_recursive_copy_onto_directory_fails() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::write(src.path().join("x.txt"), "x").unwrap();
fs::create_dir_all(dst.path().join("x.txt")).unwrap();
let result = copy_directory_recursive(src.path(), dst.path());
assert!(result.is_err());
}
#[test]
fn copy_dir_all_async_nested_happy_path() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::create_dir_all(src.path().join("deep/deeper")).unwrap();
fs::write(src.path().join("deep/deeper/f.txt"), "d").unwrap();
copy_dir_all_async(src.path(), dst.path()).unwrap();
assert_eq!(
fs::read_to_string(dst.path().join("deep/deeper/f.txt")).unwrap(),
"d"
);
}
#[test]
fn copy_dir_all_async_dst_under_file_fails() {
let src = tempdir().unwrap();
let tmp = tempdir().unwrap();
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "file").unwrap();
let result = copy_dir_all_async(src.path(), &blocker.join("dst"));
assert!(result.is_err());
}
#[test]
fn copy_dir_all_async_src_file_fails_at_read_dir() {
let tmp = tempdir().unwrap();
let file_src = tmp.path().join("plain.txt");
fs::write(&file_src, "x").unwrap();
let result = copy_dir_all_async(&file_src, &tmp.path().join("dst"));
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn copy_dir_all_async_rejects_symlink() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::write(src.path().join("real.txt"), "x").unwrap();
std::os::unix::fs::symlink(
src.path().join("real.txt"),
src.path().join("link.txt"),
)
.unwrap();
let err = copy_dir_all_async(src.path(), dst.path()).unwrap_err();
assert!(err.to_string().contains("symlink"), "got: {err}");
}
#[test]
fn copy_dir_all_async_subdir_blocked_by_file() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::create_dir_all(src.path().join("sub")).unwrap();
fs::write(src.path().join("sub/x.txt"), "x").unwrap();
fs::write(dst.path().join("sub"), "blocking file").unwrap();
let result = copy_dir_all_async(src.path(), dst.path());
assert!(result.is_err());
}
#[test]
fn copy_dir_all_async_copy_onto_directory_fails() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::write(src.path().join("x.txt"), "x").unwrap();
fs::create_dir_all(dst.path().join("x.txt")).unwrap();
let result = copy_dir_all_async(src.path(), dst.path());
assert!(result.is_err());
}
#[test]
fn copy_dir_with_progress_subdir_blocked_by_file() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::create_dir_all(src.path().join("sub")).unwrap();
fs::write(src.path().join("sub/x.txt"), "x").unwrap();
fs::write(dst.path().join("sub"), "blocking file").unwrap();
let result = copy_dir_with_progress(src.path(), dst.path());
assert!(result.is_err());
}
#[test]
fn copy_dir_with_progress_copy_onto_directory_fails() {
let src = tempdir().unwrap();
let dst = tempdir().unwrap();
fs::write(src.path().join("x.txt"), "x").unwrap();
fs::create_dir_all(dst.path().join("x.txt")).unwrap();
let result = copy_dir_with_progress(src.path(), dst.path());
assert!(result.is_err());
}
}