Skip to main content

ssg/core/
fs_ops.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! File system operations: directory copying, safety validation, and traversal.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::{PathErrorExt, SsgError};
10use rayon::prelude::*;
11
12use crate::MAX_DIR_DEPTH;
13
14/// Minimum number of entries to justify Rayon parallel dispatch overhead.
15pub(crate) const PARALLEL_THRESHOLD: usize = 16;
16
17/// Validates and copies files from source to destination.
18///
19/// This function performs comprehensive safety checks before copying files,
20/// including path validation, symlink detection, and size limitations.
21///
22/// # Arguments
23///
24/// * `src` - Source path to copy from
25/// * `dst` - Destination path to copy to
26///
27/// # Returns
28///
29/// Returns `Ok(())` if the copy operation succeeds, or an error if:
30/// * Source path is invalid or inaccessible
31/// * Source contains symlinks (not allowed)
32/// * Files exceed size limits (default: 10MB)
33/// * Destination cannot be created or written to
34///
35/// # Examples
36///
37/// ```rust
38/// use ssg::verify_and_copy_files;
39/// use tempfile::tempdir;
40/// use std::fs;
41///
42/// let src_dir = tempdir().unwrap();
43/// let dst_dir = tempdir().unwrap();
44/// fs::write(src_dir.path().join("a.txt"), "data").unwrap();
45/// verify_and_copy_files(src_dir.path(), dst_dir.path()).unwrap();
46/// assert!(dst_dir.path().join("a.txt").exists());
47/// ```
48///
49/// # Security
50///
51/// This function implements several security measures:
52/// * Path traversal prevention
53/// * Symlink restriction
54/// * File size limits
55/// * Permission validation
56pub 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 source is a file, verify its safety
74    if src.is_file() {
75        verify_file_safety(src)?;
76    }
77
78    // Ensure the destination directory exists
79    fs::create_dir_all(dst).with_path(dst)?;
80
81    // Copy directory contents with safety checks
82    copy_dir_all(src, dst)?;
83
84    Ok(())
85}
86
87/// Asynchronously validates and copies files between directories.
88///
89/// Uses iterative traversal with an explicit stack to avoid unbounded recursion.
90/// Traversal depth is bounded by [`MAX_DIR_DEPTH`].
91///
92/// # Examples
93///
94/// ```rust
95/// use ssg::fs_ops::verify_and_copy_files_async;
96/// use tempfile::tempdir;
97/// use std::fs;
98///
99/// let src = tempdir().unwrap();
100/// let dst = tempdir().unwrap();
101/// fs::write(src.path().join("x.txt"), "hi").unwrap();
102/// verify_and_copy_files_async(src.path(), dst.path()).unwrap();
103/// assert!(dst.path().join("x.txt").is_file());
104/// ```
105pub 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
124/// Iteratively copies a directory tree with depth bounds and safety checks.
125fn 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
149/// Copies a single directory entry, pushing subdirs onto the stack.
150fn 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
169/// Copies directories with a progress bar for feedback.
170///
171/// Uses iterative traversal with an explicit stack to avoid unbounded recursion.
172/// Traversal depth is bounded by [`MAX_DIR_DEPTH`].
173///
174/// # Examples
175///
176/// ```rust
177/// use ssg::fs_ops::copy_dir_with_progress;
178/// use tempfile::tempdir;
179/// use std::fs;
180///
181/// let src = tempdir().unwrap();
182/// let dst = tempdir().unwrap();
183/// fs::write(src.path().join("a.txt"), "x").unwrap();
184/// copy_dir_with_progress(src.path(), dst.path()).unwrap();
185/// assert!(dst.path().join("a.txt").exists());
186/// ```
187pub 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    // (source_dir, dest_dir, depth)
203    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
240/// Checks if a given path is safe to use.
241///
242/// Validates that the provided path does not contain directory traversal attempts
243/// or other potential security risks.
244///
245/// # Arguments
246///
247/// * `path` - The path to validate
248///
249/// # Returns
250///
251/// * `Ok(true)` - If the path is safe to use
252/// * `Ok(false)` - If the path contains unsafe elements
253/// * `Err` - If path validation fails
254///
255/// # Security
256///
257/// This function prevents directory traversal attacks by:
258/// * Checking for parent directory references (`..`) as genuine path
259///   *components* (via [`Path::components`]), not a substring match —
260///   so a literal `..` inside a filename (e.g. `notes..final.md`)
261///   is never a false positive, and no encoding trick produces a
262///   false negative.
263/// * Rejecting any such component **unconditionally**, whether or not
264///   the path currently exists. A prior version of this check only
265///   ran for non-existent paths, so a traversal payload that happened
266///   to resolve to a real file (e.g. `../../etc/passwd`, which exists
267///   on every Unix system) skipped the traversal check entirely and
268///   fell through to `canonicalize()`, which succeeds for any real
269///   file — silently reporting a genuine traversal attempt as safe.
270/// * Resolving symbolic links for paths that do exist and pass the
271///   component check, surfacing a broken symlink as unsafe.
272///
273/// This function alone does **not** confine a path to a particular
274/// directory tree — a path with no `..` components can still resolve
275/// (via a symlink) to an arbitrary location. Callers that need that
276/// stronger guarantee should also use [`is_path_within_root`].
277///
278/// # Examples
279///
280/// ```rust
281/// use ssg::fs_ops::is_safe_path;
282/// use std::path::Path;
283///
284/// assert!(is_safe_path(Path::new("safe/path")).unwrap());
285/// assert!(!is_safe_path(Path::new("../escape")).unwrap());
286/// ```
287pub fn is_safe_path(path: &Path) -> Result<bool, SsgError> {
288    use std::path::Component;
289
290    // Reject genuine parent-directory *components* unconditionally,
291    // before ever checking existence. Matching on `Component::ParentDir`
292    // (rather than a `contains("..")` substring check) means a
293    // filename that merely contains two literal dots is never
294    // mistaken for a traversal attempt.
295    if path.components().any(|c| c == Component::ParentDir) {
296        return Ok(false);
297    }
298
299    if !path.exists() {
300        return Ok(true); // Non-existent paths without traversal are safe
301    }
302
303    // canonicalize() resolves symlinks and all `..' components,
304    // so the resulting path is always absolute with no parent refs.
305    // A failure here (e.g. broken symlink) means the path is unsafe.
306    let _canonical = path.canonicalize().with_path(path)?;
307
308    Ok(true)
309}
310
311/// Checks that `path` resolves to a location inside `root`.
312///
313/// Complements [`is_safe_path`]: that function only rejects paths
314/// whose *string form* contains a `..` component, so it cannot catch
315/// a path that looks innocuous but resolves elsewhere via a symlink
316/// (e.g. `content` is a symlink to `/etc`). This function canonicalizes
317/// both `path` and `root` — resolving all symlinks and `..` components
318/// — and verifies the former is a descendant of (or equal to) the
319/// latter, closing that gap.
320///
321/// `root` must exist. `path` must exist (use [`is_safe_path`] first
322/// for pre-creation checks on paths that don't exist yet).
323///
324/// # Errors
325///
326/// Returns an [`SsgError`] if either `path` or `root` cannot be
327/// canonicalized (e.g. does not exist, or a broken symlink).
328///
329/// # Examples
330///
331/// ```rust
332/// use ssg::fs_ops::is_path_within_root;
333/// use tempfile::tempdir;
334/// use std::fs;
335///
336/// let root = tempdir().unwrap();
337/// let inner = root.path().join("content");
338/// fs::create_dir(&inner).unwrap();
339///
340/// assert!(is_path_within_root(&inner, root.path()).unwrap());
341/// ```
342pub 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
348/// Verifies the safety of a file for processing.
349///
350/// Performs comprehensive safety checks on a file to ensure it meets security
351/// requirements before processing. These checks include symlink detection and
352/// file size validation.
353///
354/// # Arguments
355///
356/// * `path` - Reference to the path of the file to verify
357///
358/// # Returns
359///
360/// * `Ok(())` - If the file passes all safety checks
361/// * `Err` - If any safety check fails
362///
363/// # Safety Checks
364///
365/// * Symlinks: Not allowed (returns error)
366/// * File size: Must be under 10MB
367/// * File type: Must be a regular file
368///
369/// # Examples
370///
371/// Verifies the safety of a file.
372///
373/// ```rust
374/// use std::fs;
375/// use std::path::Path;
376/// use ssg::verify_file_safety;
377/// use tempfile::tempdir;
378///
379/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
380/// // Create temporary directory
381/// let temp_dir = tempdir()?;
382/// let file_path = temp_dir.path().join("index.md");
383///
384/// // Create test file
385/// fs::write(&file_path, "Hello, world!")?;
386///
387/// // Perform verification
388/// verify_file_safety(&file_path)?;
389///
390/// // Directory and file are automatically cleaned up
391/// # Ok(())
392/// # }
393/// ```
394///
395/// # Errors
396///
397/// Returns an error if:
398/// * File is a symlink
399/// * File size exceeds 10MB
400/// * Cannot read file metadata
401pub fn verify_file_safety(path: &Path) -> Result<(), SsgError> {
402    const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB limit
403
404    // Get symlink metadata without following the symlink
405    let symlink_metadata = path.symlink_metadata().with_path(path)?;
406
407    // Explicitly check for symlinks first
408    if symlink_metadata.file_type().is_symlink() {
409        return Err(SsgError::SymlinkForbidden {
410            path: path.to_path_buf(),
411        });
412    }
413
414    // Only check size if it's a regular file
415    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
431/// Recursively collects all file paths within a directory.
432///
433/// Traverses a directory tree and compiles a list of all file paths found,
434/// excluding directories themselves.
435///
436/// # Arguments
437///
438/// * `dir` - Reference to the directory to search
439/// * `files` - Mutable vector to store found file paths
440///
441/// # Returns
442///
443/// * `Ok(())` - If the collection process succeeds
444/// * `Err` - If any file system operation fails
445///
446/// # Examples
447///
448/// ```rust
449/// use std::path::{Path, PathBuf};
450/// use ssg::collect_files_recursive;
451///
452/// fn main() -> Result<(), ssg::error::SsgError> {
453///     let mut files = Vec::new();
454///     let dir_path = Path::new("./examples/content");
455///
456///     collect_files_recursive(dir_path, &mut files)?;
457///
458///     for file in files {
459///         println!("Found file: {}", file.display());
460///     }
461///
462///     Ok(())
463/// }
464/// ```
465///
466/// # Note
467///
468/// This function:
469/// * Only collects file paths, not directory paths
470/// * Rejects symbolic links (consistent with security model)
471/// * Maintains original path structure
472pub fn collect_files_recursive(
473    dir: &Path,
474    files: &mut Vec<PathBuf>,
475) -> Result<(), SsgError> {
476    // (directory, depth)
477    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(&current_dir).with_path(&current_dir)? {
492            let path = entry.with_path(&current_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
504/// Recursively copies a directory whilst maintaining structure and attributes.
505///
506/// Performs a deep copy of a directory tree, preserving file attributes and
507/// handling nested directories. Uses parallel processing for improved performance.
508///
509/// # Arguments
510///
511/// * `src` - Source directory path
512/// * `dst` - Destination directory path
513///
514/// # Returns
515///
516/// * `Ok(())` - If the copy operation succeeds
517/// * `Err` - If any part of the copy operation fails
518///
519/// # Performance
520///
521/// Uses rayon for parallel processing of files, significantly improving
522/// performance for directories with many files.
523///
524/// # Safety
525///
526/// * Verifies file safety before copying
527/// * Maintains original file permissions
528/// * Handles circular references
529///
530/// # Examples
531///
532/// ```rust
533/// use ssg::fs_ops::copy_dir_all;
534/// use tempfile::tempdir;
535/// use std::fs;
536///
537/// let src = tempdir().unwrap();
538/// let dst = tempdir().unwrap();
539/// fs::write(src.path().join("z.txt"), "z").unwrap();
540/// copy_dir_all(src.path(), dst.path()).unwrap();
541/// assert!(dst.path().join("z.txt").exists());
542/// ```
543pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), SsgError> {
544    fs::create_dir_all(dst).with_path(dst)?;
545
546    // (source_dir, dest_dir, depth)
547    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
579/// Separates directory entries into files and subdirectories.
580fn 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
600/// Copies file entries, using parallel dispatch when the count justifies it.
601fn 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
621/// Asynchronously copies an entire directory structure, preserving file attributes and handling nested directories.
622///
623/// # Parameters
624///
625/// * `src`: A reference to the source directory path.
626/// * `dst`: A reference to the destination directory path.
627///
628/// # Returns
629///
630/// * `Result<()>`:
631///   - `Ok(())`: If the directory copying is successful.
632///   - `Err(e)`: If an error occurs during the directory copying, where `e` is the associated error.
633///
634/// # Errors
635///
636/// This function can return the following errors:
637///
638/// * `std::io::Error`: If an error occurs during directory creation, file copying, or permission issues.
639/// * `anyhow::Error`: If a file safety check fails.
640///
641/// # Examples
642///
643/// ```rust
644/// use ssg::fs_ops::copy_dir_all_async;
645/// use tempfile::tempdir;
646/// use std::fs;
647///
648/// let src = tempdir().unwrap();
649/// let dst = tempdir().unwrap();
650/// fs::write(src.path().join("z.txt"), "z").unwrap();
651/// copy_dir_all_async(src.path(), dst.path()).unwrap();
652/// assert!(dst.path().join("z.txt").exists());
653/// ```
654pub 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    // (source_dir, dest_dir, depth)
662    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        // Prior to the fix, `is_safe_path` only checked for `..` on
766        // non-existent paths, so an *existing* path containing `..`
767        // (even one that canonicalizes to somewhere entirely benign,
768        // like this one — `tmp/a/..` resolves right back to `tmp`)
769        // was reported safe purely because `canonicalize()` succeeds
770        // for any real file. That's the same code path a genuine
771        // traversal payload takes once it happens to resolve to a
772        // real file (e.g. `../../etc/passwd`) — see
773        // `is_safe_path_existing_traversal_to_real_file_is_rejected`
774        // below. `is_safe_path` now rejects any `..` *component*
775        // unconditionally, so this benign case is (correctly, if
776        // conservatively) rejected too. Callers that need to allow a
777        // resolved-but-in-bounds backtrack should canonicalize first
778        // and use `is_path_within_root` instead.
779        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        // The canonicalized equivalent (no `..` component at all) is
785        // exactly what a caller should pass instead, and remains safe.
786        assert!(is_safe_path(&dotdot_path.canonicalize().unwrap()).unwrap());
787    }
788
789    /// Regression test for the exact vulnerability this fix closes:
790    /// a traversal path that happens to resolve to a real, existing
791    /// file must still be rejected. Before the fix, `is_safe_path`
792    /// only checked for `..` when the target did *not* exist, so any
793    /// traversal payload landing on something real (like `/etc`,
794    /// which exists on every Unix system) skipped the check entirely.
795    #[test]
796    fn is_safe_path_existing_traversal_to_real_file_is_rejected() {
797        // `/etc` exists on every Unix CI/dev machine this crate
798        // targets. The exact depth of `..` needed to reach it from
799        // `CARGO_MANIFEST_DIR` doesn't matter for this test — what
800        // matters is that `/etc` (an unambiguously real, existing
801        // directory) is reachable via *some* relative traversal from
802        // the crate root, and that reachability must not make the
803        // check pass.
804        let existing_via_traversal = Path::new("../etc");
805        // Only assert if this environment actually has /etc reachable
806        // one level up (true for the CI/dev environments this crate
807        // targets); this keeps the test meaningful without hardcoding
808        // a specific relative depth that could vary by checkout path.
809        if existing_via_traversal.exists() {
810            assert!(!is_safe_path(existing_via_traversal).unwrap());
811        }
812        // Directly construct a path guaranteed to both (a) contain a
813        // `..` component and (b) exist, regardless of environment:
814        // canonicalize `tmp/a`, then rebuild an equivalent
815        // `tmp/a/subdir/..` form that still resolves to the real,
816        // existing `tmp/a` directory.
817        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        // A filename that merely *contains* the two-character
828        // substring ".." (not a `..` path *component*) must not be
829        // rejected -- confirms the fix uses component-based matching
830        // (`Component::ParentDir`), not a fragile `contains("..")`
831        // substring check.
832        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        // Absolute path that exists is safe
844        assert!(is_safe_path(&file).unwrap());
845    }
846
847    // -----------------------------------------------------------------
848    // is_path_within_root — canonicalize-based containment checking,
849    // catches escapes `is_safe_path` structurally cannot (symlinks).
850    // -----------------------------------------------------------------
851
852    #[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        // The exact vulnerability class `is_safe_path` alone cannot
888        // catch: a path with *no* `..` component at all that still
889        // escapes the intended root by following a symlink. `content`
890        // looks like an innocent subdirectory name, but it's actually
891        // a symlink pointing entirely outside `root`.
892        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        // symlink_metadata fails on nonexistent file → Err
940        assert!(verify_file_safety(&missing).is_err());
941    }
942
943    #[test]
944    fn verify_file_safety_directory() {
945        let tmp = tempdir().unwrap();
946        // Directories are not files but should not error (size check skipped)
947        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        // Unlike several sibling tree-walkers in this file,
992        // `collect_files_recursive` has no explicit `!dir.exists()`
993        // guard — every other test here passes a real directory, so
994        // the `fs::read_dir(&current_dir).with_path(&current_dir)?`
995        // error path (as opposed to the depth-guard error path, which
996        // is covered separately) was never exercised.
997        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        // Should not panic
1027        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        // `copy_dir_with_progress` has its own explicit `!src.exists()`
1042        // guard, which intercepts every "missing path" test above
1043        // before the loop's `fs::read_dir(&src_dir).with_path(&src_dir)?`
1044        // ever runs. Pass a *file* as `src` — it passes `.exists()` but
1045        // isn't a directory, so `fs::read_dir` itself fails, exercising
1046        // that `?` for the first time in this function.
1047        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    // -----------------------------------------------------------------
1056    // verify_and_copy_files — validation and error branches
1057    // -----------------------------------------------------------------
1058
1059    #[test]
1060    fn verify_and_copy_files_rejects_traversal_path() {
1061        let dst = tempdir().unwrap();
1062        // Nonexistent path containing ".." → is_safe_path == false.
1063        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        // A sparse file over the 10 MB limit trips verify_file_safety
1088        // on the `src.is_file()` branch.
1089        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        // A regular file passes the safety check but read_dir inside
1117        // copy_dir_all fails on a non-directory source.
1118        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    // -----------------------------------------------------------------
1127    // copy_dir_all — parallel dispatch and error branches
1128    // -----------------------------------------------------------------
1129
1130    #[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        // A plain file occupies the destination subdirectory path.
1190        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        // `copy_dir_all`'s own leading
1199        // `fs::create_dir_all(dst).with_path(dst)?` had no dedicated
1200        // failure test in this file — sibling functions
1201        // (`verify_and_copy_files`, `copy_dir_all_async`,
1202        // `copy_dir_with_progress`) each have one, but this one didn't.
1203        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        // `copy_files_maybe_parallel`'s `copy_file` closure calls
1216        // `verify_file_safety` (already covered by the symlink tests
1217        // above) and then `fs::copy(...).with_path(...)?`. No existing
1218        // test makes the *copy* itself fail for `copy_dir_all` — only
1219        // the subdirectory-creation failure above and the symlink
1220        // safety check were covered. Make the destination *file* path
1221        // already exist as a directory so `fs::copy` errors.
1222        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    // -----------------------------------------------------------------
1232    // verify_and_copy_files_async / copy_directory_recursive
1233    // -----------------------------------------------------------------
1234
1235    #[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        // The destination file path already exists as a directory.
1306        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    // -----------------------------------------------------------------
1313    // copy_dir_all_async / internal_copy_dir_async
1314    // -----------------------------------------------------------------
1315
1316    #[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    // -----------------------------------------------------------------
1391    // copy_dir_with_progress — error branches
1392    // -----------------------------------------------------------------
1393
1394    #[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}