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/// This is deliberately stricter than `commons::path::safe_join`,
279/// which allows a `..` that stays inside its root (`a/../b`): these
280/// paths may be absolute, so there is no root to measure against, and
281/// any `..` is refused instead.
282///
283/// # Examples
284///
285/// ```rust
286/// use ssg::fs_ops::is_safe_path;
287/// use std::path::Path;
288///
289/// assert!(is_safe_path(Path::new("safe/path")).unwrap());
290/// assert!(!is_safe_path(Path::new("../escape")).unwrap());
291/// ```
292pub fn is_safe_path(path: &Path) -> Result<bool, SsgError> {
293    use std::path::Component;
294
295    // Reject genuine parent-directory *components* unconditionally,
296    // before ever checking existence. Matching on `Component::ParentDir`
297    // (rather than a `contains("..")` substring check) means a
298    // filename that merely contains two literal dots is never
299    // mistaken for a traversal attempt.
300    if path.components().any(|c| c == Component::ParentDir) {
301        return Ok(false);
302    }
303
304    if !path.exists() {
305        return Ok(true); // Non-existent paths without traversal are safe
306    }
307
308    // canonicalize() resolves symlinks and all `..' components,
309    // so the resulting path is always absolute with no parent refs.
310    // A failure here (e.g. broken symlink) means the path is unsafe.
311    let _canonical = path.canonicalize().with_path(path)?;
312
313    Ok(true)
314}
315
316/// Checks that `path` resolves to a location inside `root`.
317///
318/// Complements [`is_safe_path`]: that function only rejects paths
319/// whose *string form* contains a `..` component, so it cannot catch
320/// a path that looks innocuous but resolves elsewhere via a symlink
321/// (e.g. `content` is a symlink to `/etc`). This function canonicalizes
322/// both `path` and `root` — resolving all symlinks and `..` components
323/// — and verifies the former is a descendant of (or equal to) the
324/// latter, closing that gap.
325///
326/// `root` must exist. `path` must exist (use [`is_safe_path`] first
327/// for pre-creation checks on paths that don't exist yet).
328///
329/// # Errors
330///
331/// Returns an [`SsgError`] if either `path` or `root` cannot be
332/// canonicalized (e.g. does not exist, or a broken symlink).
333///
334/// # Examples
335///
336/// ```rust
337/// use ssg::fs_ops::is_path_within_root;
338/// use tempfile::tempdir;
339/// use std::fs;
340///
341/// let root = tempdir().unwrap();
342/// let inner = root.path().join("content");
343/// fs::create_dir(&inner).unwrap();
344///
345/// assert!(is_path_within_root(&inner, root.path()).unwrap());
346/// ```
347pub 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        // Name whichever side could not be resolved.
350        path: if root.exists() { path } else { root }.to_path_buf(),
351        source,
352    })
353}
354
355/// Verifies the safety of a file for processing.
356///
357/// Performs comprehensive safety checks on a file to ensure it meets security
358/// requirements before processing. These checks include symlink detection and
359/// file size validation.
360///
361/// # Arguments
362///
363/// * `path` - Reference to the path of the file to verify
364///
365/// # Returns
366///
367/// * `Ok(())` - If the file passes all safety checks
368/// * `Err` - If any safety check fails
369///
370/// # Safety Checks
371///
372/// * Symlinks: Not allowed (returns error)
373/// * File size: Must be under 10MB
374/// * File type: Must be a regular file
375///
376/// # Examples
377///
378/// Verifies the safety of a file.
379///
380/// ```rust
381/// use std::fs;
382/// use std::path::Path;
383/// use ssg::verify_file_safety;
384/// use tempfile::tempdir;
385///
386/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
387/// // Create temporary directory
388/// let temp_dir = tempdir()?;
389/// let file_path = temp_dir.path().join("index.md");
390///
391/// // Create test file
392/// fs::write(&file_path, "Hello, world!")?;
393///
394/// // Perform verification
395/// verify_file_safety(&file_path)?;
396///
397/// // Directory and file are automatically cleaned up
398/// # Ok(())
399/// # }
400/// ```
401///
402/// # Errors
403///
404/// Returns an error if:
405/// * File is a symlink
406/// * File size exceeds 10MB
407/// * Cannot read file metadata
408pub fn verify_file_safety(path: &Path) -> Result<(), SsgError> {
409    const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB limit
410
411    // Get symlink metadata without following the symlink
412    let symlink_metadata = path.symlink_metadata().with_path(path)?;
413
414    // Explicitly check for symlinks first
415    if symlink_metadata.file_type().is_symlink() {
416        return Err(SsgError::SymlinkForbidden {
417            path: path.to_path_buf(),
418        });
419    }
420
421    // Only check size if it's a regular file
422    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
438/// Recursively collects all file paths within a directory.
439///
440/// Traverses a directory tree and compiles a list of all file paths found,
441/// excluding directories themselves.
442///
443/// # Arguments
444///
445/// * `dir` - Reference to the directory to search
446/// * `files` - Mutable vector to store found file paths
447///
448/// # Returns
449///
450/// * `Ok(())` - If the collection process succeeds
451/// * `Err` - If any file system operation fails
452///
453/// # Examples
454///
455/// ```rust
456/// use std::path::{Path, PathBuf};
457/// use ssg::collect_files_recursive;
458///
459/// fn main() -> Result<(), ssg::error::SsgError> {
460///     let mut files = Vec::new();
461///     let dir_path = Path::new("./examples/content");
462///
463///     collect_files_recursive(dir_path, &mut files)?;
464///
465///     for file in files {
466///         println!("Found file: {}", file.display());
467///     }
468///
469///     Ok(())
470/// }
471/// ```
472///
473/// # Note
474///
475/// This function:
476/// * Only collects file paths, not directory paths
477/// * Rejects symbolic links (consistent with security model)
478/// * Maintains original path structure
479pub fn collect_files_recursive(
480    dir: &Path,
481    files: &mut Vec<PathBuf>,
482) -> Result<(), SsgError> {
483    // (directory, depth)
484    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(&current_dir).with_path(&current_dir)? {
499            let path = entry.with_path(&current_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
511/// Recursively copies a directory whilst maintaining structure and attributes.
512///
513/// Performs a deep copy of a directory tree, preserving file attributes and
514/// handling nested directories. Uses parallel processing for improved performance.
515///
516/// # Arguments
517///
518/// * `src` - Source directory path
519/// * `dst` - Destination directory path
520///
521/// # Returns
522///
523/// * `Ok(())` - If the copy operation succeeds
524/// * `Err` - If any part of the copy operation fails
525///
526/// # Performance
527///
528/// Uses rayon for parallel processing of files, significantly improving
529/// performance for directories with many files.
530///
531/// # Safety
532///
533/// * Verifies file safety before copying
534/// * Maintains original file permissions
535/// * Handles circular references
536///
537/// # Examples
538///
539/// ```rust
540/// use ssg::fs_ops::copy_dir_all;
541/// use tempfile::tempdir;
542/// use std::fs;
543///
544/// let src = tempdir().unwrap();
545/// let dst = tempdir().unwrap();
546/// fs::write(src.path().join("z.txt"), "z").unwrap();
547/// copy_dir_all(src.path(), dst.path()).unwrap();
548/// assert!(dst.path().join("z.txt").exists());
549/// ```
550pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), SsgError> {
551    fs::create_dir_all(dst).with_path(dst)?;
552
553    // (source_dir, dest_dir, depth)
554    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
586/// Separates directory entries into files and subdirectories.
587fn 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
607/// Copies file entries, using parallel dispatch when the count justifies it.
608fn 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
628/// Asynchronously copies an entire directory structure, preserving file attributes and handling nested directories.
629///
630/// # Parameters
631///
632/// * `src`: A reference to the source directory path.
633/// * `dst`: A reference to the destination directory path.
634///
635/// # Returns
636///
637/// * `Result<()>`:
638///   - `Ok(())`: If the directory copying is successful.
639///   - `Err(e)`: If an error occurs during the directory copying, where `e` is the associated error.
640///
641/// # Errors
642///
643/// This function can return the following errors:
644///
645/// * `std::io::Error`: If an error occurs during directory creation, file copying, or permission issues.
646/// * `anyhow::Error`: If a file safety check fails.
647///
648/// # Examples
649///
650/// ```rust
651/// use ssg::fs_ops::copy_dir_all_async;
652/// use tempfile::tempdir;
653/// use std::fs;
654///
655/// let src = tempdir().unwrap();
656/// let dst = tempdir().unwrap();
657/// fs::write(src.path().join("z.txt"), "z").unwrap();
658/// copy_dir_all_async(src.path(), dst.path()).unwrap();
659/// assert!(dst.path().join("z.txt").exists());
660/// ```
661pub 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    // (source_dir, dest_dir, depth)
669    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        // Prior to the fix, `is_safe_path` only checked for `..` on
773        // non-existent paths, so an *existing* path containing `..`
774        // (even one that canonicalizes to somewhere entirely benign,
775        // like this one — `tmp/a/..` resolves right back to `tmp`)
776        // was reported safe purely because `canonicalize()` succeeds
777        // for any real file. That's the same code path a genuine
778        // traversal payload takes once it happens to resolve to a
779        // real file (e.g. `../../etc/passwd`) — see
780        // `is_safe_path_existing_traversal_to_real_file_is_rejected`
781        // below. `is_safe_path` now rejects any `..` *component*
782        // unconditionally, so this benign case is (correctly, if
783        // conservatively) rejected too. Callers that need to allow a
784        // resolved-but-in-bounds backtrack should canonicalize first
785        // and use `is_path_within_root` instead.
786        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        // The canonicalized equivalent (no `..` component at all) is
792        // exactly what a caller should pass instead, and remains safe.
793        assert!(is_safe_path(&dotdot_path.canonicalize().unwrap()).unwrap());
794    }
795
796    /// Regression test for the exact vulnerability this fix closes:
797    /// a traversal path that happens to resolve to a real, existing
798    /// file must still be rejected. Before the fix, `is_safe_path`
799    /// only checked for `..` when the target did *not* exist, so any
800    /// traversal payload landing on something real (like `/etc`,
801    /// which exists on every Unix system) skipped the check entirely.
802    #[test]
803    fn is_safe_path_existing_traversal_to_real_file_is_rejected() {
804        // `/etc` exists on every Unix CI/dev machine this crate
805        // targets. The exact depth of `..` needed to reach it from
806        // `CARGO_MANIFEST_DIR` doesn't matter for this test — what
807        // matters is that `/etc` (an unambiguously real, existing
808        // directory) is reachable via *some* relative traversal from
809        // the crate root, and that reachability must not make the
810        // check pass.
811        let existing_via_traversal = Path::new("../etc");
812        // Only assert if this environment actually has /etc reachable
813        // one level up (true for the CI/dev environments this crate
814        // targets); this keeps the test meaningful without hardcoding
815        // a specific relative depth that could vary by checkout path.
816        if existing_via_traversal.exists() {
817            assert!(!is_safe_path(existing_via_traversal).unwrap());
818        }
819        // Directly construct a path guaranteed to both (a) contain a
820        // `..` component and (b) exist, regardless of environment:
821        // canonicalize `tmp/a`, then rebuild an equivalent
822        // `tmp/a/subdir/..` form that still resolves to the real,
823        // existing `tmp/a` directory.
824        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        // A filename that merely *contains* the two-character
835        // substring ".." (not a `..` path *component*) must not be
836        // rejected -- confirms the fix uses component-based matching
837        // (`Component::ParentDir`), not a fragile `contains("..")`
838        // substring check.
839        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        // Absolute path that exists is safe
851        assert!(is_safe_path(&file).unwrap());
852    }
853
854    // -----------------------------------------------------------------
855    // is_path_within_root — canonicalize-based containment checking,
856    // catches escapes `is_safe_path` structurally cannot (symlinks).
857    // -----------------------------------------------------------------
858
859    #[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        // The exact vulnerability class `is_safe_path` alone cannot
895        // catch: a path with *no* `..` component at all that still
896        // escapes the intended root by following a symlink. `content`
897        // looks like an innocent subdirectory name, but it's actually
898        // a symlink pointing entirely outside `root`.
899        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        // symlink_metadata fails on nonexistent file → Err
955        assert!(verify_file_safety(&missing).is_err());
956    }
957
958    #[test]
959    fn verify_file_safety_directory() {
960        let tmp = tempdir().unwrap();
961        // Directories are not files but should not error (size check skipped)
962        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        // Unlike several sibling tree-walkers in this file,
1007        // `collect_files_recursive` has no explicit `!dir.exists()`
1008        // guard — every other test here passes a real directory, so
1009        // the `fs::read_dir(&current_dir).with_path(&current_dir)?`
1010        // error path (as opposed to the depth-guard error path, which
1011        // is covered separately) was never exercised.
1012        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        // Should not panic
1042        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        // `copy_dir_with_progress` has its own explicit `!src.exists()`
1057        // guard, which intercepts every "missing path" test above
1058        // before the loop's `fs::read_dir(&src_dir).with_path(&src_dir)?`
1059        // ever runs. Pass a *file* as `src` — it passes `.exists()` but
1060        // isn't a directory, so `fs::read_dir` itself fails, exercising
1061        // that `?` for the first time in this function.
1062        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    // -----------------------------------------------------------------
1071    // verify_and_copy_files — validation and error branches
1072    // -----------------------------------------------------------------
1073
1074    #[test]
1075    fn verify_and_copy_files_rejects_traversal_path() {
1076        let dst = tempdir().unwrap();
1077        // Nonexistent path containing ".." → is_safe_path == false.
1078        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        // A sparse file over the 10 MB limit trips verify_file_safety
1103        // on the `src.is_file()` branch.
1104        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        // A regular file passes the safety check but read_dir inside
1132        // copy_dir_all fails on a non-directory source.
1133        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    // -----------------------------------------------------------------
1142    // copy_dir_all — parallel dispatch and error branches
1143    // -----------------------------------------------------------------
1144
1145    #[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        // A plain file occupies the destination subdirectory path.
1205        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        // `copy_dir_all`'s own leading
1214        // `fs::create_dir_all(dst).with_path(dst)?` had no dedicated
1215        // failure test in this file — sibling functions
1216        // (`verify_and_copy_files`, `copy_dir_all_async`,
1217        // `copy_dir_with_progress`) each have one, but this one didn't.
1218        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        // `copy_files_maybe_parallel`'s `copy_file` closure calls
1231        // `verify_file_safety` (already covered by the symlink tests
1232        // above) and then `fs::copy(...).with_path(...)?`. No existing
1233        // test makes the *copy* itself fail for `copy_dir_all` — only
1234        // the subdirectory-creation failure above and the symlink
1235        // safety check were covered. Make the destination *file* path
1236        // already exist as a directory so `fs::copy` errors.
1237        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    // -----------------------------------------------------------------
1247    // verify_and_copy_files_async / copy_directory_recursive
1248    // -----------------------------------------------------------------
1249
1250    #[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        // The destination file path already exists as a directory.
1321        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    // -----------------------------------------------------------------
1328    // copy_dir_all_async / internal_copy_dir_async
1329    // -----------------------------------------------------------------
1330
1331    #[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    // -----------------------------------------------------------------
1406    // copy_dir_with_progress — error branches
1407    // -----------------------------------------------------------------
1408
1409    #[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}