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