Skip to main content

common/
copy.rs

1use std::ffi::{OsStr, OsString};
2use std::os::fd::AsFd;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use anyhow::{Context, anyhow};
7use throttle::get_file_iops_tokens;
8use tracing::instrument;
9
10use crate::config::DryRunMode;
11use crate::copy_data::copy_file_range_all;
12use crate::filecmp;
13use crate::preserve;
14use crate::progress;
15use crate::rm;
16use crate::rm::{Settings as RmSettings, Summary as RmSummary};
17use crate::safedir::{self, Dir, FileMeta, Handle};
18use crate::walk::{EntryKind, LeafPermit, PermitKind};
19use crate::walk_driver::{
20    DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_entry,
21};
22
23/// Error type for copy operations. See [`crate::error::OperationError`] for
24/// logging conventions and rationale.
25pub type Error = crate::error::OperationError<Summary>;
26
27/// Filter condition for overwrite operations.
28///
29/// Used with `--overwrite-filter` to skip overwriting files that match
30/// a directional condition (e.g., destination is newer than source).
31#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
32pub enum OverwriteFilter {
33    /// Skip overwriting if the destination file is strictly newer (by mtime).
34    #[value(name = "newer")]
35    Newer,
36}
37
38impl std::fmt::Display for OverwriteFilter {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            OverwriteFilter::Newer => write!(f, "newer"),
42        }
43    }
44}
45
46/// A destination entry the source compares against, taken from the directory manifest.
47pub struct ExistingDst<'a, M: crate::preserve::Metadata> {
48    /// The destination entry's metadata (with `size()` populated).
49    pub meta: &'a M,
50    /// Whether the destination entry is a regular file (only files are eligible for the
51    /// metadata-equality compare; a non-file means the source must send so the destination
52    /// can remove-and-replace it).
53    pub is_file: bool,
54}
55
56/// Decide whether the source may SKIP sending a file because the destination already holds a
57/// matching entry. `dst` is `None` when the destination has no entry at that name. Returns
58/// `true` to skip (the caller sends a `FileUnchanged` notification instead of file data).
59///
60/// This mirrors the destination's `process_single_file` identical/`--ignore-existing`/newer
61/// checks, so the source pre-filters exactly the files the destination would otherwise receive
62/// and drain. Skipping performs no filesystem mutation.
63pub fn skip_unchanged_send<S, D>(
64    overwrite_compare: &filecmp::MetadataCmpSettings,
65    overwrite_filter: Option<OverwriteFilter>,
66    ignore_existing: bool,
67    src: &S,
68    dst: Option<ExistingDst<'_, D>>,
69) -> bool
70where
71    S: crate::preserve::Metadata + std::fmt::Debug,
72    D: crate::preserve::Metadata + std::fmt::Debug,
73{
74    let Some(dst) = dst else {
75        return false; // nothing at the destination — must send
76    };
77    if ignore_existing {
78        return true; // any pre-existing entry (file/dir/symlink) — skip
79    }
80    if !dst.is_file {
81        return false; // dest is a non-file under --overwrite — send so dest replaces it
82    }
83    if filecmp::metadata_equal(overwrite_compare, src, dst.meta) {
84        return true; // identical per --overwrite-compare
85    }
86    if overwrite_filter == Some(OverwriteFilter::Newer) && filecmp::dest_is_newer(src, dst.meta) {
87        return true; // --overwrite-filter=newer and destination is strictly newer
88    }
89    false
90}
91
92/// Settings controlling rsync-style `--delete` (mirror) behavior.
93///
94/// Present (`Some`) only when `--delete` was requested. `None` means the
95/// destination is never enumerated and no pruning work is done, so the default
96/// copy path pays nothing for this feature.
97#[derive(Debug, Clone)]
98pub struct DeleteSettings {
99    /// Also remove destination entries that match an exclude pattern
100    /// (rsync `--delete-excluded`). When false, excluded entries are protected.
101    pub delete_excluded: bool,
102}
103
104#[derive(Debug, Clone)]
105pub struct Settings {
106    pub dereference: bool,
107    pub fail_early: bool,
108    pub overwrite: bool,
109    pub overwrite_compare: filecmp::MetadataCmpSettings,
110    pub overwrite_filter: Option<OverwriteFilter>,
111    pub ignore_existing: bool,
112    pub chunk_size: u64,
113    /// Skip special files (sockets, FIFOs, devices) without error.
114    pub skip_specials: bool,
115    /// Buffer size for remote copy file transfer operations in bytes.
116    ///
117    /// This is only used for remote copy operations and controls the buffer size
118    /// when copying data between files and network streams. The actual buffer is
119    /// capped to the file size to avoid over-allocation for small files.
120    pub remote_copy_buffer_size: usize,
121    /// filter settings for include/exclude patterns
122    pub filter: Option<crate::filter::FilterSettings>,
123    /// dry-run mode for previewing operations
124    pub dry_run: Option<crate::config::DryRunMode>,
125    /// rsync-style `--delete` settings; `None` disables deletion entirely.
126    pub delete: Option<DeleteSettings>,
127}
128
129/// Summary with the appropriate `*_skipped` counter set to 1 for the given entry kind.
130/// Special files count as `files_skipped` to match the historical mapping used
131/// when filters skip an entry (`specials_skipped` is reserved for `--skip-specials`).
132fn skipped_summary_for(kind: EntryKind) -> Summary {
133    match kind {
134        EntryKind::Dir => Summary {
135            directories_skipped: 1,
136            ..Default::default()
137        },
138        EntryKind::Symlink => Summary {
139            symlinks_skipped: 1,
140            ..Default::default()
141        },
142        EntryKind::File | EntryKind::Special => Summary {
143            files_skipped: 1,
144            ..Default::default()
145        },
146    }
147}
148
149/// Result of checking if an empty directory should be cleaned up.
150/// Used when filtering is active and a directory we created ended up empty.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum EmptyDirAction {
153    /// keep the directory (directly matched or no filter active)
154    Keep,
155    /// directory was only traversed, remove it
156    Remove,
157    /// dry-run mode, don't count this directory in summary
158    DryRunSkip,
159}
160
161/// Determine what to do with an empty directory when filtering is active.
162///
163/// This is called when we created a directory but nothing was copied into it
164/// (no files, symlinks, or child directories). The decision depends on whether
165/// the directory itself was directly matched by an include pattern, or if we
166/// only entered it to look for potential matches inside.
167///
168/// # Arguments
169/// * `filter` - the active filter settings (None means no filtering)
170/// * `we_created_dir` - whether we created this directory (vs it already existed)
171/// * `anything_copied` - whether any content was copied into this directory
172/// * `relative_path` - path relative to the source root (for pattern matching)
173/// * `is_root` - whether this is the root (user-specified) source directory
174/// * `is_dry_run` - whether we're in dry-run mode
175pub fn check_empty_dir_cleanup(
176    filter: Option<&crate::filter::FilterSettings>,
177    we_created_dir: bool,
178    anything_copied: bool,
179    relative_path: &std::path::Path,
180    is_root: bool,
181    is_dry_run: bool,
182) -> EmptyDirAction {
183    // if no filter active or something was copied, keep the directory
184    if filter.is_none() || anything_copied {
185        return EmptyDirAction::Keep;
186    }
187    // if we didn't create this directory, don't remove it
188    if !we_created_dir {
189        return EmptyDirAction::Keep;
190    }
191    // never remove the root directory — it's the user-specified source
192    if is_root {
193        return EmptyDirAction::Keep;
194    }
195    // filter is guaranteed to be Some here (checked above)
196    let f = filter.unwrap();
197    // check if directory directly matches include pattern
198    if f.directly_matches_include(relative_path, true) {
199        return EmptyDirAction::Keep;
200    }
201    // directory was only traversed for potential matches
202    if is_dry_run {
203        EmptyDirAction::DryRunSkip
204    } else {
205        EmptyDirAction::Remove
206    }
207}
208
209#[instrument]
210pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
211    let ft1 = md1.file_type();
212    let ft2 = md2.file_type();
213    ft1.is_dir() == ft2.is_dir()
214        && ft1.is_file() == ft2.is_file()
215        && ft1.is_symlink() == ft2.is_symlink()
216}
217
218#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
219pub struct Summary {
220    pub bytes_copied: u64,
221    pub files_copied: usize,
222    pub symlinks_created: usize,
223    pub directories_created: usize,
224    pub files_unchanged: usize,
225    pub symlinks_unchanged: usize,
226    pub directories_unchanged: usize,
227    pub files_skipped: usize,
228    pub symlinks_skipped: usize,
229    pub directories_skipped: usize,
230    pub specials_skipped: usize,
231    pub rm_summary: RmSummary,
232}
233
234impl std::ops::Add for Summary {
235    type Output = Self;
236    fn add(self, other: Self) -> Self {
237        Self {
238            bytes_copied: self.bytes_copied + other.bytes_copied,
239            files_copied: self.files_copied + other.files_copied,
240            symlinks_created: self.symlinks_created + other.symlinks_created,
241            directories_created: self.directories_created + other.directories_created,
242            files_unchanged: self.files_unchanged + other.files_unchanged,
243            symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
244            directories_unchanged: self.directories_unchanged + other.directories_unchanged,
245            files_skipped: self.files_skipped + other.files_skipped,
246            symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
247            directories_skipped: self.directories_skipped + other.directories_skipped,
248            specials_skipped: self.specials_skipped + other.specials_skipped,
249            rm_summary: self.rm_summary + other.rm_summary,
250        }
251    }
252}
253
254impl std::fmt::Display for Summary {
255    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
256        write!(
257            f,
258            "copy:\n\
259            -----\n\
260            bytes copied: {}\n\
261            files copied: {}\n\
262            symlinks created: {}\n\
263            directories created: {}\n\
264            files unchanged: {}\n\
265            symlinks unchanged: {}\n\
266            directories unchanged: {}\n\
267            files skipped: {}\n\
268            symlinks skipped: {}\n\
269            directories skipped: {}\n\
270            specials skipped: {}\n\
271            \n\
272            delete:\n\
273            -------\n\
274            {}",
275            bytesize::ByteSize(self.bytes_copied),
276            self.files_copied,
277            self.symlinks_created,
278            self.directories_created,
279            self.files_unchanged,
280            self.symlinks_unchanged,
281            self.directories_unchanged,
282            self.files_skipped,
283            self.symlinks_skipped,
284            self.directories_skipped,
285            self.specials_skipped,
286            &self.rm_summary,
287        )
288    }
289}
290
291/// Public entry point for copy operations.
292/// Internally delegates to [`copy_with_filter_base`] with an empty filter base.
293#[instrument(skip(prog_track, settings, preserve))]
294pub async fn copy(
295    prog_track: &'static progress::Progress,
296    src: &std::path::Path,
297    dst: &std::path::Path,
298    settings: &Settings,
299    preserve: &preserve::Settings,
300    is_fresh: bool,
301) -> Result<Summary, Error> {
302    copy_with_filter_base(
303        prog_track,
304        src,
305        dst,
306        settings,
307        preserve,
308        is_fresh,
309        std::path::Path::new(""),
310    )
311    .await
312}
313
314/// Like [`copy`], but treats `src` as living at `filter_base` relative to the original filter
315/// root. Used when `rlink` delegates an update-only entry to `copy`: `--delete` pruning inside
316/// the delegated subtree then matches the include/exclude filter at the entry's true relative
317/// path (e.g. `cache/*.log`) instead of relative to the delegated root.
318///
319/// The local copy walk is fd-based: the source and destination roots are opened relative to
320/// their parent directories and every per-entry operation is performed through
321/// file-descriptor-relative syscalls (see [`crate::safedir`]). This closes the TOCTOU window the
322/// old path-based walk had between classifying an entry and acting on it. `--dereference` is the
323/// one exception — it still resolves symlinks by path (`canonicalize`) and is not hardened.
324#[instrument(skip(prog_track, settings, preserve))]
325#[allow(clippy::too_many_arguments)]
326pub async fn copy_with_filter_base(
327    prog_track: &'static progress::Progress,
328    src: &std::path::Path,
329    dst: &std::path::Path,
330    settings: &Settings,
331    preserve: &preserve::Settings,
332    is_fresh: bool,
333    filter_base: &std::path::Path,
334) -> Result<Summary, Error> {
335    // Source: decompose via the shared helper so `.`/`..` operands (e.g. `rcp . dst`, `rcp src/..
336    // dst`) are canonicalized to a real directory + basename instead of being rejected; `/` is still
337    // rejected. (The helper also maps a single-component relative path's empty parent to ".".)
338    let src_operand = crate::walk::split_root_operand(src)
339        .await
340        .map_err(|err| Error::new(err, Default::default()))?;
341    let src_parent_path = src_operand.parent.as_path();
342    let src_name = src_operand.name.as_os_str();
343    let src = src_operand.display.as_path();
344    // check filter for top-level source (files, directories, and symlinks)
345    if let Some(ref filter) = settings.filter {
346        let src_metadata = crate::walk::run_metadata_probed(
347            congestion::Side::Source,
348            congestion::MetadataOp::Stat,
349            tokio::fs::symlink_metadata(src),
350        )
351        .await
352        .with_context(|| format!("failed reading metadata from src: {:?}", &src))
353        .map_err(|err| Error::new(err, Default::default()))?;
354        let is_dir = src_metadata.is_dir();
355        // for a delegated subtree (non-empty filter_base) the source is not the true filter
356        // root, so match it at its logical path with nested semantics; for a normal copy use
357        // root-item semantics (anchored patterns don't apply to the root itself).
358        let result = if filter_base.as_os_str().is_empty() {
359            filter.should_include_root_item(std::path::Path::new(src_name), is_dir)
360        } else {
361            filter.should_include(filter_base, is_dir)
362        };
363        match result {
364            crate::filter::FilterResult::Included => {}
365            result => {
366                let kind = EntryKind::from_metadata(&src_metadata);
367                if let Some(mode) = settings.dry_run {
368                    crate::dry_run::report_skip(src, &result, mode, kind.label_long());
369                }
370                kind.inc_skipped(prog_track);
371                return Ok(skipped_summary_for(kind));
372            }
373        }
374    }
375    // Open the parent directories of the source and destination roots so the root entry itself can
376    // be opened and classified relative to a directory fd (the same fd-relative path every nested
377    // entry takes). The root is then handed to the walk driver (via `run_copy_root`) by its
378    // basename, exactly like a child entry. The destination keeps the direct split — a `.`/`..`
379    // destination is not a meaningful copy target, and rejecting it avoids clobbering the cwd.
380    let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
381        return Err(Error::new(
382            anyhow!(
383                "copy destination {:?} has no parent directory or file name",
384                dst
385            ),
386            Default::default(),
387        ));
388    };
389    // empty parent (relative path with a single component) means the current directory.
390    let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
391        std::path::Path::new(".")
392    } else {
393        dst_parent_path
394    };
395    // open the operand's TRUSTED parent prefix following symlinks normally (the prefix is trusted
396    // up to and including the operand's container — only entries strictly below the named root are
397    // O_NOFOLLOW-hardened). a symlinked parent (e.g. `rcp src symlinkdir/out`) is followed.
398    let src_parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
399        .await
400        .with_context(|| format!("cannot open source parent directory {:?}", src_parent_path))
401        .map_err(|err| Error::new(err, Default::default()))?;
402    // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
403    let src_parent = Arc::new(src_parent.into_tree());
404    // In dry-run we never touch the destination, so we don't open its parent at all (the parent
405    // may not even exist). `dst_parent == None` is the signal throughout the walk that destination
406    // operations must be skipped.
407    let dst_parent = if settings.dry_run.is_some() {
408        None
409    } else {
410        // the destination's TRUSTED parent prefix is resolved following symlinks (see the source
411        // parent above): `rcp file symlink_to_dir/out` must copy into the real directory the
412        // symlinked parent points at, not fail closed on it.
413        let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
414            .await
415            .with_context(|| {
416                format!(
417                    "cannot open destination parent directory {:?}",
418                    dst_parent_path
419                )
420            })
421            .map_err(|err| Error::new(err, Default::default()))?;
422        // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
423        Some(Arc::new(dir.into_tree()))
424    };
425    run_copy_root(
426        prog_track,
427        &src_parent,
428        dst_parent,
429        src_name,
430        src,
431        dst,
432        filter_base,
433        settings,
434        preserve,
435        is_fresh,
436        None,
437    )
438    .await
439}
440
441/// Copy a single entry `name` (within `src_parent`) into `dst_parent`, using held directory
442/// handles rather than re-resolving any path. This is the fd-based delegation entry point for
443/// `rlink`: when an entry must be COPIED rather than hard-linked (a file that changed vs the
444/// update tree, a symlink, a type-mismatch, or an update-only entry), `link` hands its already-open
445/// parent `Dir`s plus the entry `name` here, so the copy inherits the same intermediate-component
446/// TOCTOU safety the link walk has — no path is re-walked from a root.
447///
448/// `src_path`/`dst_path` are the entry's reconstructed real paths; they serve as the copy walk's
449/// roots so diagnostics, `--dereference` (`canonicalize`), path-based `rm`, and `--delete` pruning
450/// reconstruct the right paths inside the delegated subtree. `filter_base` is the entry's logical
451/// path relative to the original filter root, so any `--delete` pruning inside the subtree matches
452/// include/exclude patterns at the entry's true relative path (e.g. `cache/*.log`). `dst_parent`
453/// is `None` only in dry-run (no destination mutation).
454#[instrument(skip(
455    prog_track,
456    src_parent,
457    dst_parent,
458    settings,
459    preserve,
460    open_file_guard
461))]
462#[allow(clippy::too_many_arguments)]
463pub(crate) async fn copy_child(
464    prog_track: &'static progress::Progress,
465    src_parent: &Arc<Dir>,
466    dst_parent: Option<&Arc<Dir>>,
467    name: &OsStr,
468    src_path: &std::path::Path,
469    dst_path: &std::path::Path,
470    filter_base: &std::path::Path,
471    settings: &Settings,
472    preserve: &preserve::Settings,
473    is_fresh: bool,
474    open_file_guard: Option<throttle::OpenFileGuard>,
475) -> Result<Summary, Error> {
476    run_copy_root(
477        prog_track,
478        src_parent,
479        dst_parent.map(Arc::clone),
480        name,
481        src_path,
482        dst_path,
483        filter_base,
484        settings,
485        preserve,
486        is_fresh,
487        open_file_guard.map(LeafPermit::OpenFile),
488    )
489    .await
490}
491
492/// The copy walk's [`WalkVisitor`]: holds the run-constant state shared by every entry and maps
493/// each per-entry decision onto the generic [`crate::walk_driver`] driver.
494///
495/// The driver owns enumeration, the leaf-permit lifecycle, spawning, the single
496/// drop-before-recurse site, and the error fold; this visitor supplies copy's per-entry bodies
497/// (`copy_file_fd`, `copy_symlink_fd`, `resolve_dst_dir`, `remove_existing`, the empty-dir cleanup,
498/// `--delete` prune, and directory metadata). The destination tree is threaded as an *inherited
499/// attribute* through [`Self::DirContext`] (each directory's children read their destination parent
500/// from `parent_ctx`), so the single-tree driver carries copy's second tree without modeling it.
501struct CopyVisitor {
502    prog_track: &'static progress::Progress,
503    /// The destination root operand. The root's destination basename comes from here (it may differ
504    /// from the source basename, e.g. copying `foo` → `bar`); nested entries reuse the source name.
505    /// The *source* root needs no field: an entry's source real path is the driver-maintained
506    /// `EntryCx::real_path` (seeded with the source root in [`run_copy_root`]).
507    dst_root: PathBuf,
508    /// The logical filter base: an entry's filter path is `filter_base.join(rel_path)`.
509    filter_base: PathBuf,
510    settings: Settings,
511    preserve: preserve::Settings,
512    /// The opened top-level destination parent directory (`None` in dry-run). Seeds the
513    /// [`Self::DirContext`] chain via [`WalkVisitor::root_dir_context`].
514    dst_parent: Option<Arc<Dir>>,
515    /// The initial freshness for the root entry's containing directory.
516    root_is_fresh: bool,
517}
518
519/// Inherited per-directory context: the destination parent directory for one level plus its
520/// freshness. `dst_dir == None` is the dry-run signal threaded throughout the walk (no destination
521/// mutation). This is how the single-tree driver carries copy's destination tree — each child reads
522/// its destination parent from here rather than the driver knowing a second tree exists.
523#[derive(Clone)]
524struct CopyDirContext {
525    dst_dir: Option<Arc<Dir>>,
526    is_fresh: bool,
527}
528
529/// State threaded from [`WalkVisitor::dir_pre`] to [`WalkVisitor::dir_post`] in the same task:
530/// everything `dir_post` needs to run empty-dir cleanup, `--delete` prune, and apply directory
531/// metadata.
532struct CopyDirState {
533    /// The destination directory just created/reused (`None` in dry-run).
534    dst_dir: Option<Arc<Dir>>,
535    /// The destination parent directory and this directory's destination name within it — used to
536    /// remove an empty directory fd-relative (`dst_parent.rmdir_at(dst_name)`). `None` in dry-run.
537    dst_parent: Option<Arc<Dir>>,
538    dst_name: OsString,
539    /// Whether we created the destination directory (vs. reused an existing one).
540    we_created: bool,
541    /// The source directory's metadata, applied to the destination post-order.
542    src_meta: FileMeta,
543    /// Whether this is the user-specified root directory (never empty-dir-cleaned up).
544    is_root: bool,
545    /// The `directories_created`/`directories_unchanged` contribution from resolving this
546    /// directory's destination, folded into the final summary.
547    base: Summary,
548}
549
550impl CopyVisitor {
551    /// The destination real path for the entry described by `cx` (`dst_root.join(rel_path)`, or the
552    /// root verbatim when `rel_path` is empty — joining an empty `rel_path` would append a trailing
553    /// separator that `canonicalize`/ENOTDIR-sensitive paths reject). Mirrors `copy_internal`.
554    fn dst_path_for(&self, cx: &EntryCx) -> PathBuf {
555        if cx.rel_path.as_os_str().is_empty() {
556            self.dst_root.clone()
557        } else {
558            self.dst_root.join(&cx.rel_path)
559        }
560    }
561
562    /// The destination entry's name within its parent. For nested entries this equals the source
563    /// `cx.name`, but for the root the source and destination basenames may differ (e.g. copying
564    /// `foo` → `bar`), so the root's destination name comes from `dst_root`. Mirrors `copy_internal`.
565    // copy's `Error` carries a `Summary` (intrinsically large); a fallible helper returning it trips
566    // `result_large_err` only because the `Ok` variant here is a small `OsString`. The error arm is
567    // unreachable in practice (`copy_with_filter_base` pre-validates the root's file name and a
568    // delegated `copy_child` root path always has one) — keep it as defense-in-depth.
569    #[allow(clippy::result_large_err)]
570    fn dst_name_for(&self, cx: &EntryCx) -> Result<OsString, Error> {
571        if cx.rel_path.as_os_str().is_empty() {
572            self.dst_root
573                .file_name()
574                .map(OsStr::to_owned)
575                .ok_or_else(|| {
576                    Error::new(
577                        anyhow!("copy destination {:?} has no file name", &self.dst_root),
578                        Default::default(),
579                    )
580                })
581        } else {
582            Ok(cx.name.clone())
583        }
584    }
585
586    /// rsync-style `--delete` prune for one finished directory: remove destination entries with no
587    /// source counterpart. Runs only when `--delete` was requested AND every child of this directory
588    /// succeeded — skipping the prune on any error (deleting based on a run that did not fully
589    /// succeed could remove data unexpectedly). Prune enumerates and removes through the
590    /// destination's own pinned directory fd, so a concurrent symlink swap cannot redirect it outside
591    /// the destination.
592    ///
593    /// Folds the prune's `RmSummary` into `copy_summary`. On a non-fail-early prune error it records
594    /// the error in `child_error` (surfaced later by [`Self::finalize_dir`]); it returns `Err` only
595    /// in fail-early mode or when the dry-run delete-scan open fails outright.
596    async fn prune_finished_dir(
597        &self,
598        copy_summary: &mut Summary,
599        child_error: &mut Option<anyhow::Error>,
600        processed: &ProcessedChildren,
601        dst_dir: &Option<Arc<Dir>>,
602        dst_path: &std::path::Path,
603        rel_path: &std::path::Path,
604    ) -> Result<(), Error> {
605        if child_error.is_some() && self.settings.delete.is_some() {
606            tracing::warn!(
607                "skipping --delete pruning of {:?} because the copy reported errors",
608                dst_path
609            );
610        }
611        let Some(delete_settings) = &self.settings.delete else {
612            return Ok(());
613        };
614        if child_error.is_some() {
615            return Ok(());
616        }
617        // the keep-set: every source child the driver spawned for this directory (filter-IN, special
618        // files included — they have a source counterpart so must not be pruned). this is exactly the
619        // set `copy_dir_contents` built inline before the skip-specials check.
620        let keep_set: std::collections::HashSet<OsString> =
621            processed.names().iter().cloned().collect();
622        let relative_dir = self.filter_base.join(rel_path);
623        // obtain the destination directory as an open `Dir`. in a real copy we already hold it
624        // (`dst_dir`). in --dry-run the create-or-overwrite step was skipped, so there is no held
625        // handle and the path could still be a symlink-to-directory or a non-directory; open it
626        // O_NOFOLLOW|O_DIRECTORY (dereference=false) so a symlink or non-directory fails closed here
627        // and prune is simply skipped — never following the symlink to preview deletions OUTSIDE the
628        // destination. a missing dir likewise skips.
629        let prune_dir: Option<Arc<Dir>> = match dst_dir {
630            Some(dir) => Some(Arc::clone(dir)),
631            None => {
632                match Dir::open_root_dir(dst_path, false, congestion::Side::Destination).await {
633                    Ok(dir) => Some(Arc::new(dir)),
634                    Err(err)
635                        if matches!(
636                            err.kind(),
637                            std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
638                        ) || err.raw_os_error() == Some(libc::ELOOP)
639                            || err.raw_os_error() == Some(libc::ENOTDIR) =>
640                    {
641                        tracing::debug!(
642                            "skipping --delete pruning of {:?}: not a real directory in dry-run",
643                            dst_path
644                        );
645                        None
646                    }
647                    Err(err) => {
648                        let err = anyhow::Error::new(err).context(format!(
649                            "cannot open destination {dst_path:?} for delete scan"
650                        ));
651                        // a scan-open failure is surfaced as this directory's own error (this runs only
652                        // when children all succeeded, so there is no prior error to combine with —
653                        // matching the old fail-early/collect handling).
654                        return Err(Error::new(err, *copy_summary));
655                    }
656                }
657            }
658        };
659        let Some(prune_dir) = prune_dir else {
660            return Ok(());
661        };
662        match crate::delete::prune_extraneous(
663            self.prog_track,
664            &prune_dir,
665            &relative_dir,
666            &keep_set,
667            self.settings.filter.as_ref(),
668            delete_settings,
669            self.settings.fail_early,
670            self.settings.dry_run,
671        )
672        .await
673        {
674            Ok(rm_summary) => {
675                copy_summary.rm_summary = copy_summary.rm_summary + rm_summary;
676            }
677            Err(err) => {
678                copy_summary.rm_summary = copy_summary.rm_summary + err.summary;
679                if self.settings.fail_early {
680                    return Err(Error::new(err.source, *copy_summary));
681                }
682                // non-fail-early: remember the prune error but still apply this fully-copied
683                // directory's own metadata below (matching main's collect-then-finalize), surfacing
684                // the prune error from the tail.
685                *child_error = Some(err.source);
686            }
687        }
688        Ok(())
689    }
690
691    /// Finish a directory after its children and `--delete` prune have run: when filtering left it
692    /// empty, clean it up (or, in dry-run, suppress its would-be-created count); otherwise apply the
693    /// source directory's metadata to the destination through its own held fd. A collected
694    /// `child_error` always takes precedence and is surfaced here (even when the empty directory was
695    /// pruned), so a failed child copy can never be reported as overall success.
696    async fn finalize_dir(&self, fin: FinalizeDir, cx: &EntryCx) -> Result<Summary, Error> {
697        let FinalizeDir {
698            mut copy_summary,
699            child_error,
700            dst_dir,
701            dst_parent,
702            dst_name,
703            src_meta,
704            we_created,
705            is_root,
706        } = fin;
707        let src_path = &cx.real_path;
708        let dst_path = self.dst_path_for(cx);
709        let rel_path = &cx.rel_path;
710        // when filtering is active and we created this directory, check whether anything was
711        // actually copied into it. if not, we may need to clean up the empty directory.
712        let this_dir_count = usize::from(we_created);
713        let child_dirs_created = copy_summary
714            .directories_created
715            .saturating_sub(this_dir_count);
716        let anything_copied = copy_summary.files_copied > 0
717            || copy_summary.symlinks_created > 0
718            || child_dirs_created > 0;
719        let relative_path = self.filter_base.join(rel_path);
720        match check_empty_dir_cleanup(
721            self.settings.filter.as_ref(),
722            we_created,
723            anything_copied,
724            &relative_path,
725            is_root,
726            self.settings.dry_run.is_some(),
727        ) {
728            EmptyDirAction::Keep => { /* proceed with metadata application */ }
729            EmptyDirAction::DryRunSkip => {
730                tracing::debug!(
731                    "dry-run: directory {:?} would not be created (nothing to copy inside)",
732                    dst_path
733                );
734                copy_summary.directories_created = 0;
735                // the directory itself is not created, but a child error collected during the walk
736                // must still surface — otherwise a traversal-only directory whose only child FAILED
737                // becomes "empty", gets skipped here, and the failed copy is reported as success.
738                return match child_error {
739                    Some(child_error) => Err(Error::new(child_error, copy_summary)),
740                    None => Ok(copy_summary),
741                };
742            }
743            EmptyDirAction::Remove => {
744                tracing::debug!(
745                    "directory {:?} has nothing to copy inside, removing empty directory",
746                    dst_path
747                );
748                // remove the empty directory fd-relative, through its parent dir handle:
749                // `rmdir_at` operates on `dst_name` within the held `dst_parent` fd (never by path)
750                // and only succeeds on an empty directory, so it is contained to `dst_parent` and
751                // cannot be redirected. our still-open fd to the directory itself simply detaches.
752                // `dst_parent` is always Some here: it is None only in dry-run, where this arm is
753                // unreachable (`check_empty_dir_cleanup` returns `DryRunSkip`, not `Remove`).
754                let rmdir_result = match &dst_parent {
755                    Some(dst_parent) => dst_parent.rmdir_at(&dst_name).await,
756                    None => {
757                        crate::walk::run_metadata_probed(
758                            congestion::Side::Destination,
759                            congestion::MetadataOp::RmDir,
760                            tokio::fs::remove_dir(&dst_path),
761                        )
762                        .await
763                    }
764                };
765                match rmdir_result {
766                    Ok(()) => {
767                        copy_summary.directories_created = 0;
768                        // the empty directory is removed, but a child error collected during the
769                        // walk must still surface — otherwise a traversal-only directory whose only
770                        // child FAILED becomes "empty", is removed here, and the failed copy is
771                        // reported as success.
772                        return match child_error {
773                            Some(child_error) => Err(Error::new(child_error, copy_summary)),
774                            None => Ok(copy_summary),
775                        };
776                    }
777                    Err(err) => {
778                        tracing::debug!(
779                            "failed to remove empty directory {:?}: {:#}, keeping",
780                            dst_path,
781                            &err
782                        );
783                        // fall through to apply metadata.
784                    }
785                }
786            }
787        }
788        // apply directory metadata regardless of whether all children copied successfully (the
789        // directory itself was created/opened in dir_pre). skipped in dry-run (no directory
790        // exists). a child error takes precedence over a metadata error (matching the old tail:
791        // log the metadata error, return the child error).
792        tracing::debug!("set 'dst' directory metadata");
793        let metadata_result = match &dst_dir {
794            Some(dst_dir) => safedir::set_dir_metadata_fd(&self.preserve, &src_meta, dst_dir).await,
795            None => Ok(()),
796        };
797        if let Some(child_error) = child_error {
798            if let Err(metadata_err) = metadata_result {
799                tracing::error!(
800                    "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
801                    src_path,
802                    dst_path,
803                    &metadata_err
804                );
805            }
806            return Err(Error::new(child_error, copy_summary));
807        }
808        // no child failures, so the metadata error is the primary error.
809        metadata_result
810            .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
811            .map_err(|err| Error::new(err, copy_summary))?;
812        Ok(copy_summary)
813    }
814}
815
816/// The owned per-directory state [`CopyVisitor::dir_post`] hands to [`CopyVisitor::finalize_dir`]
817/// after the child summaries are folded and `--delete` pruning has run: the accumulated summary, any
818/// collected child error to surface, and this directory's destination handles / classification.
819struct FinalizeDir {
820    copy_summary: Summary,
821    child_error: Option<anyhow::Error>,
822    dst_dir: Option<Arc<Dir>>,
823    dst_parent: Option<Arc<Dir>>,
824    dst_name: OsString,
825    src_meta: FileMeta,
826    we_created: bool,
827    is_root: bool,
828}
829
830/// Build the [`CopyVisitor`] for one copy operation and process the root entry through the generic
831/// driver. Shared by [`copy_with_filter_base`] (root entry, no pre-acquired permit) and
832/// [`copy_child`] (rlink's fd-based delegation entry point, which may pass an already-held
833/// open-files permit). The root is processed exactly like a nested child via
834/// [`crate::walk_driver::process_entry`]: it is classified authoritatively, then dispatched to
835/// `visit_leaf` (file/symlink/special) or `dir_pre`/recurse/`dir_post` (directory).
836#[allow(clippy::too_many_arguments)]
837async fn run_copy_root(
838    prog_track: &'static progress::Progress,
839    src_parent: &Arc<Dir>,
840    dst_parent: Option<Arc<Dir>>,
841    name: &OsStr,
842    src_root: &std::path::Path,
843    dst_root: &std::path::Path,
844    filter_base: &std::path::Path,
845    settings: &Settings,
846    preserve: &preserve::Settings,
847    is_fresh: bool,
848    permit: Option<LeafPermit>,
849) -> Result<Summary, Error> {
850    let visitor = Arc::new(CopyVisitor {
851        prog_track,
852        dst_root: dst_root.to_path_buf(),
853        filter_base: filter_base.to_path_buf(),
854        settings: settings.clone(),
855        preserve: *preserve,
856        dst_parent,
857        root_is_fresh: is_fresh,
858    });
859    // the root entry's owned context: parent = the hardened source parent, name = the source root
860    // basename, rel_path = "" (the root), real_path = the source root. `filter_path` is seeded with
861    // `filter_base` so that inside a delegated subtree (rlink handing an update-only/type-changed
862    // subtree to `copy_child`, rooted BELOW the original filter root) every descendant's filter
863    // decision is evaluated at its true logical path (e.g. `cache/keep.txt`, not the bare basename
864    // relative to the delegated root). For a normal `copy()` (`filter_base` empty) this is "", so
865    // `filter_path == rel_path` throughout. dry_run is the destination's None signal carried on the
866    // context for the driver's bookkeeping.
867    let root_cx = EntryCx {
868        parent: Arc::clone(src_parent),
869        name: name.to_owned(),
870        rel_path: PathBuf::new(),
871        filter_path: filter_base.to_path_buf(),
872        real_path: src_root.to_path_buf(),
873        dry_run: settings.dry_run.is_some(),
874        prog_track,
875    };
876    let root_ctx = visitor.root_dir_context();
877    process_entry(visitor, root_cx, root_ctx, permit).await
878}
879
880impl WalkVisitor for CopyVisitor {
881    type Summary = Summary;
882    type DirContext = CopyDirContext;
883    type DirState = CopyDirState;
884
885    fn root_dir_context(&self) -> CopyDirContext {
886        CopyDirContext {
887            dst_dir: self.dst_parent.clone(),
888            is_fresh: self.root_is_fresh,
889        }
890    }
891
892    fn permit_kind(&self) -> PermitKind {
893        // copy holds an open file descriptor across a regular-file copy.
894        PermitKind::OpenFile
895    }
896
897    fn want_permit(&self, hint: Option<EntryKind>) -> bool {
898        // pre-acquire the open-files permit only for a known regular-file hint. symlinks are
899        // excluded (with --dereference they may resolve to directories — deadlock risk) and so is
900        // DT_UNKNOWN (it might be a directory). this matches `copy_dir_contents`'s old policy.
901        hint == Some(EntryKind::File)
902    }
903
904    fn fail_early(&self) -> bool {
905        self.settings.fail_early
906    }
907
908    fn filter(&self) -> Option<&crate::filter::FilterSettings> {
909        self.settings.filter.as_ref()
910    }
911
912    fn on_skip(
913        &self,
914        cx: &EntryCx,
915        kind: EntryKind,
916        skip_result: &crate::filter::FilterResult,
917    ) -> Summary {
918        // mirror `copy_dir_contents`'s inline filter-skip: the dry-run "skip ..." line plus the
919        // matching `*_skipped` counter. the driver already did the shared progress increment.
920        if let Some(mode) = self.settings.dry_run {
921            crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
922        }
923        skipped_summary_for(kind)
924    }
925
926    async fn visit_leaf(
927        &self,
928        cx: &EntryCx,
929        parent_ctx: &CopyDirContext,
930        handle: Handle,
931        kind: EntryKind,
932        permit: Option<LeafPermit>,
933    ) -> Result<Summary, Error> {
934        let src_parent = &cx.parent;
935        let dst_parent = parent_ctx.dst_dir.as_ref();
936        let name = cx.name.as_os_str();
937        let src_path = &cx.real_path;
938        let dst_path = self.dst_path_for(cx);
939        let dst_name = self.dst_name_for(cx)?;
940        let is_fresh = parent_ctx.is_fresh;
941        // --dereference: resolve a symlink to its target by path and copy that instead. this is the
942        // one path-based branch we intentionally keep (hardening -L is out of scope). drop the
943        // (regular-file-only) pre-acquired permit first: it is meaningless for a symlink that may
944        // resolve to a directory and could deadlock the recursive `copy()` under a saturated pool.
945        if self.settings.dereference && kind == EntryKind::Symlink {
946            drop(permit);
947            // invariant: only the explicit `--dereference` path may re-resolve an entry by path
948            // (`canonicalize`). the non-dereference walk is fully fd-based and must never reach
949            // here. a future refactor that wires `dereference == false` into this branch trips in
950            // debug/tests.
951            debug_assert!(
952                self.settings.dereference,
953                "canonicalize reached with dereference == false; the non-dereference copy path \
954                 must never re-resolve a path"
955            );
956            let link = crate::walk::run_metadata_probed(
957                congestion::Side::Source,
958                congestion::MetadataOp::Stat,
959                tokio::fs::canonicalize(src_path),
960            )
961            .await
962            .with_context(|| format!("failed reading src symlink {:?}", src_path))
963            .map_err(|err| Error::new(err, Default::default()))?;
964            return copy(
965                self.prog_track,
966                &link,
967                &dst_path,
968                &self.settings,
969                &self.preserve,
970                is_fresh,
971            )
972            .await;
973        }
974        match kind {
975            EntryKind::File => {
976                // hold the open-files permit across the file copy. the driver pre-acquired it for a
977                // regular-file hint; if it didn't (an unknown hint that turned out to be a file),
978                // acquire it now.
979                let _guard = match permit {
980                    Some(p) => p,
981                    None => LeafPermit::OpenFile(throttle::open_file_permit().await),
982                };
983                copy_file_fd(
984                    self.prog_track,
985                    src_parent,
986                    dst_parent,
987                    name,
988                    &dst_name,
989                    &dst_path,
990                    src_path,
991                    &handle,
992                    &self.settings,
993                    &self.preserve,
994                    is_fresh,
995                )
996                .await
997            }
998            EntryKind::Symlink => {
999                drop(permit);
1000                copy_symlink_fd(
1001                    self.prog_track,
1002                    src_parent,
1003                    dst_parent,
1004                    &dst_name,
1005                    src_path,
1006                    &dst_path,
1007                    &handle,
1008                    &self.settings,
1009                    &self.preserve,
1010                    is_fresh,
1011                )
1012                .await
1013            }
1014            EntryKind::Special => {
1015                drop(permit);
1016                if self.settings.skip_specials {
1017                    tracing::debug!("skipping special file {:?}", src_path);
1018                    if let Some(mode) = self.settings.dry_run {
1019                        match mode {
1020                            DryRunMode::Brief => {}
1021                            DryRunMode::All => println!("skip special {:?}", src_path),
1022                            DryRunMode::Explain => {
1023                                println!("skip special {:?} (unsupported file type)", src_path);
1024                            }
1025                        }
1026                    }
1027                    self.prog_track.specials_skipped.inc();
1028                    return Ok(Summary {
1029                        specials_skipped: 1,
1030                        ..Default::default()
1031                    });
1032                }
1033                Err(Error::new(
1034                    anyhow!(
1035                        "copy: {:?} -> {:?} failed, unsupported src file type",
1036                        src_path,
1037                        dst_path
1038                    ),
1039                    Default::default(),
1040                ))
1041            }
1042            // a directory never reaches `visit_leaf`: the driver dispatches it to dir_pre/dir_post.
1043            EntryKind::Dir => unreachable!("directory entries are handled by dir_pre/dir_post"),
1044        }
1045    }
1046
1047    async fn dir_pre(
1048        &self,
1049        cx: &EntryCx,
1050        parent_ctx: &CopyDirContext,
1051        _handle: &Handle,
1052    ) -> DirPreResult<Self> {
1053        let src_parent = &cx.parent;
1054        let name = cx.name.as_os_str();
1055        let src_path = &cx.real_path;
1056        let dst_path = self.dst_path_for(cx);
1057        let dst_name = self.dst_name_for(cx)?;
1058        let is_root = cx.rel_path.as_os_str().is_empty();
1059        let is_fresh = parent_ctx.is_fresh;
1060        // open the source directory's contents (O_NOFOLLOW) — this is the `dir` the driver walks.
1061        let src_dir = src_parent
1062            .open_dir(name)
1063            .await
1064            .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1065            .map_err(|err| Error::new(err, Default::default()))?;
1066        let src_dir = Arc::new(src_dir);
1067        // the directory metadata applied to the destination comes from the SAME fd whose contents we
1068        // enumerate (read-side fidelity, docs/tocttou.md), not the classify `Handle`: a swap of the
1069        // dir entry between classify and `open_dir` is caught by O_NOFOLLOW (fail-closed), and on a
1070        // same-name dir swap the applied metadata pairs with the contents actually copied.
1071        let src_meta = src_dir
1072            .meta()
1073            .await
1074            .with_context(|| format!("cannot read directory metadata from {:?}", src_path))
1075            .map_err(|err| Error::new(err, Default::default()))?;
1076        if self.settings.dry_run.is_some() {
1077            // dry-run: if --ignore-existing and a non-directory already exists at the destination,
1078            // the whole subtree would be skipped. otherwise report the directory and traverse its
1079            // contents (with no destination directory).
1080            if self.settings.ignore_existing
1081                && !is_fresh
1082                && crate::walk::run_metadata_probed(
1083                    congestion::Side::Destination,
1084                    congestion::MetadataOp::Stat,
1085                    tokio::fs::symlink_metadata(&dst_path),
1086                )
1087                .await
1088                .is_ok()
1089                && !dst_path.is_dir()
1090            {
1091                match self.settings.dry_run {
1092                    Some(DryRunMode::Brief) | None => {}
1093                    Some(DryRunMode::All) => println!("skip dir {:?}", dst_path),
1094                    Some(DryRunMode::Explain) => {
1095                        println!(
1096                            "skip dir {:?} (destination exists, not a directory)",
1097                            dst_path
1098                        );
1099                    }
1100                }
1101                return Ok(DirAction::Skip(Summary {
1102                    directories_unchanged: 1,
1103                    ..Default::default()
1104                }));
1105            }
1106            crate::dry_run::report_action("copy", src_path, Some(&dst_path), "dir");
1107            let base = Summary {
1108                directories_created: 1, // report as would-be-created
1109                ..Default::default()
1110            };
1111            return Ok(DirAction::Descend {
1112                dir: src_dir,
1113                child_ctx: CopyDirContext {
1114                    dst_dir: None, // dry-run: no destination parent (no destination mutation)
1115                    is_fresh,
1116                },
1117                state: CopyDirState {
1118                    dst_dir: None,
1119                    dst_parent: None,
1120                    dst_name,
1121                    // treat as "created" so empty-dir cleanup can suppress the dry-run count.
1122                    we_created: true,
1123                    src_meta,
1124                    is_root,
1125                    base,
1126                },
1127            });
1128        }
1129        // real copy: the destination parent is Some (None only in dry-run, handled above).
1130        let dst_parent = parent_ctx
1131            .dst_dir
1132            .as_ref()
1133            .expect("destination parent must be open for a real copy");
1134        let DirSlot {
1135            dir: dst_dir,
1136            summary: base,
1137            is_fresh: child_is_fresh,
1138            we_created,
1139        } = match resolve_dst_dir(
1140            self.prog_track,
1141            dst_parent,
1142            &dst_name,
1143            &dst_path,
1144            &self.settings,
1145            is_fresh,
1146        )
1147        .await?
1148        {
1149            DirResolution::Skip(summary) => return Ok(DirAction::Skip(summary)),
1150            DirResolution::Proceed(slot) => slot,
1151        };
1152        Ok(DirAction::Descend {
1153            dir: src_dir,
1154            child_ctx: CopyDirContext {
1155                dst_dir: Some(Arc::clone(&dst_dir)),
1156                is_fresh: child_is_fresh,
1157            },
1158            state: CopyDirState {
1159                dst_dir: Some(dst_dir),
1160                dst_parent: Some(Arc::clone(dst_parent)),
1161                dst_name,
1162                we_created,
1163                src_meta,
1164                is_root,
1165                base,
1166            },
1167        })
1168    }
1169
1170    async fn dir_post(
1171        &self,
1172        cx: &EntryCx,
1173        state: CopyDirState,
1174        processed: &ProcessedChildren,
1175        child_result: Result<Summary, Error>,
1176    ) -> Result<Summary, Error> {
1177        let CopyDirState {
1178            dst_dir,
1179            dst_parent,
1180            dst_name,
1181            we_created,
1182            src_meta,
1183            is_root,
1184            base,
1185        } = state;
1186        // whether any child failed (non-fail-early: the driver still calls dir_post on error so we
1187        // can apply post-order directory metadata, but we must skip the destructive `--delete`
1188        // prune and surface the child error). the partial child summary is folded either way,
1189        // seeded with `base` (this directory's own create/unchanged contribution) — exactly as
1190        // `copy_dir_contents` seeded `copy_summary = base` before joining the children.
1191        let (child_summary, mut child_error) = match child_result {
1192            Ok(summary) => (summary, None),
1193            Err(err) => (err.summary, Some(err.source)),
1194        };
1195        let mut copy_summary = base + child_summary;
1196        let dst_path = self.dst_path_for(cx);
1197        // rsync-style --delete prune (folds the RmSummary; may record a non-fail-early child error
1198        // or fail-early). A no-op unless --delete was requested AND every child succeeded.
1199        self.prune_finished_dir(
1200            &mut copy_summary,
1201            &mut child_error,
1202            processed,
1203            &dst_dir,
1204            &dst_path,
1205            &cx.rel_path,
1206        )
1207        .await?;
1208        // empty-dir cleanup + post-order directory metadata; also surfaces any collected child error.
1209        self.finalize_dir(
1210            FinalizeDir {
1211                copy_summary,
1212                child_error,
1213                dst_dir,
1214                dst_parent,
1215                dst_name,
1216                src_meta,
1217                we_created,
1218                is_root,
1219            },
1220            cx,
1221        )
1222        .await
1223    }
1224}
1225
1226/// Copy a regular file fd-relative: create (or overwrite) the destination via `dst_parent`,
1227/// copy the bytes with `copy_file_range_all`, then apply metadata through the destination's own
1228/// fd — the open is held from creation through metadata, closing the path-based re-open TOCTOU
1229/// window. `--overwrite`/`--ignore-existing`/`is_fresh`/dry-run semantics mirror the path-based
1230/// [`copy_file`].
1231#[allow(clippy::too_many_arguments)]
1232async fn copy_file_fd(
1233    prog_track: &'static progress::Progress,
1234    src_parent: &Arc<Dir>,
1235    dst_parent: Option<&Arc<Dir>>,
1236    name: &OsStr,
1237    dst_name: &OsStr,
1238    dst_path: &std::path::Path,
1239    src_path: &std::path::Path,
1240    src_handle: &Handle,
1241    settings: &Settings,
1242    preserve: &preserve::Settings,
1243    is_fresh: bool,
1244) -> Result<Summary, Error> {
1245    // bring `FileMeta::size()` into scope locally; importing the trait at module level would
1246    // collide with `std::os::unix::fs::MetadataExt` on `std::fs::Metadata` elsewhere in this file.
1247    use crate::preserve::Metadata as _;
1248    let src_meta = src_handle.meta();
1249    // --ignore-existing: skip if the destination already exists (any type, including a dangling
1250    // symlink). probe via the held dst fd in a real copy; in dry-run there is no held fd
1251    // (`dst_parent` is None), so probe by path — dry-run is already deliberately path-based — so the
1252    // preview output and counters reflect the real skip decision.
1253    let dst_exists = match dst_parent {
1254        Some(dst_parent) => dst_parent.child(dst_name).await.is_ok(),
1255        None => tokio::fs::symlink_metadata(dst_path).await.is_ok(),
1256    };
1257    if !is_fresh && settings.ignore_existing && dst_exists {
1258        if let Some(mode) = settings.dry_run {
1259            match mode {
1260                DryRunMode::Brief => {}
1261                DryRunMode::All => println!("skip file {:?}", dst_path),
1262                DryRunMode::Explain => println!("skip file {:?} (destination exists)", dst_path),
1263            }
1264        }
1265        tracing::debug!("destination exists, skipping (--ignore-existing)");
1266        prog_track.files_unchanged.inc();
1267        return Ok(Summary {
1268            files_unchanged: 1,
1269            ..Default::default()
1270        });
1271    }
1272    // dry-run: report and return what would happen without touching the destination.
1273    if settings.dry_run.is_some() {
1274        crate::dry_run::report_action("copy", src_path, Some(dst_path), "file");
1275        return Ok(Summary {
1276            files_copied: 1,
1277            bytes_copied: src_meta.size(),
1278            ..Default::default()
1279        });
1280    }
1281    // dst_parent is guaranteed Some here: it is None only in dry-run, which returned above.
1282    let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1283    get_file_iops_tokens(settings.chunk_size, src_meta.size()).await;
1284    let mut rm_summary = RmSummary::default();
1285    // when the destination tree is not known-fresh, an entry may already exist. classify it and
1286    // decide whether to skip (identical / newer), error (no --overwrite), or remove it first.
1287    if !is_fresh && let Ok(dst_handle) = dst_parent.child(dst_name).await {
1288        if !settings.overwrite {
1289            return Err(Error::new(
1290                anyhow!(
1291                    "destination {:?} already exists, did you intend to specify --overwrite?",
1292                    dst_path
1293                ),
1294                Default::default(),
1295            ));
1296        }
1297        tracing::debug!("file exists, check if it's identical");
1298        if dst_handle.kind() == EntryKind::File {
1299            if filecmp::metadata_equal(&settings.overwrite_compare, src_meta, dst_handle.meta()) {
1300                tracing::debug!("file is identical, skipping");
1301                prog_track.files_unchanged.inc();
1302                return Ok(Summary {
1303                    files_unchanged: 1,
1304                    ..Default::default()
1305                });
1306            }
1307            if let Some(OverwriteFilter::Newer) = settings.overwrite_filter
1308                && filecmp::dest_is_newer(src_meta, dst_handle.meta())
1309            {
1310                tracing::debug!("dest is newer than source, skipping");
1311                prog_track.files_unchanged.inc();
1312                return Ok(Summary {
1313                    files_unchanged: 1,
1314                    ..Default::default()
1315                });
1316            }
1317        }
1318        tracing::info!("destination differs, removing existing entry");
1319        rm_summary = remove_existing(
1320            prog_track,
1321            dst_parent,
1322            dst_name,
1323            dst_path,
1324            &dst_handle,
1325            settings,
1326        )
1327        .await?;
1328    }
1329    // open the source for reading (fstat confirms it is still a regular file) and create the
1330    // destination fresh. the creation mode matches what the metadata applier will chmod to, so the
1331    // file has correct permissions even before metadata is fully applied. our write fd is writable
1332    // regardless of those bits (it was opened O_WRONLY at creation).
1333    let copy_summary = Summary {
1334        rm_summary,
1335        ..Default::default()
1336    };
1337    let (src_file, open_meta) = src_parent
1338        .open_file_read(name)
1339        .await
1340        .with_context(|| format!("failed opening src file {:?} for reading", src_path))
1341        .map_err(|err| Error::new(err, copy_summary))?;
1342    let create_mode = preserve::masked_mode(preserve.file.mode_mask, &open_meta);
1343    let dst_file = dst_parent
1344        .create_file(dst_name, create_mode)
1345        .await
1346        .with_context(|| format!("failed creating {:?}", dst_path))
1347        .map_err(|err| Error::new(err, copy_summary))?;
1348    tracing::debug!("copying data");
1349    let len = open_meta.size();
1350    // the data copy is the data path, not a metadata syscall — it is deliberately NOT wrapped in a
1351    // congestion probe (matching the old `tokio::fs::copy`), so the large/variable copy latency
1352    // never pollutes the per-metadata-op controller baseline. backpressure comes from the
1353    // open-files permit the caller holds. the dst file is returned so its still-open fd can carry
1354    // the metadata application that follows, closing the path-based re-open TOCTOU window.
1355    let (copied, dst_file) = tokio::task::spawn_blocking(move || {
1356        copy_file_range_all(&src_file, &dst_file, len).map(|copied| (copied, dst_file))
1357    })
1358    .await
1359    .map_err(std::io::Error::other)
1360    .and_then(|res| res)
1361    .with_context(|| format!("failed copying data to {:?}", dst_path))
1362    .map_err(|err| Error::new(err, copy_summary))?;
1363    // account for the bytes ACTUALLY copied, not `len` (the size snapshotted at open): if the source
1364    // is concurrently truncated, `copy_file_range_all` returns the shorter real count, so using `len`
1365    // would over-report. `len` still drives the copy loop and the iops-token reservation above.
1366    prog_track.files_copied.inc();
1367    prog_track.bytes_copied.add(copied);
1368    tracing::debug!("setting permissions");
1369    safedir::set_file_metadata_fd(
1370        preserve,
1371        &open_meta,
1372        dst_file.as_fd(),
1373        congestion::Side::Destination,
1374    )
1375    .await
1376    .with_context(|| format!("failed setting metadata on {:?}", dst_path))
1377    .map_err(|err| Error::new(err, copy_summary))?;
1378    let mut copy_summary = copy_summary;
1379    // count the file as copied only after all metadata has been applied (actual bytes, see above).
1380    copy_summary.bytes_copied += copied;
1381    copy_summary.files_copied += 1;
1382    Ok(copy_summary)
1383}
1384
1385/// Create a symlink fd-relative and apply its metadata through the created link's own handle.
1386/// `--overwrite`/`--ignore-existing`/dry-run semantics mirror the path-based symlink handling.
1387#[allow(clippy::too_many_arguments)]
1388async fn copy_symlink_fd(
1389    prog_track: &'static progress::Progress,
1390    src_parent: &Arc<Dir>,
1391    dst_parent: Option<&Arc<Dir>>,
1392    dst_name: &OsStr,
1393    src_path: &std::path::Path,
1394    dst_path: &std::path::Path,
1395    src_handle: &Handle,
1396    settings: &Settings,
1397    preserve: &preserve::Settings,
1398    is_fresh: bool,
1399) -> Result<Summary, Error> {
1400    // --ignore-existing: skip if the destination already exists (any type). probe via the held dst
1401    // fd in a real copy; in dry-run there is no held fd (`dst_parent` is None), so probe by path, so
1402    // the preview output and counters reflect the real skip decision.
1403    let dst_exists = match dst_parent {
1404        Some(dst_parent) => dst_parent.child(dst_name).await.is_ok(),
1405        None => tokio::fs::symlink_metadata(dst_path).await.is_ok(),
1406    };
1407    if !is_fresh && settings.ignore_existing && dst_exists {
1408        if let Some(mode) = settings.dry_run {
1409            match mode {
1410                DryRunMode::Brief => {}
1411                DryRunMode::All => println!("skip symlink {:?}", dst_path),
1412                DryRunMode::Explain => println!("skip symlink {:?} (destination exists)", dst_path),
1413            }
1414        }
1415        tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1416        prog_track.symlinks_unchanged.inc();
1417        return Ok(Summary {
1418            symlinks_unchanged: 1,
1419            ..Default::default()
1420        });
1421    }
1422    if settings.dry_run.is_some() {
1423        crate::dry_run::report_action("copy", src_path, Some(dst_path), "symlink");
1424        return Ok(Summary {
1425            symlinks_created: 1,
1426            ..Default::default()
1427        });
1428    }
1429    let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1430    // read the target AND metadata from the SAME pinned handle (one paired call), so a concurrent
1431    // same-name symlink swap can't pair one link's target with another link's owner/timestamps
1432    // (target/metadata fidelity, matching the regular-file path which takes both from one fd).
1433    let (target, src_meta) = src_handle
1434        .read_symlink(src_parent.side())
1435        .await
1436        .with_context(|| format!("failed reading symlink {:?}", src_path))
1437        .map_err(|err| Error::new(err, Default::default()))?;
1438    // fast path: the destination slot is empty, create the link directly.
1439    match dst_parent.symlink_at(dst_name, &target).await {
1440        Ok(link_handle) => {
1441            safedir::set_symlink_metadata_fd(
1442                preserve,
1443                &src_meta,
1444                &link_handle,
1445                congestion::Side::Destination,
1446            )
1447            .await
1448            .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1449            .map_err(|err| Error::new(err, Default::default()))?;
1450            prog_track.symlinks_created.inc();
1451            Ok(Summary {
1452                symlinks_created: 1,
1453                ..Default::default()
1454            })
1455        }
1456        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1457            if settings.ignore_existing {
1458                tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1459                prog_track.symlinks_unchanged.inc();
1460                return Ok(Summary {
1461                    symlinks_unchanged: 1,
1462                    ..Default::default()
1463                });
1464            }
1465            if !settings.overwrite {
1466                return Err(Error::new(
1467                    anyhow!("failed creating symlink {:?}", dst_path),
1468                    Default::default(),
1469                ));
1470            }
1471            let dst_handle = dst_parent
1472                .child(dst_name)
1473                .await
1474                .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
1475                .map_err(|err| Error::new(err, Default::default()))?;
1476            if dst_handle.kind() == EntryKind::Symlink {
1477                let dst_link = dst_parent
1478                    .read_link_at(dst_name) // rcp-toctou-allow: destination symlink (overwrite compare), not a source payload
1479                    .await
1480                    .with_context(|| format!("failed reading dst symlink: {:?}", dst_path))
1481                    .map_err(|err| Error::new(err, Default::default()))?;
1482                if dst_link == target {
1483                    tracing::debug!("'dst' is a symlink and points to the same location as 'src'");
1484                    if preserve.symlink.any()
1485                        && !filecmp::metadata_equal(
1486                            &settings.overwrite_compare,
1487                            &src_meta,
1488                            dst_handle.meta(),
1489                        )
1490                    {
1491                        tracing::debug!("'dst' metadata is different, updating");
1492                        safedir::set_symlink_metadata_fd(
1493                            preserve,
1494                            &src_meta,
1495                            &dst_handle,
1496                            congestion::Side::Destination,
1497                        )
1498                        .await
1499                        .with_context(|| {
1500                            format!("failed setting symlink metadata on {:?}", dst_path)
1501                        })
1502                        .map_err(|err| Error::new(err, Default::default()))?;
1503                        prog_track.symlinks_removed.inc();
1504                        prog_track.symlinks_created.inc();
1505                        return Ok(Summary {
1506                            rm_summary: RmSummary {
1507                                symlinks_removed: 1,
1508                                ..Default::default()
1509                            },
1510                            symlinks_created: 1,
1511                            ..Default::default()
1512                        });
1513                    }
1514                    tracing::debug!("symlink already exists, skipping");
1515                    prog_track.symlinks_unchanged.inc();
1516                    return Ok(Summary {
1517                        symlinks_unchanged: 1,
1518                        ..Default::default()
1519                    });
1520                }
1521                tracing::debug!("'dst' is a symlink but points to a different path, updating");
1522            } else {
1523                tracing::info!("'dst' is not a symlink, updating");
1524            }
1525            // remove the conflicting destination entry, then create the link fresh.
1526            let rm_summary = remove_existing(
1527                prog_track,
1528                dst_parent,
1529                dst_name,
1530                dst_path,
1531                &dst_handle,
1532                settings,
1533            )
1534            .await?;
1535            let copy_summary = Summary {
1536                rm_summary,
1537                ..Default::default()
1538            };
1539            let link_handle = dst_parent
1540                .symlink_at(dst_name, &target)
1541                .await
1542                .with_context(|| format!("failed creating symlink {:?}", dst_path))
1543                .map_err(|err| Error::new(err, copy_summary))?;
1544            safedir::set_symlink_metadata_fd(
1545                preserve,
1546                &src_meta,
1547                &link_handle,
1548                congestion::Side::Destination,
1549            )
1550            .await
1551            .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1552            .map_err(|err| Error::new(err, copy_summary))?;
1553            prog_track.symlinks_created.inc();
1554            Ok(Summary {
1555                rm_summary,
1556                symlinks_created: 1,
1557                ..Default::default()
1558            })
1559        }
1560        Err(error) => Err(Error::new(
1561            anyhow::Error::new(error).context(format!("failed creating symlink {:?}", dst_path)),
1562            Default::default(),
1563        )),
1564    }
1565}
1566
1567/// An opened destination directory plus the bookkeeping the caller needs.
1568pub(crate) struct DirSlot {
1569    pub(crate) dir: Arc<Dir>,
1570    pub(crate) summary: Summary,
1571    pub(crate) is_fresh: bool,
1572    pub(crate) we_created: bool,
1573}
1574
1575/// Outcome of resolving a destination directory.
1576pub(crate) enum DirResolution {
1577    /// Proceed into the directory.
1578    Proceed(DirSlot),
1579    /// Skip the whole subtree (e.g. `--ignore-existing` and a non-directory already exists).
1580    Skip(Summary),
1581}
1582
1583/// Create the destination directory `name` under `dst_parent`, or reuse / replace an existing
1584/// entry per `--overwrite`/`--ignore-existing`. Returns an open [`Dir`] handle to it.
1585///
1586/// New directories are created mode `0o700` (writable so children can be populated) — the real
1587/// source mode is applied later by [`CopyVisitor::dir_post`] after all children are copied, matching
1588/// the path-based behavior of creating a writable directory and restricting it last.
1589pub(crate) async fn resolve_dst_dir(
1590    prog_track: &'static progress::Progress,
1591    dst_parent: &Arc<Dir>,
1592    name: &OsStr,
1593    dst_path: &std::path::Path,
1594    settings: &Settings,
1595    is_fresh: bool,
1596) -> Result<DirResolution, Error> {
1597    match dst_parent.make_dir(name, 0o700).await {
1598        Ok(dir) => {
1599            // freshly created: children may assume the destination is empty (no conflict checks).
1600            prog_track.directories_created.inc();
1601            Ok(DirResolution::Proceed(DirSlot {
1602                dir: Arc::new(dir),
1603                summary: Summary {
1604                    directories_created: 1,
1605                    ..Default::default()
1606                },
1607                is_fresh: true,
1608                we_created: true,
1609            }))
1610        }
1611        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1612            assert!(
1613                !is_fresh,
1614                "unexpected pre-existing directory in a fresh destination: {dst_path:?}"
1615            );
1616            let dst_handle = dst_parent
1617                .child(name)
1618                .await
1619                .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
1620                .map_err(|err| Error::new(err, Default::default()))?;
1621            if dst_handle.kind() == EntryKind::Dir {
1622                // an existing directory: leave it as is and reuse it.
1623                //
1624                // N.B. its permissions may stop us writing into it, but the alternative (opening
1625                // it up while we copy) isn't safe.
1626                tracing::debug!("'dst' is a directory, leaving it as is");
1627                let dir = dst_parent
1628                    .open_dir(name)
1629                    .await
1630                    .with_context(|| format!("cannot open existing directory {:?}", dst_path))
1631                    .map_err(|err| Error::new(err, Default::default()))?;
1632                prog_track.directories_unchanged.inc();
1633                Ok(DirResolution::Proceed(DirSlot {
1634                    dir: Arc::new(dir),
1635                    summary: Summary {
1636                        directories_unchanged: 1,
1637                        ..Default::default()
1638                    },
1639                    is_fresh,
1640                    we_created: false,
1641                }))
1642            } else if settings.ignore_existing {
1643                // a non-directory exists and --ignore-existing was set: skip the whole subtree.
1644                tracing::debug!(
1645                    "destination exists but is not a directory, skipping subtree (--ignore-existing)"
1646                );
1647                prog_track.directories_unchanged.inc();
1648                Ok(DirResolution::Skip(Summary {
1649                    directories_unchanged: 1,
1650                    ..Default::default()
1651                }))
1652            } else if settings.overwrite {
1653                // a non-directory exists and --overwrite was set: remove it and create the dir.
1654                tracing::info!("'dst' is not a directory, removing and creating a new one");
1655                let rm_summary = remove_existing(
1656                    prog_track,
1657                    dst_parent,
1658                    name,
1659                    dst_path,
1660                    &dst_handle,
1661                    settings,
1662                )
1663                .await?;
1664                let dir = dst_parent
1665                    .make_dir(name, 0o700)
1666                    .await
1667                    .with_context(|| format!("cannot create directory {:?}", dst_path))
1668                    .map_err(|err| {
1669                        Error::new(
1670                            err,
1671                            Summary {
1672                                rm_summary,
1673                                ..Default::default()
1674                            },
1675                        )
1676                    })?;
1677                prog_track.directories_created.inc();
1678                Ok(DirResolution::Proceed(DirSlot {
1679                    dir: Arc::new(dir),
1680                    summary: Summary {
1681                        rm_summary,
1682                        directories_created: 1,
1683                        ..Default::default()
1684                    },
1685                    is_fresh: true,
1686                    we_created: true,
1687                }))
1688            } else {
1689                Err(Error::new(
1690                    anyhow!(
1691                        "destination {:?} already exists, did you intend to specify --overwrite?",
1692                        dst_path
1693                    ),
1694                    Default::default(),
1695                ))
1696            }
1697        }
1698        Err(error) => {
1699            let error = anyhow::Error::new(error)
1700                .context(format!("cannot create directory {:?}", dst_path));
1701            tracing::error!("{:#}", &error);
1702            Err(Error::new(error, Default::default()))
1703        }
1704    }
1705}
1706
1707/// Remove an existing destination entry (file, symlink, directory, or special) so a fresh copy can
1708/// take its place. The returned summary is folded into the caller's copy summary.
1709///
1710/// # Removal contract
1711///
1712/// The entry was already classified into `dst_handle` (via [`Dir::child`]) by the caller. Removal
1713/// here is **fd-relative and recheck-guarded**:
1714///
1715/// 1. [`Dir::recheck`] re-opens `dst_name` and confirms it is STILL the same inode that was
1716///    classified (same `dev`/`ino`). If the directory entry was swapped to a different inode
1717///    between classification and now — an intermediate-dir-to-symlink swap is the canonical
1718///    attack — `recheck` returns `ESTALE` and we fail closed, removing nothing.
1719/// 2. The entry is then removed through the held `dst_parent` fd by its kind:
1720///    - File / Symlink / Special → [`Dir::unlink_at`] (single entry, never follows a symlink).
1721///    - Empty directory → [`Dir::rmdir_at`].
1722///    - Non-empty directory → fd-relative recursive [`rm::rm_child`] on the held `dst_parent`
1723///      (`O_NOFOLLOW|O_DIRECTORY` descent), the same mechanism the remote destination uses.
1724///
1725/// Because every removal is fd-relative, it is CONTAINED to the held `dst_parent` directory: even in
1726/// the residual window between `recheck` and the removal, a final-component swap could at worst
1727/// remove a different inode that currently occupies `dst_name` WITHIN this directory — it can never
1728/// escape `dst_parent` into a privileged delete elsewhere (design invariant 6, best-effort). The
1729/// non-empty-directory subtree is descended through the parent fd with `O_NOFOLLOW`, so a
1730/// directory→symlink swap mid-walk fails closed (ELOOP/ENOTDIR) rather than following the link.
1731pub(crate) async fn remove_existing(
1732    prog_track: &'static progress::Progress,
1733    dst_parent: &Arc<Dir>,
1734    dst_name: &OsStr,
1735    dst_path: &std::path::Path,
1736    dst_handle: &Handle,
1737    settings: &Settings,
1738) -> Result<RmSummary, Error> {
1739    // recheck: confirm the entry is still the same inode we classified. fail closed on a swap.
1740    dst_parent
1741        .recheck(dst_name, dst_handle)
1742        .await
1743        .with_context(|| {
1744            format!(
1745                "destination {:?} changed identity before removal (possible TOCTOU swap)",
1746                dst_path
1747            )
1748        })
1749        .map_err(|err| Error::new(err, Default::default()))?;
1750    match dst_handle.kind() {
1751        EntryKind::File | EntryKind::Symlink | EntryKind::Special => {
1752            // capture the removed entry's size before unlinking, to account its bytes (matching the
1753            // remote destination's overwrite accounting — see rcp::destination::remove_existing_dst).
1754            let removed_size = {
1755                use crate::preserve::Metadata as _;
1756                dst_handle.meta().size()
1757            };
1758            // single-entry, fd-relative removal contained to dst_parent; never follows a symlink.
1759            dst_parent
1760                .unlink_at(dst_name)
1761                .await
1762                .with_context(|| format!("failed removing existing destination {:?}", dst_path))
1763                .map_err(|err| Error::new(err, Default::default()))?;
1764            let is_symlink = dst_handle.kind() == EntryKind::Symlink;
1765            if is_symlink {
1766                prog_track.symlinks_removed.inc();
1767            } else {
1768                prog_track.files_removed.inc();
1769                prog_track.bytes_removed.add(removed_size);
1770            }
1771            Ok(RmSummary {
1772                files_removed: usize::from(!is_symlink),
1773                symlinks_removed: usize::from(is_symlink),
1774                ..Default::default()
1775            })
1776        }
1777        EntryKind::Dir => {
1778            // try the fd-relative fast path first: an empty directory removes cleanly via rmdir_at.
1779            match dst_parent.rmdir_at(dst_name).await {
1780                Ok(()) => {
1781                    prog_track.directories_removed.inc();
1782                    Ok(RmSummary {
1783                        directories_removed: 1,
1784                        ..Default::default()
1785                    })
1786                }
1787                // POSIX permits either ENOTEMPTY or EEXIST for a non-empty directory.
1788                Err(error)
1789                    if matches!(
1790                        error.raw_os_error(),
1791                        Some(libc::ENOTEMPTY) | Some(libc::EEXIST)
1792                    ) =>
1793                {
1794                    // fd-relative recursive removal of the subtree on the held parent (guarded by
1795                    // recheck above): descent uses O_NOFOLLOW, so it cannot be redirected out of
1796                    // dst_parent by a concurrent symlink swap. Mirrors the remote destination path.
1797                    rm::rm_child(
1798                        prog_track,
1799                        dst_parent,
1800                        dst_name,
1801                        dst_path,
1802                        &RmSettings {
1803                            fail_early: settings.fail_early,
1804                            filter: None,
1805                            dry_run: None,
1806                            time_filter: None,
1807                        },
1808                    )
1809                    .await
1810                    .map_err(|err| {
1811                        Error::new(
1812                            err.source,
1813                            Summary {
1814                                rm_summary: err.summary,
1815                                ..Default::default()
1816                            },
1817                        )
1818                    })
1819                }
1820                Err(error) => Err(Error::new(
1821                    anyhow::Error::new(error)
1822                        .context(format!("failed removing existing directory {:?}", dst_path)),
1823                    Default::default(),
1824                )),
1825            }
1826        }
1827    }
1828}
1829
1830#[cfg(test)]
1831mod copy_tests {
1832    use crate::testutils;
1833    use anyhow::Context;
1834    use std::os::unix::fs::MetadataExt;
1835    use std::os::unix::fs::PermissionsExt;
1836    use tracing_test::traced_test;
1837
1838    use super::*;
1839
1840    static PROGRESS: std::sync::LazyLock<progress::Progress> =
1841        std::sync::LazyLock::new(progress::Progress::new);
1842    static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1843        std::sync::LazyLock::new(preserve::preserve_none);
1844    static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1845        std::sync::LazyLock::new(preserve::preserve_all);
1846
1847    fn settings_with_delete(delete: Option<DeleteSettings>) -> Settings {
1848        Settings {
1849            dereference: false,
1850            fail_early: false,
1851            overwrite: delete.is_some(), // --delete implies --overwrite
1852            overwrite_compare: filecmp::MetadataCmpSettings {
1853                size: true,
1854                mtime: true,
1855                ..Default::default()
1856            },
1857            overwrite_filter: None,
1858            ignore_existing: false,
1859            chunk_size: 0,
1860            skip_specials: false,
1861            remote_copy_buffer_size: 0,
1862            filter: None,
1863            dry_run: None,
1864            delete,
1865        }
1866    }
1867
1868    fn delete_on() -> Option<DeleteSettings> {
1869        Some(DeleteSettings {
1870            delete_excluded: false,
1871        })
1872    }
1873
1874    // Regression: a source operand whose final component is `.`/`..` (e.g. `rcp tree/sub/.. dst`,
1875    // `rcp . dst`) must be copied, not rejected — `split_root_operand` canonicalizes it. Uses
1876    // `tree/sub/..` (== `tree`) rather than `.` to avoid touching the process-wide cwd.
1877    #[tokio::test]
1878    async fn copies_dot_dot_source_operand() -> Result<(), anyhow::Error> {
1879        let tmp = testutils::create_temp_dir().await?;
1880        let tree = tmp.join("tree");
1881        tokio::fs::create_dir(&tree).await?;
1882        tokio::fs::write(tree.join("a.txt"), "hello").await?;
1883        tokio::fs::create_dir(tree.join("sub")).await?;
1884        let src = tree.join("sub").join(".."); // == tree
1885        let dst = tmp.join("dst");
1886        let summary = copy(
1887            &PROGRESS,
1888            &src,
1889            &dst,
1890            &settings_with_delete(None),
1891            &NO_PRESERVE_SETTINGS,
1892            false,
1893        )
1894        .await?;
1895        assert_eq!(
1896            summary.files_copied, 1,
1897            "the dot-dot source's file must be copied"
1898        );
1899        assert_eq!(tokio::fs::read_to_string(dst.join("a.txt")).await?, "hello");
1900        assert!(
1901            dst.join("sub").is_dir(),
1902            "the dot-dot source's subdir must be copied"
1903        );
1904        Ok(())
1905    }
1906
1907    #[tokio::test]
1908    #[traced_test]
1909    async fn delete_protects_skipped_special_name() -> Result<(), anyhow::Error> {
1910        let tmp_dir = testutils::setup_test_dir().await?;
1911        let test_path = tmp_dir.as_path();
1912        let src = test_path.join("src_dir");
1913        let dst = test_path.join("dst_dir");
1914        tokio::fs::create_dir(&src).await?;
1915        tokio::fs::write(src.join("file.txt"), "hello").await?;
1916        // a special file in the source that --skip-specials will skip copying
1917        nix::unistd::mkfifo(
1918            &src.join("pipe"),
1919            nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
1920        )?;
1921        // pre-existing destination: a counterpart for the skipped special, plus a genuine extra
1922        tokio::fs::create_dir(&dst).await?;
1923        tokio::fs::write(dst.join("pipe"), "old").await?;
1924        tokio::fs::write(dst.join("stale.txt"), "junk").await?;
1925
1926        let mut settings = settings_with_delete(delete_on());
1927        settings.skip_specials = true;
1928        let summary = copy(
1929            &PROGRESS,
1930            &src,
1931            &dst,
1932            &settings,
1933            &DO_PRESERVE_SETTINGS,
1934            false,
1935        )
1936        .await?;
1937
1938        assert_eq!(summary.specials_skipped, 1);
1939        assert!(dst.join("file.txt").exists());
1940        assert!(
1941            dst.join("pipe").exists(),
1942            "a destination entry matching a skipped special must not be pruned (it has a source counterpart)"
1943        );
1944        assert!(!dst.join("stale.txt").exists()); // genuine extra removed
1945        Ok(())
1946    }
1947
1948    // FIX A (PR #247 review): the operand's TRUSTED parent prefix must be resolved following
1949    // symlinks. `rcp file symlink_to_dir/out` copies into the REAL directory the symlinked parent
1950    // points at (the parent prefix is trusted up to and including the operand's container); it must
1951    // NOT fail closed with ELOOP/ENOTDIR. The hardening only applies strictly below the named root.
1952    #[tokio::test]
1953    #[traced_test]
1954    async fn copies_into_symlinked_destination_parent() -> Result<(), anyhow::Error> {
1955        let tmp_dir = testutils::setup_test_dir().await?;
1956        let test_path = tmp_dir.as_path();
1957        let src = test_path.join("src.txt");
1958        tokio::fs::write(&src, b"payload").await?;
1959        // a real destination directory and a symlink-to-dir parent prefix pointing at it.
1960        let real_dir = test_path.join("real_dst_dir");
1961        tokio::fs::create_dir(&real_dir).await?;
1962        let link_dir = test_path.join("link_dst_dir");
1963        tokio::fs::symlink(&real_dir, &link_dir).await?;
1964        // destination operand sits UNDER the symlinked (trusted) parent prefix.
1965        let dst = link_dir.join("out.txt");
1966        let summary = copy(
1967            &PROGRESS,
1968            &src,
1969            &dst,
1970            &settings_with_delete(None),
1971            &DO_PRESERVE_SETTINGS,
1972            false,
1973        )
1974        .await?;
1975        assert_eq!(summary.files_copied, 1);
1976        // the file landed in the REAL directory (the symlinked parent was followed).
1977        let written = tokio::fs::read(real_dir.join("out.txt")).await?;
1978        assert_eq!(written, b"payload");
1979        Ok(())
1980    }
1981
1982    // FIX A (PR #247 review): a symlinked SOURCE parent prefix is likewise followed —
1983    // `rcp symlinkdir/src dst` reads the file through the real directory.
1984    #[tokio::test]
1985    #[traced_test]
1986    async fn copies_from_symlinked_source_parent() -> Result<(), anyhow::Error> {
1987        let tmp_dir = testutils::setup_test_dir().await?;
1988        let test_path = tmp_dir.as_path();
1989        // a real source directory containing the file, reached via a symlinked parent prefix.
1990        let real_src_dir = test_path.join("real_src_dir");
1991        tokio::fs::create_dir(&real_src_dir).await?;
1992        tokio::fs::write(real_src_dir.join("src.txt"), b"payload").await?;
1993        let link_src_dir = test_path.join("link_src_dir");
1994        tokio::fs::symlink(&real_src_dir, &link_src_dir).await?;
1995        let src = link_src_dir.join("src.txt");
1996        let dst = test_path.join("out.txt");
1997        let summary = copy(
1998            &PROGRESS,
1999            &src,
2000            &dst,
2001            &settings_with_delete(None),
2002            &DO_PRESERVE_SETTINGS,
2003            false,
2004        )
2005        .await?;
2006        assert_eq!(summary.files_copied, 1);
2007        let written = tokio::fs::read(&dst).await?;
2008        assert_eq!(written, b"payload");
2009        Ok(())
2010    }
2011
2012    #[tokio::test]
2013    #[traced_test]
2014    async fn delete_removes_extraneous_destination_entries() -> Result<(), anyhow::Error> {
2015        let tmp_dir = testutils::setup_test_dir().await?;
2016        let test_path = tmp_dir.as_path();
2017        let src = test_path.join("foo");
2018        let dst = test_path.join("bar");
2019        // initial copy (no delete)
2020        copy(
2021            &PROGRESS,
2022            &src,
2023            &dst,
2024            &settings_with_delete(None),
2025            &DO_PRESERVE_SETTINGS,
2026            false,
2027        )
2028        .await?;
2029        // introduce extraneous entries at the destination
2030        tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2031        tokio::fs::create_dir(dst.join("extra_dir")).await?;
2032        tokio::fs::write(dst.join("extra_dir").join("nested.txt"), b"junk").await?;
2033        // re-copy with --delete
2034        let summary = copy(
2035            &PROGRESS,
2036            &src,
2037            &dst,
2038            &settings_with_delete(delete_on()),
2039            &DO_PRESERVE_SETTINGS,
2040            false,
2041        )
2042        .await?;
2043        assert_eq!(summary.rm_summary.files_removed, 2); // extraneous.txt + extra_dir/nested.txt
2044        assert_eq!(summary.rm_summary.directories_removed, 1); // extra_dir
2045        assert!(!dst.join("extraneous.txt").exists());
2046        assert!(!dst.join("extra_dir").exists());
2047        testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2048        Ok(())
2049    }
2050
2051    #[tokio::test]
2052    #[traced_test]
2053    async fn delete_prunes_extraneous_at_depth() -> Result<(), anyhow::Error> {
2054        let tmp_dir = testutils::setup_test_dir().await?;
2055        let test_path = tmp_dir.as_path();
2056        let src = test_path.join("foo");
2057        let dst = test_path.join("bar");
2058        copy(
2059            &PROGRESS,
2060            &src,
2061            &dst,
2062            &settings_with_delete(None),
2063            &DO_PRESERVE_SETTINGS,
2064            false,
2065        )
2066        .await?;
2067        // foo/bar is a common subdirectory; place a stale file inside the dst copy of it
2068        let nested = dst.join("bar");
2069        assert!(
2070            nested.is_dir(),
2071            "expected common subdirectory bar/ to exist at destination"
2072        );
2073        tokio::fs::write(nested.join("stale_nested.txt"), b"junk").await?;
2074        let summary = copy(
2075            &PROGRESS,
2076            &src,
2077            &dst,
2078            &settings_with_delete(delete_on()),
2079            &DO_PRESERVE_SETTINGS,
2080            false,
2081        )
2082        .await?;
2083        assert!(
2084            !nested.join("stale_nested.txt").exists(),
2085            "stale entry inside a common subdirectory must be pruned"
2086        );
2087        assert!(summary.rm_summary.files_removed >= 1);
2088        testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2089        Ok(())
2090    }
2091
2092    #[tokio::test]
2093    #[traced_test]
2094    async fn delete_removes_extraneous_symlink() -> Result<(), anyhow::Error> {
2095        let tmp_dir = testutils::setup_test_dir().await?;
2096        let test_path = tmp_dir.as_path();
2097        let src = test_path.join("foo");
2098        let dst = test_path.join("bar");
2099        copy(
2100            &PROGRESS,
2101            &src,
2102            &dst,
2103            &settings_with_delete(None),
2104            &DO_PRESERVE_SETTINGS,
2105            false,
2106        )
2107        .await?;
2108        // an extraneous symlink at the destination root (no source counterpart)
2109        tokio::fs::symlink("/nonexistent/target", dst.join("stale_link")).await?;
2110        let summary = copy(
2111            &PROGRESS,
2112            &src,
2113            &dst,
2114            &settings_with_delete(delete_on()),
2115            &DO_PRESERVE_SETTINGS,
2116            false,
2117        )
2118        .await?;
2119        assert!(
2120            tokio::fs::symlink_metadata(dst.join("stale_link"))
2121                .await
2122                .is_err(),
2123            "extraneous symlink must be removed"
2124        );
2125        assert_eq!(summary.rm_summary.symlinks_removed, 1);
2126        Ok(())
2127    }
2128
2129    #[tokio::test]
2130    #[traced_test]
2131    async fn delete_skips_pruning_when_copy_has_errors() -> Result<(), anyhow::Error> {
2132        let tmp_dir = testutils::setup_test_dir().await?;
2133        let test_path = tmp_dir.as_path();
2134        let src = test_path.join("foo");
2135        let dst = test_path.join("bar");
2136        // baseline copy establishes the destination
2137        copy(
2138            &PROGRESS,
2139            &src,
2140            &dst,
2141            &settings_with_delete(None),
2142            &DO_PRESERVE_SETTINGS,
2143            false,
2144        )
2145        .await?;
2146        // an extraneous file that --delete would normally prune
2147        tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2148        // make a source sub-directory unreadable so traversal fails (fail_early is false).
2149        // a directory (not a file) is used because --overwrite with mtime-equal files skips
2150        // copying identical files; a directory's read_dir fails unconditionally when mode is 0o000.
2151        let unreadable = src.join("baz");
2152        let original = tokio::fs::metadata(&unreadable).await?.permissions();
2153        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2154
2155        let result = copy(
2156            &PROGRESS,
2157            &src,
2158            &dst,
2159            &settings_with_delete(delete_on()),
2160            &DO_PRESERVE_SETTINGS,
2161            false,
2162        )
2163        .await;
2164
2165        tokio::fs::set_permissions(&unreadable, original).await?;
2166
2167        assert!(
2168            result.is_err(),
2169            "copy of the unreadable directory should fail"
2170        );
2171        assert!(
2172            dst.join("extraneous.txt").exists(),
2173            "pruning must be skipped when the copy reported errors"
2174        );
2175        Ok(())
2176    }
2177
2178    #[tokio::test]
2179    #[traced_test]
2180    async fn check_basic_copy() -> Result<(), anyhow::Error> {
2181        let tmp_dir = testutils::setup_test_dir().await?;
2182        let test_path = tmp_dir.as_path();
2183        let summary = copy(
2184            &PROGRESS,
2185            &test_path.join("foo"),
2186            &test_path.join("bar"),
2187            &Settings {
2188                dereference: false,
2189                fail_early: false,
2190                overwrite: false,
2191                overwrite_compare: filecmp::MetadataCmpSettings {
2192                    size: true,
2193                    mtime: true,
2194                    ..Default::default()
2195                },
2196                overwrite_filter: None,
2197                ignore_existing: false,
2198                chunk_size: 0,
2199                skip_specials: false,
2200                remote_copy_buffer_size: 0,
2201                filter: None,
2202                dry_run: None,
2203                delete: None,
2204            },
2205            &NO_PRESERVE_SETTINGS,
2206            false,
2207        )
2208        .await?;
2209        assert_eq!(summary.files_copied, 5);
2210        assert_eq!(summary.symlinks_created, 2);
2211        assert_eq!(summary.directories_created, 3);
2212        testutils::check_dirs_identical(
2213            &test_path.join("foo"),
2214            &test_path.join("bar"),
2215            testutils::FileEqualityCheck::Basic,
2216        )
2217        .await?;
2218        Ok(())
2219    }
2220
2221    #[tokio::test]
2222    #[traced_test]
2223    async fn no_read_permission() -> Result<(), anyhow::Error> {
2224        let tmp_dir = testutils::setup_test_dir().await?;
2225        let test_path = tmp_dir.as_path();
2226        let filepaths = vec![
2227            test_path.join("foo").join("0.txt"),
2228            test_path.join("foo").join("baz"),
2229        ];
2230        for fpath in &filepaths {
2231            // change file permissions to not readable
2232            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
2233        }
2234        match copy(
2235            &PROGRESS,
2236            &test_path.join("foo"),
2237            &test_path.join("bar"),
2238            &Settings {
2239                dereference: false,
2240                fail_early: false,
2241                overwrite: false,
2242                overwrite_compare: filecmp::MetadataCmpSettings {
2243                    size: true,
2244                    mtime: true,
2245                    ..Default::default()
2246                },
2247                overwrite_filter: None,
2248                ignore_existing: false,
2249                chunk_size: 0,
2250                skip_specials: false,
2251                remote_copy_buffer_size: 0,
2252                filter: None,
2253                dry_run: None,
2254                delete: None,
2255            },
2256            &NO_PRESERVE_SETTINGS,
2257            false,
2258        )
2259        .await
2260        {
2261            Ok(_) => panic!("Expected the copy to error!"),
2262            Err(error) => {
2263                tracing::info!("{}", &error);
2264                // foo
2265                // |- 0.txt  // <- no read permission
2266                // |- bar
2267                //    |- 1.txt
2268                //    |- 2.txt
2269                //    |- 3.txt
2270                // |- baz   // <- no read permission
2271                //    |- 4.txt
2272                //    |- 5.txt -> ../bar/2.txt
2273                //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
2274                assert_eq!(error.summary.files_copied, 3);
2275                assert_eq!(error.summary.symlinks_created, 0);
2276                assert_eq!(error.summary.directories_created, 2);
2277            }
2278        }
2279        // make source directory same as what we expect destination to be
2280        for fpath in &filepaths {
2281            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
2282            if tokio::fs::symlink_metadata(fpath).await?.is_file() {
2283                tokio::fs::remove_file(fpath).await?;
2284            } else {
2285                tokio::fs::remove_dir_all(fpath).await?;
2286            }
2287        }
2288        testutils::check_dirs_identical(
2289            &test_path.join("foo"),
2290            &test_path.join("bar"),
2291            testutils::FileEqualityCheck::Basic,
2292        )
2293        .await?;
2294        Ok(())
2295    }
2296
2297    #[tokio::test]
2298    #[traced_test]
2299    async fn check_default_mode() -> Result<(), anyhow::Error> {
2300        let tmp_dir = testutils::setup_test_dir().await?;
2301        // set file to executable
2302        tokio::fs::set_permissions(
2303            tmp_dir.join("foo").join("0.txt"),
2304            std::fs::Permissions::from_mode(0o700),
2305        )
2306        .await?;
2307        // set file executable AND also set sticky bit, setuid and setgid
2308        let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
2309        tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
2310            .await?;
2311        let test_path = tmp_dir.as_path();
2312        let summary = copy(
2313            &PROGRESS,
2314            &test_path.join("foo"),
2315            &test_path.join("bar"),
2316            &Settings {
2317                dereference: false,
2318                fail_early: false,
2319                overwrite: false,
2320                overwrite_compare: filecmp::MetadataCmpSettings {
2321                    size: true,
2322                    mtime: true,
2323                    ..Default::default()
2324                },
2325                overwrite_filter: None,
2326                ignore_existing: false,
2327                chunk_size: 0,
2328                skip_specials: false,
2329                remote_copy_buffer_size: 0,
2330                filter: None,
2331                dry_run: None,
2332                delete: None,
2333            },
2334            &NO_PRESERVE_SETTINGS,
2335            false,
2336        )
2337        .await?;
2338        assert_eq!(summary.files_copied, 5);
2339        assert_eq!(summary.symlinks_created, 2);
2340        assert_eq!(summary.directories_created, 3);
2341        // clear the setuid, setgid and sticky bit for comparison
2342        tokio::fs::set_permissions(
2343            &exec_sticky_file,
2344            std::fs::Permissions::from_mode(
2345                std::fs::symlink_metadata(&exec_sticky_file)?
2346                    .permissions()
2347                    .mode()
2348                    & 0o0777,
2349            ),
2350        )
2351        .await?;
2352        testutils::check_dirs_identical(
2353            &test_path.join("foo"),
2354            &test_path.join("bar"),
2355            testutils::FileEqualityCheck::Basic,
2356        )
2357        .await?;
2358        Ok(())
2359    }
2360
2361    #[tokio::test]
2362    #[traced_test]
2363    async fn no_write_permission() -> Result<(), anyhow::Error> {
2364        let tmp_dir = testutils::setup_test_dir().await?;
2365        let test_path = tmp_dir.as_path();
2366        // directory - readable and non-executable
2367        let non_exec_dir = test_path.join("foo").join("bogey");
2368        tokio::fs::create_dir(&non_exec_dir).await?;
2369        tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
2370        // directory - readable and executable
2371        tokio::fs::set_permissions(
2372            &test_path.join("foo").join("baz"),
2373            std::fs::Permissions::from_mode(0o500),
2374        )
2375        .await?;
2376        // file
2377        tokio::fs::set_permissions(
2378            &test_path.join("foo").join("baz").join("4.txt"),
2379            std::fs::Permissions::from_mode(0o440),
2380        )
2381        .await?;
2382        let summary = copy(
2383            &PROGRESS,
2384            &test_path.join("foo"),
2385            &test_path.join("bar"),
2386            &Settings {
2387                dereference: false,
2388                fail_early: false,
2389                overwrite: false,
2390                overwrite_compare: filecmp::MetadataCmpSettings {
2391                    size: true,
2392                    mtime: true,
2393                    ..Default::default()
2394                },
2395                overwrite_filter: None,
2396                ignore_existing: false,
2397                chunk_size: 0,
2398                skip_specials: false,
2399                remote_copy_buffer_size: 0,
2400                filter: None,
2401                dry_run: None,
2402                delete: None,
2403            },
2404            &NO_PRESERVE_SETTINGS,
2405            false,
2406        )
2407        .await?;
2408        assert_eq!(summary.files_copied, 5);
2409        assert_eq!(summary.symlinks_created, 2);
2410        assert_eq!(summary.directories_created, 4);
2411        testutils::check_dirs_identical(
2412            &test_path.join("foo"),
2413            &test_path.join("bar"),
2414            testutils::FileEqualityCheck::Basic,
2415        )
2416        .await?;
2417        Ok(())
2418    }
2419
2420    #[tokio::test]
2421    #[traced_test]
2422    async fn dereference() -> Result<(), anyhow::Error> {
2423        let tmp_dir = testutils::setup_test_dir().await?;
2424        let test_path = tmp_dir.as_path();
2425        // make files pointed to by symlinks have different permissions than the symlink itself
2426        let src1 = &test_path.join("foo").join("bar").join("2.txt");
2427        let src2 = &test_path.join("foo").join("bar").join("3.txt");
2428        let test_mode = 0o440;
2429        for f in [src1, src2] {
2430            tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
2431        }
2432        let summary = copy(
2433            &PROGRESS,
2434            &test_path.join("foo"),
2435            &test_path.join("bar"),
2436            &Settings {
2437                dereference: true, // <- important!
2438                fail_early: false,
2439                overwrite: false,
2440                overwrite_compare: filecmp::MetadataCmpSettings {
2441                    size: true,
2442                    mtime: true,
2443                    ..Default::default()
2444                },
2445                overwrite_filter: None,
2446                ignore_existing: false,
2447                chunk_size: 0,
2448                skip_specials: false,
2449                remote_copy_buffer_size: 0,
2450                filter: None,
2451                dry_run: None,
2452                delete: None,
2453            },
2454            &NO_PRESERVE_SETTINGS,
2455            false,
2456        )
2457        .await?;
2458        assert_eq!(summary.files_copied, 7);
2459        assert_eq!(summary.symlinks_created, 0);
2460        assert_eq!(summary.directories_created, 3);
2461        // ...
2462        // |- baz
2463        //    |- 4.txt
2464        //    |- 5.txt -> ../bar/2.txt
2465        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
2466        let dst1 = &test_path.join("bar").join("baz").join("5.txt");
2467        let dst2 = &test_path.join("bar").join("baz").join("6.txt");
2468        for f in [dst1, dst2] {
2469            let metadata = tokio::fs::symlink_metadata(f)
2470                .await
2471                .with_context(|| format!("failed reading metadata from {:?}", &f))?;
2472            assert!(metadata.is_file());
2473            // check that the permissions are the same as the source file modulo no sticky bit, setuid and setgid
2474            assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
2475        }
2476        Ok(())
2477    }
2478
2479    async fn cp_compare(
2480        cp_args: &[&str],
2481        rcp_settings: &Settings,
2482        preserve: bool,
2483    ) -> Result<(), anyhow::Error> {
2484        let tmp_dir = testutils::setup_test_dir().await?;
2485        let test_path = tmp_dir.as_path();
2486        // run a cp command to copy the files
2487        let cp_output = tokio::process::Command::new("cp")
2488            .args(cp_args)
2489            .arg(test_path.join("foo"))
2490            .arg(test_path.join("bar"))
2491            .output()
2492            .await?;
2493        assert!(cp_output.status.success());
2494        // now run rcp
2495        let summary = copy(
2496            &PROGRESS,
2497            &test_path.join("foo"),
2498            &test_path.join("baz"),
2499            rcp_settings,
2500            if preserve {
2501                &DO_PRESERVE_SETTINGS
2502            } else {
2503                &NO_PRESERVE_SETTINGS
2504            },
2505            false,
2506        )
2507        .await?;
2508        if rcp_settings.dereference {
2509            assert_eq!(summary.files_copied, 7);
2510            assert_eq!(summary.symlinks_created, 0);
2511        } else {
2512            assert_eq!(summary.files_copied, 5);
2513            assert_eq!(summary.symlinks_created, 2);
2514        }
2515        assert_eq!(summary.directories_created, 3);
2516        testutils::check_dirs_identical(
2517            &test_path.join("bar"),
2518            &test_path.join("baz"),
2519            if preserve {
2520                testutils::FileEqualityCheck::Timestamp
2521            } else {
2522                testutils::FileEqualityCheck::Basic
2523            },
2524        )
2525        .await?;
2526        Ok(())
2527    }
2528
2529    #[tokio::test]
2530    #[traced_test]
2531    async fn test_cp_compat() -> Result<(), anyhow::Error> {
2532        cp_compare(
2533            &["-r"],
2534            &Settings {
2535                dereference: false,
2536                fail_early: false,
2537                overwrite: false,
2538                overwrite_compare: filecmp::MetadataCmpSettings {
2539                    size: true,
2540                    mtime: true,
2541                    ..Default::default()
2542                },
2543                overwrite_filter: None,
2544                ignore_existing: false,
2545                chunk_size: 0,
2546                skip_specials: false,
2547                remote_copy_buffer_size: 0,
2548                filter: None,
2549                dry_run: None,
2550                delete: None,
2551            },
2552            false,
2553        )
2554        .await?;
2555        Ok(())
2556    }
2557
2558    #[tokio::test]
2559    #[traced_test]
2560    async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
2561        cp_compare(
2562            &["-r", "-p"],
2563            &Settings {
2564                dereference: false,
2565                fail_early: false,
2566                overwrite: false,
2567                overwrite_compare: filecmp::MetadataCmpSettings {
2568                    size: true,
2569                    mtime: true,
2570                    ..Default::default()
2571                },
2572                overwrite_filter: None,
2573                ignore_existing: false,
2574                chunk_size: 0,
2575                skip_specials: false,
2576                remote_copy_buffer_size: 0,
2577                filter: None,
2578                dry_run: None,
2579                delete: None,
2580            },
2581            true,
2582        )
2583        .await?;
2584        Ok(())
2585    }
2586
2587    #[tokio::test]
2588    #[traced_test]
2589    async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
2590        cp_compare(
2591            &["-r", "-L"],
2592            &Settings {
2593                dereference: true,
2594                fail_early: false,
2595                overwrite: false,
2596                overwrite_compare: filecmp::MetadataCmpSettings {
2597                    size: true,
2598                    mtime: true,
2599                    ..Default::default()
2600                },
2601                overwrite_filter: None,
2602                ignore_existing: false,
2603                chunk_size: 0,
2604                skip_specials: false,
2605                remote_copy_buffer_size: 0,
2606                filter: None,
2607                dry_run: None,
2608                delete: None,
2609            },
2610            false,
2611        )
2612        .await?;
2613        Ok(())
2614    }
2615
2616    #[tokio::test]
2617    #[traced_test]
2618    async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
2619        cp_compare(
2620            &["-r", "-p", "-L"],
2621            &Settings {
2622                dereference: true,
2623                fail_early: false,
2624                overwrite: false,
2625                overwrite_compare: filecmp::MetadataCmpSettings {
2626                    size: true,
2627                    mtime: true,
2628                    ..Default::default()
2629                },
2630                overwrite_filter: None,
2631                ignore_existing: false,
2632                chunk_size: 0,
2633                skip_specials: false,
2634                remote_copy_buffer_size: 0,
2635                filter: None,
2636                dry_run: None,
2637                delete: None,
2638            },
2639            true,
2640        )
2641        .await?;
2642        Ok(())
2643    }
2644
2645    async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
2646        let tmp_dir = testutils::setup_test_dir().await?;
2647        let test_path = tmp_dir.as_path();
2648        let summary = copy(
2649            &PROGRESS,
2650            &test_path.join("foo"),
2651            &test_path.join("bar"),
2652            &Settings {
2653                dereference: false,
2654                fail_early: false,
2655                overwrite: false,
2656                overwrite_compare: filecmp::MetadataCmpSettings {
2657                    size: true,
2658                    mtime: true,
2659                    ..Default::default()
2660                },
2661                overwrite_filter: None,
2662                ignore_existing: false,
2663                chunk_size: 0,
2664                skip_specials: false,
2665                remote_copy_buffer_size: 0,
2666                filter: None,
2667                dry_run: None,
2668                delete: None,
2669            },
2670            &DO_PRESERVE_SETTINGS,
2671            false,
2672        )
2673        .await?;
2674        assert_eq!(summary.files_copied, 5);
2675        assert_eq!(summary.symlinks_created, 2);
2676        assert_eq!(summary.directories_created, 3);
2677        Ok(tmp_dir)
2678    }
2679
2680    #[tokio::test]
2681    #[traced_test]
2682    async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
2683        let tmp_dir = setup_test_dir_and_copy().await?;
2684        let output_path = &tmp_dir.join("bar");
2685        {
2686            // bar
2687            // |- 0.txt
2688            // |- bar  <---------------------------------------- REMOVE
2689            //    |- 1.txt  <----------------------------------- REMOVE
2690            //    |- 2.txt  <----------------------------------- REMOVE
2691            //    |- 3.txt  <----------------------------------- REMOVE
2692            // |- baz
2693            //    |- 4.txt
2694            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
2695            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
2696            let summary = rm::rm(
2697                &PROGRESS,
2698                &output_path.join("bar"),
2699                &RmSettings {
2700                    fail_early: false,
2701                    filter: None,
2702                    dry_run: None,
2703                    time_filter: None,
2704                },
2705            )
2706            .await?
2707                + rm::rm(
2708                    &PROGRESS,
2709                    &output_path.join("baz").join("5.txt"),
2710                    &RmSettings {
2711                        fail_early: false,
2712                        filter: None,
2713                        dry_run: None,
2714                        time_filter: None,
2715                    },
2716                )
2717                .await?;
2718            assert_eq!(summary.files_removed, 3);
2719            assert_eq!(summary.symlinks_removed, 1);
2720            assert_eq!(summary.directories_removed, 1);
2721        }
2722        let summary = copy(
2723            &PROGRESS,
2724            &tmp_dir.join("foo"),
2725            output_path,
2726            &Settings {
2727                dereference: false,
2728                fail_early: false,
2729                overwrite: true, // <- important!
2730                overwrite_compare: filecmp::MetadataCmpSettings {
2731                    size: true,
2732                    mtime: true,
2733                    ..Default::default()
2734                },
2735                overwrite_filter: None,
2736                ignore_existing: false,
2737                chunk_size: 0,
2738                skip_specials: false,
2739                remote_copy_buffer_size: 0,
2740                filter: None,
2741                dry_run: None,
2742                delete: None,
2743            },
2744            &DO_PRESERVE_SETTINGS,
2745            false,
2746        )
2747        .await?;
2748        assert_eq!(summary.files_copied, 3);
2749        assert_eq!(summary.symlinks_created, 1);
2750        assert_eq!(summary.directories_created, 1);
2751        testutils::check_dirs_identical(
2752            &tmp_dir.join("foo"),
2753            output_path,
2754            testutils::FileEqualityCheck::Timestamp,
2755        )
2756        .await?;
2757        Ok(())
2758    }
2759
2760    #[tokio::test]
2761    #[traced_test]
2762    async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
2763        let tmp_dir = setup_test_dir_and_copy().await?;
2764        let output_path = &tmp_dir.join("bar");
2765        {
2766            // bar
2767            // |- 0.txt
2768            // |- bar
2769            //    |- 1.txt  <------------------------------------- REMOVE
2770            //    |- 2.txt
2771            //    |- 3.txt
2772            // |- baz  <------------------------------------------ REMOVE
2773            //    |- 4.txt  <------------------------------------- REMOVE
2774            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
2775            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt <- REMOVE
2776            let summary = rm::rm(
2777                &PROGRESS,
2778                &output_path.join("bar").join("1.txt"),
2779                &RmSettings {
2780                    fail_early: false,
2781                    filter: None,
2782                    dry_run: None,
2783                    time_filter: None,
2784                },
2785            )
2786            .await?
2787                + rm::rm(
2788                    &PROGRESS,
2789                    &output_path.join("baz"),
2790                    &RmSettings {
2791                        fail_early: false,
2792                        filter: None,
2793                        dry_run: None,
2794                        time_filter: None,
2795                    },
2796                )
2797                .await?;
2798            assert_eq!(summary.files_removed, 2);
2799            assert_eq!(summary.symlinks_removed, 2);
2800            assert_eq!(summary.directories_removed, 1);
2801        }
2802        {
2803            // replace bar/1.txt file with a directory
2804            tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
2805            // replace baz directory with a file
2806            tokio::fs::write(&output_path.join("baz"), "baz").await?;
2807        }
2808        let summary = copy(
2809            &PROGRESS,
2810            &tmp_dir.join("foo"),
2811            output_path,
2812            &Settings {
2813                dereference: false,
2814                fail_early: false,
2815                overwrite: true, // <- important!
2816                overwrite_compare: filecmp::MetadataCmpSettings {
2817                    size: true,
2818                    mtime: true,
2819                    ..Default::default()
2820                },
2821                overwrite_filter: None,
2822                ignore_existing: false,
2823                chunk_size: 0,
2824                skip_specials: false,
2825                remote_copy_buffer_size: 0,
2826                filter: None,
2827                dry_run: None,
2828                delete: None,
2829            },
2830            &DO_PRESERVE_SETTINGS,
2831            false,
2832        )
2833        .await?;
2834        assert_eq!(summary.rm_summary.files_removed, 1);
2835        assert_eq!(summary.rm_summary.symlinks_removed, 0);
2836        assert_eq!(summary.rm_summary.directories_removed, 1);
2837        assert_eq!(summary.files_copied, 2);
2838        assert_eq!(summary.symlinks_created, 2);
2839        assert_eq!(summary.directories_created, 1);
2840        testutils::check_dirs_identical(
2841            &tmp_dir.join("foo"),
2842            output_path,
2843            testutils::FileEqualityCheck::Timestamp,
2844        )
2845        .await?;
2846        Ok(())
2847    }
2848
2849    #[tokio::test]
2850    #[traced_test]
2851    async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
2852        let tmp_dir = setup_test_dir_and_copy().await?;
2853        let output_path = &tmp_dir.join("bar");
2854        {
2855            // bar
2856            // |- 0.txt
2857            // |- baz
2858            //    |- 4.txt  <------------------------------------- REMOVE
2859            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
2860            // ...
2861            let summary = rm::rm(
2862                &PROGRESS,
2863                &output_path.join("baz").join("4.txt"),
2864                &RmSettings {
2865                    fail_early: false,
2866                    filter: None,
2867                    dry_run: None,
2868                    time_filter: None,
2869                },
2870            )
2871            .await?
2872                + rm::rm(
2873                    &PROGRESS,
2874                    &output_path.join("baz").join("5.txt"),
2875                    &RmSettings {
2876                        fail_early: false,
2877                        filter: None,
2878                        dry_run: None,
2879                        time_filter: None,
2880                    },
2881                )
2882                .await?;
2883            assert_eq!(summary.files_removed, 1);
2884            assert_eq!(summary.symlinks_removed, 1);
2885            assert_eq!(summary.directories_removed, 0);
2886        }
2887        {
2888            // replace baz/4.txt file with a symlink
2889            tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
2890            // replace baz/5.txt symlink with a file
2891            tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
2892        }
2893        let summary = copy(
2894            &PROGRESS,
2895            &tmp_dir.join("foo"),
2896            output_path,
2897            &Settings {
2898                dereference: false,
2899                fail_early: false,
2900                overwrite: true, // <- important!
2901                overwrite_compare: filecmp::MetadataCmpSettings {
2902                    size: true,
2903                    mtime: true,
2904                    ..Default::default()
2905                },
2906                overwrite_filter: None,
2907                ignore_existing: false,
2908                chunk_size: 0,
2909                skip_specials: false,
2910                remote_copy_buffer_size: 0,
2911                filter: None,
2912                dry_run: None,
2913                delete: None,
2914            },
2915            &DO_PRESERVE_SETTINGS,
2916            false,
2917        )
2918        .await?;
2919        assert_eq!(summary.rm_summary.files_removed, 1);
2920        assert_eq!(summary.rm_summary.symlinks_removed, 1);
2921        assert_eq!(summary.rm_summary.directories_removed, 0);
2922        assert_eq!(summary.files_copied, 1);
2923        assert_eq!(summary.symlinks_created, 1);
2924        assert_eq!(summary.directories_created, 0);
2925        testutils::check_dirs_identical(
2926            &tmp_dir.join("foo"),
2927            output_path,
2928            testutils::FileEqualityCheck::Timestamp,
2929        )
2930        .await?;
2931        Ok(())
2932    }
2933
2934    #[tokio::test]
2935    #[traced_test]
2936    async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
2937        let tmp_dir = setup_test_dir_and_copy().await?;
2938        let output_path = &tmp_dir.join("bar");
2939        {
2940            // bar
2941            // |- 0.txt
2942            // |- bar  <------------------------------------------ REMOVE
2943            //    |- 1.txt  <------------------------------------- REMOVE
2944            //    |- 2.txt  <------------------------------------- REMOVE
2945            //    |- 3.txt  <------------------------------------- REMOVE
2946            // |- baz
2947            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
2948            // ...
2949            let summary = rm::rm(
2950                &PROGRESS,
2951                &output_path.join("bar"),
2952                &RmSettings {
2953                    fail_early: false,
2954                    filter: None,
2955                    dry_run: None,
2956                    time_filter: None,
2957                },
2958            )
2959            .await?
2960                + rm::rm(
2961                    &PROGRESS,
2962                    &output_path.join("baz").join("5.txt"),
2963                    &RmSettings {
2964                        fail_early: false,
2965                        filter: None,
2966                        dry_run: None,
2967                        time_filter: None,
2968                    },
2969                )
2970                .await?;
2971            assert_eq!(summary.files_removed, 3);
2972            assert_eq!(summary.symlinks_removed, 1);
2973            assert_eq!(summary.directories_removed, 1);
2974        }
2975        {
2976            // replace bar directory with a symlink
2977            tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
2978            // replace baz/5.txt symlink with a directory
2979            tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
2980        }
2981        let summary = copy(
2982            &PROGRESS,
2983            &tmp_dir.join("foo"),
2984            output_path,
2985            &Settings {
2986                dereference: false,
2987                fail_early: false,
2988                overwrite: true, // <- important!
2989                overwrite_compare: filecmp::MetadataCmpSettings {
2990                    size: true,
2991                    mtime: true,
2992                    ..Default::default()
2993                },
2994                overwrite_filter: None,
2995                ignore_existing: false,
2996                chunk_size: 0,
2997                skip_specials: false,
2998                remote_copy_buffer_size: 0,
2999                filter: None,
3000                dry_run: None,
3001                delete: None,
3002            },
3003            &DO_PRESERVE_SETTINGS,
3004            false,
3005        )
3006        .await?;
3007        assert_eq!(summary.rm_summary.files_removed, 0);
3008        assert_eq!(summary.rm_summary.symlinks_removed, 1);
3009        assert_eq!(summary.rm_summary.directories_removed, 1);
3010        assert_eq!(summary.files_copied, 3);
3011        assert_eq!(summary.symlinks_created, 1);
3012        assert_eq!(summary.directories_created, 1);
3013        assert_eq!(summary.files_unchanged, 2);
3014        assert_eq!(summary.symlinks_unchanged, 1);
3015        assert_eq!(summary.directories_unchanged, 2);
3016        testutils::check_dirs_identical(
3017            &tmp_dir.join("foo"),
3018            output_path,
3019            testutils::FileEqualityCheck::Timestamp,
3020        )
3021        .await?;
3022        Ok(())
3023    }
3024
3025    #[tokio::test]
3026    #[traced_test]
3027    async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
3028        let tmp_dir = testutils::setup_test_dir().await?;
3029        let test_path = tmp_dir.as_path();
3030        let summary = copy(
3031            &PROGRESS,
3032            &test_path.join("foo"),
3033            &test_path.join("bar"),
3034            &Settings {
3035                dereference: false,
3036                fail_early: false,
3037                overwrite: false,
3038                overwrite_compare: filecmp::MetadataCmpSettings {
3039                    size: true,
3040                    mtime: true,
3041                    ..Default::default()
3042                },
3043                overwrite_filter: None,
3044                ignore_existing: false,
3045                chunk_size: 0,
3046                skip_specials: false,
3047                remote_copy_buffer_size: 0,
3048                filter: None,
3049                dry_run: None,
3050                delete: None,
3051            },
3052            &NO_PRESERVE_SETTINGS, // we want timestamps to differ!
3053            false,
3054        )
3055        .await?;
3056        assert_eq!(summary.files_copied, 5);
3057        assert_eq!(summary.symlinks_created, 2);
3058        assert_eq!(summary.directories_created, 3);
3059        let source_path = &test_path.join("foo");
3060        let output_path = &tmp_dir.join("bar");
3061        // unreadable
3062        tokio::fs::set_permissions(
3063            &source_path.join("bar"),
3064            std::fs::Permissions::from_mode(0o000),
3065        )
3066        .await?;
3067        tokio::fs::set_permissions(
3068            &source_path.join("baz").join("4.txt"),
3069            std::fs::Permissions::from_mode(0o000),
3070        )
3071        .await?;
3072        // bar
3073        // |- 0.txt
3074        // |- bar  <---------------------------------------- NON READABLE
3075        // |- baz
3076        //    |- 4.txt  <----------------------------------- NON READABLE
3077        //    |- 5.txt -> ../bar/2.txt
3078        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
3079        match copy(
3080            &PROGRESS,
3081            &tmp_dir.join("foo"),
3082            output_path,
3083            &Settings {
3084                dereference: false,
3085                fail_early: false,
3086                overwrite: true, // <- important!
3087                overwrite_compare: filecmp::MetadataCmpSettings {
3088                    size: true,
3089                    mtime: true,
3090                    ..Default::default()
3091                },
3092                overwrite_filter: None,
3093                ignore_existing: false,
3094                chunk_size: 0,
3095                skip_specials: false,
3096                remote_copy_buffer_size: 0,
3097                filter: None,
3098                dry_run: None,
3099                delete: None,
3100            },
3101            &DO_PRESERVE_SETTINGS,
3102            false,
3103        )
3104        .await
3105        {
3106            Ok(_) => panic!("Expected the copy to error!"),
3107            Err(error) => {
3108                tracing::info!("{}", &error);
3109                assert_eq!(error.summary.files_copied, 1);
3110                assert_eq!(error.summary.symlinks_created, 2);
3111                assert_eq!(error.summary.directories_created, 0);
3112                assert_eq!(error.summary.rm_summary.files_removed, 2);
3113                assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
3114                assert_eq!(error.summary.rm_summary.directories_removed, 0);
3115            }
3116        }
3117        Ok(())
3118    }
3119
3120    #[tokio::test]
3121    #[traced_test]
3122    async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
3123        // Create a fresh temporary directory to avoid conflicts
3124        let tmp_dir = testutils::create_temp_dir().await?;
3125        let test_path = tmp_dir.as_path();
3126        // Create a chain of symlinks: foo -> bar -> baz (actual file)
3127        let baz_file = test_path.join("baz_file.txt");
3128        tokio::fs::write(&baz_file, "final content").await?;
3129        let bar_link = test_path.join("bar_link");
3130        let foo_link = test_path.join("foo_link");
3131        // Create chain: foo_link -> bar_link -> baz_file.txt
3132        tokio::fs::symlink(&baz_file, &bar_link).await?;
3133        tokio::fs::symlink(&bar_link, &foo_link).await?;
3134        // Create source directory with the symlink chain
3135        let src_dir = test_path.join("src_chain");
3136        tokio::fs::create_dir(&src_dir).await?;
3137        // Copy the chain into the source directory
3138        tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
3139        tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
3140        tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
3141        // Test with dereference - should copy 3 files with same content
3142        let summary = copy(
3143            &PROGRESS,
3144            &src_dir,
3145            &test_path.join("dst_with_deref"),
3146            &Settings {
3147                dereference: true, // <- important!
3148                fail_early: false,
3149                overwrite: false,
3150                overwrite_compare: filecmp::MetadataCmpSettings {
3151                    size: true,
3152                    mtime: true,
3153                    ..Default::default()
3154                },
3155                overwrite_filter: None,
3156                ignore_existing: false,
3157                chunk_size: 0,
3158                skip_specials: false,
3159                remote_copy_buffer_size: 0,
3160                filter: None,
3161                dry_run: None,
3162                delete: None,
3163            },
3164            &NO_PRESERVE_SETTINGS,
3165            false,
3166        )
3167        .await?;
3168        assert_eq!(summary.files_copied, 3); // foo, bar, baz all copied as files
3169        assert_eq!(summary.symlinks_created, 0); // dereference is set
3170        assert_eq!(summary.directories_created, 1);
3171        let dst_dir = test_path.join("dst_with_deref");
3172        // Verify all three are now regular files with the same content
3173        let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
3174        let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
3175        let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
3176        assert_eq!(foo_content, "final content");
3177        assert_eq!(bar_content, "final content");
3178        assert_eq!(baz_content, "final content");
3179        // Verify they are all regular files, not symlinks
3180        assert!(dst_dir.join("foo").is_file());
3181        assert!(dst_dir.join("bar").is_file());
3182        assert!(dst_dir.join("baz").is_file());
3183        assert!(!dst_dir.join("foo").is_symlink());
3184        assert!(!dst_dir.join("bar").is_symlink());
3185        assert!(!dst_dir.join("baz").is_symlink());
3186        Ok(())
3187    }
3188
3189    #[tokio::test]
3190    #[traced_test]
3191    async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
3192        let tmp_dir = testutils::create_temp_dir().await?;
3193        let test_path = tmp_dir.as_path();
3194        // Create a directory with specific permissions and content
3195        let target_dir = test_path.join("target_dir");
3196        tokio::fs::create_dir(&target_dir).await?;
3197        tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
3198        // Add some files to the directory
3199        tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
3200        tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
3201        tokio::fs::set_permissions(
3202            &target_dir.join("file1.txt"),
3203            std::fs::Permissions::from_mode(0o644),
3204        )
3205        .await?;
3206        tokio::fs::set_permissions(
3207            &target_dir.join("file2.txt"),
3208            std::fs::Permissions::from_mode(0o600),
3209        )
3210        .await?;
3211        // Create a symlink pointing to the directory
3212        let dir_symlink = test_path.join("dir_symlink");
3213        tokio::fs::symlink(&target_dir, &dir_symlink).await?;
3214        // Test copying the symlink with dereference - should copy as a directory
3215        let summary = copy(
3216            &PROGRESS,
3217            &dir_symlink,
3218            &test_path.join("copied_dir"),
3219            &Settings {
3220                dereference: true, // <- important!
3221                fail_early: false,
3222                overwrite: false,
3223                overwrite_compare: filecmp::MetadataCmpSettings {
3224                    size: true,
3225                    mtime: true,
3226                    ..Default::default()
3227                },
3228                overwrite_filter: None,
3229                ignore_existing: false,
3230                chunk_size: 0,
3231                skip_specials: false,
3232                remote_copy_buffer_size: 0,
3233                filter: None,
3234                dry_run: None,
3235                delete: None,
3236            },
3237            &DO_PRESERVE_SETTINGS,
3238            false,
3239        )
3240        .await?;
3241        assert_eq!(summary.files_copied, 2); // file1.txt, file2.txt
3242        assert_eq!(summary.symlinks_created, 0); // dereference is set
3243        assert_eq!(summary.directories_created, 1); // copied_dir
3244        let copied_dir = test_path.join("copied_dir");
3245        // Verify the directory and its contents were copied
3246        assert!(copied_dir.is_dir());
3247        assert!(!copied_dir.is_symlink()); // Should be a real directory, not a symlink
3248        // Verify files were copied with correct content
3249        let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
3250        let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
3251        assert_eq!(file1_content, "content1");
3252        assert_eq!(file2_content, "content2");
3253        // Verify permissions were preserved
3254        let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
3255        let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
3256        let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
3257        assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
3258        assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
3259        assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
3260        Ok(())
3261    }
3262
3263    #[tokio::test]
3264    #[traced_test]
3265    async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
3266        let tmp_dir = testutils::create_temp_dir().await?;
3267        let test_path = tmp_dir.as_path();
3268        // Create files with specific permissions
3269        let file1 = test_path.join("file1.txt");
3270        let file2 = test_path.join("file2.txt");
3271        tokio::fs::write(&file1, "content1").await?;
3272        tokio::fs::write(&file2, "content2").await?;
3273        tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
3274        tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
3275        // Create symlinks pointing to these files
3276        let symlink1 = test_path.join("symlink1");
3277        let symlink2 = test_path.join("symlink2");
3278        tokio::fs::symlink(&file1, &symlink1).await?;
3279        tokio::fs::symlink(&file2, &symlink2).await?;
3280        // Test copying symlinks with dereference and preserve
3281        let summary1 = copy(
3282            &PROGRESS,
3283            &symlink1,
3284            &test_path.join("copied_file1.txt"),
3285            &Settings {
3286                dereference: true, // <- important!
3287                fail_early: false,
3288                overwrite: false,
3289                overwrite_compare: filecmp::MetadataCmpSettings::default(),
3290                overwrite_filter: None,
3291                ignore_existing: false,
3292                chunk_size: 0,
3293                skip_specials: false,
3294                remote_copy_buffer_size: 0,
3295                filter: None,
3296                dry_run: None,
3297                delete: None,
3298            },
3299            &DO_PRESERVE_SETTINGS, // <- important!
3300            false,
3301        )
3302        .await?;
3303        let summary2 = copy(
3304            &PROGRESS,
3305            &symlink2,
3306            &test_path.join("copied_file2.txt"),
3307            &Settings {
3308                dereference: true,
3309                fail_early: false,
3310                overwrite: false,
3311                overwrite_compare: filecmp::MetadataCmpSettings::default(),
3312                overwrite_filter: None,
3313                ignore_existing: false,
3314                chunk_size: 0,
3315                skip_specials: false,
3316                remote_copy_buffer_size: 0,
3317                filter: None,
3318                dry_run: None,
3319                delete: None,
3320            },
3321            &DO_PRESERVE_SETTINGS,
3322            false,
3323        )
3324        .await?;
3325        assert_eq!(summary1.files_copied, 1);
3326        assert_eq!(summary1.symlinks_created, 0);
3327        assert_eq!(summary2.files_copied, 1);
3328        assert_eq!(summary2.symlinks_created, 0);
3329        let copied1 = test_path.join("copied_file1.txt");
3330        let copied2 = test_path.join("copied_file2.txt");
3331        // Verify files are regular files, not symlinks
3332        assert!(copied1.is_file());
3333        assert!(!copied1.is_symlink());
3334        assert!(copied2.is_file());
3335        assert!(!copied2.is_symlink());
3336        // Verify content was copied correctly
3337        let content1 = tokio::fs::read_to_string(&copied1).await?;
3338        let content2 = tokio::fs::read_to_string(&copied2).await?;
3339        assert_eq!(content1, "content1");
3340        assert_eq!(content2, "content2");
3341        // Verify permissions from the target files were preserved (not symlink permissions)
3342        let copied1_metadata = tokio::fs::metadata(&copied1).await?;
3343        let copied2_metadata = tokio::fs::metadata(&copied2).await?;
3344        assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
3345        assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
3346        Ok(())
3347    }
3348
3349    #[tokio::test]
3350    #[traced_test]
3351    async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
3352        let tmp_dir = testutils::setup_test_dir().await?;
3353        // symlink bar to bar-link
3354        tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
3355        // symlink bar-link to bar-link-link
3356        tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
3357        let summary = copy(
3358            &PROGRESS,
3359            &tmp_dir.join("foo"),
3360            &tmp_dir.join("bar"),
3361            &Settings {
3362                dereference: true, // <- important!
3363                fail_early: false,
3364                overwrite: false,
3365                overwrite_compare: filecmp::MetadataCmpSettings {
3366                    size: true,
3367                    mtime: true,
3368                    ..Default::default()
3369                },
3370                overwrite_filter: None,
3371                ignore_existing: false,
3372                chunk_size: 0,
3373                skip_specials: false,
3374                remote_copy_buffer_size: 0,
3375                filter: None,
3376                dry_run: None,
3377                delete: None,
3378            },
3379            &DO_PRESERVE_SETTINGS,
3380            false,
3381        )
3382        .await?;
3383        assert_eq!(summary.files_copied, 13); // 0.txt, 3x bar/(1.txt, 2.txt, 3.txt), baz/(4.txt, 5.txt, 6.txt)
3384        assert_eq!(summary.symlinks_created, 0); // dereference is set
3385        assert_eq!(summary.directories_created, 5);
3386        // check_dirs_identical doesn't handle dereference so let's do it manually
3387        tokio::process::Command::new("cp")
3388            .args(["-r", "-L"])
3389            .arg(tmp_dir.join("foo"))
3390            .arg(tmp_dir.join("bar-cp"))
3391            .output()
3392            .await?;
3393        testutils::check_dirs_identical(
3394            &tmp_dir.join("bar"),
3395            &tmp_dir.join("bar-cp"),
3396            testutils::FileEqualityCheck::Basic,
3397        )
3398        .await?;
3399        Ok(())
3400    }
3401
3402    /// Tests to verify error messages include root causes for debugging
3403    mod error_message_tests {
3404        use super::*;
3405
3406        /// Helper to extract full error message with chain
3407        fn get_full_error_message(error: &Error) -> String {
3408            format!("{:#}", error.source)
3409        }
3410
3411        #[tokio::test]
3412        #[traced_test]
3413        async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
3414            let tmp_dir = testutils::create_temp_dir().await?;
3415
3416            let result = copy(
3417                &PROGRESS,
3418                &tmp_dir.join("does_not_exist.txt"),
3419                &tmp_dir.join("dest.txt"),
3420                &Settings {
3421                    dereference: false,
3422                    fail_early: false,
3423                    overwrite: false,
3424                    overwrite_compare: Default::default(),
3425                    overwrite_filter: None,
3426                    ignore_existing: false,
3427                    chunk_size: 0,
3428                    skip_specials: false,
3429                    remote_copy_buffer_size: 0,
3430                    filter: None,
3431                    dry_run: None,
3432                    delete: None,
3433                },
3434                &NO_PRESERVE_SETTINGS,
3435                false,
3436            )
3437            .await;
3438
3439            assert!(result.is_err());
3440            let err_msg = get_full_error_message(&result.unwrap_err());
3441
3442            assert!(
3443                err_msg.to_lowercase().contains("no such file")
3444                    || err_msg.to_lowercase().contains("not found")
3445                    || err_msg.contains("ENOENT"),
3446                "Error message must include file not found text. Got: {}",
3447                err_msg
3448            );
3449            Ok(())
3450        }
3451
3452        #[tokio::test]
3453        #[traced_test]
3454        async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
3455            let tmp_dir = testutils::create_temp_dir().await?;
3456            let unreadable_dir = tmp_dir.join("unreadable_dir");
3457            tokio::fs::create_dir(&unreadable_dir).await?;
3458            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
3459                .await?;
3460
3461            let result = copy(
3462                &PROGRESS,
3463                &unreadable_dir,
3464                &tmp_dir.join("dest"),
3465                &Settings {
3466                    dereference: false,
3467                    fail_early: true,
3468                    overwrite: false,
3469                    overwrite_compare: Default::default(),
3470                    overwrite_filter: None,
3471                    ignore_existing: false,
3472                    chunk_size: 0,
3473                    skip_specials: false,
3474                    remote_copy_buffer_size: 0,
3475                    filter: None,
3476                    dry_run: None,
3477                    delete: None,
3478                },
3479                &NO_PRESERVE_SETTINGS,
3480                false,
3481            )
3482            .await;
3483
3484            assert!(result.is_err());
3485            let err_msg = get_full_error_message(&result.unwrap_err());
3486
3487            assert!(
3488                err_msg.to_lowercase().contains("permission")
3489                    || err_msg.contains("EACCES")
3490                    || err_msg.contains("denied"),
3491                "Error message must include permission-related text. Got: {}",
3492                err_msg
3493            );
3494
3495            // Clean up - restore permissions so cleanup can remove it
3496            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
3497                .await?;
3498            Ok(())
3499        }
3500
3501        #[tokio::test]
3502        #[traced_test]
3503        async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
3504        {
3505            let tmp_dir = testutils::setup_test_dir().await?;
3506            let test_path = tmp_dir.as_path();
3507            let readonly_parent = test_path.join("readonly_dest");
3508            tokio::fs::create_dir(&readonly_parent).await?;
3509            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3510                .await?;
3511
3512            let result = copy(
3513                &PROGRESS,
3514                &test_path.join("foo"),
3515                &readonly_parent.join("copy"),
3516                &Settings {
3517                    dereference: false,
3518                    fail_early: true,
3519                    overwrite: false,
3520                    overwrite_compare: Default::default(),
3521                    overwrite_filter: None,
3522                    ignore_existing: false,
3523                    chunk_size: 0,
3524                    skip_specials: false,
3525                    remote_copy_buffer_size: 0,
3526                    filter: None,
3527                    dry_run: None,
3528                    delete: None,
3529                },
3530                &NO_PRESERVE_SETTINGS,
3531                false,
3532            )
3533            .await;
3534
3535            // restore permissions so cleanup succeeds even when copy fails
3536            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
3537                .await?;
3538
3539            assert!(result.is_err(), "copy into read-only parent should fail");
3540            let err_msg = get_full_error_message(&result.unwrap_err());
3541
3542            assert!(
3543                err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
3544                "Error message must include permission denied text. Got: {}",
3545                err_msg
3546            );
3547            Ok(())
3548        }
3549    }
3550
3551    mod empty_dir_cleanup_tests {
3552        use super::*;
3553        use crate::filter::FilterSettings;
3554        use std::path::Path;
3555        #[test]
3556        fn test_check_empty_dir_cleanup_no_filter() {
3557            // when no filter, always keep
3558            assert_eq!(
3559                check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
3560                EmptyDirAction::Keep
3561            );
3562        }
3563        #[test]
3564        fn test_check_empty_dir_cleanup_something_copied() {
3565            // when content was copied, keep
3566            let mut filter = FilterSettings::new();
3567            filter.add_include("*.txt").unwrap();
3568            assert_eq!(
3569                check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
3570                EmptyDirAction::Keep
3571            );
3572        }
3573        #[test]
3574        fn test_check_empty_dir_cleanup_not_created() {
3575            // when we didn't create the directory, keep
3576            let mut filter = FilterSettings::new();
3577            filter.add_include("*.txt").unwrap();
3578            assert_eq!(
3579                check_empty_dir_cleanup(
3580                    Some(&filter),
3581                    false,
3582                    false,
3583                    Path::new("any"),
3584                    false,
3585                    false
3586                ),
3587                EmptyDirAction::Keep
3588            );
3589        }
3590        #[test]
3591        fn test_check_empty_dir_cleanup_directly_matched() {
3592            // when directory directly matches include pattern, keep
3593            let mut filter = FilterSettings::new();
3594            filter.add_include("target/").unwrap();
3595            assert_eq!(
3596                check_empty_dir_cleanup(
3597                    Some(&filter),
3598                    true,
3599                    false,
3600                    Path::new("target"),
3601                    false,
3602                    false
3603                ),
3604                EmptyDirAction::Keep
3605            );
3606        }
3607        #[test]
3608        fn test_check_empty_dir_cleanup_traversed_only() {
3609            // when directory was only traversed, remove
3610            let mut filter = FilterSettings::new();
3611            filter.add_include("*.txt").unwrap();
3612            assert_eq!(
3613                check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
3614                EmptyDirAction::Remove
3615            );
3616        }
3617        #[test]
3618        fn test_check_empty_dir_cleanup_dry_run() {
3619            // in dry-run mode, skip instead of remove
3620            let mut filter = FilterSettings::new();
3621            filter.add_include("*.txt").unwrap();
3622            assert_eq!(
3623                check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
3624                EmptyDirAction::DryRunSkip
3625            );
3626        }
3627        #[test]
3628        fn test_check_empty_dir_cleanup_root_always_kept() {
3629            // root directory is never removed, even with filter and nothing copied
3630            let mut filter = FilterSettings::new();
3631            filter.add_include("*.txt").unwrap();
3632            assert_eq!(
3633                check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
3634                EmptyDirAction::Keep
3635            );
3636        }
3637        #[test]
3638        fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
3639            // root directory is kept even in dry-run mode
3640            let mut filter = FilterSettings::new();
3641            filter.add_include("*.txt").unwrap();
3642            assert_eq!(
3643                check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
3644                EmptyDirAction::Keep
3645            );
3646        }
3647    }
3648
3649    /// Verify that directory metadata is applied even when child operations fail.
3650    /// This is a regression test for a bug where directory permissions were not preserved
3651    /// when copying with fail_early=false and some children failed to copy.
3652    #[tokio::test]
3653    #[traced_test]
3654    async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
3655        let tmp_dir = testutils::create_temp_dir().await?;
3656        let test_path = tmp_dir.as_path();
3657        // create source directory with specific permissions
3658        let src_dir = test_path.join("src");
3659        tokio::fs::create_dir(&src_dir).await?;
3660        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
3661        // create a readable file and an unreadable file inside
3662        let readable_file = src_dir.join("readable.txt");
3663        tokio::fs::write(&readable_file, "content").await?;
3664        let unreadable_file = src_dir.join("unreadable.txt");
3665        tokio::fs::write(&unreadable_file, "secret").await?;
3666        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3667            .await?;
3668        let dst_dir = test_path.join("dst");
3669        // copy with fail_early=false and preserve=all
3670        let result = copy(
3671            &PROGRESS,
3672            &src_dir,
3673            &dst_dir,
3674            &Settings {
3675                dereference: false,
3676                fail_early: false,
3677                overwrite: false,
3678                overwrite_compare: Default::default(),
3679                overwrite_filter: None,
3680                ignore_existing: false,
3681                chunk_size: 0,
3682                skip_specials: false,
3683                remote_copy_buffer_size: 0,
3684                filter: None,
3685                dry_run: None,
3686                delete: None,
3687            },
3688            &DO_PRESERVE_SETTINGS,
3689            false,
3690        )
3691        .await;
3692        // restore permissions so cleanup can succeed
3693        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
3694            .await?;
3695        // verify the operation returned an error (unreadable file should fail)
3696        assert!(result.is_err(), "copy should fail due to unreadable file");
3697        let error = result.unwrap_err();
3698        // verify some files were copied (the readable one)
3699        assert_eq!(error.summary.files_copied, 1);
3700        assert_eq!(error.summary.directories_created, 1);
3701        // verify the destination directory exists and has the correct permissions
3702        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3703        assert!(dst_metadata.is_dir());
3704        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
3705        assert_eq!(
3706            actual_mode, 0o750,
3707            "directory should have preserved source permissions (0o750), got {:o}",
3708            actual_mode
3709        );
3710        Ok(())
3711    }
3712
3713    /// A child copy failure inside a filter-traversal-only directory must surface as an overall
3714    /// error even though the now-empty directory is pruned (regression: the empty-dir cleanup path
3715    /// returned Ok and swallowed the collected child error, so a failed copy looked successful).
3716    #[tokio::test]
3717    #[traced_test]
3718    async fn pruned_empty_traversal_dir_still_surfaces_child_error() -> Result<(), anyhow::Error> {
3719        let tmp_dir = testutils::create_temp_dir().await?;
3720        let test_path = tmp_dir.as_path();
3721        let src_dir = test_path.join("src");
3722        let sub = src_dir.join("sub");
3723        tokio::fs::create_dir_all(&sub).await?;
3724        // the only entry under `sub` is unreadable, so its copy fails and nothing lands in `sub`,
3725        // leaving it an empty traversal-only directory that the filter cleanup prunes.
3726        let unreadable = sub.join("file.txt");
3727        tokio::fs::write(&unreadable, "secret").await?;
3728        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3729        let dst_dir = test_path.join("dst");
3730        let mut filter = crate::filter::FilterSettings::new();
3731        filter.add_include("*.txt").unwrap();
3732        let result = copy(
3733            &PROGRESS,
3734            &src_dir,
3735            &dst_dir,
3736            &Settings {
3737                dereference: false,
3738                fail_early: false,
3739                overwrite: false,
3740                overwrite_compare: Default::default(),
3741                overwrite_filter: None,
3742                ignore_existing: false,
3743                chunk_size: 0,
3744                skip_specials: false,
3745                remote_copy_buffer_size: 0,
3746                filter: Some(filter),
3747                dry_run: None,
3748                delete: None,
3749            },
3750            &DO_PRESERVE_SETTINGS,
3751            false,
3752        )
3753        .await;
3754        // restore permissions so the temp dir can be cleaned up
3755        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).await?;
3756        assert!(
3757            result.is_err(),
3758            "the failed child copy must surface even though the empty traversal dir was pruned"
3759        );
3760        // the empty traversal directory itself was still cleaned up (count adjustment preserved).
3761        assert!(
3762            !dst_dir.join("sub").exists(),
3763            "empty traversal directory should have been removed"
3764        );
3765        Ok(())
3766    }
3767
3768    /// Verify that fail-early does not apply parent directory metadata after a child fails.
3769    #[tokio::test]
3770    #[traced_test]
3771    async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error()
3772    -> Result<(), anyhow::Error> {
3773        let tmp_dir = testutils::create_temp_dir().await?;
3774        let test_path = tmp_dir.as_path();
3775        let src_dir = test_path.join("src");
3776        tokio::fs::create_dir(&src_dir).await?;
3777        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
3778        let unreadable_file = src_dir.join("unreadable.txt");
3779        tokio::fs::write(&unreadable_file, "secret").await?;
3780        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3781            .await?;
3782        let fixed_secs = 946684800;
3783        let fixed_nsec = 123_456_789;
3784        let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
3785        nix::sys::stat::utimensat(
3786            nix::fcntl::AT_FDCWD,
3787            &src_dir,
3788            &fixed_time,
3789            &fixed_time,
3790            nix::sys::stat::UtimensatFlags::NoFollowSymlink,
3791        )?;
3792        let src_metadata = tokio::fs::metadata(&src_dir).await?;
3793        let dst_dir = test_path.join("dst");
3794        let result = copy(
3795            &PROGRESS,
3796            &src_dir,
3797            &dst_dir,
3798            &Settings {
3799                dereference: false,
3800                fail_early: true,
3801                overwrite: false,
3802                overwrite_compare: Default::default(),
3803                overwrite_filter: None,
3804                ignore_existing: false,
3805                chunk_size: 0,
3806                skip_specials: false,
3807                remote_copy_buffer_size: 0,
3808                filter: None,
3809                dry_run: None,
3810                delete: None,
3811            },
3812            &DO_PRESERVE_SETTINGS,
3813            false,
3814        )
3815        .await;
3816        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
3817            .await?;
3818        assert!(result.is_err(), "copy should fail due to unreadable file");
3819        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3820        assert!(dst_metadata.is_dir());
3821        assert_ne!(
3822            (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
3823            (src_metadata.mtime(), src_metadata.mtime_nsec()),
3824            "fail-early should return before applying preserved directory timestamps"
3825        );
3826        Ok(())
3827    }
3828    mod filter_tests {
3829        use super::*;
3830        use crate::filter::FilterSettings;
3831        /// Test that path-based patterns (with /) work correctly with nested paths.
3832        /// This test exposes the bug where only entry_name is passed to the filter
3833        /// instead of the relative path.
3834        #[tokio::test]
3835        #[traced_test]
3836        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
3837            let tmp_dir = testutils::setup_test_dir().await?;
3838            let test_path = tmp_dir.as_path();
3839            // test directory structure from setup_test_dir:
3840            // foo/
3841            //   0.txt
3842            //   bar/
3843            //     1.txt
3844            //     2.txt
3845            //   baz/
3846            //     3.txt -> ../0.txt (symlink)
3847            //     4.txt
3848            //     5 -> ../bar (symlink)
3849            // create filter that should match bar/*.txt (files in bar directory)
3850            let mut filter = FilterSettings::new();
3851            filter.add_include("bar/*.txt").unwrap();
3852            let summary = copy(
3853                &PROGRESS,
3854                &test_path.join("foo"),
3855                &test_path.join("dst"),
3856                &Settings {
3857                    dereference: false,
3858                    fail_early: false,
3859                    overwrite: false,
3860                    overwrite_compare: Default::default(),
3861                    overwrite_filter: None,
3862                    ignore_existing: false,
3863                    chunk_size: 0,
3864                    skip_specials: false,
3865                    remote_copy_buffer_size: 0,
3866                    filter: Some(filter),
3867                    dry_run: None,
3868                    delete: None,
3869                },
3870                &NO_PRESERVE_SETTINGS,
3871                false,
3872            )
3873            .await?;
3874            // should only copy files matching bar/*.txt pattern
3875            // bar/1.txt, bar/2.txt, and bar/3.txt should be copied
3876            assert_eq!(
3877                summary.files_copied, 3,
3878                "should copy 3 files matching bar/*.txt"
3879            );
3880            // verify the right files exist
3881            assert!(
3882                test_path.join("dst/bar/1.txt").exists(),
3883                "bar/1.txt should be copied"
3884            );
3885            assert!(
3886                test_path.join("dst/bar/2.txt").exists(),
3887                "bar/2.txt should be copied"
3888            );
3889            assert!(
3890                test_path.join("dst/bar/3.txt").exists(),
3891                "bar/3.txt should be copied"
3892            );
3893            // verify files outside the pattern don't exist
3894            assert!(
3895                !test_path.join("dst/0.txt").exists(),
3896                "0.txt should not be copied"
3897            );
3898            Ok(())
3899        }
3900        /// Test that anchored patterns (starting with /) match only at root.
3901        #[tokio::test]
3902        #[traced_test]
3903        async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
3904            let tmp_dir = testutils::setup_test_dir().await?;
3905            let test_path = tmp_dir.as_path();
3906            // create filter that should match /bar/** (bar directory and all its contents)
3907            let mut filter = FilterSettings::new();
3908            filter.add_include("/bar/**").unwrap();
3909            let summary = copy(
3910                &PROGRESS,
3911                &test_path.join("foo"),
3912                &test_path.join("dst"),
3913                &Settings {
3914                    dereference: false,
3915                    fail_early: false,
3916                    overwrite: false,
3917                    overwrite_compare: Default::default(),
3918                    overwrite_filter: None,
3919                    ignore_existing: false,
3920                    chunk_size: 0,
3921                    skip_specials: false,
3922                    remote_copy_buffer_size: 0,
3923                    filter: Some(filter),
3924                    dry_run: None,
3925                    delete: None,
3926                },
3927                &NO_PRESERVE_SETTINGS,
3928                false,
3929            )
3930            .await?;
3931            // should only copy bar directory and its contents
3932            assert!(
3933                test_path.join("dst/bar").exists(),
3934                "bar directory should be copied"
3935            );
3936            assert!(
3937                !test_path.join("dst/baz").exists(),
3938                "baz directory should not be copied"
3939            );
3940            assert!(
3941                !test_path.join("dst/0.txt").exists(),
3942                "0.txt should not be copied"
3943            );
3944            // verify summary counts
3945            assert_eq!(
3946                summary.files_copied, 3,
3947                "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
3948            );
3949            assert_eq!(
3950                summary.directories_created, 2,
3951                "should create 2 directories (root dst + bar)"
3952            );
3953            // skipped: 0.txt (file) and baz (directory) - baz contents not counted since dir is skipped
3954            assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
3955            assert_eq!(
3956                summary.directories_skipped, 1,
3957                "should skip 1 directory (baz)"
3958            );
3959            Ok(())
3960        }
3961        /// Test that double-star patterns (**) match across directories.
3962        #[tokio::test]
3963        #[traced_test]
3964        async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
3965            let tmp_dir = testutils::setup_test_dir().await?;
3966            let test_path = tmp_dir.as_path();
3967            // create filter that should match all .txt files at any depth
3968            let mut filter = FilterSettings::new();
3969            filter.add_include("**/*.txt").unwrap();
3970            let summary = copy(
3971                &PROGRESS,
3972                &test_path.join("foo"),
3973                &test_path.join("dst"),
3974                &Settings {
3975                    dereference: false,
3976                    fail_early: false,
3977                    overwrite: false,
3978                    overwrite_compare: Default::default(),
3979                    overwrite_filter: None,
3980                    ignore_existing: false,
3981                    chunk_size: 0,
3982                    skip_specials: false,
3983                    remote_copy_buffer_size: 0,
3984                    filter: Some(filter),
3985                    dry_run: None,
3986                    delete: None,
3987                },
3988                &NO_PRESERVE_SETTINGS,
3989                false,
3990            )
3991            .await?;
3992            // should copy all .txt files: 0.txt, bar/1.txt, bar/2.txt, bar/3.txt, baz/4.txt
3993            assert_eq!(
3994                summary.files_copied, 5,
3995                "should copy all 5 .txt files with **/*.txt pattern"
3996            );
3997            Ok(())
3998        }
3999        /// Test that filters are applied to top-level file arguments.
4000        #[tokio::test]
4001        #[traced_test]
4002        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
4003            let tmp_dir = testutils::setup_test_dir().await?;
4004            let test_path = tmp_dir.as_path();
4005            // create filter that excludes .txt files
4006            let mut filter = FilterSettings::new();
4007            filter.add_exclude("*.txt").unwrap();
4008            let result = copy(
4009                &PROGRESS,
4010                &test_path.join("foo/0.txt"), // single file source
4011                &test_path.join("dst.txt"),
4012                &Settings {
4013                    dereference: false,
4014                    fail_early: false,
4015                    overwrite: false,
4016                    overwrite_compare: Default::default(),
4017                    overwrite_filter: None,
4018                    ignore_existing: false,
4019                    chunk_size: 0,
4020                    skip_specials: false,
4021                    remote_copy_buffer_size: 0,
4022                    filter: Some(filter),
4023                    dry_run: None,
4024                    delete: None,
4025                },
4026                &NO_PRESERVE_SETTINGS,
4027                false,
4028            )
4029            .await?;
4030            // the file should NOT be copied because it matches the exclude pattern
4031            assert_eq!(
4032                result.files_copied, 0,
4033                "file matching exclude pattern should not be copied"
4034            );
4035            assert!(
4036                !test_path.join("dst.txt").exists(),
4037                "excluded file should not exist at destination"
4038            );
4039            Ok(())
4040        }
4041        /// Test that filters apply to root directories with simple exclude patterns.
4042        #[tokio::test]
4043        #[traced_test]
4044        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
4045            let test_path = testutils::create_temp_dir().await?;
4046            // create a directory that should be excluded
4047            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
4048            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
4049            // create filter that excludes *_dir/ directories
4050            let mut filter = FilterSettings::new();
4051            filter.add_exclude("*_dir/").unwrap();
4052            let result = copy(
4053                &PROGRESS,
4054                &test_path.join("excluded_dir"),
4055                &test_path.join("dst"),
4056                &Settings {
4057                    dereference: false,
4058                    fail_early: false,
4059                    overwrite: false,
4060                    overwrite_compare: Default::default(),
4061                    overwrite_filter: None,
4062                    ignore_existing: false,
4063                    chunk_size: 0,
4064                    skip_specials: false,
4065                    remote_copy_buffer_size: 0,
4066                    filter: Some(filter),
4067                    dry_run: None,
4068                    delete: None,
4069                },
4070                &NO_PRESERVE_SETTINGS,
4071                false,
4072            )
4073            .await?;
4074            // directory should NOT be copied because it matches exclude pattern
4075            assert_eq!(
4076                result.directories_created, 0,
4077                "root directory matching exclude should not be created"
4078            );
4079            assert!(
4080                !test_path.join("dst").exists(),
4081                "excluded root directory should not exist at destination"
4082            );
4083            Ok(())
4084        }
4085        /// Test that filters apply to root symlinks with simple exclude patterns.
4086        #[tokio::test]
4087        #[traced_test]
4088        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
4089            let test_path = testutils::create_temp_dir().await?;
4090            // create a target file and a symlink to it
4091            tokio::fs::write(test_path.join("target.txt"), "content").await?;
4092            tokio::fs::symlink(
4093                test_path.join("target.txt"),
4094                test_path.join("excluded_link"),
4095            )
4096            .await?;
4097            // create filter that excludes *_link
4098            let mut filter = FilterSettings::new();
4099            filter.add_exclude("*_link").unwrap();
4100            let result = copy(
4101                &PROGRESS,
4102                &test_path.join("excluded_link"),
4103                &test_path.join("dst"),
4104                &Settings {
4105                    dereference: false,
4106                    fail_early: false,
4107                    overwrite: false,
4108                    overwrite_compare: Default::default(),
4109                    overwrite_filter: None,
4110                    ignore_existing: false,
4111                    chunk_size: 0,
4112                    skip_specials: false,
4113                    remote_copy_buffer_size: 0,
4114                    filter: Some(filter),
4115                    dry_run: None,
4116                    delete: None,
4117                },
4118                &NO_PRESERVE_SETTINGS,
4119                false,
4120            )
4121            .await?;
4122            // symlink should NOT be copied because it matches exclude pattern
4123            assert_eq!(
4124                result.symlinks_created, 0,
4125                "root symlink matching exclude should not be created"
4126            );
4127            assert!(
4128                !test_path.join("dst").exists(),
4129                "excluded root symlink should not exist at destination"
4130            );
4131            Ok(())
4132        }
4133        /// Test combined include and exclude patterns (exclude takes precedence).
4134        #[tokio::test]
4135        #[traced_test]
4136        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
4137            let tmp_dir = testutils::setup_test_dir().await?;
4138            let test_path = tmp_dir.as_path();
4139            // test structure from setup_test_dir:
4140            // foo/
4141            //   0.txt
4142            //   bar/ (1.txt, 2.txt, 3.txt)
4143            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
4144            // include all .txt files, but exclude bar/2.txt specifically
4145            let mut filter = FilterSettings::new();
4146            filter.add_include("**/*.txt").unwrap();
4147            filter.add_exclude("bar/2.txt").unwrap();
4148            let summary = copy(
4149                &PROGRESS,
4150                &test_path.join("foo"),
4151                &test_path.join("dst"),
4152                &Settings {
4153                    dereference: false,
4154                    fail_early: false,
4155                    overwrite: false,
4156                    overwrite_compare: Default::default(),
4157                    overwrite_filter: None,
4158                    ignore_existing: false,
4159                    chunk_size: 0,
4160                    skip_specials: false,
4161                    remote_copy_buffer_size: 0,
4162                    filter: Some(filter),
4163                    dry_run: None,
4164                    delete: None,
4165                },
4166                &NO_PRESERVE_SETTINGS,
4167                false,
4168            )
4169            .await?;
4170            // should copy: 0.txt, bar/1.txt, bar/3.txt, baz/4.txt = 4 files
4171            // should skip: bar/2.txt (excluded by pattern) = 1 file
4172            // symlinks 5.txt and 6.txt don't match *.txt include pattern (symlinks, not files)
4173            assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
4174            assert_eq!(
4175                summary.files_skipped, 1,
4176                "should skip 1 file (bar/2.txt excluded)"
4177            );
4178            // verify specific files
4179            assert!(
4180                test_path.join("dst/bar/1.txt").exists(),
4181                "bar/1.txt should be copied"
4182            );
4183            assert!(
4184                !test_path.join("dst/bar/2.txt").exists(),
4185                "bar/2.txt should be excluded"
4186            );
4187            assert!(
4188                test_path.join("dst/bar/3.txt").exists(),
4189                "bar/3.txt should be copied"
4190            );
4191            Ok(())
4192        }
4193        /// Test that skipped counts accurately reflect what was filtered.
4194        #[tokio::test]
4195        #[traced_test]
4196        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
4197            let tmp_dir = testutils::setup_test_dir().await?;
4198            let test_path = tmp_dir.as_path();
4199            // test structure from setup_test_dir:
4200            // foo/
4201            //   0.txt
4202            //   bar/ (1.txt, 2.txt, 3.txt)
4203            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
4204            // exclude bar/ directory entirely
4205            let mut filter = FilterSettings::new();
4206            filter.add_exclude("bar/").unwrap();
4207            let summary = copy(
4208                &PROGRESS,
4209                &test_path.join("foo"),
4210                &test_path.join("dst"),
4211                &Settings {
4212                    dereference: false,
4213                    fail_early: false,
4214                    overwrite: false,
4215                    overwrite_compare: Default::default(),
4216                    overwrite_filter: None,
4217                    ignore_existing: false,
4218                    chunk_size: 0,
4219                    skip_specials: false,
4220                    remote_copy_buffer_size: 0,
4221                    filter: Some(filter),
4222                    dry_run: None,
4223                    delete: None,
4224                },
4225                &NO_PRESERVE_SETTINGS,
4226                false,
4227            )
4228            .await?;
4229            // copied: 0.txt (1 file), baz/4.txt (1 file), 5.txt symlink, 6.txt symlink
4230            // skipped: bar directory (1 dir) - contents not counted since whole dir skipped
4231            // directories: foo (root), baz = 2
4232            assert_eq!(summary.files_copied, 2, "should copy 2 files");
4233            assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
4234            assert_eq!(
4235                summary.directories_created, 2,
4236                "should create 2 directories"
4237            );
4238            assert_eq!(
4239                summary.directories_skipped, 1,
4240                "should skip 1 directory (bar)"
4241            );
4242            assert_eq!(
4243                summary.files_skipped, 0,
4244                "no files skipped (bar contents not counted)"
4245            );
4246            Ok(())
4247        }
4248        /// Test that empty directories are not created when they were only traversed to look
4249        /// for matches (regression test for bug where --include='foo' would create empty dir baz).
4250        #[tokio::test]
4251        #[traced_test]
4252        async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
4253            let test_path = testutils::create_temp_dir().await?;
4254            // create structure:
4255            // src/
4256            //   foo (file)
4257            //   bar (file)
4258            //   baz/ (empty directory)
4259            let src_path = test_path.join("src");
4260            tokio::fs::create_dir(&src_path).await?;
4261            tokio::fs::write(src_path.join("foo"), "content").await?;
4262            tokio::fs::write(src_path.join("bar"), "content").await?;
4263            tokio::fs::create_dir(src_path.join("baz")).await?;
4264            // include only 'foo' file
4265            let mut filter = FilterSettings::new();
4266            filter.add_include("foo").unwrap();
4267            let summary = copy(
4268                &PROGRESS,
4269                &src_path,
4270                &test_path.join("dst"),
4271                &Settings {
4272                    dereference: false,
4273                    fail_early: false,
4274                    overwrite: false,
4275                    overwrite_compare: Default::default(),
4276                    overwrite_filter: None,
4277                    ignore_existing: false,
4278                    chunk_size: 0,
4279                    skip_specials: false,
4280                    remote_copy_buffer_size: 0,
4281                    filter: Some(filter),
4282                    dry_run: None,
4283                    delete: None,
4284                },
4285                &NO_PRESERVE_SETTINGS,
4286                false,
4287            )
4288            .await?;
4289            // only 'foo' should be copied
4290            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4291            assert_eq!(
4292                summary.directories_created, 1,
4293                "should create only root directory (not empty 'baz')"
4294            );
4295            // verify foo was copied
4296            assert!(
4297                test_path.join("dst").join("foo").exists(),
4298                "foo should be copied"
4299            );
4300            // verify bar was not copied (not matching include pattern)
4301            assert!(
4302                !test_path.join("dst").join("bar").exists(),
4303                "bar should not be copied"
4304            );
4305            // verify empty baz directory was NOT created
4306            assert!(
4307                !test_path.join("dst").join("baz").exists(),
4308                "empty baz directory should NOT be created"
4309            );
4310            Ok(())
4311        }
4312        /// Test that directories with only non-matching content are not created at destination.
4313        /// This is different from empty directories - the source dir has content but none matches.
4314        #[tokio::test]
4315        #[traced_test]
4316        async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
4317            let test_path = testutils::create_temp_dir().await?;
4318            // create structure:
4319            // src/
4320            //   foo (file)
4321            //   baz/
4322            //     qux (file - doesn't match 'foo')
4323            //     quux (file - doesn't match 'foo')
4324            let src_path = test_path.join("src");
4325            tokio::fs::create_dir(&src_path).await?;
4326            tokio::fs::write(src_path.join("foo"), "content").await?;
4327            tokio::fs::create_dir(src_path.join("baz")).await?;
4328            tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
4329            tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
4330            // include only 'foo' file
4331            let mut filter = FilterSettings::new();
4332            filter.add_include("foo").unwrap();
4333            let summary = copy(
4334                &PROGRESS,
4335                &src_path,
4336                &test_path.join("dst"),
4337                &Settings {
4338                    dereference: false,
4339                    fail_early: false,
4340                    overwrite: false,
4341                    overwrite_compare: Default::default(),
4342                    overwrite_filter: None,
4343                    ignore_existing: false,
4344                    chunk_size: 0,
4345                    skip_specials: false,
4346                    remote_copy_buffer_size: 0,
4347                    filter: Some(filter),
4348                    dry_run: None,
4349                    delete: None,
4350                },
4351                &NO_PRESERVE_SETTINGS,
4352                false,
4353            )
4354            .await?;
4355            // only 'foo' should be copied
4356            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4357            assert_eq!(
4358                summary.files_skipped, 2,
4359                "should skip 2 files (qux and quux)"
4360            );
4361            assert_eq!(
4362                summary.directories_created, 1,
4363                "should create only root directory (not 'baz' with non-matching content)"
4364            );
4365            // verify foo was copied
4366            assert!(
4367                test_path.join("dst").join("foo").exists(),
4368                "foo should be copied"
4369            );
4370            // verify baz directory was NOT created (even though source baz has content)
4371            assert!(
4372                !test_path.join("dst").join("baz").exists(),
4373                "baz directory should NOT be created (no matching content inside)"
4374            );
4375            Ok(())
4376        }
4377        /// Test that empty directories are not reported as created in dry-run mode
4378        /// when they were only traversed.
4379        #[tokio::test]
4380        #[traced_test]
4381        async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
4382            let test_path = testutils::create_temp_dir().await?;
4383            // create structure:
4384            // src/
4385            //   foo (file)
4386            //   bar (file)
4387            //   baz/ (empty directory)
4388            let src_path = test_path.join("src");
4389            tokio::fs::create_dir(&src_path).await?;
4390            tokio::fs::write(src_path.join("foo"), "content").await?;
4391            tokio::fs::write(src_path.join("bar"), "content").await?;
4392            tokio::fs::create_dir(src_path.join("baz")).await?;
4393            // include only 'foo' file
4394            let mut filter = FilterSettings::new();
4395            filter.add_include("foo").unwrap();
4396            let summary = copy(
4397                &PROGRESS,
4398                &src_path,
4399                &test_path.join("dst"),
4400                &Settings {
4401                    dereference: false,
4402                    fail_early: false,
4403                    overwrite: false,
4404                    overwrite_compare: Default::default(),
4405                    overwrite_filter: None,
4406                    ignore_existing: false,
4407                    chunk_size: 0,
4408                    skip_specials: false,
4409                    remote_copy_buffer_size: 0,
4410                    filter: Some(filter),
4411                    dry_run: Some(crate::config::DryRunMode::Explain),
4412                    delete: None,
4413                },
4414                &NO_PRESERVE_SETTINGS,
4415                false,
4416            )
4417            .await?;
4418            // only 'foo' should be reported as would-be-copied
4419            assert_eq!(
4420                summary.files_copied, 1,
4421                "should report only 'foo' would be copied"
4422            );
4423            assert_eq!(
4424                summary.directories_created, 1,
4425                "should report only root directory would be created (not empty 'baz')"
4426            );
4427            // verify nothing was actually created (dry-run mode)
4428            assert!(
4429                !test_path.join("dst").exists(),
4430                "dst should not exist in dry-run"
4431            );
4432            Ok(())
4433        }
4434        /// Test that existing directories are NOT removed when using --overwrite,
4435        /// even if nothing is copied into them due to filters.
4436        #[tokio::test]
4437        #[traced_test]
4438        async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
4439            let test_path = testutils::create_temp_dir().await?;
4440            // create source structure:
4441            // src/
4442            //   foo (file)
4443            //   bar (file)
4444            //   baz/ (empty directory)
4445            let src_path = test_path.join("src");
4446            tokio::fs::create_dir(&src_path).await?;
4447            tokio::fs::write(src_path.join("foo"), "content").await?;
4448            tokio::fs::write(src_path.join("bar"), "content").await?;
4449            tokio::fs::create_dir(src_path.join("baz")).await?;
4450            // create destination with baz directory already existing
4451            let dst_path = test_path.join("dst");
4452            tokio::fs::create_dir(&dst_path).await?;
4453            tokio::fs::create_dir(dst_path.join("baz")).await?;
4454            // add a marker file inside dst/baz to verify we don't touch it
4455            tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
4456            // include only 'foo' file - baz should not match
4457            let mut filter = FilterSettings::new();
4458            filter.add_include("foo").unwrap();
4459            let summary = copy(
4460                &PROGRESS,
4461                &src_path,
4462                &dst_path,
4463                &Settings {
4464                    dereference: false,
4465                    fail_early: false,
4466                    overwrite: true, // enable overwrite mode
4467                    overwrite_compare: Default::default(),
4468                    overwrite_filter: None,
4469                    ignore_existing: false,
4470                    chunk_size: 0,
4471                    skip_specials: false,
4472                    remote_copy_buffer_size: 0,
4473                    filter: Some(filter),
4474                    dry_run: None,
4475                    delete: None,
4476                },
4477                &NO_PRESERVE_SETTINGS,
4478                false,
4479            )
4480            .await?;
4481            // foo should be copied
4482            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4483            // dst and baz should be unchanged (both already existed)
4484            assert_eq!(
4485                summary.directories_unchanged, 2,
4486                "root dst and baz directories should be unchanged"
4487            );
4488            assert_eq!(
4489                summary.directories_created, 0,
4490                "should not create any directories"
4491            );
4492            // verify foo was copied
4493            assert!(dst_path.join("foo").exists(), "foo should be copied");
4494            // verify bar was NOT copied
4495            assert!(!dst_path.join("bar").exists(), "bar should not be copied");
4496            // verify existing baz directory still exists with its content
4497            assert!(
4498                dst_path.join("baz").exists(),
4499                "existing baz directory should still exist"
4500            );
4501            assert!(
4502                dst_path.join("baz").join("marker.txt").exists(),
4503                "existing content in baz should still exist"
4504            );
4505            Ok(())
4506        }
4507    }
4508    mod dry_run_tests {
4509        use super::*;
4510        use crate::filter::FilterSettings;
4511        /// Test that dry-run mode for directories doesn't create the destination
4512        /// and doesn't try to set metadata on non-existent directories.
4513        #[tokio::test]
4514        #[traced_test]
4515        async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
4516            let tmp_dir = testutils::setup_test_dir().await?;
4517            let test_path = tmp_dir.as_path();
4518            let dst_path = test_path.join("nonexistent_dst");
4519            // verify destination doesn't exist
4520            assert!(
4521                !dst_path.exists(),
4522                "destination should not exist before dry-run"
4523            );
4524            let summary = copy(
4525                &PROGRESS,
4526                &test_path.join("foo"),
4527                &dst_path,
4528                &Settings {
4529                    dereference: false,
4530                    fail_early: false,
4531                    overwrite: false,
4532                    overwrite_compare: Default::default(),
4533                    overwrite_filter: None,
4534                    ignore_existing: false,
4535                    chunk_size: 0,
4536                    skip_specials: false,
4537                    remote_copy_buffer_size: 0,
4538                    filter: None,
4539                    dry_run: Some(crate::config::DryRunMode::Brief),
4540                    delete: None,
4541                },
4542                &NO_PRESERVE_SETTINGS,
4543                false,
4544            )
4545            .await?;
4546            // verify destination still doesn't exist
4547            assert!(
4548                !dst_path.exists(),
4549                "dry-run should not create destination directory"
4550            );
4551            // verify summary reports what would be created
4552            assert!(
4553                summary.directories_created > 0,
4554                "dry-run should report directories that would be created"
4555            );
4556            assert!(
4557                summary.files_copied > 0,
4558                "dry-run should report files that would be copied"
4559            );
4560            Ok(())
4561        }
4562        /// Regression: in dry-run, `--ignore-existing` must report an existing destination file as
4563        /// skipped (files_unchanged), not as a would-be copy. The dry-run signal is `dst_parent ==
4564        /// None`, so the skip must probe existence by path rather than via a held dst fd.
4565        #[tokio::test]
4566        async fn dry_run_ignore_existing_reports_file_as_skipped() -> Result<(), anyhow::Error> {
4567            let tmp = testutils::create_temp_dir().await?;
4568            let src = tmp.join("src.txt");
4569            let dst = tmp.join("dst.txt");
4570            tokio::fs::write(&src, "new").await?;
4571            tokio::fs::write(&dst, "old").await?; // destination already exists and differs
4572            let summary = copy(
4573                &PROGRESS,
4574                &src,
4575                &dst,
4576                &Settings {
4577                    dereference: false,
4578                    fail_early: false,
4579                    overwrite: false,
4580                    overwrite_compare: Default::default(),
4581                    overwrite_filter: None,
4582                    ignore_existing: true,
4583                    chunk_size: 0,
4584                    skip_specials: false,
4585                    remote_copy_buffer_size: 0,
4586                    filter: None,
4587                    dry_run: Some(crate::config::DryRunMode::Brief),
4588                    delete: None,
4589                },
4590                &NO_PRESERVE_SETTINGS,
4591                false,
4592            )
4593            .await?;
4594            assert_eq!(
4595                summary.files_unchanged, 1,
4596                "existing file must be reported skipped in dry-run under --ignore-existing"
4597            );
4598            assert_eq!(
4599                summary.files_copied, 0,
4600                "must not report a would-be copy when --ignore-existing skips"
4601            );
4602            assert_eq!(
4603                tokio::fs::read_to_string(&dst).await?,
4604                "old",
4605                "dry-run must not mutate the destination"
4606            );
4607            Ok(())
4608        }
4609        /// Regression: the symlink counterpart of `dry_run_ignore_existing_reports_file_as_skipped`
4610        /// — an existing destination must be reported as `symlinks_unchanged`, not `symlinks_created`.
4611        #[tokio::test]
4612        async fn dry_run_ignore_existing_reports_symlink_as_skipped() -> Result<(), anyhow::Error> {
4613            let tmp = testutils::create_temp_dir().await?;
4614            let src = tmp.join("src_link");
4615            let dst = tmp.join("dst_link");
4616            tokio::fs::symlink("some/target", &src).await?;
4617            tokio::fs::write(&dst, "existing").await?; // destination already exists (any type)
4618            let summary = copy(
4619                &PROGRESS,
4620                &src,
4621                &dst,
4622                &Settings {
4623                    dereference: false,
4624                    fail_early: false,
4625                    overwrite: false,
4626                    overwrite_compare: Default::default(),
4627                    overwrite_filter: None,
4628                    ignore_existing: true,
4629                    chunk_size: 0,
4630                    skip_specials: false,
4631                    remote_copy_buffer_size: 0,
4632                    filter: None,
4633                    dry_run: Some(crate::config::DryRunMode::Brief),
4634                    delete: None,
4635                },
4636                &NO_PRESERVE_SETTINGS,
4637                false,
4638            )
4639            .await?;
4640            assert_eq!(
4641                summary.symlinks_unchanged, 1,
4642                "existing symlink dst must be reported skipped in dry-run under --ignore-existing"
4643            );
4644            assert_eq!(
4645                summary.symlinks_created, 0,
4646                "must not report a would-be symlink creation when --ignore-existing skips"
4647            );
4648            Ok(())
4649        }
4650        /// Test that root directory is always created even when nothing matches
4651        /// the include pattern. The root is the user-specified source — it should
4652        /// never be removed/skipped due to empty-dir cleanup.
4653        #[tokio::test]
4654        #[traced_test]
4655        async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
4656            let test_path = testutils::create_temp_dir().await?;
4657            // create structure:
4658            // src/
4659            //   bar.log (doesn't match *.txt)
4660            //   baz/ (empty directory)
4661            let src_path = test_path.join("src");
4662            tokio::fs::create_dir(&src_path).await?;
4663            tokio::fs::write(src_path.join("bar.log"), "content").await?;
4664            tokio::fs::create_dir(src_path.join("baz")).await?;
4665            // include only *.txt - nothing in source matches
4666            let mut filter = FilterSettings::new();
4667            filter.add_include("*.txt").unwrap();
4668            let dst_path = test_path.join("dst");
4669            let summary = copy(
4670                &PROGRESS,
4671                &src_path,
4672                &dst_path,
4673                &Settings {
4674                    dereference: false,
4675                    fail_early: false,
4676                    overwrite: false,
4677                    overwrite_compare: Default::default(),
4678                    overwrite_filter: None,
4679                    ignore_existing: false,
4680                    chunk_size: 0,
4681                    skip_specials: false,
4682                    remote_copy_buffer_size: 0,
4683                    filter: Some(filter),
4684                    dry_run: None,
4685                    delete: None,
4686                },
4687                &NO_PRESERVE_SETTINGS,
4688                false,
4689            )
4690            .await?;
4691            // no files should be copied
4692            assert_eq!(summary.files_copied, 0, "no files match *.txt");
4693            // root directory should still be created
4694            assert_eq!(
4695                summary.directories_created, 1,
4696                "root directory should always be created"
4697            );
4698            assert!(dst_path.exists(), "root destination directory should exist");
4699            // non-matching subdirectories should not be created
4700            assert!(
4701                !dst_path.join("baz").exists(),
4702                "empty baz should not be created"
4703            );
4704            Ok(())
4705        }
4706        /// Test that root directory is counted in dry-run even when nothing matches.
4707        #[tokio::test]
4708        #[traced_test]
4709        async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
4710        {
4711            let test_path = testutils::create_temp_dir().await?;
4712            let src_path = test_path.join("src");
4713            tokio::fs::create_dir(&src_path).await?;
4714            tokio::fs::write(src_path.join("bar.log"), "content").await?;
4715            // include only *.txt - nothing matches
4716            let mut filter = FilterSettings::new();
4717            filter.add_include("*.txt").unwrap();
4718            let dst_path = test_path.join("dst");
4719            let summary = copy(
4720                &PROGRESS,
4721                &src_path,
4722                &dst_path,
4723                &Settings {
4724                    dereference: false,
4725                    fail_early: false,
4726                    overwrite: false,
4727                    overwrite_compare: Default::default(),
4728                    overwrite_filter: None,
4729                    ignore_existing: false,
4730                    chunk_size: 0,
4731                    skip_specials: false,
4732                    remote_copy_buffer_size: 0,
4733                    filter: Some(filter),
4734                    dry_run: Some(crate::config::DryRunMode::Explain),
4735                    delete: None,
4736                },
4737                &NO_PRESERVE_SETTINGS,
4738                false,
4739            )
4740            .await?;
4741            assert_eq!(summary.files_copied, 0, "no files match *.txt");
4742            assert_eq!(
4743                summary.directories_created, 1,
4744                "root directory should be counted in dry-run"
4745            );
4746            assert!(
4747                !dst_path.exists(),
4748                "nothing should be created in dry-run mode"
4749            );
4750            Ok(())
4751        }
4752    }
4753
4754    /// stress tests exercising max-open-files saturation during copy
4755    mod max_open_files_tests {
4756        use super::*;
4757
4758        /// wide copy: many files with a very low open-files limit.
4759        /// verifies all files are copied correctly under permit saturation.
4760        #[tokio::test]
4761        #[traced_test]
4762        async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
4763            let tmp_dir = testutils::create_temp_dir().await?;
4764            let src = tmp_dir.join("src");
4765            let dst = tmp_dir.join("dst");
4766            tokio::fs::create_dir(&src).await?;
4767            let file_count = 200;
4768            for i in 0..file_count {
4769                tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
4770            }
4771            // set a very low limit to force permit contention
4772            throttle::set_max_open_files(4);
4773            let summary = copy(
4774                &PROGRESS,
4775                &src,
4776                &dst,
4777                &Settings {
4778                    dereference: false,
4779                    fail_early: true,
4780                    overwrite: false,
4781                    overwrite_compare: Default::default(),
4782                    overwrite_filter: None,
4783                    ignore_existing: false,
4784                    chunk_size: 0,
4785                    skip_specials: false,
4786                    remote_copy_buffer_size: 0,
4787                    filter: None,
4788                    dry_run: None,
4789                    delete: None,
4790                },
4791                &NO_PRESERVE_SETTINGS,
4792                false,
4793            )
4794            .await?;
4795            assert_eq!(summary.files_copied, file_count);
4796            assert_eq!(summary.directories_created, 1);
4797            for i in 0..file_count {
4798                let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
4799                assert_eq!(content, format!("content-{}", i));
4800            }
4801            Ok(())
4802        }
4803
4804        /// deep + wide copy: directory tree deeper than the open-files limit, with files
4805        /// at every level. verifies no deadlock occurs (directories don't consume permits).
4806        #[tokio::test]
4807        #[traced_test]
4808        async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
4809            let tmp_dir = testutils::create_temp_dir().await?;
4810            let src = tmp_dir.join("src");
4811            let dst = tmp_dir.join("dst");
4812            let depth = 20;
4813            let files_per_level = 5;
4814            let limit = 4;
4815            // create a directory chain deeper than the permit limit, with files at each level
4816            let mut dir = src.clone();
4817            for level in 0..depth {
4818                tokio::fs::create_dir_all(&dir).await?;
4819                for f in 0..files_per_level {
4820                    tokio::fs::write(
4821                        dir.join(format!("f{}_{}.txt", level, f)),
4822                        format!("L{}F{}", level, f),
4823                    )
4824                    .await?;
4825                }
4826                dir = dir.join(format!("d{}", level));
4827            }
4828            throttle::set_max_open_files(limit);
4829            let summary = tokio::time::timeout(
4830                std::time::Duration::from_secs(30),
4831                copy(
4832                    &PROGRESS,
4833                    &src,
4834                    &dst,
4835                    &Settings {
4836                        dereference: false,
4837                        fail_early: true,
4838                        overwrite: false,
4839                        overwrite_compare: Default::default(),
4840                        overwrite_filter: None,
4841                        ignore_existing: false,
4842                        chunk_size: 0,
4843                        skip_specials: false,
4844                        remote_copy_buffer_size: 0,
4845                        filter: None,
4846                        dry_run: None,
4847                        delete: None,
4848                    },
4849                    &NO_PRESERVE_SETTINGS,
4850                    false,
4851                ),
4852            )
4853            .await
4854            .context("copy timed out — possible deadlock")?
4855            .context("copy failed")?;
4856            assert_eq!(summary.files_copied, depth * files_per_level);
4857            assert_eq!(summary.directories_created, depth);
4858            // spot-check content at a few levels
4859            let mut check_dir = dst.clone();
4860            for level in 0..depth {
4861                let content =
4862                    tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
4863                assert_eq!(content, format!("L{}F0", level));
4864                check_dir = check_dir.join(format!("d{}", level));
4865            }
4866            Ok(())
4867        }
4868
4869        /// Regression: copy_file → rm cross-pool deadlock.
4870        ///
4871        /// Scenario: many parallel copies overwrite destinations that are
4872        /// directories (so each copy_file path takes the
4873        /// "remove existing then copy" branch, and rm recurses). Each copy
4874        /// task holds an open-files permit during copy_file; if rm also
4875        /// drew permits from the open-files pool, a saturated pool would
4876        /// deadlock — every permit held by a copy task waiting for rm to
4877        /// release one. Decoupling rm onto pending-meta avoids that.
4878        #[tokio::test]
4879        #[traced_test]
4880        async fn parallel_overwrite_dir_with_file_no_deadlock() -> Result<(), anyhow::Error> {
4881            let tmp_dir = testutils::create_temp_dir().await?;
4882            let src = tmp_dir.join("src");
4883            let dst = tmp_dir.join("dst");
4884            tokio::fs::create_dir(&src).await?;
4885            tokio::fs::create_dir(&dst).await?;
4886            // 8 sources are regular files; the 8 corresponding destinations
4887            // are directories with nested files — copy with --overwrite
4888            // forces rm of each dst directory tree from inside copy_file.
4889            let n = 8;
4890            for i in 0..n {
4891                tokio::fs::write(src.join(format!("e{}", i)), format!("file-{}", i)).await?;
4892                let dst_subdir = dst.join(format!("e{}", i));
4893                tokio::fs::create_dir(&dst_subdir).await?;
4894                for j in 0..3 {
4895                    tokio::fs::write(
4896                        dst_subdir.join(format!("inner_{}.txt", j)),
4897                        format!("inner-{}-{}", i, j),
4898                    )
4899                    .await?;
4900                }
4901            }
4902            // Saturate the open-files pool: if rm shared this pool, every
4903            // outer copy task would hold its single permit and the inner rm
4904            // recursion would block forever.
4905            throttle::set_max_open_files(2);
4906            let summary = tokio::time::timeout(
4907                std::time::Duration::from_secs(30),
4908                copy(
4909                    &PROGRESS,
4910                    &src,
4911                    &dst,
4912                    &Settings {
4913                        dereference: false,
4914                        fail_early: true,
4915                        overwrite: true,
4916                        overwrite_compare: Default::default(),
4917                        overwrite_filter: None,
4918                        ignore_existing: false,
4919                        chunk_size: 0,
4920                        skip_specials: false,
4921                        remote_copy_buffer_size: 0,
4922                        filter: None,
4923                        dry_run: None,
4924                        delete: None,
4925                    },
4926                    &NO_PRESERVE_SETTINGS,
4927                    false,
4928                ),
4929            )
4930            .await
4931            .context(
4932                "copy timed out — deadlock between copy_file's open-files permit and inner rm",
4933            )?
4934            .context("copy failed")?;
4935            assert_eq!(summary.files_copied, n);
4936            assert_eq!(summary.rm_summary.files_removed, n * 3);
4937            assert_eq!(summary.rm_summary.directories_removed, n);
4938            for i in 0..n {
4939                let path = dst.join(format!("e{}", i));
4940                let content = tokio::fs::read_to_string(&path).await?;
4941                assert_eq!(content, format!("file-{}", i));
4942            }
4943            Ok(())
4944        }
4945    }
4946
4947    mod skip_specials_tests {
4948        use super::*;
4949
4950        #[tokio::test]
4951        #[traced_test]
4952        async fn skip_specials_skips_socket_in_directory() -> Result<(), anyhow::Error> {
4953            let tmp_dir = testutils::setup_test_dir().await?;
4954            let test_path = tmp_dir.as_path();
4955            let src = test_path.join("src_dir");
4956            let dst = test_path.join("dst_dir");
4957            tokio::fs::create_dir(&src).await?;
4958            tokio::fs::write(src.join("file.txt"), "hello").await?;
4959            // create a unix socket inside the source directory
4960            let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
4961            let summary = copy(
4962                &PROGRESS,
4963                &src,
4964                &dst,
4965                &Settings {
4966                    dereference: false,
4967                    fail_early: false,
4968                    overwrite: false,
4969                    overwrite_compare: Default::default(),
4970                    overwrite_filter: None,
4971                    ignore_existing: false,
4972                    chunk_size: 0,
4973                    skip_specials: true,
4974                    remote_copy_buffer_size: 0,
4975                    filter: None,
4976                    dry_run: None,
4977                    delete: None,
4978                },
4979                &NO_PRESERVE_SETTINGS,
4980                false,
4981            )
4982            .await?;
4983            assert_eq!(summary.files_copied, 1);
4984            assert_eq!(summary.specials_skipped, 1);
4985            assert!(dst.join("file.txt").exists());
4986            assert!(!dst.join("test.sock").exists());
4987            Ok(())
4988        }
4989
4990        #[tokio::test]
4991        #[traced_test]
4992        async fn skip_specials_skips_fifo_in_directory() -> Result<(), anyhow::Error> {
4993            let tmp_dir = testutils::setup_test_dir().await?;
4994            let test_path = tmp_dir.as_path();
4995            let src = test_path.join("src_dir");
4996            let dst = test_path.join("dst_dir");
4997            tokio::fs::create_dir(&src).await?;
4998            tokio::fs::write(src.join("file.txt"), "hello").await?;
4999            // create a FIFO inside the source directory
5000            nix::unistd::mkfifo(
5001                &src.join("test.fifo"),
5002                nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
5003            )?;
5004            let summary = copy(
5005                &PROGRESS,
5006                &src,
5007                &dst,
5008                &Settings {
5009                    dereference: false,
5010                    fail_early: false,
5011                    overwrite: false,
5012                    overwrite_compare: Default::default(),
5013                    overwrite_filter: None,
5014                    ignore_existing: false,
5015                    chunk_size: 0,
5016                    skip_specials: true,
5017                    remote_copy_buffer_size: 0,
5018                    filter: None,
5019                    dry_run: None,
5020                    delete: None,
5021                },
5022                &NO_PRESERVE_SETTINGS,
5023                false,
5024            )
5025            .await?;
5026            assert_eq!(summary.files_copied, 1);
5027            assert_eq!(summary.specials_skipped, 1);
5028            assert!(dst.join("file.txt").exists());
5029            assert!(!dst.join("test.fifo").exists());
5030            Ok(())
5031        }
5032
5033        #[tokio::test]
5034        #[traced_test]
5035        async fn special_file_errors_without_skip_specials() -> Result<(), anyhow::Error> {
5036            let tmp_dir = testutils::setup_test_dir().await?;
5037            let test_path = tmp_dir.as_path();
5038            let src = test_path.join("src_dir");
5039            let dst = test_path.join("dst_dir");
5040            tokio::fs::create_dir(&src).await?;
5041            tokio::fs::write(src.join("file.txt"), "hello").await?;
5042            let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
5043            let result = copy(
5044                &PROGRESS,
5045                &src,
5046                &dst,
5047                &Settings {
5048                    dereference: false,
5049                    fail_early: false,
5050                    overwrite: false,
5051                    overwrite_compare: Default::default(),
5052                    overwrite_filter: None,
5053                    ignore_existing: false,
5054                    chunk_size: 0,
5055                    skip_specials: false,
5056                    remote_copy_buffer_size: 0,
5057                    filter: None,
5058                    dry_run: None,
5059                    delete: None,
5060                },
5061                &NO_PRESERVE_SETTINGS,
5062                false,
5063            )
5064            .await;
5065            assert!(result.is_err());
5066            let err = result.unwrap_err();
5067            assert!(
5068                format!("{:#}", err).contains("unsupported src file type"),
5069                "error should mention unsupported file type, got: {:#}",
5070                err
5071            );
5072            Ok(())
5073        }
5074
5075        #[tokio::test]
5076        #[traced_test]
5077        async fn skip_specials_top_level_socket() -> Result<(), anyhow::Error> {
5078            let tmp_dir = testutils::setup_test_dir().await?;
5079            let test_path = tmp_dir.as_path();
5080            let src_socket = test_path.join("test.sock");
5081            let dst = test_path.join("dst.sock");
5082            let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
5083            let summary = copy(
5084                &PROGRESS,
5085                &src_socket,
5086                &dst,
5087                &Settings {
5088                    dereference: false,
5089                    fail_early: false,
5090                    overwrite: false,
5091                    overwrite_compare: Default::default(),
5092                    overwrite_filter: None,
5093                    ignore_existing: false,
5094                    chunk_size: 0,
5095                    skip_specials: true,
5096                    remote_copy_buffer_size: 0,
5097                    filter: None,
5098                    dry_run: None,
5099                    delete: None,
5100                },
5101                &NO_PRESERVE_SETTINGS,
5102                false,
5103            )
5104            .await?;
5105            assert_eq!(summary.specials_skipped, 1);
5106            assert_eq!(summary.files_copied, 0);
5107            assert!(!dst.exists());
5108            Ok(())
5109        }
5110    }
5111
5112    #[tokio::test]
5113    #[traced_test]
5114    async fn delete_protects_excluded_then_removes_with_delete_excluded()
5115    -> Result<(), anyhow::Error> {
5116        let tmp_dir = testutils::setup_test_dir().await?;
5117        let test_path = tmp_dir.as_path();
5118        let src = test_path.join("foo");
5119        let dst = test_path.join("bar");
5120        copy(
5121            &PROGRESS,
5122            &src,
5123            &dst,
5124            &settings_with_delete(None),
5125            &DO_PRESERVE_SETTINGS,
5126            false,
5127        )
5128        .await?;
5129        // a destination-only file that matches an exclude pattern
5130        tokio::fs::write(dst.join("keep.log"), b"protected").await?;
5131
5132        let mut filter = crate::filter::FilterSettings::new();
5133        filter.add_exclude("*.log")?;
5134
5135        // default --delete: keep.log is protected
5136        let mut settings = settings_with_delete(delete_on());
5137        settings.filter = Some(filter.clone());
5138        copy(
5139            &PROGRESS,
5140            &src,
5141            &dst,
5142            &settings,
5143            &DO_PRESERVE_SETTINGS,
5144            false,
5145        )
5146        .await?;
5147        assert!(
5148            dst.join("keep.log").exists(),
5149            "*.log must be protected by default"
5150        );
5151
5152        // --delete-excluded: keep.log is removed
5153        let mut settings = settings_with_delete(Some(DeleteSettings {
5154            delete_excluded: true,
5155        }));
5156        settings.filter = Some(filter);
5157        copy(
5158            &PROGRESS,
5159            &src,
5160            &dst,
5161            &settings,
5162            &DO_PRESERVE_SETTINGS,
5163            false,
5164        )
5165        .await?;
5166        assert!(!dst.join("keep.log").exists());
5167        Ok(())
5168    }
5169
5170    #[tokio::test]
5171    #[traced_test]
5172    async fn delete_dry_run_reports_without_removing() -> Result<(), anyhow::Error> {
5173        let tmp_dir = testutils::setup_test_dir().await?;
5174        let test_path = tmp_dir.as_path();
5175        let src = test_path.join("foo");
5176        let dst = test_path.join("bar");
5177        copy(
5178            &PROGRESS,
5179            &src,
5180            &dst,
5181            &settings_with_delete(None),
5182            &DO_PRESERVE_SETTINGS,
5183            false,
5184        )
5185        .await?;
5186        tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
5187
5188        let mut settings = settings_with_delete(delete_on());
5189        settings.dry_run = Some(crate::config::DryRunMode::Brief);
5190        let summary = copy(
5191            &PROGRESS,
5192            &src,
5193            &dst,
5194            &settings,
5195            &DO_PRESERVE_SETTINGS,
5196            false,
5197        )
5198        .await?;
5199
5200        // the key invariant: dry-run must NOT remove anything
5201        assert!(
5202            dst.join("stale.txt").exists(),
5203            "dry-run must not remove anything"
5204        );
5205        // rm's dry-run does count would-be removals in files_removed
5206        assert_eq!(summary.rm_summary.files_removed, 1);
5207        Ok(())
5208    }
5209
5210    #[tokio::test]
5211    #[traced_test]
5212    async fn delete_does_not_prune_when_source_unreadable() -> Result<(), anyhow::Error> {
5213        let tmp_dir = testutils::setup_test_dir().await?;
5214        let test_path = tmp_dir.as_path();
5215        let src = test_path.join("foo");
5216        let dst = test_path.join("bar");
5217        copy(
5218            &PROGRESS,
5219            &src,
5220            &dst,
5221            &settings_with_delete(None),
5222            &DO_PRESERVE_SETTINGS,
5223            false,
5224        )
5225        .await?;
5226        tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
5227        // make the source directory unreadable so enumeration fails
5228        let original = tokio::fs::metadata(&src).await?.permissions();
5229        tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
5230
5231        let result = copy(
5232            &PROGRESS,
5233            &src,
5234            &dst,
5235            &settings_with_delete(delete_on()),
5236            &DO_PRESERVE_SETTINGS,
5237            false,
5238        )
5239        .await;
5240
5241        // restore permissions before asserting (so the temp dir cleans up)
5242        tokio::fs::set_permissions(&src, original).await?;
5243
5244        assert!(result.is_err(), "unreadable source must error");
5245        assert!(
5246            dst.join("stale.txt").exists(),
5247            "destination must not be pruned when source enumeration fails"
5248        );
5249        Ok(())
5250    }
5251
5252    // Overwriting an existing regular file goes through the fd-relative + recheck-guarded removal
5253    // path: the destination file is removed (counted as a single `files_removed`) and recreated
5254    // with the source content. This exercises `remove_existing`'s `unlink_at` branch via the real
5255    // fd-based `copy` walk (not the legacy path-based `copy_file`).
5256    #[tokio::test]
5257    #[traced_test]
5258    async fn overwrite_regular_file_removes_and_recreates_via_fd() -> Result<(), anyhow::Error> {
5259        let tmp_dir = testutils::create_temp_dir().await?;
5260        let test_path = tmp_dir.as_path();
5261        let src_file = test_path.join("src.txt");
5262        let dst_file = test_path.join("dst.txt");
5263        // distinct sizes so the metadata comparison treats them as different and overwrite proceeds.
5264        tokio::fs::write(&src_file, "fresh source content").await?;
5265        tokio::fs::write(&dst_file, "old").await?;
5266        let summary = copy(
5267            &PROGRESS,
5268            &src_file,
5269            &dst_file,
5270            &Settings {
5271                dereference: false,
5272                fail_early: false,
5273                overwrite: true, // <- exercises the overwrite removal path
5274                overwrite_compare: filecmp::MetadataCmpSettings {
5275                    size: true,
5276                    mtime: true,
5277                    ..Default::default()
5278                },
5279                overwrite_filter: None,
5280                ignore_existing: false,
5281                chunk_size: 0,
5282                skip_specials: false,
5283                remote_copy_buffer_size: 0,
5284                filter: None,
5285                dry_run: None,
5286                delete: None,
5287            },
5288            &NO_PRESERVE_SETTINGS,
5289            false,
5290        )
5291        .await?;
5292        // the existing regular file was removed (fd-relative unlink_at) and the source recreated.
5293        assert_eq!(summary.files_copied, 1);
5294        assert_eq!(summary.rm_summary.files_removed, 1);
5295        assert_eq!(summary.rm_summary.symlinks_removed, 0);
5296        assert_eq!(summary.rm_summary.directories_removed, 0);
5297        let content = tokio::fs::read_to_string(&dst_file).await?;
5298        assert_eq!(content, "fresh source content");
5299        Ok(())
5300    }
5301
5302    // ── TOCTOU hardening: -L separability, swap-loop races, fd-budget ────────────────
5303
5304    /// Default non-dereference copy settings used by the TOCTOU/fd-budget tests below.
5305    /// `overwrite` is on so a fresh destination per iteration is never required to be empty.
5306    fn toctou_settings() -> Settings {
5307        Settings {
5308            dereference: false,
5309            fail_early: false,
5310            overwrite: true,
5311            overwrite_compare: filecmp::MetadataCmpSettings {
5312                size: true,
5313                mtime: true,
5314                ..Default::default()
5315            },
5316            overwrite_filter: None,
5317            ignore_existing: false,
5318            chunk_size: 0,
5319            skip_specials: false,
5320            remote_copy_buffer_size: 0,
5321            filter: None,
5322            dry_run: None,
5323            delete: None,
5324        }
5325    }
5326
5327    // Task 1.4: with `dereference == false`, a symlink in the source is copied as a symlink — the
5328    // non-dereference (fd-based) path is taken and `canonicalize` is never invoked. The
5329    // `debug_assert!(settings.dereference, ...)` guarding the canonicalize call is the actual
5330    // invariant guard (it would fire in debug/tests if a refactor reached it); this test exercises
5331    // that non-dereference symlink path end-to-end and proves the link is preserved as a link.
5332    #[tokio::test]
5333    #[traced_test]
5334    async fn non_dereference_copy_never_canonicalizes() -> Result<(), anyhow::Error> {
5335        let tmp_dir = testutils::create_temp_dir().await?;
5336        let test_path = tmp_dir.as_path();
5337        let src = test_path.join("src");
5338        let dst = test_path.join("dst");
5339        tokio::fs::create_dir(&src).await?;
5340        // a real file plus a symlink to it living inside the source tree.
5341        tokio::fs::write(src.join("real.txt"), "REAL_CONTENT").await?;
5342        tokio::fs::symlink("real.txt", src.join("link")).await?;
5343        let summary = copy(
5344            &PROGRESS,
5345            &src,
5346            &dst,
5347            &toctou_settings(),
5348            &NO_PRESERVE_SETTINGS,
5349            false,
5350        )
5351        .await?;
5352        assert_eq!(summary.files_copied, 1);
5353        assert_eq!(
5354            summary.symlinks_created, 1,
5355            "symlink must be copied as a symlink"
5356        );
5357        // the destination entry must itself be a symlink (non-deref path), not a dereferenced file.
5358        let link_md = tokio::fs::symlink_metadata(dst.join("link")).await?;
5359        assert!(
5360            link_md.file_type().is_symlink(),
5361            "with dereference == false the source symlink must remain a symlink in the destination"
5362        );
5363        // and its target text is preserved verbatim (it was never resolved/canonicalized).
5364        let target = tokio::fs::read_link(dst.join("link")).await?;
5365        assert_eq!(target, std::path::Path::new("real.txt"));
5366        Ok(())
5367    }
5368
5369    // Task 1.5 helper: repeatedly swap `path` between a real regular file (content `REAL_CONTENT`)
5370    // and a symlink pointing at `sentinel`, using rename so each individual state is atomic. Two
5371    // staging names (a prepared symlink and the real file) live alongside `path` and are renamed
5372    // over it in a tight loop until `stop` is set. Runs on a dedicated OS thread (std::thread), so
5373    // it makes progress regardless of the tokio runtime's scheduling.
5374    fn spawn_file_symlink_swapper(
5375        dir: std::path::PathBuf,
5376        entry_name: &'static str,
5377        sentinel: std::path::PathBuf,
5378        stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
5379    ) -> std::thread::JoinHandle<()> {
5380        std::thread::spawn(move || {
5381            let entry = dir.join(entry_name);
5382            let staged_real = dir.join("__staged_real");
5383            let staged_link = dir.join("__staged_link");
5384            while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5385                // prepare the real file at a staging name, then rename it over `entry`.
5386                let _ = std::fs::remove_file(&staged_real);
5387                if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
5388                    continue;
5389                }
5390                let _ = std::fs::rename(&staged_real, &entry);
5391                // prepare a symlink-to-sentinel at the other staging name, then rename it over.
5392                let _ = std::fs::remove_file(&staged_link);
5393                let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
5394                let _ = std::fs::rename(&staged_link, &entry);
5395            }
5396        })
5397    }
5398
5399    // Task 1.5 (a): final-component file<->symlink swap. While the copy runs, `src/sub/entry` is
5400    // rapidly flipped between a real regular file and a symlink to a sentinel outside the tree. The
5401    // copy may grab the real file, or O_NOFOLLOW/fstat may catch the symlink (error or skip), but
5402    // the sentinel's secret content must NEVER end up in the destination as data.
5403    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5404    async fn file_symlink_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
5405        let tmp_dir = testutils::create_temp_dir().await?;
5406        let test_path = tmp_dir.as_path();
5407        // sentinel lives OUTSIDE the source tree with distinctive content.
5408        let sentinel = test_path.join("sentinel_secret");
5409        tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
5410        let src = test_path.join("src");
5411        let sub = src.join("sub");
5412        tokio::fs::create_dir(&src).await?;
5413        tokio::fs::create_dir(&sub).await?;
5414        tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
5415
5416        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5417        let swapper =
5418            spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
5419
5420        let settings = toctou_settings();
5421        let mut caught_swaps = 0usize;
5422        let mut copied_real = 0usize;
5423        for i in 0..200 {
5424            let dst = test_path.join(format!("dst_{i}"));
5425            let result = tokio::time::timeout(
5426                std::time::Duration::from_secs(30),
5427                copy(
5428                    &PROGRESS,
5429                    &src,
5430                    &dst,
5431                    &settings,
5432                    &NO_PRESERVE_SETTINGS,
5433                    false,
5434                ),
5435            )
5436            .await
5437            .expect("copy must not hang under concurrent swapping");
5438            match result {
5439                Ok(_) => {}
5440                Err(_) => caught_swaps += 1, // a swap was caught mid-copy (failed closed)
5441            }
5442            // CORE ASSERTION: if a regular file landed at the destination, it is the real content —
5443            // never the sentinel's secret. The entry may instead be absent or a symlink.
5444            let entry_dst = dst.join("sub").join("entry");
5445            if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
5446                && md.file_type().is_file()
5447            {
5448                let content = tokio::fs::read_to_string(&entry_dst).await?;
5449                assert_ne!(
5450                    content, "SENTINEL_SECRET_CONTENT",
5451                    "iteration {i}: sentinel content leaked into the destination as a regular file"
5452                );
5453                assert_eq!(
5454                    content, "REAL_CONTENT",
5455                    "iteration {i}: a regular destination file must hold the real content"
5456                );
5457                copied_real += 1;
5458            }
5459            let _ = tokio::fs::remove_dir_all(&dst).await;
5460        }
5461
5462        stop.store(true, std::sync::atomic::Ordering::Relaxed);
5463        swapper.join().expect("swapper thread panicked");
5464        // sanity: the run did meaningful work (caught some swaps or copied the real file at least
5465        // once). This is not the safety assertion — the safety assertion is "sentinel never leaks"
5466        // above and holds on every iteration regardless of timing.
5467        tracing::info!("file/symlink swap: caught_swaps={caught_swaps}, copied_real={copied_real}");
5468        assert!(
5469            caught_swaps + copied_real > 0,
5470            "expected at least one observable outcome across 200 iterations"
5471        );
5472        Ok(())
5473    }
5474
5475    // Task 1.5 (b): intermediate-directory swap. While the copy runs, `src/sub` is flipped between a
5476    // real directory (containing a real file) and a symlink to a directory outside the tree holding
5477    // a sentinel. open_dir uses O_NOFOLLOW, so the walk either descends the real directory or fails
5478    // closed — it must never follow the symlink and copy the outside sentinel.
5479    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5480    async fn intermediate_dir_swap_never_follows_symlink() -> Result<(), anyhow::Error> {
5481        let tmp_dir = testutils::create_temp_dir().await?;
5482        let test_path = tmp_dir.as_path();
5483        // an "outside" directory tree holding a sentinel file, reachable only via a symlink.
5484        let outside = test_path.join("outside_dir");
5485        tokio::fs::create_dir(&outside).await?;
5486        tokio::fs::write(outside.join("sentinel.txt"), "SENTINEL_SECRET_CONTENT").await?;
5487        let src = test_path.join("src");
5488        tokio::fs::create_dir(&src).await?;
5489        // the real `sub` directory with a real file inside.
5490        let real_sub = src.join("sub");
5491        tokio::fs::create_dir(&real_sub).await?;
5492        tokio::fs::write(real_sub.join("real.txt"), "REAL_CONTENT").await?;
5493
5494        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5495        let swapper = {
5496            let src = src.clone();
5497            let outside = outside.clone();
5498            let stop = stop.clone();
5499            std::thread::spawn(move || {
5500                let sub = src.join("sub");
5501                let staged_dir = src.join("__staged_sub_dir");
5502                let staged_link = src.join("__staged_sub_link");
5503                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5504                    // stage a real directory (with the real file) then rename it over `sub`.
5505                    let _ = std::fs::remove_dir_all(&staged_dir);
5506                    if std::fs::create_dir(&staged_dir).is_ok() {
5507                        let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL_CONTENT");
5508                        // RENAME_EXCHANGE isn't available portably here; remove-then-rename. The
5509                        // window where `sub` is briefly absent is fine — the copy may error, which
5510                        // is an accepted failed-closed outcome.
5511                        let _ = std::fs::remove_dir_all(&sub);
5512                        let _ = std::fs::remove_file(&sub);
5513                        let _ = std::fs::rename(&staged_dir, &sub);
5514                    }
5515                    // stage a symlink to the outside dir, then swap it in over `sub`.
5516                    let _ = std::fs::remove_file(&staged_link);
5517                    if std::os::unix::fs::symlink(&outside, &staged_link).is_ok() {
5518                        let _ = std::fs::remove_dir_all(&sub);
5519                        let _ = std::fs::remove_file(&sub);
5520                        let _ = std::fs::rename(&staged_link, &sub);
5521                    }
5522                }
5523            })
5524        };
5525
5526        let settings = toctou_settings();
5527        for i in 0..200 {
5528            let dst = test_path.join(format!("dst_{i}"));
5529            let _ = tokio::time::timeout(
5530                std::time::Duration::from_secs(30),
5531                copy(
5532                    &PROGRESS,
5533                    &src,
5534                    &dst,
5535                    &settings,
5536                    &NO_PRESERVE_SETTINGS,
5537                    false,
5538                ),
5539            )
5540            .await
5541            .expect("copy must not hang under concurrent dir swapping");
5542            // CORE ASSERTION: the walk must never DESCEND through the symlink and copy the outside
5543            // tree's contents as data. Classify `dst/sub` WITHOUT following symlinks: if it is a
5544            // symlink, the link was copied verbatim (safe — `outside` was never read); if it is a
5545            // real directory, the copy descended a real `sub`, and the sentinel — which only ever
5546            // lived in `outside`, reachable solely via the symlink — must NOT have been reproduced
5547            // inside it. (`Path::exists` follows symlinks, so it would falsely "see" the sentinel
5548            // through a verbatim-copied symlink; `symlink_metadata` is the correct probe here.)
5549            // when `dst/sub` is a symlink it was copied verbatim (accepted failed-closed outcome,
5550            // `outside` was never read); only a REAL destination directory means the walk descended,
5551            // and then it must have descended the real `sub` — never the symlink into `outside`.
5552            let sub_dst = dst.join("sub");
5553            if let Ok(sub_md) = tokio::fs::symlink_metadata(&sub_dst).await
5554                && sub_md.file_type().is_dir()
5555            {
5556                let leaked = sub_dst.join("sentinel.txt");
5557                let leaked_is_real_file = tokio::fs::symlink_metadata(&leaked)
5558                    .await
5559                    .map(|m| m.file_type().is_file())
5560                    .unwrap_or(false);
5561                assert!(
5562                    !leaked_is_real_file,
5563                    "iteration {i}: intermediate-dir symlink was followed; the outside \
5564                     sentinel was copied into a real destination directory"
5565                );
5566                // a real `sub` directory copied from the real source holds the real file.
5567                let real_dst = sub_dst.join("real.txt");
5568                if let Ok(content) = tokio::fs::read_to_string(&real_dst).await {
5569                    assert_eq!(
5570                        content, "REAL_CONTENT",
5571                        "iteration {i}: copied file must hold real content"
5572                    );
5573                }
5574            }
5575            let _ = tokio::fs::remove_dir_all(&dst).await;
5576        }
5577
5578        stop.store(true, std::sync::atomic::Ordering::Relaxed);
5579        swapper.join().expect("dir swapper thread panicked");
5580        Ok(())
5581    }
5582
5583    // Task 1.5 (d): destination NON-EMPTY-directory overwrite removal under an intermediate-component
5584    // swap — the racy mirror of `test_remote_dest_overwrite_replaces_intermediate_symlink_in_tree`,
5585    // targeting the removal path B1 hardened. The source has regular files at `src/mid/deeper/subK`,
5586    // while the destination has NON-EMPTY directories at `dst/mid/deeper/subK`. A `--overwrite` copy
5587    // must therefore REMOVE those non-empty directories (`remove_existing` → ENOTEMPTY →
5588    // `rm::rm_child`) before creating the files. Concurrently, a swapper flips the INTERMEDIATE
5589    // component `dst/mid` between a real directory (holding `deeper/subK`) and a symlink to an
5590    // out-of-tree directory mirroring `deeper/subK/`, whose `subK/` each hold a sentinel.
5591    //
5592    // This is a fail-closed STRESS test: under the fd code the copy either descends a real `dst/mid`
5593    // and removes through the pinned `deeper` fd, or fails closed at the intermediate `make_dir`
5594    // O_NOFOLLOW when it observes the symlink — never escaping. CORE ASSERTION: no out-of-tree
5595    // sentinel is ever deleted, and the copy never hangs. (Because the walk fails closed at the
5596    // intermediate `make_dir` whenever it sees the symlink, this racy form rarely even reaches the
5597    // removal branch and does NOT reliably reproduce the path-based escape; the deterministic
5598    // `nonempty_dir_overwrite_removal_uses_pinned_fd_not_path` below is the regression-catcher that
5599    // FAILS when the branch is reverted to `rm::rm`.)
5600    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5601    async fn dest_nonempty_dir_overwrite_removal_contained_under_intermediate_swap()
5602    -> Result<(), anyhow::Error> {
5603        const N: usize = 8;
5604        let tmp_dir = testutils::create_temp_dir().await?;
5605        let test_path = tmp_dir.as_path();
5606        // an out-of-tree tree mirroring `deeper/`, holding several non-empty `subK/` each guarding a
5607        // sentinel. A path-based rm(dst/mid/deeper/subK) through a `mid`->out_of_tree symlink would
5608        // delete out_of_tree/deeper/subK. Several targets widen the window: once `mid` flips
5609        // to a symlink mid-copy (after `deeper` was opened), every remaining path-based rm escapes.
5610        let out_of_tree = test_path.join("out_of_tree");
5611        let oot_deeper = out_of_tree.join("deeper");
5612        for k in 0..N {
5613            let oot_sub = oot_deeper.join(format!("sub{k}"));
5614            tokio::fs::create_dir_all(&oot_sub).await?;
5615            tokio::fs::write(oot_sub.join("sentinel.txt"), "SENTINEL").await?;
5616        }
5617
5618        // source: src/mid/deeper/subK are regular FILES (so the copy must replace the dst dirs).
5619        let src = test_path.join("src");
5620        let src_deeper = src.join("mid").join("deeper");
5621        tokio::fs::create_dir_all(&src_deeper).await?;
5622        for k in 0..N {
5623            tokio::fs::write(src_deeper.join(format!("sub{k}")), "REAL_FILE_CONTENT").await?;
5624        }
5625
5626        // destination root persists; the swapper owns the intermediate `dst/mid`.
5627        let dst = test_path.join("dst");
5628        tokio::fs::create_dir(&dst).await?;
5629
5630        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5631        let swapper = {
5632            let dst = dst.clone();
5633            let out_of_tree = out_of_tree.clone();
5634            let stop = stop.clone();
5635            std::thread::spawn(move || {
5636                let mid = dst.join("mid");
5637                let staged_dir = dst.join("__staged_mid_dir");
5638                let staged_link = dst.join("__staged_mid_link");
5639                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5640                    // stage a real `mid` directory holding `deeper/subK` as NON-EMPTY directories,
5641                    // then rename it over `mid`. This is the state in which the overwrite-removal
5642                    // branch fires (src has FILES at mid/deeper/subK, dst has non-empty dirs).
5643                    let _ = std::fs::remove_dir_all(&staged_dir);
5644                    let staged_deeper = staged_dir.join("deeper");
5645                    if std::fs::create_dir_all(&staged_deeper).is_ok() {
5646                        for k in 0..N {
5647                            let s = staged_deeper.join(format!("sub{k}"));
5648                            if std::fs::create_dir(&s).is_ok() {
5649                                let _ = std::fs::write(s.join("inner.txt"), b"INNER_CONTENT");
5650                            }
5651                        }
5652                        let _ = std::fs::remove_dir_all(&mid);
5653                        let _ = std::fs::remove_file(&mid);
5654                        let _ = std::fs::rename(&staged_dir, &mid);
5655                    }
5656                    // stage a symlink to the out-of-tree directory, then swap it in over `mid`.
5657                    let _ = std::fs::remove_file(&staged_link);
5658                    if std::os::unix::fs::symlink(&out_of_tree, &staged_link).is_ok() {
5659                        let _ = std::fs::remove_dir_all(&mid);
5660                        let _ = std::fs::remove_file(&mid);
5661                        let _ = std::fs::rename(&staged_link, &mid);
5662                    }
5663                }
5664            })
5665        };
5666
5667        let settings = toctou_settings();
5668        for i in 0..200 {
5669            let _ = tokio::time::timeout(
5670                std::time::Duration::from_secs(30),
5671                copy(
5672                    &PROGRESS,
5673                    &src,
5674                    &dst,
5675                    &settings,
5676                    &NO_PRESERVE_SETTINGS,
5677                    false,
5678                ),
5679            )
5680            .await
5681            .expect("copy must not hang under concurrent intermediate-component swapping");
5682            // CORE ASSERTION (every iteration, regardless of timing): no out-of-tree sentinel is
5683            // ever deleted. The fd-relative removal is contained to the pinned `dst/mid/deeper` fd
5684            // and its O_NOFOLLOW descent fails closed on a swapped-in symlink — it can never
5685            // re-resolve `dst/mid/deeper/subK` through a `mid`->out_of_tree symlink and delete
5686            // `out_of_tree/deeper/subK`.
5687            for k in 0..N {
5688                assert!(
5689                    oot_deeper
5690                        .join(format!("sub{k}"))
5691                        .join("sentinel.txt")
5692                        .exists(),
5693                    "iteration {i}: out-of-tree sentinel sub{k} was deleted — the non-empty-\
5694                     directory overwrite removal escaped the destination tree through a swapped \
5695                     intermediate symlink (path-based rm regression)"
5696                );
5697            }
5698        }
5699
5700        stop.store(true, std::sync::atomic::Ordering::Relaxed);
5701        swapper
5702            .join()
5703            .expect("intermediate-swap swapper thread panicked");
5704        // sanity: the out-of-tree subtree itself also survived intact.
5705        assert!(
5706            oot_deeper.join("sub0").is_dir(),
5707            "out-of-tree sub directory must survive"
5708        );
5709        Ok(())
5710    }
5711
5712    // Deterministic companion to the racy test above, proving the B1 fix at the contract level: the
5713    // non-empty-directory overwrite removal acts through the PINNED parent fd, never by re-resolving
5714    // the display path. We open the REAL parent directory as `dst_parent`, classify the real
5715    // non-empty child, but pass a `dst_path` whose INTERMEDIATE component is an out-of-tree symlink
5716    // (the post-classification redirect a racy attacker would plant). The fd-relative `rm_child`
5717    // removes the real child through the held fd and leaves the out-of-tree tree untouched. The old
5718    // path-based `rm::rm(dst_path)` would instead re-resolve `dst_path` through the symlink and
5719    // delete the out-of-tree subtree — so this test FAILS if the branch is reverted to `rm::rm`
5720    // (verified by hand). `recheck` passes because the real child's identity is unchanged.
5721    #[tokio::test]
5722    #[traced_test]
5723    async fn nonempty_dir_overwrite_removal_uses_pinned_fd_not_path() -> Result<(), anyhow::Error> {
5724        let tmp_dir = testutils::create_temp_dir().await?;
5725        let test_path = tmp_dir.as_path();
5726
5727        // the REAL destination tree: base/realdeeper/victim is a NON-EMPTY directory.
5728        let real_deeper = test_path.join("base").join("realdeeper");
5729        let real_victim = real_deeper.join("victim");
5730        tokio::fs::create_dir_all(&real_victim).await?;
5731        tokio::fs::write(real_victim.join("inner.txt"), "INNER").await?;
5732
5733        // an out-of-tree tree the stale display path would resolve into. Its `victim/` holds a
5734        // sentinel; a path-based rm of the redirected path would delete it.
5735        let out_of_tree = test_path.join("out_of_tree");
5736        let oot_victim = out_of_tree.join("realdeeper").join("victim");
5737        tokio::fs::create_dir_all(&oot_victim).await?;
5738        tokio::fs::write(oot_victim.join("sentinel.txt"), "SENTINEL").await?;
5739
5740        // the redirected display path: base/mid -> out_of_tree, so `mid/realdeeper/victim`
5741        // resolves to out_of_tree/realdeeper/victim by PATH, but the pinned fd points at the real
5742        // base/realdeeper.
5743        let mid_link = test_path.join("base").join("mid");
5744        tokio::fs::symlink(&out_of_tree, &mid_link).await?;
5745        let stale_dst_path = mid_link.join("realdeeper").join("victim");
5746
5747        // open the REAL parent fd and classify the real non-empty child through it.
5748        let dst_parent =
5749            Arc::new(Dir::open_root_dir(&real_deeper, false, congestion::Side::Destination).await?);
5750        let victim_name: &OsStr = OsStr::new("victim");
5751        let dst_handle = dst_parent.child(victim_name).await?;
5752        assert_eq!(dst_handle.kind(), EntryKind::Dir);
5753
5754        let settings = toctou_settings();
5755        let summary = remove_existing(
5756            &PROGRESS,
5757            &dst_parent,
5758            victim_name,
5759            &stale_dst_path,
5760            &dst_handle,
5761            &settings,
5762        )
5763        .await?;
5764        assert_eq!(
5765            summary.directories_removed, 1,
5766            "the real non-empty victim directory should have been removed via the pinned fd"
5767        );
5768
5769        // the real child was removed through the held fd...
5770        assert!(
5771            !real_victim.exists(),
5772            "the real non-empty victim should have been removed through the pinned parent fd"
5773        );
5774        // ...and the out-of-tree subtree (which the stale path resolves into) was NOT touched.
5775        assert!(
5776            oot_victim.join("sentinel.txt").exists(),
5777            "out-of-tree sentinel was deleted — removal re-resolved the display path through the \
5778             intermediate symlink instead of using the pinned fd (path-based rm regression)"
5779        );
5780        Ok(())
5781    }
5782
5783    // Task 1.5 (c): FIFO swap. While the copy runs, `src/sub/entry` is flipped between a real file
5784    // and a FIFO (named pipe). open_file_read uses O_NONBLOCK + an fstat S_ISREG check, so a FIFO is
5785    // rejected without blocking. The whole copy is wrapped in a generous timeout: the assertion is
5786    // that it never hangs, and a FIFO is never copied as file data.
5787    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5788    async fn fifo_swap_never_hangs_or_copies_pipe() -> Result<(), anyhow::Error> {
5789        let tmp_dir = testutils::create_temp_dir().await?;
5790        let test_path = tmp_dir.as_path();
5791        let src = test_path.join("src");
5792        let sub = src.join("sub");
5793        tokio::fs::create_dir(&src).await?;
5794        tokio::fs::create_dir(&sub).await?;
5795        tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
5796
5797        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5798        let swapper = {
5799            let sub = sub.clone();
5800            let stop = stop.clone();
5801            std::thread::spawn(move || {
5802                let entry = sub.join("entry");
5803                let staged_real = sub.join("__staged_real");
5804                let staged_fifo = sub.join("__staged_fifo");
5805                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5806                    // real file staged then renamed over `entry`.
5807                    let _ = std::fs::remove_file(&staged_real);
5808                    if std::fs::write(&staged_real, b"REAL_CONTENT").is_ok() {
5809                        let _ = std::fs::rename(&staged_real, &entry);
5810                    }
5811                    // FIFO staged then renamed over `entry`. mkfifo can't target an existing name,
5812                    // so create it at a staging path and rename it in.
5813                    let _ = std::fs::remove_file(&staged_fifo);
5814                    let made = nix::unistd::mkfifo(
5815                        &staged_fifo,
5816                        nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
5817                    )
5818                    .is_ok();
5819                    if made {
5820                        let _ = std::fs::rename(&staged_fifo, &entry);
5821                    }
5822                }
5823            })
5824        };
5825
5826        let settings = toctou_settings();
5827        for i in 0..200 {
5828            let dst = test_path.join(format!("dst_{i}"));
5829            // the generous timeout is the anti-hang assertion: a FIFO opened without O_NONBLOCK
5830            // would block the copy forever waiting for a writer.
5831            let _ = tokio::time::timeout(
5832                std::time::Duration::from_secs(30),
5833                copy(
5834                    &PROGRESS,
5835                    &src,
5836                    &dst,
5837                    &settings,
5838                    &NO_PRESERVE_SETTINGS,
5839                    false,
5840                ),
5841            )
5842            .await
5843            .expect("copy must not hang when an entry is swapped to a FIFO");
5844            // CORE ASSERTION: whatever landed at the destination is either absent or a real regular
5845            // file with the real content — a FIFO is never copied as data, and never copied as a
5846            // special (no specials are expected in the destination).
5847            let entry_dst = dst.join("sub").join("entry");
5848            if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await {
5849                use std::os::unix::fs::FileTypeExt as _;
5850                let ft = md.file_type();
5851                assert!(
5852                    !ft.is_fifo(),
5853                    "iteration {i}: a FIFO was reproduced at the destination"
5854                );
5855                if ft.is_file() {
5856                    let content = tokio::fs::read_to_string(&entry_dst).await?;
5857                    assert_eq!(
5858                        content, "REAL_CONTENT",
5859                        "iteration {i}: a regular destination file must hold the real content"
5860                    );
5861                }
5862            }
5863            let _ = tokio::fs::remove_dir_all(&dst).await;
5864        }
5865
5866        stop.store(true, std::sync::atomic::Ordering::Relaxed);
5867        swapper.join().expect("fifo swapper thread panicked");
5868        Ok(())
5869    }
5870
5871    // Task 1.6: a deep, narrow chain `a/a/a/.../file` copies fully without hanging or exhausting
5872    // fds. `copy_internal` is `#[async_recursion]`, so depth exercises the recursive chain; 800 is
5873    // deep enough to stress the fd budget / hold-and-wait avoidance without risking a stack blowup.
5874    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5875    async fn copy_deep_narrow_tree_completes() -> Result<(), anyhow::Error> {
5876        let tmp_dir = testutils::create_temp_dir().await?;
5877        let test_path = tmp_dir.as_path();
5878        let src = test_path.join("src");
5879        const DEPTH: usize = 800;
5880        // build src/a/a/.../a/leaf.txt by appending one "a" per level.
5881        let mut cur = src.clone();
5882        for _ in 0..DEPTH {
5883            cur = cur.join("a");
5884        }
5885        tokio::fs::create_dir_all(&cur).await?;
5886        tokio::fs::write(cur.join("leaf.txt"), "DEEP_LEAF").await?;
5887
5888        let dst = test_path.join("dst");
5889        let summary = tokio::time::timeout(
5890            std::time::Duration::from_secs(120),
5891            copy(
5892                &PROGRESS,
5893                &src,
5894                &dst,
5895                &toctou_settings(),
5896                &NO_PRESERVE_SETTINGS,
5897                false,
5898            ),
5899        )
5900        .await
5901        .expect("deep-narrow copy must not hang (fd budget / hold-and-wait)")?;
5902        assert_eq!(
5903            summary.files_copied, 1,
5904            "the single leaf file must be copied"
5905        );
5906        // the destination root directory plus the DEPTH-level `a` chain copied into it.
5907        assert_eq!(summary.directories_created, DEPTH + 1);
5908        // verify the leaf actually landed at the bottom of the destination chain.
5909        let mut leaf = dst.clone();
5910        for _ in 0..DEPTH {
5911            leaf = leaf.join("a");
5912        }
5913        let content = tokio::fs::read_to_string(leaf.join("leaf.txt")).await?;
5914        assert_eq!(content, "DEEP_LEAF");
5915        Ok(())
5916    }
5917
5918    // Task 1.6: a single very wide directory (many sibling files) copies fully within a timeout. The
5919    // per-entry open-files permit bounds concurrency; this confirms the wide fan-out drains without
5920    // fd exhaustion or a hang.
5921    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5922    async fn copy_wide_tree_completes() -> Result<(), anyhow::Error> {
5923        let tmp_dir = testutils::create_temp_dir().await?;
5924        let test_path = tmp_dir.as_path();
5925        let src = test_path.join("src");
5926        tokio::fs::create_dir(&src).await?;
5927        const WIDTH: usize = 2000;
5928        for i in 0..WIDTH {
5929            tokio::fs::write(src.join(format!("f{i}.txt")), format!("content {i}")).await?;
5930        }
5931        let dst = test_path.join("dst");
5932        let summary = tokio::time::timeout(
5933            std::time::Duration::from_secs(120),
5934            copy(
5935                &PROGRESS,
5936                &src,
5937                &dst,
5938                &toctou_settings(),
5939                &NO_PRESERVE_SETTINGS,
5940                false,
5941            ),
5942        )
5943        .await
5944        .expect("wide copy must not hang (fd exhaustion / permit deadlock)")?;
5945        assert_eq!(summary.files_copied, WIDTH, "every file must be copied");
5946        // spot-check a few files actually landed with the right content.
5947        for i in [0usize, WIDTH / 2, WIDTH - 1] {
5948            let content = tokio::fs::read_to_string(dst.join(format!("f{i}.txt"))).await?;
5949            assert_eq!(content, format!("content {i}"));
5950        }
5951        Ok(())
5952    }
5953
5954    // fake metadata carrying the fields --overwrite-compare can use (size, mtime, uid, gid, mode).
5955    #[derive(Clone, Debug)]
5956    struct CmpMeta {
5957        uid: u32,
5958        gid: u32,
5959        mode: u32,
5960        size: u64,
5961        mtime: i64,
5962    }
5963    impl crate::preserve::Metadata for CmpMeta {
5964        fn uid(&self) -> u32 {
5965            self.uid
5966        }
5967        fn gid(&self) -> u32 {
5968            self.gid
5969        }
5970        fn atime(&self) -> i64 {
5971            0
5972        }
5973        fn atime_nsec(&self) -> i64 {
5974            0
5975        }
5976        fn mtime(&self) -> i64 {
5977            self.mtime
5978        }
5979        fn mtime_nsec(&self) -> i64 {
5980            0
5981        }
5982        fn permissions(&self) -> std::fs::Permissions {
5983            std::os::unix::fs::PermissionsExt::from_mode(self.mode)
5984        }
5985        fn size(&self) -> u64 {
5986            self.size
5987        }
5988    }
5989
5990    fn size_mtime() -> filecmp::MetadataCmpSettings {
5991        filecmp::MetadataCmpSettings {
5992            size: true,
5993            mtime: true,
5994            ..Default::default()
5995        }
5996    }
5997    fn base() -> CmpMeta {
5998        CmpMeta {
5999            uid: 0,
6000            gid: 0,
6001            mode: 0o644,
6002            size: 10,
6003            mtime: 100,
6004        }
6005    }
6006
6007    #[test]
6008    fn skip_send_no_existing_entry_sends() {
6009        let src = base();
6010        let r = skip_unchanged_send(
6011            &size_mtime(),
6012            None,
6013            false,
6014            &src,
6015            None::<ExistingDst<CmpMeta>>,
6016        );
6017        assert!(!r);
6018    }
6019
6020    #[test]
6021    fn skip_send_ignore_existing_skips_any_type() {
6022        let src = base();
6023        let dst = base();
6024        let r = skip_unchanged_send(
6025            &size_mtime(),
6026            None,
6027            true,
6028            &src,
6029            Some(ExistingDst {
6030                meta: &dst,
6031                is_file: false,
6032            }),
6033        );
6034        assert!(r);
6035    }
6036
6037    #[test]
6038    fn skip_send_overwrite_identical_skips() {
6039        let src = base();
6040        let dst = base();
6041        let r = skip_unchanged_send(
6042            &size_mtime(),
6043            None,
6044            false,
6045            &src,
6046            Some(ExistingDst {
6047                meta: &dst,
6048                is_file: true,
6049            }),
6050        );
6051        assert!(r);
6052    }
6053
6054    #[test]
6055    fn skip_send_overwrite_different_size_sends() {
6056        let src = base();
6057        let dst = CmpMeta { size: 11, ..base() };
6058        let r = skip_unchanged_send(
6059            &size_mtime(),
6060            None,
6061            false,
6062            &src,
6063            Some(ExistingDst {
6064                meta: &dst,
6065                is_file: true,
6066            }),
6067        );
6068        assert!(!r);
6069    }
6070
6071    #[test]
6072    fn skip_send_overwrite_different_mtime_sends() {
6073        let src = base();
6074        let dst = CmpMeta {
6075            mtime: 200,
6076            ..base()
6077        };
6078        let r = skip_unchanged_send(
6079            &size_mtime(),
6080            None,
6081            false,
6082            &src,
6083            Some(ExistingDst {
6084                meta: &dst,
6085                is_file: true,
6086            }),
6087        );
6088        assert!(!r);
6089    }
6090
6091    #[test]
6092    fn skip_send_overwrite_non_file_sends() {
6093        let src = base();
6094        let dst = base();
6095        let r = skip_unchanged_send(
6096            &size_mtime(),
6097            None,
6098            false,
6099            &src,
6100            Some(ExistingDst {
6101                meta: &dst,
6102                is_file: false,
6103            }),
6104        );
6105        assert!(!r);
6106    }
6107
6108    #[test]
6109    fn skip_send_filter_newer_skips_when_dest_newer() {
6110        let src = CmpMeta {
6111            mtime: 100,
6112            size: 5,
6113            ..base()
6114        };
6115        let dst = CmpMeta {
6116            mtime: 200,
6117            size: 11,
6118            ..base()
6119        };
6120        let r = skip_unchanged_send(
6121            &size_mtime(),
6122            Some(OverwriteFilter::Newer),
6123            false,
6124            &src,
6125            Some(ExistingDst {
6126                meta: &dst,
6127                is_file: true,
6128            }),
6129        );
6130        assert!(r);
6131    }
6132
6133    #[test]
6134    fn skip_send_filter_newer_sends_when_dest_older() {
6135        let src = CmpMeta {
6136            mtime: 200,
6137            size: 5,
6138            ..base()
6139        };
6140        let dst = CmpMeta {
6141            mtime: 100,
6142            size: 11,
6143            ..base()
6144        };
6145        let r = skip_unchanged_send(
6146            &size_mtime(),
6147            Some(OverwriteFilter::Newer),
6148            false,
6149            &src,
6150            Some(ExistingDst {
6151                meta: &dst,
6152                is_file: true,
6153            }),
6154        );
6155        assert!(!r);
6156    }
6157}