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