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, RemovalSnapshot};
18use crate::walk::{AdmittedEntry, AdmittedLeaf, EntryAdmission, EntryKind, LeafPermit, PermitKind};
19use crate::walk_driver::{
20    DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_admitted_entry,
21    process_entry,
22};
23
24/// Error type for copy operations. See [`crate::error::OperationError`] for
25/// logging conventions and rationale.
26pub type Error = crate::error::OperationError<Summary>;
27
28/// Filter condition for overwrite operations.
29///
30/// Used with `--overwrite-filter` to skip overwriting files that match
31/// a directional condition (e.g., destination is newer than source).
32#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
33pub enum OverwriteFilter {
34    /// Skip overwriting if the destination file is strictly newer (by mtime).
35    #[value(name = "newer")]
36    Newer,
37}
38
39impl std::fmt::Display for OverwriteFilter {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            OverwriteFilter::Newer => write!(f, "newer"),
43        }
44    }
45}
46
47/// A destination entry the source compares against, taken from the directory manifest.
48pub struct ExistingDst<'a, M: crate::preserve::Metadata> {
49    /// The destination entry's metadata (with `size()` populated).
50    pub meta: &'a M,
51    /// Whether the destination entry is a regular file (only files are eligible for the
52    /// metadata-equality compare; a non-file means the source must send so the destination
53    /// can remove-and-replace it).
54    pub is_file: bool,
55}
56
57/// Decide whether the source may SKIP sending a file because the destination already holds a
58/// matching entry. `dst` is `None` when the destination has no entry at that name. Returns
59/// `true` to skip (the caller sends a `FileUnchanged` notification instead of file data).
60///
61/// This mirrors the destination's `process_single_file` identical/`--ignore-existing`/newer
62/// checks, so the source pre-filters exactly the files the destination would otherwise receive
63/// and drain. Skipping performs no filesystem mutation.
64pub fn skip_unchanged_send<S, D>(
65    overwrite_compare: &filecmp::MetadataCmpSettings,
66    overwrite_filter: Option<OverwriteFilter>,
67    ignore_existing: bool,
68    src: &S,
69    dst: Option<ExistingDst<'_, D>>,
70) -> bool
71where
72    S: crate::preserve::Metadata + std::fmt::Debug,
73    D: crate::preserve::Metadata + std::fmt::Debug,
74{
75    let Some(dst) = dst else {
76        return false; // nothing at the destination — must send
77    };
78    if ignore_existing {
79        return true; // any pre-existing entry (file/dir/symlink) — skip
80    }
81    if !dst.is_file {
82        return false; // dest is a non-file under --overwrite — send so dest replaces it
83    }
84    if filecmp::metadata_equal(overwrite_compare, src, dst.meta) {
85        return true; // identical per --overwrite-compare
86    }
87    if overwrite_filter == Some(OverwriteFilter::Newer) && filecmp::dest_is_newer(src, dst.meta) {
88        return true; // --overwrite-filter=newer and destination is strictly newer
89    }
90    false
91}
92
93/// Settings controlling rsync-style `--delete` (mirror) behavior.
94///
95/// Present (`Some`) only when `--delete` was requested. `None` means the
96/// destination is never enumerated and no pruning work is done, so the default
97/// copy path pays nothing for this feature.
98#[derive(Debug, Clone)]
99pub struct DeleteSettings {
100    /// Also remove destination entries that match an exclude pattern
101    /// (rsync `--delete-excluded`). When false, excluded entries are protected.
102    pub delete_excluded: bool,
103}
104
105#[derive(Debug, Clone)]
106pub struct Settings {
107    pub dereference: bool,
108    pub fail_early: bool,
109    pub overwrite: bool,
110    pub overwrite_compare: filecmp::MetadataCmpSettings,
111    pub overwrite_filter: Option<OverwriteFilter>,
112    pub ignore_existing: bool,
113    pub chunk_size: u64,
114    /// Skip special files (sockets, FIFOs, devices) without error.
115    pub skip_specials: bool,
116    /// Buffer size for remote copy file transfer operations in bytes.
117    ///
118    /// This is only used for remote copy operations and controls the buffer size
119    /// when copying data between files and network streams. The actual buffer is
120    /// capped to the file size to avoid over-allocation for small files.
121    pub remote_copy_buffer_size: usize,
122    /// filter settings for include/exclude patterns
123    pub filter: Option<crate::filter::FilterSettings>,
124    /// dry-run mode for previewing operations
125    pub dry_run: Option<crate::config::DryRunMode>,
126    /// rsync-style `--delete` settings; `None` disables deletion entirely.
127    pub delete: Option<DeleteSettings>,
128}
129
130/// Summary with the appropriate `*_skipped` counter set to 1 for the given entry kind.
131/// Special files count as `files_skipped` to match the historical mapping used
132/// when filters skip an entry (`specials_skipped` is reserved for `--skip-specials`).
133fn skipped_summary_for(kind: EntryKind) -> Summary {
134    match kind {
135        EntryKind::Dir => Summary {
136            directories_skipped: 1,
137            ..Default::default()
138        },
139        EntryKind::Symlink => Summary {
140            symlinks_skipped: 1,
141            ..Default::default()
142        },
143        EntryKind::File | EntryKind::Special => Summary {
144            files_skipped: 1,
145            ..Default::default()
146        },
147    }
148}
149
150/// Result of checking if an empty directory should be cleaned up.
151/// Used when filtering is active and a directory we created ended up empty.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum EmptyDirAction {
154    /// keep the directory (directly matched or no filter active)
155    Keep,
156    /// directory was only traversed, remove it
157    Remove,
158    /// dry-run mode, don't count this directory in summary
159    DryRunSkip,
160}
161
162/// Determine what to do with an empty directory when filtering is active.
163///
164/// This is called when we created a directory but nothing was copied into it
165/// (no files, symlinks, or child directories). The decision depends on whether
166/// the directory itself was directly matched by an include pattern, or if we
167/// only entered it to look for potential matches inside.
168///
169/// # Arguments
170/// * `filter` - the active filter settings (None means no filtering)
171/// * `we_created_dir` - whether we created this directory (vs it already existed)
172/// * `anything_copied` - whether any content was copied into this directory
173/// * `relative_path` - path relative to the source root (for pattern matching)
174/// * `is_root` - whether this is the root (user-specified) source directory
175/// * `is_dry_run` - whether we're in dry-run mode
176pub fn check_empty_dir_cleanup(
177    filter: Option<&crate::filter::FilterSettings>,
178    we_created_dir: bool,
179    anything_copied: bool,
180    relative_path: &std::path::Path,
181    is_root: bool,
182    is_dry_run: bool,
183) -> EmptyDirAction {
184    // if no filter active or something was copied, keep the directory
185    if filter.is_none() || anything_copied {
186        return EmptyDirAction::Keep;
187    }
188    // if we didn't create this directory, don't remove it
189    if !we_created_dir {
190        return EmptyDirAction::Keep;
191    }
192    // never remove the root directory — it's the user-specified source
193    if is_root {
194        return EmptyDirAction::Keep;
195    }
196    // filter is guaranteed to be Some here (checked above)
197    let f = filter.unwrap();
198    // check if directory directly matches include pattern
199    if f.directly_matches_include(relative_path, true) {
200        return EmptyDirAction::Keep;
201    }
202    // directory was only traversed for potential matches
203    if is_dry_run {
204        EmptyDirAction::DryRunSkip
205    } else {
206        EmptyDirAction::Remove
207    }
208}
209
210#[instrument]
211pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
212    let ft1 = md1.file_type();
213    let ft2 = md2.file_type();
214    ft1.is_dir() == ft2.is_dir()
215        && ft1.is_file() == ft2.is_file()
216        && ft1.is_symlink() == ft2.is_symlink()
217}
218
219#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
220pub struct Summary {
221    pub bytes_copied: u64,
222    pub files_copied: usize,
223    pub symlinks_created: usize,
224    pub directories_created: usize,
225    pub files_unchanged: usize,
226    pub symlinks_unchanged: usize,
227    pub directories_unchanged: usize,
228    pub files_skipped: usize,
229    pub symlinks_skipped: usize,
230    pub directories_skipped: usize,
231    pub specials_skipped: usize,
232    pub rm_summary: RmSummary,
233}
234
235impl std::ops::Add for Summary {
236    type Output = Self;
237    fn add(self, other: Self) -> Self {
238        Self {
239            bytes_copied: self.bytes_copied + other.bytes_copied,
240            files_copied: self.files_copied + other.files_copied,
241            symlinks_created: self.symlinks_created + other.symlinks_created,
242            directories_created: self.directories_created + other.directories_created,
243            files_unchanged: self.files_unchanged + other.files_unchanged,
244            symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
245            directories_unchanged: self.directories_unchanged + other.directories_unchanged,
246            files_skipped: self.files_skipped + other.files_skipped,
247            symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
248            directories_skipped: self.directories_skipped + other.directories_skipped,
249            specials_skipped: self.specials_skipped + other.specials_skipped,
250            rm_summary: self.rm_summary + other.rm_summary,
251        }
252    }
253}
254
255impl std::fmt::Display for Summary {
256    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
257        write!(
258            f,
259            "copy:\n\
260            -----\n\
261            bytes copied: {}\n\
262            files copied: {}\n\
263            symlinks created: {}\n\
264            directories created: {}\n\
265            files unchanged: {}\n\
266            symlinks unchanged: {}\n\
267            directories unchanged: {}\n\
268            files skipped: {}\n\
269            symlinks skipped: {}\n\
270            directories skipped: {}\n\
271            specials skipped: {}\n\
272            \n\
273            delete:\n\
274            -------\n\
275            {}",
276            bytesize::ByteSize(self.bytes_copied),
277            self.files_copied,
278            self.symlinks_created,
279            self.directories_created,
280            self.files_unchanged,
281            self.symlinks_unchanged,
282            self.directories_unchanged,
283            self.files_skipped,
284            self.symlinks_skipped,
285            self.directories_skipped,
286            self.specials_skipped,
287            &self.rm_summary,
288        )
289    }
290}
291
292/// Public entry point for copy operations.
293/// Internally delegates to [`copy_with_filter_base`] with an empty filter base.
294#[instrument(skip(prog_track, settings, preserve))]
295pub async fn copy(
296    prog_track: &'static progress::Progress,
297    src: &std::path::Path,
298    dst: &std::path::Path,
299    settings: &Settings,
300    preserve: &preserve::Settings,
301    is_fresh: bool,
302) -> Result<Summary, Error> {
303    copy_with_filter_base(
304        prog_track,
305        src,
306        dst,
307        settings,
308        preserve,
309        is_fresh,
310        std::path::Path::new(""),
311    )
312    .await
313}
314
315/// Like [`copy`], but treats `src` as living at `filter_base` relative to the original filter
316/// root. Used when `rlink` delegates an update-only entry to `copy`: `--delete` pruning inside
317/// the delegated subtree then matches the include/exclude filter at the entry's true relative
318/// path (e.g. `cache/*.log`) instead of relative to the delegated root.
319///
320/// The local copy walk is fd-based: the source and destination roots are opened relative to
321/// their parent directories and every per-entry operation is performed through
322/// file-descriptor-relative syscalls (see [`crate::safedir`]). The held parents prevent full-path
323/// redirection and contain each by-name operation to its intended directory. `--dereference` is the
324/// one exception — it still resolves symlinks by path (`canonicalize`) and is not hardened.
325#[instrument(skip(prog_track, settings, preserve))]
326#[allow(clippy::too_many_arguments)]
327pub async fn copy_with_filter_base(
328    prog_track: &'static progress::Progress,
329    src: &std::path::Path,
330    dst: &std::path::Path,
331    settings: &Settings,
332    preserve: &preserve::Settings,
333    is_fresh: bool,
334    filter_base: &std::path::Path,
335) -> Result<Summary, Error> {
336    copy_with_filter_base_admitted(
337        prog_track,
338        src,
339        dst,
340        settings,
341        preserve,
342        is_fresh,
343        filter_base,
344        DeleteScanAnchor::new(dst, std::path::Path::new("")),
345        None,
346    )
347    .await
348}
349
350/// Path-based root setup with transferable leaf admission.
351///
352/// Normal public calls arrive without a guard and acquire before opening either operand parent or
353/// performing a strict fd-based root filter probe. `--dereference` transfers its spawn-loop guard
354/// here, so a wide set of symlinks cannot fan out into unadmitted target walks. The shared driver
355/// releases the guard if authoritative target classification proves it is a directory.
356#[instrument(skip(prog_track, settings, preserve, open_file_guard))]
357#[allow(clippy::too_many_arguments)]
358async fn copy_with_filter_base_admitted(
359    prog_track: &'static progress::Progress,
360    src: &std::path::Path,
361    dst: &std::path::Path,
362    settings: &Settings,
363    preserve: &preserve::Settings,
364    is_fresh: bool,
365    filter_base: &std::path::Path,
366    delete_scan_anchor: DeleteScanAnchor,
367    open_file_guard: Option<throttle::OpenFileGuard>,
368) -> Result<Summary, Error> {
369    let open_file_guard = match open_file_guard {
370        Some(guard) => guard,
371        None => throttle::open_file_permit().await,
372    };
373    enum CopyRootSetup {
374        Complete(Summary),
375        Ready {
376            src_operand: crate::walk::RootOperand,
377            src_parent: Arc<Dir>,
378            dst_parent: Option<Arc<Dir>>,
379        },
380    }
381    let setup_admission = open_file_guard.admission();
382    let setup = safedir::with_fd_admission(setup_admission, async {
383        // Source: decompose via the shared helper so `.`/`..` operands (e.g. `rcp . dst`, `rcp src/..
384        // dst`) are canonicalized to a real directory + basename instead of being rejected; `/` is still
385        // rejected. (The helper also maps a single-component relative path's empty parent to ".".)
386        let src_operand = crate::walk::split_root_operand(src)
387            .await
388            .map_err(|err| Error::new(err, Default::default()))?;
389        let src_parent_path = src_operand.parent.as_path();
390        let src_name = src_operand.name.as_os_str();
391        let src = src_operand.display.as_path();
392        // The destination's parent path, split leniently for the up-front strict validation below (a
393        // `.`/`..`/`/` destination has no distinct parent+name; the authoritative split — which rejects
394        // such a destination — runs AFTER the filter, preserving default-mode ordering so a filtered-out
395        // source still skips cleanly).
396        let dst_parent_path_opt: Option<&std::path::Path> = match (dst.parent(), dst.file_name()) {
397            (Some(parent), Some(_)) if parent.as_os_str().is_empty() => {
398                Some(std::path::Path::new("."))
399            }
400            (Some(parent), Some(_)) => Some(parent),
401            _ => None,
402        };
403        // Under strict operand resolution (--require-toctou-safe), resolve BOTH operand parents UP
404        // FRONT — before the source root-filter early-return, unconditionally, in every mode (real and
405        // dry-run) — via `open_parent_dir` (openat2 RESOLVE_NO_SYMLINKS). This single open per operand
406        // IS the strict validation: a symlink in any directory component of the source OR destination
407        // path fails closed with ELOOP here, so no filter/dry-run/overwrite branch downstream can let a
408        // symlinked prefix through. The held fds are reused below (source classify + walk; destination
409        // walk). On the DEFAULT path this is skipped: the filter check runs first over a path stat, so
410        // an excluded root under an execute-only (0111, searchable-not-readable) parent skips cleanly
411        // without requiring parent read.
412        let strict = crate::safedir::strict_operand_resolution();
413        let strict_src_parent: Option<Arc<Dir>> = if strict {
414            let parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
415                .await
416                .with_context(|| {
417                    format!("cannot open source parent directory {:?}", src_parent_path)
418                })
419                .map_err(|err| Error::new(err, Default::default()))?;
420            // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
421            Some(Arc::new(parent.into_tree()))
422        } else {
423            None
424        };
425        let strict_dst_parent: Option<Arc<Dir>> = match (strict, dst_parent_path_opt) {
426            (true, Some(dst_parent_path)) => {
427                match Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination).await {
428                    Ok(parent) => Some(Arc::new(parent.into_tree())),
429                    // an absent destination parent (dry-run previewing into a not-yet-created tree, or
430                    // a filtered-out root) is not a symlink violation — leave it to the walk's
431                    // functional handling below. Only a symlinked prefix component (ELOOP) fails here.
432                    Err(err)
433                        if err.kind() == std::io::ErrorKind::NotFound
434                            || err.raw_os_error() == Some(libc::ENOTDIR) =>
435                    {
436                        None
437                    }
438                    Err(err) => {
439                        return Err(Error::new(
440                            anyhow::Error::new(err).context(format!(
441                                "cannot open destination parent directory {dst_parent_path:?}"
442                            )),
443                            Default::default(),
444                        ));
445                    }
446                }
447            }
448            // default mode, or a degenerate destination (rejected by the authoritative split below)
449            _ => None,
450        };
451        // check filter for top-level source (files, directories, and symlinks)
452        if let Some(ref filter) = settings.filter {
453            let (kind, is_dir) = match &strict_src_parent {
454                // strict: classify via the held parent fd (O_NOFOLLOW), never by path
455                Some(parent) => {
456                    let root_handle = parent
457                        .child(src_name)
458                        .await
459                        .with_context(|| format!("failed reading metadata from src: {:?}", &src))
460                        .map_err(|err| Error::new(err, Default::default()))?;
461                    (root_handle.kind(), root_handle.kind() == EntryKind::Dir)
462                }
463                None => {
464                    let src_metadata = crate::walk::run_metadata_probed(
465                        congestion::Side::Source,
466                        congestion::MetadataOp::Stat,
467                        tokio::fs::symlink_metadata(src),
468                    )
469                    .await
470                    .with_context(|| format!("failed reading metadata from src: {:?}", &src))
471                    .map_err(|err| Error::new(err, Default::default()))?;
472                    (
473                        EntryKind::from_metadata(&src_metadata),
474                        src_metadata.is_dir(),
475                    )
476                }
477            };
478            // for a delegated subtree (non-empty filter_base) the source is not the true filter
479            // root, so match it at its logical path with nested semantics; for a normal copy use
480            // root-item semantics (anchored patterns don't apply to the root itself).
481            let result = if filter_base.as_os_str().is_empty() {
482                filter.should_include_root_item(std::path::Path::new(src_name), is_dir)
483            } else {
484                filter.should_include(filter_base, is_dir)
485            };
486            match result {
487                crate::filter::FilterResult::Included => {}
488                result => {
489                    if let Some(mode) = settings.dry_run {
490                        crate::dry_run::report_skip(src, &result, mode, kind.label_long());
491                    }
492                    kind.inc_skipped(prog_track);
493                    return Ok(CopyRootSetup::Complete(skipped_summary_for(kind)));
494                }
495            }
496        }
497        // Source parent for the walk: reuse the strict-validated fd, or open it following symlinks
498        // (default mode; the trusted prefix up to and including the operand's container).
499        let src_parent = match strict_src_parent {
500            Some(parent) => parent,
501            None => {
502                let parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
503                    .await
504                    .with_context(|| {
505                        format!("cannot open source parent directory {:?}", src_parent_path)
506                    })
507                    .map_err(|err| Error::new(err, Default::default()))?;
508                Arc::new(parent.into_tree())
509            }
510        };
511        // One `listxattr` on the source ROOT per run — a constant, not a per-entry probe — warning that
512        // a source root carrying an ACL is about to be copied by settings that drop it. Skipped
513        // entirely by a copy that asked for no preservation at all, once ACLs are preserved for both
514        // kinds, and after the first root of the run.
515        crate::safedir::warn_if_root_acl_unpreserved_at(
516            &src_parent,
517            src_name,
518            src,
519            crate::safedir::RootAclNotice::for_preserve(preserve),
520        )
521        .await;
522        // Authoritative destination split (runs AFTER the filter, preserving default-mode ordering): a
523        // `.`/`..`/`/` destination is not a meaningful copy target, and rejecting it avoids clobbering
524        // the cwd. empty parent (a single-component relative path) means the current directory.
525        let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
526            return Err(Error::new(
527                anyhow!(
528                    "copy destination {:?} has no parent directory or file name",
529                    dst
530                ),
531                Default::default(),
532            ));
533        };
534        let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
535            std::path::Path::new(".")
536        } else {
537            dst_parent_path
538        };
539        // In dry-run we never touch the destination, so we don't open its parent at all (the parent
540        // may not even exist). `dst_parent == None` is the signal throughout the walk that destination
541        // operations must be skipped. In a real copy, reuse the strict-validated parent, or open it
542        // following symlinks (default mode; `rcp file symlink_to_dir/out` copies into the real dir).
543        let dst_parent = if settings.dry_run.is_some() {
544            None
545        } else {
546            match strict_dst_parent {
547                Some(parent) => Some(parent),
548                None => {
549                    let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
550                        .await
551                        .with_context(|| {
552                            format!(
553                                "cannot open destination parent directory {:?}",
554                                dst_parent_path
555                            )
556                        })
557                        .map_err(|err| Error::new(err, Default::default()))?;
558                    Some(Arc::new(dir.into_tree()))
559                }
560            }
561        };
562        Ok(CopyRootSetup::Ready {
563            src_operand,
564            src_parent,
565            dst_parent,
566        })
567    })
568    .await?;
569    let (src_operand, src_parent, dst_parent) = match setup {
570        CopyRootSetup::Complete(summary) => return Ok(summary),
571        CopyRootSetup::Ready {
572            src_operand,
573            src_parent,
574            dst_parent,
575        } => (src_operand, src_parent, dst_parent),
576    };
577    run_copy_root(
578        prog_track,
579        &src_parent,
580        dst_parent,
581        &src_operand.name,
582        &src_operand.display,
583        dst,
584        filter_base,
585        settings,
586        preserve,
587        is_fresh,
588        delete_scan_anchor,
589        EntryAdmission::Held(LeafPermit::OpenFile(open_file_guard)).into(),
590    )
591    .await
592}
593
594/// Classification state accepted by copy's fd-relative root boundary.
595///
596/// An admitted entry retains the exact handle whose type selected the copy action, but its root
597/// filter decision is still pending. A filtered entry is rlink's exact handle after filtering in
598/// its logical namespace, so the shared driver must consume it directly rather than re-filter it
599/// under a delegated physical basename. Regular-file and directory payload opens remain fd-relative
600/// by parent and name, preserving their existing same-type replacement semantics; symlink payload
601/// stays pinned to the admitted handle. Ordinary unclassified entries retain the existing
602/// classify-under-admission path.
603pub(crate) enum CopyEntryAdmission {
604    Unclassified(EntryAdmission),
605    Admitted(AdmittedEntry),
606    Filtered(AdmittedEntry),
607}
608
609impl From<EntryAdmission> for CopyEntryAdmission {
610    fn from(admission: EntryAdmission) -> Self {
611        Self::Unclassified(admission)
612    }
613}
614
615impl From<AdmittedEntry> for CopyEntryAdmission {
616    fn from(entry: AdmittedEntry) -> Self {
617        Self::Admitted(entry)
618    }
619}
620
621/// Copy a single entry `name` (within `src_parent`) into `dst_parent`, using held directory
622/// handles rather than re-resolving any path. This is the fd-based delegation entry point for
623/// `rlink`: when an entry must be COPIED rather than hard-linked (a file that changed vs the
624/// update tree, a symlink, a type-mismatch, or an update-only entry), `link` hands its already-open
625/// parent `Dir`s plus the entry `name` here, so the copy inherits the same intermediate-component
626/// TOCTOU safety the link walk has — no path is re-walked from a root.
627///
628/// `src_path`/`dst_path` are the entry's reconstructed real paths; they serve as the copy walk's
629/// roots for diagnostics and `--dereference` (`canonicalize`). `filter_base` is the entry's logical
630/// path relative to the original filter root. `delete_scan_anchor` separately retains the original
631/// destination operand plus this subtree's physical base, so dry-run pruning never derives its
632/// trust boundary from either display or filter paths. `dst_parent` is `None` only in dry-run (no
633/// destination mutation).
634#[instrument(skip(prog_track, src_parent, dst_parent, settings, preserve, admission))]
635#[allow(clippy::too_many_arguments)]
636pub(crate) async fn copy_child(
637    prog_track: &'static progress::Progress,
638    src_parent: &Arc<Dir>,
639    dst_parent: Option<&Arc<Dir>>,
640    name: &OsStr,
641    src_path: &std::path::Path,
642    dst_path: &std::path::Path,
643    filter_base: &std::path::Path,
644    settings: &Settings,
645    preserve: &preserve::Settings,
646    is_fresh: bool,
647    delete_scan_anchor: DeleteScanAnchor,
648    admission: CopyEntryAdmission,
649) -> Result<Summary, Error> {
650    run_copy_root(
651        prog_track,
652        src_parent,
653        dst_parent.map(Arc::clone),
654        name,
655        src_path,
656        dst_path,
657        filter_base,
658        settings,
659        preserve,
660        is_fresh,
661        delete_scan_anchor,
662        admission,
663    )
664    .await
665}
666
667/// Physical authority for reopening a destination subtree during dry-run deletion preview.
668#[derive(Clone, Debug)]
669pub(crate) struct DeleteScanAnchor {
670    named_root: PathBuf,
671    base: PathBuf,
672}
673
674impl DeleteScanAnchor {
675    /// Retain the named destination root and an independently supplied relative subtree base.
676    #[must_use]
677    pub(crate) fn new(named_root: &std::path::Path, base: &std::path::Path) -> Self {
678        Self {
679            named_root: named_root.to_path_buf(),
680            base: base.to_path_buf(),
681        }
682    }
683
684    fn relative_from_root(&self, relative: &std::path::Path) -> PathBuf {
685        if self.base.as_os_str().is_empty() {
686            relative.to_path_buf()
687        } else if relative.as_os_str().is_empty() {
688            self.base.clone()
689        } else {
690            self.base.join(relative)
691        }
692    }
693
694    fn descend(&self, relative: &std::path::Path) -> Self {
695        Self {
696            named_root: self.named_root.clone(),
697            base: self.relative_from_root(relative),
698        }
699    }
700
701    async fn open(&self, relative: &std::path::Path) -> std::io::Result<Option<Dir>> {
702        let relative = self.relative_from_root(relative);
703        safedir::open_existing_dir_beneath_operand(
704            &self.named_root,
705            &relative,
706            congestion::Side::Destination,
707        )
708        .await
709    }
710}
711
712/// The copy walk's [`WalkVisitor`]: holds the run-constant state shared by every entry and maps
713/// each per-entry decision onto the generic [`crate::walk_driver`] driver.
714///
715/// The driver owns enumeration, the leaf-permit lifecycle, spawning, the single
716/// drop-before-recurse site, and the error fold; this visitor supplies copy's per-entry bodies
717/// (`copy_file_fd`, `copy_symlink_fd`, `resolve_dst_dir`, `remove_existing`, the empty-dir cleanup,
718/// `--delete` prune, and directory metadata). The destination tree is threaded as an *inherited
719/// attribute* through [`Self::DirContext`] (each directory's children read their destination parent
720/// from `parent_ctx`), so the single-tree driver carries copy's second tree without modeling it.
721struct CopyVisitor {
722    prog_track: &'static progress::Progress,
723    /// The destination root operand. The root's destination basename comes from here (it may differ
724    /// from the source basename, e.g. copying `foo` → `bar`); nested entries reuse the source name.
725    /// The *source* root needs no field: an entry's source real path is the driver-maintained
726    /// `EntryCx::real_path` (seeded with the source root in [`run_copy_root`]).
727    dst_root: PathBuf,
728    /// The logical filter base: an entry's filter path is `filter_base.join(rel_path)`.
729    filter_base: PathBuf,
730    /// The original destination operand plus this walk's physical subtree base. Unlike
731    /// `filter_base`, this is never derived from logical filter coordinates.
732    delete_scan_anchor: DeleteScanAnchor,
733    settings: Settings,
734    preserve: preserve::Settings,
735    /// The opened top-level destination parent directory (`None` in dry-run). Seeds the
736    /// [`Self::DirContext`] chain via [`WalkVisitor::root_dir_context`].
737    dst_parent: Option<Arc<Dir>>,
738    /// The initial freshness for the root entry's containing directory.
739    root_is_fresh: bool,
740}
741
742/// Inherited per-directory context: the destination parent directory for one level plus its
743/// freshness. `dst_dir == None` is the dry-run signal threaded throughout the walk (no destination
744/// mutation). This is how the single-tree driver carries copy's destination tree — each child reads
745/// its destination parent from here rather than the driver knowing a second tree exists.
746#[derive(Clone)]
747struct CopyDirContext {
748    dst_dir: Option<Arc<Dir>>,
749    is_fresh: bool,
750}
751
752/// State threaded from [`WalkVisitor::dir_pre`] to [`WalkVisitor::dir_post`] in the same task:
753/// everything `dir_post` needs to run empty-dir cleanup, `--delete` prune, and apply directory
754/// metadata.
755struct CopyDirState {
756    /// The destination directory just created/reused (`None` in dry-run).
757    dst_dir: Option<Arc<Dir>>,
758    /// The destination parent directory and this directory's destination name within it — used to
759    /// remove an empty directory fd-relative (`dst_parent.rmdir_at(dst_name)`). `None` in dry-run.
760    dst_parent: Option<Arc<Dir>>,
761    dst_name: OsString,
762    /// Whether we created the destination directory (vs. reused an existing one).
763    we_created: bool,
764    /// What the strict-mode lockdown must put back at finalize (original owner + original ACLs),
765    /// `Some` iff this reused directory was locked down. See [`DirSlot::reused_lock`].
766    reused_lock: Option<safedir::ReusedDirLock>,
767    /// The source directory's metadata, applied to the destination post-order.
768    src_meta: FileMeta,
769    /// The source directory's access + default ACLs, read from the same fd as `src_meta` and
770    /// applied post-order alongside it. `None` when `d:acl` was not requested (so nothing was read)
771    /// or in dry-run (nothing is applied).
772    src_acls: Option<safedir::Acls>,
773    /// Whether this is the user-specified root directory (never empty-dir-cleaned up).
774    is_root: bool,
775    /// The `directories_created`/`directories_unchanged` contribution from resolving this
776    /// directory's destination, folded into the final summary.
777    base: Summary,
778}
779
780impl CopyVisitor {
781    /// The destination real path for the entry described by `cx` (`dst_root.join(rel_path)`, or the
782    /// root verbatim when `rel_path` is empty — joining an empty `rel_path` would append a trailing
783    /// separator that `canonicalize`/ENOTDIR-sensitive paths reject). Mirrors `copy_internal`.
784    fn dst_path_for(&self, cx: &EntryCx) -> PathBuf {
785        if cx.rel_path.as_os_str().is_empty() {
786            self.dst_root.clone()
787        } else {
788            self.dst_root.join(&cx.rel_path)
789        }
790    }
791
792    /// The destination entry's name within its parent. For nested entries this equals the source
793    /// `cx.name`, but for the root the source and destination basenames may differ (e.g. copying
794    /// `foo` → `bar`), so the root's destination name comes from `dst_root`. Mirrors `copy_internal`.
795    // copy's `Error` carries a `Summary` (intrinsically large); a fallible helper returning it trips
796    // `result_large_err` only because the `Ok` variant here is a small `OsString`. The error arm is
797    // unreachable in practice (`copy_with_filter_base` pre-validates the root's file name and a
798    // delegated `copy_child` root path always has one) — keep it as defense-in-depth.
799    #[allow(clippy::result_large_err)]
800    fn dst_name_for(&self, cx: &EntryCx) -> Result<OsString, Error> {
801        if cx.rel_path.as_os_str().is_empty() {
802            self.dst_root
803                .file_name()
804                .map(OsStr::to_owned)
805                .ok_or_else(|| {
806                    Error::new(
807                        anyhow!("copy destination {:?} has no file name", &self.dst_root),
808                        Default::default(),
809                    )
810                })
811        } else {
812            Ok(cx.name.clone())
813        }
814    }
815
816    /// rsync-style `--delete` prune for one finished directory: remove destination entries with no
817    /// source counterpart. Runs only when `--delete` was requested AND every child of this directory
818    /// succeeded — skipping the prune on any error (deleting based on a run that did not fully
819    /// succeed could remove data unexpectedly). Prune enumerates and removes through the
820    /// destination's own pinned directory fd, so a concurrent symlink swap cannot redirect it outside
821    /// the destination.
822    ///
823    /// Folds the prune's `RmSummary` into `copy_summary`. On a non-fail-early prune error it records
824    /// the error in `child_error` (surfaced later by [`Self::finalize_dir`]); it returns `Err` only
825    /// in fail-early mode or when the dry-run delete-scan open fails outright.
826    async fn prune_finished_dir(
827        &self,
828        copy_summary: &mut Summary,
829        child_error: &mut Option<anyhow::Error>,
830        processed: &ProcessedChildren,
831        dst_dir: &Option<Arc<Dir>>,
832        dst_path: &std::path::Path,
833        rel_path: &std::path::Path,
834    ) -> Result<(), Error> {
835        if child_error.is_some() && self.settings.delete.is_some() {
836            tracing::warn!(
837                "skipping --delete pruning of {:?} because the copy reported errors",
838                dst_path
839            );
840        }
841        let Some(delete_settings) = &self.settings.delete else {
842            return Ok(());
843        };
844        if child_error.is_some() {
845            return Ok(());
846        }
847        // the keep-set: every source child whose final exact decision included it and whose work
848        // succeeded (special files included — they have a source counterpart so must not be pruned).
849        // this is exactly the set `copy_dir_contents` built inline before the skip-specials check.
850        let keep_set: std::collections::HashSet<OsString> =
851            processed.names().iter().cloned().collect();
852        let relative_dir = self.filter_base.join(rel_path);
853        // in a real copy we already hold the destination directory. Dry-run instead descends from
854        // the original named operand one `O_NOFOLLOW` component at a time; the local `dst_path` is
855        // only a diagnostic string and cannot redirect the scan through a symlinked operand root.
856        let prune_dir: Option<Arc<Dir>> = match dst_dir {
857            Some(dir) => Some(Arc::clone(dir)),
858            None => match self.delete_scan_anchor.open(rel_path).await {
859                Ok(Some(dir)) => Some(Arc::new(dir)),
860                Ok(None) => {
861                    tracing::debug!(
862                        "skipping --delete pruning of {:?}: not a real directory in dry-run",
863                        dst_path
864                    );
865                    None
866                }
867                Err(err) => {
868                    let err = anyhow::Error::new(err).context(format!(
869                        "cannot open destination {dst_path:?} for delete scan"
870                    ));
871                    // a scan-open failure is surfaced as this directory's own error (this runs only
872                    // when children all succeeded, so there is no prior error to combine with —
873                    // matching the old fail-early/collect handling).
874                    return Err(Error::new(err, *copy_summary));
875                }
876            },
877        };
878        let Some(prune_dir) = prune_dir else {
879            return Ok(());
880        };
881        match crate::delete::prune_extraneous(
882            self.prog_track,
883            &prune_dir,
884            &relative_dir,
885            &keep_set,
886            self.settings.filter.as_ref(),
887            delete_settings,
888            self.settings.fail_early,
889            self.settings.dry_run,
890        )
891        .await
892        {
893            Ok(rm_summary) => {
894                copy_summary.rm_summary = copy_summary.rm_summary + rm_summary;
895            }
896            Err(err) => {
897                copy_summary.rm_summary = copy_summary.rm_summary + err.summary;
898                if self.settings.fail_early {
899                    return Err(Error::new(err.source, *copy_summary));
900                }
901                // non-fail-early: remember the prune error but still apply this fully-copied
902                // directory's own metadata below (matching main's collect-then-finalize), surfacing
903                // the prune error from the tail.
904                *child_error = Some(err.source);
905            }
906        }
907        Ok(())
908    }
909
910    /// Finish a directory after its children and `--delete` prune have run: when filtering left it
911    /// empty, clean it up (or, in dry-run, suppress its would-be-created count); otherwise apply the
912    /// source directory's metadata to the destination through its own held fd. A collected
913    /// `child_error` always takes precedence and is surfaced here (even when the empty directory was
914    /// pruned), so a failed child copy can never be reported as overall success.
915    async fn finalize_dir(&self, fin: FinalizeDir, cx: &EntryCx) -> Result<Summary, Error> {
916        let FinalizeDir {
917            mut copy_summary,
918            child_error,
919            dst_dir,
920            dst_parent,
921            dst_name,
922            src_meta,
923            src_acls,
924            we_created,
925            reused_lock,
926            is_root,
927        } = fin;
928        let src_path = &cx.real_path;
929        let dst_path = self.dst_path_for(cx);
930        let rel_path = &cx.rel_path;
931        // when filtering is active and we created this directory, check whether anything was
932        // actually copied into it. if not, we may need to clean up the empty directory.
933        let this_dir_count = usize::from(we_created);
934        let child_dirs_created = copy_summary
935            .directories_created
936            .saturating_sub(this_dir_count);
937        let anything_copied = copy_summary.files_copied > 0
938            || copy_summary.symlinks_created > 0
939            || child_dirs_created > 0;
940        let relative_path = self.filter_base.join(rel_path);
941        match check_empty_dir_cleanup(
942            self.settings.filter.as_ref(),
943            we_created,
944            anything_copied,
945            &relative_path,
946            is_root,
947            self.settings.dry_run.is_some(),
948        ) {
949            EmptyDirAction::Keep => { /* proceed with metadata application */ }
950            EmptyDirAction::DryRunSkip => {
951                tracing::debug!(
952                    "dry-run: directory {:?} would not be created (nothing to copy inside)",
953                    dst_path
954                );
955                copy_summary.directories_created = 0;
956                // the directory itself is not created, but a child error collected during the walk
957                // must still surface — otherwise a traversal-only directory whose only child FAILED
958                // becomes "empty", gets skipped here, and the failed copy is reported as success.
959                return match child_error {
960                    Some(child_error) => Err(Error::new(child_error, copy_summary)),
961                    None => Ok(copy_summary),
962                };
963            }
964            EmptyDirAction::Remove => {
965                tracing::debug!(
966                    "directory {:?} has nothing to copy inside, removing empty directory",
967                    dst_path
968                );
969                // remove the empty directory fd-relative, through its parent dir handle:
970                // `rmdir_at` operates on `dst_name` within the held `dst_parent` fd (never by path)
971                // and only succeeds on an empty directory, so it is contained to `dst_parent` and
972                // cannot be redirected. our still-open fd to the directory itself simply detaches.
973                // `dst_parent` is always Some here: it is None only in dry-run, where this arm is
974                // unreachable (`check_empty_dir_cleanup` returns `DryRunSkip`, not `Remove`).
975                let rmdir_result = match &dst_parent {
976                    Some(dst_parent) => dst_parent.rmdir_at(&dst_name).await,
977                    None => {
978                        crate::walk::run_metadata_probed(
979                            congestion::Side::Destination,
980                            congestion::MetadataOp::RmDir,
981                            tokio::fs::remove_dir(&dst_path),
982                        )
983                        .await
984                    }
985                };
986                match rmdir_result {
987                    Ok(()) => {
988                        copy_summary.directories_created = 0;
989                        // the empty directory is removed, but a child error collected during the
990                        // walk must still surface — otherwise a traversal-only directory whose only
991                        // child FAILED becomes "empty", is removed here, and the failed copy is
992                        // reported as success.
993                        return match child_error {
994                            Some(child_error) => Err(Error::new(child_error, copy_summary)),
995                            None => Ok(copy_summary),
996                        };
997                    }
998                    Err(err) => {
999                        tracing::debug!(
1000                            "failed to remove empty directory {:?}: {:#}, keeping",
1001                            dst_path,
1002                            &err
1003                        );
1004                        // fall through to apply metadata.
1005                    }
1006                }
1007            }
1008        }
1009        // apply directory metadata regardless of whether all children copied successfully (the
1010        // directory itself was created/opened in dir_pre). skipped in dry-run (no directory
1011        // exists). a child error takes precedence over a metadata error (matching the old tail:
1012        // log the metadata error, return the child error).
1013        tracing::debug!("set 'dst' directory metadata");
1014        let metadata_result = match &dst_dir {
1015            // For a reused directory locked down under strict mode, `set_reused_dir_metadata_fd`
1016            // restores the original owner component-wise — so no transient window hands the directory
1017            // back to a hostile prior owner — then applies the source metadata (mode last, so
1018            // setgid/special bits survive) and puts back the ACLs the lockdown stripped.
1019            // `reused_lock` is None for fresh dirs / non-strict reuse.
1020            Some(dst_dir) => {
1021                safedir::set_reused_dir_metadata_fd(
1022                    &self.preserve,
1023                    &src_meta,
1024                    src_acls.as_ref(),
1025                    reused_lock,
1026                    dst_dir,
1027                )
1028                .await
1029            }
1030            None => Ok(()),
1031        };
1032        if let Some(child_error) = child_error {
1033            if let Err(metadata_err) = metadata_result {
1034                tracing::error!(
1035                    "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
1036                    src_path,
1037                    dst_path,
1038                    &metadata_err
1039                );
1040            }
1041            return Err(Error::new(child_error, copy_summary));
1042        }
1043        // no child failures, so the metadata error is the primary error.
1044        metadata_result
1045            .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
1046            .map_err(|err| Error::new(err, copy_summary))?;
1047        Ok(copy_summary)
1048    }
1049}
1050
1051/// The owned per-directory state [`CopyVisitor::dir_post`] hands to [`CopyVisitor::finalize_dir`]
1052/// after the child summaries are folded and `--delete` pruning has run: the accumulated summary, any
1053/// collected child error to surface, and this directory's destination handles / classification.
1054struct FinalizeDir {
1055    copy_summary: Summary,
1056    child_error: Option<anyhow::Error>,
1057    dst_dir: Option<Arc<Dir>>,
1058    dst_parent: Option<Arc<Dir>>,
1059    dst_name: OsString,
1060    src_meta: FileMeta,
1061    src_acls: Option<safedir::Acls>,
1062    we_created: bool,
1063    reused_lock: Option<safedir::ReusedDirLock>,
1064    is_root: bool,
1065}
1066
1067async fn filter_and_process_admitted_root(
1068    visitor: Arc<CopyVisitor>,
1069    root_cx: EntryCx,
1070    root_ctx: CopyDirContext,
1071    entry: AdmittedEntry,
1072    filter: &crate::filter::FilterSettings,
1073) -> Result<Summary, Error> {
1074    let kind = entry.kind();
1075    let filter_result = if root_cx.filter_path.as_os_str().is_empty() {
1076        filter.should_include_root_item(
1077            std::path::Path::new(root_cx.name.as_os_str()),
1078            kind == EntryKind::Dir,
1079        )
1080    } else {
1081        filter.should_include(&root_cx.filter_path, kind == EntryKind::Dir)
1082    };
1083    if !matches!(filter_result, crate::filter::FilterResult::Included) {
1084        if let Some(mode) = visitor.settings.dry_run {
1085            crate::dry_run::report_skip(
1086                &root_cx.real_path,
1087                &filter_result,
1088                mode,
1089                kind.label_long(),
1090            );
1091        }
1092        kind.inc_skipped(visitor.prog_track);
1093        return Ok(skipped_summary_for(kind));
1094    }
1095    process_admitted_entry(visitor, root_cx, root_ctx, entry).await
1096}
1097
1098/// Build the [`CopyVisitor`] for one copy operation and process the root entry through the generic
1099/// driver. Shared by [`copy_with_filter_base`] (which acquires root admission before parent setup)
1100/// and [`copy_child`] (rlink's fd-based delegation entry point, which may pass an unclassified,
1101/// admitted, or already-filtered exact entry). The root is processed exactly like a nested child via
1102/// [`process_entry`] or [`process_admitted_entry`], then dispatched to `visit_leaf`
1103/// (file/symlink/special) or `dir_pre`/recurse/`dir_post` (directory).
1104#[allow(clippy::too_many_arguments)]
1105async fn run_copy_root(
1106    prog_track: &'static progress::Progress,
1107    src_parent: &Arc<Dir>,
1108    dst_parent: Option<Arc<Dir>>,
1109    name: &OsStr,
1110    src_root: &std::path::Path,
1111    dst_root: &std::path::Path,
1112    filter_base: &std::path::Path,
1113    settings: &Settings,
1114    preserve: &preserve::Settings,
1115    is_fresh: bool,
1116    delete_scan_anchor: DeleteScanAnchor,
1117    admission: CopyEntryAdmission,
1118) -> Result<Summary, Error> {
1119    let visitor = Arc::new(CopyVisitor {
1120        prog_track,
1121        dst_root: dst_root.to_path_buf(),
1122        filter_base: filter_base.to_path_buf(),
1123        delete_scan_anchor,
1124        settings: settings.clone(),
1125        preserve: *preserve,
1126        dst_parent,
1127        root_is_fresh: is_fresh,
1128    });
1129    // the root entry's owned context: parent = the hardened source parent, name = the source root
1130    // basename, rel_path = "" (the root), real_path = the source root. `filter_path` is seeded with
1131    // `filter_base` so that inside a delegated subtree (rlink handing an update-only/type-changed
1132    // subtree to `copy_child`, rooted BELOW the original filter root) every descendant's filter
1133    // decision is evaluated at its true logical path (e.g. `cache/keep.txt`, not the bare basename
1134    // relative to the delegated root). For a normal `copy()` (`filter_base` empty) this is "", so
1135    // `filter_path == rel_path` throughout. dry_run is the destination's None signal carried on the
1136    // context for the driver's bookkeeping.
1137    let root_cx = EntryCx {
1138        parent: Arc::clone(src_parent),
1139        name: name.to_owned(),
1140        rel_path: PathBuf::new(),
1141        filter_path: filter_base.to_path_buf(),
1142        real_path: src_root.to_path_buf(),
1143        dry_run: settings.dry_run.is_some(),
1144        prog_track,
1145    };
1146    let root_ctx = visitor.root_dir_context();
1147    match admission {
1148        CopyEntryAdmission::Unclassified(admission) => {
1149            if let Some(filter) = visitor.filter().cloned() {
1150                let admission = crate::walk::ensure_entry_admission(
1151                    visitor.permit_kind(),
1152                    admission.require_admission(),
1153                )
1154                .await;
1155                let entry = crate::walk::classify_admitted_entry(
1156                    &root_cx.parent,
1157                    &root_cx.name,
1158                    admission.into_permit(),
1159                )
1160                .await
1161                .with_context(|| format!("failed reading metadata from {:?}", &root_cx.real_path))
1162                .map_err(|err| Error::new(err, Default::default()))?;
1163                return filter_and_process_admitted_root(
1164                    visitor, root_cx, root_ctx, entry, &filter,
1165                )
1166                .await;
1167            }
1168            process_entry(visitor, root_cx, root_ctx, admission).await
1169        }
1170        CopyEntryAdmission::Admitted(entry) => {
1171            if let Some(filter) = visitor.filter().cloned() {
1172                return filter_and_process_admitted_root(
1173                    visitor, root_cx, root_ctx, entry, &filter,
1174                )
1175                .await;
1176            }
1177            process_admitted_entry(visitor, root_cx, root_ctx, entry).await
1178        }
1179        CopyEntryAdmission::Filtered(entry) => {
1180            process_admitted_entry(visitor, root_cx, root_ctx, entry).await
1181        }
1182    }
1183}
1184
1185impl WalkVisitor for CopyVisitor {
1186    type Summary = Summary;
1187    type DirContext = CopyDirContext;
1188    type DirState = CopyDirState;
1189
1190    fn root_dir_context(&self) -> CopyDirContext {
1191        CopyDirContext {
1192            dst_dir: self.dst_parent.clone(),
1193            is_fresh: self.root_is_fresh,
1194        }
1195    }
1196
1197    fn permit_kind(&self) -> PermitKind {
1198        // copy retains fd-backed handles across each leaf-dispatch path, including nested
1199        // destination removal under overwrite
1200        PermitKind::OpenFile
1201    }
1202
1203    fn fail_early(&self) -> bool {
1204        self.settings.fail_early
1205    }
1206
1207    fn filter(&self) -> Option<&crate::filter::FilterSettings> {
1208        self.settings.filter.as_ref()
1209    }
1210
1211    fn filter_requires_admitted_entry(&self) -> bool {
1212        true
1213    }
1214
1215    fn filter_allows_hint_only_skip(&self, _dir_ctx: &Self::DirContext) -> bool {
1216        // --delete derives its keep-set from dispatched children, and dry-run exposes the exact
1217        // action/counters to the user. In either mode, a hint-only skip could omit an actually
1218        // included source replacement from an observable result.
1219        self.settings.delete.is_none() && self.settings.dry_run.is_none()
1220    }
1221
1222    fn on_skip(
1223        &self,
1224        cx: &EntryCx,
1225        kind: EntryKind,
1226        skip_result: &crate::filter::FilterResult,
1227    ) -> Summary {
1228        // mirror `copy_dir_contents`'s inline filter-skip: the dry-run "skip ..." line plus the
1229        // matching `*_skipped` counter. the driver already did the shared progress increment.
1230        if let Some(mode) = self.settings.dry_run {
1231            crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
1232        }
1233        skipped_summary_for(kind)
1234    }
1235
1236    async fn visit_leaf(
1237        &self,
1238        cx: &EntryCx,
1239        parent_ctx: &CopyDirContext,
1240        leaf: AdmittedLeaf,
1241    ) -> Result<Summary, Error> {
1242        let src_parent = &cx.parent;
1243        let dst_parent = parent_ctx.dst_dir.as_ref();
1244        let name = cx.name.as_os_str();
1245        let src_path = &cx.real_path;
1246        let dst_path = self.dst_path_for(cx);
1247        let dst_name = self.dst_name_for(cx)?;
1248        let is_fresh = parent_ctx.is_fresh;
1249        // --dereference: resolve a symlink to its target by path and copy that instead. this is the
1250        // one path-based branch we intentionally keep (hardening -L is out of scope). transfer the
1251        // provisional admission into the target's root walk; that walk releases it if authoritative
1252        // classification proves the target is a directory.
1253        if self.settings.dereference && leaf.kind() == EntryKind::Symlink {
1254            let open_file_guard = match leaf.into_permit() {
1255                Some(permit) => permit.into_open_file(),
1256                None => throttle::open_file_permit().await,
1257            };
1258            // invariant: only the explicit `--dereference` path may re-resolve an entry by path
1259            // (`canonicalize`). the non-dereference walk is fully fd-based and must never reach
1260            // here. a future refactor that wires `dereference == false` into this branch trips in
1261            // debug/tests.
1262            debug_assert!(
1263                self.settings.dereference,
1264                "canonicalize reached with dereference == false; the non-dereference copy path \
1265                 must never re-resolve a path"
1266            );
1267            let link = crate::walk::run_metadata_probed(
1268                congestion::Side::Source,
1269                congestion::MetadataOp::Stat,
1270                tokio::fs::canonicalize(src_path),
1271            )
1272            .await
1273            .with_context(|| format!("failed reading src symlink {:?}", src_path))
1274            .map_err(|err| Error::new(err, Default::default()))?;
1275            return copy_with_filter_base_admitted(
1276                self.prog_track,
1277                &link,
1278                &dst_path,
1279                &self.settings,
1280                &self.preserve,
1281                is_fresh,
1282                std::path::Path::new(""),
1283                self.delete_scan_anchor.descend(&cx.rel_path),
1284                Some(open_file_guard),
1285            )
1286            .await;
1287        }
1288        match leaf.kind() {
1289            EntryKind::File => {
1290                copy_file_fd(
1291                    self.prog_track,
1292                    src_parent,
1293                    dst_parent,
1294                    name,
1295                    &dst_name,
1296                    &dst_path,
1297                    src_path,
1298                    leaf.handle(),
1299                    &self.settings,
1300                    &self.preserve,
1301                    is_fresh,
1302                )
1303                .await
1304            }
1305            EntryKind::Symlink => {
1306                copy_symlink_fd(
1307                    self.prog_track,
1308                    src_parent,
1309                    dst_parent,
1310                    &dst_name,
1311                    src_path,
1312                    &dst_path,
1313                    leaf.handle(),
1314                    &self.settings,
1315                    &self.preserve,
1316                    is_fresh,
1317                )
1318                .await
1319            }
1320            EntryKind::Special => {
1321                if self.settings.skip_specials {
1322                    tracing::debug!("skipping special file {:?}", src_path);
1323                    if let Some(mode) = self.settings.dry_run {
1324                        match mode {
1325                            DryRunMode::Brief => {}
1326                            DryRunMode::All => println!("skip special {:?}", src_path),
1327                            DryRunMode::Explain => {
1328                                println!("skip special {:?} (unsupported file type)", src_path);
1329                            }
1330                        }
1331                    }
1332                    self.prog_track.specials_skipped.inc();
1333                    return Ok(Summary {
1334                        specials_skipped: 1,
1335                        ..Default::default()
1336                    });
1337                }
1338                Err(Error::new(
1339                    anyhow!(
1340                        "copy: {:?} -> {:?} failed, unsupported src file type",
1341                        src_path,
1342                        dst_path
1343                    ),
1344                    Default::default(),
1345                ))
1346            }
1347            // a directory never reaches `visit_leaf`: the driver dispatches it to dir_pre/dir_post.
1348            EntryKind::Dir => unreachable!("directory entries are handled by dir_pre/dir_post"),
1349        }
1350    }
1351
1352    async fn dir_pre(
1353        &self,
1354        cx: &EntryCx,
1355        parent_ctx: &CopyDirContext,
1356        _handle: &Handle,
1357    ) -> DirPreResult<Self> {
1358        let src_parent = &cx.parent;
1359        let name = cx.name.as_os_str();
1360        let src_path = &cx.real_path;
1361        let dst_path = self.dst_path_for(cx);
1362        let dst_name = self.dst_name_for(cx)?;
1363        let is_root = cx.rel_path.as_os_str().is_empty();
1364        let is_fresh = parent_ctx.is_fresh;
1365        // open the source directory's contents (O_NOFOLLOW) — this is the `dir` the driver walks.
1366        let src_dir = src_parent
1367            .open_dir(name)
1368            .await
1369            .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1370            .map_err(|err| Error::new(err, Default::default()))?;
1371        let src_dir = Arc::new(src_dir);
1372        // the directory metadata applied to the destination comes from the SAME fd whose contents we
1373        // enumerate (read-side fidelity, docs/tocttou.md), not the classify `Handle`: a swap of the
1374        // dir entry between classify and `open_dir` is caught by O_NOFOLLOW (fail-closed), and on a
1375        // same-name dir swap the applied metadata pairs with the contents actually copied.
1376        let src_meta = src_dir
1377            .meta()
1378            .await
1379            .with_context(|| format!("cannot read directory metadata from {:?}", src_path))
1380            .map_err(|err| Error::new(err, Default::default()))?;
1381        if self.settings.dry_run.is_some() {
1382            // dry-run: if --ignore-existing and a non-directory already exists at the destination,
1383            // the whole subtree would be skipped. otherwise report the directory and traverse its
1384            // contents (with no destination directory).
1385            let dst_nondir_exists = if !self.settings.ignore_existing || is_fresh {
1386                false
1387            } else if crate::safedir::strict_operand_resolution() {
1388                // strict: fd-relative probe so a symlinked destination prefix fails closed
1389                matches!(strict_dry_run_dst_kind(&dst_path).await?, Some(kind) if kind != EntryKind::Dir)
1390            } else {
1391                match crate::walk::run_metadata_probed(
1392                    congestion::Side::Destination,
1393                    congestion::MetadataOp::Stat,
1394                    tokio::fs::symlink_metadata(&dst_path),
1395                )
1396                .await
1397                {
1398                    // Decide from the metadata already in hand rather than asking again with
1399                    // `Path::is_dir()`. That call FOLLOWED symlinks, so a destination symlink — even
1400                    // one pointing at a directory — read as "a directory is there, recurse", while the
1401                    // real `--ignore-existing` run classifies the same entry through the parent's fd
1402                    // (`O_NOFOLLOW`) as a non-directory and skips the whole subtree. The dry run
1403                    // predicted a copy the real run does not perform. It was also a second,
1404                    // un-throttled syscall re-resolving a path we had just probed.
1405                    Ok(meta) => !meta.is_dir(),
1406                    // `NotFound` is the ONE error that means the slot is empty — the same rule
1407                    // [`dry_run_dst_exists`] and [`preflight_dst_file`] follow. Every other failure
1408                    // (`EACCES` on a destination parent, `ELOOP`, `ENOTDIR`, `EIO`) says we could not
1409                    // look, and reporting those as "nothing there" made `--dry-run` announce
1410                    // "would copy" and exit 0 for a subtree the real run cannot even reach.
1411                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1412                    Err(error) => {
1413                        return Err(Error::new(
1414                            anyhow::Error::new(error)
1415                                .context(format!("cannot probe destination {dst_path:?}")),
1416                            Default::default(),
1417                        ));
1418                    }
1419                }
1420            };
1421            if dst_nondir_exists {
1422                match self.settings.dry_run {
1423                    Some(DryRunMode::Brief) | None => {}
1424                    Some(DryRunMode::All) => println!("skip dir {:?}", dst_path),
1425                    Some(DryRunMode::Explain) => {
1426                        println!(
1427                            "skip dir {:?} (destination exists, not a directory)",
1428                            dst_path
1429                        );
1430                    }
1431                }
1432                return Ok(DirAction::Skip(Summary {
1433                    directories_unchanged: 1,
1434                    ..Default::default()
1435                }));
1436            }
1437            crate::dry_run::report_action("copy", src_path, Some(&dst_path), "dir");
1438            let base = Summary {
1439                directories_created: 1, // report as would-be-created
1440                ..Default::default()
1441            };
1442            return Ok(DirAction::Descend {
1443                dir: src_dir,
1444                child_ctx: CopyDirContext {
1445                    dst_dir: None, // dry-run: no destination parent (no destination mutation)
1446                    is_fresh,
1447                },
1448                state: CopyDirState {
1449                    dst_dir: None,
1450                    dst_parent: None,
1451                    dst_name,
1452                    // treat as "created" so empty-dir cleanup can suppress the dry-run count.
1453                    we_created: true,
1454                    // dry-run never opens/locks a destination directory, so nothing to restore.
1455                    reused_lock: None,
1456                    src_meta,
1457                    // dry-run applies no metadata at all, so it never reads ACLs either.
1458                    src_acls: None,
1459                    is_root,
1460                    base,
1461                },
1462            });
1463        }
1464        // the directory's ACLs come from the same fd as `src_meta`, for the same read-side fidelity
1465        // reason. Both ACLs: a directory's DEFAULT ACL is what its children inherit, so dropping it
1466        // would silently change the destination tree's inheritance policy. Only when `d:acl` was
1467        // requested — the probe is a per-entry syscall an ordinary copy must not pay for.
1468        let src_acls = if self.preserve.dir.acl {
1469            Some(
1470                src_dir
1471                    .read_acls()
1472                    .await
1473                    .with_context(|| format!("cannot read ACLs from directory {:?}", src_path))
1474                    .map_err(|err| Error::new(err, Default::default()))?,
1475            )
1476        } else {
1477            None
1478        };
1479        // real copy: the destination parent is Some (None only in dry-run, handled above).
1480        let dst_parent = parent_ctx
1481            .dst_dir
1482            .as_ref()
1483            .expect("destination parent must be open for a real copy");
1484        let DirSlot {
1485            dir: dst_dir,
1486            summary: base,
1487            is_fresh: child_is_fresh,
1488            we_created,
1489            reused_lock,
1490        } = match resolve_dst_dir(
1491            self.prog_track,
1492            dst_parent,
1493            &dst_name,
1494            &dst_path,
1495            &self.settings,
1496        )
1497        .await?
1498        {
1499            DirResolution::Skip(summary) => return Ok(DirAction::Skip(summary)),
1500            DirResolution::Proceed(slot) => slot,
1501        };
1502        Ok(DirAction::Descend {
1503            dir: src_dir,
1504            child_ctx: CopyDirContext {
1505                dst_dir: Some(Arc::clone(&dst_dir)),
1506                is_fresh: child_is_fresh,
1507            },
1508            state: CopyDirState {
1509                dst_dir: Some(dst_dir),
1510                dst_parent: Some(Arc::clone(dst_parent)),
1511                dst_name,
1512                we_created,
1513                reused_lock,
1514                src_meta,
1515                src_acls,
1516                is_root,
1517                base,
1518            },
1519        })
1520    }
1521
1522    async fn dir_post(
1523        &self,
1524        cx: &EntryCx,
1525        state: CopyDirState,
1526        processed: &ProcessedChildren,
1527        child_result: Result<Summary, Error>,
1528    ) -> Result<Summary, Error> {
1529        let CopyDirState {
1530            dst_dir,
1531            dst_parent,
1532            dst_name,
1533            we_created,
1534            reused_lock,
1535            src_meta,
1536            src_acls,
1537            is_root,
1538            base,
1539        } = state;
1540        // whether any child failed (non-fail-early: the driver still calls dir_post on error so we
1541        // can apply post-order directory metadata, but we must skip the destructive `--delete`
1542        // prune and surface the child error). the partial child summary is folded either way,
1543        // seeded with `base` (this directory's own create/unchanged contribution) — exactly as
1544        // `copy_dir_contents` seeded `copy_summary = base` before joining the children.
1545        let (child_summary, mut child_error) = match child_result {
1546            Ok(summary) => (summary, None),
1547            Err(err) => (err.summary, Some(err.source)),
1548        };
1549        let mut copy_summary = base + child_summary;
1550        let dst_path = self.dst_path_for(cx);
1551        // rsync-style --delete prune (folds the RmSummary; may record a non-fail-early child error
1552        // or fail-early). A no-op unless --delete was requested AND every child succeeded.
1553        self.prune_finished_dir(
1554            &mut copy_summary,
1555            &mut child_error,
1556            processed,
1557            &dst_dir,
1558            &dst_path,
1559            &cx.rel_path,
1560        )
1561        .await?;
1562        // empty-dir cleanup + post-order directory metadata; also surfaces any collected child error.
1563        self.finalize_dir(
1564            FinalizeDir {
1565                copy_summary,
1566                child_error,
1567                dst_dir,
1568                dst_parent,
1569                dst_name,
1570                src_meta,
1571                src_acls,
1572                we_created,
1573                reused_lock,
1574                is_root,
1575            },
1576            cx,
1577        )
1578        .await
1579    }
1580}
1581
1582/// Probe a destination operand's existence/kind for a dry-run `--ignore-existing`
1583/// decision under strict operand resolution: fd-relative via the destination's
1584/// parent (`openat2(RESOLVE_NO_SYMLINKS)`), so a symlinked destination prefix
1585/// fails closed instead of being followed by the path-based fallback. Only called
1586/// while strict operand resolution is armed (the default path keeps its
1587/// path-based probe unchanged).
1588async fn strict_dry_run_dst_kind(dst_path: &std::path::Path) -> Result<Option<EntryKind>, Error> {
1589    crate::safedir::strict_probe_dst_kind(dst_path, congestion::Side::Destination)
1590        .await
1591        .map_err(|err| {
1592            Error::new(
1593                anyhow::Error::new(err).context(format!("cannot probe destination {dst_path:?}")),
1594                Default::default(),
1595            )
1596        })
1597}
1598
1599/// Does anything occupy the destination path, as seen from a **dry-run**?
1600///
1601/// Dry-run holds no destination fd, so this is the one place a destination is probed by path rather
1602/// than through a pinned parent. A real copy never calls it: there the create itself reports the
1603/// conflict, which is both cheaper and not subject to a swap between probe and act.
1604///
1605/// Call ONLY when the answer is used (`--ignore-existing`). Under strict operand resolution the
1606/// probe can *fail*, and an unused one must never fail a valid `--dry-run --delete` onto a final
1607/// destination symlink.
1608async fn dry_run_dst_exists(dst_path: &std::path::Path) -> Result<bool, Error> {
1609    if crate::safedir::strict_operand_resolution() {
1610        return Ok(strict_dry_run_dst_kind(dst_path).await?.is_some());
1611    }
1612    match tokio::fs::symlink_metadata(dst_path).await {
1613        Ok(_) => Ok(true),
1614        // `NotFound` is the ONE error that means the slot is empty — the same rule
1615        // [`preflight_dst_file`] and the remote destination follow. Every other failure (EACCES on a
1616        // parent directory,
1617        // ELOOP, ENOTDIR, EIO) says we could not LOOK, not that there is nothing there. Reporting
1618        // those as vacant made `--dry-run --ignore-existing` print "would copy" and exit 0 through a
1619        // destination parent it cannot even traverse — predicting a copy the real run then fails, and
1620        // swallowing the one error that explains why.
1621        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1622        Err(error) => Err(Error::new(
1623            anyhow::Error::new(error).context(format!("cannot probe destination {dst_path:?}")),
1624            Default::default(),
1625        )),
1626    }
1627}
1628
1629/// Copy a regular file fd-relative: create (or overwrite) the destination via `dst_parent`,
1630/// copy the bytes with `copy_file_range_all`, then apply metadata through the destination's own
1631/// fd — the open is held from creation through metadata, closing the path-based re-open TOCTOU
1632/// window. `--overwrite`/`--ignore-existing`/`is_fresh`/dry-run semantics mirror the path-based
1633/// [`copy_file`].
1634#[allow(clippy::too_many_arguments)]
1635async fn copy_file_fd(
1636    prog_track: &'static progress::Progress,
1637    src_parent: &Arc<Dir>,
1638    dst_parent: Option<&Arc<Dir>>,
1639    name: &OsStr,
1640    dst_name: &OsStr,
1641    dst_path: &std::path::Path,
1642    src_path: &std::path::Path,
1643    src_handle: &Handle,
1644    settings: &Settings,
1645    preserve: &preserve::Settings,
1646    is_fresh: bool,
1647) -> Result<Summary, Error> {
1648    // bring `FileMeta::size()` into scope locally; importing the trait at module level would
1649    // collide with `std::os::unix::fs::MetadataExt` on `std::fs::Metadata` elsewhere in this file.
1650    use crate::preserve::Metadata as _;
1651    debug_assert_eq!(src_handle.kind(), EntryKind::File);
1652    // --ignore-existing in dry-run: skip if the destination already exists (any type, including a
1653    // dangling symlink). a real copy does NOT probe here — [`preflight_dst_file`] below is the single
1654    // owner of every conflict decision, `--ignore-existing` included, and it reaches that decision
1655    // on both of its routes, so an fd-based probe here would be a redundant `child()` whose only
1656    // distinct effect was to mistake a lookup *failure* (EACCES, EMFILE, ESTALE) for a vacant slot.
1657    if dst_parent.is_none()
1658        && !is_fresh
1659        && settings.ignore_existing
1660        && dry_run_dst_exists(dst_path).await?
1661    {
1662        if let Some(mode) = settings.dry_run {
1663            match mode {
1664                DryRunMode::Brief => {}
1665                DryRunMode::All => println!("skip file {:?}", dst_path),
1666                DryRunMode::Explain => {
1667                    println!("skip file {:?} (destination exists)", dst_path)
1668                }
1669            }
1670        }
1671        tracing::debug!("destination exists, skipping (--ignore-existing)");
1672        prog_track.files_unchanged.inc();
1673        return Ok(Summary {
1674            files_unchanged: 1,
1675            ..Default::default()
1676        });
1677    }
1678    if settings.dry_run.is_none() {
1679        // dst_parent is guaranteed Some here: it is None only in dry-run.
1680        let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1681        let preflight = if is_fresh {
1682            DestinationPreflight::Vacant
1683        } else {
1684            preflight_dst_file(prog_track, dst_parent, dst_name, dst_path, settings).await?
1685        };
1686        // the admitted handle is the approved point-in-time metadata snapshot. Only a vacant slot
1687        // or a candidate that differs from that snapshot needs the current payload opened.
1688        let (current, plan) = match preflight {
1689            DestinationPreflight::Skip(summary) => return Ok(summary),
1690            DestinationPreflight::Vacant => (
1691                CurrentRegular::open(src_parent, name, src_path).await?,
1692                FilePlan::Vacant,
1693            ),
1694            DestinationPreflight::OverwriteCandidate(dst_handle) => {
1695                match plan_overwrite_candidate(prog_track, &dst_handle, src_handle.meta(), settings)
1696                {
1697                    OverwriteCandidateDecision::Skip(summary) => return Ok(summary),
1698                    OverwriteCandidateDecision::Replace => {}
1699                }
1700                // if the actionable payload no longer has the admitted identity, re-plan from the
1701                // payload fd's paired metadata before touching the destination.
1702                let current = CurrentRegular::open(src_parent, name, src_path).await?;
1703                if !current.matches(src_handle)
1704                    && let OverwriteCandidateDecision::Skip(summary) =
1705                        plan_overwrite_candidate(prog_track, &dst_handle, &current.meta, settings)
1706                {
1707                    return Ok(summary);
1708                }
1709                (
1710                    current,
1711                    FilePlan::Replace(dst_handle.into_removal_snapshot()),
1712                )
1713            }
1714        };
1715        return copy_current_regular(
1716            prog_track, src_parent, dst_parent, dst_name, dst_path, src_path, current, plan,
1717            settings, preserve,
1718        )
1719        .await;
1720    }
1721    // dry-run: report and return what would happen without touching the destination.
1722    crate::dry_run::report_action("copy", src_path, Some(dst_path), "file");
1723    Ok(Summary {
1724        files_copied: 1,
1725        bytes_copied: src_handle.meta().size(),
1726        ..Default::default()
1727    })
1728}
1729
1730/// Copies one already-open current regular source after destination-only preflight has completed.
1731#[allow(clippy::too_many_arguments)]
1732async fn copy_current_regular(
1733    prog_track: &'static progress::Progress,
1734    src_parent: &Arc<Dir>,
1735    dst_parent: &Arc<Dir>,
1736    dst_name: &OsStr,
1737    dst_path: &std::path::Path,
1738    src_path: &std::path::Path,
1739    current: CurrentRegular,
1740    plan: FilePlan,
1741    settings: &Settings,
1742    preserve: &preserve::Settings,
1743) -> Result<Summary, Error> {
1744    use crate::preserve::Metadata as _;
1745    let CurrentRegular {
1746        file: src_file,
1747        meta: src_meta,
1748    } = current;
1749    // `copy_summary` accumulates what has already happened to the destination, so every error
1750    // payload below reports the removals an overwrite performed.
1751    let mut copy_summary = Summary::default();
1752    // preflight, metadata planning, payload open, reserve, then mutate. the current source was
1753    // opened while the destination was still intact, and its metadata was re-planned if it differed
1754    // from the metadata-only snapshot. an overwrite that unlinked first would leave the user with
1755    // neither file whenever the source turned out to be unreadable or had changed kind: a copy that
1756    // could never have produced bytes must not destroy what it was going to replace.
1757    //
1758    // this is not cancellation safety and does not try to be. `create_file` waits on the ops-throttle
1759    // internally, so the removal below and the create after it are not one committed step:
1760    // cancellation (Ctrl-C, a `--fail-early` sibling abort) can land between them and leave the
1761    // destination absent, and `create_file` can itself fail with EMFILE/ENOSPC/EIO. rcp's copy
1762    // semantics are point-in-time and non-atomic (docs/remote_protocol.md) — an interrupted
1763    // `--overwrite` corrupting the files it was overwriting is accepted, and the data copy in the very
1764    // next step would leave a truncated destination anyway. Closing that gap means staging plus
1765    // rename, which costs performance rcp deliberately does not spend.
1766    //
1767    get_file_iops_tokens(settings.chunk_size, src_meta.size()).await;
1768    // read the source ACL from the SAME fd whose bytes are about to be copied (read-side fidelity,
1769    // docs/tocttou.md), and before the destination is touched — an unreadable source must not cost
1770    // the user the file being overwritten. Only when `acl` was requested: the probe is a syscall per
1771    // entry that `stat` cannot fold in, so an ordinary copy must not pay for it.
1772    let src_acls = if preserve.file.acl {
1773        Some(
1774            safedir::read_acls_fd(src_file.as_fd(), src_parent.side(), false)
1775                .await
1776                .with_context(|| format!("failed reading ACLs from {:?}", src_path))
1777                .map_err(|err| Error::new(err, copy_summary))?,
1778        )
1779    } else {
1780        None
1781    };
1782    // the source fd is held and the throttle budget is reserved, so the destination can now be
1783    // replaced: the failure that would most often abandon this copy before any byte exists is behind
1784    // us. It is not the last one — `create_file` below has its own (EMFILE, ENOSPC, EIO) and waits on
1785    // the ops-throttle, so a cancellation can still land between the two; see the note above on why
1786    // rcp accepts that rather than staging and renaming. `execute_dst_plan` removes by name through
1787    // the pinned parent; a compatible replacement since planning may be removed, but the operation
1788    // cannot escape that directory.
1789    execute_dst_plan(
1790        prog_track,
1791        dst_parent,
1792        dst_name,
1793        dst_path,
1794        plan,
1795        settings,
1796        &mut copy_summary,
1797    )
1798    .await?;
1799    let dst_file = match dst_parent.create_file(dst_name).await {
1800        Ok(dst_file) => dst_file,
1801        // the destination slot is occupied. `create_file`'s `O_EXCL` is the only way either route
1802        // finds out that a writer got here between our last look and now: on the known-fresh route
1803        // there was no earlier look at all (freshness is an optimization hint, not an enforceable
1804        // invariant — see `resolve_dst_dir` — so a concurrent writer can populate a directory we
1805        // just created), and on the other route the slot was planned but has been refilled since.
1806        // both cases are the same conflict, so resolve it here and honor --overwrite /
1807        // --ignore-existing rather than failing the copy on EEXIST. Planning and executing are
1808        // back-to-back here, unlike the route above: the source and the tokens are already held, so
1809        // there is nothing left that could abandon the copy after the removal.
1810        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1811            let preflight =
1812                preflight_dst_file(prog_track, dst_parent, dst_name, dst_path, settings)
1813                    .await
1814                    // rebuild from `err.source`, never from a stringified error: the chain must survive.
1815                    .map_err(|err| Error::new(err.source, err.summary + copy_summary))?;
1816            let plan = match preflight {
1817                DestinationPreflight::Vacant => FilePlan::Vacant,
1818                DestinationPreflight::Skip(summary) => {
1819                    return Ok(skip_summary(summary, &copy_summary));
1820                }
1821                DestinationPreflight::OverwriteCandidate(dst_handle) => {
1822                    match plan_overwrite_candidate(prog_track, &dst_handle, &src_meta, settings) {
1823                        OverwriteCandidateDecision::Skip(summary) => {
1824                            return Ok(skip_summary(summary, &copy_summary));
1825                        }
1826                        OverwriteCandidateDecision::Replace => {
1827                            FilePlan::Replace(dst_handle.into_removal_snapshot())
1828                        }
1829                    }
1830                }
1831            };
1832            execute_dst_plan(
1833                prog_track,
1834                dst_parent,
1835                dst_name,
1836                dst_path,
1837                plan,
1838                settings,
1839                &mut copy_summary,
1840            )
1841            .await?;
1842            // retry exactly once. the slot was cleared just above, so a second EEXIST means yet
1843            // another writer refilled it — report that rather than looping, which against a live
1844            // competing writer would never terminate.
1845            dst_parent
1846                .create_file(dst_name)
1847                .await
1848                .with_context(|| format!("failed creating {:?}", dst_path))
1849                .map_err(|err| Error::new(err, copy_summary))?
1850        }
1851        Err(error) => {
1852            return Err(Error::new(
1853                anyhow::Error::new(error).context(format!("failed creating {:?}", dst_path)),
1854                copy_summary,
1855            ));
1856        }
1857    };
1858    tracing::debug!("copying data");
1859    let len = src_meta.size();
1860    // the data copy is the data path, not a metadata syscall — it is deliberately NOT wrapped in a
1861    // congestion probe (matching the old `tokio::fs::copy`), so the large/variable copy latency
1862    // never pollutes the per-metadata-op controller baseline. backpressure comes from the
1863    // open-file admission is retained by the canonical non-cancellable blocking boundary. the
1864    // destination file is returned so it remains live through metadata application, closing the
1865    // path-based re-open and cancellation-lifetime gaps.
1866    #[cfg(test)]
1867    let data_gate_path = dst_path.to_path_buf();
1868    let data_output = safedir::run_fd_admitted_blocking(move || {
1869        #[cfg(test)]
1870        let gate_visit = {
1871            use std::os::fd::AsRawFd as _;
1872            crate::testutils::wait_on_blocking_path_gate(
1873                &data_gate_path,
1874                dst_file.as_fd().as_raw_fd(),
1875            )
1876        };
1877        let copy_result = copy_file_range_all(&src_file, &dst_file, len);
1878        #[cfg(test)]
1879        {
1880            Ok((copy_result, dst_file, gate_visit))
1881        }
1882        #[cfg(not(test))]
1883        {
1884            Ok((copy_result, dst_file))
1885        }
1886    })
1887    .await
1888    .with_context(|| format!("failed copying data to {:?}", dst_path))
1889    .map_err(|err| Error::new(err, copy_summary))?;
1890    #[cfg(test)]
1891    let (copy_result, dst_file, _gate_visit) = data_output;
1892    #[cfg(not(test))]
1893    let (copy_result, dst_file) = data_output;
1894    let copied = copy_result
1895        .with_context(|| format!("failed copying data to {:?}", dst_path))
1896        .map_err(|err| Error::new(err, copy_summary))?;
1897    // account for the bytes ACTUALLY copied, not `len` (the size snapshotted at open): if the source
1898    // is concurrently truncated, `copy_file_range_all` returns the shorter real count, so using `len`
1899    // would over-report. `len` still drives the copy loop and the iops-token reservation above.
1900    prog_track.files_copied.inc();
1901    prog_track.bytes_copied.add(copied);
1902    tracing::debug!("setting permissions");
1903    safedir::set_file_metadata_fd(
1904        preserve,
1905        &src_meta,
1906        src_acls.as_ref(),
1907        dst_file.as_fd(),
1908        congestion::Side::Destination,
1909    )
1910    .await
1911    .with_context(|| format!("failed setting metadata on {:?}", dst_path))
1912    .map_err(|err| Error::new(err, copy_summary))?;
1913    // count the file as copied only after all metadata has been applied (actual bytes, see above).
1914    copy_summary.bytes_copied += copied;
1915    copy_summary.files_copied += 1;
1916    Ok(copy_summary)
1917}
1918
1919/// Create a symlink fd-relative and apply its metadata through the created link's own handle.
1920/// `--overwrite`/`--ignore-existing`/dry-run semantics mirror the path-based symlink handling.
1921#[allow(clippy::too_many_arguments)]
1922async fn copy_symlink_fd(
1923    prog_track: &'static progress::Progress,
1924    src_parent: &Arc<Dir>,
1925    dst_parent: Option<&Arc<Dir>>,
1926    dst_name: &OsStr,
1927    src_path: &std::path::Path,
1928    dst_path: &std::path::Path,
1929    src_handle: &Handle,
1930    settings: &Settings,
1931    preserve: &preserve::Settings,
1932    is_fresh: bool,
1933) -> Result<Summary, Error> {
1934    // --ignore-existing in DRY-RUN: skip if the destination already exists (any type). A real copy
1935    // does NOT probe here, for the same reason [`copy_file_fd`] does not: `symlink_at` below fails
1936    // `EEXIST` on an occupied slot and its recovery branch already owns `--ignore-existing`, so an
1937    // fd-based probe here would be a redundant `child()` whose only distinct effect was to mistake
1938    // a lookup *failure* (EACCES, EMFILE, ESTALE) for a vacant slot.
1939    if dst_parent.is_none()
1940        && !is_fresh
1941        && settings.ignore_existing
1942        && dry_run_dst_exists(dst_path).await?
1943    {
1944        if let Some(mode) = settings.dry_run {
1945            match mode {
1946                DryRunMode::Brief => {}
1947                DryRunMode::All => println!("skip symlink {:?}", dst_path),
1948                DryRunMode::Explain => {
1949                    println!("skip symlink {:?} (destination exists)", dst_path)
1950                }
1951            }
1952        }
1953        tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1954        prog_track.symlinks_unchanged.inc();
1955        return Ok(Summary {
1956            symlinks_unchanged: 1,
1957            ..Default::default()
1958        });
1959    }
1960    if settings.dry_run.is_some() {
1961        crate::dry_run::report_action("copy", src_path, Some(dst_path), "symlink");
1962        return Ok(Summary {
1963            symlinks_created: 1,
1964            ..Default::default()
1965        });
1966    }
1967    let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1968    // read the target AND metadata from the SAME pinned handle (one paired call), so a concurrent
1969    // same-name symlink swap can't pair one link's target with another link's owner/timestamps
1970    // (target/metadata fidelity, matching the regular-file path which takes both from one fd).
1971    let (target, src_meta) = src_handle
1972        .read_symlink(src_parent.side())
1973        .await
1974        .with_context(|| format!("failed reading symlink {:?}", src_path))
1975        .map_err(|err| Error::new(err, Default::default()))?;
1976    // fast path: the destination slot is empty, create the link directly.
1977    match dst_parent.symlink_at(dst_name, &target).await {
1978        Ok(link_handle) => {
1979            safedir::set_symlink_metadata_fd(
1980                preserve,
1981                &src_meta,
1982                &link_handle,
1983                congestion::Side::Destination,
1984            )
1985            .await
1986            .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1987            .map_err(|err| Error::new(err, Default::default()))?;
1988            prog_track.symlinks_created.inc();
1989            Ok(Summary {
1990                symlinks_created: 1,
1991                ..Default::default()
1992            })
1993        }
1994        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1995            if settings.ignore_existing {
1996                tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1997                prog_track.symlinks_unchanged.inc();
1998                return Ok(Summary {
1999                    symlinks_unchanged: 1,
2000                    ..Default::default()
2001                });
2002            }
2003            if !settings.overwrite {
2004                return Err(Error::new(
2005                    anyhow!("failed creating symlink {:?}", dst_path),
2006                    Default::default(),
2007                ));
2008            }
2009            let dst_handle = dst_parent
2010                .child(dst_name)
2011                .await
2012                .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
2013                .map_err(|err| Error::new(err, Default::default()))?;
2014            if dst_handle.kind() == EntryKind::Symlink {
2015                let dst_link = dst_parent
2016                    .read_link_at(dst_name) // rcp-toctou-allow: destination symlink (overwrite compare), not a source payload
2017                    .await
2018                    .with_context(|| format!("failed reading dst symlink: {:?}", dst_path))
2019                    .map_err(|err| Error::new(err, Default::default()))?;
2020                if dst_link == target {
2021                    tracing::debug!("'dst' is a symlink and points to the same location as 'src'");
2022                    if preserve.symlink.any()
2023                        && !filecmp::metadata_equal(
2024                            &settings.overwrite_compare,
2025                            &src_meta,
2026                            dst_handle.meta(),
2027                        )
2028                    {
2029                        tracing::debug!("'dst' metadata is different, updating");
2030                        safedir::set_symlink_metadata_fd(
2031                            preserve,
2032                            &src_meta,
2033                            &dst_handle,
2034                            congestion::Side::Destination,
2035                        )
2036                        .await
2037                        .with_context(|| {
2038                            format!("failed setting symlink metadata on {:?}", dst_path)
2039                        })
2040                        .map_err(|err| Error::new(err, Default::default()))?;
2041                        prog_track.symlinks_removed.inc();
2042                        prog_track.symlinks_created.inc();
2043                        return Ok(Summary {
2044                            rm_summary: RmSummary {
2045                                symlinks_removed: 1,
2046                                ..Default::default()
2047                            },
2048                            symlinks_created: 1,
2049                            ..Default::default()
2050                        });
2051                    }
2052                    tracing::debug!("symlink already exists, skipping");
2053                    prog_track.symlinks_unchanged.inc();
2054                    return Ok(Summary {
2055                        symlinks_unchanged: 1,
2056                        ..Default::default()
2057                    });
2058                }
2059                tracing::debug!("'dst' is a symlink but points to a different path, updating");
2060            } else {
2061                tracing::info!("'dst' is not a symlink, updating");
2062            }
2063            // remove the conflicting destination entry, then create the link fresh.
2064            let rm_summary = remove_existing(
2065                prog_track,
2066                dst_parent,
2067                dst_name,
2068                dst_path,
2069                dst_handle.into_removal_snapshot(),
2070                settings,
2071            )
2072            .await?;
2073            let copy_summary = Summary {
2074                rm_summary,
2075                ..Default::default()
2076            };
2077            let link_handle = dst_parent
2078                .symlink_at(dst_name, &target)
2079                .await
2080                .with_context(|| format!("failed creating symlink {:?}", dst_path))
2081                .map_err(|err| Error::new(err, copy_summary))?;
2082            safedir::set_symlink_metadata_fd(
2083                preserve,
2084                &src_meta,
2085                &link_handle,
2086                congestion::Side::Destination,
2087            )
2088            .await
2089            .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
2090            .map_err(|err| Error::new(err, copy_summary))?;
2091            prog_track.symlinks_created.inc();
2092            Ok(Summary {
2093                rm_summary,
2094                symlinks_created: 1,
2095                ..Default::default()
2096            })
2097        }
2098        Err(error) => Err(Error::new(
2099            anyhow::Error::new(error).context(format!("failed creating symlink {:?}", dst_path)),
2100            Default::default(),
2101        )),
2102    }
2103}
2104
2105/// The current by-name regular source whose metadata and bytes come from one open descriptor.
2106struct CurrentRegular {
2107    file: std::fs::File,
2108    meta: FileMeta,
2109}
2110
2111impl CurrentRegular {
2112    /// Opens the current final component and rejects a replacement of any non-regular type.
2113    async fn open(
2114        src_parent: &Dir,
2115        name: &OsStr,
2116        src_path: &std::path::Path,
2117    ) -> Result<Self, Error> {
2118        let (file, meta) = src_parent
2119            .open_file_read(name)
2120            .await
2121            .with_context(|| format!("failed opening src file {:?} for reading", src_path))
2122            .map_err(|err| Error::new(err, Default::default()))?;
2123        Ok(Self { file, meta })
2124    }
2125
2126    fn matches(&self, handle: &Handle) -> bool {
2127        self.meta.dev() == handle.dev() && self.meta.ino() == handle.ino()
2128    }
2129}
2130
2131/// Source-independent decision made before reopening the current regular source.
2132#[derive(Debug)]
2133enum DestinationPreflight {
2134    /// Nothing occupies the destination slot.
2135    Vacant,
2136    /// Leave the destination alone without acquiring the current source.
2137    Skip(Summary),
2138    /// An existing entry may be compared with the current source and then replaced.
2139    OverwriteCandidate(Handle),
2140}
2141
2142/// What to do about whatever occupies a destination file's slot — decided without touching it.
2143#[derive(Debug)]
2144enum FilePlan {
2145    /// Nothing is in the way. Create straight away.
2146    Vacant,
2147    /// An entry is in the way and `--overwrite` says to replace it. Once source-dependent planning
2148    /// completes, the lightweight snapshot keeps the planned kind and accounting size without
2149    /// holding the classification fd across the throttle wait. Removal is by name through the
2150    /// pinned parent, so retaining that fd could not bind the later mutation to the classified
2151    /// inode.
2152    Replace(RemovalSnapshot),
2153}
2154
2155/// Metadata-dependent decision for an occupied overwrite candidate.
2156#[derive(Clone, Copy, Debug)]
2157enum OverwriteCandidateDecision {
2158    Skip(Summary),
2159    Replace,
2160}
2161
2162/// Merge a skipped plan's summary with removals the copy already performed, producing what the
2163/// caller returns.
2164///
2165/// Needed because a copy can plan the slot twice — once up front, then again after `create_file`
2166/// reports it refilled — and a removal performed before the first create must survive into the
2167/// totals rather than being dropped when the second plan decides to skip. Its progress counter has
2168/// already been incremented either way.
2169fn skip_summary(skip: Summary, copy_summary: &Summary) -> Summary {
2170    Summary {
2171        rm_summary: copy_summary.rm_summary + skip.rm_summary,
2172        ..skip
2173    }
2174}
2175
2176/// Carry out the one mutating step a [`FilePlan`] can call for, folding its accounting into the
2177/// running copy summary so every later error payload reports the removal that already happened.
2178///
2179/// Split from [`preflight_dst_file`] and [`plan_overwrite_candidate`] deliberately: the caller
2180/// settles destination-only outcomes, opens the current source, and reserves throttle capacity
2181/// before actually unlinking. Separating these steps ensures a source-open failure leaves the
2182/// existing destination intact. The split buys nothing against *cancellation* — see
2183/// [`copy_file_fd`] for why rcp does not chase that.
2184async fn execute_dst_plan(
2185    prog_track: &'static progress::Progress,
2186    dst_parent: &Arc<Dir>,
2187    dst_name: &OsStr,
2188    dst_path: &std::path::Path,
2189    plan: FilePlan,
2190    settings: &Settings,
2191    copy_summary: &mut Summary,
2192) -> Result<(), Error> {
2193    let dst_snapshot = match plan {
2194        FilePlan::Vacant => return Ok(()),
2195        FilePlan::Replace(dst_snapshot) => dst_snapshot,
2196    };
2197    match remove_existing(
2198        prog_track,
2199        dst_parent,
2200        dst_name,
2201        dst_path,
2202        dst_snapshot,
2203        settings,
2204    )
2205    .await
2206    {
2207        Ok(rm_summary) => {
2208            copy_summary.rm_summary = copy_summary.rm_summary + rm_summary;
2209            Ok(())
2210        }
2211        // rebuild from `err.source`, never from a stringified error: the chain must survive.
2212        Err(err) => Err(Error::new(err.source, err.summary + *copy_summary)),
2213    }
2214}
2215
2216/// Resolve every destination-only outcome before reopening the current source.
2217///
2218/// An occupied slot either skips under `--ignore-existing`, fails without `--overwrite`, or becomes
2219/// an [`DestinationPreflight::OverwriteCandidate`] whose metadata-dependent comparison is deferred
2220/// to [`plan_overwrite_candidate`]. A vacant slot needs no source metadata either. This function
2221/// never mutates.
2222///
2223/// [`copy_file_fd`] calls this from both of its routes: up front when the destination is not
2224/// known-fresh, and again when the destination was believed fresh but `create_file` reported the
2225/// slot occupied anyway.
2226async fn preflight_dst_file(
2227    prog_track: &'static progress::Progress,
2228    dst_parent: &Arc<Dir>,
2229    dst_name: &OsStr,
2230    dst_path: &std::path::Path,
2231    settings: &Settings,
2232) -> Result<DestinationPreflight, Error> {
2233    let dst_handle = match dst_parent.child(dst_name).await {
2234        Ok(dst_handle) => dst_handle,
2235        // `NotFound` is the ONE error that means "nothing occupies the slot". Every other failure
2236        // (EACCES on the parent, EMFILE, ESTALE, EIO) says we could not look — not that there is
2237        // nothing there — and must keep its context rather than be recast as a vacant slot. The
2238        // difference is user-visible in both directions: `--ignore-existing` would fail on a
2239        // create instead of skipping, and the `create_file` recovery route, which reaches this
2240        // function only because the slot is KNOWN occupied, would report a second bare `EEXIST`
2241        // naming neither the real errno nor the fact that the lookup itself failed.
2242        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2243            return Ok(DestinationPreflight::Vacant);
2244        }
2245        Err(error) => {
2246            return Err(Error::new(
2247                anyhow::Error::new(error)
2248                    .context(format!("failed looking up destination {:?}", dst_path)),
2249                Default::default(),
2250            ));
2251        }
2252    };
2253    if settings.ignore_existing {
2254        tracing::debug!("destination exists, skipping (--ignore-existing)");
2255        prog_track.files_unchanged.inc();
2256        return Ok(DestinationPreflight::Skip(Summary {
2257            files_unchanged: 1,
2258            ..Default::default()
2259        }));
2260    }
2261    if !settings.overwrite {
2262        return Err(Error::new(
2263            anyhow!(
2264                "destination {:?} already exists, did you intend to specify --overwrite?",
2265                dst_path
2266            ),
2267            Default::default(),
2268        ));
2269    }
2270    Ok(DestinationPreflight::OverwriteCandidate(dst_handle))
2271}
2272
2273/// Inspect an overwrite candidate using a regular source's metadata without consuming the
2274/// destination handle. The caller can therefore repeat the decision if payload identity drifts.
2275fn plan_overwrite_candidate(
2276    prog_track: &'static progress::Progress,
2277    dst_handle: &Handle,
2278    src_meta: &FileMeta,
2279    settings: &Settings,
2280) -> OverwriteCandidateDecision {
2281    tracing::debug!("file exists, check if it's identical");
2282    if dst_handle.kind() == EntryKind::File {
2283        if filecmp::metadata_equal(&settings.overwrite_compare, src_meta, dst_handle.meta()) {
2284            tracing::debug!("file is identical, skipping");
2285            prog_track.files_unchanged.inc();
2286            return OverwriteCandidateDecision::Skip(Summary {
2287                files_unchanged: 1,
2288                ..Default::default()
2289            });
2290        }
2291        if let Some(OverwriteFilter::Newer) = settings.overwrite_filter
2292            && filecmp::dest_is_newer(src_meta, dst_handle.meta())
2293        {
2294            tracing::debug!("dest is newer than source, skipping");
2295            prog_track.files_unchanged.inc();
2296            return OverwriteCandidateDecision::Skip(Summary {
2297                files_unchanged: 1,
2298                ..Default::default()
2299            });
2300        }
2301    }
2302    tracing::info!("destination differs, will replace existing entry");
2303    OverwriteCandidateDecision::Replace
2304}
2305
2306/// An opened destination directory plus the bookkeeping the caller needs.
2307pub(crate) struct DirSlot {
2308    pub(crate) dir: Arc<Dir>,
2309    pub(crate) summary: Summary,
2310    pub(crate) is_fresh: bool,
2311    pub(crate) we_created: bool,
2312    /// What the lockdown must undo at finalize (the original owner and the original ACLs), `Some`
2313    /// iff this directory was LOCKED (strict operand resolution + a reused existing directory).
2314    /// `None` for a freshly created directory, which must never be restore-chowned and whose
2315    /// inherited ACLs were stripped outright by `make_dir` rather than snapshotted. Its presence is
2316    /// the "locked" marker.
2317    pub(crate) reused_lock: Option<safedir::ReusedDirLock>,
2318}
2319
2320/// Outcome of resolving a destination directory.
2321pub(crate) enum DirResolution {
2322    /// Proceed into the directory.
2323    Proceed(DirSlot),
2324    /// Skip the whole subtree (e.g. `--ignore-existing` and a non-directory already exists).
2325    Skip(Summary),
2326}
2327
2328/// Create the destination directory `name` under `dst_parent`, or reuse / replace an existing
2329/// entry per `--overwrite`/`--ignore-existing`. Returns an open [`Dir`] handle to it.
2330///
2331/// New directories are created at [`safedir::DST_DIR_CREATE_MODE`] (writable so children can be
2332/// populated) — the real source mode is applied later by [`CopyVisitor::dir_post`] after all
2333/// children are copied, matching the path-based behavior of creating a writable directory and
2334/// restricting it last.
2335pub(crate) async fn resolve_dst_dir(
2336    prog_track: &'static progress::Progress,
2337    dst_parent: &Arc<Dir>,
2338    name: &OsStr,
2339    dst_path: &std::path::Path,
2340    settings: &Settings,
2341) -> Result<DirResolution, Error> {
2342    match dst_parent
2343        .make_dir(name, safedir::DST_DIR_CREATE_MODE)
2344        .await
2345    {
2346        Ok(dir) => {
2347            // freshly created: children may assume the destination is empty (no conflict checks).
2348            prog_track.directories_created.inc();
2349            Ok(DirResolution::Proceed(DirSlot {
2350                dir: Arc::new(dir),
2351                summary: Summary {
2352                    directories_created: 1,
2353                    ..Default::default()
2354                },
2355                is_fresh: true,
2356                we_created: true,
2357                // fresh dirs are already created copier-owned at 0o700, and `make_dir` stripped any
2358                // ACL they inherited; nothing to restore.
2359                reused_lock: None,
2360            }))
2361        }
2362        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2363            // the destination slot is occupied — including when the caller believed the parent was
2364            // fresh: freshness is an optimization hint, not an enforceable invariant. a concurrent
2365            // writer (another source operand of this same invocation whose destination aliases ours,
2366            // a second process, or a destination filesystem that folds two source names onto one)
2367            // can populate a directory we just created. handle it exactly like any other pre-existing
2368            // entry.
2369            let dst_handle = dst_parent
2370                .child(name)
2371                .await
2372                .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
2373                .map_err(|err| Error::new(err, Default::default()))?;
2374            if dst_handle.kind() == EntryKind::Dir {
2375                // an existing directory: reuse it.
2376                //
2377                // In the default (non-strict) path we leave it exactly as-is and copy into it: its
2378                // permissions may stop us writing, but the alternative (opening it up while we copy)
2379                // isn't safe. Under strict operand resolution we instead LOCK IT DOWN — take it over
2380                // and restrict it to 0o700 for the copy's duration, restoring the original owner and
2381                // source mode at finalize (see `finalize_dir`) so the final state is unchanged.
2382                tracing::debug!("'dst' is a directory, reusing it");
2383                let dir = dst_parent
2384                    .open_dir(name)
2385                    .await
2386                    .with_context(|| format!("cannot open existing directory {:?}", dst_path))
2387                    .map_err(|err| Error::new(err, Default::default()))?;
2388                // strict-only lockdown: take over the reused directory and restrict it to 0o700 for
2389                // the copy's duration (no-op / None in the default path). A recheck or EPERM failure
2390                // surfaces as this directory's error, honoring --fail-early; `dir_pre` returning Err
2391                // skips descent, so no child is ever written into an unsecured directory.
2392                let reused_lock = safedir::lockdown_reused_dir(&dir, &dst_handle)
2393                    .await
2394                    .with_context(|| {
2395                        format!("cannot secure reused destination directory {:?}", dst_path)
2396                    })
2397                    .map_err(|err| Error::new(err, Default::default()))?;
2398                prog_track.directories_unchanged.inc();
2399                Ok(DirResolution::Proceed(DirSlot {
2400                    dir: Arc::new(dir),
2401                    summary: Summary {
2402                        directories_unchanged: 1,
2403                        ..Default::default()
2404                    },
2405                    // a directory we did NOT create is never fresh: its children must run their own
2406                    // existence checks.
2407                    is_fresh: false,
2408                    we_created: false,
2409                    reused_lock,
2410                }))
2411            } else if settings.ignore_existing {
2412                // a non-directory exists and --ignore-existing was set: skip the whole subtree.
2413                tracing::debug!(
2414                    "destination exists but is not a directory, skipping subtree (--ignore-existing)"
2415                );
2416                prog_track.directories_unchanged.inc();
2417                Ok(DirResolution::Skip(Summary {
2418                    directories_unchanged: 1,
2419                    ..Default::default()
2420                }))
2421            } else if settings.overwrite {
2422                // a non-directory exists and --overwrite was set: remove it and create the dir.
2423                tracing::info!("'dst' is not a directory, removing and creating a new one");
2424                let rm_summary = remove_existing(
2425                    prog_track,
2426                    dst_parent,
2427                    name,
2428                    dst_path,
2429                    dst_handle.into_removal_snapshot(),
2430                    settings,
2431                )
2432                .await?;
2433                let dir = dst_parent
2434                    .make_dir(name, safedir::DST_DIR_CREATE_MODE)
2435                    .await
2436                    .with_context(|| format!("cannot create directory {:?}", dst_path))
2437                    .map_err(|err| {
2438                        Error::new(
2439                            err,
2440                            Summary {
2441                                rm_summary,
2442                                ..Default::default()
2443                            },
2444                        )
2445                    })?;
2446                prog_track.directories_created.inc();
2447                Ok(DirResolution::Proceed(DirSlot {
2448                    dir: Arc::new(dir),
2449                    summary: Summary {
2450                        rm_summary,
2451                        directories_created: 1,
2452                        ..Default::default()
2453                    },
2454                    is_fresh: true,
2455                    we_created: true,
2456                    // freshly created after removing a non-dir: copier-owned at 0o700 with its
2457                    // inherited ACLs stripped by `make_dir`; nothing to restore.
2458                    reused_lock: None,
2459                }))
2460            } else {
2461                Err(Error::new(
2462                    anyhow!(
2463                        "destination {:?} already exists, did you intend to specify --overwrite?",
2464                        dst_path
2465                    ),
2466                    Default::default(),
2467                ))
2468            }
2469        }
2470        Err(error) => {
2471            let error = anyhow::Error::new(error)
2472                .context(format!("cannot create directory {:?}", dst_path));
2473            tracing::error!("{:#}", &error);
2474            Err(Error::new(error, Default::default()))
2475        }
2476    }
2477}
2478
2479/// Remove an existing destination entry (file, symlink, directory, or special) so a fresh copy can
2480/// take its place. The returned summary is folded into the caller's copy summary.
2481///
2482/// # Removal contract
2483///
2484/// The caller retains the entry's planned kind and accounting size in `dst_snapshot`, then releases
2485/// its classification fd. Removal is by name through the held `dst_parent` fd:
2486///
2487/// - File / Symlink / Special → [`Dir::unlink_at`] (single entry, never follows a symlink).
2488/// - Empty directory → [`Dir::rmdir_at`].
2489/// - Non-empty directory → fd-relative recursive [`rm::rm_child`] on the held `dst_parent`
2490///   (`O_NOFOLLOW|O_DIRECTORY` descent), the same mechanism the remote destination uses.
2491///
2492/// Every removal is contained to `dst_parent`. A final-component replacement can make the
2493/// kind-directed syscall fail or make it remove a different entry currently occupying `dst_name`,
2494/// but it cannot redirect the operation outside the pinned parent. Re-opening and comparing the
2495/// planned inode would add an `openat` and `fstatat` without closing the remaining window between
2496/// comparison and mutation, and exact final-component identity is outside the overwrite guarantee.
2497/// Overwrite comparison/filter decisions and direct-leaf removal counters therefore describe the
2498/// planning snapshot; a compatible replacement is not re-evaluated before the by-name syscall.
2499pub async fn remove_existing(
2500    prog_track: &'static progress::Progress,
2501    dst_parent: &Arc<Dir>,
2502    dst_name: &OsStr,
2503    dst_path: &std::path::Path,
2504    dst_snapshot: RemovalSnapshot,
2505    settings: &Settings,
2506) -> Result<RmSummary, Error> {
2507    match dst_snapshot.kind() {
2508        EntryKind::File | EntryKind::Symlink | EntryKind::Special => {
2509            let removed_size = dst_snapshot.size();
2510            // single-entry, fd-relative removal contained to dst_parent; never follows a symlink.
2511            dst_parent
2512                .unlink_at(dst_name)
2513                .await
2514                .with_context(|| format!("failed removing existing destination {:?}", dst_path))
2515                .map_err(|err| Error::new(err, Default::default()))?;
2516            let is_symlink = dst_snapshot.kind() == EntryKind::Symlink;
2517            if is_symlink {
2518                prog_track.symlinks_removed.inc();
2519            } else {
2520                prog_track.files_removed.inc();
2521                prog_track.bytes_removed.add(removed_size);
2522            }
2523            Ok(RmSummary {
2524                // the summary must carry the same bytes the progress counter just took, or the
2525                // end-of-run totals and the error payloads disagree with the live display. a
2526                // symlink contributes none, matching `rm::rm_file`'s accounting.
2527                bytes_removed: if is_symlink { 0 } else { removed_size },
2528                files_removed: usize::from(!is_symlink),
2529                symlinks_removed: usize::from(is_symlink),
2530                ..Default::default()
2531            })
2532        }
2533        EntryKind::Dir => {
2534            // try the fd-relative fast path first: an empty directory removes cleanly via rmdir_at.
2535            match dst_parent.rmdir_at(dst_name).await {
2536                Ok(()) => {
2537                    prog_track.directories_removed.inc();
2538                    Ok(RmSummary {
2539                        directories_removed: 1,
2540                        ..Default::default()
2541                    })
2542                }
2543                // POSIX permits either ENOTEMPTY or EEXIST for a non-empty directory.
2544                Err(error)
2545                    if matches!(
2546                        error.raw_os_error(),
2547                        Some(libc::ENOTEMPTY) | Some(libc::EEXIST)
2548                    ) =>
2549                {
2550                    // fd-relative recursive removal of the subtree on the held parent: descent uses
2551                    // O_NOFOLLOW, so a concurrent symlink swap cannot redirect it out of dst_parent.
2552                    // Mirrors the remote destination path.
2553                    rm::rm_child(
2554                        prog_track,
2555                        dst_parent,
2556                        dst_name,
2557                        dst_path,
2558                        &RmSettings {
2559                            fail_early: settings.fail_early,
2560                            filter: None,
2561                            dry_run: None,
2562                            time_filter: None,
2563                        },
2564                    )
2565                    .await
2566                    .map_err(|err| {
2567                        Error::new(
2568                            err.source,
2569                            Summary {
2570                                rm_summary: err.summary,
2571                                ..Default::default()
2572                            },
2573                        )
2574                    })
2575                }
2576                Err(error) => Err(Error::new(
2577                    anyhow::Error::new(error)
2578                        .context(format!("failed removing existing directory {:?}", dst_path)),
2579                    Default::default(),
2580                )),
2581            }
2582        }
2583    }
2584}
2585
2586#[cfg(test)]
2587mod copy_tests {
2588    use crate::testutils;
2589    use anyhow::Context;
2590    use std::os::unix::fs::MetadataExt;
2591    use std::os::unix::fs::PermissionsExt;
2592    use tracing_test::traced_test;
2593
2594    use super::*;
2595
2596    static PROGRESS: std::sync::LazyLock<progress::Progress> =
2597        std::sync::LazyLock::new(progress::Progress::new);
2598    static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
2599        std::sync::LazyLock::new(preserve::preserve_none);
2600    static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
2601        std::sync::LazyLock::new(preserve::preserve_all);
2602
2603    fn settings_with_delete(delete: Option<DeleteSettings>) -> Settings {
2604        Settings {
2605            dereference: false,
2606            fail_early: false,
2607            overwrite: delete.is_some(), // --delete implies --overwrite
2608            overwrite_compare: filecmp::MetadataCmpSettings {
2609                size: true,
2610                mtime: true,
2611                ..Default::default()
2612            },
2613            overwrite_filter: None,
2614            ignore_existing: false,
2615            chunk_size: 0,
2616            skip_specials: false,
2617            remote_copy_buffer_size: 0,
2618            filter: None,
2619            dry_run: None,
2620            delete,
2621        }
2622    }
2623
2624    fn delete_on() -> Option<DeleteSettings> {
2625        Some(DeleteSettings {
2626            delete_excluded: false,
2627        })
2628    }
2629
2630    /// Runs admission-sensitive work and proves its sole open-file permit is returned afterwards.
2631    async fn run_with_open_file_cleanup<F>(
2632        admission: &testutils::AdmissionLimit,
2633        operation: F,
2634    ) -> Result<F::Output, anyhow::Error>
2635    where
2636        F: std::future::Future,
2637    {
2638        let output = admission
2639            .run_with_timeout(std::time::Duration::from_secs(20), operation)
2640            .await
2641            .context("copy operation did not finish at an open-file limit of one")?;
2642        let returned = admission
2643            .run_with_timeout(
2644                std::time::Duration::from_secs(20),
2645                throttle::open_file_permit(),
2646            )
2647            .await
2648            .context("copy operation did not return open-file capacity")?;
2649        drop(returned);
2650        Ok(output)
2651    }
2652
2653    // Regression: a source operand whose final component is `.`/`..` (e.g. `rcp tree/sub/.. dst`,
2654    // `rcp . dst`) must be copied, not rejected — `split_root_operand` canonicalizes it. Uses
2655    // `tree/sub/..` (== `tree`) rather than `.` to avoid touching the process-wide cwd.
2656    #[tokio::test]
2657    async fn copies_dot_dot_source_operand() -> Result<(), anyhow::Error> {
2658        let tmp = testutils::create_temp_dir().await?;
2659        let tree = tmp.join("tree");
2660        tokio::fs::create_dir(&tree).await?;
2661        tokio::fs::write(tree.join("a.txt"), "hello").await?;
2662        tokio::fs::create_dir(tree.join("sub")).await?;
2663        let src = tree.join("sub").join(".."); // == tree
2664        let dst = tmp.join("dst");
2665        let summary = copy(
2666            &PROGRESS,
2667            &src,
2668            &dst,
2669            &settings_with_delete(None),
2670            &NO_PRESERVE_SETTINGS,
2671            false,
2672        )
2673        .await?;
2674        assert_eq!(
2675            summary.files_copied, 1,
2676            "the dot-dot source's file must be copied"
2677        );
2678        assert_eq!(tokio::fs::read_to_string(dst.join("a.txt")).await?, "hello");
2679        assert!(
2680            dst.join("sub").is_dir(),
2681            "the dot-dot source's subdir must be copied"
2682        );
2683        Ok(())
2684    }
2685
2686    #[tokio::test]
2687    #[traced_test]
2688    async fn delete_protects_skipped_special_name() -> Result<(), anyhow::Error> {
2689        let tmp_dir = testutils::setup_test_dir().await?;
2690        let test_path = tmp_dir.as_path();
2691        let src = test_path.join("src_dir");
2692        let dst = test_path.join("dst_dir");
2693        tokio::fs::create_dir(&src).await?;
2694        tokio::fs::write(src.join("file.txt"), "hello").await?;
2695        // a special file in the source that --skip-specials will skip copying
2696        nix::unistd::mkfifo(
2697            &src.join("pipe"),
2698            nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
2699        )?;
2700        // pre-existing destination: a counterpart for the skipped special, plus a genuine extra
2701        tokio::fs::create_dir(&dst).await?;
2702        tokio::fs::write(dst.join("pipe"), "old").await?;
2703        tokio::fs::write(dst.join("stale.txt"), "junk").await?;
2704
2705        let mut settings = settings_with_delete(delete_on());
2706        settings.skip_specials = true;
2707        let summary = copy(
2708            &PROGRESS,
2709            &src,
2710            &dst,
2711            &settings,
2712            &DO_PRESERVE_SETTINGS,
2713            false,
2714        )
2715        .await?;
2716
2717        assert_eq!(summary.specials_skipped, 1);
2718        assert!(dst.join("file.txt").exists());
2719        assert!(
2720            dst.join("pipe").exists(),
2721            "a destination entry matching a skipped special must not be pruned (it has a source counterpart)"
2722        );
2723        assert!(!dst.join("stale.txt").exists()); // genuine extra removed
2724        Ok(())
2725    }
2726
2727    // FIX A (PR #247 review): the operand's TRUSTED parent prefix must be resolved following
2728    // symlinks. `rcp file symlink_to_dir/out` copies into the REAL directory the symlinked parent
2729    // points at (the parent prefix is trusted up to and including the operand's container); it must
2730    // NOT fail closed with ELOOP/ENOTDIR. The hardening only applies strictly below the named root.
2731    #[tokio::test]
2732    #[traced_test]
2733    async fn copies_into_symlinked_destination_parent() -> Result<(), anyhow::Error> {
2734        let tmp_dir = testutils::setup_test_dir().await?;
2735        let test_path = tmp_dir.as_path();
2736        let src = test_path.join("src.txt");
2737        tokio::fs::write(&src, b"payload").await?;
2738        // a real destination directory and a symlink-to-dir parent prefix pointing at it.
2739        let real_dir = test_path.join("real_dst_dir");
2740        tokio::fs::create_dir(&real_dir).await?;
2741        let link_dir = test_path.join("link_dst_dir");
2742        tokio::fs::symlink(&real_dir, &link_dir).await?;
2743        // destination operand sits UNDER the symlinked (trusted) parent prefix.
2744        let dst = link_dir.join("out.txt");
2745        let summary = copy(
2746            &PROGRESS,
2747            &src,
2748            &dst,
2749            &settings_with_delete(None),
2750            &DO_PRESERVE_SETTINGS,
2751            false,
2752        )
2753        .await?;
2754        assert_eq!(summary.files_copied, 1);
2755        // the file landed in the REAL directory (the symlinked parent was followed).
2756        let written = tokio::fs::read(real_dir.join("out.txt")).await?;
2757        assert_eq!(written, b"payload");
2758        Ok(())
2759    }
2760
2761    // FIX A (PR #247 review): a symlinked SOURCE parent prefix is likewise followed —
2762    // `rcp symlinkdir/src dst` reads the file through the real directory.
2763    #[tokio::test]
2764    #[traced_test]
2765    async fn copies_from_symlinked_source_parent() -> Result<(), anyhow::Error> {
2766        let tmp_dir = testutils::setup_test_dir().await?;
2767        let test_path = tmp_dir.as_path();
2768        // a real source directory containing the file, reached via a symlinked parent prefix.
2769        let real_src_dir = test_path.join("real_src_dir");
2770        tokio::fs::create_dir(&real_src_dir).await?;
2771        tokio::fs::write(real_src_dir.join("src.txt"), b"payload").await?;
2772        let link_src_dir = test_path.join("link_src_dir");
2773        tokio::fs::symlink(&real_src_dir, &link_src_dir).await?;
2774        let src = link_src_dir.join("src.txt");
2775        let dst = test_path.join("out.txt");
2776        let summary = copy(
2777            &PROGRESS,
2778            &src,
2779            &dst,
2780            &settings_with_delete(None),
2781            &DO_PRESERVE_SETTINGS,
2782            false,
2783        )
2784        .await?;
2785        assert_eq!(summary.files_copied, 1);
2786        let written = tokio::fs::read(&dst).await?;
2787        assert_eq!(written, b"payload");
2788        Ok(())
2789    }
2790
2791    #[tokio::test]
2792    #[traced_test]
2793    async fn delete_removes_extraneous_destination_entries() -> Result<(), anyhow::Error> {
2794        let tmp_dir = testutils::setup_test_dir().await?;
2795        let test_path = tmp_dir.as_path();
2796        let src = test_path.join("foo");
2797        let dst = test_path.join("bar");
2798        // initial copy (no delete)
2799        copy(
2800            &PROGRESS,
2801            &src,
2802            &dst,
2803            &settings_with_delete(None),
2804            &DO_PRESERVE_SETTINGS,
2805            false,
2806        )
2807        .await?;
2808        // introduce extraneous entries at the destination
2809        tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2810        tokio::fs::create_dir(dst.join("extra_dir")).await?;
2811        tokio::fs::write(dst.join("extra_dir").join("nested.txt"), b"junk").await?;
2812        // re-copy with --delete
2813        let summary = copy(
2814            &PROGRESS,
2815            &src,
2816            &dst,
2817            &settings_with_delete(delete_on()),
2818            &DO_PRESERVE_SETTINGS,
2819            false,
2820        )
2821        .await?;
2822        assert_eq!(summary.rm_summary.files_removed, 2); // extraneous.txt + extra_dir/nested.txt
2823        assert_eq!(summary.rm_summary.directories_removed, 1); // extra_dir
2824        assert!(!dst.join("extraneous.txt").exists());
2825        assert!(!dst.join("extra_dir").exists());
2826        testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2827        Ok(())
2828    }
2829
2830    #[tokio::test]
2831    #[traced_test]
2832    async fn delete_prunes_extraneous_at_depth() -> Result<(), anyhow::Error> {
2833        let tmp_dir = testutils::setup_test_dir().await?;
2834        let test_path = tmp_dir.as_path();
2835        let src = test_path.join("foo");
2836        let dst = test_path.join("bar");
2837        copy(
2838            &PROGRESS,
2839            &src,
2840            &dst,
2841            &settings_with_delete(None),
2842            &DO_PRESERVE_SETTINGS,
2843            false,
2844        )
2845        .await?;
2846        // foo/bar is a common subdirectory; place a stale file inside the dst copy of it
2847        let nested = dst.join("bar");
2848        assert!(
2849            nested.is_dir(),
2850            "expected common subdirectory bar/ to exist at destination"
2851        );
2852        tokio::fs::write(nested.join("stale_nested.txt"), b"junk").await?;
2853        let summary = copy(
2854            &PROGRESS,
2855            &src,
2856            &dst,
2857            &settings_with_delete(delete_on()),
2858            &DO_PRESERVE_SETTINGS,
2859            false,
2860        )
2861        .await?;
2862        assert!(
2863            !nested.join("stale_nested.txt").exists(),
2864            "stale entry inside a common subdirectory must be pruned"
2865        );
2866        assert!(summary.rm_summary.files_removed >= 1);
2867        testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2868        Ok(())
2869    }
2870
2871    #[tokio::test]
2872    #[traced_test]
2873    async fn delete_removes_extraneous_symlink() -> Result<(), anyhow::Error> {
2874        let tmp_dir = testutils::setup_test_dir().await?;
2875        let test_path = tmp_dir.as_path();
2876        let src = test_path.join("foo");
2877        let dst = test_path.join("bar");
2878        copy(
2879            &PROGRESS,
2880            &src,
2881            &dst,
2882            &settings_with_delete(None),
2883            &DO_PRESERVE_SETTINGS,
2884            false,
2885        )
2886        .await?;
2887        // an extraneous symlink at the destination root (no source counterpart)
2888        tokio::fs::symlink("/nonexistent/target", dst.join("stale_link")).await?;
2889        let summary = copy(
2890            &PROGRESS,
2891            &src,
2892            &dst,
2893            &settings_with_delete(delete_on()),
2894            &DO_PRESERVE_SETTINGS,
2895            false,
2896        )
2897        .await?;
2898        assert!(
2899            tokio::fs::symlink_metadata(dst.join("stale_link"))
2900                .await
2901                .is_err(),
2902            "extraneous symlink must be removed"
2903        );
2904        assert_eq!(summary.rm_summary.symlinks_removed, 1);
2905        Ok(())
2906    }
2907
2908    #[tokio::test]
2909    #[traced_test]
2910    async fn delete_skips_pruning_when_copy_has_errors() -> Result<(), anyhow::Error> {
2911        let tmp_dir = testutils::setup_test_dir().await?;
2912        let test_path = tmp_dir.as_path();
2913        let src = test_path.join("foo");
2914        let dst = test_path.join("bar");
2915        // baseline copy establishes the destination
2916        copy(
2917            &PROGRESS,
2918            &src,
2919            &dst,
2920            &settings_with_delete(None),
2921            &DO_PRESERVE_SETTINGS,
2922            false,
2923        )
2924        .await?;
2925        // an extraneous file that --delete would normally prune
2926        tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2927        // make a source sub-directory unreadable so traversal fails (fail_early is false).
2928        // a directory (not a file) is used because --overwrite with mtime-equal files skips
2929        // copying identical files; a directory's read_dir fails unconditionally when mode is 0o000.
2930        let unreadable = src.join("baz");
2931        let original = tokio::fs::metadata(&unreadable).await?.permissions();
2932        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2933
2934        let result = copy(
2935            &PROGRESS,
2936            &src,
2937            &dst,
2938            &settings_with_delete(delete_on()),
2939            &DO_PRESERVE_SETTINGS,
2940            false,
2941        )
2942        .await;
2943
2944        tokio::fs::set_permissions(&unreadable, original).await?;
2945
2946        assert!(
2947            result.is_err(),
2948            "copy of the unreadable directory should fail"
2949        );
2950        assert!(
2951            dst.join("extraneous.txt").exists(),
2952            "pruning must be skipped when the copy reported errors"
2953        );
2954        Ok(())
2955    }
2956
2957    #[tokio::test]
2958    #[traced_test]
2959    async fn check_basic_copy() -> Result<(), anyhow::Error> {
2960        let tmp_dir = testutils::setup_test_dir().await?;
2961        let test_path = tmp_dir.as_path();
2962        let summary = copy(
2963            &PROGRESS,
2964            &test_path.join("foo"),
2965            &test_path.join("bar"),
2966            &Settings {
2967                dereference: false,
2968                fail_early: false,
2969                overwrite: false,
2970                overwrite_compare: filecmp::MetadataCmpSettings {
2971                    size: true,
2972                    mtime: true,
2973                    ..Default::default()
2974                },
2975                overwrite_filter: None,
2976                ignore_existing: false,
2977                chunk_size: 0,
2978                skip_specials: false,
2979                remote_copy_buffer_size: 0,
2980                filter: None,
2981                dry_run: None,
2982                delete: None,
2983            },
2984            &NO_PRESERVE_SETTINGS,
2985            false,
2986        )
2987        .await?;
2988        assert_eq!(summary.files_copied, 5);
2989        assert_eq!(summary.symlinks_created, 2);
2990        assert_eq!(summary.directories_created, 3);
2991        testutils::check_dirs_identical(
2992            &test_path.join("foo"),
2993            &test_path.join("bar"),
2994            testutils::FileEqualityCheck::Basic,
2995        )
2996        .await?;
2997        Ok(())
2998    }
2999
3000    #[tokio::test]
3001    #[traced_test]
3002    async fn no_read_permission() -> Result<(), anyhow::Error> {
3003        let tmp_dir = testutils::setup_test_dir().await?;
3004        let test_path = tmp_dir.as_path();
3005        let filepaths = vec![
3006            test_path.join("foo").join("0.txt"),
3007            test_path.join("foo").join("baz"),
3008        ];
3009        for fpath in &filepaths {
3010            // change file permissions to not readable
3011            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
3012        }
3013        match copy(
3014            &PROGRESS,
3015            &test_path.join("foo"),
3016            &test_path.join("bar"),
3017            &Settings {
3018                dereference: false,
3019                fail_early: false,
3020                overwrite: false,
3021                overwrite_compare: filecmp::MetadataCmpSettings {
3022                    size: true,
3023                    mtime: true,
3024                    ..Default::default()
3025                },
3026                overwrite_filter: None,
3027                ignore_existing: false,
3028                chunk_size: 0,
3029                skip_specials: false,
3030                remote_copy_buffer_size: 0,
3031                filter: None,
3032                dry_run: None,
3033                delete: None,
3034            },
3035            &NO_PRESERVE_SETTINGS,
3036            false,
3037        )
3038        .await
3039        {
3040            Ok(_) => panic!("Expected the copy to error!"),
3041            Err(error) => {
3042                tracing::info!("{:#}", &error);
3043                // foo
3044                // |- 0.txt  // <- no read permission
3045                // |- bar
3046                //    |- 1.txt
3047                //    |- 2.txt
3048                //    |- 3.txt
3049                // |- baz   // <- no read permission
3050                //    |- 4.txt
3051                //    |- 5.txt -> ../bar/2.txt
3052                //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
3053                assert_eq!(error.summary.files_copied, 3);
3054                assert_eq!(error.summary.symlinks_created, 0);
3055                assert_eq!(error.summary.directories_created, 2);
3056            }
3057        }
3058        // make source directory same as what we expect destination to be
3059        for fpath in &filepaths {
3060            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
3061            if tokio::fs::symlink_metadata(fpath).await?.is_file() {
3062                tokio::fs::remove_file(fpath).await?;
3063            } else {
3064                tokio::fs::remove_dir_all(fpath).await?;
3065            }
3066        }
3067        testutils::check_dirs_identical(
3068            &test_path.join("foo"),
3069            &test_path.join("bar"),
3070            testutils::FileEqualityCheck::Basic,
3071        )
3072        .await?;
3073        Ok(())
3074    }
3075
3076    #[cfg_attr(
3077        rcp_nix_sandbox,
3078        ignore = "Nix sandbox cannot set special permission bits"
3079    )]
3080    #[tokio::test]
3081    #[traced_test]
3082    async fn check_default_mode() -> Result<(), anyhow::Error> {
3083        let tmp_dir = testutils::setup_test_dir().await?;
3084        // set file to executable
3085        tokio::fs::set_permissions(
3086            tmp_dir.join("foo").join("0.txt"),
3087            std::fs::Permissions::from_mode(0o700),
3088        )
3089        .await?;
3090        // set file executable AND also set sticky bit, setuid and setgid
3091        let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
3092        tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
3093            .await?;
3094        let test_path = tmp_dir.as_path();
3095        let summary = copy(
3096            &PROGRESS,
3097            &test_path.join("foo"),
3098            &test_path.join("bar"),
3099            &Settings {
3100                dereference: false,
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            &NO_PRESERVE_SETTINGS,
3118            false,
3119        )
3120        .await?;
3121        assert_eq!(summary.files_copied, 5);
3122        assert_eq!(summary.symlinks_created, 2);
3123        assert_eq!(summary.directories_created, 3);
3124        // clear the setuid, setgid and sticky bit for comparison
3125        tokio::fs::set_permissions(
3126            &exec_sticky_file,
3127            std::fs::Permissions::from_mode(
3128                std::fs::symlink_metadata(&exec_sticky_file)?
3129                    .permissions()
3130                    .mode()
3131                    & 0o0777,
3132            ),
3133        )
3134        .await?;
3135        testutils::check_dirs_identical(
3136            &test_path.join("foo"),
3137            &test_path.join("bar"),
3138            testutils::FileEqualityCheck::Basic,
3139        )
3140        .await?;
3141        Ok(())
3142    }
3143
3144    #[tokio::test]
3145    #[traced_test]
3146    async fn no_write_permission() -> Result<(), anyhow::Error> {
3147        let tmp_dir = testutils::setup_test_dir().await?;
3148        let test_path = tmp_dir.as_path();
3149        // directory - readable and non-executable
3150        let non_exec_dir = test_path.join("foo").join("bogey");
3151        tokio::fs::create_dir(&non_exec_dir).await?;
3152        tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
3153        // directory - readable and executable
3154        tokio::fs::set_permissions(
3155            &test_path.join("foo").join("baz"),
3156            std::fs::Permissions::from_mode(0o500),
3157        )
3158        .await?;
3159        // file
3160        tokio::fs::set_permissions(
3161            &test_path.join("foo").join("baz").join("4.txt"),
3162            std::fs::Permissions::from_mode(0o440),
3163        )
3164        .await?;
3165        let summary = copy(
3166            &PROGRESS,
3167            &test_path.join("foo"),
3168            &test_path.join("bar"),
3169            &Settings {
3170                dereference: false,
3171                fail_early: false,
3172                overwrite: false,
3173                overwrite_compare: filecmp::MetadataCmpSettings {
3174                    size: true,
3175                    mtime: true,
3176                    ..Default::default()
3177                },
3178                overwrite_filter: None,
3179                ignore_existing: false,
3180                chunk_size: 0,
3181                skip_specials: false,
3182                remote_copy_buffer_size: 0,
3183                filter: None,
3184                dry_run: None,
3185                delete: None,
3186            },
3187            &NO_PRESERVE_SETTINGS,
3188            false,
3189        )
3190        .await?;
3191        assert_eq!(summary.files_copied, 5);
3192        assert_eq!(summary.symlinks_created, 2);
3193        assert_eq!(summary.directories_created, 4);
3194        testutils::check_dirs_identical(
3195            &test_path.join("foo"),
3196            &test_path.join("bar"),
3197            testutils::FileEqualityCheck::Basic,
3198        )
3199        .await?;
3200        Ok(())
3201    }
3202
3203    #[tokio::test]
3204    #[traced_test]
3205    async fn dereference() -> Result<(), anyhow::Error> {
3206        let tmp_dir = testutils::setup_test_dir().await?;
3207        let test_path = tmp_dir.as_path();
3208        // make files pointed to by symlinks have different permissions than the symlink itself
3209        let src1 = &test_path.join("foo").join("bar").join("2.txt");
3210        let src2 = &test_path.join("foo").join("bar").join("3.txt");
3211        let test_mode = 0o440;
3212        for f in [src1, src2] {
3213            tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
3214        }
3215        let summary = copy(
3216            &PROGRESS,
3217            &test_path.join("foo"),
3218            &test_path.join("bar"),
3219            &Settings {
3220                dereference: true, // <- important!
3221                fail_early: false,
3222                overwrite: false,
3223                overwrite_compare: filecmp::MetadataCmpSettings {
3224                    size: true,
3225                    mtime: true,
3226                    ..Default::default()
3227                },
3228                overwrite_filter: None,
3229                ignore_existing: false,
3230                chunk_size: 0,
3231                skip_specials: false,
3232                remote_copy_buffer_size: 0,
3233                filter: None,
3234                dry_run: None,
3235                delete: None,
3236            },
3237            &NO_PRESERVE_SETTINGS,
3238            false,
3239        )
3240        .await?;
3241        assert_eq!(summary.files_copied, 7);
3242        assert_eq!(summary.symlinks_created, 0);
3243        assert_eq!(summary.directories_created, 3);
3244        // ...
3245        // |- baz
3246        //    |- 4.txt
3247        //    |- 5.txt -> ../bar/2.txt
3248        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
3249        let dst1 = &test_path.join("bar").join("baz").join("5.txt");
3250        let dst2 = &test_path.join("bar").join("baz").join("6.txt");
3251        for f in [dst1, dst2] {
3252            let metadata = tokio::fs::symlink_metadata(f)
3253                .await
3254                .with_context(|| format!("failed reading metadata from {:?}", &f))?;
3255            assert!(metadata.is_file());
3256            // check that the permissions are the same as the source file modulo no sticky bit, setuid and setgid
3257            assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
3258        }
3259        Ok(())
3260    }
3261
3262    async fn cp_compare(
3263        cp_args: &[&str],
3264        rcp_settings: &Settings,
3265        preserve: bool,
3266    ) -> Result<(), anyhow::Error> {
3267        let tmp_dir = testutils::setup_test_dir().await?;
3268        let test_path = tmp_dir.as_path();
3269        // run a cp command to copy the files
3270        let cp_output = tokio::process::Command::new("cp")
3271            .args(cp_args)
3272            .arg(test_path.join("foo"))
3273            .arg(test_path.join("bar"))
3274            .output()
3275            .await?;
3276        assert!(cp_output.status.success());
3277        // now run rcp
3278        let summary = copy(
3279            &PROGRESS,
3280            &test_path.join("foo"),
3281            &test_path.join("baz"),
3282            rcp_settings,
3283            if preserve {
3284                &DO_PRESERVE_SETTINGS
3285            } else {
3286                &NO_PRESERVE_SETTINGS
3287            },
3288            false,
3289        )
3290        .await?;
3291        if rcp_settings.dereference {
3292            assert_eq!(summary.files_copied, 7);
3293            assert_eq!(summary.symlinks_created, 0);
3294        } else {
3295            assert_eq!(summary.files_copied, 5);
3296            assert_eq!(summary.symlinks_created, 2);
3297        }
3298        assert_eq!(summary.directories_created, 3);
3299        testutils::check_dirs_identical(
3300            &test_path.join("bar"),
3301            &test_path.join("baz"),
3302            if preserve {
3303                testutils::FileEqualityCheck::Timestamp
3304            } else {
3305                testutils::FileEqualityCheck::Basic
3306            },
3307        )
3308        .await?;
3309        Ok(())
3310    }
3311
3312    #[tokio::test]
3313    #[traced_test]
3314    async fn test_cp_compat() -> Result<(), anyhow::Error> {
3315        cp_compare(
3316            &["-r"],
3317            &Settings {
3318                dereference: false,
3319                fail_early: false,
3320                overwrite: false,
3321                overwrite_compare: filecmp::MetadataCmpSettings {
3322                    size: true,
3323                    mtime: true,
3324                    ..Default::default()
3325                },
3326                overwrite_filter: None,
3327                ignore_existing: false,
3328                chunk_size: 0,
3329                skip_specials: false,
3330                remote_copy_buffer_size: 0,
3331                filter: None,
3332                dry_run: None,
3333                delete: None,
3334            },
3335            false,
3336        )
3337        .await?;
3338        Ok(())
3339    }
3340
3341    #[tokio::test]
3342    #[traced_test]
3343    async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
3344        cp_compare(
3345            &["-r", "-p"],
3346            &Settings {
3347                dereference: false,
3348                fail_early: false,
3349                overwrite: false,
3350                overwrite_compare: filecmp::MetadataCmpSettings {
3351                    size: true,
3352                    mtime: true,
3353                    ..Default::default()
3354                },
3355                overwrite_filter: None,
3356                ignore_existing: false,
3357                chunk_size: 0,
3358                skip_specials: false,
3359                remote_copy_buffer_size: 0,
3360                filter: None,
3361                dry_run: None,
3362                delete: None,
3363            },
3364            true,
3365        )
3366        .await?;
3367        Ok(())
3368    }
3369
3370    #[tokio::test]
3371    #[traced_test]
3372    async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
3373        cp_compare(
3374            &["-r", "-L"],
3375            &Settings {
3376                dereference: true,
3377                fail_early: false,
3378                overwrite: false,
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            false,
3394        )
3395        .await?;
3396        Ok(())
3397    }
3398
3399    #[tokio::test]
3400    #[traced_test]
3401    async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
3402        cp_compare(
3403            &["-r", "-p", "-L"],
3404            &Settings {
3405                dereference: true,
3406                fail_early: false,
3407                overwrite: false,
3408                overwrite_compare: filecmp::MetadataCmpSettings {
3409                    size: true,
3410                    mtime: true,
3411                    ..Default::default()
3412                },
3413                overwrite_filter: None,
3414                ignore_existing: false,
3415                chunk_size: 0,
3416                skip_specials: false,
3417                remote_copy_buffer_size: 0,
3418                filter: None,
3419                dry_run: None,
3420                delete: None,
3421            },
3422            true,
3423        )
3424        .await?;
3425        Ok(())
3426    }
3427
3428    async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
3429        let tmp_dir = testutils::setup_test_dir().await?;
3430        let test_path = tmp_dir.as_path();
3431        let summary = copy(
3432            &PROGRESS,
3433            &test_path.join("foo"),
3434            &test_path.join("bar"),
3435            &Settings {
3436                dereference: false,
3437                fail_early: false,
3438                overwrite: false,
3439                overwrite_compare: filecmp::MetadataCmpSettings {
3440                    size: true,
3441                    mtime: true,
3442                    ..Default::default()
3443                },
3444                overwrite_filter: None,
3445                ignore_existing: false,
3446                chunk_size: 0,
3447                skip_specials: false,
3448                remote_copy_buffer_size: 0,
3449                filter: None,
3450                dry_run: None,
3451                delete: None,
3452            },
3453            &DO_PRESERVE_SETTINGS,
3454            false,
3455        )
3456        .await?;
3457        assert_eq!(summary.files_copied, 5);
3458        assert_eq!(summary.symlinks_created, 2);
3459        assert_eq!(summary.directories_created, 3);
3460        Ok(tmp_dir)
3461    }
3462
3463    #[tokio::test]
3464    #[traced_test]
3465    async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
3466        let tmp_dir = setup_test_dir_and_copy().await?;
3467        let output_path = &tmp_dir.join("bar");
3468        {
3469            // bar
3470            // |- 0.txt
3471            // |- bar  <---------------------------------------- REMOVE
3472            //    |- 1.txt  <----------------------------------- REMOVE
3473            //    |- 2.txt  <----------------------------------- REMOVE
3474            //    |- 3.txt  <----------------------------------- REMOVE
3475            // |- baz
3476            //    |- 4.txt
3477            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
3478            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
3479            let summary = rm::rm(
3480                &PROGRESS,
3481                &output_path.join("bar"),
3482                &RmSettings {
3483                    fail_early: false,
3484                    filter: None,
3485                    dry_run: None,
3486                    time_filter: None,
3487                },
3488            )
3489            .await?
3490                + rm::rm(
3491                    &PROGRESS,
3492                    &output_path.join("baz").join("5.txt"),
3493                    &RmSettings {
3494                        fail_early: false,
3495                        filter: None,
3496                        dry_run: None,
3497                        time_filter: None,
3498                    },
3499                )
3500                .await?;
3501            assert_eq!(summary.files_removed, 3);
3502            assert_eq!(summary.symlinks_removed, 1);
3503            assert_eq!(summary.directories_removed, 1);
3504        }
3505        let summary = copy(
3506            &PROGRESS,
3507            &tmp_dir.join("foo"),
3508            output_path,
3509            &Settings {
3510                dereference: false,
3511                fail_early: false,
3512                overwrite: true, // <- important!
3513                overwrite_compare: filecmp::MetadataCmpSettings {
3514                    size: true,
3515                    mtime: true,
3516                    ..Default::default()
3517                },
3518                overwrite_filter: None,
3519                ignore_existing: false,
3520                chunk_size: 0,
3521                skip_specials: false,
3522                remote_copy_buffer_size: 0,
3523                filter: None,
3524                dry_run: None,
3525                delete: None,
3526            },
3527            &DO_PRESERVE_SETTINGS,
3528            false,
3529        )
3530        .await?;
3531        assert_eq!(summary.files_copied, 3);
3532        assert_eq!(summary.symlinks_created, 1);
3533        assert_eq!(summary.directories_created, 1);
3534        testutils::check_dirs_identical(
3535            &tmp_dir.join("foo"),
3536            output_path,
3537            testutils::FileEqualityCheck::Timestamp,
3538        )
3539        .await?;
3540        Ok(())
3541    }
3542
3543    #[tokio::test]
3544    #[traced_test]
3545    async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
3546        let tmp_dir = setup_test_dir_and_copy().await?;
3547        let output_path = &tmp_dir.join("bar");
3548        {
3549            // bar
3550            // |- 0.txt
3551            // |- bar
3552            //    |- 1.txt  <------------------------------------- REMOVE
3553            //    |- 2.txt
3554            //    |- 3.txt
3555            // |- baz  <------------------------------------------ REMOVE
3556            //    |- 4.txt  <------------------------------------- REMOVE
3557            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
3558            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt <- REMOVE
3559            let summary = rm::rm(
3560                &PROGRESS,
3561                &output_path.join("bar").join("1.txt"),
3562                &RmSettings {
3563                    fail_early: false,
3564                    filter: None,
3565                    dry_run: None,
3566                    time_filter: None,
3567                },
3568            )
3569            .await?
3570                + rm::rm(
3571                    &PROGRESS,
3572                    &output_path.join("baz"),
3573                    &RmSettings {
3574                        fail_early: false,
3575                        filter: None,
3576                        dry_run: None,
3577                        time_filter: None,
3578                    },
3579                )
3580                .await?;
3581            assert_eq!(summary.files_removed, 2);
3582            assert_eq!(summary.symlinks_removed, 2);
3583            assert_eq!(summary.directories_removed, 1);
3584        }
3585        {
3586            // replace bar/1.txt file with a directory
3587            tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
3588            // replace baz directory with a file
3589            tokio::fs::write(&output_path.join("baz"), "baz").await?;
3590        }
3591        let summary = copy(
3592            &PROGRESS,
3593            &tmp_dir.join("foo"),
3594            output_path,
3595            &Settings {
3596                dereference: false,
3597                fail_early: false,
3598                overwrite: true, // <- important!
3599                overwrite_compare: filecmp::MetadataCmpSettings {
3600                    size: true,
3601                    mtime: true,
3602                    ..Default::default()
3603                },
3604                overwrite_filter: None,
3605                ignore_existing: false,
3606                chunk_size: 0,
3607                skip_specials: false,
3608                remote_copy_buffer_size: 0,
3609                filter: None,
3610                dry_run: None,
3611                delete: None,
3612            },
3613            &DO_PRESERVE_SETTINGS,
3614            false,
3615        )
3616        .await?;
3617        assert_eq!(summary.rm_summary.files_removed, 1);
3618        assert_eq!(summary.rm_summary.symlinks_removed, 0);
3619        assert_eq!(summary.rm_summary.directories_removed, 1);
3620        assert_eq!(summary.files_copied, 2);
3621        assert_eq!(summary.symlinks_created, 2);
3622        assert_eq!(summary.directories_created, 1);
3623        testutils::check_dirs_identical(
3624            &tmp_dir.join("foo"),
3625            output_path,
3626            testutils::FileEqualityCheck::Timestamp,
3627        )
3628        .await?;
3629        Ok(())
3630    }
3631
3632    #[tokio::test]
3633    #[traced_test]
3634    async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
3635        let tmp_dir = setup_test_dir_and_copy().await?;
3636        let output_path = &tmp_dir.join("bar");
3637        {
3638            // bar
3639            // |- 0.txt
3640            // |- baz
3641            //    |- 4.txt  <------------------------------------- REMOVE
3642            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
3643            // ...
3644            let summary = rm::rm(
3645                &PROGRESS,
3646                &output_path.join("baz").join("4.txt"),
3647                &RmSettings {
3648                    fail_early: false,
3649                    filter: None,
3650                    dry_run: None,
3651                    time_filter: None,
3652                },
3653            )
3654            .await?
3655                + rm::rm(
3656                    &PROGRESS,
3657                    &output_path.join("baz").join("5.txt"),
3658                    &RmSettings {
3659                        fail_early: false,
3660                        filter: None,
3661                        dry_run: None,
3662                        time_filter: None,
3663                    },
3664                )
3665                .await?;
3666            assert_eq!(summary.files_removed, 1);
3667            assert_eq!(summary.symlinks_removed, 1);
3668            assert_eq!(summary.directories_removed, 0);
3669        }
3670        {
3671            // replace baz/4.txt file with a symlink
3672            tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
3673            // replace baz/5.txt symlink with a file
3674            tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
3675        }
3676        let summary = copy(
3677            &PROGRESS,
3678            &tmp_dir.join("foo"),
3679            output_path,
3680            &Settings {
3681                dereference: false,
3682                fail_early: false,
3683                overwrite: true, // <- important!
3684                overwrite_compare: filecmp::MetadataCmpSettings {
3685                    size: true,
3686                    mtime: true,
3687                    ..Default::default()
3688                },
3689                overwrite_filter: None,
3690                ignore_existing: false,
3691                chunk_size: 0,
3692                skip_specials: false,
3693                remote_copy_buffer_size: 0,
3694                filter: None,
3695                dry_run: None,
3696                delete: None,
3697            },
3698            &DO_PRESERVE_SETTINGS,
3699            false,
3700        )
3701        .await?;
3702        assert_eq!(summary.rm_summary.files_removed, 1);
3703        assert_eq!(summary.rm_summary.symlinks_removed, 1);
3704        assert_eq!(summary.rm_summary.directories_removed, 0);
3705        assert_eq!(summary.files_copied, 1);
3706        assert_eq!(summary.symlinks_created, 1);
3707        assert_eq!(summary.directories_created, 0);
3708        testutils::check_dirs_identical(
3709            &tmp_dir.join("foo"),
3710            output_path,
3711            testutils::FileEqualityCheck::Timestamp,
3712        )
3713        .await?;
3714        Ok(())
3715    }
3716
3717    #[tokio::test]
3718    #[traced_test]
3719    async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
3720        let tmp_dir = setup_test_dir_and_copy().await?;
3721        let output_path = &tmp_dir.join("bar");
3722        {
3723            // bar
3724            // |- 0.txt
3725            // |- bar  <------------------------------------------ REMOVE
3726            //    |- 1.txt  <------------------------------------- REMOVE
3727            //    |- 2.txt  <------------------------------------- REMOVE
3728            //    |- 3.txt  <------------------------------------- REMOVE
3729            // |- baz
3730            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
3731            // ...
3732            let summary = rm::rm(
3733                &PROGRESS,
3734                &output_path.join("bar"),
3735                &RmSettings {
3736                    fail_early: false,
3737                    filter: None,
3738                    dry_run: None,
3739                    time_filter: None,
3740                },
3741            )
3742            .await?
3743                + rm::rm(
3744                    &PROGRESS,
3745                    &output_path.join("baz").join("5.txt"),
3746                    &RmSettings {
3747                        fail_early: false,
3748                        filter: None,
3749                        dry_run: None,
3750                        time_filter: None,
3751                    },
3752                )
3753                .await?;
3754            assert_eq!(summary.files_removed, 3);
3755            assert_eq!(summary.symlinks_removed, 1);
3756            assert_eq!(summary.directories_removed, 1);
3757        }
3758        {
3759            // replace bar directory with a symlink
3760            tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
3761            // replace baz/5.txt symlink with a directory
3762            tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
3763        }
3764        let summary = copy(
3765            &PROGRESS,
3766            &tmp_dir.join("foo"),
3767            output_path,
3768            &Settings {
3769                dereference: false,
3770                fail_early: false,
3771                overwrite: true, // <- important!
3772                overwrite_compare: filecmp::MetadataCmpSettings {
3773                    size: true,
3774                    mtime: true,
3775                    ..Default::default()
3776                },
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,
3787            false,
3788        )
3789        .await?;
3790        assert_eq!(summary.rm_summary.files_removed, 0);
3791        assert_eq!(summary.rm_summary.symlinks_removed, 1);
3792        assert_eq!(summary.rm_summary.directories_removed, 1);
3793        assert_eq!(summary.files_copied, 3);
3794        assert_eq!(summary.symlinks_created, 1);
3795        assert_eq!(summary.directories_created, 1);
3796        assert_eq!(summary.files_unchanged, 2);
3797        assert_eq!(summary.symlinks_unchanged, 1);
3798        assert_eq!(summary.directories_unchanged, 2);
3799        testutils::check_dirs_identical(
3800            &tmp_dir.join("foo"),
3801            output_path,
3802            testutils::FileEqualityCheck::Timestamp,
3803        )
3804        .await?;
3805        Ok(())
3806    }
3807
3808    #[tokio::test]
3809    #[traced_test]
3810    async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
3811        let tmp_dir = testutils::setup_test_dir().await?;
3812        let test_path = tmp_dir.as_path();
3813        let summary = copy(
3814            &PROGRESS,
3815            &test_path.join("foo"),
3816            &test_path.join("bar"),
3817            &Settings {
3818                dereference: false,
3819                fail_early: false,
3820                overwrite: false,
3821                overwrite_compare: filecmp::MetadataCmpSettings {
3822                    size: true,
3823                    mtime: true,
3824                    ..Default::default()
3825                },
3826                overwrite_filter: None,
3827                ignore_existing: false,
3828                chunk_size: 0,
3829                skip_specials: false,
3830                remote_copy_buffer_size: 0,
3831                filter: None,
3832                dry_run: None,
3833                delete: None,
3834            },
3835            &NO_PRESERVE_SETTINGS, // we want timestamps to differ!
3836            false,
3837        )
3838        .await?;
3839        assert_eq!(summary.files_copied, 5);
3840        assert_eq!(summary.symlinks_created, 2);
3841        assert_eq!(summary.directories_created, 3);
3842        let source_path = &test_path.join("foo");
3843        let output_path = &tmp_dir.join("bar");
3844        // unreadable
3845        tokio::fs::set_permissions(
3846            &source_path.join("bar"),
3847            std::fs::Permissions::from_mode(0o000),
3848        )
3849        .await?;
3850        tokio::fs::set_permissions(
3851            &source_path.join("baz").join("4.txt"),
3852            std::fs::Permissions::from_mode(0o000),
3853        )
3854        .await?;
3855        // bar
3856        // |- 0.txt
3857        // |- bar  <---------------------------------------- NON READABLE
3858        // |- baz
3859        //    |- 4.txt  <----------------------------------- NON READABLE
3860        //    |- 5.txt -> ../bar/2.txt
3861        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
3862        match copy(
3863            &PROGRESS,
3864            &tmp_dir.join("foo"),
3865            output_path,
3866            &Settings {
3867                dereference: false,
3868                fail_early: false,
3869                overwrite: true, // <- important!
3870                overwrite_compare: filecmp::MetadataCmpSettings {
3871                    size: true,
3872                    mtime: true,
3873                    ..Default::default()
3874                },
3875                overwrite_filter: None,
3876                ignore_existing: false,
3877                chunk_size: 0,
3878                skip_specials: false,
3879                remote_copy_buffer_size: 0,
3880                filter: None,
3881                dry_run: None,
3882                delete: None,
3883            },
3884            &DO_PRESERVE_SETTINGS,
3885            false,
3886        )
3887        .await
3888        {
3889            Ok(_) => panic!("Expected the copy to error!"),
3890            Err(error) => {
3891                tracing::info!("{:#}", &error);
3892                assert_eq!(error.summary.files_copied, 1);
3893                assert_eq!(error.summary.symlinks_created, 2);
3894                assert_eq!(error.summary.directories_created, 0);
3895                // one removal, not two: `foo/baz/4.txt` is unreadable, so its copy fails at the
3896                // source open before the destination is unlinked. `bar/baz/4.txt` survives a copy
3897                // that could never have replaced it, which is the failure-ordering invariant owned
3898                // by `plan_overwrite_candidate` and `execute_dst_plan`.
3899                assert_eq!(error.summary.rm_summary.files_removed, 1);
3900                assert!(
3901                    output_path.join("baz").join("4.txt").exists(),
3902                    "the destination of an unreadable source must not be removed"
3903                );
3904                assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
3905                assert_eq!(error.summary.rm_summary.directories_removed, 0);
3906            }
3907        }
3908        Ok(())
3909    }
3910
3911    #[tokio::test]
3912    #[traced_test]
3913    async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
3914        // Create a fresh temporary directory to avoid conflicts
3915        let tmp_dir = testutils::create_temp_dir().await?;
3916        let test_path = tmp_dir.as_path();
3917        // Create a chain of symlinks: foo -> bar -> baz (actual file)
3918        let baz_file = test_path.join("baz_file.txt");
3919        tokio::fs::write(&baz_file, "final content").await?;
3920        let bar_link = test_path.join("bar_link");
3921        let foo_link = test_path.join("foo_link");
3922        // Create chain: foo_link -> bar_link -> baz_file.txt
3923        tokio::fs::symlink(&baz_file, &bar_link).await?;
3924        tokio::fs::symlink(&bar_link, &foo_link).await?;
3925        // Create source directory with the symlink chain
3926        let src_dir = test_path.join("src_chain");
3927        tokio::fs::create_dir(&src_dir).await?;
3928        // Copy the chain into the source directory
3929        tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
3930        tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
3931        tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
3932        // Test with dereference - should copy 3 files with same content
3933        let summary = copy(
3934            &PROGRESS,
3935            &src_dir,
3936            &test_path.join("dst_with_deref"),
3937            &Settings {
3938                dereference: true, // <- important!
3939                fail_early: false,
3940                overwrite: false,
3941                overwrite_compare: filecmp::MetadataCmpSettings {
3942                    size: true,
3943                    mtime: true,
3944                    ..Default::default()
3945                },
3946                overwrite_filter: None,
3947                ignore_existing: false,
3948                chunk_size: 0,
3949                skip_specials: false,
3950                remote_copy_buffer_size: 0,
3951                filter: None,
3952                dry_run: None,
3953                delete: None,
3954            },
3955            &NO_PRESERVE_SETTINGS,
3956            false,
3957        )
3958        .await?;
3959        assert_eq!(summary.files_copied, 3); // foo, bar, baz all copied as files
3960        assert_eq!(summary.symlinks_created, 0); // dereference is set
3961        assert_eq!(summary.directories_created, 1);
3962        let dst_dir = test_path.join("dst_with_deref");
3963        // Verify all three are now regular files with the same content
3964        let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
3965        let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
3966        let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
3967        assert_eq!(foo_content, "final content");
3968        assert_eq!(bar_content, "final content");
3969        assert_eq!(baz_content, "final content");
3970        // Verify they are all regular files, not symlinks
3971        assert!(dst_dir.join("foo").is_file());
3972        assert!(dst_dir.join("bar").is_file());
3973        assert!(dst_dir.join("baz").is_file());
3974        assert!(!dst_dir.join("foo").is_symlink());
3975        assert!(!dst_dir.join("bar").is_symlink());
3976        assert!(!dst_dir.join("baz").is_symlink());
3977        Ok(())
3978    }
3979
3980    #[tokio::test]
3981    #[traced_test]
3982    async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
3983        let tmp_dir = testutils::create_temp_dir().await?;
3984        let test_path = tmp_dir.as_path();
3985        // Create a directory with specific permissions and content
3986        let target_dir = test_path.join("target_dir");
3987        tokio::fs::create_dir(&target_dir).await?;
3988        tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
3989        // Add some files to the directory
3990        tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
3991        tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
3992        tokio::fs::set_permissions(
3993            &target_dir.join("file1.txt"),
3994            std::fs::Permissions::from_mode(0o644),
3995        )
3996        .await?;
3997        tokio::fs::set_permissions(
3998            &target_dir.join("file2.txt"),
3999            std::fs::Permissions::from_mode(0o600),
4000        )
4001        .await?;
4002        // Create a symlink pointing to the directory
4003        let dir_symlink = test_path.join("dir_symlink");
4004        tokio::fs::symlink(&target_dir, &dir_symlink).await?;
4005        // Test copying the symlink with dereference - should copy as a directory
4006        let summary = copy(
4007            &PROGRESS,
4008            &dir_symlink,
4009            &test_path.join("copied_dir"),
4010            &Settings {
4011                dereference: true, // <- important!
4012                fail_early: false,
4013                overwrite: false,
4014                overwrite_compare: filecmp::MetadataCmpSettings {
4015                    size: true,
4016                    mtime: true,
4017                    ..Default::default()
4018                },
4019                overwrite_filter: None,
4020                ignore_existing: false,
4021                chunk_size: 0,
4022                skip_specials: false,
4023                remote_copy_buffer_size: 0,
4024                filter: None,
4025                dry_run: None,
4026                delete: None,
4027            },
4028            &DO_PRESERVE_SETTINGS,
4029            false,
4030        )
4031        .await?;
4032        assert_eq!(summary.files_copied, 2); // file1.txt, file2.txt
4033        assert_eq!(summary.symlinks_created, 0); // dereference is set
4034        assert_eq!(summary.directories_created, 1); // copied_dir
4035        let copied_dir = test_path.join("copied_dir");
4036        // Verify the directory and its contents were copied
4037        assert!(copied_dir.is_dir());
4038        assert!(!copied_dir.is_symlink()); // Should be a real directory, not a symlink
4039        // Verify files were copied with correct content
4040        let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
4041        let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
4042        assert_eq!(file1_content, "content1");
4043        assert_eq!(file2_content, "content2");
4044        // Verify permissions were preserved
4045        let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
4046        let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
4047        let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
4048        assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
4049        assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
4050        assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
4051        Ok(())
4052    }
4053
4054    #[tokio::test]
4055    #[traced_test]
4056    async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
4057        let tmp_dir = testutils::create_temp_dir().await?;
4058        let test_path = tmp_dir.as_path();
4059        // Create files with specific permissions
4060        let file1 = test_path.join("file1.txt");
4061        let file2 = test_path.join("file2.txt");
4062        tokio::fs::write(&file1, "content1").await?;
4063        tokio::fs::write(&file2, "content2").await?;
4064        tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
4065        tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
4066        // Create symlinks pointing to these files
4067        let symlink1 = test_path.join("symlink1");
4068        let symlink2 = test_path.join("symlink2");
4069        tokio::fs::symlink(&file1, &symlink1).await?;
4070        tokio::fs::symlink(&file2, &symlink2).await?;
4071        // Test copying symlinks with dereference and preserve
4072        let summary1 = copy(
4073            &PROGRESS,
4074            &symlink1,
4075            &test_path.join("copied_file1.txt"),
4076            &Settings {
4077                dereference: true, // <- important!
4078                fail_early: false,
4079                overwrite: false,
4080                overwrite_compare: filecmp::MetadataCmpSettings::default(),
4081                overwrite_filter: None,
4082                ignore_existing: false,
4083                chunk_size: 0,
4084                skip_specials: false,
4085                remote_copy_buffer_size: 0,
4086                filter: None,
4087                dry_run: None,
4088                delete: None,
4089            },
4090            &DO_PRESERVE_SETTINGS, // <- important!
4091            false,
4092        )
4093        .await?;
4094        let summary2 = copy(
4095            &PROGRESS,
4096            &symlink2,
4097            &test_path.join("copied_file2.txt"),
4098            &Settings {
4099                dereference: true,
4100                fail_early: false,
4101                overwrite: false,
4102                overwrite_compare: filecmp::MetadataCmpSettings::default(),
4103                overwrite_filter: None,
4104                ignore_existing: false,
4105                chunk_size: 0,
4106                skip_specials: false,
4107                remote_copy_buffer_size: 0,
4108                filter: None,
4109                dry_run: None,
4110                delete: None,
4111            },
4112            &DO_PRESERVE_SETTINGS,
4113            false,
4114        )
4115        .await?;
4116        assert_eq!(summary1.files_copied, 1);
4117        assert_eq!(summary1.symlinks_created, 0);
4118        assert_eq!(summary2.files_copied, 1);
4119        assert_eq!(summary2.symlinks_created, 0);
4120        let copied1 = test_path.join("copied_file1.txt");
4121        let copied2 = test_path.join("copied_file2.txt");
4122        // Verify files are regular files, not symlinks
4123        assert!(copied1.is_file());
4124        assert!(!copied1.is_symlink());
4125        assert!(copied2.is_file());
4126        assert!(!copied2.is_symlink());
4127        // Verify content was copied correctly
4128        let content1 = tokio::fs::read_to_string(&copied1).await?;
4129        let content2 = tokio::fs::read_to_string(&copied2).await?;
4130        assert_eq!(content1, "content1");
4131        assert_eq!(content2, "content2");
4132        // Verify permissions from the target files were preserved (not symlink permissions)
4133        let copied1_metadata = tokio::fs::metadata(&copied1).await?;
4134        let copied2_metadata = tokio::fs::metadata(&copied2).await?;
4135        assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
4136        assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
4137        Ok(())
4138    }
4139
4140    #[tokio::test]
4141    #[traced_test]
4142    async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
4143        let tmp_dir = testutils::setup_test_dir().await?;
4144        // symlink bar to bar-link
4145        tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
4146        // symlink bar-link to bar-link-link
4147        tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
4148        let summary = copy(
4149            &PROGRESS,
4150            &tmp_dir.join("foo"),
4151            &tmp_dir.join("bar"),
4152            &Settings {
4153                dereference: true, // <- important!
4154                fail_early: false,
4155                overwrite: false,
4156                overwrite_compare: filecmp::MetadataCmpSettings {
4157                    size: true,
4158                    mtime: true,
4159                    ..Default::default()
4160                },
4161                overwrite_filter: None,
4162                ignore_existing: false,
4163                chunk_size: 0,
4164                skip_specials: false,
4165                remote_copy_buffer_size: 0,
4166                filter: None,
4167                dry_run: None,
4168                delete: None,
4169            },
4170            &DO_PRESERVE_SETTINGS,
4171            false,
4172        )
4173        .await?;
4174        assert_eq!(summary.files_copied, 13); // 0.txt, 3x bar/(1.txt, 2.txt, 3.txt), baz/(4.txt, 5.txt, 6.txt)
4175        assert_eq!(summary.symlinks_created, 0); // dereference is set
4176        assert_eq!(summary.directories_created, 5);
4177        // check_dirs_identical doesn't handle dereference so let's do it manually
4178        tokio::process::Command::new("cp")
4179            .args(["-r", "-L"])
4180            .arg(tmp_dir.join("foo"))
4181            .arg(tmp_dir.join("bar-cp"))
4182            .output()
4183            .await?;
4184        testutils::check_dirs_identical(
4185            &tmp_dir.join("bar"),
4186            &tmp_dir.join("bar-cp"),
4187            testutils::FileEqualityCheck::Basic,
4188        )
4189        .await?;
4190        Ok(())
4191    }
4192
4193    /// Tests to verify error messages include root causes for debugging
4194    mod error_message_tests {
4195        use super::*;
4196
4197        /// Helper to extract full error message with chain
4198        fn get_full_error_message(error: &Error) -> String {
4199            format!("{:#}", error.source)
4200        }
4201
4202        #[tokio::test]
4203        #[traced_test]
4204        async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
4205            let tmp_dir = testutils::create_temp_dir().await?;
4206
4207            let result = copy(
4208                &PROGRESS,
4209                &tmp_dir.join("does_not_exist.txt"),
4210                &tmp_dir.join("dest.txt"),
4211                &Settings {
4212                    dereference: false,
4213                    fail_early: false,
4214                    overwrite: false,
4215                    overwrite_compare: Default::default(),
4216                    overwrite_filter: None,
4217                    ignore_existing: false,
4218                    chunk_size: 0,
4219                    skip_specials: false,
4220                    remote_copy_buffer_size: 0,
4221                    filter: None,
4222                    dry_run: None,
4223                    delete: None,
4224                },
4225                &NO_PRESERVE_SETTINGS,
4226                false,
4227            )
4228            .await;
4229
4230            assert!(result.is_err());
4231            let err_msg = get_full_error_message(&result.unwrap_err());
4232
4233            assert!(
4234                err_msg.to_lowercase().contains("no such file")
4235                    || err_msg.to_lowercase().contains("not found")
4236                    || err_msg.contains("ENOENT"),
4237                "Error message must include file not found text. Got: {}",
4238                err_msg
4239            );
4240            Ok(())
4241        }
4242
4243        #[tokio::test]
4244        #[traced_test]
4245        async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
4246            let tmp_dir = testutils::create_temp_dir().await?;
4247            let unreadable_dir = tmp_dir.join("unreadable_dir");
4248            tokio::fs::create_dir(&unreadable_dir).await?;
4249            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
4250                .await?;
4251
4252            let result = copy(
4253                &PROGRESS,
4254                &unreadable_dir,
4255                &tmp_dir.join("dest"),
4256                &Settings {
4257                    dereference: false,
4258                    fail_early: true,
4259                    overwrite: false,
4260                    overwrite_compare: Default::default(),
4261                    overwrite_filter: None,
4262                    ignore_existing: false,
4263                    chunk_size: 0,
4264                    skip_specials: false,
4265                    remote_copy_buffer_size: 0,
4266                    filter: None,
4267                    dry_run: None,
4268                    delete: None,
4269                },
4270                &NO_PRESERVE_SETTINGS,
4271                false,
4272            )
4273            .await;
4274
4275            assert!(result.is_err());
4276            let err_msg = get_full_error_message(&result.unwrap_err());
4277
4278            assert!(
4279                err_msg.to_lowercase().contains("permission")
4280                    || err_msg.contains("EACCES")
4281                    || err_msg.contains("denied"),
4282                "Error message must include permission-related text. Got: {}",
4283                err_msg
4284            );
4285
4286            // Clean up - restore permissions so cleanup can remove it
4287            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
4288                .await?;
4289            Ok(())
4290        }
4291
4292        #[tokio::test]
4293        #[traced_test]
4294        async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
4295        {
4296            let tmp_dir = testutils::setup_test_dir().await?;
4297            let test_path = tmp_dir.as_path();
4298            let readonly_parent = test_path.join("readonly_dest");
4299            tokio::fs::create_dir(&readonly_parent).await?;
4300            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
4301                .await?;
4302
4303            let result = copy(
4304                &PROGRESS,
4305                &test_path.join("foo"),
4306                &readonly_parent.join("copy"),
4307                &Settings {
4308                    dereference: false,
4309                    fail_early: true,
4310                    overwrite: false,
4311                    overwrite_compare: Default::default(),
4312                    overwrite_filter: None,
4313                    ignore_existing: false,
4314                    chunk_size: 0,
4315                    skip_specials: false,
4316                    remote_copy_buffer_size: 0,
4317                    filter: None,
4318                    dry_run: None,
4319                    delete: None,
4320                },
4321                &NO_PRESERVE_SETTINGS,
4322                false,
4323            )
4324            .await;
4325
4326            // restore permissions so cleanup succeeds even when copy fails
4327            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
4328                .await?;
4329
4330            assert!(result.is_err(), "copy into read-only parent should fail");
4331            let err_msg = get_full_error_message(&result.unwrap_err());
4332
4333            assert!(
4334                err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
4335                "Error message must include permission denied text. Got: {}",
4336                err_msg
4337            );
4338            Ok(())
4339        }
4340
4341        // Regression guard for the reused-directory lockdown: WITHOUT --require-toctou-safe the
4342        // reuse branch leaves a pre-existing destination directory exactly as-is, so copying into a
4343        // read-only reused directory still fails EACCES. The lockdown that makes such a directory
4344        // writable for the copy is strict-mode only (exercised in common/tests/strict_resolution.rs);
4345        // this pins that the default path is unchanged.
4346        #[tokio::test]
4347        #[traced_test]
4348        async fn default_reused_readonly_dir_still_fails_eacces() -> Result<(), anyhow::Error> {
4349            let tmp_dir = testutils::setup_test_dir().await?;
4350            let test_path = tmp_dir.as_path();
4351            // reused destination directory: exists, owned by us, read-only
4352            let dst = test_path.join("dst");
4353            tokio::fs::create_dir(&dst).await?;
4354            tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o500)).await?;
4355            let result = copy(
4356                &PROGRESS,
4357                &test_path.join("foo"),
4358                &dst,
4359                &Settings {
4360                    dereference: false,
4361                    fail_early: true,
4362                    overwrite: true,
4363                    overwrite_compare: Default::default(),
4364                    overwrite_filter: None,
4365                    ignore_existing: false,
4366                    chunk_size: 0,
4367                    skip_specials: false,
4368                    remote_copy_buffer_size: 0,
4369                    filter: None,
4370                    dry_run: None,
4371                    delete: None,
4372                },
4373                &NO_PRESERVE_SETTINGS,
4374                false,
4375            )
4376            .await;
4377            // restore permissions so the tempdir cleanup can remove the directory
4378            tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o700)).await?;
4379            assert!(
4380                result.is_err(),
4381                "copy into a read-only reused directory must fail without strict mode"
4382            );
4383            let err_msg = get_full_error_message(&result.unwrap_err());
4384            assert!(
4385                err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
4386                "error must be a permission error. Got: {}",
4387                err_msg
4388            );
4389            Ok(())
4390        }
4391    }
4392
4393    mod empty_dir_cleanup_tests {
4394        use super::*;
4395        use crate::filter::FilterSettings;
4396        use std::path::Path;
4397        #[test]
4398        fn test_check_empty_dir_cleanup_no_filter() {
4399            // when no filter, always keep
4400            assert_eq!(
4401                check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
4402                EmptyDirAction::Keep
4403            );
4404        }
4405        #[test]
4406        fn test_check_empty_dir_cleanup_something_copied() {
4407            // when content was copied, keep
4408            let mut filter = crate::filter::FilterSettings::new();
4409            filter.add_include("*.txt").unwrap();
4410            assert_eq!(
4411                check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
4412                EmptyDirAction::Keep
4413            );
4414        }
4415        #[test]
4416        fn test_check_empty_dir_cleanup_not_created() {
4417            // when we didn't create the directory, keep
4418            let mut filter = FilterSettings::new();
4419            filter.add_include("*.txt").unwrap();
4420            assert_eq!(
4421                check_empty_dir_cleanup(
4422                    Some(&filter),
4423                    false,
4424                    false,
4425                    Path::new("any"),
4426                    false,
4427                    false
4428                ),
4429                EmptyDirAction::Keep
4430            );
4431        }
4432        #[test]
4433        fn test_check_empty_dir_cleanup_directly_matched() {
4434            // when directory directly matches include pattern, keep
4435            let mut filter = FilterSettings::new();
4436            filter.add_include("target/").unwrap();
4437            assert_eq!(
4438                check_empty_dir_cleanup(
4439                    Some(&filter),
4440                    true,
4441                    false,
4442                    Path::new("target"),
4443                    false,
4444                    false
4445                ),
4446                EmptyDirAction::Keep
4447            );
4448        }
4449        #[test]
4450        fn test_check_empty_dir_cleanup_traversed_only() {
4451            // when directory was only traversed, remove
4452            let mut filter = FilterSettings::new();
4453            filter.add_include("*.txt").unwrap();
4454            assert_eq!(
4455                check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
4456                EmptyDirAction::Remove
4457            );
4458        }
4459        #[test]
4460        fn test_check_empty_dir_cleanup_dry_run() {
4461            // in dry-run mode, skip instead of remove
4462            let mut filter = FilterSettings::new();
4463            filter.add_include("*.txt").unwrap();
4464            assert_eq!(
4465                check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
4466                EmptyDirAction::DryRunSkip
4467            );
4468        }
4469        #[test]
4470        fn test_check_empty_dir_cleanup_root_always_kept() {
4471            // root directory is never removed, even with filter and nothing copied
4472            let mut filter = FilterSettings::new();
4473            filter.add_include("*.txt").unwrap();
4474            assert_eq!(
4475                check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
4476                EmptyDirAction::Keep
4477            );
4478        }
4479        #[test]
4480        fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
4481            // root directory is kept even in dry-run mode
4482            let mut filter = FilterSettings::new();
4483            filter.add_include("*.txt").unwrap();
4484            assert_eq!(
4485                check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
4486                EmptyDirAction::Keep
4487            );
4488        }
4489    }
4490
4491    /// Verify that directory metadata is applied even when child operations fail.
4492    /// This is a regression test for a bug where directory permissions were not preserved
4493    /// when copying with fail_early=false and some children failed to copy.
4494    #[tokio::test]
4495    #[traced_test]
4496    async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
4497        let tmp_dir = testutils::create_temp_dir().await?;
4498        let test_path = tmp_dir.as_path();
4499        // create source directory with specific permissions
4500        let src_dir = test_path.join("src");
4501        tokio::fs::create_dir(&src_dir).await?;
4502        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
4503        // create a readable file and an unreadable file inside
4504        let readable_file = src_dir.join("readable.txt");
4505        tokio::fs::write(&readable_file, "content").await?;
4506        let unreadable_file = src_dir.join("unreadable.txt");
4507        tokio::fs::write(&unreadable_file, "secret").await?;
4508        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
4509            .await?;
4510        let dst_dir = test_path.join("dst");
4511        // copy with fail_early=false and preserve=all
4512        let result = copy(
4513            &PROGRESS,
4514            &src_dir,
4515            &dst_dir,
4516            &Settings {
4517                dereference: false,
4518                fail_early: false,
4519                overwrite: false,
4520                overwrite_compare: Default::default(),
4521                overwrite_filter: None,
4522                ignore_existing: false,
4523                chunk_size: 0,
4524                skip_specials: false,
4525                remote_copy_buffer_size: 0,
4526                filter: None,
4527                dry_run: None,
4528                delete: None,
4529            },
4530            &DO_PRESERVE_SETTINGS,
4531            false,
4532        )
4533        .await;
4534        // restore permissions so cleanup can succeed
4535        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
4536            .await?;
4537        // verify the operation returned an error (unreadable file should fail)
4538        assert!(result.is_err(), "copy should fail due to unreadable file");
4539        let error = result.unwrap_err();
4540        // verify some files were copied (the readable one)
4541        assert_eq!(error.summary.files_copied, 1);
4542        assert_eq!(error.summary.directories_created, 1);
4543        // verify the destination directory exists and has the correct permissions
4544        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
4545        assert!(dst_metadata.is_dir());
4546        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
4547        assert_eq!(
4548            actual_mode, 0o750,
4549            "directory should have preserved source permissions (0o750), got {:o}",
4550            actual_mode
4551        );
4552        Ok(())
4553    }
4554
4555    /// A child copy failure inside a filter-traversal-only directory must surface as an overall
4556    /// error even though the now-empty directory is pruned (regression: the empty-dir cleanup path
4557    /// returned Ok and swallowed the collected child error, so a failed copy looked successful).
4558    #[tokio::test]
4559    #[traced_test]
4560    async fn pruned_empty_traversal_dir_still_surfaces_child_error() -> Result<(), anyhow::Error> {
4561        let tmp_dir = testutils::create_temp_dir().await?;
4562        let test_path = tmp_dir.as_path();
4563        let src_dir = test_path.join("src");
4564        let sub = src_dir.join("sub");
4565        tokio::fs::create_dir_all(&sub).await?;
4566        // the only entry under `sub` is unreadable, so its copy fails and nothing lands in `sub`,
4567        // leaving it an empty traversal-only directory that the filter cleanup prunes.
4568        let unreadable = sub.join("file.txt");
4569        tokio::fs::write(&unreadable, "secret").await?;
4570        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
4571        let dst_dir = test_path.join("dst");
4572        let mut filter = crate::filter::FilterSettings::new();
4573        filter.add_include("*.txt").unwrap();
4574        let result = copy(
4575            &PROGRESS,
4576            &src_dir,
4577            &dst_dir,
4578            &Settings {
4579                dereference: false,
4580                fail_early: false,
4581                overwrite: false,
4582                overwrite_compare: Default::default(),
4583                overwrite_filter: None,
4584                ignore_existing: false,
4585                chunk_size: 0,
4586                skip_specials: false,
4587                remote_copy_buffer_size: 0,
4588                filter: Some(filter),
4589                dry_run: None,
4590                delete: None,
4591            },
4592            &DO_PRESERVE_SETTINGS,
4593            false,
4594        )
4595        .await;
4596        // restore permissions so the temp dir can be cleaned up
4597        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).await?;
4598        assert!(
4599            result.is_err(),
4600            "the failed child copy must surface even though the empty traversal dir was pruned"
4601        );
4602        // the empty traversal directory itself was still cleaned up (count adjustment preserved).
4603        assert!(
4604            !dst_dir.join("sub").exists(),
4605            "empty traversal directory should have been removed"
4606        );
4607        Ok(())
4608    }
4609
4610    /// Verify that fail-early does not apply parent directory metadata after a child fails.
4611    #[tokio::test]
4612    #[traced_test]
4613    async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error()
4614    -> Result<(), anyhow::Error> {
4615        let tmp_dir = testutils::create_temp_dir().await?;
4616        let test_path = tmp_dir.as_path();
4617        let src_dir = test_path.join("src");
4618        tokio::fs::create_dir(&src_dir).await?;
4619        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
4620        let unreadable_file = src_dir.join("unreadable.txt");
4621        tokio::fs::write(&unreadable_file, "secret").await?;
4622        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
4623            .await?;
4624        let fixed_secs = 946684800;
4625        let fixed_nsec = 123_456_789;
4626        let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
4627        nix::sys::stat::utimensat(
4628            nix::fcntl::AT_FDCWD,
4629            &src_dir,
4630            &fixed_time,
4631            &fixed_time,
4632            nix::sys::stat::UtimensatFlags::NoFollowSymlink,
4633        )?;
4634        let src_metadata = tokio::fs::metadata(&src_dir).await?;
4635        let dst_dir = test_path.join("dst");
4636        let result = copy(
4637            &PROGRESS,
4638            &src_dir,
4639            &dst_dir,
4640            &Settings {
4641                dereference: false,
4642                fail_early: true,
4643                overwrite: false,
4644                overwrite_compare: Default::default(),
4645                overwrite_filter: None,
4646                ignore_existing: false,
4647                chunk_size: 0,
4648                skip_specials: false,
4649                remote_copy_buffer_size: 0,
4650                filter: None,
4651                dry_run: None,
4652                delete: None,
4653            },
4654            &DO_PRESERVE_SETTINGS,
4655            false,
4656        )
4657        .await;
4658        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
4659            .await?;
4660        assert!(result.is_err(), "copy should fail due to unreadable file");
4661        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
4662        assert!(dst_metadata.is_dir());
4663        assert_ne!(
4664            (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
4665            (src_metadata.mtime(), src_metadata.mtime_nsec()),
4666            "fail-early should return before applying preserved directory timestamps"
4667        );
4668        Ok(())
4669    }
4670    mod filter_tests {
4671        use super::*;
4672        use crate::filter::FilterSettings;
4673        /// Test that path-based patterns (with /) work correctly with nested paths.
4674        /// This test exposes the bug where only entry_name is passed to the filter
4675        /// instead of the relative path.
4676        #[tokio::test]
4677        #[traced_test]
4678        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
4679            let tmp_dir = testutils::setup_test_dir().await?;
4680            let test_path = tmp_dir.as_path();
4681            // test directory structure from setup_test_dir:
4682            // foo/
4683            //   0.txt
4684            //   bar/
4685            //     1.txt
4686            //     2.txt
4687            //   baz/
4688            //     3.txt -> ../0.txt (symlink)
4689            //     4.txt
4690            //     5 -> ../bar (symlink)
4691            // create filter that should match bar/*.txt (files in bar directory)
4692            let mut filter = FilterSettings::new();
4693            filter.add_include("bar/*.txt").unwrap();
4694            let summary = copy(
4695                &PROGRESS,
4696                &test_path.join("foo"),
4697                &test_path.join("dst"),
4698                &Settings {
4699                    dereference: false,
4700                    fail_early: false,
4701                    overwrite: false,
4702                    overwrite_compare: Default::default(),
4703                    overwrite_filter: None,
4704                    ignore_existing: false,
4705                    chunk_size: 0,
4706                    skip_specials: false,
4707                    remote_copy_buffer_size: 0,
4708                    filter: Some(filter),
4709                    dry_run: None,
4710                    delete: None,
4711                },
4712                &NO_PRESERVE_SETTINGS,
4713                false,
4714            )
4715            .await?;
4716            // should only copy files matching bar/*.txt pattern
4717            // bar/1.txt, bar/2.txt, and bar/3.txt should be copied
4718            assert_eq!(
4719                summary.files_copied, 3,
4720                "should copy 3 files matching bar/*.txt"
4721            );
4722            // verify the right files exist
4723            assert!(
4724                test_path.join("dst/bar/1.txt").exists(),
4725                "bar/1.txt should be copied"
4726            );
4727            assert!(
4728                test_path.join("dst/bar/2.txt").exists(),
4729                "bar/2.txt should be copied"
4730            );
4731            assert!(
4732                test_path.join("dst/bar/3.txt").exists(),
4733                "bar/3.txt should be copied"
4734            );
4735            // verify files outside the pattern don't exist
4736            assert!(
4737                !test_path.join("dst/0.txt").exists(),
4738                "0.txt should not be copied"
4739            );
4740            Ok(())
4741        }
4742        /// Test that anchored patterns (starting with /) match only at root.
4743        #[tokio::test]
4744        #[traced_test]
4745        async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
4746            let tmp_dir = testutils::setup_test_dir().await?;
4747            let test_path = tmp_dir.as_path();
4748            // create filter that should match /bar/** (bar directory and all its contents)
4749            let mut filter = FilterSettings::new();
4750            filter.add_include("/bar/**").unwrap();
4751            let summary = copy(
4752                &PROGRESS,
4753                &test_path.join("foo"),
4754                &test_path.join("dst"),
4755                &Settings {
4756                    dereference: false,
4757                    fail_early: false,
4758                    overwrite: false,
4759                    overwrite_compare: Default::default(),
4760                    overwrite_filter: None,
4761                    ignore_existing: false,
4762                    chunk_size: 0,
4763                    skip_specials: false,
4764                    remote_copy_buffer_size: 0,
4765                    filter: Some(filter),
4766                    dry_run: None,
4767                    delete: None,
4768                },
4769                &NO_PRESERVE_SETTINGS,
4770                false,
4771            )
4772            .await?;
4773            // should only copy bar directory and its contents
4774            assert!(
4775                test_path.join("dst/bar").exists(),
4776                "bar directory should be copied"
4777            );
4778            assert!(
4779                !test_path.join("dst/baz").exists(),
4780                "baz directory should not be copied"
4781            );
4782            assert!(
4783                !test_path.join("dst/0.txt").exists(),
4784                "0.txt should not be copied"
4785            );
4786            // verify summary counts
4787            assert_eq!(
4788                summary.files_copied, 3,
4789                "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
4790            );
4791            assert_eq!(
4792                summary.directories_created, 2,
4793                "should create 2 directories (root dst + bar)"
4794            );
4795            // skipped: 0.txt (file) and baz (directory) - baz contents not counted since dir is skipped
4796            assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
4797            assert_eq!(
4798                summary.directories_skipped, 1,
4799                "should skip 1 directory (baz)"
4800            );
4801            Ok(())
4802        }
4803        /// Test that double-star patterns (**) match across directories.
4804        #[tokio::test]
4805        #[traced_test]
4806        async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
4807            let tmp_dir = testutils::setup_test_dir().await?;
4808            let test_path = tmp_dir.as_path();
4809            // create filter that should match all .txt files at any depth
4810            let mut filter = FilterSettings::new();
4811            filter.add_include("**/*.txt").unwrap();
4812            let summary = copy(
4813                &PROGRESS,
4814                &test_path.join("foo"),
4815                &test_path.join("dst"),
4816                &Settings {
4817                    dereference: false,
4818                    fail_early: false,
4819                    overwrite: false,
4820                    overwrite_compare: Default::default(),
4821                    overwrite_filter: None,
4822                    ignore_existing: false,
4823                    chunk_size: 0,
4824                    skip_specials: false,
4825                    remote_copy_buffer_size: 0,
4826                    filter: Some(filter),
4827                    dry_run: None,
4828                    delete: None,
4829                },
4830                &NO_PRESERVE_SETTINGS,
4831                false,
4832            )
4833            .await?;
4834            // should copy all .txt files: 0.txt, bar/1.txt, bar/2.txt, bar/3.txt, baz/4.txt
4835            assert_eq!(
4836                summary.files_copied, 5,
4837                "should copy all 5 .txt files with **/*.txt pattern"
4838            );
4839            Ok(())
4840        }
4841        /// Test that filters are applied to top-level file arguments.
4842        #[tokio::test]
4843        #[traced_test]
4844        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
4845            let tmp_dir = testutils::setup_test_dir().await?;
4846            let test_path = tmp_dir.as_path();
4847            // create filter that excludes .txt files
4848            let mut filter = FilterSettings::new();
4849            filter.add_exclude("*.txt").unwrap();
4850            let result = copy(
4851                &PROGRESS,
4852                &test_path.join("foo/0.txt"), // single file source
4853                &test_path.join("dst.txt"),
4854                &Settings {
4855                    dereference: false,
4856                    fail_early: false,
4857                    overwrite: false,
4858                    overwrite_compare: Default::default(),
4859                    overwrite_filter: None,
4860                    ignore_existing: false,
4861                    chunk_size: 0,
4862                    skip_specials: false,
4863                    remote_copy_buffer_size: 0,
4864                    filter: Some(filter),
4865                    dry_run: None,
4866                    delete: None,
4867                },
4868                &NO_PRESERVE_SETTINGS,
4869                false,
4870            )
4871            .await?;
4872            // the file should NOT be copied because it matches the exclude pattern
4873            assert_eq!(
4874                result.files_copied, 0,
4875                "file matching exclude pattern should not be copied"
4876            );
4877            assert!(
4878                !test_path.join("dst.txt").exists(),
4879                "excluded file should not exist at destination"
4880            );
4881            Ok(())
4882        }
4883        /// Test that filters apply to root directories with simple exclude patterns.
4884        #[tokio::test]
4885        #[traced_test]
4886        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
4887            let test_path = testutils::create_temp_dir().await?;
4888            // create a directory that should be excluded
4889            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
4890            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
4891            // create filter that excludes *_dir/ directories
4892            let mut filter = FilterSettings::new();
4893            filter.add_exclude("*_dir/").unwrap();
4894            let result = copy(
4895                &PROGRESS,
4896                &test_path.join("excluded_dir"),
4897                &test_path.join("dst"),
4898                &Settings {
4899                    dereference: false,
4900                    fail_early: false,
4901                    overwrite: false,
4902                    overwrite_compare: Default::default(),
4903                    overwrite_filter: None,
4904                    ignore_existing: false,
4905                    chunk_size: 0,
4906                    skip_specials: false,
4907                    remote_copy_buffer_size: 0,
4908                    filter: Some(filter),
4909                    dry_run: None,
4910                    delete: None,
4911                },
4912                &NO_PRESERVE_SETTINGS,
4913                false,
4914            )
4915            .await?;
4916            // directory should NOT be copied because it matches exclude pattern
4917            assert_eq!(
4918                result.directories_created, 0,
4919                "root directory matching exclude should not be created"
4920            );
4921            assert!(
4922                !test_path.join("dst").exists(),
4923                "excluded root directory should not exist at destination"
4924            );
4925            Ok(())
4926        }
4927
4928        /// A root file admitted after the cheap filter cannot materialize an excluded replacement
4929        /// directory; the exact authoritative entry must make the final filter decision.
4930        #[tokio::test]
4931        #[traced_test]
4932        async fn root_file_swap_to_excluded_directory_is_rechecked_authoritatively()
4933        -> Result<(), anyhow::Error> {
4934            let root = testutils::create_temp_dir().await?;
4935            let src = root.join("source");
4936            let dst = root.join("destination");
4937            tokio::fs::write(&src, "INITIAL-FILE").await?;
4938
4939            let mut filter = FilterSettings::new();
4940            filter.add_exclude("source/")?;
4941            assert!(matches!(
4942                filter.should_include_root_item(std::path::Path::new("source"), false),
4943                crate::filter::FilterResult::Included
4944            ));
4945            assert!(!matches!(
4946                filter.should_include_root_item(std::path::Path::new("source"), true),
4947                crate::filter::FilterResult::Included
4948            ));
4949
4950            let admission = testutils::AdmissionLimit::new().await;
4951            admission.set_files_in_flight(1);
4952            let src_parent = Arc::new(
4953                Dir::open_parent_dir(&root, congestion::Side::Source)
4954                    .await?
4955                    .into_tree(),
4956            );
4957            let dst_parent = Arc::new(
4958                Dir::open_parent_dir(&root, congestion::Side::Destination)
4959                    .await?
4960                    .into_tree(),
4961            );
4962
4963            // the cheap root check above saw a file. replace it before the admitted final check.
4964            tokio::fs::remove_file(&src).await?;
4965            tokio::fs::create_dir(&src).await?;
4966            tokio::fs::write(src.join("replacement.txt"), "EXCLUDED").await?;
4967            let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
4968            let entry = crate::walk::classify_admitted_entry(
4969                &src_parent,
4970                std::ffi::OsStr::new("source"),
4971                permit,
4972            )
4973            .await?;
4974            assert_eq!(entry.kind(), EntryKind::Dir);
4975
4976            let mut settings = settings_with_delete(None);
4977            settings.filter = Some(filter);
4978            let summary = run_with_open_file_cleanup(
4979                &admission,
4980                run_copy_root(
4981                    &PROGRESS,
4982                    &src_parent,
4983                    Some(dst_parent),
4984                    std::ffi::OsStr::new("source"),
4985                    &src,
4986                    &dst,
4987                    std::path::Path::new(""),
4988                    &settings,
4989                    &NO_PRESERVE_SETTINGS,
4990                    false,
4991                    DeleteScanAnchor::new(&dst, std::path::Path::new("")),
4992                    CopyEntryAdmission::Admitted(entry),
4993                ),
4994            )
4995            .await??;
4996
4997            assert_eq!(summary.directories_skipped, 1);
4998            assert_eq!(summary.directories_created, 0);
4999            assert!(
5000                !dst.exists(),
5001                "an excluded replacement directory must not be materialized"
5002            );
5003            Ok(())
5004        }
5005
5006        /// A nested reliable file hint may pass the cheap filter, but a replacement directory must
5007        /// be rechecked under admission and excluded before the exact entry reaches dispatch.
5008        #[tokio::test(flavor = "current_thread")]
5009        #[traced_test]
5010        async fn nested_file_swap_to_excluded_directory_is_rechecked_authoritatively()
5011        -> Result<(), anyhow::Error> {
5012            let root = testutils::create_temp_dir().await?;
5013            let src = root.join("source");
5014            let dst = root.join("destination");
5015            let node = src.join("node");
5016            tokio::fs::create_dir(&src).await?;
5017            tokio::fs::write(&node, "INITIAL-FILE").await?;
5018
5019            let mut filter = FilterSettings::new();
5020            filter.add_exclude("node/")?;
5021            assert!(matches!(
5022                filter.should_include(std::path::Path::new("node"), false),
5023                crate::filter::FilterResult::Included
5024            ));
5025            assert!(!matches!(
5026                filter.should_include(std::path::Path::new("node"), true),
5027                crate::filter::FilterResult::Included
5028            ));
5029            let mut settings = settings_with_delete(None);
5030            settings.filter = Some(filter);
5031
5032            // inject the reliable file hint captured before this replacement, so the boundary is
5033            // deterministic without depending on a scheduler race or filesystem-specific d_type.
5034            tokio::fs::remove_file(&node).await?;
5035            tokio::fs::create_dir(&node).await?;
5036            tokio::fs::write(node.join("replacement.txt"), "EXCLUDED").await?;
5037            tokio::fs::create_dir(&dst).await?;
5038            let admission = testutils::AdmissionLimit::new().await;
5039            admission.set_files_in_flight(1);
5040            let src_dir =
5041                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
5042            let dst_dir =
5043                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
5044            let visitor = Arc::new(CopyVisitor {
5045                prog_track: &PROGRESS,
5046                dst_root: dst.clone(),
5047                filter_base: PathBuf::new(),
5048                delete_scan_anchor: DeleteScanAnchor::new(&dst, std::path::Path::new("")),
5049                settings,
5050                preserve: *NO_PRESERVE_SETTINGS,
5051                dst_parent: None,
5052                root_is_fresh: false,
5053            });
5054            let root_cx = EntryCx {
5055                parent: Arc::clone(&src_dir),
5056                name: std::ffi::OsString::from("source"),
5057                rel_path: PathBuf::new(),
5058                filter_path: PathBuf::new(),
5059                real_path: src.clone(),
5060                dry_run: false,
5061                prog_track: &PROGRESS,
5062            };
5063            let child_ctx = CopyDirContext {
5064                dst_dir: Some(dst_dir),
5065                is_fresh: true,
5066            };
5067            let (summary, processed) = run_with_open_file_cleanup(
5068                &admission,
5069                crate::walk_driver::scope_tasks(crate::walk_driver::walk_dir_entries(
5070                    Arc::clone(&visitor),
5071                    src_dir,
5072                    &root_cx,
5073                    &child_ctx,
5074                    vec![(std::ffi::OsString::from("node"), Some(EntryKind::File))],
5075                )),
5076            )
5077            .await?
5078            .map_err(|error| error.source)?;
5079
5080            assert_eq!(summary.directories_skipped, 1);
5081            assert!(processed.names().is_empty());
5082            assert!(
5083                !dst.join("node").exists(),
5084                "an excluded nested replacement directory must not be materialized"
5085            );
5086            Ok(())
5087        }
5088
5089        /// A stale directory hint cannot hide an included source file from `--delete`'s keep-set.
5090        #[tokio::test(flavor = "current_thread")]
5091        #[traced_test]
5092        async fn delete_rechecks_a_directory_hint_before_pruning_an_included_file()
5093        -> Result<(), anyhow::Error> {
5094            let root = testutils::create_temp_dir().await?;
5095            let src = root.join("source");
5096            let dst = root.join("destination");
5097            tokio::fs::create_dir(&src).await?;
5098            tokio::fs::create_dir(&dst).await?;
5099            tokio::fs::write(src.join("node"), "CURRENT-SOURCE-CONTENT").await?;
5100            tokio::fs::write(dst.join("node"), "OLD").await?;
5101
5102            let mut filter = FilterSettings::new();
5103            filter.add_exclude("node/")?;
5104            assert!(!matches!(
5105                filter.should_include(std::path::Path::new("node"), true),
5106                crate::filter::FilterResult::Included
5107            ));
5108            assert!(matches!(
5109                filter.should_include(std::path::Path::new("node"), false),
5110                crate::filter::FilterResult::Included
5111            ));
5112            let mut settings = settings_with_delete(delete_on());
5113            settings.filter = Some(filter);
5114
5115            let admission = testutils::AdmissionLimit::new().await;
5116            admission.set_files_in_flight(1);
5117            let src_dir =
5118                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
5119            let dst_dir =
5120                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
5121            let visitor = Arc::new(CopyVisitor {
5122                prog_track: &PROGRESS,
5123                dst_root: dst.clone(),
5124                filter_base: PathBuf::new(),
5125                delete_scan_anchor: DeleteScanAnchor::new(&dst, std::path::Path::new("")),
5126                settings,
5127                preserve: *NO_PRESERVE_SETTINGS,
5128                dst_parent: None,
5129                root_is_fresh: false,
5130            });
5131            let root_cx = EntryCx {
5132                parent: Arc::clone(&src_dir),
5133                name: std::ffi::OsString::from("source"),
5134                rel_path: PathBuf::new(),
5135                filter_path: PathBuf::new(),
5136                real_path: src.clone(),
5137                dry_run: false,
5138                prog_track: &PROGRESS,
5139            };
5140            let child_ctx = CopyDirContext {
5141                dst_dir: Some(Arc::clone(&dst_dir)),
5142                is_fresh: false,
5143            };
5144
5145            // inject a stale directory hint for the real source file, then run the same post-order
5146            // prune that consumes the driver's processed-child keep-set.
5147            let (summary, processed) = run_with_open_file_cleanup(&admission, async {
5148                let (mut summary, processed) =
5149                    crate::walk_driver::scope_tasks(crate::walk_driver::walk_dir_entries(
5150                        Arc::clone(&visitor),
5151                        src_dir,
5152                        &root_cx,
5153                        &child_ctx,
5154                        vec![(std::ffi::OsString::from("node"), Some(EntryKind::Dir))],
5155                    ))
5156                    .await
5157                    .map_err(|error| error.source)?;
5158                let mut child_error = None;
5159                visitor
5160                    .prune_finished_dir(
5161                        &mut summary,
5162                        &mut child_error,
5163                        &processed,
5164                        &Some(dst_dir),
5165                        &dst,
5166                        std::path::Path::new(""),
5167                    )
5168                    .await
5169                    .map_err(|error| error.source)?;
5170                assert!(child_error.is_none());
5171                Ok::<_, anyhow::Error>((summary, processed))
5172            })
5173            .await??;
5174
5175            assert_eq!(processed.names(), &[std::ffi::OsString::from("node")]);
5176            assert_eq!(summary.files_copied, 1);
5177            assert_eq!(summary.rm_summary.files_removed, 1);
5178            assert_eq!(
5179                tokio::fs::read_to_string(dst.join("node")).await?,
5180                "CURRENT-SOURCE-CONTENT"
5181            );
5182            Ok(())
5183        }
5184
5185        /// A dry-run preview is authoritative even without `--delete`: a stale directory hint must
5186        /// not suppress the included regular file that now occupies the name.
5187        #[tokio::test(flavor = "current_thread")]
5188        #[traced_test]
5189        async fn dry_run_rechecks_a_directory_hint_before_previewing_an_included_file()
5190        -> Result<(), anyhow::Error> {
5191            let root = testutils::create_temp_dir().await?;
5192            let src = root.join("source");
5193            let dst = root.join("destination");
5194            tokio::fs::create_dir(&src).await?;
5195            tokio::fs::write(src.join("node"), "CURRENT-SOURCE-CONTENT").await?;
5196            let mut filter = FilterSettings::new();
5197            filter.add_exclude("node/")?;
5198            assert!(!matches!(
5199                filter.should_include(std::path::Path::new("node"), true),
5200                crate::filter::FilterResult::Included
5201            ));
5202            assert!(matches!(
5203                filter.should_include(std::path::Path::new("node"), false),
5204                crate::filter::FilterResult::Included
5205            ));
5206            let mut settings = settings_with_delete(None);
5207            settings.filter = Some(filter);
5208            settings.dry_run = Some(crate::config::DryRunMode::Brief);
5209            let admission = testutils::AdmissionLimit::new().await;
5210            admission.set_files_in_flight(1);
5211            let src_dir =
5212                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
5213            let visitor = Arc::new(CopyVisitor {
5214                prog_track: &PROGRESS,
5215                dst_root: dst.clone(),
5216                filter_base: PathBuf::new(),
5217                delete_scan_anchor: DeleteScanAnchor::new(&dst, std::path::Path::new("")),
5218                settings,
5219                preserve: *NO_PRESERVE_SETTINGS,
5220                dst_parent: None,
5221                root_is_fresh: false,
5222            });
5223            let root_cx = EntryCx {
5224                parent: Arc::clone(&src_dir),
5225                name: std::ffi::OsString::from("source"),
5226                rel_path: PathBuf::new(),
5227                filter_path: PathBuf::new(),
5228                real_path: src.clone(),
5229                dry_run: true,
5230                prog_track: &PROGRESS,
5231            };
5232            let (summary, processed) = run_with_open_file_cleanup(
5233                &admission,
5234                crate::walk_driver::scope_tasks(crate::walk_driver::walk_dir_entries(
5235                    visitor,
5236                    src_dir,
5237                    &root_cx,
5238                    &CopyDirContext {
5239                        dst_dir: None,
5240                        is_fresh: false,
5241                    },
5242                    vec![(std::ffi::OsString::from("node"), Some(EntryKind::Dir))],
5243                )),
5244            )
5245            .await??;
5246            assert_eq!(processed.names(), &[std::ffi::OsString::from("node")]);
5247            assert_eq!(summary.files_copied, 1);
5248            assert_eq!(summary.directories_skipped, 0);
5249            Ok(())
5250        }
5251
5252        /// Test that filters apply to root symlinks with simple exclude patterns.
5253        #[tokio::test]
5254        #[traced_test]
5255        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
5256            let test_path = testutils::create_temp_dir().await?;
5257            // create a target file and a symlink to it
5258            tokio::fs::write(test_path.join("target.txt"), "content").await?;
5259            tokio::fs::symlink(
5260                test_path.join("target.txt"),
5261                test_path.join("excluded_link"),
5262            )
5263            .await?;
5264            // create filter that excludes *_link
5265            let mut filter = FilterSettings::new();
5266            filter.add_exclude("*_link").unwrap();
5267            let result = copy(
5268                &PROGRESS,
5269                &test_path.join("excluded_link"),
5270                &test_path.join("dst"),
5271                &Settings {
5272                    dereference: false,
5273                    fail_early: false,
5274                    overwrite: false,
5275                    overwrite_compare: Default::default(),
5276                    overwrite_filter: None,
5277                    ignore_existing: false,
5278                    chunk_size: 0,
5279                    skip_specials: false,
5280                    remote_copy_buffer_size: 0,
5281                    filter: Some(filter),
5282                    dry_run: None,
5283                    delete: None,
5284                },
5285                &NO_PRESERVE_SETTINGS,
5286                false,
5287            )
5288            .await?;
5289            // symlink should NOT be copied because it matches exclude pattern
5290            assert_eq!(
5291                result.symlinks_created, 0,
5292                "root symlink matching exclude should not be created"
5293            );
5294            assert!(
5295                !test_path.join("dst").exists(),
5296                "excluded root symlink should not exist at destination"
5297            );
5298            Ok(())
5299        }
5300        /// Test combined include and exclude patterns (exclude takes precedence).
5301        #[tokio::test]
5302        #[traced_test]
5303        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
5304            let tmp_dir = testutils::setup_test_dir().await?;
5305            let test_path = tmp_dir.as_path();
5306            // test structure from setup_test_dir:
5307            // foo/
5308            //   0.txt
5309            //   bar/ (1.txt, 2.txt, 3.txt)
5310            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
5311            // include all .txt files, but exclude bar/2.txt specifically
5312            let mut filter = FilterSettings::new();
5313            filter.add_include("**/*.txt").unwrap();
5314            filter.add_exclude("bar/2.txt").unwrap();
5315            let summary = copy(
5316                &PROGRESS,
5317                &test_path.join("foo"),
5318                &test_path.join("dst"),
5319                &Settings {
5320                    dereference: false,
5321                    fail_early: false,
5322                    overwrite: false,
5323                    overwrite_compare: Default::default(),
5324                    overwrite_filter: None,
5325                    ignore_existing: false,
5326                    chunk_size: 0,
5327                    skip_specials: false,
5328                    remote_copy_buffer_size: 0,
5329                    filter: Some(filter),
5330                    dry_run: None,
5331                    delete: None,
5332                },
5333                &NO_PRESERVE_SETTINGS,
5334                false,
5335            )
5336            .await?;
5337            // should copy: 0.txt, bar/1.txt, bar/3.txt, baz/4.txt = 4 files
5338            // should skip: bar/2.txt (excluded by pattern) = 1 file
5339            // symlinks 5.txt and 6.txt don't match *.txt include pattern (symlinks, not files)
5340            assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
5341            assert_eq!(
5342                summary.files_skipped, 1,
5343                "should skip 1 file (bar/2.txt excluded)"
5344            );
5345            // verify specific files
5346            assert!(
5347                test_path.join("dst/bar/1.txt").exists(),
5348                "bar/1.txt should be copied"
5349            );
5350            assert!(
5351                !test_path.join("dst/bar/2.txt").exists(),
5352                "bar/2.txt should be excluded"
5353            );
5354            assert!(
5355                test_path.join("dst/bar/3.txt").exists(),
5356                "bar/3.txt should be copied"
5357            );
5358            Ok(())
5359        }
5360        /// Test that skipped counts accurately reflect what was filtered.
5361        #[tokio::test]
5362        #[traced_test]
5363        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
5364            let tmp_dir = testutils::setup_test_dir().await?;
5365            let test_path = tmp_dir.as_path();
5366            // test structure from setup_test_dir:
5367            // foo/
5368            //   0.txt
5369            //   bar/ (1.txt, 2.txt, 3.txt)
5370            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
5371            // exclude bar/ directory entirely
5372            let mut filter = FilterSettings::new();
5373            filter.add_exclude("bar/").unwrap();
5374            let summary = copy(
5375                &PROGRESS,
5376                &test_path.join("foo"),
5377                &test_path.join("dst"),
5378                &Settings {
5379                    dereference: false,
5380                    fail_early: false,
5381                    overwrite: false,
5382                    overwrite_compare: Default::default(),
5383                    overwrite_filter: None,
5384                    ignore_existing: false,
5385                    chunk_size: 0,
5386                    skip_specials: false,
5387                    remote_copy_buffer_size: 0,
5388                    filter: Some(filter),
5389                    dry_run: None,
5390                    delete: None,
5391                },
5392                &NO_PRESERVE_SETTINGS,
5393                false,
5394            )
5395            .await?;
5396            // copied: 0.txt (1 file), baz/4.txt (1 file), 5.txt symlink, 6.txt symlink
5397            // skipped: bar directory (1 dir) - contents not counted since whole dir skipped
5398            // directories: foo (root), baz = 2
5399            assert_eq!(summary.files_copied, 2, "should copy 2 files");
5400            assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
5401            assert_eq!(
5402                summary.directories_created, 2,
5403                "should create 2 directories"
5404            );
5405            assert_eq!(
5406                summary.directories_skipped, 1,
5407                "should skip 1 directory (bar)"
5408            );
5409            assert_eq!(
5410                summary.files_skipped, 0,
5411                "no files skipped (bar contents not counted)"
5412            );
5413            Ok(())
5414        }
5415        /// Test that empty directories are not created when they were only traversed to look
5416        /// for matches (regression test for bug where --include='foo' would create empty dir baz).
5417        #[tokio::test]
5418        #[traced_test]
5419        async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
5420            let test_path = testutils::create_temp_dir().await?;
5421            // create structure:
5422            // src/
5423            //   foo (file)
5424            //   bar (file)
5425            //   baz/ (empty directory)
5426            let src_path = test_path.join("src");
5427            tokio::fs::create_dir(&src_path).await?;
5428            tokio::fs::write(src_path.join("foo"), "content").await?;
5429            tokio::fs::write(src_path.join("bar"), "content").await?;
5430            tokio::fs::create_dir(src_path.join("baz")).await?;
5431            // include only 'foo' file
5432            let mut filter = FilterSettings::new();
5433            filter.add_include("foo").unwrap();
5434            let summary = copy(
5435                &PROGRESS,
5436                &src_path,
5437                &test_path.join("dst"),
5438                &Settings {
5439                    dereference: false,
5440                    fail_early: false,
5441                    overwrite: false,
5442                    overwrite_compare: Default::default(),
5443                    overwrite_filter: None,
5444                    ignore_existing: false,
5445                    chunk_size: 0,
5446                    skip_specials: false,
5447                    remote_copy_buffer_size: 0,
5448                    filter: Some(filter),
5449                    dry_run: None,
5450                    delete: None,
5451                },
5452                &NO_PRESERVE_SETTINGS,
5453                false,
5454            )
5455            .await?;
5456            // only 'foo' should be copied
5457            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
5458            assert_eq!(
5459                summary.directories_created, 1,
5460                "should create only root directory (not empty 'baz')"
5461            );
5462            // verify foo was copied
5463            assert!(
5464                test_path.join("dst").join("foo").exists(),
5465                "foo should be copied"
5466            );
5467            // verify bar was not copied (not matching include pattern)
5468            assert!(
5469                !test_path.join("dst").join("bar").exists(),
5470                "bar should not be copied"
5471            );
5472            // verify empty baz directory was NOT created
5473            assert!(
5474                !test_path.join("dst").join("baz").exists(),
5475                "empty baz directory should NOT be created"
5476            );
5477            Ok(())
5478        }
5479        /// Test that directories with only non-matching content are not created at destination.
5480        /// This is different from empty directories - the source dir has content but none matches.
5481        #[tokio::test]
5482        #[traced_test]
5483        async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
5484            let test_path = testutils::create_temp_dir().await?;
5485            // create structure:
5486            // src/
5487            //   foo (file)
5488            //   baz/
5489            //     qux (file - doesn't match 'foo')
5490            //     quux (file - doesn't match 'foo')
5491            let src_path = test_path.join("src");
5492            tokio::fs::create_dir(&src_path).await?;
5493            tokio::fs::write(src_path.join("foo"), "content").await?;
5494            tokio::fs::create_dir(src_path.join("baz")).await?;
5495            tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
5496            tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
5497            // include only 'foo' file
5498            let mut filter = FilterSettings::new();
5499            filter.add_include("foo").unwrap();
5500            let summary = copy(
5501                &PROGRESS,
5502                &src_path,
5503                &test_path.join("dst"),
5504                &Settings {
5505                    dereference: false,
5506                    fail_early: false,
5507                    overwrite: false,
5508                    overwrite_compare: Default::default(),
5509                    overwrite_filter: None,
5510                    ignore_existing: false,
5511                    chunk_size: 0,
5512                    skip_specials: false,
5513                    remote_copy_buffer_size: 0,
5514                    filter: Some(filter),
5515                    dry_run: None,
5516                    delete: None,
5517                },
5518                &NO_PRESERVE_SETTINGS,
5519                false,
5520            )
5521            .await?;
5522            // only 'foo' should be copied
5523            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
5524            assert_eq!(
5525                summary.files_skipped, 2,
5526                "should skip 2 files (qux and quux)"
5527            );
5528            assert_eq!(
5529                summary.directories_created, 1,
5530                "should create only root directory (not 'baz' with non-matching content)"
5531            );
5532            // verify foo was copied
5533            assert!(
5534                test_path.join("dst").join("foo").exists(),
5535                "foo should be copied"
5536            );
5537            // verify baz directory was NOT created (even though source baz has content)
5538            assert!(
5539                !test_path.join("dst").join("baz").exists(),
5540                "baz directory should NOT be created (no matching content inside)"
5541            );
5542            Ok(())
5543        }
5544        /// Test that empty directories are not reported as created in dry-run mode
5545        /// when they were only traversed.
5546        #[tokio::test]
5547        #[traced_test]
5548        async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
5549            let test_path = testutils::create_temp_dir().await?;
5550            // create structure:
5551            // src/
5552            //   foo (file)
5553            //   bar (file)
5554            //   baz/ (empty directory)
5555            let src_path = test_path.join("src");
5556            tokio::fs::create_dir(&src_path).await?;
5557            tokio::fs::write(src_path.join("foo"), "content").await?;
5558            tokio::fs::write(src_path.join("bar"), "content").await?;
5559            tokio::fs::create_dir(src_path.join("baz")).await?;
5560            // include only 'foo' file
5561            let mut filter = FilterSettings::new();
5562            filter.add_include("foo").unwrap();
5563            let summary = copy(
5564                &PROGRESS,
5565                &src_path,
5566                &test_path.join("dst"),
5567                &Settings {
5568                    dereference: false,
5569                    fail_early: false,
5570                    overwrite: false,
5571                    overwrite_compare: Default::default(),
5572                    overwrite_filter: None,
5573                    ignore_existing: false,
5574                    chunk_size: 0,
5575                    skip_specials: false,
5576                    remote_copy_buffer_size: 0,
5577                    filter: Some(filter),
5578                    dry_run: Some(crate::config::DryRunMode::Explain),
5579                    delete: None,
5580                },
5581                &NO_PRESERVE_SETTINGS,
5582                false,
5583            )
5584            .await?;
5585            // only 'foo' should be reported as would-be-copied
5586            assert_eq!(
5587                summary.files_copied, 1,
5588                "should report only 'foo' would be copied"
5589            );
5590            assert_eq!(
5591                summary.directories_created, 1,
5592                "should report only root directory would be created (not empty 'baz')"
5593            );
5594            // verify nothing was actually created (dry-run mode)
5595            assert!(
5596                !test_path.join("dst").exists(),
5597                "dst should not exist in dry-run"
5598            );
5599            Ok(())
5600        }
5601        /// Test that existing directories are NOT removed when using --overwrite,
5602        /// even if nothing is copied into them due to filters.
5603        #[tokio::test]
5604        #[traced_test]
5605        async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
5606            let test_path = testutils::create_temp_dir().await?;
5607            // create source structure:
5608            // src/
5609            //   foo (file)
5610            //   bar (file)
5611            //   baz/ (empty directory)
5612            let src_path = test_path.join("src");
5613            tokio::fs::create_dir(&src_path).await?;
5614            tokio::fs::write(src_path.join("foo"), "content").await?;
5615            tokio::fs::write(src_path.join("bar"), "content").await?;
5616            tokio::fs::create_dir(src_path.join("baz")).await?;
5617            // create destination with baz directory already existing
5618            let dst_path = test_path.join("dst");
5619            tokio::fs::create_dir(&dst_path).await?;
5620            tokio::fs::create_dir(dst_path.join("baz")).await?;
5621            // add a marker file inside dst/baz to verify we don't touch it
5622            tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
5623            // include only 'foo' file - baz should not match
5624            let mut filter = FilterSettings::new();
5625            filter.add_include("foo").unwrap();
5626            let summary = copy(
5627                &PROGRESS,
5628                &src_path,
5629                &dst_path,
5630                &Settings {
5631                    dereference: false,
5632                    fail_early: false,
5633                    overwrite: true, // enable overwrite mode
5634                    overwrite_compare: Default::default(),
5635                    overwrite_filter: None,
5636                    ignore_existing: false,
5637                    chunk_size: 0,
5638                    skip_specials: false,
5639                    remote_copy_buffer_size: 0,
5640                    filter: Some(filter),
5641                    dry_run: None,
5642                    delete: None,
5643                },
5644                &NO_PRESERVE_SETTINGS,
5645                false,
5646            )
5647            .await?;
5648            // foo should be copied
5649            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
5650            // dst and baz should be unchanged (both already existed)
5651            assert_eq!(
5652                summary.directories_unchanged, 2,
5653                "root dst and baz directories should be unchanged"
5654            );
5655            assert_eq!(
5656                summary.directories_created, 0,
5657                "should not create any directories"
5658            );
5659            // verify foo was copied
5660            assert!(dst_path.join("foo").exists(), "foo should be copied");
5661            // verify bar was NOT copied
5662            assert!(!dst_path.join("bar").exists(), "bar should not be copied");
5663            // verify existing baz directory still exists with its content
5664            assert!(
5665                dst_path.join("baz").exists(),
5666                "existing baz directory should still exist"
5667            );
5668            assert!(
5669                dst_path.join("baz").join("marker.txt").exists(),
5670                "existing content in baz should still exist"
5671            );
5672            Ok(())
5673        }
5674    }
5675    mod dry_run_tests {
5676        use super::*;
5677        use crate::filter::FilterSettings;
5678        /// Test that dry-run mode for directories doesn't create the destination
5679        /// and doesn't try to set metadata on non-existent directories.
5680        #[tokio::test]
5681        #[traced_test]
5682        async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
5683            let tmp_dir = testutils::setup_test_dir().await?;
5684            let test_path = tmp_dir.as_path();
5685            let dst_path = test_path.join("nonexistent_dst");
5686            // verify destination doesn't exist
5687            assert!(
5688                !dst_path.exists(),
5689                "destination should not exist before dry-run"
5690            );
5691            let summary = copy(
5692                &PROGRESS,
5693                &test_path.join("foo"),
5694                &dst_path,
5695                &Settings {
5696                    dereference: false,
5697                    fail_early: false,
5698                    overwrite: false,
5699                    overwrite_compare: Default::default(),
5700                    overwrite_filter: None,
5701                    ignore_existing: false,
5702                    chunk_size: 0,
5703                    skip_specials: false,
5704                    remote_copy_buffer_size: 0,
5705                    filter: None,
5706                    dry_run: Some(crate::config::DryRunMode::Brief),
5707                    delete: None,
5708                },
5709                &NO_PRESERVE_SETTINGS,
5710                false,
5711            )
5712            .await?;
5713            // verify destination still doesn't exist
5714            assert!(
5715                !dst_path.exists(),
5716                "dry-run should not create destination directory"
5717            );
5718            // verify summary reports what would be created
5719            assert!(
5720                summary.directories_created > 0,
5721                "dry-run should report directories that would be created"
5722            );
5723            assert!(
5724                summary.files_copied > 0,
5725                "dry-run should report files that would be copied"
5726            );
5727            Ok(())
5728        }
5729        /// Regression: in dry-run, `--ignore-existing` must report an existing destination file as
5730        /// skipped (files_unchanged), not as a would-be copy. The dry-run signal is `dst_parent ==
5731        /// None`, so the skip must probe existence by path rather than via a held dst fd.
5732        #[tokio::test]
5733        async fn dry_run_ignore_existing_reports_file_as_skipped() -> Result<(), anyhow::Error> {
5734            let tmp = testutils::create_temp_dir().await?;
5735            let src = tmp.join("src.txt");
5736            let dst = tmp.join("dst.txt");
5737            tokio::fs::write(&src, "new").await?;
5738            tokio::fs::write(&dst, "old").await?; // destination already exists and differs
5739            let summary = copy(
5740                &PROGRESS,
5741                &src,
5742                &dst,
5743                &Settings {
5744                    dereference: false,
5745                    fail_early: false,
5746                    overwrite: false,
5747                    overwrite_compare: Default::default(),
5748                    overwrite_filter: None,
5749                    ignore_existing: true,
5750                    chunk_size: 0,
5751                    skip_specials: false,
5752                    remote_copy_buffer_size: 0,
5753                    filter: None,
5754                    dry_run: Some(crate::config::DryRunMode::Brief),
5755                    delete: None,
5756                },
5757                &NO_PRESERVE_SETTINGS,
5758                false,
5759            )
5760            .await?;
5761            assert_eq!(
5762                summary.files_unchanged, 1,
5763                "existing file must be reported skipped in dry-run under --ignore-existing"
5764            );
5765            assert_eq!(
5766                summary.files_copied, 0,
5767                "must not report a would-be copy when --ignore-existing skips"
5768            );
5769            assert_eq!(
5770                tokio::fs::read_to_string(&dst).await?,
5771                "old",
5772                "dry-run must not mutate the destination"
5773            );
5774            Ok(())
5775        }
5776        /// Regression: the symlink counterpart of `dry_run_ignore_existing_reports_file_as_skipped`
5777        /// — an existing destination must be reported as `symlinks_unchanged`, not `symlinks_created`.
5778        #[tokio::test]
5779        async fn dry_run_ignore_existing_reports_symlink_as_skipped() -> Result<(), anyhow::Error> {
5780            let tmp = testutils::create_temp_dir().await?;
5781            let src = tmp.join("src_link");
5782            let dst = tmp.join("dst_link");
5783            tokio::fs::symlink("some/target", &src).await?;
5784            tokio::fs::write(&dst, "existing").await?; // destination already exists (any type)
5785            let summary = copy(
5786                &PROGRESS,
5787                &src,
5788                &dst,
5789                &Settings {
5790                    dereference: false,
5791                    fail_early: false,
5792                    overwrite: false,
5793                    overwrite_compare: Default::default(),
5794                    overwrite_filter: None,
5795                    ignore_existing: true,
5796                    chunk_size: 0,
5797                    skip_specials: false,
5798                    remote_copy_buffer_size: 0,
5799                    filter: None,
5800                    dry_run: Some(crate::config::DryRunMode::Brief),
5801                    delete: None,
5802                },
5803                &NO_PRESERVE_SETTINGS,
5804                false,
5805            )
5806            .await?;
5807            assert_eq!(
5808                summary.symlinks_unchanged, 1,
5809                "existing symlink dst must be reported skipped in dry-run under --ignore-existing"
5810            );
5811            assert_eq!(
5812                summary.symlinks_created, 0,
5813                "must not report a would-be symlink creation when --ignore-existing skips"
5814            );
5815            Ok(())
5816        }
5817        /// A destination SYMLINK is a non-directory to `--ignore-existing`, even when it points at a
5818        /// directory, so the whole subtree is skipped — and the dry run must predict that.
5819        ///
5820        /// Dry-run and real execution both classify the final component without following it. A
5821        /// path-based `Path::is_dir()` probe would follow the link and incorrectly predict that the
5822        /// subtree is copied instead of skipped.
5823        #[tokio::test]
5824        #[traced_test]
5825        async fn dry_run_ignore_existing_reports_dir_behind_a_symlink_as_skipped()
5826        -> Result<(), anyhow::Error> {
5827            let tmp = testutils::create_temp_dir().await?;
5828            let src = tmp.join("src_dir");
5829            tokio::fs::create_dir(&src).await?;
5830            tokio::fs::write(src.join("child.txt"), "content").await?;
5831            // the destination is a SYMLINK pointing at a real directory
5832            let link_target = tmp.join("elsewhere");
5833            tokio::fs::create_dir(&link_target).await?;
5834            let dst = tmp.join("dst_dir");
5835            tokio::fs::symlink(&link_target, &dst).await?;
5836            let summary = copy(
5837                &PROGRESS,
5838                &src,
5839                &dst,
5840                &Settings {
5841                    dereference: false,
5842                    fail_early: false,
5843                    overwrite: false,
5844                    overwrite_compare: Default::default(),
5845                    overwrite_filter: None,
5846                    ignore_existing: true,
5847                    chunk_size: 0,
5848                    skip_specials: false,
5849                    remote_copy_buffer_size: 0,
5850                    filter: None,
5851                    dry_run: Some(crate::config::DryRunMode::Brief),
5852                    delete: None,
5853                },
5854                &NO_PRESERVE_SETTINGS,
5855                false,
5856            )
5857            .await?;
5858            assert_eq!(
5859                summary.directories_unchanged, 1,
5860                "a destination symlink is a non-directory, so --ignore-existing skips the subtree; \
5861                 dry-run must report that rather than following the link"
5862            );
5863            assert_eq!(
5864                summary.directories_created, 0,
5865                "must not report a would-be directory creation when --ignore-existing skips"
5866            );
5867            assert_eq!(
5868                summary.files_copied, 0,
5869                "the skipped subtree's children must not be reported as would-be-copied"
5870            );
5871            Ok(())
5872        }
5873
5874        /// A destination lookup that FAILS is not a vacant destination: `--dry-run` must report the
5875        /// failure rather than predicting a copy the real run cannot perform.
5876        ///
5877        /// The unprobeable destination has to be a NESTED one for this to isolate the probe. Sealing
5878        /// the destination's own parent would exercise the root operand's `open_parent_dir` instead
5879        /// of the child probe. The destination ROOT is therefore a directory the copy can find but
5880        /// not traverse (`0o600` — readable, not searchable), which only the path-based child probe
5881        /// touches: a dry run creates and opens no destination directories, so nothing else in the
5882        /// copy needs to traverse it.
5883        ///
5884        /// Skipped for root, which ignores the permission bits this relies on.
5885        #[tokio::test]
5886        #[traced_test]
5887        async fn dry_run_ignore_existing_reports_an_unprobeable_destination_as_an_error()
5888        -> Result<(), anyhow::Error> {
5889            if nix::unistd::geteuid().is_root() {
5890                eprintln!("skipping: root bypasses the directory permissions this test relies on");
5891                return Ok(());
5892            }
5893            let tmp = testutils::create_temp_dir().await?;
5894            let src = tmp.join("src_dir");
5895            tokio::fs::create_dir(&src).await?;
5896            // EMPTY, deliberately: a file inside it would be caught by the FILE probe
5897            // ([`dry_run_dst_exists`]) instead, and the test would pass without the directory probe
5898            // ever mattering. With nothing under `sub`, the directory probe is the only thing that can
5899            // notice; it is therefore the only probe capable of observing the EACCES.
5900            tokio::fs::create_dir(src.join("sub")).await?;
5901            // the destination root exists but is NOT searchable, so probing `dst/sub` inside it gets
5902            // EACCES — "could not look", not "nothing is there"
5903            let sealed = tmp.join("dst_dir");
5904            tokio::fs::create_dir(&sealed).await?;
5905            tokio::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o600)).await?;
5906            let result = copy(
5907                &PROGRESS,
5908                &src,
5909                &sealed,
5910                &Settings {
5911                    dereference: false,
5912                    fail_early: false,
5913                    overwrite: false,
5914                    overwrite_compare: Default::default(),
5915                    overwrite_filter: None,
5916                    ignore_existing: true,
5917                    chunk_size: 0,
5918                    skip_specials: false,
5919                    remote_copy_buffer_size: 0,
5920                    filter: None,
5921                    dry_run: Some(crate::config::DryRunMode::Brief),
5922                    delete: None,
5923                },
5924                &NO_PRESERVE_SETTINGS,
5925                false,
5926            )
5927            .await;
5928            // restore permissions so the temp dir can be cleaned up
5929            tokio::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o700)).await?;
5930            let error = result.expect_err(
5931                "an unprobeable destination must be reported, not treated as vacant and predicted \
5932                 as a copy",
5933            );
5934            let rendered = format!("{:#}", error.source);
5935            assert!(
5936                rendered.contains("Permission denied"),
5937                "the error must keep the real cause; got: {rendered}"
5938            );
5939            Ok(())
5940        }
5941
5942        /// Test that root directory is always created even when nothing matches
5943        /// the include pattern. The root is the user-specified source — it should
5944        /// never be removed/skipped due to empty-dir cleanup.
5945        #[tokio::test]
5946        #[traced_test]
5947        async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
5948            let test_path = testutils::create_temp_dir().await?;
5949            // create structure:
5950            // src/
5951            //   bar.log (doesn't match *.txt)
5952            //   baz/ (empty directory)
5953            let src_path = test_path.join("src");
5954            tokio::fs::create_dir(&src_path).await?;
5955            tokio::fs::write(src_path.join("bar.log"), "content").await?;
5956            tokio::fs::create_dir(src_path.join("baz")).await?;
5957            // include only *.txt - nothing in source matches
5958            let mut filter = FilterSettings::new();
5959            filter.add_include("*.txt").unwrap();
5960            let dst_path = test_path.join("dst");
5961            let summary = copy(
5962                &PROGRESS,
5963                &src_path,
5964                &dst_path,
5965                &Settings {
5966                    dereference: false,
5967                    fail_early: false,
5968                    overwrite: false,
5969                    overwrite_compare: Default::default(),
5970                    overwrite_filter: None,
5971                    ignore_existing: false,
5972                    chunk_size: 0,
5973                    skip_specials: false,
5974                    remote_copy_buffer_size: 0,
5975                    filter: Some(filter),
5976                    dry_run: None,
5977                    delete: None,
5978                },
5979                &NO_PRESERVE_SETTINGS,
5980                false,
5981            )
5982            .await?;
5983            // no files should be copied
5984            assert_eq!(summary.files_copied, 0, "no files match *.txt");
5985            // root directory should still be created
5986            assert_eq!(
5987                summary.directories_created, 1,
5988                "root directory should always be created"
5989            );
5990            assert!(dst_path.exists(), "root destination directory should exist");
5991            // non-matching subdirectories should not be created
5992            assert!(
5993                !dst_path.join("baz").exists(),
5994                "empty baz should not be created"
5995            );
5996            Ok(())
5997        }
5998        /// Test that root directory is counted in dry-run even when nothing matches.
5999        #[tokio::test]
6000        #[traced_test]
6001        async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
6002        {
6003            let test_path = testutils::create_temp_dir().await?;
6004            let src_path = test_path.join("src");
6005            tokio::fs::create_dir(&src_path).await?;
6006            tokio::fs::write(src_path.join("bar.log"), "content").await?;
6007            // include only *.txt - nothing matches
6008            let mut filter = FilterSettings::new();
6009            filter.add_include("*.txt").unwrap();
6010            let dst_path = test_path.join("dst");
6011            let summary = copy(
6012                &PROGRESS,
6013                &src_path,
6014                &dst_path,
6015                &Settings {
6016                    dereference: false,
6017                    fail_early: false,
6018                    overwrite: false,
6019                    overwrite_compare: Default::default(),
6020                    overwrite_filter: None,
6021                    ignore_existing: false,
6022                    chunk_size: 0,
6023                    skip_specials: false,
6024                    remote_copy_buffer_size: 0,
6025                    filter: Some(filter),
6026                    dry_run: Some(crate::config::DryRunMode::Explain),
6027                    delete: None,
6028                },
6029                &NO_PRESERVE_SETTINGS,
6030                false,
6031            )
6032            .await?;
6033            assert_eq!(summary.files_copied, 0, "no files match *.txt");
6034            assert_eq!(
6035                summary.directories_created, 1,
6036                "root directory should be counted in dry-run"
6037            );
6038            assert!(
6039                !dst_path.exists(),
6040                "nothing should be created in dry-run mode"
6041            );
6042            Ok(())
6043        }
6044    }
6045
6046    /// Stress tests exercise max-files-in-flight saturation during copy.
6047    mod max_files_in_flight_tests {
6048        use super::*;
6049
6050        /// Public root setup must reserve open-file capacity before opening operand parents or
6051        /// performing an fd-based filter probe.
6052        #[tokio::test]
6053        async fn filtered_public_root_waits_for_admission_before_setup() -> anyhow::Result<()> {
6054            let root = testutils::create_temp_dir().await?;
6055            let src = root.join("src");
6056            let dst = root.join("dst");
6057            tokio::fs::write(&src, b"x").await?;
6058            let mut filter = crate::filter::FilterSettings::new();
6059            filter.add_exclude("src")?;
6060            let mut settings = settings_with_delete(None);
6061            settings.filter = Some(filter);
6062            let admission = testutils::AdmissionLimit::new().await;
6063            admission.set_files_in_flight(1);
6064            let stat_resource =
6065                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6066            admission.set_max_ops_in_flight(stat_resource, 1);
6067            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6068            let operation = copy(
6069                &PROGRESS,
6070                &src,
6071                &dst,
6072                &settings,
6073                &NO_PRESERVE_SETTINGS,
6074                false,
6075            );
6076            tokio::pin!(operation);
6077            let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
6078            let mut second_permit = Box::pin(throttle::open_file_permit());
6079            let setup_bypassed_admission = futures::poll!(second_permit.as_mut()).is_ready();
6080            drop(second_permit);
6081            drop(held_stat);
6082            let result = admission
6083                .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6084                .await;
6085            assert!(
6086                stopped_at_stat_gate,
6087                "copy root did not reach the held stat gate"
6088            );
6089            let summary =
6090                result.context("copy root did not resume after stat capacity was released")??;
6091            assert!(
6092                !setup_bypassed_admission,
6093                "copy performed root setup before open-file admission"
6094            );
6095            assert_eq!(summary.files_skipped, 1);
6096            Ok(())
6097        }
6098
6099        /// An entry with no `d_type` hint reserves open-file capacity under the shared
6100        /// pre-spawn policy.
6101        #[tokio::test]
6102        async fn unknown_type_reserves_max_files_in_flight_capacity() {
6103            let admission = testutils::AdmissionLimit::new().await;
6104            admission.set_files_in_flight(1);
6105            let held_permit = throttle::open_file_permit().await;
6106            let visitor = CopyVisitor {
6107                prog_track: &PROGRESS,
6108                dst_root: PathBuf::new(),
6109                filter_base: PathBuf::new(),
6110                delete_scan_anchor: DeleteScanAnchor::new(
6111                    std::path::Path::new("dst"),
6112                    std::path::Path::new(""),
6113                ),
6114                settings: settings_with_delete(None),
6115                preserve: *NO_PRESERVE_SETTINGS,
6116                dst_parent: None,
6117                root_is_fresh: false,
6118            };
6119            let acquire = crate::walk::preacquire_leaf_permit(visitor.permit_kind(), None);
6120            tokio::pin!(acquire);
6121            assert!(
6122                futures::poll!(acquire.as_mut()).is_pending(),
6123                "DT_UNKNOWN must not bypass pre-spawn open-file backpressure"
6124            );
6125            drop(held_permit);
6126            let permit = acquire.await;
6127            assert!(
6128                permit.is_some(),
6129                "DT_UNKNOWN must reserve capacity for authoritative classification"
6130            );
6131        }
6132
6133        /// A non-dereferenced symlink must retain its admission permit through the
6134        /// fd-bearing readlink/create work, not release it immediately after classification.
6135        #[tokio::test]
6136        async fn symlink_holds_open_file_capacity_until_copy_finishes() -> anyhow::Result<()> {
6137            let root = testutils::create_temp_dir().await?;
6138            let src_name = std::ffi::OsStr::new("src-link");
6139            let dst_name = std::ffi::OsStr::new("dst-link");
6140            tokio::fs::symlink("target", root.join(src_name)).await?;
6141            let admission = testutils::AdmissionLimit::new().await;
6142            let src_parent = Arc::new(
6143                Dir::open_parent_dir(&root, congestion::Side::Source)
6144                    .await?
6145                    .into_tree(),
6146            );
6147            let dst_parent = Arc::new(
6148                Dir::open_parent_dir(&root, congestion::Side::Destination)
6149                    .await?
6150                    .into_tree(),
6151            );
6152            let handle = src_parent.child(src_name).await?;
6153            assert_eq!(handle.kind(), EntryKind::Symlink);
6154            let dst_root = root.join(dst_name);
6155            let visitor = CopyVisitor {
6156                prog_track: &PROGRESS,
6157                dst_root: dst_root.clone(),
6158                filter_base: PathBuf::new(),
6159                delete_scan_anchor: DeleteScanAnchor::new(&dst_root, std::path::Path::new("")),
6160                settings: settings_with_delete(None),
6161                preserve: *NO_PRESERVE_SETTINGS,
6162                dst_parent: Some(Arc::clone(&dst_parent)),
6163                root_is_fresh: true,
6164            };
6165            let cx = EntryCx {
6166                parent: src_parent,
6167                name: src_name.to_owned(),
6168                rel_path: PathBuf::new(),
6169                filter_path: PathBuf::new(),
6170                real_path: root.join(src_name),
6171                dry_run: false,
6172                prog_track: &PROGRESS,
6173            };
6174            let parent_ctx = CopyDirContext {
6175                dst_dir: Some(dst_parent),
6176                is_fresh: true,
6177            };
6178            admission.set_files_in_flight(1);
6179            let permit = Some(LeafPermit::OpenFile(throttle::open_file_permit().await));
6180            let readlink_resource =
6181                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::ReadLink);
6182            admission.set_max_ops_in_flight(readlink_resource, 1);
6183            let held_readlink = throttle::ops_in_flight_permit(readlink_resource).await;
6184            let leaf = AdmittedLeaf::try_new(handle, permit)
6185                .map_err(|_| anyhow::anyhow!("symlink classification returned a directory"))?;
6186            let visit = visitor.visit_leaf(&cx, &parent_ctx, leaf);
6187            tokio::pin!(visit);
6188            let stopped_at_readlink_gate = futures::poll!(visit.as_mut()).is_pending();
6189            let mut second_permit = Box::pin(throttle::open_file_permit());
6190            let released_before_copy_finished = futures::poll!(second_permit.as_mut()).is_ready();
6191            drop(second_permit);
6192            drop(held_readlink);
6193            let result = admission
6194                .run_with_timeout(std::time::Duration::from_secs(20), visit.as_mut())
6195                .await;
6196            assert!(
6197                stopped_at_readlink_gate,
6198                "symlink copy did not reach the held readlink gate"
6199            );
6200            assert!(
6201                !released_before_copy_finished,
6202                "symlink released its open-file permit while fd-bearing work was still pending"
6203            );
6204            let summary = result
6205                .context("symlink copy did not resume after readlink capacity was released")?
6206                .map_err(|error| error.source)?;
6207            assert_eq!(summary.symlinks_created, 1);
6208            assert_eq!(
6209                tokio::fs::read_link(root.join(dst_name)).await?,
6210                std::path::Path::new("target")
6211            );
6212            Ok(())
6213        }
6214
6215        /// A dereferenced symlink must transfer its admission guard into the target copy. Releasing
6216        /// it before `canonicalize` lets a wide directory of symlinks spawn unbounded target walks
6217        /// which open parent/entry fds before reacquiring capacity.
6218        #[tokio::test]
6219        async fn dereferenced_symlink_transfers_open_file_capacity_to_target_copy()
6220        -> anyhow::Result<()> {
6221            let root = testutils::create_temp_dir().await?;
6222            let src_name = std::ffi::OsStr::new("src-link");
6223            tokio::fs::write(root.join("target"), b"payload").await?;
6224            tokio::fs::symlink("target", root.join(src_name)).await?;
6225            let admission = testutils::AdmissionLimit::new().await;
6226            let src_parent = Arc::new(
6227                Dir::open_parent_dir(&root, congestion::Side::Source)
6228                    .await?
6229                    .into_tree(),
6230            );
6231            let handle = src_parent.child(src_name).await?;
6232            let mut settings = settings_with_delete(None);
6233            settings.dereference = true;
6234            settings.dry_run = Some(crate::config::DryRunMode::Brief);
6235            let dst_root = root.join("dst");
6236            let visitor = CopyVisitor {
6237                prog_track: &PROGRESS,
6238                dst_root: dst_root.clone(),
6239                filter_base: PathBuf::new(),
6240                delete_scan_anchor: DeleteScanAnchor::new(&dst_root, std::path::Path::new("")),
6241                settings,
6242                preserve: *NO_PRESERVE_SETTINGS,
6243                dst_parent: None,
6244                root_is_fresh: false,
6245            };
6246            let cx = EntryCx {
6247                parent: src_parent,
6248                name: src_name.to_owned(),
6249                rel_path: PathBuf::new(),
6250                filter_path: PathBuf::new(),
6251                real_path: root.join(src_name),
6252                dry_run: true,
6253                prog_track: &PROGRESS,
6254            };
6255            let parent_ctx = CopyDirContext {
6256                dst_dir: None,
6257                is_fresh: false,
6258            };
6259            admission.set_files_in_flight(1);
6260            let permit = Some(LeafPermit::OpenFile(throttle::open_file_permit().await));
6261            let stat_resource =
6262                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6263            admission.set_max_ops_in_flight(stat_resource, 1);
6264            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6265            let leaf = AdmittedLeaf::try_new(handle, permit)
6266                .map_err(|_| anyhow::anyhow!("symlink classification returned a directory"))?;
6267            let visit = visitor.visit_leaf(&cx, &parent_ctx, leaf);
6268            tokio::pin!(visit);
6269            let stopped_at_canonicalize_gate = futures::poll!(visit.as_mut()).is_pending();
6270            // Queue a test waiter immediately behind canonicalize. Once canonicalize releases its
6271            // Stat slot, FIFO semaphore ordering gives that slot to this waiter before the target
6272            // root setup can acquire its next Stat slot. Holding it therefore pins `visit` inside
6273            // target setup, on the far side of the guard-transfer boundary.
6274            let mut target_setup_gate = Box::pin(throttle::ops_in_flight_permit(stat_resource));
6275            let target_setup_waiter_queued =
6276                futures::poll!(target_setup_gate.as_mut()).is_pending();
6277            drop(held_stat);
6278            let gate_race = tokio::time::timeout(std::time::Duration::from_secs(20), async {
6279                tokio::select! {
6280                    permit = target_setup_gate.as_mut() => Ok(permit),
6281                    result = visit.as_mut() => Err(result),
6282                }
6283            })
6284            .await;
6285            let mut setup_guard = None;
6286            let mut early_visit_result = None;
6287            let gate_timed_out = match gate_race {
6288                Ok(Ok(guard)) => {
6289                    setup_guard = Some(guard);
6290                    false
6291                }
6292                Ok(Err(result)) => {
6293                    early_visit_result = Some(result);
6294                    false
6295                }
6296                Err(_) => true,
6297            };
6298            drop(target_setup_gate);
6299            let finished_before_target_setup = early_visit_result.is_some();
6300            let mut second_permit = Box::pin(throttle::open_file_permit());
6301            let released_inside_target_setup = futures::poll!(second_permit.as_mut()).is_ready();
6302            drop(second_permit);
6303            drop(setup_guard);
6304            let result = match early_visit_result {
6305                Some(result) => Ok(result),
6306                None => {
6307                    admission
6308                        .run_with_timeout(std::time::Duration::from_secs(20), visit.as_mut())
6309                        .await
6310                }
6311            };
6312            assert!(
6313                stopped_at_canonicalize_gate,
6314                "dereference did not reach the held canonicalize Stat gate"
6315            );
6316            assert!(
6317                target_setup_waiter_queued,
6318                "target-setup test waiter did not queue behind canonicalize"
6319            );
6320            assert!(
6321                !gate_timed_out,
6322                "canonicalize did not release its Stat slot"
6323            );
6324            assert!(
6325                !finished_before_target_setup,
6326                "dereferenced copy finished before reaching target setup"
6327            );
6328            assert!(
6329                !released_inside_target_setup,
6330                "dereference released admission instead of transferring it into target setup"
6331            );
6332            let summary = result
6333                .context("dereferenced copy did not resume after target setup was released")?
6334                .map_err(|error| error.source)?;
6335            assert_eq!(summary.files_copied, 1);
6336            Ok(())
6337        }
6338
6339        /// A dereferenced symlink whose target is a directory must release the transferred guard
6340        /// after authoritative target classification. Any strong ambient admission retained by the
6341        /// outer symlink visitor would make the first descendant wait forever at a limit of one.
6342        #[tokio::test]
6343        async fn dereferenced_symlink_directory_releases_capacity_before_descent()
6344        -> anyhow::Result<()> {
6345            let root = testutils::create_temp_dir().await?;
6346            let target = root.join("target");
6347            let src = root.join("src-link");
6348            let dst = root.join("dst");
6349            tokio::fs::create_dir(&target).await?;
6350            tokio::fs::write(target.join("child"), b"payload").await?;
6351            tokio::fs::symlink("target", &src).await?;
6352            let mut settings = settings_with_delete(None);
6353            settings.dereference = true;
6354            let admission = testutils::AdmissionLimit::new().await;
6355            admission.set_files_in_flight(1);
6356            let result = admission
6357                .run_with_timeout(
6358                    std::time::Duration::from_secs(20),
6359                    copy(
6360                        &PROGRESS,
6361                        &src,
6362                        &dst,
6363                        &settings,
6364                        &NO_PRESERVE_SETTINGS,
6365                        false,
6366                    ),
6367                )
6368                .await;
6369            let summary = result.context(
6370                "dereferenced directory copy retained admission across recursive descent",
6371            )??;
6372            assert_eq!(summary.files_copied, 1);
6373            assert_eq!(tokio::fs::read(dst.join("child")).await?, b"payload");
6374            Ok(())
6375        }
6376
6377        /// Cancelling the real local data-copy boundary must retain admission until its
6378        /// non-cancellable fd-owning job and abandoned output have both dropped.
6379        #[tokio::test]
6380        async fn cancelled_copy_file_fd_retains_capacity_until_its_fds_close() -> anyhow::Result<()>
6381        {
6382            let root = testutils::create_temp_dir().await?;
6383            let src = root.join("source");
6384            let dst = root.join("destination");
6385            if let Err(error) = tokio::fs::write(&src, vec![b'x'; 4096]).await {
6386                let _ = tokio::fs::remove_dir_all(&root).await;
6387                return Err(error.into());
6388            }
6389            let gate = testutils::BlockingPathGate::install(dst.clone());
6390            let admission = testutils::AdmissionLimit::new().await;
6391            admission.set_files_in_flight(1);
6392            let timeout = std::time::Duration::from_secs(20);
6393            let settings = settings_with_delete(None);
6394            let task = tokio::spawn(async move {
6395                copy(
6396                    &PROGRESS,
6397                    &src,
6398                    &dst,
6399                    &settings,
6400                    &NO_PRESERVE_SETTINGS,
6401                    false,
6402                )
6403                .await
6404            });
6405            let observations =
6406                testutils::cancel_at_blocking_path(admission, gate, task, timeout, |_| ()).await;
6407            let cleanup_result = tokio::fs::remove_dir_all(root).await;
6408            let (observations, ()) = observations?;
6409            cleanup_result?;
6410
6411            assert!(observations.waiter_was_cancelled);
6412            assert!(
6413                observations.admission_was_retained_while_work_gated,
6414                "cancelling copy_file_fd released capacity while its fd-owning job was live"
6415            );
6416            assert!(
6417                observations.fd_was_open_while_work_gated,
6418                "copy_file_fd closed its fd while the job was gated"
6419            );
6420            assert_eq!(observations.hit_count_before_release, 1);
6421            assert!(
6422                observations.fd_was_closed_at_output_drop_start,
6423                "copy_file_fd reached its output-drop boundary before its destination fd closed"
6424            );
6425            assert!(
6426                observations.admission_was_retained_at_output_drop_start,
6427                "copy_file_fd released capacity before its abandoned output dropped"
6428            );
6429            assert_eq!(observations.final_hit_count, 1);
6430            Ok(())
6431        }
6432
6433        /// Wide copy: many files with a very low files-in-flight limit.
6434        /// Verifies all files are copied correctly under permit saturation.
6435        #[tokio::test]
6436        #[traced_test]
6437        async fn wide_copy_under_files_in_flight_saturation() -> Result<(), anyhow::Error> {
6438            let tmp_dir = testutils::create_temp_dir().await?;
6439            let src = tmp_dir.join("src");
6440            let dst = tmp_dir.join("dst");
6441            tokio::fs::create_dir(&src).await?;
6442            let file_count = 200;
6443            for i in 0..file_count {
6444                tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
6445            }
6446            // set a very low limit to force permit contention
6447            let admission = testutils::AdmissionLimit::new().await;
6448            admission.set_files_in_flight(4);
6449            let summary = admission
6450                .run_with_timeout(
6451                    std::time::Duration::from_secs(30),
6452                    copy(
6453                        &PROGRESS,
6454                        &src,
6455                        &dst,
6456                        &Settings {
6457                            dereference: false,
6458                            fail_early: true,
6459                            overwrite: false,
6460                            overwrite_compare: Default::default(),
6461                            overwrite_filter: None,
6462                            ignore_existing: false,
6463                            chunk_size: 0,
6464                            skip_specials: false,
6465                            remote_copy_buffer_size: 0,
6466                            filter: None,
6467                            dry_run: None,
6468                            delete: None,
6469                        },
6470                        &NO_PRESERVE_SETTINGS,
6471                        false,
6472                    ),
6473                )
6474                .await
6475                .context("copy timed out — possible deadlock")??;
6476            assert_eq!(summary.files_copied, file_count);
6477            assert_eq!(summary.directories_created, 1);
6478            for i in 0..file_count {
6479                let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
6480                assert_eq!(content, format!("content-{}", i));
6481            }
6482            Ok(())
6483        }
6484
6485        /// Deep + wide copy: a directory tree deeper than the files-in-flight limit, with files at
6486        /// every level. Verifies directories do not retain leaf admission across recursion.
6487        #[tokio::test]
6488        #[traced_test]
6489        async fn deep_tree_no_deadlock_under_files_in_flight_saturation()
6490        -> Result<(), anyhow::Error> {
6491            let tmp_dir = testutils::create_temp_dir().await?;
6492            let src = tmp_dir.join("src");
6493            let dst = tmp_dir.join("dst");
6494            let depth = 20;
6495            let files_per_level = 5;
6496            let limit = 1;
6497            // create a directory chain deeper than the permit limit, with files at each level
6498            let mut dir = src.clone();
6499            for level in 0..depth {
6500                tokio::fs::create_dir_all(&dir).await?;
6501                for f in 0..files_per_level {
6502                    tokio::fs::write(
6503                        dir.join(format!("f{}_{}.txt", level, f)),
6504                        format!("L{}F{}", level, f),
6505                    )
6506                    .await?;
6507                }
6508                dir = dir.join(format!("d{}", level));
6509            }
6510            let admission = testutils::AdmissionLimit::new().await;
6511            admission.set_files_in_flight(limit);
6512            let summary = admission
6513                .run_with_timeout(
6514                    std::time::Duration::from_secs(30),
6515                    copy(
6516                        &PROGRESS,
6517                        &src,
6518                        &dst,
6519                        &Settings {
6520                            dereference: false,
6521                            fail_early: true,
6522                            overwrite: false,
6523                            overwrite_compare: Default::default(),
6524                            overwrite_filter: None,
6525                            ignore_existing: false,
6526                            chunk_size: 0,
6527                            skip_specials: false,
6528                            remote_copy_buffer_size: 0,
6529                            filter: None,
6530                            dry_run: None,
6531                            delete: None,
6532                        },
6533                        &NO_PRESERVE_SETTINGS,
6534                        false,
6535                    ),
6536                )
6537                .await
6538                .context("copy timed out — possible deadlock")?
6539                .context("copy failed")?;
6540            assert_eq!(summary.files_copied, depth * files_per_level);
6541            assert_eq!(summary.directories_created, depth);
6542            // spot-check content at a few levels
6543            let mut check_dir = dst.clone();
6544            for level in 0..depth {
6545                let content =
6546                    tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
6547                assert_eq!(content, format!("L{}F0", level));
6548                check_dir = check_dir.join(format!("d{}", level));
6549            }
6550            Ok(())
6551        }
6552
6553        /// Regression: copy-file overwrite removal must use the separate rm pool.
6554        ///
6555        /// Scenario: many parallel copies overwrite destinations that are directories. Each
6556        /// `copy_child` / `copy_file_fd` path retains OpenFile admission while `remove_existing`
6557        /// invokes recursive `rm_child`. If rm also drew from OpenFile, a saturated pool would
6558        /// deadlock — every permit would be held by a copy task waiting for rm to release one. The
6559        /// separate PendingMeta pool avoids that cycle.
6560        #[tokio::test]
6561        #[traced_test]
6562        async fn parallel_overwrite_dir_with_file_no_deadlock() -> Result<(), anyhow::Error> {
6563            let tmp_dir = testutils::create_temp_dir().await?;
6564            let src = tmp_dir.join("src");
6565            let dst = tmp_dir.join("dst");
6566            tokio::fs::create_dir(&src).await?;
6567            tokio::fs::create_dir(&dst).await?;
6568            // 8 sources are regular files; the 8 corresponding destinations
6569            // are directories with nested files — copy with --overwrite
6570            // forces rm of each dst directory tree from inside copy_file_fd.
6571            let n = 8;
6572            for i in 0..n {
6573                tokio::fs::write(src.join(format!("e{}", i)), format!("file-{}", i)).await?;
6574                let dst_subdir = dst.join(format!("e{}", i));
6575                tokio::fs::create_dir(&dst_subdir).await?;
6576                for j in 0..3 {
6577                    tokio::fs::write(
6578                        dst_subdir.join(format!("inner_{}.txt", j)),
6579                        format!("inner-{}-{}", i, j),
6580                    )
6581                    .await?;
6582                }
6583            }
6584            // saturate the OpenFile pool: if rm shared it, the outer copy tasks would hold every
6585            // permit and their inner rm recursions would block forever.
6586            let admission = testutils::AdmissionLimit::new().await;
6587            admission.set_files_in_flight(2);
6588            let summary = admission
6589                .run_with_timeout(
6590                    std::time::Duration::from_secs(30),
6591                    copy(
6592                        &PROGRESS,
6593                        &src,
6594                        &dst,
6595                        &Settings {
6596                            dereference: false,
6597                            fail_early: true,
6598                            overwrite: true,
6599                            overwrite_compare: Default::default(),
6600                            overwrite_filter: None,
6601                            ignore_existing: false,
6602                            chunk_size: 0,
6603                            skip_specials: false,
6604                            remote_copy_buffer_size: 0,
6605                            filter: None,
6606                            dry_run: None,
6607                            delete: None,
6608                        },
6609                        &NO_PRESERVE_SETTINGS,
6610                        false,
6611                    ),
6612                )
6613                .await
6614                .context(
6615                    "copy timed out — deadlock between copy_file_fd's OpenFile permit and inner rm",
6616                )?
6617                .context("copy failed")?;
6618            assert_eq!(summary.files_copied, n);
6619            assert_eq!(summary.rm_summary.files_removed, n * 3);
6620            assert_eq!(summary.rm_summary.directories_removed, n);
6621            for i in 0..n {
6622                let path = dst.join(format!("e{}", i));
6623                let content = tokio::fs::read_to_string(&path).await?;
6624                assert_eq!(content, format!("file-{}", i));
6625            }
6626            Ok(())
6627        }
6628    }
6629
6630    mod skip_specials_tests {
6631        use super::*;
6632
6633        #[tokio::test]
6634        #[traced_test]
6635        async fn skip_specials_skips_socket_in_directory() -> Result<(), anyhow::Error> {
6636            let tmp_dir = testutils::setup_test_dir().await?;
6637            let test_path = tmp_dir.as_path();
6638            let src = test_path.join("src_dir");
6639            let dst = test_path.join("dst_dir");
6640            tokio::fs::create_dir(&src).await?;
6641            tokio::fs::write(src.join("file.txt"), "hello").await?;
6642            // create a unix socket inside the source directory
6643            let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
6644            let summary = copy(
6645                &PROGRESS,
6646                &src,
6647                &dst,
6648                &Settings {
6649                    dereference: false,
6650                    fail_early: false,
6651                    overwrite: false,
6652                    overwrite_compare: Default::default(),
6653                    overwrite_filter: None,
6654                    ignore_existing: false,
6655                    chunk_size: 0,
6656                    skip_specials: true,
6657                    remote_copy_buffer_size: 0,
6658                    filter: None,
6659                    dry_run: None,
6660                    delete: None,
6661                },
6662                &NO_PRESERVE_SETTINGS,
6663                false,
6664            )
6665            .await?;
6666            assert_eq!(summary.files_copied, 1);
6667            assert_eq!(summary.specials_skipped, 1);
6668            assert!(dst.join("file.txt").exists());
6669            assert!(!dst.join("test.sock").exists());
6670            Ok(())
6671        }
6672
6673        #[tokio::test]
6674        #[traced_test]
6675        async fn skip_specials_skips_fifo_in_directory() -> Result<(), anyhow::Error> {
6676            let tmp_dir = testutils::setup_test_dir().await?;
6677            let test_path = tmp_dir.as_path();
6678            let src = test_path.join("src_dir");
6679            let dst = test_path.join("dst_dir");
6680            tokio::fs::create_dir(&src).await?;
6681            tokio::fs::write(src.join("file.txt"), "hello").await?;
6682            // create a FIFO inside the source directory
6683            nix::unistd::mkfifo(
6684                &src.join("test.fifo"),
6685                nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
6686            )?;
6687            let summary = copy(
6688                &PROGRESS,
6689                &src,
6690                &dst,
6691                &Settings {
6692                    dereference: false,
6693                    fail_early: false,
6694                    overwrite: false,
6695                    overwrite_compare: Default::default(),
6696                    overwrite_filter: None,
6697                    ignore_existing: false,
6698                    chunk_size: 0,
6699                    skip_specials: true,
6700                    remote_copy_buffer_size: 0,
6701                    filter: None,
6702                    dry_run: None,
6703                    delete: None,
6704                },
6705                &NO_PRESERVE_SETTINGS,
6706                false,
6707            )
6708            .await?;
6709            assert_eq!(summary.files_copied, 1);
6710            assert_eq!(summary.specials_skipped, 1);
6711            assert!(dst.join("file.txt").exists());
6712            assert!(!dst.join("test.fifo").exists());
6713            Ok(())
6714        }
6715
6716        #[tokio::test]
6717        #[traced_test]
6718        async fn special_file_errors_without_skip_specials() -> Result<(), anyhow::Error> {
6719            let tmp_dir = testutils::setup_test_dir().await?;
6720            let test_path = tmp_dir.as_path();
6721            let src = test_path.join("src_dir");
6722            let dst = test_path.join("dst_dir");
6723            tokio::fs::create_dir(&src).await?;
6724            tokio::fs::write(src.join("file.txt"), "hello").await?;
6725            let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
6726            let result = copy(
6727                &PROGRESS,
6728                &src,
6729                &dst,
6730                &Settings {
6731                    dereference: false,
6732                    fail_early: false,
6733                    overwrite: false,
6734                    overwrite_compare: Default::default(),
6735                    overwrite_filter: None,
6736                    ignore_existing: false,
6737                    chunk_size: 0,
6738                    skip_specials: false,
6739                    remote_copy_buffer_size: 0,
6740                    filter: None,
6741                    dry_run: None,
6742                    delete: None,
6743                },
6744                &NO_PRESERVE_SETTINGS,
6745                false,
6746            )
6747            .await;
6748            assert!(result.is_err());
6749            let err = result.unwrap_err();
6750            assert!(
6751                format!("{:#}", err).contains("unsupported src file type"),
6752                "error should mention unsupported file type, got: {:#}",
6753                err
6754            );
6755            Ok(())
6756        }
6757
6758        #[tokio::test]
6759        #[traced_test]
6760        async fn skip_specials_top_level_socket() -> Result<(), anyhow::Error> {
6761            let tmp_dir = testutils::setup_test_dir().await?;
6762            let test_path = tmp_dir.as_path();
6763            let src_socket = test_path.join("test.sock");
6764            let dst = test_path.join("dst.sock");
6765            let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
6766            let summary = copy(
6767                &PROGRESS,
6768                &src_socket,
6769                &dst,
6770                &Settings {
6771                    dereference: false,
6772                    fail_early: false,
6773                    overwrite: false,
6774                    overwrite_compare: Default::default(),
6775                    overwrite_filter: None,
6776                    ignore_existing: false,
6777                    chunk_size: 0,
6778                    skip_specials: true,
6779                    remote_copy_buffer_size: 0,
6780                    filter: None,
6781                    dry_run: None,
6782                    delete: None,
6783                },
6784                &NO_PRESERVE_SETTINGS,
6785                false,
6786            )
6787            .await?;
6788            assert_eq!(summary.specials_skipped, 1);
6789            assert_eq!(summary.files_copied, 0);
6790            assert!(!dst.exists());
6791            Ok(())
6792        }
6793    }
6794
6795    #[tokio::test]
6796    #[traced_test]
6797    async fn delete_protects_excluded_then_removes_with_delete_excluded()
6798    -> Result<(), anyhow::Error> {
6799        let tmp_dir = testutils::setup_test_dir().await?;
6800        let test_path = tmp_dir.as_path();
6801        let src = test_path.join("foo");
6802        let dst = test_path.join("bar");
6803        copy(
6804            &PROGRESS,
6805            &src,
6806            &dst,
6807            &settings_with_delete(None),
6808            &DO_PRESERVE_SETTINGS,
6809            false,
6810        )
6811        .await?;
6812        // a destination-only file that matches an exclude pattern
6813        tokio::fs::write(dst.join("keep.log"), b"protected").await?;
6814
6815        let mut filter = crate::filter::FilterSettings::new();
6816        filter.add_exclude("*.log")?;
6817
6818        // default --delete: keep.log is protected
6819        let mut settings = settings_with_delete(delete_on());
6820        settings.filter = Some(filter.clone());
6821        copy(
6822            &PROGRESS,
6823            &src,
6824            &dst,
6825            &settings,
6826            &DO_PRESERVE_SETTINGS,
6827            false,
6828        )
6829        .await?;
6830        assert!(
6831            dst.join("keep.log").exists(),
6832            "*.log must be protected by default"
6833        );
6834
6835        // --delete-excluded: keep.log is removed
6836        let mut settings = settings_with_delete(Some(DeleteSettings {
6837            delete_excluded: true,
6838        }));
6839        settings.filter = Some(filter);
6840        copy(
6841            &PROGRESS,
6842            &src,
6843            &dst,
6844            &settings,
6845            &DO_PRESERVE_SETTINGS,
6846            false,
6847        )
6848        .await?;
6849        assert!(!dst.join("keep.log").exists());
6850        Ok(())
6851    }
6852
6853    #[tokio::test]
6854    #[traced_test]
6855    async fn delete_dry_run_reports_without_removing() -> Result<(), anyhow::Error> {
6856        let tmp_dir = testutils::setup_test_dir().await?;
6857        let test_path = tmp_dir.as_path();
6858        let src = test_path.join("foo");
6859        let dst = test_path.join("bar");
6860        copy(
6861            &PROGRESS,
6862            &src,
6863            &dst,
6864            &settings_with_delete(None),
6865            &DO_PRESERVE_SETTINGS,
6866            false,
6867        )
6868        .await?;
6869        tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
6870
6871        let mut settings = settings_with_delete(delete_on());
6872        settings.dry_run = Some(crate::config::DryRunMode::Brief);
6873        let summary = copy(
6874            &PROGRESS,
6875            &src,
6876            &dst,
6877            &settings,
6878            &DO_PRESERVE_SETTINGS,
6879            false,
6880        )
6881        .await?;
6882
6883        // the key invariant: dry-run must NOT remove anything
6884        assert!(
6885            dst.join("stale.txt").exists(),
6886            "dry-run must not remove anything"
6887        );
6888        // rm's dry-run does count would-be removals in files_removed
6889        assert_eq!(summary.rm_summary.files_removed, 1);
6890        Ok(())
6891    }
6892
6893    #[tokio::test]
6894    #[traced_test]
6895    async fn delete_dry_run_does_not_scan_through_named_destination_symlink()
6896    -> Result<(), anyhow::Error> {
6897        let root = testutils::create_temp_dir().await?;
6898        let src = root.join("src");
6899        let outside = root.join("outside");
6900        let dst = root.join("dst");
6901        tokio::fs::create_dir_all(src.join("sub")).await?;
6902        tokio::fs::create_dir_all(outside.join("sub")).await?;
6903        tokio::fs::write(outside.join("sub/stale"), "OUTSIDE").await?;
6904        tokio::fs::symlink(&outside, &dst).await?;
6905        let mut settings = settings_with_delete(delete_on());
6906        settings.dry_run = Some(crate::config::DryRunMode::Brief);
6907        let summary = copy(
6908            &PROGRESS,
6909            &src,
6910            &dst,
6911            &settings,
6912            &NO_PRESERVE_SETTINGS,
6913            false,
6914        )
6915        .await?;
6916        assert_eq!(summary.rm_summary.files_removed, 0);
6917        assert_eq!(
6918            tokio::fs::read_to_string(outside.join("sub/stale")).await?,
6919            "OUTSIDE"
6920        );
6921        Ok(())
6922    }
6923
6924    #[tokio::test]
6925    #[traced_test]
6926    async fn dereferenced_delete_dry_run_scans_its_destination_subtree() -> Result<(), anyhow::Error>
6927    {
6928        let root = testutils::create_temp_dir().await?;
6929        let src = root.join("src");
6930        let target = root.join("target");
6931        let dst = root.join("dst");
6932        tokio::fs::create_dir_all(&src).await?;
6933        tokio::fs::create_dir_all(&target).await?;
6934        tokio::fs::write(target.join("kept"), "SOURCE").await?;
6935        tokio::fs::symlink(&target, src.join("node")).await?;
6936        tokio::fs::create_dir_all(dst.join("node")).await?;
6937        tokio::fs::write(dst.join("node/stale"), "STALE").await?;
6938        let mut settings = settings_with_delete(delete_on());
6939        settings.dry_run = Some(crate::config::DryRunMode::Brief);
6940        settings.dereference = true;
6941
6942        let summary = copy(
6943            &PROGRESS,
6944            &src,
6945            &dst,
6946            &settings,
6947            &NO_PRESERVE_SETTINGS,
6948            false,
6949        )
6950        .await?;
6951
6952        assert_eq!(summary.rm_summary.files_removed, 1);
6953        assert_eq!(summary.rm_summary.directories_removed, 0);
6954        assert!(dst.join("node/stale").exists());
6955        Ok(())
6956    }
6957
6958    #[tokio::test]
6959    #[traced_test]
6960    async fn delete_does_not_prune_when_source_unreadable() -> Result<(), anyhow::Error> {
6961        let tmp_dir = testutils::setup_test_dir().await?;
6962        let test_path = tmp_dir.as_path();
6963        let src = test_path.join("foo");
6964        let dst = test_path.join("bar");
6965        copy(
6966            &PROGRESS,
6967            &src,
6968            &dst,
6969            &settings_with_delete(None),
6970            &DO_PRESERVE_SETTINGS,
6971            false,
6972        )
6973        .await?;
6974        tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
6975        // make the source directory unreadable so enumeration fails
6976        let original = tokio::fs::metadata(&src).await?.permissions();
6977        tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
6978
6979        let result = copy(
6980            &PROGRESS,
6981            &src,
6982            &dst,
6983            &settings_with_delete(delete_on()),
6984            &DO_PRESERVE_SETTINGS,
6985            false,
6986        )
6987        .await;
6988
6989        // restore permissions before asserting (so the temp dir cleans up)
6990        tokio::fs::set_permissions(&src, original).await?;
6991
6992        assert!(result.is_err(), "unreadable source must error");
6993        assert!(
6994            dst.join("stale.txt").exists(),
6995            "destination must not be pruned when source enumeration fails"
6996        );
6997        Ok(())
6998    }
6999
7000    // overwriting an existing regular file goes through fd-relative removal: the destination file
7001    // is removed (counted as a single `files_removed`) and recreated with the source content. This
7002    // exercises `remove_existing`'s `unlink_at` branch through the fd-based copy walk.
7003    #[tokio::test]
7004    #[traced_test]
7005    async fn overwrite_regular_file_removes_and_recreates_via_fd() -> Result<(), anyhow::Error> {
7006        let tmp_dir = testutils::create_temp_dir().await?;
7007        let test_path = tmp_dir.as_path();
7008        let src_file = test_path.join("src.txt");
7009        let dst_file = test_path.join("dst.txt");
7010        // distinct sizes so the metadata comparison treats them as different and overwrite proceeds.
7011        tokio::fs::write(&src_file, "fresh source content").await?;
7012        tokio::fs::write(&dst_file, "old").await?;
7013        let summary = copy(
7014            &PROGRESS,
7015            &src_file,
7016            &dst_file,
7017            &Settings {
7018                dereference: false,
7019                fail_early: false,
7020                overwrite: true, // <- exercises the overwrite removal path
7021                overwrite_compare: filecmp::MetadataCmpSettings {
7022                    size: true,
7023                    mtime: true,
7024                    ..Default::default()
7025                },
7026                overwrite_filter: None,
7027                ignore_existing: false,
7028                chunk_size: 0,
7029                skip_specials: false,
7030                remote_copy_buffer_size: 0,
7031                filter: None,
7032                dry_run: None,
7033                delete: None,
7034            },
7035            &NO_PRESERVE_SETTINGS,
7036            false,
7037        )
7038        .await?;
7039        // the existing regular file was removed (fd-relative unlink_at) and the source recreated.
7040        assert_eq!(summary.files_copied, 1);
7041        assert_eq!(summary.rm_summary.files_removed, 1);
7042        assert_eq!(summary.rm_summary.symlinks_removed, 0);
7043        assert_eq!(summary.rm_summary.directories_removed, 0);
7044        let content = tokio::fs::read_to_string(&dst_file).await?;
7045        assert_eq!(content, "fresh source content");
7046        Ok(())
7047    }
7048
7049    /// The admitted handle is the approved point-in-time metadata snapshot. A later same-type
7050    /// replacement does not force work when that snapshot already matches the destination.
7051    #[tokio::test]
7052    #[traced_test]
7053    async fn admitted_file_replacement_matching_destination_does_not_force_work()
7054    -> Result<(), anyhow::Error> {
7055        let root = testutils::create_temp_dir().await?;
7056        let src = root.join("source");
7057        let dst = root.join("destination");
7058        tokio::fs::write(&src, "SELECTED-A").await?;
7059        tokio::fs::hard_link(&src, &dst).await?;
7060
7061        let admission = testutils::AdmissionLimit::new().await;
7062        admission.set_files_in_flight(1);
7063        let src_parent = Arc::new(
7064            Dir::open_parent_dir(&root, congestion::Side::Source)
7065                .await?
7066                .into_tree(),
7067        );
7068        let dst_parent = Arc::new(
7069            Dir::open_parent_dir(&root, congestion::Side::Destination)
7070                .await?
7071                .into_tree(),
7072        );
7073        let selected = src_parent.child(std::ffi::OsStr::new("source")).await?;
7074        assert_eq!(selected.kind(), EntryKind::File);
7075        let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7076        let entry = AdmittedEntry::new(selected, permit);
7077
7078        tokio::fs::remove_file(&src).await?;
7079        tokio::fs::write(&src, "CURRENT-BY-NAME-REPLACEMENT").await?;
7080        let mut settings = settings_with_delete(None);
7081        settings.overwrite = true;
7082        settings.overwrite_compare = filecmp::MetadataCmpSettings {
7083            size: true,
7084            ..Default::default()
7085        };
7086
7087        let summary = admission
7088            .run_with_timeout(
7089                std::time::Duration::from_secs(20),
7090                copy_child(
7091                    &PROGRESS,
7092                    &src_parent,
7093                    Some(&dst_parent),
7094                    std::ffi::OsStr::new("source"),
7095                    &src,
7096                    &dst,
7097                    std::path::Path::new(""),
7098                    &settings,
7099                    &NO_PRESERVE_SETTINGS,
7100                    false,
7101                    DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7102                    CopyEntryAdmission::Admitted(entry),
7103                ),
7104            )
7105            .await
7106            .context("admitted replacement copy did not finish at a limit of one")??;
7107        let returned = admission
7108            .run_with_timeout(
7109                std::time::Duration::from_secs(20),
7110                throttle::open_file_permit(),
7111            )
7112            .await
7113            .context("admitted replacement copy did not return open-file capacity")?;
7114        drop(returned);
7115
7116        assert_eq!(summary.files_unchanged, 1);
7117        assert_eq!(summary.files_copied, 0);
7118        assert_eq!(summary.rm_summary.files_removed, 0);
7119        assert_eq!(tokio::fs::read_to_string(&dst).await?, "SELECTED-A");
7120        Ok(())
7121    }
7122
7123    /// An action planned from the admitted snapshot must be reconsidered when the opened payload
7124    /// has another identity. The payload fd's paired metadata can turn that replace into a skip.
7125    #[tokio::test]
7126    #[traced_test]
7127    async fn actionable_admitted_file_replacement_replans_against_opened_payload()
7128    -> Result<(), anyhow::Error> {
7129        let root = testutils::create_temp_dir().await?;
7130        let src = root.join("source");
7131        let dst = root.join("destination");
7132        tokio::fs::write(&src, "SELECTED-A").await?;
7133        tokio::fs::write(&dst, "KEEP-ME").await?;
7134
7135        let admission = testutils::AdmissionLimit::new().await;
7136        admission.set_files_in_flight(1);
7137        let src_parent = Arc::new(
7138            Dir::open_parent_dir(&root, congestion::Side::Source)
7139                .await?
7140                .into_tree(),
7141        );
7142        let dst_parent = Arc::new(
7143            Dir::open_parent_dir(&root, congestion::Side::Destination)
7144                .await?
7145                .into_tree(),
7146        );
7147        let selected = src_parent.child(std::ffi::OsStr::new("source")).await?;
7148        assert_eq!(selected.kind(), EntryKind::File);
7149        let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7150        let entry = AdmittedEntry::new(selected, permit);
7151
7152        tokio::fs::remove_file(&src).await?;
7153        tokio::fs::write(&src, "PAYLOAD").await?;
7154        let mut settings = settings_with_delete(None);
7155        settings.overwrite = true;
7156        settings.overwrite_compare = filecmp::MetadataCmpSettings {
7157            size: true,
7158            ..Default::default()
7159        };
7160
7161        let summary = admission
7162            .run_with_timeout(
7163                std::time::Duration::from_secs(20),
7164                copy_child(
7165                    &PROGRESS,
7166                    &src_parent,
7167                    Some(&dst_parent),
7168                    std::ffi::OsStr::new("source"),
7169                    &src,
7170                    &dst,
7171                    std::path::Path::new(""),
7172                    &settings,
7173                    &NO_PRESERVE_SETTINGS,
7174                    false,
7175                    DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7176                    CopyEntryAdmission::Admitted(entry),
7177                ),
7178            )
7179            .await
7180            .context("actionable admitted replacement did not finish at a limit of one")??;
7181        let returned = admission
7182            .run_with_timeout(
7183                std::time::Duration::from_secs(20),
7184                throttle::open_file_permit(),
7185            )
7186            .await
7187            .context("actionable admitted replacement did not return open-file capacity")?;
7188        drop(returned);
7189
7190        assert_eq!(summary.files_unchanged, 1);
7191        assert_eq!(summary.files_copied, 0);
7192        assert_eq!(summary.rm_summary.files_removed, 0);
7193        assert_eq!(tokio::fs::read_to_string(&dst).await?, "KEEP-ME");
7194        Ok(())
7195    }
7196
7197    // ── TOCTOU hardening: -L separability, swap-loop races, fd-budget ────────────────
7198
7199    /// An included dry-run regular file needs only metadata: even when its payload cannot be
7200    /// opened, the preview reports the copy and its snapshotted size without error.
7201    #[tokio::test]
7202    #[traced_test]
7203    async fn dry_run_previews_an_included_unreadable_regular_file() -> Result<(), anyhow::Error> {
7204        let root = testutils::create_temp_dir().await?;
7205        let src = root.join("source");
7206        let dst = root.join("destination");
7207        let contents = b"metadata is enough";
7208        tokio::fs::write(&src, contents).await?;
7209        tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
7210        let mut filter = crate::filter::FilterSettings::new();
7211        filter.add_include("source")?;
7212        let result = copy(
7213            &PROGRESS,
7214            &src,
7215            &dst,
7216            &Settings {
7217                filter: Some(filter),
7218                dry_run: Some(DryRunMode::Brief),
7219                ..settings_with_delete(None)
7220            },
7221            &NO_PRESERVE_SETTINGS,
7222            false,
7223        )
7224        .await;
7225        tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o600)).await?;
7226        let summary = result?;
7227        assert_eq!(summary.files_copied, 1);
7228        assert_eq!(summary.bytes_copied, contents.len() as u64);
7229        assert!(!dst.exists(), "dry-run must not create the destination");
7230        Ok(())
7231    }
7232
7233    /// An identical overwrite is a metadata-only no-op: an unreadable payload must not turn the
7234    /// skip into an error or disturb the destination.
7235    #[tokio::test]
7236    #[traced_test]
7237    async fn identical_overwrite_skips_an_unreadable_regular_file() -> Result<(), anyhow::Error> {
7238        let root = testutils::create_temp_dir().await?;
7239        let src = root.join("source");
7240        let dst = root.join("destination");
7241        tokio::fs::write(&src, "SRC-NEW").await?;
7242        tokio::fs::write(&dst, "KEEP-ME").await?;
7243        tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
7244        let result = copy(
7245            &PROGRESS,
7246            &src,
7247            &dst,
7248            &Settings {
7249                overwrite: true,
7250                overwrite_compare: filecmp::MetadataCmpSettings {
7251                    size: true,
7252                    ..Default::default()
7253                },
7254                ..settings_with_delete(None)
7255            },
7256            &NO_PRESERVE_SETTINGS,
7257            false,
7258        )
7259        .await;
7260        tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o600)).await?;
7261        let summary = result?;
7262        assert_eq!(summary.files_unchanged, 1);
7263        assert_eq!(summary.files_copied, 0);
7264        assert_eq!(summary.rm_summary.files_removed, 0);
7265        assert_eq!(tokio::fs::read_to_string(&dst).await?, "KEEP-ME");
7266        Ok(())
7267    }
7268
7269    /// `--ignore-existing` is a destination-only no-op: an admitted source that disappears after
7270    /// selection must not turn the skip into a source-open error.
7271    #[tokio::test]
7272    #[traced_test]
7273    async fn ignore_existing_skips_before_reopening_an_admitted_missing_source()
7274    -> Result<(), anyhow::Error> {
7275        let root = testutils::create_temp_dir().await?;
7276        let src = root.join("source");
7277        let dst = root.join("destination");
7278        tokio::fs::write(&src, "SOURCE").await?;
7279        tokio::fs::write(&dst, "PRECIOUS").await?;
7280
7281        let admission = testutils::AdmissionLimit::new().await;
7282        admission.set_files_in_flight(1);
7283        let src_parent = Arc::new(
7284            Dir::open_parent_dir(&root, congestion::Side::Source)
7285                .await?
7286                .into_tree(),
7287        );
7288        let dst_parent = Arc::new(
7289            Dir::open_parent_dir(&root, congestion::Side::Destination)
7290                .await?
7291                .into_tree(),
7292        );
7293        let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7294        let entry = crate::walk::classify_admitted_entry(
7295            &src_parent,
7296            std::ffi::OsStr::new("source"),
7297            permit,
7298        )
7299        .await?;
7300        tokio::fs::remove_file(&src).await?;
7301
7302        let summary = run_with_open_file_cleanup(
7303            &admission,
7304            copy_child(
7305                &PROGRESS,
7306                &src_parent,
7307                Some(&dst_parent),
7308                std::ffi::OsStr::new("source"),
7309                &src,
7310                &dst,
7311                std::path::Path::new(""),
7312                &Settings {
7313                    ignore_existing: true,
7314                    ..settings_with_delete(None)
7315                },
7316                &NO_PRESERVE_SETTINGS,
7317                false,
7318                DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7319                CopyEntryAdmission::Admitted(entry),
7320            ),
7321        )
7322        .await??;
7323
7324        assert_eq!(summary.files_unchanged, 1);
7325        assert_eq!(summary.files_copied, 0);
7326        assert_eq!(tokio::fs::read_to_string(&dst).await?, "PRECIOUS");
7327        Ok(())
7328    }
7329
7330    /// A destination conflict is diagnosed before reopening an admitted source that has vanished.
7331    #[tokio::test]
7332    #[traced_test]
7333    async fn no_overwrite_reports_destination_conflict_before_an_admitted_missing_source()
7334    -> Result<(), anyhow::Error> {
7335        let root = testutils::create_temp_dir().await?;
7336        let src = root.join("source");
7337        let dst = root.join("destination");
7338        tokio::fs::write(&src, "SOURCE").await?;
7339        tokio::fs::write(&dst, "PRECIOUS").await?;
7340
7341        let admission = testutils::AdmissionLimit::new().await;
7342        admission.set_files_in_flight(1);
7343        let src_parent = Arc::new(
7344            Dir::open_parent_dir(&root, congestion::Side::Source)
7345                .await?
7346                .into_tree(),
7347        );
7348        let dst_parent = Arc::new(
7349            Dir::open_parent_dir(&root, congestion::Side::Destination)
7350                .await?
7351                .into_tree(),
7352        );
7353        let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7354        let entry = crate::walk::classify_admitted_entry(
7355            &src_parent,
7356            std::ffi::OsStr::new("source"),
7357            permit,
7358        )
7359        .await?;
7360        tokio::fs::remove_file(&src).await?;
7361
7362        let error = run_with_open_file_cleanup(
7363            &admission,
7364            copy_child(
7365                &PROGRESS,
7366                &src_parent,
7367                Some(&dst_parent),
7368                std::ffi::OsStr::new("source"),
7369                &src,
7370                &dst,
7371                std::path::Path::new(""),
7372                &settings_with_delete(None),
7373                &NO_PRESERVE_SETTINGS,
7374                false,
7375                DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7376                CopyEntryAdmission::Admitted(entry),
7377            ),
7378        )
7379        .await?
7380        .expect_err("an existing destination without --overwrite must fail");
7381
7382        let message = format!("{:#}", &error.source);
7383        assert!(
7384            message.contains("did you intend to specify --overwrite?"),
7385            "destination conflict lost precedence: {message}"
7386        );
7387        assert!(
7388            !message.contains("failed opening src file"),
7389            "source acquisition masked the destination conflict: {message}"
7390        );
7391        assert_eq!(tokio::fs::read_to_string(&dst).await?, "PRECIOUS");
7392        Ok(())
7393    }
7394
7395    /// Dry-run `--ignore-existing` also decides the no-op before reopening an admitted source.
7396    #[tokio::test]
7397    #[traced_test]
7398    async fn dry_run_ignore_existing_skips_before_reopening_an_admitted_missing_source()
7399    -> Result<(), anyhow::Error> {
7400        let root = testutils::create_temp_dir().await?;
7401        let src = root.join("source");
7402        let dst = root.join("destination");
7403        tokio::fs::write(&src, "SOURCE").await?;
7404        tokio::fs::write(&dst, "PRECIOUS").await?;
7405
7406        let admission = testutils::AdmissionLimit::new().await;
7407        admission.set_files_in_flight(1);
7408        let src_parent = Arc::new(
7409            Dir::open_parent_dir(&root, congestion::Side::Source)
7410                .await?
7411                .into_tree(),
7412        );
7413        let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7414        let entry = crate::walk::classify_admitted_entry(
7415            &src_parent,
7416            std::ffi::OsStr::new("source"),
7417            permit,
7418        )
7419        .await?;
7420        tokio::fs::remove_file(&src).await?;
7421
7422        let summary = run_with_open_file_cleanup(
7423            &admission,
7424            copy_child(
7425                &PROGRESS,
7426                &src_parent,
7427                None,
7428                std::ffi::OsStr::new("source"),
7429                &src,
7430                &dst,
7431                std::path::Path::new(""),
7432                &Settings {
7433                    ignore_existing: true,
7434                    dry_run: Some(DryRunMode::Brief),
7435                    ..settings_with_delete(None)
7436                },
7437                &NO_PRESERVE_SETTINGS,
7438                false,
7439                DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7440                CopyEntryAdmission::Admitted(entry),
7441            ),
7442        )
7443        .await??;
7444
7445        assert_eq!(summary.files_unchanged, 1);
7446        assert_eq!(summary.files_copied, 0);
7447        assert_eq!(tokio::fs::read_to_string(&dst).await?, "PRECIOUS");
7448        Ok(())
7449    }
7450
7451    /// Default non-dereference copy settings used by the TOCTOU/fd-budget tests below.
7452    /// `overwrite` is on so a fresh destination per iteration is never required to be empty.
7453    fn toctou_settings() -> Settings {
7454        Settings {
7455            dereference: false,
7456            fail_early: false,
7457            overwrite: true,
7458            overwrite_compare: filecmp::MetadataCmpSettings {
7459                size: true,
7460                mtime: true,
7461                ..Default::default()
7462            },
7463            overwrite_filter: None,
7464            ignore_existing: false,
7465            chunk_size: 0,
7466            skip_specials: false,
7467            remote_copy_buffer_size: 0,
7468            filter: None,
7469            dry_run: None,
7470            delete: None,
7471        }
7472    }
7473
7474    // Task 1.4: with `dereference == false`, a symlink in the source is copied as a symlink — the
7475    // non-dereference (fd-based) path is taken and `canonicalize` is never invoked. The
7476    // `debug_assert!(settings.dereference, ...)` guarding the canonicalize call is the actual
7477    // invariant guard (it would fire in debug/tests if a refactor reached it); this test exercises
7478    // that non-dereference symlink path end-to-end and proves the link is preserved as a link.
7479    #[tokio::test]
7480    #[traced_test]
7481    async fn non_dereference_copy_never_canonicalizes() -> Result<(), anyhow::Error> {
7482        let tmp_dir = testutils::create_temp_dir().await?;
7483        let test_path = tmp_dir.as_path();
7484        let src = test_path.join("src");
7485        let dst = test_path.join("dst");
7486        tokio::fs::create_dir(&src).await?;
7487        // a real file plus a symlink to it living inside the source tree.
7488        tokio::fs::write(src.join("real.txt"), "REAL_CONTENT").await?;
7489        tokio::fs::symlink("real.txt", src.join("link")).await?;
7490        let summary = copy(
7491            &PROGRESS,
7492            &src,
7493            &dst,
7494            &toctou_settings(),
7495            &NO_PRESERVE_SETTINGS,
7496            false,
7497        )
7498        .await?;
7499        assert_eq!(summary.files_copied, 1);
7500        assert_eq!(
7501            summary.symlinks_created, 1,
7502            "symlink must be copied as a symlink"
7503        );
7504        // the destination entry must itself be a symlink (non-deref path), not a dereferenced file.
7505        let link_md = tokio::fs::symlink_metadata(dst.join("link")).await?;
7506        assert!(
7507            link_md.file_type().is_symlink(),
7508            "with dereference == false the source symlink must remain a symlink in the destination"
7509        );
7510        // and its target text is preserved verbatim (it was never resolved/canonicalized).
7511        let target = tokio::fs::read_link(dst.join("link")).await?;
7512        assert_eq!(target, std::path::Path::new("real.txt"));
7513        Ok(())
7514    }
7515
7516    // Task 1.5 helper: repeatedly swap `path` between a real regular file (content `REAL_CONTENT`)
7517    // and a symlink pointing at `sentinel`, using rename so each individual state is atomic. Two
7518    // staging names (a prepared symlink and the real file) live alongside `path` and are renamed
7519    // over it in a tight loop until `stop` is set. Runs on a dedicated OS thread (std::thread), so
7520    // it makes progress regardless of the tokio runtime's scheduling.
7521    fn spawn_file_symlink_swapper(
7522        dir: std::path::PathBuf,
7523        entry_name: &'static str,
7524        sentinel: std::path::PathBuf,
7525        stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
7526    ) -> std::thread::JoinHandle<()> {
7527        std::thread::spawn(move || {
7528            let entry = dir.join(entry_name);
7529            let staged_real = dir.join("__staged_real");
7530            let staged_link = dir.join("__staged_link");
7531            while !stop.load(std::sync::atomic::Ordering::Relaxed) {
7532                // prepare the real file at a staging name, then rename it over `entry`.
7533                let _ = std::fs::remove_file(&staged_real);
7534                if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
7535                    continue;
7536                }
7537                let _ = std::fs::rename(&staged_real, &entry);
7538                // prepare a symlink-to-sentinel at the other staging name, then rename it over.
7539                let _ = std::fs::remove_file(&staged_link);
7540                let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
7541                let _ = std::fs::rename(&staged_link, &entry);
7542            }
7543        })
7544    }
7545
7546    // Task 1.5 (a): final-component file<->symlink swap. While the copy runs, `src/sub/entry` is
7547    // rapidly flipped between a real regular file and a symlink to a sentinel outside the tree. The
7548    // copy may grab the real file, or O_NOFOLLOW/fstat may catch the symlink (error or skip), but
7549    // the sentinel's secret content must NEVER end up in the destination as data.
7550    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7551    async fn file_symlink_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
7552        let tmp_dir = testutils::create_temp_dir().await?;
7553        let test_path = tmp_dir.as_path();
7554        // sentinel lives OUTSIDE the source tree with distinctive content.
7555        let sentinel = test_path.join("sentinel_secret");
7556        tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
7557        let src = test_path.join("src");
7558        let sub = src.join("sub");
7559        tokio::fs::create_dir(&src).await?;
7560        tokio::fs::create_dir(&sub).await?;
7561        tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
7562
7563        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7564        let swapper =
7565            spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
7566
7567        let settings = toctou_settings();
7568        let mut caught_swaps = 0usize;
7569        let mut copied_real = 0usize;
7570        for i in 0..200 {
7571            let dst = test_path.join(format!("dst_{i}"));
7572            let result = tokio::time::timeout(
7573                std::time::Duration::from_secs(30),
7574                copy(
7575                    &PROGRESS,
7576                    &src,
7577                    &dst,
7578                    &settings,
7579                    &NO_PRESERVE_SETTINGS,
7580                    false,
7581                ),
7582            )
7583            .await
7584            .expect("copy must not hang under concurrent swapping");
7585            match result {
7586                Ok(_) => {}
7587                Err(_) => caught_swaps += 1, // a swap was caught mid-copy (failed closed)
7588            }
7589            // CORE ASSERTION: if a regular file landed at the destination, it is the real content —
7590            // never the sentinel's secret. The entry may instead be absent or a symlink.
7591            let entry_dst = dst.join("sub").join("entry");
7592            if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
7593                && md.file_type().is_file()
7594            {
7595                let content = tokio::fs::read_to_string(&entry_dst).await?;
7596                assert_ne!(
7597                    content, "SENTINEL_SECRET_CONTENT",
7598                    "iteration {i}: sentinel content leaked into the destination as a regular file"
7599                );
7600                assert_eq!(
7601                    content, "REAL_CONTENT",
7602                    "iteration {i}: a regular destination file must hold the real content"
7603                );
7604                copied_real += 1;
7605            }
7606            let _ = tokio::fs::remove_dir_all(&dst).await;
7607        }
7608
7609        stop.store(true, std::sync::atomic::Ordering::Relaxed);
7610        swapper.join().expect("swapper thread panicked");
7611        // sanity: the run did meaningful work (caught some swaps or copied the real file at least
7612        // once). This is not the safety assertion — the safety assertion is "sentinel never leaks"
7613        // above and holds on every iteration regardless of timing.
7614        tracing::info!("file/symlink swap: caught_swaps={caught_swaps}, copied_real={copied_real}");
7615        assert!(
7616            caught_swaps + copied_real > 0,
7617            "expected at least one observable outcome across 200 iterations"
7618        );
7619        Ok(())
7620    }
7621
7622    // Task 1.5 (b): intermediate-directory swap. While the copy runs, `src/sub` is flipped between a
7623    // real directory (containing a real file) and a symlink to a directory outside the tree holding
7624    // a sentinel. open_dir uses O_NOFOLLOW, so the walk either descends the real directory or fails
7625    // closed — it must never follow the symlink and copy the outside sentinel.
7626    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7627    async fn intermediate_dir_swap_never_follows_symlink() -> Result<(), anyhow::Error> {
7628        let tmp_dir = testutils::create_temp_dir().await?;
7629        let test_path = tmp_dir.as_path();
7630        // an "outside" directory tree holding a sentinel file, reachable only via a symlink.
7631        let outside = test_path.join("outside_dir");
7632        tokio::fs::create_dir(&outside).await?;
7633        tokio::fs::write(outside.join("sentinel.txt"), "SENTINEL_SECRET_CONTENT").await?;
7634        let src = test_path.join("src");
7635        tokio::fs::create_dir(&src).await?;
7636        // the real `sub` directory with a real file inside.
7637        let real_sub = src.join("sub");
7638        tokio::fs::create_dir(&real_sub).await?;
7639        tokio::fs::write(real_sub.join("real.txt"), "REAL_CONTENT").await?;
7640
7641        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7642        let swapper = {
7643            let src = src.clone();
7644            let outside = outside.clone();
7645            let stop = stop.clone();
7646            std::thread::spawn(move || {
7647                let sub = src.join("sub");
7648                let staged_dir = src.join("__staged_sub_dir");
7649                let staged_link = src.join("__staged_sub_link");
7650                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
7651                    // stage a real directory (with the real file) then rename it over `sub`.
7652                    let _ = std::fs::remove_dir_all(&staged_dir);
7653                    if std::fs::create_dir(&staged_dir).is_ok() {
7654                        let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL_CONTENT");
7655                        // RENAME_EXCHANGE isn't available portably here; remove-then-rename. The
7656                        // window where `sub` is briefly absent is fine — the copy may error, which
7657                        // is an accepted failed-closed outcome.
7658                        let _ = std::fs::remove_dir_all(&sub);
7659                        let _ = std::fs::remove_file(&sub);
7660                        let _ = std::fs::rename(&staged_dir, &sub);
7661                    }
7662                    // stage a symlink to the outside dir, then swap it in over `sub`.
7663                    let _ = std::fs::remove_file(&staged_link);
7664                    if std::os::unix::fs::symlink(&outside, &staged_link).is_ok() {
7665                        let _ = std::fs::remove_dir_all(&sub);
7666                        let _ = std::fs::remove_file(&sub);
7667                        let _ = std::fs::rename(&staged_link, &sub);
7668                    }
7669                }
7670            })
7671        };
7672
7673        let settings = toctou_settings();
7674        for i in 0..200 {
7675            let dst = test_path.join(format!("dst_{i}"));
7676            let _ = tokio::time::timeout(
7677                std::time::Duration::from_secs(30),
7678                copy(
7679                    &PROGRESS,
7680                    &src,
7681                    &dst,
7682                    &settings,
7683                    &NO_PRESERVE_SETTINGS,
7684                    false,
7685                ),
7686            )
7687            .await
7688            .expect("copy must not hang under concurrent dir swapping");
7689            // CORE ASSERTION: the walk must never DESCEND through the symlink and copy the outside
7690            // tree's contents as data. Classify `dst/sub` WITHOUT following symlinks: if it is a
7691            // symlink, the link was copied verbatim (safe — `outside` was never read); if it is a
7692            // real directory, the copy descended a real `sub`, and the sentinel — which only ever
7693            // lived in `outside`, reachable solely via the symlink — must NOT have been reproduced
7694            // inside it. (`Path::exists` follows symlinks, so it would falsely "see" the sentinel
7695            // through a verbatim-copied symlink; `symlink_metadata` is the correct probe here.)
7696            // when `dst/sub` is a symlink it was copied verbatim (accepted failed-closed outcome,
7697            // `outside` was never read); only a REAL destination directory means the walk descended,
7698            // and then it must have descended the real `sub` — never the symlink into `outside`.
7699            let sub_dst = dst.join("sub");
7700            if let Ok(sub_md) = tokio::fs::symlink_metadata(&sub_dst).await
7701                && sub_md.file_type().is_dir()
7702            {
7703                let leaked = sub_dst.join("sentinel.txt");
7704                let leaked_is_real_file = tokio::fs::symlink_metadata(&leaked)
7705                    .await
7706                    .map(|m| m.file_type().is_file())
7707                    .unwrap_or(false);
7708                assert!(
7709                    !leaked_is_real_file,
7710                    "iteration {i}: intermediate-dir symlink was followed; the outside \
7711                     sentinel was copied into a real destination directory"
7712                );
7713                // a real `sub` directory copied from the real source holds the real file.
7714                let real_dst = sub_dst.join("real.txt");
7715                if let Ok(content) = tokio::fs::read_to_string(&real_dst).await {
7716                    assert_eq!(
7717                        content, "REAL_CONTENT",
7718                        "iteration {i}: copied file must hold real content"
7719                    );
7720                }
7721            }
7722            let _ = tokio::fs::remove_dir_all(&dst).await;
7723        }
7724
7725        stop.store(true, std::sync::atomic::Ordering::Relaxed);
7726        swapper.join().expect("dir swapper thread panicked");
7727        Ok(())
7728    }
7729
7730    // Task 1.5 (d): destination NON-EMPTY-directory overwrite removal under an intermediate-component
7731    // swap — the racy mirror of `test_remote_dest_overwrite_replaces_intermediate_symlink_in_tree`,
7732    // targeting the removal path B1 hardened. The source has regular files at `src/mid/deeper/subK`,
7733    // while the destination has NON-EMPTY directories at `dst/mid/deeper/subK`. A `--overwrite` copy
7734    // must therefore REMOVE those non-empty directories (`remove_existing` → ENOTEMPTY →
7735    // `rm::rm_child`) before creating the files. Concurrently, a swapper flips the INTERMEDIATE
7736    // component `dst/mid` between a real directory (holding `deeper/subK`) and a symlink to an
7737    // out-of-tree directory mirroring `deeper/subK/`, whose `subK/` each hold a sentinel.
7738    //
7739    // This is a fail-closed STRESS test: under the fd code the copy either descends a real `dst/mid`
7740    // and removes through the pinned `deeper` fd, or fails closed at the intermediate `make_dir`
7741    // O_NOFOLLOW when it observes the symlink — never escaping. CORE ASSERTION: no out-of-tree
7742    // sentinel is ever deleted, and the copy never hangs. (Because the walk fails closed at the
7743    // intermediate `make_dir` whenever it sees the symlink, this racy form rarely even reaches the
7744    // removal branch and does NOT reliably reproduce the path-based escape; the deterministic
7745    // `nonempty_dir_overwrite_removal_uses_pinned_fd_not_path` below is the regression-catcher that
7746    // FAILS when the branch is reverted to `rm::rm`.)
7747    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7748    async fn dest_nonempty_dir_overwrite_removal_contained_under_intermediate_swap()
7749    -> Result<(), anyhow::Error> {
7750        const N: usize = 8;
7751        let tmp_dir = testutils::create_temp_dir().await?;
7752        let test_path = tmp_dir.as_path();
7753        // an out-of-tree tree mirroring `deeper/`, holding several non-empty `subK/` each guarding a
7754        // sentinel. A path-based rm(dst/mid/deeper/subK) through a `mid`->out_of_tree symlink would
7755        // delete out_of_tree/deeper/subK. Several targets widen the window: once `mid` flips
7756        // to a symlink mid-copy (after `deeper` was opened), every remaining path-based rm escapes.
7757        let out_of_tree = test_path.join("out_of_tree");
7758        let oot_deeper = out_of_tree.join("deeper");
7759        for k in 0..N {
7760            let oot_sub = oot_deeper.join(format!("sub{k}"));
7761            tokio::fs::create_dir_all(&oot_sub).await?;
7762            tokio::fs::write(oot_sub.join("sentinel.txt"), "SENTINEL").await?;
7763        }
7764
7765        // source: src/mid/deeper/subK are regular FILES (so the copy must replace the dst dirs).
7766        let src = test_path.join("src");
7767        let src_deeper = src.join("mid").join("deeper");
7768        tokio::fs::create_dir_all(&src_deeper).await?;
7769        for k in 0..N {
7770            tokio::fs::write(src_deeper.join(format!("sub{k}")), "REAL_FILE_CONTENT").await?;
7771        }
7772
7773        // destination root persists; the swapper owns the intermediate `dst/mid`.
7774        let dst = test_path.join("dst");
7775        tokio::fs::create_dir(&dst).await?;
7776
7777        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7778        let swapper = {
7779            let dst = dst.clone();
7780            let out_of_tree = out_of_tree.clone();
7781            let stop = stop.clone();
7782            std::thread::spawn(move || {
7783                let mid = dst.join("mid");
7784                let staged_dir = dst.join("__staged_mid_dir");
7785                let staged_link = dst.join("__staged_mid_link");
7786                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
7787                    // stage a real `mid` directory holding `deeper/subK` as NON-EMPTY directories,
7788                    // then rename it over `mid`. This is the state in which the overwrite-removal
7789                    // branch fires (src has FILES at mid/deeper/subK, dst has non-empty dirs).
7790                    let _ = std::fs::remove_dir_all(&staged_dir);
7791                    let staged_deeper = staged_dir.join("deeper");
7792                    if std::fs::create_dir_all(&staged_deeper).is_ok() {
7793                        for k in 0..N {
7794                            let s = staged_deeper.join(format!("sub{k}"));
7795                            if std::fs::create_dir(&s).is_ok() {
7796                                let _ = std::fs::write(s.join("inner.txt"), b"INNER_CONTENT");
7797                            }
7798                        }
7799                        let _ = std::fs::remove_dir_all(&mid);
7800                        let _ = std::fs::remove_file(&mid);
7801                        let _ = std::fs::rename(&staged_dir, &mid);
7802                    }
7803                    // stage a symlink to the out-of-tree directory, then swap it in over `mid`.
7804                    let _ = std::fs::remove_file(&staged_link);
7805                    if std::os::unix::fs::symlink(&out_of_tree, &staged_link).is_ok() {
7806                        let _ = std::fs::remove_dir_all(&mid);
7807                        let _ = std::fs::remove_file(&mid);
7808                        let _ = std::fs::rename(&staged_link, &mid);
7809                    }
7810                }
7811            })
7812        };
7813
7814        let settings = toctou_settings();
7815        for i in 0..200 {
7816            let _ = tokio::time::timeout(
7817                std::time::Duration::from_secs(30),
7818                copy(
7819                    &PROGRESS,
7820                    &src,
7821                    &dst,
7822                    &settings,
7823                    &NO_PRESERVE_SETTINGS,
7824                    false,
7825                ),
7826            )
7827            .await
7828            .expect("copy must not hang under concurrent intermediate-component swapping");
7829            // CORE ASSERTION (every iteration, regardless of timing): no out-of-tree sentinel is
7830            // ever deleted. The fd-relative removal is contained to the pinned `dst/mid/deeper` fd
7831            // and its O_NOFOLLOW descent fails closed on a swapped-in symlink — it can never
7832            // re-resolve `dst/mid/deeper/subK` through a `mid`->out_of_tree symlink and delete
7833            // `out_of_tree/deeper/subK`.
7834            for k in 0..N {
7835                assert!(
7836                    oot_deeper
7837                        .join(format!("sub{k}"))
7838                        .join("sentinel.txt")
7839                        .exists(),
7840                    "iteration {i}: out-of-tree sentinel sub{k} was deleted — the non-empty-\
7841                     directory overwrite removal escaped the destination tree through a swapped \
7842                     intermediate symlink (path-based rm regression)"
7843                );
7844            }
7845        }
7846
7847        stop.store(true, std::sync::atomic::Ordering::Relaxed);
7848        swapper
7849            .join()
7850            .expect("intermediate-swap swapper thread panicked");
7851        // sanity: the out-of-tree subtree itself also survived intact.
7852        assert!(
7853            oot_deeper.join("sub0").is_dir(),
7854            "out-of-tree sub directory must survive"
7855        );
7856        Ok(())
7857    }
7858
7859    // deterministic companion to the racy test above: non-empty-directory overwrite removal acts
7860    // through the pinned parent fd, never by re-resolving the display path. We open the real parent
7861    // directory as `dst_parent`, classify its non-empty child, but pass a `dst_path` whose
7862    // intermediate component is an out-of-tree symlink. The fd-relative `rm_child` removes the real
7863    // child through the held fd and leaves the out-of-tree tree untouched; a path-based
7864    // `rm::rm(dst_path)` would follow the display path and delete the out-of-tree subtree.
7865    #[tokio::test]
7866    #[traced_test]
7867    async fn nonempty_dir_overwrite_removal_uses_pinned_fd_not_path() -> Result<(), anyhow::Error> {
7868        let tmp_dir = testutils::create_temp_dir().await?;
7869        let test_path = tmp_dir.as_path();
7870
7871        // the REAL destination tree: base/realdeeper/victim is a NON-EMPTY directory.
7872        let real_deeper = test_path.join("base").join("realdeeper");
7873        let real_victim = real_deeper.join("victim");
7874        tokio::fs::create_dir_all(&real_victim).await?;
7875        tokio::fs::write(real_victim.join("inner.txt"), "INNER").await?;
7876
7877        // an out-of-tree tree the stale display path would resolve into. Its `victim/` holds a
7878        // sentinel; a path-based rm of the redirected path would delete it.
7879        let out_of_tree = test_path.join("out_of_tree");
7880        let oot_victim = out_of_tree.join("realdeeper").join("victim");
7881        tokio::fs::create_dir_all(&oot_victim).await?;
7882        tokio::fs::write(oot_victim.join("sentinel.txt"), "SENTINEL").await?;
7883
7884        // the redirected display path: base/mid -> out_of_tree, so `mid/realdeeper/victim`
7885        // resolves to out_of_tree/realdeeper/victim by PATH, but the pinned fd points at the real
7886        // base/realdeeper.
7887        let mid_link = test_path.join("base").join("mid");
7888        tokio::fs::symlink(&out_of_tree, &mid_link).await?;
7889        let stale_dst_path = mid_link.join("realdeeper").join("victim");
7890
7891        // open the REAL parent fd and classify the real non-empty child through it.
7892        let dst_parent =
7893            Arc::new(Dir::open_root_dir(&real_deeper, false, congestion::Side::Destination).await?);
7894        let victim_name: &OsStr = OsStr::new("victim");
7895        let dst_handle = dst_parent.child(victim_name).await?;
7896        assert_eq!(dst_handle.kind(), EntryKind::Dir);
7897
7898        let settings = toctou_settings();
7899        let summary = remove_existing(
7900            &PROGRESS,
7901            &dst_parent,
7902            victim_name,
7903            &stale_dst_path,
7904            dst_handle.into_removal_snapshot(),
7905            &settings,
7906        )
7907        .await?;
7908        assert_eq!(
7909            summary.directories_removed, 1,
7910            "the real non-empty victim directory should have been removed via the pinned fd"
7911        );
7912
7913        // the real child was removed through the held fd...
7914        assert!(
7915            !real_victim.exists(),
7916            "the real non-empty victim should have been removed through the pinned parent fd"
7917        );
7918        // ...and the out-of-tree subtree (which the stale path resolves into) was NOT touched.
7919        assert!(
7920            oot_victim.join("sentinel.txt").exists(),
7921            "out-of-tree sentinel was deleted — removal re-resolved the display path through the \
7922             intermediate symlink instead of using the pinned fd (path-based rm regression)"
7923        );
7924        Ok(())
7925    }
7926
7927    // regression: `is_fresh` is an optimization hint ("we just created the parent directory, so it
7928    // must be empty"), not an enforceable invariant. a concurrent writer can populate a directory we
7929    // believe we just created: another source operand of the SAME invocation whose destination
7930    // aliases ours (`rcp A/x B/x dst/` maps both operands to `dst/x` and dispatches them
7931    // concurrently), a second `rcp` process, or a destination filesystem that folds two source names
7932    // onto one. passing `is_fresh = true` below while `dst`/`dst/sub` already exist reproduces that
7933    // race deterministically, without needing actual concurrency.
7934    #[tokio::test]
7935    async fn copies_into_a_fresh_marked_destination_another_writer_populated()
7936    -> Result<(), anyhow::Error> {
7937        let tmp = testutils::create_temp_dir().await?;
7938        let src = tmp.join("src");
7939        tokio::fs::create_dir(&src).await?;
7940        tokio::fs::create_dir(src.join("sub")).await?;
7941        tokio::fs::write(src.join("sub").join("a.txt"), "a").await?;
7942        // dst and dst/sub already exist (someone got there first).
7943        let dst = tmp.join("dst");
7944        tokio::fs::create_dir(&dst).await?;
7945        tokio::fs::create_dir(dst.join("sub")).await?;
7946        let summary = copy(
7947            &PROGRESS,
7948            &src,
7949            &dst,
7950            &settings_with_delete(None),
7951            &NO_PRESERVE_SETTINGS,
7952            true, // caller believes it is fresh
7953        )
7954        .await?;
7955        assert_eq!(summary.directories_created, 0);
7956        assert_eq!(summary.directories_unchanged, 2);
7957        assert_eq!(summary.files_copied, 1);
7958        assert_eq!(
7959            tokio::fs::read_to_string(dst.join("sub").join("a.txt")).await?,
7960            "a"
7961        );
7962        Ok(())
7963    }
7964
7965    // a reused directory's children must receive `is_fresh: false` rather than inheriting the
7966    // caller's stale belief. This becomes observable when a child leaf already conflicts with the
7967    // source: with `dst/sub/a.txt` pre-existing here, a leaked `is_fresh == true` would skip the
7968    // conflict-check/overwrite block in `copy_file_fd` and fail on `create_file`'s `O_EXCL` instead
7969    // of overwriting.
7970    #[tokio::test]
7971    async fn stale_fresh_hint_overwrites_preexisting_file() -> Result<(), anyhow::Error> {
7972        let tmp = testutils::create_temp_dir().await?;
7973        let src = tmp.join("src");
7974        tokio::fs::create_dir(&src).await?;
7975        tokio::fs::create_dir(src.join("sub")).await?;
7976        tokio::fs::write(src.join("sub").join("a.txt"), "new").await?;
7977        let dst = tmp.join("dst");
7978        tokio::fs::create_dir(&dst).await?;
7979        tokio::fs::create_dir(dst.join("sub")).await?;
7980        tokio::fs::write(dst.join("sub").join("a.txt"), "old-content-differs").await?;
7981        let summary = copy(
7982            &PROGRESS,
7983            &src,
7984            &dst,
7985            &settings_with_delete(delete_on()), // --delete implies --overwrite
7986            &NO_PRESERVE_SETTINGS,
7987            true, // caller believes it is fresh, but another writer got here first
7988        )
7989        .await?;
7990        assert_eq!(summary.files_copied, 1);
7991        assert_eq!(
7992            tokio::fs::read_to_string(dst.join("sub").join("a.txt")).await?,
7993            "new"
7994        );
7995        Ok(())
7996    }
7997
7998    // the tests below cover the FILE sibling of the stale-hint problem: the caller passes
7999    // `is_fresh = true` (the parent directory was just created, so it is presumed empty) while the
8000    // destination file already exists, because another writer got there first. a file operand is the
8001    // most direct way to reach `copy_file_fd` with `is_fresh == true` — it is also a real production
8002    // shape, since `rlink` delegates a single file to `copy_child` with the freshness of the
8003    // directory it just created. every conflict decision (`--overwrite`, `--ignore-existing`, an
8004    // identical file under `--overwrite-compare`, and the no-`--overwrite` error) must be reached
8005    // from here, not just from the `!is_fresh` route.
8006
8007    #[tokio::test]
8008    async fn overwrites_a_pre_existing_file_in_a_fresh_marked_destination()
8009    -> Result<(), anyhow::Error> {
8010        let tmp = testutils::create_temp_dir().await?;
8011        let src_file = tmp.join("src.txt");
8012        let dst_file = tmp.join("dst.txt");
8013        // distinct sizes so the metadata comparison treats them as different and overwrite proceeds.
8014        tokio::fs::write(&src_file, "fresh source content").await?;
8015        tokio::fs::write(&dst_file, "old").await?;
8016        let summary = copy(
8017            &PROGRESS,
8018            &src_file,
8019            &dst_file,
8020            &Settings {
8021                overwrite: true,
8022                ..settings_with_delete(None)
8023            },
8024            &NO_PRESERVE_SETTINGS,
8025            true, // caller believes it is fresh, but another writer got here first
8026        )
8027        .await?;
8028        assert_eq!(summary.files_copied, 1);
8029        assert_eq!(summary.rm_summary.files_removed, 1);
8030        assert_eq!(
8031            tokio::fs::read_to_string(&dst_file).await?,
8032            "fresh source content"
8033        );
8034        Ok(())
8035    }
8036
8037    #[tokio::test]
8038    async fn ignores_a_pre_existing_file_in_a_fresh_marked_destination() -> Result<(), anyhow::Error>
8039    {
8040        let tmp = testutils::create_temp_dir().await?;
8041        let src_file = tmp.join("src.txt");
8042        let dst_file = tmp.join("dst.txt");
8043        tokio::fs::write(&src_file, "fresh source content").await?;
8044        tokio::fs::write(&dst_file, "old").await?;
8045        let summary = copy(
8046            &PROGRESS,
8047            &src_file,
8048            &dst_file,
8049            &Settings {
8050                ignore_existing: true,
8051                ..settings_with_delete(None)
8052            },
8053            &NO_PRESERVE_SETTINGS,
8054            true,
8055        )
8056        .await?;
8057        assert_eq!(summary.files_unchanged, 1);
8058        assert_eq!(summary.files_copied, 0);
8059        assert_eq!(summary.rm_summary.files_removed, 0);
8060        assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "old");
8061        Ok(())
8062    }
8063
8064    // `--overwrite` with a destination the comparison deems identical must leave it alone rather
8065    // than remove and rewrite it. compares on size only so the fixture doesn't have to match mtimes.
8066    #[tokio::test]
8067    async fn skips_an_identical_pre_existing_file_in_a_fresh_marked_destination()
8068    -> Result<(), anyhow::Error> {
8069        let tmp = testutils::create_temp_dir().await?;
8070        let src_file = tmp.join("src.txt");
8071        let dst_file = tmp.join("dst.txt");
8072        tokio::fs::write(&src_file, "abc").await?;
8073        tokio::fs::write(&dst_file, "xyz").await?;
8074        let summary = copy(
8075            &PROGRESS,
8076            &src_file,
8077            &dst_file,
8078            &Settings {
8079                overwrite: true,
8080                overwrite_compare: filecmp::MetadataCmpSettings {
8081                    size: true,
8082                    ..Default::default()
8083                },
8084                ..settings_with_delete(None)
8085            },
8086            &NO_PRESERVE_SETTINGS,
8087            true,
8088        )
8089        .await?;
8090        assert_eq!(summary.files_unchanged, 1);
8091        assert_eq!(summary.files_copied, 0);
8092        assert_eq!(summary.rm_summary.files_removed, 0);
8093        // untouched: an identical destination is not removed and recreated.
8094        assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "xyz");
8095        Ok(())
8096    }
8097
8098    // without `--overwrite` the conflict is still an error — but it must be the actionable one that
8099    // names the flag, not the raw `EEXIST` from `create_file`'s `O_EXCL`.
8100    #[tokio::test]
8101    async fn reports_missing_overwrite_for_a_pre_existing_file_in_a_fresh_marked_destination()
8102    -> Result<(), anyhow::Error> {
8103        let tmp = testutils::create_temp_dir().await?;
8104        let src_file = tmp.join("src.txt");
8105        let dst_file = tmp.join("dst.txt");
8106        tokio::fs::write(&src_file, "fresh source content").await?;
8107        tokio::fs::write(&dst_file, "old").await?;
8108        let error = copy(
8109            &PROGRESS,
8110            &src_file,
8111            &dst_file,
8112            &settings_with_delete(None), // neither --overwrite nor --ignore-existing
8113            &NO_PRESERVE_SETTINGS,
8114            true,
8115        )
8116        .await
8117        .expect_err("a pre-existing destination without --overwrite must fail");
8118        let message = format!("{:#}", &error.source);
8119        assert!(
8120            message.contains("did you intend to specify --overwrite?"),
8121            "expected the actionable overwrite error, got: {message}"
8122        );
8123        // and the destination is left exactly as it was.
8124        assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "old");
8125        Ok(())
8126    }
8127
8128    // the `!is_fresh` counterpart of `ignores_a_pre_existing_file_in_a_fresh_marked_destination`:
8129    // `--ignore-existing` on a real (non-dry-run) local file copy had no coverage here, only in
8130    // dry-run and remote tests. pins that the route the helper is shared with keeps its behavior.
8131    #[tokio::test]
8132    async fn ignores_a_pre_existing_file_when_the_destination_is_not_fresh()
8133    -> Result<(), anyhow::Error> {
8134        let tmp = testutils::create_temp_dir().await?;
8135        let src_file = tmp.join("src.txt");
8136        let dst_file = tmp.join("dst.txt");
8137        tokio::fs::write(&src_file, "fresh source content").await?;
8138        tokio::fs::write(&dst_file, "old").await?;
8139        let summary = copy(
8140            &PROGRESS,
8141            &src_file,
8142            &dst_file,
8143            &Settings {
8144                ignore_existing: true,
8145                ..settings_with_delete(None)
8146            },
8147            &NO_PRESERVE_SETTINGS,
8148            false,
8149        )
8150        .await?;
8151        assert_eq!(summary.files_unchanged, 1);
8152        assert_eq!(summary.files_copied, 0);
8153        assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "old");
8154        Ok(())
8155    }
8156
8157    // the source must open successfully before the destination is unlinked. an unreadable source
8158    // deterministically exercises the interval after planning but before mutation, and must leave
8159    // the destination intact. The same ordering covers cancellation and a long `--iops-throttle`
8160    // wait, neither of which is injectable here.
8161    #[tokio::test]
8162    async fn overwrite_keeps_the_destination_when_the_source_cannot_be_opened()
8163    -> Result<(), anyhow::Error> {
8164        let tmp = testutils::create_temp_dir().await?;
8165        let src = tmp.join("src");
8166        tokio::fs::create_dir(&src).await?;
8167        tokio::fs::write(src.join("a.txt"), "new content").await?;
8168        // unreadable source file; the parent directory stays traversable so the walk still
8169        // classifies it and the copy still plans to replace the destination.
8170        tokio::fs::set_permissions(src.join("a.txt"), std::fs::Permissions::from_mode(0o000))
8171            .await?;
8172        let dst = tmp.join("dst");
8173        tokio::fs::create_dir(&dst).await?;
8174        // different size, so `--overwrite-compare` says the destination differs and must go.
8175        tokio::fs::write(dst.join("a.txt"), "PRECIOUS").await?;
8176        let error = copy(
8177            &PROGRESS,
8178            &src,
8179            &dst,
8180            &Settings {
8181                overwrite: true,
8182                ..settings_with_delete(None)
8183            },
8184            &NO_PRESERVE_SETTINGS,
8185            false,
8186        )
8187        .await
8188        .expect_err("an unreadable source must fail the copy");
8189        let message = format!("{:#}", &error.source);
8190        assert!(
8191            message.contains("failed opening src file"),
8192            "expected the source open to be the failure; got: {message}"
8193        );
8194        assert_eq!(
8195            tokio::fs::read_to_string(dst.join("a.txt")).await?,
8196            "PRECIOUS",
8197            "the destination must survive a copy that never got far enough to replace it"
8198        );
8199        assert_eq!(
8200            error.summary.rm_summary.files_removed, 0,
8201            "nothing was removed, and the summary must say so"
8202        );
8203        Ok(())
8204    }
8205
8206    // `execute_dst_plan` is the ONLY mutating step, and it accumulates rather than overwrites: a
8207    // copy can plan the slot twice — once up front, then again after `create_file` reports it
8208    // refilled by a competing writer — and the first removal has to survive into the totals.
8209    #[tokio::test]
8210    async fn executing_a_second_replace_keeps_the_first_removal_in_the_totals()
8211    -> Result<(), anyhow::Error> {
8212        let tmp = testutils::create_temp_dir().await?;
8213        tokio::fs::write(tmp.join("victim.txt"), "0123456").await?; // 7 bytes
8214        let dst_parent =
8215            Arc::new(Dir::open_root_dir(&tmp, false, congestion::Side::Destination).await?);
8216        let victim: &OsStr = OsStr::new("victim.txt");
8217        let plan = FilePlan::Replace(dst_parent.child(victim).await?.into_removal_snapshot());
8218        // as if an earlier removal had already happened in this same copy.
8219        let mut copy_summary = Summary {
8220            rm_summary: RmSummary {
8221                files_removed: 1,
8222                bytes_removed: 10,
8223                ..Default::default()
8224            },
8225            ..Default::default()
8226        };
8227        execute_dst_plan(
8228            &PROGRESS,
8229            &dst_parent,
8230            victim,
8231            &tmp.join("victim.txt"),
8232            plan,
8233            &settings_with_delete(None),
8234            &mut copy_summary,
8235        )
8236        .await?;
8237        assert_eq!(copy_summary.rm_summary.files_removed, 2);
8238        assert_eq!(copy_summary.rm_summary.bytes_removed, 17);
8239        assert!(!tmp.join("victim.txt").exists(), "the entry was unlinked");
8240        // and `Vacant` must be a genuine no-op on the same summary.
8241        execute_dst_plan(
8242            &PROGRESS,
8243            &dst_parent,
8244            victim,
8245            &tmp.join("victim.txt"),
8246            FilePlan::Vacant,
8247            &settings_with_delete(None),
8248            &mut copy_summary,
8249        )
8250        .await?;
8251        assert_eq!(copy_summary.rm_summary.files_removed, 2);
8252        Ok(())
8253    }
8254
8255    // a competing writer may replace the planned entry before execution. overwrite removes the
8256    // current contained entry, while its accounting retains the metadata captured during planning.
8257    #[tokio::test]
8258    async fn executing_a_swapped_replace_removes_the_current_entry_and_keeps_earlier_totals()
8259    -> Result<(), anyhow::Error> {
8260        let tmp = testutils::create_temp_dir().await?;
8261        tokio::fs::write(tmp.join("victim.txt"), "original").await?;
8262        let dst_parent =
8263            Arc::new(Dir::open_root_dir(&tmp, false, congestion::Side::Destination).await?);
8264        let victim: &OsStr = OsStr::new("victim.txt");
8265        let plan = FilePlan::Replace(dst_parent.child(victim).await?.into_removal_snapshot());
8266        // a competing writer swaps in a different inode under the same name after we planned.
8267        tokio::fs::remove_file(tmp.join("victim.txt")).await?;
8268        tokio::fs::write(tmp.join("victim.txt"), "someone else's file").await?;
8269        let mut copy_summary = Summary {
8270            rm_summary: RmSummary {
8271                files_removed: 1,
8272                bytes_removed: 10,
8273                ..Default::default()
8274            },
8275            ..Default::default()
8276        };
8277        execute_dst_plan(
8278            &PROGRESS,
8279            &dst_parent,
8280            victim,
8281            &tmp.join("victim.txt"),
8282            plan,
8283            &settings_with_delete(None),
8284            &mut copy_summary,
8285        )
8286        .await?;
8287        assert_eq!(copy_summary.rm_summary.files_removed, 2);
8288        assert_eq!(copy_summary.rm_summary.bytes_removed, 18);
8289        assert!(
8290            !tmp.join("victim.txt").exists(),
8291            "overwrite removes the entry currently occupying the contained slot"
8292        );
8293        Ok(())
8294    }
8295
8296    #[tokio::test]
8297    async fn file_plan_swapped_to_symlink_unlinks_the_slot_and_keeps_file_snapshot_accounting()
8298    -> Result<(), anyhow::Error> {
8299        let tmp = testutils::create_temp_dir().await?;
8300        let dst = tmp.join("dst");
8301        tokio::fs::create_dir(&dst).await?;
8302        let victim_path = dst.join("victim.txt");
8303        let sentinel_path = tmp.join("sentinel.txt");
8304        tokio::fs::write(&victim_path, "original").await?;
8305        tokio::fs::write(&sentinel_path, "keep me").await?;
8306        let dst_parent =
8307            Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
8308        let victim: &OsStr = OsStr::new("victim.txt");
8309        let plan = FilePlan::Replace(dst_parent.child(victim).await?.into_removal_snapshot());
8310
8311        // a competing writer replaces the planned file with a link to an external target.
8312        tokio::fs::remove_file(&victim_path).await?;
8313        tokio::fs::symlink(&sentinel_path, &victim_path).await?;
8314
8315        let mut copy_summary = Summary::default();
8316        execute_dst_plan(
8317            &PROGRESS,
8318            &dst_parent,
8319            victim,
8320            &victim_path,
8321            plan,
8322            &settings_with_delete(None),
8323            &mut copy_summary,
8324        )
8325        .await?;
8326
8327        assert!(
8328            tokio::fs::symlink_metadata(&victim_path).await.is_err(),
8329            "overwrite must unlink the replacement symlink"
8330        );
8331        assert_eq!(tokio::fs::read_to_string(&sentinel_path).await?, "keep me");
8332        assert_eq!(copy_summary.rm_summary.files_removed, 1);
8333        assert_eq!(copy_summary.rm_summary.symlinks_removed, 0);
8334        assert_eq!(copy_summary.rm_summary.bytes_removed, 8);
8335        Ok(())
8336    }
8337
8338    // the pure half of the same accounting rule: when the retry's plan says SKIP, a removal the
8339    // copy already performed still has to reach the summary the caller returns.
8340    #[test]
8341    fn a_skip_keeps_an_earlier_removal_in_the_totals() {
8342        let copy_summary = Summary {
8343            rm_summary: RmSummary {
8344                files_removed: 1,
8345                bytes_removed: 10,
8346                ..Default::default()
8347            },
8348            ..Default::default()
8349        };
8350        let skip = Summary {
8351            files_unchanged: 1,
8352            ..Default::default()
8353        };
8354        let summary = skip_summary(skip, &copy_summary);
8355        assert_eq!(summary.files_unchanged, 1);
8356        assert_eq!(summary.rm_summary.files_removed, 1);
8357        assert_eq!(summary.rm_summary.bytes_removed, 10);
8358    }
8359
8360    // the two tests below are NOT regression tests for the file fix above — they PASS on the code
8361    // that preceded it, and that is the point. `copy_symlink_fd` never consults `is_fresh` in its
8362    // create path: it attempts `symlink_at` unconditionally and resolves any `AlreadyExists` through
8363    // the normal ignore-existing / overwrite logic, which is the shape `copy_file_fd` was changed to
8364    // adopt. these pin that the symlink sibling stays immune to a stale freshness hint, so a later
8365    // refactor that teaches its `AlreadyExists` branch to trust `is_fresh` fails here instead of
8366    // silently reintroducing the file bug on the symlink path.
8367
8368    #[tokio::test]
8369    async fn stale_fresh_hint_overwrites_preexisting_symlink() -> Result<(), anyhow::Error> {
8370        let tmp = testutils::create_temp_dir().await?;
8371        let src_link = tmp.join("src.link");
8372        let dst_link = tmp.join("dst.link");
8373        tokio::fs::symlink("target-a", &src_link).await?;
8374        tokio::fs::symlink("target-b", &dst_link).await?;
8375        let summary = copy(
8376            &PROGRESS,
8377            &src_link,
8378            &dst_link,
8379            &Settings {
8380                overwrite: true,
8381                ..settings_with_delete(None)
8382            },
8383            &NO_PRESERVE_SETTINGS,
8384            true, // caller believes it is fresh, but another writer got here first
8385        )
8386        .await?;
8387        assert_eq!(summary.symlinks_created, 1);
8388        assert_eq!(summary.rm_summary.symlinks_removed, 1);
8389        assert_eq!(
8390            tokio::fs::read_link(&dst_link).await?.to_str(),
8391            Some("target-a")
8392        );
8393        Ok(())
8394    }
8395
8396    #[tokio::test]
8397    async fn stale_fresh_hint_ignores_preexisting_symlink() -> Result<(), anyhow::Error> {
8398        let tmp = testutils::create_temp_dir().await?;
8399        let src_link = tmp.join("src.link");
8400        let dst_link = tmp.join("dst.link");
8401        tokio::fs::symlink("target-a", &src_link).await?;
8402        tokio::fs::symlink("target-b", &dst_link).await?;
8403        let summary = copy(
8404            &PROGRESS,
8405            &src_link,
8406            &dst_link,
8407            &Settings {
8408                ignore_existing: true,
8409                ..settings_with_delete(None)
8410            },
8411            &NO_PRESERVE_SETTINGS,
8412            true,
8413        )
8414        .await?;
8415        assert_eq!(summary.symlinks_unchanged, 1);
8416        assert_eq!(summary.symlinks_created, 0);
8417        assert_eq!(
8418            tokio::fs::read_link(&dst_link).await?.to_str(),
8419            Some("target-b")
8420        );
8421        Ok(())
8422    }
8423
8424    // Task 1.5 (c): FIFO swap. While the copy runs, `src/sub/entry` is flipped between a real file
8425    // and a FIFO (named pipe). open_file_read uses O_NONBLOCK + an fstat S_ISREG check, so a FIFO is
8426    // rejected without blocking. The whole copy is wrapped in a generous timeout: the assertion is
8427    // that it never hangs, and a FIFO is never copied as file data.
8428    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8429    async fn fifo_swap_never_hangs_or_copies_pipe() -> Result<(), anyhow::Error> {
8430        let tmp_dir = testutils::create_temp_dir().await?;
8431        let test_path = tmp_dir.as_path();
8432        let src = test_path.join("src");
8433        let sub = src.join("sub");
8434        tokio::fs::create_dir(&src).await?;
8435        tokio::fs::create_dir(&sub).await?;
8436        tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
8437
8438        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
8439        let swapper = {
8440            let sub = sub.clone();
8441            let stop = stop.clone();
8442            std::thread::spawn(move || {
8443                let entry = sub.join("entry");
8444                let staged_real = sub.join("__staged_real");
8445                let staged_fifo = sub.join("__staged_fifo");
8446                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
8447                    // real file staged then renamed over `entry`.
8448                    let _ = std::fs::remove_file(&staged_real);
8449                    if std::fs::write(&staged_real, b"REAL_CONTENT").is_ok() {
8450                        let _ = std::fs::rename(&staged_real, &entry);
8451                    }
8452                    // FIFO staged then renamed over `entry`. mkfifo can't target an existing name,
8453                    // so create it at a staging path and rename it in.
8454                    let _ = std::fs::remove_file(&staged_fifo);
8455                    let made = nix::unistd::mkfifo(
8456                        &staged_fifo,
8457                        nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
8458                    )
8459                    .is_ok();
8460                    if made {
8461                        let _ = std::fs::rename(&staged_fifo, &entry);
8462                    }
8463                }
8464            })
8465        };
8466
8467        let settings = toctou_settings();
8468        for i in 0..200 {
8469            let dst = test_path.join(format!("dst_{i}"));
8470            // the generous timeout is the anti-hang assertion: a FIFO opened without O_NONBLOCK
8471            // would block the copy forever waiting for a writer.
8472            let _ = tokio::time::timeout(
8473                std::time::Duration::from_secs(30),
8474                copy(
8475                    &PROGRESS,
8476                    &src,
8477                    &dst,
8478                    &settings,
8479                    &NO_PRESERVE_SETTINGS,
8480                    false,
8481                ),
8482            )
8483            .await
8484            .expect("copy must not hang when an entry is swapped to a FIFO");
8485            // CORE ASSERTION: whatever landed at the destination is either absent or a real regular
8486            // file with the real content — a FIFO is never copied as data, and never copied as a
8487            // special (no specials are expected in the destination).
8488            let entry_dst = dst.join("sub").join("entry");
8489            if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await {
8490                use std::os::unix::fs::FileTypeExt as _;
8491                let ft = md.file_type();
8492                assert!(
8493                    !ft.is_fifo(),
8494                    "iteration {i}: a FIFO was reproduced at the destination"
8495                );
8496                if ft.is_file() {
8497                    let content = tokio::fs::read_to_string(&entry_dst).await?;
8498                    assert_eq!(
8499                        content, "REAL_CONTENT",
8500                        "iteration {i}: a regular destination file must hold the real content"
8501                    );
8502                }
8503            }
8504            let _ = tokio::fs::remove_dir_all(&dst).await;
8505        }
8506
8507        stop.store(true, std::sync::atomic::Ordering::Relaxed);
8508        swapper.join().expect("fifo swapper thread panicked");
8509        Ok(())
8510    }
8511
8512    // Task 1.6: a deep, narrow chain `a/a/a/.../file` copies fully without hanging or exhausting
8513    // fds. `copy_internal` is `#[async_recursion]`, so depth exercises the recursive chain; 800 is
8514    // deep enough to stress the fd budget / hold-and-wait avoidance without risking a stack blowup.
8515    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8516    async fn copy_deep_narrow_tree_completes() -> Result<(), anyhow::Error> {
8517        let tmp_dir = testutils::create_temp_dir().await?;
8518        let test_path = tmp_dir.as_path();
8519        let src = test_path.join("src");
8520        const DEPTH: usize = 800;
8521        // build src/a/a/.../a/leaf.txt by appending one "a" per level.
8522        let mut cur = src.clone();
8523        for _ in 0..DEPTH {
8524            cur = cur.join("a");
8525        }
8526        tokio::fs::create_dir_all(&cur).await?;
8527        tokio::fs::write(cur.join("leaf.txt"), "DEEP_LEAF").await?;
8528
8529        let dst = test_path.join("dst");
8530        let summary = tokio::time::timeout(
8531            std::time::Duration::from_secs(120),
8532            copy(
8533                &PROGRESS,
8534                &src,
8535                &dst,
8536                &toctou_settings(),
8537                &NO_PRESERVE_SETTINGS,
8538                false,
8539            ),
8540        )
8541        .await
8542        .expect("deep-narrow copy must not hang (fd budget / hold-and-wait)")?;
8543        assert_eq!(
8544            summary.files_copied, 1,
8545            "the single leaf file must be copied"
8546        );
8547        // the destination root directory plus the DEPTH-level `a` chain copied into it.
8548        assert_eq!(summary.directories_created, DEPTH + 1);
8549        // verify the leaf actually landed at the bottom of the destination chain.
8550        let mut leaf = dst.clone();
8551        for _ in 0..DEPTH {
8552            leaf = leaf.join("a");
8553        }
8554        let content = tokio::fs::read_to_string(leaf.join("leaf.txt")).await?;
8555        assert_eq!(content, "DEEP_LEAF");
8556        Ok(())
8557    }
8558
8559    // Task 1.6: a single very wide directory (many sibling files) copies fully within a timeout. The
8560    // per-entry file-in-flight permit bounds concurrency; this confirms the wide fan-out drains without
8561    // fd exhaustion or a hang.
8562    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8563    async fn copy_wide_tree_completes() -> Result<(), anyhow::Error> {
8564        let tmp_dir = testutils::create_temp_dir().await?;
8565        let test_path = tmp_dir.as_path();
8566        let src = test_path.join("src");
8567        tokio::fs::create_dir(&src).await?;
8568        const WIDTH: usize = 2000;
8569        for i in 0..WIDTH {
8570            tokio::fs::write(src.join(format!("f{i}.txt")), format!("content {i}")).await?;
8571        }
8572        let dst = test_path.join("dst");
8573        let summary = tokio::time::timeout(
8574            std::time::Duration::from_secs(120),
8575            copy(
8576                &PROGRESS,
8577                &src,
8578                &dst,
8579                &toctou_settings(),
8580                &NO_PRESERVE_SETTINGS,
8581                false,
8582            ),
8583        )
8584        .await
8585        .expect("wide copy must not hang (fd exhaustion / permit deadlock)")?;
8586        assert_eq!(summary.files_copied, WIDTH, "every file must be copied");
8587        // spot-check a few files actually landed with the right content.
8588        for i in [0usize, WIDTH / 2, WIDTH - 1] {
8589            let content = tokio::fs::read_to_string(dst.join(format!("f{i}.txt"))).await?;
8590            assert_eq!(content, format!("content {i}"));
8591        }
8592        Ok(())
8593    }
8594
8595    // fake metadata carrying the fields --overwrite-compare can use (size, mtime, uid, gid, mode).
8596    #[derive(Clone, Debug)]
8597    struct CmpMeta {
8598        uid: u32,
8599        gid: u32,
8600        mode: u32,
8601        size: u64,
8602        mtime: i64,
8603    }
8604    impl crate::preserve::Metadata for CmpMeta {
8605        fn uid(&self) -> u32 {
8606            self.uid
8607        }
8608        fn gid(&self) -> u32 {
8609            self.gid
8610        }
8611        fn atime(&self) -> i64 {
8612            0
8613        }
8614        fn atime_nsec(&self) -> i64 {
8615            0
8616        }
8617        fn mtime(&self) -> i64 {
8618            self.mtime
8619        }
8620        fn mtime_nsec(&self) -> i64 {
8621            0
8622        }
8623        fn permissions(&self) -> std::fs::Permissions {
8624            std::os::unix::fs::PermissionsExt::from_mode(self.mode)
8625        }
8626        fn size(&self) -> u64 {
8627            self.size
8628        }
8629    }
8630
8631    fn size_mtime() -> filecmp::MetadataCmpSettings {
8632        filecmp::MetadataCmpSettings {
8633            size: true,
8634            mtime: true,
8635            ..Default::default()
8636        }
8637    }
8638    fn base() -> CmpMeta {
8639        CmpMeta {
8640            uid: 0,
8641            gid: 0,
8642            mode: 0o644,
8643            size: 10,
8644            mtime: 100,
8645        }
8646    }
8647
8648    #[test]
8649    fn skip_send_no_existing_entry_sends() {
8650        let src = base();
8651        let r = skip_unchanged_send(
8652            &size_mtime(),
8653            None,
8654            false,
8655            &src,
8656            None::<ExistingDst<CmpMeta>>,
8657        );
8658        assert!(!r);
8659    }
8660
8661    #[test]
8662    fn skip_send_ignore_existing_skips_any_type() {
8663        let src = base();
8664        let dst = base();
8665        let r = skip_unchanged_send(
8666            &size_mtime(),
8667            None,
8668            true,
8669            &src,
8670            Some(ExistingDst {
8671                meta: &dst,
8672                is_file: false,
8673            }),
8674        );
8675        assert!(r);
8676    }
8677
8678    #[test]
8679    fn skip_send_overwrite_identical_skips() {
8680        let src = base();
8681        let dst = base();
8682        let r = skip_unchanged_send(
8683            &size_mtime(),
8684            None,
8685            false,
8686            &src,
8687            Some(ExistingDst {
8688                meta: &dst,
8689                is_file: true,
8690            }),
8691        );
8692        assert!(r);
8693    }
8694
8695    #[test]
8696    fn skip_send_overwrite_different_size_sends() {
8697        let src = base();
8698        let dst = CmpMeta { size: 11, ..base() };
8699        let r = skip_unchanged_send(
8700            &size_mtime(),
8701            None,
8702            false,
8703            &src,
8704            Some(ExistingDst {
8705                meta: &dst,
8706                is_file: true,
8707            }),
8708        );
8709        assert!(!r);
8710    }
8711
8712    #[test]
8713    fn skip_send_overwrite_different_mtime_sends() {
8714        let src = base();
8715        let dst = CmpMeta {
8716            mtime: 200,
8717            ..base()
8718        };
8719        let r = skip_unchanged_send(
8720            &size_mtime(),
8721            None,
8722            false,
8723            &src,
8724            Some(ExistingDst {
8725                meta: &dst,
8726                is_file: true,
8727            }),
8728        );
8729        assert!(!r);
8730    }
8731
8732    #[test]
8733    fn skip_send_overwrite_non_file_sends() {
8734        let src = base();
8735        let dst = base();
8736        let r = skip_unchanged_send(
8737            &size_mtime(),
8738            None,
8739            false,
8740            &src,
8741            Some(ExistingDst {
8742                meta: &dst,
8743                is_file: false,
8744            }),
8745        );
8746        assert!(!r);
8747    }
8748
8749    #[test]
8750    fn skip_send_filter_newer_skips_when_dest_newer() {
8751        let src = CmpMeta {
8752            mtime: 100,
8753            size: 5,
8754            ..base()
8755        };
8756        let dst = CmpMeta {
8757            mtime: 200,
8758            size: 11,
8759            ..base()
8760        };
8761        let r = skip_unchanged_send(
8762            &size_mtime(),
8763            Some(OverwriteFilter::Newer),
8764            false,
8765            &src,
8766            Some(ExistingDst {
8767                meta: &dst,
8768                is_file: true,
8769            }),
8770        );
8771        assert!(r);
8772    }
8773
8774    #[test]
8775    fn skip_send_filter_newer_sends_when_dest_older() {
8776        let src = CmpMeta {
8777            mtime: 200,
8778            size: 5,
8779            ..base()
8780        };
8781        let dst = CmpMeta {
8782            mtime: 100,
8783            size: 11,
8784            ..base()
8785        };
8786        let r = skip_unchanged_send(
8787            &size_mtime(),
8788            Some(OverwriteFilter::Newer),
8789            false,
8790            &src,
8791            Some(ExistingDst {
8792                meta: &dst,
8793                is_file: true,
8794            }),
8795        );
8796        assert!(!r);
8797    }
8798}