recursive_copy 2.1.0

A minimal, safe, and portable recursive copy library for Unix systems.
Documentation
//! Implementation of the recursive copy logic.
//!
//! This module handles the traversal of directory trees, file duplication,
//! and preservation of metadata (permissions, timestamps) as defined
//! by the provided configuration.

use crate::{CopyError, CopyOptions};
use std::fs;
use std::io::{self, copy};
use std::os::unix::fs::{self as unix_fs, FileTypeExt, PermissionsExt};
use std::path::Path;
use walkdir_minimal::WalkDir;

impl CopyOptions {
    /// Builds a [`WalkDir`] iterator pre-configured from these options.
    ///
    /// Maps `depth`, `follow_symlinks`, and `ignore_permission_denied` to
    /// their `WalkDir` counterparts. Loop detection is always enabled;
    /// `follow_links` is delegated to the walker, which handles cycles
    /// internally via `(dev, ino)` tracking.
    fn build_walker(&self, path: &Path) -> Result<WalkDir, CopyError> {
        Ok(WalkDir::new(path)?
            .max_depth(self.depth)
            .follow_links(self.follow_symlinks)
            .detect_loops(true)
            .ignore_permission_denied(self.ignore_permission_denied))
    }
}

/// Recursively copies a file or directory from `src` to `dst`.
///
/// # Arguments
/// * `src` - The path to the source file or directory.
/// * `dst` - The destination path.
/// * `opts` - Configuration options for the copy process.
///
/// # Returns
/// * `Ok(())` if the copy operation was successful.
/// * `Err(CopyError)` if an error occurred during the process (e.g., a source not found).
pub fn copy_all<P: AsRef<Path>>(src: P, dst: P, opts: &CopyOptions) -> Result<(), CopyError> {
    let src = src.as_ref();
    let dst = dst.as_ref();
    if !src.exists() {
        return Err(CopyError::Io(io::Error::from(io::ErrorKind::NotFound)));
    }

    if src.is_file() {
        let dest_path = if dst.is_dir() {
            dst.join(src.file_name().unwrap_or_default())
        } else {
            dst.to_path_buf()
        };
        return copy_one(src, &dest_path, opts);
    }

    if src.is_dir() {
        if dst.exists() && !dst.is_dir() {
            return Err(CopyError::Io(io::Error::from(io::ErrorKind::NotADirectory)));
        }

        let base_dst = if !dst.exists() {
            fs::create_dir_all(dst)?;
            dst.to_path_buf()
        } else if opts.content_only {
            dst.to_path_buf()
        } else {
            let p = dst.join(src.file_name().unwrap_or_default());
            fs::create_dir_all(&p)?;
            p
        };

        return walk_and_copy(src, &base_dst, opts);
    }

    Err(CopyError::Io(io::Error::from(io::ErrorKind::Unsupported)))
}

/// Internal helper that traverses the directory tree and copies its contents.
///
/// Symlink following and loop detection are fully delegated to the walker.
/// When `follow_symlinks` is true and `restrict_symlinks` is also true, any
/// entry whose canonicalized path falls outside the source tree is silently
/// skipped — preventing symlinks like `link -> /etc/passwd` from copying
/// content outside the intended directory.
/// When `follow_symlinks` is false, symlinks are recreated at the destination
/// as-is via [`recreate_symlink`]. Special files (block/char devices, FIFOs,
/// sockets) are always skipped.
///
/// # Arguments
/// * `src` - Source directory to walk.
/// * `dst` - Destination directory where contents will be placed.
/// * `opts` - User-defined copy options.
///
/// # Returns
/// * `Ok(())` on success.
/// * `Err(CopyError)` if walking or copying fails.
fn walk_and_copy(src: &Path, dst: &Path, opts: &CopyOptions) -> Result<(), CopyError> {
    let src_real = if opts.follow_symlinks && opts.restrict_symlinks {
        src.canonicalize().ok()
    } else {
        None
    };

    let walker = opts.build_walker(src)?;
    for entry_res in walker {
        let entry = entry_res.map_err(CopyError::Walk)?;
        let src_path = entry.path();
        let dst_path = dst.join(src_path.strip_prefix(src).unwrap_or(src_path));
        let ft = entry.symlink_metadata().map_err(CopyError::Io)?.file_type();

        if ft.is_block_device() || ft.is_char_device() || ft.is_fifo() || ft.is_socket() {
            continue;
        }

        // When following symlinks, guard against targets that escaped the
        // source tree. The walker resolves the link transparently, so
        // src_path here is already the real resolved path — canonicalize
        // and compare against the pre-computed src_real.
        if let Some(ref base_real) = src_real {
            if let Ok(entry_real) = src_path.canonicalize() {
                if !entry_real.starts_with(base_real) {
                    eprintln!("Skipping entry outside source tree: {}", src_path.display());
                    continue;
                }
            }
        }

        if ft.is_dir() {
            if !dst_path.exists() {
                fs::create_dir_all(&dst_path)?;
            }
        } else if ft.is_file() {
            copy_one(src_path, &dst_path, opts)?;
        } else if ft.is_symlink() {
            // Reached only when follow_symlinks is false; the walker returns
            // symlinks as-is instead of resolving them.
            recreate_symlink(src_path, &dst_path, opts)?;
        }
    }
    Ok(())
}

/// Copies a single file and preserves its Unix permissions.
///
/// # Arguments
/// * `src` - Source file path.
/// * `dst` - Destination file path.
/// * `opts` - Options to determine if overwriting is allowed.
///
/// # Returns
/// * `Ok(())` on successful file copy.
pub fn copy_one<P: AsRef<Path>>(src: P, dst: P, opts: &CopyOptions) -> Result<(), CopyError> {
    let src = src.as_ref();
    let dst = dst.as_ref();
    if dst.exists() {
        if !opts.overwrite {
            return Ok(());
        }
        fs::remove_file(dst)?;
    } else if let Some(p) = dst.parent() {
        fs::create_dir_all(p)?;
    }

    let mut input = fs::File::open(src)?;
    let mut output = fs::File::create(dst)?;
    copy(&mut input, &mut output)?;

    let mode = fs::metadata(src)?.permissions().mode() & 0o777;
    let mut perms = output.metadata()?.permissions();
    perms.set_mode(mode);
    fs::set_permissions(dst, perms)?;

    Ok(())
}

/// Reads a symbolic link and recreates it at the destination.
///
/// # Arguments
/// * `src` - The existing symbolic link path.
/// * `dst` - The path where the new symlink will be created.
/// * `opts` - Options to determine if overwriting an existing destination is allowed.
///
/// # Returns
/// * `Ok(())` on success.
/// * `Err(CopyError)` if the link cannot be read or created.
fn recreate_symlink(src: &Path, dst: &Path, opts: &CopyOptions) -> Result<(), CopyError> {
    let target = fs::read_link(src)?;
    if dst.exists() {
        if opts.overwrite {
            fs::remove_file(dst)?;
        } else {
            return Ok(());
        }
    }

    if let Some(p) = dst.parent() {
        fs::create_dir_all(p)?;
    }

    unix_fs::symlink(&target, dst)?;
    Ok(())
}