Skip to main content

common/
link.rs

1use anyhow::{Context, anyhow};
2use async_recursion::async_recursion;
3use std::sync::Arc;
4use tracing::instrument;
5
6use crate::copy;
7use crate::copy::{
8    EmptyDirAction, Settings as CopySettings, Summary as CopySummary, check_empty_dir_cleanup,
9};
10use crate::filecmp;
11use crate::preserve;
12use crate::progress;
13use crate::safedir::Dir;
14use crate::walk::{self, EntryKind, LeafPermit, PermitKind};
15
16/// Error type for link operations. See [`crate::error::OperationError`] for
17/// logging conventions and rationale.
18pub type Error = crate::error::OperationError<Summary>;
19
20#[derive(Debug, Clone)]
21pub struct Settings {
22    pub copy_settings: CopySettings,
23    pub update_compare: filecmp::MetadataCmpSettings,
24    pub update_exclusive: bool,
25    /// filter settings for include/exclude patterns
26    pub filter: Option<crate::filter::FilterSettings>,
27    /// dry-run mode for previewing operations
28    pub dry_run: Option<crate::config::DryRunMode>,
29    /// metadata preservation settings
30    pub preserve: preserve::Settings,
31}
32
33/// Summary with the appropriate `*_skipped` counter set to 1 for the given entry kind.
34/// Special files count as `files_skipped` to match the historical mapping used
35/// when filters skip an entry (`specials_skipped` is reserved for `--skip-specials`).
36fn skipped_summary_for(kind: EntryKind) -> Summary {
37    let copy_summary = match kind {
38        EntryKind::Dir => CopySummary {
39            directories_skipped: 1,
40            ..Default::default()
41        },
42        EntryKind::Symlink => CopySummary {
43            symlinks_skipped: 1,
44            ..Default::default()
45        },
46        EntryKind::File | EntryKind::Special => CopySummary {
47            files_skipped: 1,
48            ..Default::default()
49        },
50    };
51    Summary {
52        copy_summary,
53        ..Default::default()
54    }
55}
56
57#[derive(Copy, Clone, Debug, Default)]
58pub struct Summary {
59    pub hard_links_created: usize,
60    pub hard_links_unchanged: usize,
61    pub copy_summary: CopySummary,
62}
63
64impl std::ops::Add for Summary {
65    type Output = Self;
66    fn add(self, other: Self) -> Self {
67        Self {
68            hard_links_created: self.hard_links_created + other.hard_links_created,
69            hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
70            copy_summary: self.copy_summary + other.copy_summary,
71        }
72    }
73}
74
75impl std::fmt::Display for Summary {
76    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
77        write!(
78            f,
79            "{}\n\
80            link:\n\
81            -----\n\
82            hard-links created: {}\n\
83            hard links unchanged: {}\n",
84            &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
85        )
86    }
87}
88
89/// Hard-link the already-classified source entry (pinned by `src_handle`) to `dst_name` within
90/// `dst_dir`, fd-relative and inode-exact.
91///
92/// `dst_dir.hard_link_handle_at` links the EXACT inode `src_handle` pins (via its `O_PATH` fd,
93/// using `linkat(.., "/proc/self/fd/N", .., AT_SYMLINK_FOLLOW)`) rather than re-resolving the
94/// source by name. This closes a TOCTOU window the old by-name `linkat` had: on an actor-writable
95/// source, `name` could be swapped to a different inode (symlink, FIFO, another file) between
96/// classification and the link, so the by-name link would target the replacement while rlink
97/// reported a hard-linked file. Linking the pinned inode means we either hard-link the exact
98/// regular file we classified or fail closed (`ENOENT` when its last link was removed) — never the
99/// swapped-in replacement. `linkat` still refuses to hard-link a directory (`EPERM`).
100///
101/// On `EEXIST` under `--overwrite`, the existing destination is re-classified through `dst_dir`'s
102/// fd and, if it is an identical hard link (same dev+ino), left as is; otherwise it is removed via
103/// the recheck-guarded [`copy::remove_existing`] and the link is retried — mirroring copy's
104/// fd-relative overwrite branches.
105#[instrument(skip(prog_track, settings))]
106#[allow(clippy::too_many_arguments)]
107async fn hard_link_entry_fd(
108    prog_track: &'static progress::Progress,
109    src_handle: &crate::safedir::Handle,
110    dst_dir: &Arc<Dir>,
111    dst_name: &std::ffi::OsStr,
112    dst_path: &std::path::Path,
113    settings: &Settings,
114) -> Result<Summary, Error> {
115    let mut link_summary = Summary::default();
116    match dst_dir.hard_link_handle_at(src_handle, dst_name).await {
117        Ok(()) => {}
118        Err(error)
119            if settings.copy_settings.overwrite
120                && error.kind() == std::io::ErrorKind::AlreadyExists =>
121        {
122            tracing::debug!("'dst' already exists, check if we need to update");
123            let dst_handle = dst_dir
124                .child(dst_name)
125                .await
126                .with_context(|| format!("cannot read {dst_path:?} metadata"))
127                .map_err(|err| Error::new(err, Default::default()))?;
128            // identical hard link: same file type and same (dev, ino) as the source entry. Both
129            // handles pin their inodes (O_PATH), so a matching (dev, ino) genuinely proves the two
130            // names already resolve to the same inode — no change needed.
131            if dst_handle.kind() == src_handle.kind()
132                && dst_handle.dev() == src_handle.dev()
133                && dst_handle.ino() == src_handle.ino()
134            {
135                tracing::debug!("no change, leaving file as is");
136                prog_track.hard_links_unchanged.inc();
137                return Ok(Summary {
138                    hard_links_unchanged: 1,
139                    ..Default::default()
140                });
141            }
142            tracing::info!("'dst' file type changed, removing and hard-linking");
143            // recheck-guarded, fd-relative removal contained to dst_dir (mirrors copy.rs).
144            let rm_summary = copy::remove_existing(
145                prog_track,
146                dst_dir,
147                dst_name,
148                dst_path,
149                &dst_handle,
150                &settings.copy_settings,
151            )
152            .await
153            .map_err(|err| {
154                link_summary.copy_summary.rm_summary = err.summary.rm_summary;
155                Error::new(err.source, link_summary)
156            })?;
157            link_summary.copy_summary.rm_summary = rm_summary;
158            dst_dir
159                .hard_link_handle_at(src_handle, dst_name)
160                .await
161                .with_context(|| format!("failed to hard link to {dst_path:?}"))
162                .map_err(|err| Error::new(err, link_summary))?;
163        }
164        Err(error) => {
165            return Err(Error::new(
166                anyhow::Error::from(error).context(format!("failed to hard link to {dst_path:?}")),
167                link_summary,
168            ));
169        }
170    }
171    prog_track.hard_links_created.inc();
172    link_summary.hard_links_created = 1;
173    Ok(link_summary)
174}
175
176/// Public entry point for link operations.
177///
178/// The dual-tree link walk is fd-based: the source, optional `update`, and destination roots are
179/// opened relative to their parent directories and every per-entry operation is performed through
180/// file-descriptor-relative syscalls (see [`crate::safedir`]). Hard links are made inode-exact
181/// through the already-classified source `Handle` (`linkat` via `/proc/self/fd/N` with
182/// `AT_SYMLINK_FOLLOW`), so the link targets the exact regular file that was classified, even if
183/// its directory entry is concurrently swapped — never a re-resolved name; entries that must be
184/// copied instead of hard-linked are delegated to `copy::copy_child` with the held parent `Dir`s —
185/// no path is re-resolved from a root. This closes the TOCTOU window the old path-based walk had
186/// between classifying an entry and acting on it. `--dereference` is the one exception — copy still
187/// resolves symlinks by path (`canonicalize`) and is not hardened.
188#[instrument(skip(prog_track, settings))]
189pub async fn link(
190    prog_track: &'static progress::Progress,
191    cwd: &std::path::Path,
192    src: &std::path::Path,
193    dst: &std::path::Path,
194    update: &Option<std::path::PathBuf>,
195    settings: &Settings,
196    is_fresh: bool,
197) -> Result<Summary, Error> {
198    // `cwd` is retained for API/signature parity (callers still pass it) but the fd-based walk
199    // reconstructs every path from the explicit roots, so it is no longer threaded into the walk.
200    let _ = cwd;
201    // A missing --update root is destructive under both --update-exclusive (materialized set =
202    // update set, so nothing materializes) AND --delete (the source-only keep_set makes any dst
203    // entry the missing update tree WOULD have protected look extraneous, and prune wipes it).
204    // In either case `link_internal` hits the recursive early-return / silent `None` fallback
205    // before that destruction would happen, so rlink reports success — silently preserving
206    // stale dst (--update-exclusive) or silently pruning would-be-protected entries (--delete).
207    // Reject at the public entry so a typo'd --update can't quietly do the wrong thing. The
208    // plain "--update without --delete or --update-exclusive" case still falls back to no-update
209    // mode (long-standing behavior), and recursive child-level "update missing" cases stay
210    // handled inside link_internal — they correctly no-op so the parent's prune removes their
211    // dst counterpart per the documented semantics.
212    if let Some(update_path) = update.as_ref() {
213        let destructive = settings.update_exclusive || settings.copy_settings.delete.is_some();
214        if crate::safedir::strict_operand_resolution() {
215            // Under strict operand resolution, validate the update operand prefix UP FRONT
216            // (fd-relative, `openat2(RESOLVE_NO_SYMLINKS)`), unconditionally for ALL --update — so a
217            // plain --update with an excluded source (which returns before the update parent would
218            // otherwise be opened) cannot slip a symlinked update prefix through. A symlink in a
219            // directory component fails closed (ELOOP); this also serves the destructive-mode
220            // existence check below.
221            let kind = crate::safedir::strict_probe_dst_kind(update_path, congestion::Side::Source)
222                .await
223                .map_err(|err| {
224                    Error::new(
225                        anyhow::Error::new(err).context(format!(
226                            "failed reading metadata from update {update_path:?}"
227                        )),
228                        Default::default(),
229                    )
230                })?;
231            if destructive && kind.is_none() {
232                return Err(Error::new(
233                    anyhow!(
234                        "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
235                        update_path
236                    ),
237                    Default::default(),
238                ));
239            }
240        } else if destructive {
241            match crate::walk::run_metadata_probed(
242                congestion::Side::Source,
243                congestion::MetadataOp::Stat,
244                tokio::fs::symlink_metadata(update_path),
245            )
246            .await
247            {
248                Ok(_) => {}
249                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
250                    return Err(Error::new(
251                        anyhow!(
252                            "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
253                            update_path
254                        ),
255                        Default::default(),
256                    ));
257                }
258                Err(err) => {
259                    return Err(Error::new(
260                        anyhow::Error::new(err).context(format!(
261                            "failed reading metadata from update {:?}",
262                            update_path
263                        )),
264                        Default::default(),
265                    ));
266                }
267            }
268        }
269    }
270    // Source: decompose via the shared helper so `.`/`..` operands (e.g. `rlink . dst`, `rlink
271    // tree/.. dst`) are canonicalized to a real directory + basename instead of being rejected; `/`
272    // is still rejected. (The destination and `--update` operands keep their direct split below.)
273    let src_operand = crate::walk::split_root_operand(src)
274        .await
275        .map_err(|err| Error::new(err, Default::default()))?;
276    let src = src_operand.display.as_path();
277    let src_name = src_operand.name.as_os_str();
278    // under strict operand resolution (--require-toctou-safe) the parent prefix is opened BEFORE
279    // the root filter check, so a symlinked source prefix fails closed even when the root would be
280    // skipped by a filter, and the root is classified fd-relative. On the default path the filter
281    // check keeps the historical path-based `symlink_metadata` classification and runs FIRST: an
282    // excluded root under an execute-only (0111, searchable-not-readable) parent must skip cleanly,
283    // and the O_RDONLY parent open would fail EACCES there.
284    let strict_src_parent: Option<Arc<Dir>> = if crate::safedir::strict_operand_resolution() {
285        let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
286            .await
287            .with_context(|| {
288                format!(
289                    "cannot open source parent directory {:?}",
290                    src_operand.parent
291                )
292            })
293            .map_err(|err| Error::new(err, Default::default()))?;
294        // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
295        Some(Arc::new(parent.into_tree()))
296    } else {
297        None
298    };
299    // The destination's parent path, split leniently for the up-front strict validation below (a
300    // `.`/`..`/`/` destination has no distinct parent+name; the authoritative split — which rejects
301    // such a destination — runs AFTER the filter, preserving default-mode ordering so a filtered-out
302    // source still skips cleanly). empty parent (a single-component relative path) means ".".
303    let dst_parent_path_opt: Option<&std::path::Path> = match (dst.parent(), dst.file_name()) {
304        (Some(parent), Some(_)) if parent.as_os_str().is_empty() => Some(std::path::Path::new(".")),
305        (Some(parent), Some(_)) => Some(parent),
306        _ => None,
307    };
308    // Under strict operand resolution, resolve the destination parent UP FRONT — before the source
309    // root-filter early-return, unconditionally — so a symlinked destination prefix fails closed in
310    // every mode regardless of filters/flags (see copy::copy for the same pattern). An absent parent
311    // (dry-run previewing into a not-yet-created tree, or a filtered-out root) is not a symlink
312    // violation; only a symlinked prefix component (ELOOP) fails closed here.
313    let strict_dst_parent: Option<Arc<Dir>> = match (
314        crate::safedir::strict_operand_resolution(),
315        dst_parent_path_opt,
316    ) {
317        (true, Some(dst_parent_path)) => {
318            match Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination).await {
319                Ok(parent) => Some(Arc::new(parent.into_tree())),
320                Err(err)
321                    if err.kind() == std::io::ErrorKind::NotFound
322                        || err.raw_os_error() == Some(libc::ENOTDIR) =>
323                {
324                    None
325                }
326                Err(err) => {
327                    return Err(Error::new(
328                        anyhow::Error::new(err).context(format!(
329                            "cannot open destination parent directory {dst_parent_path:?}"
330                        )),
331                        Default::default(),
332                    ));
333                }
334            }
335        }
336        // default mode, or a degenerate destination (rejected by the authoritative split below)
337        _ => None,
338    };
339    // check filter for top-level source (files, directories, and symlinks)
340    if let Some(ref filter) = settings.filter {
341        let (kind, is_dir) = match &strict_src_parent {
342            // strict: classify via the held parent fd (O_NOFOLLOW), never by path
343            Some(parent) => {
344                let root_handle = parent
345                    .child(src_name)
346                    .await
347                    .with_context(|| format!("failed reading metadata from {:?}", &src))
348                    .map_err(|err| Error::new(err, Default::default()))?;
349                (root_handle.kind(), root_handle.kind() == EntryKind::Dir)
350            }
351            None => {
352                let src_metadata = crate::walk::run_metadata_probed(
353                    congestion::Side::Source,
354                    congestion::MetadataOp::Stat,
355                    tokio::fs::symlink_metadata(src),
356                )
357                .await
358                .with_context(|| format!("failed reading metadata from {:?}", &src))
359                .map_err(|err| Error::new(err, Default::default()))?;
360                (
361                    EntryKind::from_metadata(&src_metadata),
362                    src_metadata.is_dir(),
363                )
364            }
365        };
366        let result = filter.should_include_root_item(std::path::Path::new(src_name), is_dir);
367        match result {
368            crate::filter::FilterResult::Included => {}
369            result => {
370                if let Some(mode) = settings.dry_run {
371                    crate::dry_run::report_skip(src, &result, mode, kind.label_long());
372                }
373                kind.inc_skipped(prog_track);
374                return Ok(skipped_summary_for(kind));
375            }
376        }
377    }
378    // Authoritative destination split (runs AFTER the filter, preserving default-mode ordering): a
379    // `.`/`..`/`/` destination is not a meaningful link target, and rejecting it avoids clobbering
380    // the cwd. empty parent (a single-component relative path) means the current directory.
381    let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
382        return Err(Error::new(
383            anyhow!(
384                "link destination {:?} has no parent directory or file name",
385                dst
386            ),
387            Default::default(),
388        ));
389    };
390    let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
391        std::path::Path::new(".")
392    } else {
393        dst_parent_path
394    };
395    // Open the parent directories of the source, destination, and (optional) update roots for the
396    // walk so each root entry is opened and classified relative to a directory fd — the same
397    // fd-relative path every nested entry takes. The roots are then handed to `link_internal` by
398    // their basenames, exactly like child entries. Under strict operand resolution both parents were
399    // already opened+validated above, so reuse the held fds here.
400    let src_parent = match strict_src_parent {
401        Some(parent) => parent,
402        None => {
403            let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
404                .await
405                .with_context(|| {
406                    format!(
407                        "cannot open source parent directory {:?}",
408                        src_operand.parent
409                    )
410                })
411                .map_err(|err| Error::new(err, Default::default()))?;
412            // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
413            Arc::new(parent.into_tree())
414        }
415    };
416    // In dry-run we never touch the destination, so we don't open its parent at all (it may not
417    // even exist). `dst_parent == None` is the signal throughout the walk that destination
418    // operations must be skipped. In a real link, reuse the strict-validated parent, or open it
419    // following symlinks (default mode; a symlinked destination container is followed into the real
420    // dir).
421    let dst_parent = if settings.dry_run.is_some() {
422        None
423    } else {
424        match strict_dst_parent {
425            Some(parent) => Some(parent),
426            None => {
427                let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
428                    .await
429                    .with_context(|| {
430                        format!(
431                            "cannot open destination parent directory {:?}",
432                            dst_parent_path
433                        )
434                    })
435                    .map_err(|err| Error::new(err, Default::default()))?;
436                // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below).
437                Some(Arc::new(dir.into_tree()))
438            }
439        }
440    };
441    // The update tree (if present) is rooted at `update`; open its parent and remember the root
442    // basename so `link_internal` can classify it via the held fd. A missing update root is handled
443    // inside `link_internal` (recursive early-return / silent None fallback) exactly as before.
444    //
445    // For plain `--update` (no `--delete`, no `--update-exclusive`) a missing parent is treated the
446    // same as a missing update root: fall back silently to no-update mode. This preserves the long-
447    // standing behavior where `rlink --update /tmp/no/such src dst` (with `/tmp/no` absent) proceeds
448    // by linking from `src` rather than erroring — the existing missing-update-root fallback already
449    // applies to that case; the parent-open merely must not ENOENT-fail before `link_internal` can
450    // apply it. Under `--delete` or `--update-exclusive` the missing-root is already rejected above,
451    // so those cases never reach here with a missing update; any open_parent_dir error there is
452    // unexpected and propagates as before.
453    let update_parent = match update.as_ref() {
454        Some(update_path) => {
455            // decompose the update operand the same way as the source: the update tree is a READ
456            // tree, so `.`/`..`/`dir/..` are meaningful and `split_root_operand` canonicalizes them
457            // (and rejects `/`). This makes `rlink --update . src dst` / `--update tree/.. src dst`
458            // work instead of erroring, matching the source-operand handling. The helper already
459            // normalizes an empty parent to ".", so no `resolve_parent` is needed here.
460            let update_operand = crate::walk::split_root_operand(update_path)
461                .await
462                .map_err(|err| Error::new(err, Default::default()))?;
463            let update_parent_path = update_operand.parent;
464            let update_name = update_operand.name;
465            // the update tree's TRUSTED parent prefix is resolved following symlinks (see the
466            // source parent above): a symlinked update container must be followed into the real dir.
467            let fallback_eligible =
468                !settings.update_exclusive && settings.copy_settings.delete.is_none();
469            match Dir::open_parent_dir(&update_parent_path, congestion::Side::Source).await {
470                // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below).
471                Ok(dir) => Some((Arc::new(dir.into_tree()), update_name)),
472                Err(err)
473                    if fallback_eligible
474                        && (matches!(
475                            err.kind(),
476                            std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
477                        ) || err.raw_os_error() == Some(libc::ENOTDIR)) =>
478                {
479                    // the update path's parent (or an ancestor) doesn't exist — treat the whole
480                    // update tree as absent and fall back to no-update mode, exactly as when the
481                    // update ROOT itself is missing (handled inside link_internal).
482                    tracing::debug!(
483                        "update parent {:?} not found ({:#}); falling back to no-update mode",
484                        update_parent_path,
485                        err
486                    );
487                    None
488                }
489                Err(err) => {
490                    return Err(Error::new(
491                        anyhow::Error::new(err).context(format!(
492                            "cannot open update parent directory {:?}",
493                            update_parent_path
494                        )),
495                        Default::default(),
496                    ));
497                }
498            }
499        }
500        None => None,
501    };
502    let update_ref = update_parent
503        .as_ref()
504        .map(|(dir, name)| (dir, name.as_os_str()));
505    link_internal(
506        prog_track,
507        &src_parent,
508        update_ref,
509        dst_parent.as_ref(),
510        src_name,
511        src,
512        dst,
513        update.as_deref(),
514        std::path::Path::new(""),
515        settings,
516        is_fresh,
517        None,
518    )
519    .await
520}
521/// Tracks which child names will be materialized at the destination for a single directory
522/// pass, used by `--delete` to decide what to prune. Operations are named after their
523/// semantic intent so the call sites don't repeat the gating conditions (delete-on-vs-off,
524/// `--update-exclusive` carve-out, skip-special-vs-real materialization).
525///
526/// When `--delete` is off the inner set is `None` and every method is a no-op — zero heap
527/// cost in the hot path.
528struct DeleteKeepSet {
529    inner: Option<std::collections::HashSet<std::ffi::OsString>>,
530    /// Under `--update-exclusive` with an active update tree, the source loop must NOT
531    /// register source-only entries — only the update set materializes.
532    src_records_disabled: bool,
533}
534
535impl DeleteKeepSet {
536    fn new(
537        delete: Option<&copy::DeleteSettings>,
538        update_exclusive: bool,
539        update_present: bool,
540    ) -> Self {
541        Self {
542            inner: delete.is_some().then(std::collections::HashSet::new),
543            src_records_disabled: update_exclusive && update_present,
544        }
545    }
546    /// Source loop: this src entry passed the filter. Called even when `--skip-specials`
547    /// will skip materialization — the dst counterpart still needs to be retained.
548    fn record_src(&mut self, name: &std::ffi::OsStr) {
549        if let Some(set) = &mut self.inner
550            && !self.src_records_disabled
551        {
552            set.insert(name.to_owned());
553        }
554    }
555    /// Update loop: this update entry passed the filter at its logical path.
556    fn record_update(&mut self, name: &std::ffi::OsStr) {
557        if let Some(set) = &mut self.inner {
558            set.insert(name.to_owned());
559        }
560    }
561    /// Borrow the underlying set for `prune_extraneous`. `None` means `--delete` is off and
562    /// the caller should skip the prune entirely.
563    fn as_set(&self) -> Option<&std::collections::HashSet<std::ffi::OsString>> {
564        self.inner.as_ref()
565    }
566}
567
568/// Per-entry worker of the fd-based dual-tree link walk.
569///
570/// `src_parent` is the open source directory holding `name`; `update` is `Some((dir, name))` when
571/// an update tree is present at this level (its directory handle plus the update root's basename
572/// for the root entry); `dst_parent` is the open destination directory (`None` in dry-run).
573/// `src_root`/`dst_root`/`update_root` are the user-specified roots and `rel_path` is this entry's
574/// path relative to those roots (empty for the root entry) — joined onto a root they reconstruct
575/// the real path used for diagnostics, `rm`, and `--dereference`. `rel_path` is also this entry's
576/// logical filter path.
577///
578/// The src entry is classified via `src_parent.child(name)` (fstat-authoritative; the getdents
579/// hint is only a spawn-loop heuristic). When an update entry exists at this name it is classified
580/// too, and the hard-link-vs-copy decision mirrors the old `--update` overlay logic exactly:
581/// a type-mismatch / changed file / symlink in the update tree is COPIED from the update version
582/// via [`copy::copy_child`]; an unchanged file is HARD-LINKED from the source; a directory recurses
583/// the dual tree. With no update tree, a source file is hard-linked, a source symlink is copied,
584/// and a directory recurses.
585///
586/// `permit` is the leaf permit the spawn loop pre-acquired for a regular-file src hint (via
587/// [`walk::preacquire_leaf_permit`]). It is USED only on the one path that copies a *changed*
588/// same-type regular file from the update tree (the data copy reuses it); on every other path it is
589/// dropped at the single consolidated drop site below — see that comment for why this is rlink's
590/// acknowledged dual-tree special case.
591#[instrument(skip(prog_track, src_parent, update, dst_parent, settings, permit))]
592#[async_recursion]
593#[allow(clippy::too_many_arguments)]
594async fn link_internal(
595    prog_track: &'static progress::Progress,
596    src_parent: &Arc<Dir>,
597    update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
598    dst_parent: Option<&Arc<Dir>>,
599    name: &std::ffi::OsStr,
600    src_root: &std::path::Path,
601    dst_root: &std::path::Path,
602    update_root: Option<&std::path::Path>,
603    rel_path: &std::path::Path,
604    settings: &Settings,
605    is_fresh: bool,
606    permit: Option<LeafPermit>,
607) -> Result<Summary, Error> {
608    let _prog_guard = prog_track.ops.guard();
609    // real filesystem paths reconstructed from the roots + accumulated relative path. used for
610    // diagnostics, the path-based `--delete` prune scan / `rm`, the `--dereference` canonicalize
611    // fallback inside copy, and to derive `dst_name`. joining an empty `rel_path` (the root entry)
612    // would append a trailing separator, so use the root verbatim when `rel_path` is empty.
613    let (src_path, dst_path) = if rel_path.as_os_str().is_empty() {
614        (src_root.to_path_buf(), dst_root.to_path_buf())
615    } else {
616        (src_root.join(rel_path), dst_root.join(rel_path))
617    };
618    let update_path = update_root.map(|root| {
619        if rel_path.as_os_str().is_empty() {
620            root.to_path_buf()
621        } else {
622            root.join(rel_path)
623        }
624    });
625    // the destination entry's name within `dst_parent`. for nested entries this equals the source
626    // `name`, but for the root the source and destination basenames differ (e.g. linking `foo` to
627    // `bar`), so destination operations must use this name.
628    let dst_name = dst_path
629        .file_name()
630        .ok_or_else(|| {
631            Error::new(
632                anyhow!("link destination {:?} has no file name", &dst_path),
633                Default::default(),
634            )
635        })?
636        .to_owned();
637    tracing::debug!("classifying source entry");
638    let src_handle = src_parent
639        .child(name)
640        .await
641        .with_context(|| format!("failed reading metadata from {:?}", &src_path))
642        .map_err(|err| Error::new(err, Default::default()))?;
643    // classify the update entry at this name (if an update tree is present at this level). a
644    // NotFound is the "this path is missing from update" case; under --update-exclusive it means
645    // we're done (nothing materializes), otherwise we fall back to no-update mode for this entry.
646    let mut update_handle = match update {
647        Some((update_dir, update_name)) => {
648            tracing::debug!("classifying 'update' entry");
649            match update_dir.child(update_name).await {
650                Ok(handle) => Some(handle),
651                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
652                    if settings.update_exclusive {
653                        // the path is missing from update, we're done
654                        return Ok(Default::default());
655                    }
656                    None
657                }
658                Err(error) => {
659                    return Err(Error::new(
660                        anyhow::Error::new(error)
661                            .context(format!("failed reading metadata from {:?}", &update_path)),
662                        Default::default(),
663                    ));
664                }
665            }
666        }
667        None => None,
668    };
669    // Re-evaluate the filter using the UPDATE entry's authoritative type before letting it drive
670    // the materialization decision below. The spawn loop in `link_dir_contents` evaluated the
671    // filter against the SOURCE entry's type; when the same name is a TYPE MISMATCH (e.g. src
672    // `cache` is a file, update `cache` is a directory) a type-dependent pattern like the dir-only
673    // `cache/` can pass the src (a dir-only pattern doesn't match a file) yet exclude the update.
674    // Without this check the type-mismatch branch would `delegate_copy` the excluded update entry
675    // (`copy_child` does NOT re-apply the top-level filter to the delegated root), copying an
676    // excluded subtree. Note a filtered-out update reaching here is necessarily a type mismatch: if
677    // the pattern were type-independent it would have excluded the src too, so the src loop would
678    // not have spawned this worker.
679    if let Some(handle) = update_handle.as_ref() {
680        let update_is_dir = handle.kind() == EntryKind::Dir;
681        // For the ROOT entry (`rel_path` empty) judge the update side with root-item semantics —
682        // symmetric with how the source root was filtered (`should_include_root_item` at the top of
683        // `link`) and with how main's delegated copy filtered the update root — rather than the
684        // nested `should_include("")` path, which would judge the root by different rules than the
685        // source (e.g. a non-anchored `--include data` includes a root file `data` under root-item
686        // semantics but not under the nested empty-path check). Nested entries keep the accumulated
687        // `rel_path` with the normal nested filter.
688        let update_excluded = match settings.filter.as_ref() {
689            Some(filter) if rel_path.as_os_str().is_empty() => {
690                let (_, update_name) =
691                    update.expect("update_handle is Some only when update is Some");
692                !matches!(
693                    filter.should_include_root_item(
694                        std::path::Path::new(update_name),
695                        update_is_dir,
696                    ),
697                    crate::filter::FilterResult::Included
698                )
699            }
700            _ => walk::should_skip_entry(&settings.filter, rel_path, update_is_dir).is_some(),
701        };
702        if update_excluded {
703            if settings.update_exclusive {
704                // --update-exclusive materializes only the (filter-passing) update set, so an
705                // excluded update entry materializes nothing — exactly the NotFound case above.
706                // The src is not materialized; under --delete its keep-set entry was never recorded
707                // (`record_src` is a no-op when `src_records_disabled`), so the dst counterpart is
708                // pruned/exclude-protected by the prune scan, never materialized-then-pruned.
709                tracing::debug!(
710                    "update entry {:?} is filtered out under --update-exclusive; materializing nothing",
711                    update_path
712                );
713                return Ok(Default::default());
714            }
715            // Normal --update (union): the update's version of this name is excluded, so the src's
716            // version stands. Treat the update entry as absent and fall through to the no-update
717            // src handling (hard-link a src file, copy a src symlink, recurse a src dir). The src
718            // already passed its own filter in the spawn loop, so its --delete keep-set entry
719            // correctly stays recorded.
720            tracing::debug!(
721                "update entry {:?} is filtered out; falling back to source-only handling",
722                update_path
723            );
724            update_handle = None;
725        }
726    }
727    // ── rlink's acknowledged dual-tree special case (docs/tocttou.md, "One shared traversal driver"): the single permit-drop site ──
728    //
729    // The spawn loop in `link_dir_contents` pre-acquired this leaf permit (open-files pool) for a
730    // regular-file src `hint`, but the authoritative dual-tree decision below may instead COPY
731    // (possibly recursing, when the update entry is a directory), HARD-LINK, SKIP, or recurse a
732    // directory. The permit is USED on exactly ONE path: copying a *changed* same-type regular file
733    // from the update tree (`copy_child` reuses it for the data copy). On every other path — both
734    // the recursing ones (a type-mismatch where the update side may be a directory, and a same-type
735    // directory) and the non-recursive leaf ones (hard-link, symlink copy, special, no-update) — the
736    // permit must NOT be held: a recursing path holding it across `copy_child`/`link_dir_entry` is
737    // the hold-and-wait deadlock the leaf-permit lifecycle eliminates, and a leaf path holding the
738    // mismatched permit across its own `.await` is pointless.
739    //
740    // So decide once, here, before any `.await` that isn't the intended copy: extract the open-files
741    // guard for the file-changed-copy path and drop the permit for everything else. Replacing the
742    // seven hand-maintained `drop(open_file_guard)` sites with this one is the Class-1 cleanup; this
743    // is the one site exempted from the Phase H no-manual-drop lint, because rlink's dual-tree walk
744    // is not a `WalkVisitor` and cannot use the driver's single drop-before-recurse home.
745    let is_file_changed_copy = match update_handle.as_ref() {
746        Some(update_handle) => {
747            update_handle.kind() == src_handle.kind()
748                && update_handle.kind() == EntryKind::File
749                && !filecmp::metadata_equal(
750                    &settings.update_compare,
751                    src_handle.meta(),
752                    update_handle.meta(),
753                )
754        }
755        None => false,
756    };
757    let copy_guard: Option<throttle::OpenFileGuard> = if is_file_changed_copy {
758        // keep the permit for the data copy: hand the inner open-files guard to `copy_child`.
759        match permit {
760            Some(LeafPermit::OpenFile(guard)) => Some(guard),
761            // a non-OpenFile permit can never reach rlink (its `want` only takes from the OpenFile
762            // pool), and `None` means the pool was disabled — either way there is nothing to pass.
763            _ => None,
764        }
765    } else {
766        // every non-copy / recursing branch: release the leaf permit now (the consolidated drop).
767        drop(permit);
768        None
769    };
770    if let Some(update_handle) = update_handle.as_ref() {
771        let (update_dir, update_name) = update.unwrap();
772        let update_path = update_path.as_deref().unwrap();
773        if update_handle.kind() != src_handle.kind() {
774            // file type changed, just copy the updated one
775            tracing::debug!(
776                "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
777                src_path,
778                src_handle.kind(),
779                update_path,
780                update_handle.kind()
781            );
782            // the leaf permit was already released at the consolidated drop site above (this is the
783            // type-mismatch path, where the update side may be a directory and we recurse via copy).
784            // delegate at this entry's logical path so that, under --delete, pruning inside the
785            // delegated subtree matches include/exclude descendants at the correct filter root
786            // (e.g. `node/*.log`). pass the HELD update parent + name, never a re-resolved path.
787            return delegate_copy(
788                prog_track,
789                update_dir,
790                dst_parent,
791                update_name,
792                update_path,
793                &dst_path,
794                rel_path,
795                settings,
796                is_fresh,
797                None,
798            )
799            .await;
800        }
801        if update_handle.kind() == EntryKind::File {
802            // check if the file is unchanged and if so hard-link, otherwise copy from the updated one
803            if filecmp::metadata_equal(
804                &settings.update_compare,
805                src_handle.meta(),
806                update_handle.meta(),
807            ) {
808                // unchanged file: hard-link from src. the permit was already dropped above (this is
809                // not the file-changed-copy path), so `copy_guard` is None and there is nothing held.
810                tracing::debug!("no change, hard link 'src'");
811                if settings.dry_run.is_some() {
812                    crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
813                    return Ok(Summary {
814                        hard_links_created: 1,
815                        ..Default::default()
816                    });
817                }
818                let dst_dir =
819                    dst_parent.expect("destination parent must be open for a real hard link");
820                return hard_link_entry_fd(
821                    prog_track,
822                    &src_handle,
823                    dst_dir,
824                    &dst_name,
825                    &dst_path,
826                    settings,
827                )
828                .await;
829            }
830            tracing::debug!(
831                "link: {:?} metadata has changed, copying from {:?}",
832                src_path,
833                update_path
834            );
835            // changed file: delegate to copy, reusing the pre-acquired permit (the common path: the
836            // spawn loop pre-acquires for regular-file hints). `copy_guard` is the open-files guard
837            // the consolidated decision above extracted for exactly this path. copy_child
838            // re-classifies and applies its own --overwrite/dry-run logic for a file.
839            return delegate_copy(
840                prog_track,
841                update_dir,
842                dst_parent,
843                update_name,
844                update_path,
845                &dst_path,
846                rel_path,
847                settings,
848                is_fresh,
849                copy_guard,
850            )
851            .await;
852        }
853        if update_handle.kind() == EntryKind::Symlink {
854            // update symlink: copy it. the permit was already dropped at the consolidated site.
855            tracing::debug!("'update' is a symlink so just symlink that");
856            return delegate_copy(
857                prog_track,
858                update_dir,
859                dst_parent,
860                update_name,
861                update_path,
862                &dst_path,
863                rel_path,
864                settings,
865                is_fresh,
866                None,
867            )
868            .await;
869        }
870    } else {
871        // update hasn't been specified (or is absent at this name): hard-link a source file,
872        // copy a source symlink.
873        // the permit (if any) was already released at the consolidated drop site above, so the
874        // no-update hard-link / symlink-copy paths here hold nothing.
875        tracing::debug!("no 'update' entry");
876        if src_handle.kind() == EntryKind::File {
877            if settings.dry_run.is_some() {
878                crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
879                return Ok(Summary {
880                    hard_links_created: 1,
881                    ..Default::default()
882                });
883            }
884            let dst_dir = dst_parent.expect("destination parent must be open for a real hard link");
885            return hard_link_entry_fd(
886                prog_track,
887                &src_handle,
888                dst_dir,
889                &dst_name,
890                &dst_path,
891                settings,
892            )
893            .await;
894        }
895        if src_handle.kind() == EntryKind::Symlink {
896            tracing::debug!("'src' is a symlink so just symlink that");
897            return delegate_copy(
898                prog_track, src_parent, dst_parent, name, &src_path, &dst_path, rel_path, settings,
899                is_fresh, None,
900            )
901            .await;
902        }
903    }
904    if src_handle.kind() != EntryKind::Dir {
905        // special file (or unsupported type): non-recursive; the permit is already released above.
906        if settings.copy_settings.skip_specials {
907            tracing::debug!(
908                "skipping special file {:?} (kind: {:?})",
909                src_path,
910                src_handle.kind()
911            );
912            if let Some(mode) = settings.dry_run {
913                match mode {
914                    crate::config::DryRunMode::Brief => {}
915                    crate::config::DryRunMode::All => println!("skip special {:?}", src_path),
916                    crate::config::DryRunMode::Explain => {
917                        println!(
918                            "skip special {:?} (unsupported file type: {:?})",
919                            src_path,
920                            src_handle.kind()
921                        );
922                    }
923                }
924            }
925            prog_track.specials_skipped.inc();
926            return Ok(Summary {
927                copy_summary: CopySummary {
928                    specials_skipped: 1,
929                    ..Default::default()
930                },
931                ..Default::default()
932            });
933        }
934        return Err(Error::new(
935            anyhow!(
936                "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
937                src_path,
938                dst_path,
939                src_handle.kind()
940            ),
941            Default::default(),
942        ));
943    }
944    // directory: recurse the dual tree. the leaf permit was released at the consolidated drop site
945    // above — this recursing path never holds it (the hold-and-wait deadlock invariant).
946    debug_assert!(
947        update_handle.is_none() || update_handle.as_ref().unwrap().kind() == EntryKind::Dir
948    );
949    // Only drive the dual-tree update walk when an update directory entry actually exists at this
950    // name. If `update_handle` is None (the update tree has no counterpart for this src dir, the
951    // recursive "update missing" case), process this subtree in no-update mode: hard-link the whole
952    // source subtree. Passing the parent update tuple here would make `link_dir_entry` try to
953    // `open_dir` a non-existent update child.
954    let update_for_dir = update.filter(|_| update_handle.is_some());
955    let update_root_for_dir = update_root.filter(|_| update_handle.is_some());
956    link_dir_entry(
957        prog_track,
958        src_parent,
959        update_for_dir,
960        dst_parent,
961        name,
962        &dst_name,
963        src_root,
964        dst_root,
965        update_root_for_dir,
966        rel_path,
967        &src_path,
968        &dst_path,
969        update_path.as_deref().filter(|_| update_handle.is_some()),
970        settings,
971        is_fresh,
972    )
973    .await
974}
975
976/// Delegate a single entry to the fd-based copy ([`copy::copy_child`]), passing the HELD parent
977/// directory handles plus the entry `name` — never re-resolving a path. `filter_base` for the
978/// delegation is the entry's logical relative path (so `--delete` pruning inside the subtree
979/// matches include/exclude patterns at the entry's true path). The returned copy summary is folded
980/// into a link `Summary`.
981#[allow(clippy::too_many_arguments)]
982async fn delegate_copy(
983    prog_track: &'static progress::Progress,
984    src_parent: &Arc<Dir>,
985    dst_parent: Option<&Arc<Dir>>,
986    name: &std::ffi::OsStr,
987    src_path: &std::path::Path,
988    dst_path: &std::path::Path,
989    filter_base: &std::path::Path,
990    settings: &Settings,
991    is_fresh: bool,
992    open_file_guard: Option<throttle::OpenFileGuard>,
993) -> Result<Summary, Error> {
994    let copy_summary = copy::copy_child(
995        prog_track,
996        src_parent,
997        dst_parent,
998        name,
999        src_path,
1000        dst_path,
1001        filter_base,
1002        &settings.copy_settings,
1003        &settings.preserve,
1004        is_fresh,
1005        open_file_guard,
1006    )
1007    .await
1008    .map_err(|err| {
1009        let copy_summary = err.summary;
1010        Error::new(
1011            err.source,
1012            Summary {
1013                copy_summary,
1014                ..Default::default()
1015            },
1016        )
1017    })?;
1018    Ok(Summary {
1019        copy_summary,
1020        ..Default::default()
1021    })
1022}
1023
1024/// Resolve (create / reuse / overwrite) the destination directory fd-relative, open the source
1025/// (and update) directories, then recurse via [`link_dir_contents`]. Mirrors copy's
1026/// [`copy::resolve_dst_dir`] for the overwrite branches (recheck-guarded, fd-relative removal).
1027#[allow(clippy::too_many_arguments)]
1028async fn link_dir_entry(
1029    prog_track: &'static progress::Progress,
1030    src_parent: &Arc<Dir>,
1031    update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
1032    dst_parent: Option<&Arc<Dir>>,
1033    name: &std::ffi::OsStr,
1034    dst_name: &std::ffi::OsStr,
1035    src_root: &std::path::Path,
1036    dst_root: &std::path::Path,
1037    update_root: Option<&std::path::Path>,
1038    rel_path: &std::path::Path,
1039    src_path: &std::path::Path,
1040    dst_path: &std::path::Path,
1041    update_path: Option<&std::path::Path>,
1042    settings: &Settings,
1043    is_fresh: bool,
1044) -> Result<Summary, Error> {
1045    let src_dir = src_parent
1046        .open_dir(name)
1047        .await
1048        .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1049        .map_err(|err| Error::new(err, Default::default()))?;
1050    let src_dir = Arc::new(src_dir);
1051    // open the update directory too (it has the same file type as src here — both are dirs).
1052    let update_dir = match update {
1053        Some((update_parent, update_name)) => {
1054            let dir = update_parent
1055                .open_dir(update_name)
1056                .await
1057                .with_context(|| {
1058                    format!("cannot open update directory {:?} for reading", update_path)
1059                })
1060                .map_err(|err| Error::new(err, Default::default()))?;
1061            Some(Arc::new(dir))
1062        }
1063        None => None,
1064    };
1065    // dry-run: report the directory and traverse its contents, but never create a destination dir.
1066    if settings.dry_run.is_some() {
1067        crate::dry_run::report_action("link", src_path, Some(dst_path), "dir");
1068        let base = Summary {
1069            copy_summary: CopySummary {
1070                directories_created: 1, // report as would-be-created
1071                ..Default::default()
1072            },
1073            ..Default::default()
1074        };
1075        return link_dir_contents(
1076            prog_track,
1077            &src_dir,
1078            update_dir.as_ref(),
1079            None, // dry-run: no destination dir
1080            None, // dry-run: no destination parent
1081            dst_name,
1082            src_root,
1083            dst_root,
1084            update_root,
1085            rel_path,
1086            src_path,
1087            dst_path,
1088            true, // treat as "created" so empty-dir cleanup can suppress the dry-run count
1089            is_fresh,
1090            settings,
1091            base,
1092        )
1093        .await;
1094    }
1095    // real link: dst_parent is Some.
1096    let dst_parent = dst_parent.expect("destination parent must be open for a real link");
1097    let copy::DirSlot {
1098        dir: dst_dir,
1099        summary: base,
1100        is_fresh: child_is_fresh,
1101        we_created,
1102    } = match copy::resolve_dst_dir(
1103        prog_track,
1104        dst_parent,
1105        dst_name,
1106        dst_path,
1107        &settings.copy_settings,
1108        is_fresh,
1109    )
1110    .await
1111    .map_err(|err| {
1112        Error::new(
1113            err.source,
1114            Summary {
1115                copy_summary: err.summary,
1116                ..Default::default()
1117            },
1118        )
1119    })? {
1120        copy::DirResolution::Skip(summary) => {
1121            return Ok(Summary {
1122                copy_summary: summary,
1123                ..Default::default()
1124            });
1125        }
1126        copy::DirResolution::Proceed(slot) => slot,
1127    };
1128    link_dir_contents(
1129        prog_track,
1130        &src_dir,
1131        update_dir.as_ref(),
1132        Some(&dst_dir),
1133        Some(dst_parent),
1134        dst_name,
1135        src_root,
1136        dst_root,
1137        update_root,
1138        rel_path,
1139        src_path,
1140        dst_path,
1141        we_created,
1142        child_is_fresh,
1143        settings,
1144        Summary {
1145            copy_summary: base,
1146            ..Default::default()
1147        },
1148    )
1149    .await
1150}
1151
1152/// The dual-tree body of a directory link: enumerate the source entries (hard-linking unchanged
1153/// files, delegating copies, recursing into subdirectories), then enumerate the update entries and
1154/// copy those not present in the source, then run `--delete` pruning, empty-directory cleanup, and
1155/// finally apply the directory's own metadata.
1156///
1157/// `dst_dir == None` / `dst_parent == None` means dry-run (no destination mutation). `base` carries
1158/// the `directories_created`/`directories_unchanged` contribution from resolving this directory.
1159#[allow(clippy::too_many_arguments)]
1160async fn link_dir_contents(
1161    prog_track: &'static progress::Progress,
1162    src_dir: &Arc<Dir>,
1163    update_dir: Option<&Arc<Dir>>,
1164    dst_dir: Option<&Arc<Dir>>,
1165    dst_parent: Option<&Arc<Dir>>,
1166    dst_name: &std::ffi::OsStr,
1167    src_root: &std::path::Path,
1168    dst_root: &std::path::Path,
1169    update_root: Option<&std::path::Path>,
1170    rel_path: &std::path::Path,
1171    src_path: &std::path::Path,
1172    dst_path: &std::path::Path,
1173    we_created_this_dir: bool,
1174    is_fresh: bool,
1175    settings: &Settings,
1176    base: Summary,
1177) -> Result<Summary, Error> {
1178    tracing::debug!("process contents of 'src' directory");
1179    let src_entries = src_dir
1180        .read_entries()
1181        .await
1182        .with_context(|| format!("cannot open directory {src_path:?} for reading"))
1183        .map_err(|err| Error::new(err, base))?;
1184    let mut link_summary = base;
1185    let mut join_set = tokio::task::JoinSet::new();
1186    let errors = crate::error_collector::ErrorCollector::default();
1187    // create a set of all the files we already processed
1188    let mut processed_files = std::collections::HashSet::new();
1189    // Keep-set for --delete: names that will be materialized at the destination. See
1190    // `DeleteKeepSet` for the semantics of `record_src` / `record_update` /
1191    // `drop_src_when_update_filtered`. No-op when --delete is off, so the call sites stay
1192    // unconditional in the hot path.
1193    let mut keep_set = DeleteKeepSet::new(
1194        settings.copy_settings.delete.as_ref(),
1195        settings.update_exclusive,
1196        update_dir.is_some(),
1197    );
1198    // iterate through src entries and recursively call "link" on each one
1199    for (entry_name, hint) in src_entries {
1200        // classification for the special-skip, symlink-dispatch, and permit pre-acquire decisions
1201        // uses the cheap getdents hint; `link_internal` re-classifies authoritatively via fstat
1202        // before acting. an unknown hint (DT_UNKNOWN) is treated as a regular file for those, the
1203        // same default the old path-based walk used when `file_type()` was unavailable.
1204        let entry_kind = hint.unwrap_or(EntryKind::File);
1205        let entry_is_symlink = entry_kind == EntryKind::Symlink;
1206        let entry_rel = rel_path.join(&entry_name);
1207        let entry_path = src_path.join(&entry_name);
1208        // the dir-ness that drives the FILTER decision AND the dry-run recurse-vs-leaf branch
1209        // below must be AUTHORITATIVE: on a DT_UNKNOWN entry, defaulting to non-dir would wrongly
1210        // omit a real directory's whole subtree under an is_dir-dependent filter, or (in dry-run,
1211        // even with NO filter) preview it as a leaf and never descend into it. `filter_is_dir`
1212        // fstats when a filter is active OR — via force_authoritative — when dry-run needs the
1213        // type for control flow. One extra fstat only in those DT_UNKNOWN cases (never follows
1214        // symlinks).
1215        let entry_is_dir = walk::filter_is_dir(
1216            settings.filter.as_ref(),
1217            src_dir,
1218            &entry_name,
1219            hint,
1220            settings.dry_run.is_some(),
1221        )
1222        .await;
1223        // apply filter if configured (logical path == entry_rel, since link's filter_base is empty)
1224        if let Some(skip_result) =
1225            walk::should_skip_entry(&settings.filter, &entry_rel, entry_is_dir)
1226        {
1227            if let Some(mode) = settings.dry_run {
1228                crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
1229            }
1230            tracing::debug!("skipping {:?} due to filter", &entry_path);
1231            link_summary = link_summary + skipped_summary_for(entry_kind);
1232            entry_kind.inc_skipped(prog_track);
1233            continue;
1234        }
1235        // keep-set: a source entry has a destination counterpart that must not be pruned, even
1236        // when --skip-specials skips copying it (computed before the skip-specials check below).
1237        keep_set.record_src(&entry_name);
1238        // skip special files (sockets, FIFOs, devices) when --skip-specials is set
1239        if settings.copy_settings.skip_specials && entry_kind == EntryKind::Special {
1240            tracing::debug!("skipping special file {:?}", &entry_path);
1241            if let Some(mode) = settings.dry_run {
1242                match mode {
1243                    crate::config::DryRunMode::Brief => {}
1244                    crate::config::DryRunMode::All => {
1245                        println!("skip special {:?}", &entry_path)
1246                    }
1247                    crate::config::DryRunMode::Explain => {
1248                        println!(
1249                            "skip special {:?} (unsupported file type: {:?})",
1250                            &entry_path, entry_kind
1251                        );
1252                    }
1253                }
1254            }
1255            link_summary.copy_summary.specials_skipped += 1;
1256            prog_track.specials_skipped.inc();
1257            continue;
1258        }
1259        processed_files.insert(entry_name.clone());
1260        // dry-run for non-directory entries: report the would-be action without recursing.
1261        if settings.dry_run.is_some() && !entry_is_dir {
1262            let dst_entry_path = dst_path.join(&entry_name);
1263            crate::dry_run::report_action(
1264                "link",
1265                &entry_path,
1266                Some(&dst_entry_path),
1267                entry_kind.label(),
1268            );
1269            if entry_is_symlink {
1270                // for symlinks in dry-run, count as symlink (in copy_summary)
1271                link_summary.copy_summary.symlinks_created += 1;
1272            } else {
1273                // for files in dry-run, count the "would be created" hard link.
1274                // N.B. when an update tree is present, link_internal decides file-vs-copy
1275                // per-entry; the dry-run hint here counts a hard link, matching the old walk which
1276                // likewise counted a hard link for a regular-file src entry in dry-run.
1277                link_summary.hard_links_created += 1;
1278            }
1279            continue;
1280        }
1281        // for regular-file entries, pre-acquire a leaf permit (open-files pool) BEFORE spawning so we
1282        // don't create unbounded tasks. `preacquire_leaf_permit` is the shared lifecycle primitive:
1283        // its `want` opts in only for a regular-file hint, so directories take none (they recurse and
1284        // would deadlock against a saturated pool), symlinks take none (they pass through to copy,
1285        // which handles permits internally), and a DT_UNKNOWN hint takes none for the same reason —
1286        // exactly the original `hint == Some(File)` policy. `link_internal` re-classifies
1287        // authoritatively and either uses the permit (changed-file copy) or drops it.
1288        //
1289        // Acquire-then-IMMEDIATELY-spawn (the permit is moved into `do_link` and spawned on the next
1290        // line, in the same loop step) is load-bearing: collecting a Vec of pre-acquired permits and
1291        // spawning later would hold N permits before any task runs and self-deadlock a saturated pool.
1292        // This mirrors the single-tree driver's incremental acquire-then-spawn loop
1293        // (`walk_driver::walk_dir_contents`, joined via `walk_driver::join_and_fold`).
1294        let permit = walk::preacquire_leaf_permit(PermitKind::OpenFile, hint, |h| {
1295            h == Some(EntryKind::File)
1296        })
1297        .await;
1298        let src_parent = Arc::clone(src_dir);
1299        let dst_parent = dst_dir.map(Arc::clone);
1300        let update_parent = update_dir.map(Arc::clone);
1301        let settings = settings.clone();
1302        let src_root = src_root.to_owned();
1303        let dst_root = dst_root.to_owned();
1304        let update_root = update_root.map(std::path::Path::to_path_buf);
1305        let do_link = move || async move {
1306            let update_ref = update_parent
1307                .as_ref()
1308                .map(|dir| (dir, entry_name.as_os_str()));
1309            link_internal(
1310                prog_track,
1311                &src_parent,
1312                update_ref,
1313                dst_parent.as_ref(),
1314                &entry_name,
1315                &src_root,
1316                &dst_root,
1317                update_root.as_deref(),
1318                &entry_rel,
1319                &settings,
1320                is_fresh,
1321                permit,
1322            )
1323            .await
1324        };
1325        join_set.spawn(do_link());
1326    }
1327    // only process update if the path was provided and the directory is present
1328    if let Some(update_dir) = update_dir {
1329        let update_root = update_root.expect("update_dir present implies update_root present");
1330        tracing::debug!("process contents of 'update' directory");
1331        let update_entries = update_dir
1332            .read_entries()
1333            .await
1334            .with_context(|| {
1335                format!(
1336                    "cannot open directory {:?} for reading",
1337                    update_path_dbg(update_root, rel_path)
1338                )
1339            })
1340            .map_err(|err| Error::new(err, link_summary))?;
1341        // Iterate through update entries and for each one that's not present in src, copy it.
1342        //
1343        // We deliberately do NOT pre-acquire any permit here. Two cycles rule out the
1344        // straightforward options:
1345        //   * `open_file_permit`: copy_child → copy_internal re-acquires open-files for each file;
1346        //     a saturated pool would deadlock the inner acquire if we held one across the call.
1347        //   * `pending_meta_permit`: with --overwrite, copy_child → copy_file_fd → rm::rm drains
1348        //     pending_meta for child entries (rm.rs spawn loop). N tasks here each holding a
1349        //     pending_meta permit would deadlock waiting on each other's inner rm.
1350        //
1351        // The spawn count at this site is naturally bounded by the number of update-only entries
1352        // (user input — typically modest). Each spawned task's work is throttled by copy's own
1353        // internal open-files backpressure inside copy_internal's spawn loop.
1354        for (entry_name, hint) in update_entries {
1355            let entry_kind = hint.unwrap_or(EntryKind::File);
1356            let entry_rel = rel_path.join(&entry_name);
1357            // the FILTER `is_dir` decision must use the AUTHORITATIVE type: on a DT_UNKNOWN update
1358            // entry with an is_dir-dependent include filter, defaulting to non-dir would wrongly
1359            // omit a real directory's whole subtree. one extra fstat only in that DT_UNKNOWN+filter
1360            // case (never follows symlinks).
1361            let entry_is_dir = walk::filter_is_dir(
1362                settings.filter.as_ref(),
1363                update_dir,
1364                &entry_name,
1365                hint,
1366                // used only for the filter decision here — no dry-run recurse-vs-leaf shortcut
1367                // (delegate_copy reclassifies authoritatively) — so no need to force an fstat.
1368                false,
1369            )
1370            .await;
1371            // evaluate the filter for this update entry at its logical path. This MUST run
1372            // regardless of `--delete`: `copy_child` wraps `copy_internal`, which (unlike the old
1373            // path-based `copy_with_filter_base`) does NOT re-apply a top-level filter to the entry
1374            // it is handed, so without this skip an `--exclude`'d update-only entry would be copied.
1375            let skip_result = walk::should_skip_entry(&settings.filter, &entry_rel, entry_is_dir);
1376            let filtered_out = skip_result.is_some();
1377            // keep-set: every filter-passing update entry is materialized at the destination
1378            // (entries also in `src` are linked, update-only entries are copied). Computed before
1379            // the dedup `continue` so entries also present in `src` are covered — this is what
1380            // makes --update-exclusive mirror the update set exactly.
1381            //
1382            // A filtered-out update entry contributes NOTHING here and never drops an existing
1383            // src-side registration: when the src loop materialized a same-name entry it can only
1384            // be a TYPE MISMATCH (a type-independent pattern would have excluded the src too), and
1385            // the union semantics keep the src's version (`link_internal` falls back to source-only
1386            // handling), so its keep-set entry must survive — pruning it would delete what we just
1387            // materialized. Under --update-exclusive `record_src` was a no-op, so there is likewise
1388            // nothing to keep. Either way the filtered-out branch leaves the keep-set untouched.
1389            if settings.copy_settings.delete.is_some() && !filtered_out {
1390                keep_set.record_update(&entry_name);
1391            }
1392            if processed_files.contains(&entry_name) {
1393                // we already must have considered this file, skip it
1394                continue;
1395            }
1396            // filtered-out update-only entry: skip the delegation entirely (matching the old
1397            // `copy_with_filter_base`'s top-level filter), and record the skip in the summary /
1398            // counters exactly as the source loop does for a filtered src entry.
1399            if let Some(skip_result) = skip_result {
1400                let update_entry_path = update_root.join(&entry_rel);
1401                if let Some(mode) = settings.dry_run {
1402                    crate::dry_run::report_skip(
1403                        &update_entry_path,
1404                        &skip_result,
1405                        mode,
1406                        entry_kind.label(),
1407                    );
1408                }
1409                tracing::debug!(
1410                    "skipping update entry {:?} due to filter",
1411                    &update_entry_path
1412                );
1413                link_summary = link_summary + skipped_summary_for(entry_kind);
1414                entry_kind.inc_skipped(prog_track);
1415                continue;
1416            }
1417            tracing::debug!("found a new entry in the 'update' directory");
1418            let update_entry_path = update_root.join(&entry_rel);
1419            let dst_entry_path = dst_path.join(&entry_name);
1420            let update_parent = Arc::clone(update_dir);
1421            let dst_parent = dst_dir.map(Arc::clone);
1422            let settings = settings.clone();
1423            let do_copy = move || async move {
1424                // filter-base for the delegated copy: this update entry's path relative to the
1425                // source root, so any --delete pruning inside it matches the include/exclude filter
1426                // at the entry's true relative path (e.g. cache/*.log), not relative to the entry.
1427                delegate_copy(
1428                    prog_track,
1429                    &update_parent,
1430                    dst_parent.as_ref(),
1431                    &entry_name,
1432                    &update_entry_path,
1433                    &dst_entry_path,
1434                    &entry_rel,
1435                    &settings,
1436                    is_fresh,
1437                    None,
1438                )
1439                .await
1440            };
1441            join_set.spawn(do_copy());
1442        }
1443    }
1444    while let Some(res) = join_set.join_next().await {
1445        match res {
1446            Ok(result) => match result {
1447                Ok(summary) => link_summary = link_summary + summary,
1448                Err(error) => {
1449                    tracing::error!(
1450                        "link: {:?} -> {:?} failed with: {:#}",
1451                        src_path,
1452                        dst_path,
1453                        &error
1454                    );
1455                    link_summary = link_summary + error.summary;
1456                    if settings.copy_settings.fail_early {
1457                        return Err(Error::new(error.source, link_summary));
1458                    }
1459                    errors.push(error.source);
1460                }
1461            },
1462            Err(error) => {
1463                if settings.copy_settings.fail_early {
1464                    return Err(Error::new(error.into(), link_summary));
1465                }
1466                errors.push(error.into());
1467            }
1468        }
1469    }
1470    // rsync-style --delete for rlink: remove destination entries the link operation did not
1471    // materialize. `keep_set` holds exactly the materialized names: src ∪ update normally, or just
1472    // the update set under --update-exclusive (where source-only entries are not materialized and
1473    // so are pruned, matching `rsync --link-dest --delete`).
1474    if let Some(delete_settings) = &settings.copy_settings.delete {
1475        if errors.has_errors() {
1476            // rsync-style safety: skip pruning when this subtree's link/update pass reported errors
1477            // — deleting based on a run that did not fully succeed could remove data unexpectedly.
1478            tracing::warn!(
1479                "skipping --delete pruning of {:?} because the link/update pass reported errors",
1480                dst_path
1481            );
1482        } else {
1483            // the prune scan runs through the destination directory's own pinned fd. In a real link
1484            // we already hold it (`dst_dir`); in --dry-run the create-or-overwrite step is skipped,
1485            // so open it `O_NOFOLLOW|O_DIRECTORY` (dereference=false) — a symlink or non-directory
1486            // fails closed and prune is skipped, never following the symlink to delete a tree
1487            // OUTSIDE the destination. A missing dir likewise skips.
1488            // This reopen runs only in --dry-run (a real link holds `dst_dir`). It re-resolves the
1489            // destination directory BELOW the named root; under strict operand resolution the open
1490            // is openat2(RESOLVE_NO_SYMLINKS), so a symlink anywhere in `dst_path` — an intermediate
1491            // component the real run would replace, or a final symlink — fails with ELOOP and is
1492            // SKIPPED here (nothing to prune; the real run creates a fresh directory). The operand
1493            // prefix ABOVE the named root is validated up front, so this below-root skip does not
1494            // weaken the strict contract and keeps the preview consistent with the real run.
1495            let prune_dir: Option<Arc<Dir>> = match dst_dir {
1496                Some(dir) => Some(Arc::clone(dir)),
1497                None => {
1498                    match Dir::open_root_dir(dst_path, false, congestion::Side::Destination).await {
1499                        Ok(dir) => Some(Arc::new(dir)),
1500                        Err(err)
1501                            if matches!(
1502                                err.kind(),
1503                                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1504                            ) || err.raw_os_error() == Some(libc::ELOOP)
1505                                || err.raw_os_error() == Some(libc::ENOTDIR) =>
1506                        {
1507                            tracing::debug!(
1508                                "skipping --delete pruning of {:?}: not a real directory",
1509                                dst_path
1510                            );
1511                            None
1512                        }
1513                        Err(err) => {
1514                            let err = anyhow::Error::new(err).context(format!(
1515                                "cannot open destination {dst_path:?} for delete scan"
1516                            ));
1517                            if settings.copy_settings.fail_early {
1518                                return Err(Error::new(err, link_summary));
1519                            }
1520                            errors.push(err);
1521                            None
1522                        }
1523                    }
1524                }
1525            };
1526            if let Some(prune_dir) = prune_dir {
1527                match crate::delete::prune_extraneous(
1528                    prog_track,
1529                    &prune_dir,
1530                    rel_path,
1531                    keep_set
1532                        .as_set()
1533                        .expect("--delete is on, so DeleteKeepSet is active"),
1534                    settings.filter.as_ref(),
1535                    delete_settings,
1536                    settings.copy_settings.fail_early,
1537                    settings.dry_run,
1538                )
1539                .await
1540                {
1541                    Ok(rm_summary) => {
1542                        link_summary.copy_summary.rm_summary =
1543                            link_summary.copy_summary.rm_summary + rm_summary;
1544                    }
1545                    Err(err) => {
1546                        link_summary.copy_summary.rm_summary =
1547                            link_summary.copy_summary.rm_summary + err.summary;
1548                        if settings.copy_settings.fail_early {
1549                            return Err(Error::new(err.source, link_summary));
1550                        }
1551                        errors.push(err.source);
1552                    }
1553                }
1554            }
1555        }
1556    }
1557    // when filtering is active and we created this directory, check if anything was actually
1558    // linked/copied into it. if nothing was linked, we may need to clean up the empty directory.
1559    let this_dir_count = usize::from(we_created_this_dir);
1560    let child_dirs_created = link_summary
1561        .copy_summary
1562        .directories_created
1563        .saturating_sub(this_dir_count);
1564    let anything_linked = link_summary.hard_links_created > 0
1565        || link_summary.copy_summary.files_copied > 0
1566        || link_summary.copy_summary.symlinks_created > 0
1567        || child_dirs_created > 0;
1568    let is_root = rel_path.as_os_str().is_empty();
1569    match check_empty_dir_cleanup(
1570        settings.filter.as_ref(),
1571        we_created_this_dir,
1572        anything_linked,
1573        rel_path,
1574        is_root,
1575        settings.dry_run.is_some(),
1576    ) {
1577        EmptyDirAction::Keep => { /* proceed with metadata application */ }
1578        EmptyDirAction::DryRunSkip => {
1579            tracing::debug!(
1580                "dry-run: directory {:?} would not be created (nothing to link inside)",
1581                dst_path
1582            );
1583            link_summary.copy_summary.directories_created = 0;
1584            // a child error collected during the walk must still surface — otherwise a
1585            // traversal-only directory whose only child FAILED becomes "empty", is skipped here, and
1586            // the failed link is reported as success (mirrors copy::finalize_dir).
1587            if errors.has_errors() {
1588                return Err(Error::new(errors.into_error().unwrap(), link_summary));
1589            }
1590            return Ok(link_summary);
1591        }
1592        EmptyDirAction::Remove => {
1593            tracing::debug!(
1594                "directory {:?} has nothing to link inside, removing empty directory",
1595                dst_path
1596            );
1597            // remove the empty directory fd-relative, through its parent dir handle: `rmdir_at`
1598            // operates on `dst_name` within the held `dst_parent` fd (never by path) and only
1599            // succeeds on an empty directory, so it is contained to `dst_parent`. `dst_parent` is
1600            // always Some here (None only in dry-run, where this arm is unreachable).
1601            let rmdir_result = match dst_parent {
1602                Some(dst_parent) => dst_parent.rmdir_at(dst_name).await,
1603                None => {
1604                    crate::walk::run_metadata_probed(
1605                        congestion::Side::Destination,
1606                        congestion::MetadataOp::RmDir,
1607                        tokio::fs::remove_dir(dst_path),
1608                    )
1609                    .await
1610                }
1611            };
1612            match rmdir_result {
1613                Ok(()) => {
1614                    link_summary.copy_summary.directories_created = 0;
1615                    // surface a collected child error even though the empty directory was removed,
1616                    // so a failed child link is never reported as success (mirrors
1617                    // copy::finalize_dir).
1618                    if errors.has_errors() {
1619                        return Err(Error::new(errors.into_error().unwrap(), link_summary));
1620                    }
1621                    return Ok(link_summary);
1622                }
1623                Err(err) => {
1624                    // removal failed (not empty, permission error, etc.) — keep directory
1625                    tracing::debug!(
1626                        "failed to remove empty directory {:?}: {:#}, keeping",
1627                        dst_path,
1628                        &err
1629                    );
1630                    // fall through to apply metadata
1631                }
1632            }
1633        }
1634    }
1635    // apply directory metadata regardless of whether all children linked successfully. the
1636    // directory itself was created/opened above. skipped in dry-run (no directory exists). prefer
1637    // the update directory's metadata when an update tree is present at this level (it is the
1638    // materialized version, matching the old `update_metadata_opt` preference), else the source
1639    // directory's. The metadata is read from the SAME fd whose contents were enumerated (read-side
1640    // fidelity, docs/tocttou.md), not the classify handles.
1641    tracing::debug!("set 'dst' directory metadata");
1642    let metadata_result = match dst_dir {
1643        Some(dst_dir) => {
1644            let meta_dir = update_dir.unwrap_or(src_dir);
1645            match meta_dir.meta().await {
1646                Ok(preserve_meta) => {
1647                    crate::safedir::set_dir_metadata_fd(&settings.preserve, &preserve_meta, dst_dir)
1648                        .await
1649                }
1650                Err(e) => Err(e),
1651            }
1652        }
1653        None => Ok(()),
1654    };
1655    if errors.has_errors() {
1656        // child failures take precedence - log metadata error if it also failed
1657        if let Err(metadata_err) = metadata_result {
1658            tracing::error!(
1659                "link: {:?} -> {:?} failed to set directory metadata: {:#}",
1660                src_path,
1661                dst_path,
1662                &metadata_err
1663            );
1664        }
1665        // unwrap is safe: has_errors() guarantees into_error() returns Some
1666        return Err(Error::new(errors.into_error().unwrap(), link_summary));
1667    }
1668    // no child failures, so metadata error is the primary error
1669    metadata_result
1670        .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
1671        .map_err(|err| Error::new(err, link_summary))?;
1672    Ok(link_summary)
1673}
1674
1675/// Reconstruct an update entry's path purely for a diagnostic message.
1676fn update_path_dbg(
1677    update_root: &std::path::Path,
1678    rel_path: &std::path::Path,
1679) -> std::path::PathBuf {
1680    if rel_path.as_os_str().is_empty() {
1681        update_root.to_path_buf()
1682    } else {
1683        update_root.join(rel_path)
1684    }
1685}
1686
1687#[cfg(test)]
1688mod link_tests {
1689    use crate::rm;
1690    use crate::testutils;
1691    use std::os::unix::fs::PermissionsExt;
1692    use tracing_test::traced_test;
1693
1694    use super::*;
1695
1696    static PROGRESS: std::sync::LazyLock<progress::Progress> =
1697        std::sync::LazyLock::new(progress::Progress::new);
1698
1699    mod delete_keep_set_tests {
1700        //! Pure-logic unit tests for `DeleteKeepSet`. No filesystem needed — these pin the
1701        //! src-vs-update materialization rules so a future refactor can't silently break them.
1702
1703        use super::super::DeleteKeepSet;
1704        use crate::copy::DeleteSettings;
1705        use std::ffi::{OsStr, OsString};
1706
1707        fn delete_on() -> DeleteSettings {
1708            DeleteSettings {
1709                delete_excluded: false,
1710            }
1711        }
1712
1713        #[test]
1714        fn record_src_no_op_when_delete_off() {
1715            let mut k = DeleteKeepSet::new(None, false, false);
1716            k.record_src(OsStr::new("foo"));
1717            assert!(k.as_set().is_none());
1718        }
1719
1720        #[test]
1721        fn record_src_no_op_under_update_exclusive_with_update() {
1722            // `--update-exclusive` with an active update tree means the materialized set is
1723            // the update set; source-only entries must NOT be retained.
1724            let d = delete_on();
1725            let mut k = DeleteKeepSet::new(Some(&d), true, true);
1726            k.record_src(OsStr::new("src_only"));
1727            assert!(!k.as_set().unwrap().contains(OsStr::new("src_only")));
1728        }
1729
1730        #[test]
1731        fn record_src_records_when_update_exclusive_without_update() {
1732            // `--update-exclusive` is a no-op (carve-out doesn't apply) when no `--update`
1733            // path is given.
1734            let d = delete_on();
1735            let mut k = DeleteKeepSet::new(Some(&d), true, false);
1736            k.record_src(OsStr::new("foo"));
1737            assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1738        }
1739
1740        #[test]
1741        fn record_src_records_in_normal_delete_mode() {
1742            let d = delete_on();
1743            let mut k = DeleteKeepSet::new(Some(&d), false, false);
1744            k.record_src(OsStr::new("foo"));
1745            assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1746        }
1747
1748        #[test]
1749        fn record_update_always_records_when_delete_on() {
1750            // The update loop registers ALL filter-passing update entries, irrespective of
1751            // `--update-exclusive` — the update set IS the materialized set in that mode.
1752            let d = delete_on();
1753            let mut k = DeleteKeepSet::new(Some(&d), true, true);
1754            k.record_update(OsStr::new("from_update"));
1755            assert!(k.as_set().unwrap().contains(OsStr::new("from_update")));
1756        }
1757
1758        #[test]
1759        fn record_update_no_op_when_delete_off() {
1760            let mut k = DeleteKeepSet::new(None, false, false);
1761            k.record_update(OsStr::new("from_update"));
1762            assert!(k.as_set().is_none());
1763        }
1764
1765        #[test]
1766        fn filtered_out_update_keeps_materialized_src_entry_in_normal_mode() {
1767            // Type-mismatch under normal `--update` (union): src had a regular file at `node`,
1768            // update has a dir at `node/` excluded by the dir-only `node/` pattern. The src's
1769            // version of the name stands (`link_internal` falls back to source-only handling), so
1770            // the keep-set entry recorded by the source loop MUST survive — pruning it would
1771            // delete the file we just materialized. The filtered-out update branch is therefore a
1772            // no-op on the keep-set: `node` stays.
1773            let d = delete_on();
1774            let mut k = DeleteKeepSet::new(Some(&d), false, true);
1775            k.record_src(OsStr::new("node"));
1776            // (the update loop's filtered-out branch records nothing and removes nothing)
1777            assert!(
1778                k.as_set().unwrap().contains(OsStr::new("node")),
1779                "src entry must stay in the keep-set when its update counterpart is filtered out"
1780            );
1781        }
1782
1783        #[test]
1784        fn filtered_out_update_keeps_skipped_special() {
1785            // The skip-special case: source loop ran `record_src` but never reached
1786            // `processed_files.insert` (it `continue`d on the skip-special branch). The dst
1787            // counterpart must be retained per --skip-specials semantics, and a filtered-out
1788            // update entry at the same name does not change that.
1789            let d = delete_on();
1790            let mut k = DeleteKeepSet::new(Some(&d), false, true);
1791            k.record_src(OsStr::new("pipe"));
1792            assert!(k.as_set().unwrap().contains(OsStr::new("pipe")));
1793        }
1794
1795        #[test]
1796        fn full_directory_pass_keep_set_union_semantics() {
1797            // Models the union of src + update under plain `--delete --update` (no
1798            // --update-exclusive). Names: src has `keep`, `pipe` (special, skipped),
1799            // `node` (file). update has `from_upd`, `node` (a dir excluded by the dir-only
1800            // `node/` pattern). Under union semantics the excluded update `node/` does not
1801            // displace the src `node` file: `link_internal` materializes the src version, so
1802            // `node` STAYS in the keep-set (the filtered-out update branch records/removes
1803            // nothing). This is the corrected behavior versus the old type-mismatch bug, where
1804            // the excluded update dir was copied AND the src keep-set entry was dropped.
1805            let d = delete_on();
1806            let mut k = DeleteKeepSet::new(Some(&d), false, true);
1807
1808            // source loop
1809            k.record_src(OsStr::new("keep"));
1810            k.record_src(OsStr::new("pipe")); // --skip-specials: continues, processed_files NOT populated
1811            k.record_src(OsStr::new("node"));
1812
1813            // update loop: only filter-passing update entries are recorded; `node` is filtered out
1814            // and so contributes nothing (and does not drop the src `node`).
1815            k.record_update(OsStr::new("from_upd"));
1816
1817            let set: std::collections::HashSet<OsString> = k.as_set().unwrap().clone();
1818            let expected: std::collections::HashSet<OsString> =
1819                ["keep", "pipe", "node", "from_upd"]
1820                    .into_iter()
1821                    .map(OsString::from)
1822                    .collect();
1823            assert_eq!(set, expected);
1824        }
1825    }
1826
1827    fn common_settings(dereference: bool, overwrite: bool) -> Settings {
1828        Settings {
1829            copy_settings: CopySettings {
1830                dereference,
1831                fail_early: false,
1832                overwrite,
1833                overwrite_compare: filecmp::MetadataCmpSettings {
1834                    size: true,
1835                    mtime: true,
1836                    ..Default::default()
1837                },
1838                overwrite_filter: None,
1839                ignore_existing: false,
1840                chunk_size: 0,
1841                skip_specials: false,
1842                remote_copy_buffer_size: 0,
1843                filter: None,
1844                dry_run: None,
1845                delete: None,
1846            },
1847            update_compare: filecmp::MetadataCmpSettings {
1848                size: true,
1849                mtime: true,
1850                ..Default::default()
1851            },
1852            update_exclusive: false,
1853            filter: None,
1854            dry_run: None,
1855            preserve: preserve::preserve_all(),
1856        }
1857    }
1858
1859    #[tokio::test]
1860    #[traced_test]
1861    async fn test_basic_link() -> Result<(), anyhow::Error> {
1862        let tmp_dir = testutils::setup_test_dir().await?;
1863        let test_path = tmp_dir.as_path();
1864        let summary = link(
1865            &PROGRESS,
1866            test_path,
1867            &test_path.join("foo"),
1868            &test_path.join("bar"),
1869            &None,
1870            &common_settings(false, false),
1871            false,
1872        )
1873        .await?;
1874        assert_eq!(summary.hard_links_created, 5);
1875        assert_eq!(summary.copy_summary.files_copied, 0);
1876        assert_eq!(summary.copy_summary.symlinks_created, 2);
1877        assert_eq!(summary.copy_summary.directories_created, 3);
1878        testutils::check_dirs_identical(
1879            &test_path.join("foo"),
1880            &test_path.join("bar"),
1881            testutils::FileEqualityCheck::Timestamp,
1882        )
1883        .await?;
1884        Ok(())
1885    }
1886
1887    // Regression: a source operand whose final component is `.`/`..` (e.g. `rlink tree/.. dst`)
1888    // must be linked, not rejected — `split_root_operand` canonicalizes it. Uses `tree/sub/..`
1889    // (== `tree`) rather than `.` to avoid touching the process-wide cwd.
1890    #[tokio::test]
1891    async fn links_dot_dot_source_operand() -> Result<(), anyhow::Error> {
1892        use std::os::unix::fs::MetadataExt;
1893        let tmp = testutils::create_temp_dir().await?;
1894        let tree = tmp.join("tree");
1895        tokio::fs::create_dir(&tree).await?;
1896        tokio::fs::write(tree.join("a.txt"), "hello").await?;
1897        tokio::fs::create_dir(tree.join("sub")).await?;
1898        let src = tree.join("sub").join(".."); // == tree
1899        let dst = tmp.join("dst");
1900        let summary = link(
1901            &PROGRESS,
1902            &tmp,
1903            &src,
1904            &dst,
1905            &None,
1906            &common_settings(false, false),
1907            false,
1908        )
1909        .await?;
1910        assert_eq!(
1911            summary.hard_links_created, 1,
1912            "the dot-dot source's file must be hard-linked"
1913        );
1914        assert!(
1915            dst.join("sub").is_dir(),
1916            "the dot-dot source's subdir must be created"
1917        );
1918        // the dst file shares the src inode (a hard link, not a copy).
1919        let src_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
1920        let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
1921        assert_eq!(src_ino, dst_ino, "dst must be a hard link to the src inode");
1922        Ok(())
1923    }
1924
1925    // Regression: an `--update` operand whose final component is `.`/`..` (e.g.
1926    // `rlink --update tree/.. src dst`) must be accepted, not rejected — the update tree is a READ
1927    // tree, so `split_root_operand` canonicalizes it the same as the source. Uses `tree/sub/..`
1928    // (== `tree`) rather than `.` to avoid touching the process-wide cwd; src == update == tree so
1929    // the file links deterministically from the update tree.
1930    #[tokio::test]
1931    async fn links_dot_dot_update_operand() -> Result<(), anyhow::Error> {
1932        use std::os::unix::fs::MetadataExt;
1933        let tmp = testutils::create_temp_dir().await?;
1934        let tree = tmp.join("tree");
1935        tokio::fs::create_dir(&tree).await?;
1936        tokio::fs::write(tree.join("a.txt"), "hello").await?;
1937        tokio::fs::create_dir(tree.join("sub")).await?;
1938        let dst = tmp.join("dst");
1939        // the --update operand spelled with a trailing `..` (== tree); it must be canonicalized and
1940        // used, not rejected with "has no parent directory or file name".
1941        let update_operand = tree.join("sub").join(".."); // == tree
1942        let summary = link(
1943            &PROGRESS,
1944            &tmp,
1945            &tree,
1946            &dst,
1947            &Some(update_operand),
1948            &common_settings(false, false),
1949            false,
1950        )
1951        .await?;
1952        assert_eq!(
1953            summary.hard_links_created, 1,
1954            "the file must be hard-linked from the dot-dot update tree"
1955        );
1956        // the dst file shares the update tree's inode (linked from it, not copied).
1957        let update_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
1958        let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
1959        assert_eq!(
1960            update_ino, dst_ino,
1961            "dst must be hard-linked from the update tree inode"
1962        );
1963        Ok(())
1964    }
1965
1966    #[tokio::test]
1967    #[traced_test]
1968    async fn test_basic_link_update() -> Result<(), anyhow::Error> {
1969        let tmp_dir = testutils::setup_test_dir().await?;
1970        let test_path = tmp_dir.as_path();
1971        let summary = link(
1972            &PROGRESS,
1973            test_path,
1974            &test_path.join("foo"),
1975            &test_path.join("bar"),
1976            &Some(test_path.join("foo")),
1977            &common_settings(false, false),
1978            false,
1979        )
1980        .await?;
1981        assert_eq!(summary.hard_links_created, 5);
1982        assert_eq!(summary.copy_summary.files_copied, 0);
1983        assert_eq!(summary.copy_summary.symlinks_created, 2);
1984        assert_eq!(summary.copy_summary.directories_created, 3);
1985        testutils::check_dirs_identical(
1986            &test_path.join("foo"),
1987            &test_path.join("bar"),
1988            testutils::FileEqualityCheck::Timestamp,
1989        )
1990        .await?;
1991        Ok(())
1992    }
1993
1994    #[tokio::test]
1995    #[traced_test]
1996    async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
1997        let tmp_dir = testutils::setup_test_dir().await?;
1998        tokio::fs::create_dir(tmp_dir.join("baz")).await?;
1999        let test_path = tmp_dir.as_path();
2000        let summary = link(
2001            &PROGRESS,
2002            test_path,
2003            &test_path.join("baz"), // empty source
2004            &test_path.join("bar"),
2005            &Some(test_path.join("foo")),
2006            &common_settings(false, false),
2007            false,
2008        )
2009        .await?;
2010        assert_eq!(summary.hard_links_created, 0);
2011        assert_eq!(summary.copy_summary.files_copied, 5);
2012        assert_eq!(summary.copy_summary.symlinks_created, 2);
2013        assert_eq!(summary.copy_summary.directories_created, 3);
2014        testutils::check_dirs_identical(
2015            &test_path.join("foo"),
2016            &test_path.join("bar"),
2017            testutils::FileEqualityCheck::Timestamp,
2018        )
2019        .await?;
2020        Ok(())
2021    }
2022
2023    #[tokio::test]
2024    #[traced_test]
2025    async fn test_link_destination_permission_error_includes_root_cause()
2026    -> Result<(), anyhow::Error> {
2027        let tmp_dir = testutils::setup_test_dir().await?;
2028        let test_path = tmp_dir.as_path();
2029        let readonly_parent = test_path.join("readonly_dest");
2030        tokio::fs::create_dir(&readonly_parent).await?;
2031        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
2032            .await?;
2033
2034        let mut settings = common_settings(false, false);
2035        settings.copy_settings.fail_early = true;
2036
2037        let result = link(
2038            &PROGRESS,
2039            test_path,
2040            &test_path.join("foo"),
2041            &readonly_parent.join("bar"),
2042            &None,
2043            &settings,
2044            false,
2045        )
2046        .await;
2047
2048        // restore permissions to allow temporary directory cleanup
2049        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
2050            .await?;
2051
2052        assert!(result.is_err(), "link into read-only parent should fail");
2053        let err = result.unwrap_err();
2054        let err_msg = format!("{:#}", err.source);
2055        assert!(
2056            err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
2057            "Error message must include permission denied text. Got: {}",
2058            err_msg
2059        );
2060        Ok(())
2061    }
2062
2063    #[tokio::test]
2064    #[traced_test]
2065    async fn hard_link_file_into_readonly_parent_returns_error() -> Result<(), anyhow::Error> {
2066        // regression: hard_link_helper used to silently ignore non-AlreadyExists errors
2067        // and report hard_links_created=1 when the underlying hard_link call had failed
2068        let tmp_dir = testutils::setup_test_dir().await?;
2069        let src = tmp_dir.join("src.txt");
2070        tokio::fs::write(&src, "content").await?;
2071        let readonly_parent = tmp_dir.join("readonly_parent");
2072        tokio::fs::create_dir(&readonly_parent).await?;
2073        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
2074            .await?;
2075        let dst = readonly_parent.join("dst.txt");
2076        let settings = common_settings(false, false);
2077        let result = link(&PROGRESS, &tmp_dir, &src, &dst, &None, &settings, false).await;
2078        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
2079            .await?;
2080        let err = result.expect_err("link into read-only parent should fail");
2081        assert_eq!(err.summary.hard_links_created, 0);
2082        let err_msg = format!("{:#}", err.source);
2083        assert!(
2084            err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
2085            "error should include root cause, got: {err_msg}"
2086        );
2087        Ok(())
2088    }
2089
2090    pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
2091        // update
2092        // |- 0.txt
2093        // |- bar
2094        //    |- 1.txt
2095        //    |- 2.txt -> ../0.txt
2096        let foo_path = tmp_dir.join("update");
2097        tokio::fs::create_dir(&foo_path).await.unwrap();
2098        tokio::fs::write(foo_path.join("0.txt"), "0-new")
2099            .await
2100            .unwrap();
2101        let bar_path = foo_path.join("bar");
2102        tokio::fs::create_dir(&bar_path).await.unwrap();
2103        tokio::fs::write(bar_path.join("1.txt"), "1-new")
2104            .await
2105            .unwrap();
2106        tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
2107            .await
2108            .unwrap();
2109        tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
2110        Ok(())
2111    }
2112
2113    #[tokio::test]
2114    #[traced_test]
2115    async fn test_link_update() -> Result<(), anyhow::Error> {
2116        let tmp_dir = testutils::setup_test_dir().await?;
2117        setup_update_dir(&tmp_dir).await?;
2118        let test_path = tmp_dir.as_path();
2119        let summary = link(
2120            &PROGRESS,
2121            test_path,
2122            &test_path.join("foo"),
2123            &test_path.join("bar"),
2124            &Some(test_path.join("update")),
2125            &common_settings(false, false),
2126            false,
2127        )
2128        .await?;
2129        assert_eq!(summary.hard_links_created, 2);
2130        assert_eq!(summary.copy_summary.files_copied, 2);
2131        assert_eq!(summary.copy_summary.symlinks_created, 3);
2132        assert_eq!(summary.copy_summary.directories_created, 3);
2133        // compare subset of src and dst
2134        testutils::check_dirs_identical(
2135            &test_path.join("foo").join("baz"),
2136            &test_path.join("bar").join("baz"),
2137            testutils::FileEqualityCheck::HardLink,
2138        )
2139        .await?;
2140        // compare update and dst
2141        testutils::check_dirs_identical(
2142            &test_path.join("update"),
2143            &test_path.join("bar"),
2144            testutils::FileEqualityCheck::Timestamp,
2145        )
2146        .await?;
2147        Ok(())
2148    }
2149
2150    #[tokio::test]
2151    #[traced_test]
2152    async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
2153        let tmp_dir = testutils::setup_test_dir().await?;
2154        setup_update_dir(&tmp_dir).await?;
2155        let test_path = tmp_dir.as_path();
2156        let mut settings = common_settings(false, false);
2157        settings.update_exclusive = true;
2158        let summary = link(
2159            &PROGRESS,
2160            test_path,
2161            &test_path.join("foo"),
2162            &test_path.join("bar"),
2163            &Some(test_path.join("update")),
2164            &settings,
2165            false,
2166        )
2167        .await?;
2168        // we should end up with same directory as the update
2169        // |- 0.txt
2170        // |- bar
2171        //    |- 1.txt
2172        //    |- 2.txt -> ../0.txt
2173        assert_eq!(summary.hard_links_created, 0);
2174        assert_eq!(summary.copy_summary.files_copied, 2);
2175        assert_eq!(summary.copy_summary.symlinks_created, 1);
2176        assert_eq!(summary.copy_summary.directories_created, 2);
2177        // compare update and dst
2178        testutils::check_dirs_identical(
2179            &test_path.join("update"),
2180            &test_path.join("bar"),
2181            testutils::FileEqualityCheck::Timestamp,
2182        )
2183        .await?;
2184        Ok(())
2185    }
2186
2187    async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
2188        let tmp_dir = testutils::setup_test_dir().await?;
2189        let test_path = tmp_dir.as_path();
2190        let summary = link(
2191            &PROGRESS,
2192            test_path,
2193            &test_path.join("foo"),
2194            &test_path.join("bar"),
2195            &None,
2196            &common_settings(false, false),
2197            false,
2198        )
2199        .await?;
2200        assert_eq!(summary.hard_links_created, 5);
2201        assert_eq!(summary.copy_summary.symlinks_created, 2);
2202        assert_eq!(summary.copy_summary.directories_created, 3);
2203        Ok(tmp_dir)
2204    }
2205
2206    #[tokio::test]
2207    #[traced_test]
2208    async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
2209        let tmp_dir = setup_test_dir_and_link().await?;
2210        let output_path = &tmp_dir.join("bar");
2211        {
2212            // bar
2213            // |- 0.txt
2214            // |- bar  <---------------------------------------- REMOVE
2215            //    |- 1.txt  <----------------------------------- REMOVE
2216            //    |- 2.txt  <----------------------------------- REMOVE
2217            //    |- 3.txt  <----------------------------------- REMOVE
2218            // |- baz
2219            //    |- 4.txt
2220            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
2221            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
2222            let summary = rm::rm(
2223                &PROGRESS,
2224                &output_path.join("bar"),
2225                &rm::Settings {
2226                    fail_early: false,
2227                    filter: None,
2228                    dry_run: None,
2229                    time_filter: None,
2230                },
2231            )
2232            .await?
2233                + rm::rm(
2234                    &PROGRESS,
2235                    &output_path.join("baz").join("5.txt"),
2236                    &rm::Settings {
2237                        fail_early: false,
2238                        filter: None,
2239                        dry_run: None,
2240                        time_filter: None,
2241                    },
2242                )
2243                .await?;
2244            assert_eq!(summary.files_removed, 3);
2245            assert_eq!(summary.symlinks_removed, 1);
2246            assert_eq!(summary.directories_removed, 1);
2247        }
2248        let summary = link(
2249            &PROGRESS,
2250            &tmp_dir,
2251            &tmp_dir.join("foo"),
2252            output_path,
2253            &None,
2254            &common_settings(false, true), // overwrite!
2255            false,
2256        )
2257        .await?;
2258        assert_eq!(summary.hard_links_created, 3);
2259        assert_eq!(summary.copy_summary.symlinks_created, 1);
2260        assert_eq!(summary.copy_summary.directories_created, 1);
2261        testutils::check_dirs_identical(
2262            &tmp_dir.join("foo"),
2263            output_path,
2264            testutils::FileEqualityCheck::Timestamp,
2265        )
2266        .await?;
2267        Ok(())
2268    }
2269
2270    #[tokio::test]
2271    #[traced_test]
2272    async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
2273        let tmp_dir = setup_test_dir_and_link().await?;
2274        let output_path = &tmp_dir.join("bar");
2275        {
2276            // bar
2277            // |- 0.txt
2278            // |- bar  <---------------------------------------- REMOVE
2279            //    |- 1.txt  <----------------------------------- REMOVE
2280            //    |- 2.txt  <----------------------------------- REMOVE
2281            //    |- 3.txt  <----------------------------------- REMOVE
2282            // |- baz
2283            //    |- 4.txt
2284            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
2285            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
2286            let summary = rm::rm(
2287                &PROGRESS,
2288                &output_path.join("bar"),
2289                &rm::Settings {
2290                    fail_early: false,
2291                    filter: None,
2292                    dry_run: None,
2293                    time_filter: None,
2294                },
2295            )
2296            .await?
2297                + rm::rm(
2298                    &PROGRESS,
2299                    &output_path.join("baz").join("5.txt"),
2300                    &rm::Settings {
2301                        fail_early: false,
2302                        filter: None,
2303                        dry_run: None,
2304                        time_filter: None,
2305                    },
2306                )
2307                .await?;
2308            assert_eq!(summary.files_removed, 3);
2309            assert_eq!(summary.symlinks_removed, 1);
2310            assert_eq!(summary.directories_removed, 1);
2311        }
2312        setup_update_dir(&tmp_dir).await?;
2313        // update
2314        // |- 0.txt
2315        // |- bar
2316        //    |- 1.txt
2317        //    |- 2.txt -> ../0.txt
2318        let summary = link(
2319            &PROGRESS,
2320            &tmp_dir,
2321            &tmp_dir.join("foo"),
2322            output_path,
2323            &Some(tmp_dir.join("update")),
2324            &common_settings(false, true), // overwrite!
2325            false,
2326        )
2327        .await?;
2328        assert_eq!(summary.hard_links_created, 1); // 3.txt
2329        assert_eq!(summary.copy_summary.files_copied, 2); // 0.txt, 1.txt
2330        assert_eq!(summary.copy_summary.symlinks_created, 2); // 2.txt, 5.txt
2331        assert_eq!(summary.copy_summary.directories_created, 1);
2332        // compare subset of src and dst
2333        testutils::check_dirs_identical(
2334            &tmp_dir.join("foo").join("baz"),
2335            &tmp_dir.join("bar").join("baz"),
2336            testutils::FileEqualityCheck::HardLink,
2337        )
2338        .await?;
2339        // compare update and dst
2340        testutils::check_dirs_identical(
2341            &tmp_dir.join("update"),
2342            &tmp_dir.join("bar"),
2343            testutils::FileEqualityCheck::Timestamp,
2344        )
2345        .await?;
2346        Ok(())
2347    }
2348
2349    #[tokio::test]
2350    #[traced_test]
2351    async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
2352        let tmp_dir = setup_test_dir_and_link().await?;
2353        let output_path = &tmp_dir.join("bar");
2354        {
2355            // bar
2356            // |- 0.txt
2357            // |- bar
2358            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
2359            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
2360            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
2361            // |- baz    <-------------------------------------- REPLACE W/ FILE
2362            //    |- ...
2363            let bar_path = output_path.join("bar");
2364            let summary = rm::rm(
2365                &PROGRESS,
2366                &bar_path.join("1.txt"),
2367                &rm::Settings {
2368                    fail_early: false,
2369                    filter: None,
2370                    dry_run: None,
2371                    time_filter: None,
2372                },
2373            )
2374            .await?
2375                + rm::rm(
2376                    &PROGRESS,
2377                    &bar_path.join("2.txt"),
2378                    &rm::Settings {
2379                        fail_early: false,
2380                        filter: None,
2381                        dry_run: None,
2382                        time_filter: None,
2383                    },
2384                )
2385                .await?
2386                + rm::rm(
2387                    &PROGRESS,
2388                    &bar_path.join("3.txt"),
2389                    &rm::Settings {
2390                        fail_early: false,
2391                        filter: None,
2392                        dry_run: None,
2393                        time_filter: None,
2394                    },
2395                )
2396                .await?
2397                + rm::rm(
2398                    &PROGRESS,
2399                    &output_path.join("baz"),
2400                    &rm::Settings {
2401                        fail_early: false,
2402                        filter: None,
2403                        dry_run: None,
2404                        time_filter: None,
2405                    },
2406                )
2407                .await?;
2408            assert_eq!(summary.files_removed, 4);
2409            assert_eq!(summary.symlinks_removed, 2);
2410            assert_eq!(summary.directories_removed, 1);
2411            // REPLACE with a file, a symlink, a directory and a file
2412            tokio::fs::write(bar_path.join("1.txt"), "1-new")
2413                .await
2414                .unwrap();
2415            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
2416                .await
2417                .unwrap();
2418            tokio::fs::create_dir(&bar_path.join("3.txt"))
2419                .await
2420                .unwrap();
2421            tokio::fs::write(&output_path.join("baz"), "baz")
2422                .await
2423                .unwrap();
2424        }
2425        let summary = link(
2426            &PROGRESS,
2427            &tmp_dir,
2428            &tmp_dir.join("foo"),
2429            output_path,
2430            &None,
2431            &common_settings(false, true), // overwrite!
2432            false,
2433        )
2434        .await?;
2435        assert_eq!(summary.hard_links_created, 4);
2436        assert_eq!(summary.copy_summary.files_copied, 0);
2437        assert_eq!(summary.copy_summary.symlinks_created, 2);
2438        assert_eq!(summary.copy_summary.directories_created, 1);
2439        testutils::check_dirs_identical(
2440            &tmp_dir.join("foo"),
2441            &tmp_dir.join("bar"),
2442            testutils::FileEqualityCheck::HardLink,
2443        )
2444        .await?;
2445        Ok(())
2446    }
2447
2448    #[tokio::test]
2449    #[traced_test]
2450    async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
2451        let tmp_dir = setup_test_dir_and_link().await?;
2452        let output_path = &tmp_dir.join("bar");
2453        {
2454            // bar
2455            // |- 0.txt
2456            // |- bar
2457            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
2458            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
2459            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
2460            // |- baz    <-------------------------------------- REPLACE W/ FILE
2461            //    |- ...
2462            let bar_path = output_path.join("bar");
2463            let summary = rm::rm(
2464                &PROGRESS,
2465                &bar_path.join("1.txt"),
2466                &rm::Settings {
2467                    fail_early: false,
2468                    filter: None,
2469                    dry_run: None,
2470                    time_filter: None,
2471                },
2472            )
2473            .await?
2474                + rm::rm(
2475                    &PROGRESS,
2476                    &bar_path.join("2.txt"),
2477                    &rm::Settings {
2478                        fail_early: false,
2479                        filter: None,
2480                        dry_run: None,
2481                        time_filter: None,
2482                    },
2483                )
2484                .await?
2485                + rm::rm(
2486                    &PROGRESS,
2487                    &bar_path.join("3.txt"),
2488                    &rm::Settings {
2489                        fail_early: false,
2490                        filter: None,
2491                        dry_run: None,
2492                        time_filter: None,
2493                    },
2494                )
2495                .await?
2496                + rm::rm(
2497                    &PROGRESS,
2498                    &output_path.join("baz"),
2499                    &rm::Settings {
2500                        fail_early: false,
2501                        filter: None,
2502                        dry_run: None,
2503                        time_filter: None,
2504                    },
2505                )
2506                .await?;
2507            assert_eq!(summary.files_removed, 4);
2508            assert_eq!(summary.symlinks_removed, 2);
2509            assert_eq!(summary.directories_removed, 1);
2510            // REPLACE with a file, a symlink, a directory and a file
2511            tokio::fs::write(bar_path.join("1.txt"), "1-new")
2512                .await
2513                .unwrap();
2514            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
2515                .await
2516                .unwrap();
2517            tokio::fs::create_dir(&bar_path.join("3.txt"))
2518                .await
2519                .unwrap();
2520            tokio::fs::write(&output_path.join("baz"), "baz")
2521                .await
2522                .unwrap();
2523        }
2524        let source_path = &tmp_dir.join("foo");
2525        // unreadable
2526        tokio::fs::set_permissions(
2527            &source_path.join("baz"),
2528            std::fs::Permissions::from_mode(0o000),
2529        )
2530        .await?;
2531        // bar
2532        // |- ...
2533        // |- baz <- NON READABLE
2534        match link(
2535            &PROGRESS,
2536            &tmp_dir,
2537            &tmp_dir.join("foo"),
2538            output_path,
2539            &None,
2540            &common_settings(false, true), // overwrite!
2541            false,
2542        )
2543        .await
2544        {
2545            Ok(_) => panic!("Expected the link to error!"),
2546            Err(error) => {
2547                tracing::info!("{}", &error);
2548                assert_eq!(error.summary.hard_links_created, 3);
2549                assert_eq!(error.summary.copy_summary.files_copied, 0);
2550                assert_eq!(error.summary.copy_summary.symlinks_created, 0);
2551                assert_eq!(error.summary.copy_summary.directories_created, 0);
2552                assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
2553                assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
2554                assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
2555            }
2556        }
2557        Ok(())
2558    }
2559
2560    /// Verify that directory metadata is applied even when child link operations fail.
2561    /// This is a regression test for a bug where directory permissions were not preserved
2562    /// when linking with fail_early=false and some children failed to link.
2563    #[tokio::test]
2564    #[traced_test]
2565    async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
2566        let tmp_dir = testutils::create_temp_dir().await?;
2567        let test_path = tmp_dir.as_path();
2568        // create source directory with specific permissions
2569        let src_dir = test_path.join("src");
2570        tokio::fs::create_dir(&src_dir).await?;
2571        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
2572        // create a readable file (will be linked successfully)
2573        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
2574        // create a subdirectory with a file, then make the subdirectory unreadable
2575        // this will cause the recursive walk to fail when trying to read subdirectory contents
2576        let unreadable_subdir = src_dir.join("unreadable_subdir");
2577        tokio::fs::create_dir(&unreadable_subdir).await?;
2578        tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
2579        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
2580            .await?;
2581        let dst_dir = test_path.join("dst");
2582        // link with fail_early=false
2583        let result = link(
2584            &PROGRESS,
2585            test_path,
2586            &src_dir,
2587            &dst_dir,
2588            &None,
2589            &common_settings(false, false),
2590            false,
2591        )
2592        .await;
2593        // restore permissions so cleanup can succeed
2594        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
2595            .await?;
2596        // verify the operation returned an error (unreadable subdirectory should fail)
2597        assert!(
2598            result.is_err(),
2599            "link should fail due to unreadable subdirectory"
2600        );
2601        let error = result.unwrap_err();
2602        // verify the readable file was linked successfully
2603        assert_eq!(error.summary.hard_links_created, 1);
2604        // verify the destination directory exists and has the correct permissions
2605        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2606        assert!(dst_metadata.is_dir());
2607        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
2608        assert_eq!(
2609            actual_mode, 0o750,
2610            "directory should have preserved source permissions (0o750), got {:o}",
2611            actual_mode
2612        );
2613        Ok(())
2614    }
2615    mod filter_tests {
2616        use super::*;
2617        use crate::filter::FilterSettings;
2618        /// Test that path-based patterns (with /) work correctly with nested paths.
2619        #[tokio::test]
2620        #[traced_test]
2621        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
2622            let tmp_dir = testutils::setup_test_dir().await?;
2623            let test_path = tmp_dir.as_path();
2624            // create filter that should only link files in bar/ directory
2625            let mut filter = FilterSettings::new();
2626            filter.add_include("bar/*.txt").unwrap();
2627            let summary = link(
2628                &PROGRESS,
2629                test_path,
2630                &test_path.join("foo"),
2631                &test_path.join("dst"),
2632                &None,
2633                &Settings {
2634                    copy_settings: CopySettings {
2635                        dereference: false,
2636                        fail_early: false,
2637                        overwrite: false,
2638                        overwrite_compare: Default::default(),
2639                        overwrite_filter: None,
2640                        ignore_existing: false,
2641                        chunk_size: 0,
2642                        skip_specials: false,
2643                        remote_copy_buffer_size: 0,
2644                        filter: None,
2645                        dry_run: None,
2646                        delete: None,
2647                    },
2648                    update_compare: Default::default(),
2649                    update_exclusive: false,
2650                    filter: Some(filter),
2651                    dry_run: None,
2652                    preserve: preserve::preserve_all(),
2653                },
2654                false,
2655            )
2656            .await?;
2657            // should only link files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
2658            assert_eq!(
2659                summary.hard_links_created, 3,
2660                "should link 3 files matching bar/*.txt"
2661            );
2662            // verify the right files were linked
2663            assert!(
2664                test_path.join("dst/bar/1.txt").exists(),
2665                "bar/1.txt should be linked"
2666            );
2667            assert!(
2668                test_path.join("dst/bar/2.txt").exists(),
2669                "bar/2.txt should be linked"
2670            );
2671            assert!(
2672                test_path.join("dst/bar/3.txt").exists(),
2673                "bar/3.txt should be linked"
2674            );
2675            // verify files outside the pattern don't exist
2676            assert!(
2677                !test_path.join("dst/0.txt").exists(),
2678                "0.txt should not be linked"
2679            );
2680            Ok(())
2681        }
2682        /// Regression: with a filter active and `fail_early = false`, a directory whose only
2683        /// traversed child FAILS becomes "empty" and is pruned by the empty-dir cleanup — the child
2684        /// failure must still surface, not be masked as success. copy.rs's `finalize_dir` guards
2685        /// this in its DryRunSkip/Remove arms; `link_dir_contents` must do the same.
2686        #[tokio::test]
2687        #[traced_test]
2688        async fn test_filter_pruned_empty_dir_surfaces_child_error() -> Result<(), anyhow::Error> {
2689            let tmp_dir = testutils::create_temp_dir().await?;
2690            let test_path = tmp_dir.as_path();
2691            // src/ is the root; src/sub/ is traversal-only under the filter (it does not directly
2692            // match), and its sole child `unreadable/` (mode 0o000) fails to open during the walk.
2693            // nothing links into sub/, so the empty-dir cleanup prunes it.
2694            let src_dir = test_path.join("src");
2695            let unreadable = src_dir.join("sub").join("unreadable");
2696            tokio::fs::create_dir_all(&unreadable).await?;
2697            tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
2698            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2699            // an include pattern that matches nothing present forces traversal of sub/ and
2700            // unreadable/ without directly matching sub/, so sub/ is "traversal-only".
2701            let mut filter = FilterSettings::new();
2702            filter.add_include("*.match").unwrap();
2703            let mut settings = common_settings(false, false);
2704            settings.filter = Some(filter);
2705            let result = link(
2706                &PROGRESS,
2707                test_path,
2708                &src_dir,
2709                &test_path.join("dst"),
2710                &None,
2711                &settings,
2712                false,
2713            )
2714            .await;
2715            // restore perms so the temp dir can be cleaned up
2716            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
2717            assert!(
2718                result.is_err(),
2719                "a child link failure inside a filter-pruned empty directory must surface as an \
2720                 error, not be masked as success"
2721            );
2722            Ok(())
2723        }
2724        /// As above but in dry-run mode, which hits the `DryRunSkip` arm instead of `Remove`: a
2725        /// collected child error must still surface rather than being reported as a clean dry run.
2726        #[tokio::test]
2727        #[traced_test]
2728        async fn test_filter_pruned_empty_dir_surfaces_child_error_dry_run()
2729        -> Result<(), anyhow::Error> {
2730            let tmp_dir = testutils::create_temp_dir().await?;
2731            let test_path = tmp_dir.as_path();
2732            let src_dir = test_path.join("src");
2733            let unreadable = src_dir.join("sub").join("unreadable");
2734            tokio::fs::create_dir_all(&unreadable).await?;
2735            tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
2736            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2737            let mut filter = FilterSettings::new();
2738            filter.add_include("*.match").unwrap();
2739            let mut settings = common_settings(false, false);
2740            settings.filter = Some(filter);
2741            settings.dry_run = Some(crate::config::DryRunMode::Brief);
2742            settings.copy_settings.dry_run = Some(crate::config::DryRunMode::Brief);
2743            let result = link(
2744                &PROGRESS,
2745                test_path,
2746                &src_dir,
2747                &test_path.join("dst"),
2748                &None,
2749                &settings,
2750                false,
2751            )
2752            .await;
2753            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
2754            assert!(
2755                result.is_err(),
2756                "dry-run must also surface the child error, not report a clean run"
2757            );
2758            Ok(())
2759        }
2760        /// Test that filters are applied to top-level file arguments.
2761        #[tokio::test]
2762        #[traced_test]
2763        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
2764            let tmp_dir = testutils::setup_test_dir().await?;
2765            let test_path = tmp_dir.as_path();
2766            // create filter that excludes .txt files
2767            let mut filter = FilterSettings::new();
2768            filter.add_exclude("*.txt").unwrap();
2769            let summary = link(
2770                &PROGRESS,
2771                test_path,
2772                &test_path.join("foo/0.txt"), // single file source
2773                &test_path.join("dst/0.txt"),
2774                &None,
2775                &Settings {
2776                    copy_settings: CopySettings {
2777                        dereference: false,
2778                        fail_early: false,
2779                        overwrite: false,
2780                        overwrite_compare: Default::default(),
2781                        overwrite_filter: None,
2782                        ignore_existing: false,
2783                        chunk_size: 0,
2784                        skip_specials: false,
2785                        remote_copy_buffer_size: 0,
2786                        filter: None,
2787                        dry_run: None,
2788                        delete: None,
2789                    },
2790                    update_compare: Default::default(),
2791                    update_exclusive: false,
2792                    filter: Some(filter),
2793                    dry_run: None,
2794                    preserve: preserve::preserve_all(),
2795                },
2796                false,
2797            )
2798            .await?;
2799            // the file should NOT be linked because it matches the exclude pattern
2800            assert_eq!(
2801                summary.hard_links_created, 0,
2802                "file matching exclude pattern should not be linked"
2803            );
2804            assert!(
2805                !test_path.join("dst/0.txt").exists(),
2806                "excluded file should not exist at destination"
2807            );
2808            Ok(())
2809        }
2810        /// Test that filters apply to root directories with simple exclude patterns.
2811        #[tokio::test]
2812        #[traced_test]
2813        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
2814            let test_path = testutils::create_temp_dir().await?;
2815            // create a directory that should be excluded
2816            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
2817            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
2818            // create filter that excludes *_dir/ directories
2819            let mut filter = FilterSettings::new();
2820            filter.add_exclude("*_dir/").unwrap();
2821            let result = link(
2822                &PROGRESS,
2823                &test_path,
2824                &test_path.join("excluded_dir"),
2825                &test_path.join("dst"),
2826                &None,
2827                &Settings {
2828                    copy_settings: CopySettings {
2829                        dereference: false,
2830                        fail_early: false,
2831                        overwrite: false,
2832                        overwrite_compare: Default::default(),
2833                        overwrite_filter: None,
2834                        ignore_existing: false,
2835                        chunk_size: 0,
2836                        skip_specials: false,
2837                        remote_copy_buffer_size: 0,
2838                        filter: None,
2839                        dry_run: None,
2840                        delete: None,
2841                    },
2842                    update_compare: Default::default(),
2843                    update_exclusive: false,
2844                    filter: Some(filter),
2845                    dry_run: None,
2846                    preserve: preserve::preserve_all(),
2847                },
2848                false,
2849            )
2850            .await?;
2851            // directory should NOT be linked because it matches exclude pattern
2852            assert_eq!(
2853                result.copy_summary.directories_created, 0,
2854                "root directory matching exclude should not be created"
2855            );
2856            assert!(
2857                !test_path.join("dst").exists(),
2858                "excluded root directory should not exist at destination"
2859            );
2860            Ok(())
2861        }
2862        /// Test that filters apply to root symlinks with simple exclude patterns.
2863        #[tokio::test]
2864        #[traced_test]
2865        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
2866            let test_path = testutils::create_temp_dir().await?;
2867            // create a target file and a symlink to it
2868            tokio::fs::write(test_path.join("target.txt"), "content").await?;
2869            tokio::fs::symlink(
2870                test_path.join("target.txt"),
2871                test_path.join("excluded_link"),
2872            )
2873            .await?;
2874            // create filter that excludes *_link
2875            let mut filter = FilterSettings::new();
2876            filter.add_exclude("*_link").unwrap();
2877            let result = link(
2878                &PROGRESS,
2879                &test_path,
2880                &test_path.join("excluded_link"),
2881                &test_path.join("dst"),
2882                &None,
2883                &Settings {
2884                    copy_settings: CopySettings {
2885                        dereference: false,
2886                        fail_early: false,
2887                        overwrite: false,
2888                        overwrite_compare: Default::default(),
2889                        overwrite_filter: None,
2890                        ignore_existing: false,
2891                        chunk_size: 0,
2892                        skip_specials: false,
2893                        remote_copy_buffer_size: 0,
2894                        filter: None,
2895                        dry_run: None,
2896                        delete: None,
2897                    },
2898                    update_compare: Default::default(),
2899                    update_exclusive: false,
2900                    filter: Some(filter),
2901                    dry_run: None,
2902                    preserve: preserve::preserve_all(),
2903                },
2904                false,
2905            )
2906            .await?;
2907            // symlink should NOT be copied because it matches exclude pattern
2908            assert_eq!(
2909                result.copy_summary.symlinks_created, 0,
2910                "root symlink matching exclude should not be created"
2911            );
2912            assert!(
2913                !test_path.join("dst").exists(),
2914                "excluded root symlink should not exist at destination"
2915            );
2916            Ok(())
2917        }
2918        /// Test combined include and exclude patterns (exclude takes precedence).
2919        #[tokio::test]
2920        #[traced_test]
2921        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
2922            let tmp_dir = testutils::setup_test_dir().await?;
2923            let test_path = tmp_dir.as_path();
2924            // test structure from setup_test_dir:
2925            // foo/
2926            //   0.txt
2927            //   bar/ (1.txt, 2.txt, 3.txt)
2928            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
2929            // include all .txt files in bar/, but exclude 2.txt specifically
2930            let mut filter = FilterSettings::new();
2931            filter.add_include("bar/*.txt").unwrap();
2932            filter.add_exclude("bar/2.txt").unwrap();
2933            let summary = link(
2934                &PROGRESS,
2935                test_path,
2936                &test_path.join("foo"),
2937                &test_path.join("dst"),
2938                &None,
2939                &Settings {
2940                    copy_settings: CopySettings {
2941                        dereference: false,
2942                        fail_early: false,
2943                        overwrite: false,
2944                        overwrite_compare: Default::default(),
2945                        overwrite_filter: None,
2946                        ignore_existing: false,
2947                        chunk_size: 0,
2948                        skip_specials: false,
2949                        remote_copy_buffer_size: 0,
2950                        filter: None,
2951                        dry_run: None,
2952                        delete: None,
2953                    },
2954                    update_compare: Default::default(),
2955                    update_exclusive: false,
2956                    filter: Some(filter),
2957                    dry_run: None,
2958                    preserve: preserve::preserve_all(),
2959                },
2960                false,
2961            )
2962            .await?;
2963            // should link: bar/1.txt, bar/3.txt = 2 hard links
2964            // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
2965            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
2966            assert_eq!(
2967                summary.copy_summary.files_skipped, 2,
2968                "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
2969            );
2970            // verify
2971            assert!(
2972                test_path.join("dst/bar/1.txt").exists(),
2973                "bar/1.txt should be linked"
2974            );
2975            assert!(
2976                !test_path.join("dst/bar/2.txt").exists(),
2977                "bar/2.txt should be excluded"
2978            );
2979            assert!(
2980                test_path.join("dst/bar/3.txt").exists(),
2981                "bar/3.txt should be linked"
2982            );
2983            Ok(())
2984        }
2985        /// Test that skipped counts accurately reflect what was filtered.
2986        #[tokio::test]
2987        #[traced_test]
2988        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
2989            let tmp_dir = testutils::setup_test_dir().await?;
2990            let test_path = tmp_dir.as_path();
2991            // test structure from setup_test_dir:
2992            // foo/
2993            //   0.txt
2994            //   bar/ (1.txt, 2.txt, 3.txt)
2995            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
2996            // exclude bar/ directory entirely
2997            let mut filter = FilterSettings::new();
2998            filter.add_exclude("bar/").unwrap();
2999            let summary = link(
3000                &PROGRESS,
3001                test_path,
3002                &test_path.join("foo"),
3003                &test_path.join("dst"),
3004                &None,
3005                &Settings {
3006                    copy_settings: CopySettings {
3007                        dereference: false,
3008                        fail_early: false,
3009                        overwrite: false,
3010                        overwrite_compare: Default::default(),
3011                        overwrite_filter: None,
3012                        ignore_existing: false,
3013                        chunk_size: 0,
3014                        skip_specials: false,
3015                        remote_copy_buffer_size: 0,
3016                        filter: None,
3017                        dry_run: None,
3018                        delete: None,
3019                    },
3020                    update_compare: Default::default(),
3021                    update_exclusive: false,
3022                    filter: Some(filter),
3023                    dry_run: None,
3024                    preserve: preserve::preserve_all(),
3025                },
3026                false,
3027            )
3028            .await?;
3029            // linked: 0.txt (1 hard link), baz/4.txt (1 hard link)
3030            // symlinks copied: 5.txt, 6.txt
3031            // skipped: bar directory (1 dir)
3032            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
3033            assert_eq!(
3034                summary.copy_summary.symlinks_created, 2,
3035                "should copy 2 symlinks"
3036            );
3037            assert_eq!(
3038                summary.copy_summary.directories_skipped, 1,
3039                "should skip 1 directory (bar)"
3040            );
3041            // bar should not exist in dst
3042            assert!(
3043                !test_path.join("dst/bar").exists(),
3044                "bar directory should not be linked"
3045            );
3046            Ok(())
3047        }
3048        /// Test that empty directories are not created when they were only traversed to look
3049        /// for matches (regression test for bug where --include='foo' would create empty dir baz).
3050        #[tokio::test]
3051        #[traced_test]
3052        async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
3053            let test_path = testutils::create_temp_dir().await?;
3054            // create structure:
3055            // src/
3056            //   foo (file)
3057            //   bar (file)
3058            //   baz/ (empty directory)
3059            let src_path = test_path.join("src");
3060            tokio::fs::create_dir(&src_path).await?;
3061            tokio::fs::write(src_path.join("foo"), "content").await?;
3062            tokio::fs::write(src_path.join("bar"), "content").await?;
3063            tokio::fs::create_dir(src_path.join("baz")).await?;
3064            // include only 'foo' file
3065            let mut filter = FilterSettings::new();
3066            filter.add_include("foo").unwrap();
3067            let summary = link(
3068                &PROGRESS,
3069                &test_path,
3070                &src_path,
3071                &test_path.join("dst"),
3072                &None,
3073                &Settings {
3074                    copy_settings: copy::Settings {
3075                        dereference: false,
3076                        fail_early: false,
3077                        overwrite: false,
3078                        overwrite_compare: Default::default(),
3079                        overwrite_filter: None,
3080                        ignore_existing: false,
3081                        chunk_size: 0,
3082                        skip_specials: false,
3083                        remote_copy_buffer_size: 0,
3084                        filter: None,
3085                        dry_run: None,
3086                        delete: None,
3087                    },
3088                    update_compare: Default::default(),
3089                    update_exclusive: false,
3090                    filter: Some(filter),
3091                    dry_run: None,
3092                    preserve: preserve::preserve_all(),
3093                },
3094                false,
3095            )
3096            .await?;
3097            // only 'foo' should be linked
3098            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3099            assert_eq!(
3100                summary.copy_summary.directories_created, 1,
3101                "should create only root directory (not empty 'baz')"
3102            );
3103            // verify foo was linked
3104            assert!(
3105                test_path.join("dst").join("foo").exists(),
3106                "foo should be linked"
3107            );
3108            // verify bar was not linked (not matching include pattern)
3109            assert!(
3110                !test_path.join("dst").join("bar").exists(),
3111                "bar should not be linked"
3112            );
3113            // verify empty baz directory was NOT created
3114            assert!(
3115                !test_path.join("dst").join("baz").exists(),
3116                "empty baz directory should NOT be created"
3117            );
3118            Ok(())
3119        }
3120        /// Test that directories with only non-matching content are not created at destination.
3121        /// This is different from empty directories - the source dir has content but none matches.
3122        #[tokio::test]
3123        #[traced_test]
3124        async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
3125            let test_path = testutils::create_temp_dir().await?;
3126            // create structure:
3127            // src/
3128            //   foo (file)
3129            //   baz/
3130            //     qux (file - doesn't match 'foo')
3131            //     quux (file - doesn't match 'foo')
3132            let src_path = test_path.join("src");
3133            tokio::fs::create_dir(&src_path).await?;
3134            tokio::fs::write(src_path.join("foo"), "content").await?;
3135            tokio::fs::create_dir(src_path.join("baz")).await?;
3136            tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
3137            tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
3138            // include only 'foo' file
3139            let mut filter = FilterSettings::new();
3140            filter.add_include("foo").unwrap();
3141            let summary = link(
3142                &PROGRESS,
3143                &test_path,
3144                &src_path,
3145                &test_path.join("dst"),
3146                &None,
3147                &Settings {
3148                    copy_settings: copy::Settings {
3149                        dereference: false,
3150                        fail_early: false,
3151                        overwrite: false,
3152                        overwrite_compare: Default::default(),
3153                        overwrite_filter: None,
3154                        ignore_existing: false,
3155                        chunk_size: 0,
3156                        skip_specials: false,
3157                        remote_copy_buffer_size: 0,
3158                        filter: None,
3159                        dry_run: None,
3160                        delete: None,
3161                    },
3162                    update_compare: Default::default(),
3163                    update_exclusive: false,
3164                    filter: Some(filter),
3165                    dry_run: None,
3166                    preserve: preserve::preserve_all(),
3167                },
3168                false,
3169            )
3170            .await?;
3171            // only 'foo' should be linked
3172            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3173            assert_eq!(
3174                summary.copy_summary.files_skipped, 2,
3175                "should skip 2 files (qux and quux)"
3176            );
3177            assert_eq!(
3178                summary.copy_summary.directories_created, 1,
3179                "should create only root directory (not 'baz' with non-matching content)"
3180            );
3181            // verify foo was linked
3182            assert!(
3183                test_path.join("dst").join("foo").exists(),
3184                "foo should be linked"
3185            );
3186            // verify baz directory was NOT created (even though source baz has content)
3187            assert!(
3188                !test_path.join("dst").join("baz").exists(),
3189                "baz directory should NOT be created (no matching content inside)"
3190            );
3191            Ok(())
3192        }
3193        /// Test that empty directories are not reported as created in dry-run mode
3194        /// when they were only traversed.
3195        #[tokio::test]
3196        #[traced_test]
3197        async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
3198            let test_path = testutils::create_temp_dir().await?;
3199            // create structure:
3200            // src/
3201            //   foo (file)
3202            //   bar (file)
3203            //   baz/ (empty directory)
3204            let src_path = test_path.join("src");
3205            tokio::fs::create_dir(&src_path).await?;
3206            tokio::fs::write(src_path.join("foo"), "content").await?;
3207            tokio::fs::write(src_path.join("bar"), "content").await?;
3208            tokio::fs::create_dir(src_path.join("baz")).await?;
3209            // include only 'foo' file
3210            let mut filter = FilterSettings::new();
3211            filter.add_include("foo").unwrap();
3212            let summary = link(
3213                &PROGRESS,
3214                &test_path,
3215                &src_path,
3216                &test_path.join("dst"),
3217                &None,
3218                &Settings {
3219                    copy_settings: copy::Settings {
3220                        dereference: false,
3221                        fail_early: false,
3222                        overwrite: false,
3223                        overwrite_compare: Default::default(),
3224                        overwrite_filter: None,
3225                        ignore_existing: false,
3226                        chunk_size: 0,
3227                        skip_specials: false,
3228                        remote_copy_buffer_size: 0,
3229                        filter: None,
3230                        dry_run: None,
3231                        delete: None,
3232                    },
3233                    update_compare: Default::default(),
3234                    update_exclusive: false,
3235                    filter: Some(filter),
3236                    dry_run: Some(crate::config::DryRunMode::Explain),
3237                    preserve: preserve::preserve_all(),
3238                },
3239                false,
3240            )
3241            .await?;
3242            // only 'foo' should be reported as would-be-linked
3243            assert_eq!(
3244                summary.hard_links_created, 1,
3245                "should report only 'foo' would be linked"
3246            );
3247            assert_eq!(
3248                summary.copy_summary.directories_created, 1,
3249                "should report only root directory would be created (not empty 'baz')"
3250            );
3251            // verify nothing was actually created (dry-run mode)
3252            assert!(
3253                !test_path.join("dst").exists(),
3254                "dst should not exist in dry-run"
3255            );
3256            Ok(())
3257        }
3258        /// Test that existing directories are NOT removed when using --overwrite,
3259        /// even if nothing is linked into them due to filters.
3260        #[tokio::test]
3261        #[traced_test]
3262        async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
3263            let test_path = testutils::create_temp_dir().await?;
3264            // create source structure:
3265            // src/
3266            //   foo (file)
3267            //   bar (file)
3268            //   baz/ (empty directory)
3269            let src_path = test_path.join("src");
3270            tokio::fs::create_dir(&src_path).await?;
3271            tokio::fs::write(src_path.join("foo"), "content").await?;
3272            tokio::fs::write(src_path.join("bar"), "content").await?;
3273            tokio::fs::create_dir(src_path.join("baz")).await?;
3274            // create destination with baz directory already existing
3275            let dst_path = test_path.join("dst");
3276            tokio::fs::create_dir(&dst_path).await?;
3277            tokio::fs::create_dir(dst_path.join("baz")).await?;
3278            // add a marker file inside dst/baz to verify we don't touch it
3279            tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
3280            // include only 'foo' file - baz should not match
3281            let mut filter = FilterSettings::new();
3282            filter.add_include("foo").unwrap();
3283            let summary = link(
3284                &PROGRESS,
3285                &test_path,
3286                &src_path,
3287                &dst_path,
3288                &None,
3289                &Settings {
3290                    copy_settings: copy::Settings {
3291                        dereference: false,
3292                        fail_early: false,
3293                        overwrite: true, // enable overwrite mode
3294                        overwrite_compare: Default::default(),
3295                        overwrite_filter: None,
3296                        ignore_existing: false,
3297                        chunk_size: 0,
3298                        skip_specials: false,
3299                        remote_copy_buffer_size: 0,
3300                        filter: None,
3301                        dry_run: None,
3302                        delete: None,
3303                    },
3304                    update_compare: Default::default(),
3305                    update_exclusive: false,
3306                    filter: Some(filter),
3307                    dry_run: None,
3308                    preserve: preserve::preserve_all(),
3309                },
3310                false,
3311            )
3312            .await?;
3313            // foo should be linked
3314            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3315            // dst and baz should be unchanged (both already existed)
3316            assert_eq!(
3317                summary.copy_summary.directories_unchanged, 2,
3318                "root dst and baz directories should be unchanged"
3319            );
3320            assert_eq!(
3321                summary.copy_summary.directories_created, 0,
3322                "should not create any directories"
3323            );
3324            // verify foo was linked
3325            assert!(dst_path.join("foo").exists(), "foo should be linked");
3326            // verify bar was NOT linked
3327            assert!(!dst_path.join("bar").exists(), "bar should not be linked");
3328            // verify existing baz directory still exists with its content
3329            assert!(
3330                dst_path.join("baz").exists(),
3331                "existing baz directory should still exist"
3332            );
3333            assert!(
3334                dst_path.join("baz").join("marker.txt").exists(),
3335                "existing content in baz should still exist"
3336            );
3337            Ok(())
3338        }
3339
3340        /// Regression: an update-only entry matching an `--exclude` pattern must NOT be copied to
3341        /// the destination when `--delete` is OFF. The fd-based link delegates update-only entries
3342        /// to `copy::copy_child` (which wraps `copy_internal` and does not re-apply a top-level
3343        /// filter), so the update loop must evaluate the filter itself — independently of `--delete`
3344        /// — and skip the delegation, matching the old path-based `copy_with_filter_base`.
3345        #[tokio::test]
3346        #[traced_test]
3347        async fn update_only_excluded_entry_not_copied_without_delete() -> Result<(), anyhow::Error>
3348        {
3349            let test_path = testutils::create_temp_dir().await?;
3350            // src has `keep.txt`; update has `keep.txt` (also in src) plus update-only `extra.txt`
3351            // and `wanted.txt`. With `--exclude extra.txt` and NO `--delete`, `extra.txt` must be
3352            // skipped while `wanted.txt` is copied.
3353            let src = test_path.join("src");
3354            let update = test_path.join("update");
3355            let dst = test_path.join("dst");
3356            tokio::fs::create_dir(&src).await?;
3357            tokio::fs::create_dir(&update).await?;
3358            tokio::fs::write(src.join("keep.txt"), "keep").await?;
3359            tokio::fs::write(update.join("keep.txt"), "keep").await?;
3360            tokio::fs::write(update.join("extra.txt"), "EXCLUDED").await?;
3361            tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
3362
3363            let mut filter = FilterSettings::new();
3364            filter.add_exclude("extra.txt").unwrap();
3365            let mut settings = common_settings(false, false);
3366            settings.filter = Some(filter);
3367            // --delete is OFF (the bug only manifests with delete off).
3368            assert!(settings.copy_settings.delete.is_none());
3369
3370            let summary = link(
3371                &PROGRESS,
3372                &test_path,
3373                &src,
3374                &dst,
3375                &Some(update.clone()),
3376                &settings,
3377                false,
3378            )
3379            .await?;
3380
3381            assert!(
3382                !dst.join("extra.txt").exists(),
3383                "update-only entry matching --exclude must NOT be copied when --delete is off"
3384            );
3385            assert!(
3386                dst.join("wanted.txt").exists(),
3387                "non-excluded update-only entry should be copied"
3388            );
3389            assert!(dst.join("keep.txt").exists(), "shared entry should exist");
3390            assert_eq!(
3391                summary.copy_summary.files_skipped, 1,
3392                "the excluded update-only file should be counted skipped"
3393            );
3394            Ok(())
3395        }
3396
3397        /// Verify a hard-link relationship between two paths by inode + device identity.
3398        fn are_hardlinked(a: &std::path::Path, b: &std::path::Path) -> bool {
3399            use std::os::unix::fs::MetadataExt;
3400            match (std::fs::symlink_metadata(a), std::fs::symlink_metadata(b)) {
3401                (Ok(ma), Ok(mb)) => ma.ino() == mb.ino() && ma.dev() == mb.dev(),
3402                _ => false,
3403            }
3404        }
3405
3406        /// The chatgpt-codex re-review scenario (PR #247): in rlink's dual-tree walk the source
3407        /// loop evaluates the filter against the SOURCE entry's type. When `src/cache` is a FILE
3408        /// and `update/cache` is a DIRECTORY, a dir-only exclude `cache/` passes the src file (a
3409        /// dir-only pattern doesn't match a file), so `link_internal` runs and hits its
3410        /// type-mismatch branch. Before the fix that branch unconditionally delegated a copy of the
3411        /// UPDATE entry — and `copy_child` does not re-apply the top-level filter to the delegated
3412        /// root — so the excluded `cache/` directory was copied. The fix re-checks the filter using
3413        /// the UPDATE entry's type; the excluded update is dropped and, under union (`--update`)
3414        /// semantics, the src `cache` FILE is materialized instead.
3415        ///
3416        /// This test FAILS without the fix: `dst/cache` is created as the excluded update directory
3417        /// (and the src file is not materialized).
3418        #[tokio::test]
3419        #[traced_test]
3420        async fn type_mismatch_excluded_update_dir_not_copied_src_file_kept()
3421        -> Result<(), anyhow::Error> {
3422            let test_path = testutils::create_temp_dir().await?;
3423            let src = test_path.join("src");
3424            let update = test_path.join("update");
3425            let dst = test_path.join("dst");
3426            tokio::fs::create_dir(&src).await?;
3427            tokio::fs::create_dir(&update).await?;
3428            // src `cache` is a FILE; update `cache` is a DIRECTORY (the type mismatch).
3429            tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3430            tokio::fs::create_dir(update.join("cache")).await?;
3431            tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3432            // a non-conflicting shared file to confirm normal linking still happens.
3433            tokio::fs::write(src.join("keep.txt"), "keep").await?;
3434            tokio::fs::write(update.join("keep.txt"), "keep").await?;
3435            // pin an identical mtime (incl. nsec) on both `keep.txt` copies so the
3436            // size+mtime `update_compare` deterministically treats them as unchanged and
3437            // hard-links from src. Two separate writes can otherwise land on different
3438            // nanoseconds, flakily comparing as changed and copying instead (the bytes are
3439            // identical either way) — see PR #247 CI flake on test-musl-debug.
3440            let keep_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
3441            filetime::set_file_mtime(src.join("keep.txt"), keep_mtime)?;
3442            filetime::set_file_mtime(update.join("keep.txt"), keep_mtime)?;
3443
3444            let mut filter = FilterSettings::new();
3445            filter.add_exclude("cache/").unwrap(); // dir-only: matches a dir `cache`, not a file
3446            let mut settings = common_settings(false, false);
3447            settings.filter = Some(filter);
3448            assert!(settings.copy_settings.delete.is_none());
3449
3450            let summary = link(
3451                &PROGRESS,
3452                &test_path,
3453                &src,
3454                &dst,
3455                &Some(update.clone()),
3456                &settings,
3457                false,
3458            )
3459            .await?;
3460
3461            // the excluded update directory must NOT be copied.
3462            assert!(
3463                !dst.join("cache").join("inner.dat").exists(),
3464                "excluded update directory `cache/` must not be copied"
3465            );
3466            assert!(
3467                !dst.join("cache").is_dir(),
3468                "dst/cache must not be the excluded update directory"
3469            );
3470            // the src `cache` FILE stands (union semantics) and is hard-linked from src.
3471            assert!(
3472                dst.join("cache").is_file(),
3473                "src `cache` file must be materialized when the update dir is excluded"
3474            );
3475            assert_eq!(
3476                tokio::fs::read_to_string(dst.join("cache")).await?,
3477                "SRC-FILE"
3478            );
3479            assert!(
3480                are_hardlinked(&src.join("cache"), &dst.join("cache")),
3481                "the src `cache` file must be hard-linked into the destination"
3482            );
3483            assert!(
3484                dst.join("keep.txt").exists(),
3485                "shared entry should still link"
3486            );
3487            // `cache` (src file, union) and `keep.txt` (unchanged) are both hard-linked from src;
3488            // nothing is copied. Exactly one directory is created — the `dst` root — proving the
3489            // excluded `cache/` subtree added no directory.
3490            assert_eq!(summary.hard_links_created, 2);
3491            assert_eq!(summary.copy_summary.files_copied, 0);
3492            assert_eq!(
3493                summary.copy_summary.directories_created, 1,
3494                "only the dst root is created; the excluded `cache/` dir must not be"
3495            );
3496            Ok(())
3497        }
3498
3499        /// The REVERSE type mismatch (the symmetric code path): `src/data` is a DIRECTORY and
3500        /// `update/data` is a FILE. The dir-only include `data/` matches the directory form of the
3501        /// name but not the file form, and `data/**` includes the directory's contents. So the src
3502        /// `data` directory (and its `inner.txt`) passes the filter and the src loop spawns the
3503        /// worker, while the update `data` FILE is `ExcludedByDefault` (no include matches a file
3504        /// named `data`). The type-mismatch branch re-checks the filter using the update FILE's
3505        /// type, finds it excluded, and (union semantics) materializes the src DIRECTORY instead of
3506        /// copying the excluded update file. Without the fix the excluded update file would replace
3507        /// the src directory at the destination.
3508        #[tokio::test]
3509        #[traced_test]
3510        async fn reverse_type_mismatch_excluded_update_file_not_copied_src_dir_kept()
3511        -> Result<(), anyhow::Error> {
3512            let test_path = testutils::create_temp_dir().await?;
3513            let src = test_path.join("src");
3514            let update = test_path.join("update");
3515            let dst = test_path.join("dst");
3516            tokio::fs::create_dir(&src).await?;
3517            tokio::fs::create_dir(&update).await?;
3518            // src `data` is a DIRECTORY (with a file inside); update `data` is a FILE.
3519            tokio::fs::create_dir(src.join("data")).await?;
3520            tokio::fs::write(src.join("data").join("inner.txt"), "SRC-DIR-CONTENT").await?;
3521            tokio::fs::write(update.join("data"), "UPDATE-FILE-EXCLUDED").await?;
3522
3523            // `data/` (dir-only) includes the directory form of the name; `data/**` includes its
3524            // contents. The update FILE `data` matches neither and is excluded by type — the
3525            // symmetric form of the bot's scenario.
3526            let mut filter = FilterSettings::new();
3527            filter.add_include("data/").unwrap();
3528            filter.add_include("data/**").unwrap();
3529            let mut settings = common_settings(false, false);
3530            settings.filter = Some(filter);
3531            assert!(settings.copy_settings.delete.is_none());
3532
3533            link(
3534                &PROGRESS,
3535                &test_path,
3536                &src,
3537                &dst,
3538                &Some(update.clone()),
3539                &settings,
3540                false,
3541            )
3542            .await?;
3543
3544            // the excluded update FILE must NOT overwrite/replace the src directory.
3545            assert!(
3546                dst.join("data").is_dir(),
3547                "src `data` directory must be materialized when the update file is excluded"
3548            );
3549            assert!(
3550                dst.join("data").join("inner.txt").exists(),
3551                "src directory contents must be linked through"
3552            );
3553            assert!(
3554                are_hardlinked(
3555                    &src.join("data").join("inner.txt"),
3556                    &dst.join("data").join("inner.txt")
3557                ),
3558                "src directory's file must be hard-linked into the destination"
3559            );
3560            Ok(())
3561        }
3562
3563        /// `--update-exclusive` + the type-mismatch scenario: src `cache` is a FILE, update `cache`
3564        /// is a DIRECTORY excluded by `cache/`. Under exclusive mode only the (filter-passing)
3565        /// update set materializes, so an EXCLUDED update entry materializes NOTHING — the src is
3566        /// not materialized (it is not a fallback under exclusivity), and no stale src copy is left.
3567        /// This mirrors the NotFound-under-exclusive case (`return Ok(Default::default())`).
3568        #[tokio::test]
3569        #[traced_test]
3570        async fn type_mismatch_excluded_update_dir_update_exclusive_materializes_nothing()
3571        -> Result<(), anyhow::Error> {
3572            let test_path = testutils::create_temp_dir().await?;
3573            let src = test_path.join("src");
3574            let update = test_path.join("update");
3575            let dst = test_path.join("dst");
3576            tokio::fs::create_dir(&src).await?;
3577            tokio::fs::create_dir(&update).await?;
3578            tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3579            tokio::fs::create_dir(update.join("cache")).await?;
3580            tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3581            // a filter-passing update-only file proves the rest of the exclusive copy still works.
3582            tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
3583
3584            let mut filter = FilterSettings::new();
3585            filter.add_exclude("cache/").unwrap();
3586            let mut settings = common_settings(false, false);
3587            settings.update_exclusive = true;
3588            settings.filter = Some(filter);
3589
3590            link(
3591                &PROGRESS,
3592                &test_path,
3593                &src,
3594                &dst,
3595                &Some(update.clone()),
3596                &settings,
3597                false,
3598            )
3599            .await?;
3600
3601            assert!(
3602                !dst.join("cache").exists(),
3603                "under --update-exclusive an excluded-update type-mismatch must materialize nothing \
3604                 (no excluded dir, no stale src file)"
3605            );
3606            assert!(
3607                dst.join("wanted.txt").exists(),
3608                "filter-passing update-only entries are still copied under --update-exclusive"
3609            );
3610            Ok(())
3611        }
3612
3613        /// `--delete` + the type-mismatch scenario under normal `--update`: the src `cache` FILE is
3614        /// materialized (union) and MUST be retained by the keep-set — never materialized-then-pruned
3615        /// — while a pre-existing extraneous dst entry is removed. Also confirms the excluded update
3616        /// directory leaves no leftover. `prune_extraneous` would otherwise prune the dst `cache`
3617        /// file (a dir-only `cache/` exclude does not protect a file), so correctness depends on
3618        /// `cache` staying in the keep-set.
3619        #[tokio::test]
3620        #[traced_test]
3621        async fn type_mismatch_excluded_update_dir_delete_keeps_src_file()
3622        -> Result<(), anyhow::Error> {
3623            let test_path = testutils::create_temp_dir().await?;
3624            let src = test_path.join("src");
3625            let update = test_path.join("update");
3626            let dst = test_path.join("dst");
3627            tokio::fs::create_dir(&src).await?;
3628            tokio::fs::create_dir(&update).await?;
3629            tokio::fs::create_dir(&dst).await?;
3630            tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3631            tokio::fs::create_dir(update.join("cache")).await?;
3632            tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3633            // pre-existing extraneous dst entry that --delete should prune.
3634            tokio::fs::write(dst.join("stale.txt"), "stale").await?;
3635
3636            let mut filter = FilterSettings::new();
3637            filter.add_exclude("cache/").unwrap();
3638            let mut settings = common_settings(false, true); // --delete implies --overwrite
3639            settings.filter = Some(filter);
3640            settings.copy_settings.delete = Some(copy::DeleteSettings {
3641                delete_excluded: false,
3642            });
3643
3644            link(
3645                &PROGRESS,
3646                &test_path,
3647                &src,
3648                &dst,
3649                &Some(update.clone()),
3650                &settings,
3651                false,
3652            )
3653            .await?;
3654
3655            assert!(
3656                dst.join("cache").is_file(),
3657                "src `cache` file must survive --delete (kept in the keep-set, not pruned)"
3658            );
3659            assert_eq!(
3660                tokio::fs::read_to_string(dst.join("cache")).await?,
3661                "SRC-FILE"
3662            );
3663            assert!(
3664                !dst.join("cache").is_dir(),
3665                "the excluded update directory must leave no leftover"
3666            );
3667            assert!(
3668                !dst.join("stale.txt").exists(),
3669                "extraneous dst entry must be pruned by --delete"
3670            );
3671            Ok(())
3672        }
3673    }
3674    mod dry_run_tests {
3675        use super::*;
3676        /// Test that dry-run mode for files doesn't create hard links.
3677        #[tokio::test]
3678        #[traced_test]
3679        async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
3680            let tmp_dir = testutils::setup_test_dir().await?;
3681            let test_path = tmp_dir.as_path();
3682            let src_file = test_path.join("foo/0.txt");
3683            let dst_file = test_path.join("dst_link.txt");
3684            // verify destination doesn't exist
3685            assert!(
3686                !dst_file.exists(),
3687                "destination should not exist before dry-run"
3688            );
3689            let summary = link(
3690                &PROGRESS,
3691                test_path,
3692                &src_file,
3693                &dst_file,
3694                &None,
3695                &Settings {
3696                    copy_settings: CopySettings {
3697                        dereference: false,
3698                        fail_early: false,
3699                        overwrite: false,
3700                        overwrite_compare: Default::default(),
3701                        overwrite_filter: None,
3702                        ignore_existing: false,
3703                        chunk_size: 0,
3704                        skip_specials: false,
3705                        remote_copy_buffer_size: 0,
3706                        filter: None,
3707                        dry_run: None,
3708                        delete: None,
3709                    },
3710                    update_compare: Default::default(),
3711                    update_exclusive: false,
3712                    filter: None,
3713                    dry_run: Some(crate::config::DryRunMode::Brief),
3714                    preserve: preserve::preserve_all(),
3715                },
3716                false,
3717            )
3718            .await?;
3719            // verify destination still doesn't exist
3720            assert!(!dst_file.exists(), "dry-run should not create hard link");
3721            // verify summary reports what would be created
3722            assert_eq!(
3723                summary.hard_links_created, 1,
3724                "dry-run should report 1 hard link that would be created"
3725            );
3726            Ok(())
3727        }
3728        /// Test that dry-run mode for directories doesn't create the destination directory.
3729        #[tokio::test]
3730        #[traced_test]
3731        async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
3732            let tmp_dir = testutils::setup_test_dir().await?;
3733            let test_path = tmp_dir.as_path();
3734            let dst_path = test_path.join("nonexistent_dst");
3735            // verify destination doesn't exist
3736            assert!(
3737                !dst_path.exists(),
3738                "destination should not exist before dry-run"
3739            );
3740            let summary = link(
3741                &PROGRESS,
3742                test_path,
3743                &test_path.join("foo"),
3744                &dst_path,
3745                &None,
3746                &Settings {
3747                    copy_settings: CopySettings {
3748                        dereference: false,
3749                        fail_early: false,
3750                        overwrite: false,
3751                        overwrite_compare: Default::default(),
3752                        overwrite_filter: None,
3753                        ignore_existing: false,
3754                        chunk_size: 0,
3755                        skip_specials: false,
3756                        remote_copy_buffer_size: 0,
3757                        filter: None,
3758                        dry_run: None,
3759                        delete: None,
3760                    },
3761                    update_compare: Default::default(),
3762                    update_exclusive: false,
3763                    filter: None,
3764                    dry_run: Some(crate::config::DryRunMode::Brief),
3765                    preserve: preserve::preserve_all(),
3766                },
3767                false,
3768            )
3769            .await?;
3770            // verify destination still doesn't exist
3771            assert!(
3772                !dst_path.exists(),
3773                "dry-run should not create destination directory"
3774            );
3775            // verify summary reports what would be created
3776            assert!(
3777                summary.hard_links_created > 0,
3778                "dry-run should report hard links that would be created"
3779            );
3780            Ok(())
3781        }
3782        /// Test that dry-run mode correctly reports symlinks (not as hard links).
3783        #[tokio::test]
3784        #[traced_test]
3785        async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
3786            let tmp_dir = testutils::setup_test_dir().await?;
3787            let test_path = tmp_dir.as_path();
3788            // baz contains: 4.txt (file), 5.txt (symlink), 6.txt (symlink)
3789            let src_path = test_path.join("foo/baz");
3790            let dst_path = test_path.join("dst_baz");
3791            // verify destination doesn't exist
3792            assert!(
3793                !dst_path.exists(),
3794                "destination should not exist before dry-run"
3795            );
3796            let summary = link(
3797                &PROGRESS,
3798                test_path,
3799                &src_path,
3800                &dst_path,
3801                &None,
3802                &Settings {
3803                    copy_settings: CopySettings {
3804                        dereference: false,
3805                        fail_early: false,
3806                        overwrite: false,
3807                        overwrite_compare: Default::default(),
3808                        overwrite_filter: None,
3809                        ignore_existing: false,
3810                        chunk_size: 0,
3811                        skip_specials: false,
3812                        remote_copy_buffer_size: 0,
3813                        filter: None,
3814                        dry_run: None,
3815                        delete: None,
3816                    },
3817                    update_compare: Default::default(),
3818                    update_exclusive: false,
3819                    filter: None,
3820                    dry_run: Some(crate::config::DryRunMode::Brief),
3821                    preserve: preserve::preserve_all(),
3822                },
3823                false,
3824            )
3825            .await?;
3826            // verify destination still doesn't exist
3827            assert!(!dst_path.exists(), "dry-run should not create destination");
3828            // baz contains 1 regular file (4.txt) and 2 symlinks (5.txt, 6.txt)
3829            assert_eq!(
3830                summary.hard_links_created, 1,
3831                "dry-run should report 1 hard link (for 4.txt)"
3832            );
3833            assert_eq!(
3834                summary.copy_summary.symlinks_created, 2,
3835                "dry-run should report 2 symlinks (5.txt and 6.txt)"
3836            );
3837            Ok(())
3838        }
3839    }
3840
3841    /// Verify that fail-early preserves the summary from the failing subtree.
3842    ///
3843    /// Regression test: the fail-early return path in the join loop must
3844    /// accumulate error.summary from the failing child into the parent's
3845    /// link_summary. Without this, directories_created from the child subtree
3846    /// would be lost.
3847    #[tokio::test]
3848    #[traced_test]
3849    async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
3850        let tmp_dir = testutils::create_temp_dir().await?;
3851        let test_path = tmp_dir.as_path();
3852        // src/sub/  has a file and an unreadable subdirectory:
3853        //   src/sub/good.txt            <-- links successfully
3854        //   src/sub/unreadable_dir/     <-- mode 000, can't be traversed
3855        //     src/sub/unreadable_dir/f.txt
3856        let src_dir = test_path.join("src");
3857        let sub_dir = src_dir.join("sub");
3858        let bad_dir = sub_dir.join("unreadable_dir");
3859        tokio::fs::create_dir_all(&bad_dir).await?;
3860        tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
3861        tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
3862        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
3863        let dst_dir = test_path.join("dst");
3864        let result = link(
3865            &PROGRESS,
3866            test_path,
3867            &src_dir,
3868            &dst_dir,
3869            &None,
3870            &Settings {
3871                copy_settings: CopySettings {
3872                    fail_early: true,
3873                    ..common_settings(false, false).copy_settings
3874                },
3875                ..common_settings(false, false)
3876            },
3877            false,
3878        )
3879        .await;
3880        // restore permissions for cleanup
3881        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
3882        let error = result.expect_err("link should fail due to unreadable directory");
3883        // sub/'s link_internal created dst/sub/ (directories_created=1) before
3884        // its join loop encountered the unreadable_dir error. that directory
3885        // creation must be reflected in the error summary propagated up to the
3886        // top-level caller.
3887        assert!(
3888            error.summary.copy_summary.directories_created >= 2,
3889            "fail-early summary should include directories from the failing subtree, \
3890             got directories_created={} (expected >= 2: dst/ and dst/sub/)",
3891            error.summary.copy_summary.directories_created
3892        );
3893        Ok(())
3894    }
3895
3896    #[tokio::test]
3897    #[traced_test]
3898    async fn skip_specials_skips_socket_in_link() -> Result<(), anyhow::Error> {
3899        let tmp_dir = testutils::setup_test_dir().await?;
3900        let test_path = tmp_dir.as_path();
3901        let src = test_path.join("src_dir");
3902        let dst = test_path.join("dst_dir");
3903        tokio::fs::create_dir(&src).await?;
3904        tokio::fs::write(src.join("file.txt"), "hello").await?;
3905        let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
3906        let mut settings = common_settings(false, false);
3907        settings.copy_settings.skip_specials = true;
3908        let summary = link(&PROGRESS, test_path, &src, &dst, &None, &settings, false).await?;
3909        assert_eq!(summary.hard_links_created, 1);
3910        assert_eq!(summary.copy_summary.specials_skipped, 1);
3911        assert!(dst.join("file.txt").exists());
3912        assert!(!dst.join("test.sock").exists());
3913        Ok(())
3914    }
3915
3916    #[tokio::test]
3917    #[traced_test]
3918    async fn delete_skips_pruning_when_link_has_errors() -> Result<(), anyhow::Error> {
3919        let tmp_dir = testutils::setup_test_dir().await?;
3920        let test_path = tmp_dir.as_path();
3921        let src = test_path.join("foo");
3922        let dst = test_path.join("bar");
3923        // baseline link establishes the destination (no delete)
3924        link(
3925            &PROGRESS,
3926            test_path,
3927            &src,
3928            &dst,
3929            &None,
3930            &common_settings(false, false),
3931            false,
3932        )
3933        .await?;
3934        // an extraneous file that --delete would normally prune
3935        tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
3936        // make a source sub-directory unreadable so traversal fails (fail_early is false).
3937        // a directory is used because --overwrite with mtime-equal files skips copying
3938        // identical files; a directory's read_dir fails unconditionally when mode is 0o000.
3939        let unreadable = src.join("baz");
3940        let original = tokio::fs::metadata(&unreadable).await?.permissions();
3941        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3942
3943        let delete_settings = Settings {
3944            copy_settings: CopySettings {
3945                overwrite: true,
3946                fail_early: false,
3947                delete: Some(copy::DeleteSettings {
3948                    delete_excluded: false,
3949                }),
3950                ..common_settings(false, true).copy_settings
3951            },
3952            ..common_settings(false, true)
3953        };
3954        let result = link(
3955            &PROGRESS,
3956            test_path,
3957            &src,
3958            &dst,
3959            &None,
3960            &delete_settings,
3961            false,
3962        )
3963        .await;
3964
3965        tokio::fs::set_permissions(&unreadable, original).await?;
3966
3967        assert!(
3968            result.is_err(),
3969            "link of the unreadable directory should fail"
3970        );
3971        assert!(
3972            dst.join("extraneous.txt").exists(),
3973            "pruning must be skipped when the link/update pass reported errors"
3974        );
3975        Ok(())
3976    }
3977
3978    #[tokio::test]
3979    #[traced_test]
3980    async fn skip_specials_top_level_socket_in_link() -> Result<(), anyhow::Error> {
3981        let tmp_dir = testutils::setup_test_dir().await?;
3982        let test_path = tmp_dir.as_path();
3983        let src_socket = test_path.join("test.sock");
3984        let dst = test_path.join("dst.sock");
3985        let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
3986        let mut settings = common_settings(false, false);
3987        settings.copy_settings.skip_specials = true;
3988        let summary = link(
3989            &PROGRESS,
3990            test_path,
3991            &src_socket,
3992            &dst,
3993            &None,
3994            &settings,
3995            false,
3996        )
3997        .await?;
3998        assert_eq!(summary.copy_summary.specials_skipped, 1);
3999        assert_eq!(summary.hard_links_created, 0);
4000        assert!(!dst.exists());
4001        Ok(())
4002    }
4003
4004    /// Stress tests exercising max-open-files saturation during link.
4005    mod max_open_files_tests {
4006        use super::*;
4007
4008        /// deep + wide link: directory tree deeper than the open-files limit, with files
4009        /// at every level. verifies no deadlock occurs (directories don't consume permits).
4010        #[tokio::test]
4011        #[traced_test]
4012        async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
4013            let tmp_dir = testutils::create_temp_dir().await?;
4014            let src = tmp_dir.join("src");
4015            let dst = tmp_dir.join("dst");
4016            let depth = 20;
4017            let files_per_level = 5;
4018            let limit = 4;
4019            // create a directory chain deeper than the permit limit, with files at each level
4020            let mut dir = src.clone();
4021            for level in 0..depth {
4022                tokio::fs::create_dir_all(&dir).await?;
4023                for f in 0..files_per_level {
4024                    tokio::fs::write(
4025                        dir.join(format!("f{}_{}.txt", level, f)),
4026                        format!("L{}F{}", level, f),
4027                    )
4028                    .await?;
4029                }
4030                dir = dir.join(format!("d{}", level));
4031            }
4032            throttle::set_max_open_files(limit);
4033            let summary = tokio::time::timeout(
4034                std::time::Duration::from_secs(30),
4035                link(
4036                    &PROGRESS,
4037                    tmp_dir.as_path(),
4038                    &src,
4039                    &dst,
4040                    &None,
4041                    &common_settings(false, false),
4042                    false,
4043                ),
4044            )
4045            .await
4046            .context("link timed out — possible deadlock")?
4047            .context("link failed")?;
4048            assert_eq!(summary.hard_links_created, depth * files_per_level);
4049            assert_eq!(summary.copy_summary.directories_created, depth);
4050            // spot-check that hard links work by reading content at a few levels
4051            let mut check_dir = dst.clone();
4052            for level in 0..depth {
4053                let content =
4054                    tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
4055                assert_eq!(content, format!("L{}F0", level));
4056                check_dir = check_dir.join(format!("d{}", level));
4057            }
4058            Ok(())
4059        }
4060
4061        /// Regression: link_internal's spawn-time guard must be released before
4062        /// delegating to copy::copy on the file-type-changed path.
4063        ///
4064        /// Scenario: many src entries are regular files (so the spawn loop
4065        /// pre-acquires open-files permits for them), but the corresponding
4066        /// `update` entries are directories (file types differ). link_internal
4067        /// then calls copy::copy on the update directory, which enters
4068        /// copy_internal. If the spawn-time permit were still held while
4069        /// copy::copy ran, copy_internal's own open-files acquire for any
4070        /// inner file would deadlock against a saturated pool.
4071        #[tokio::test]
4072        #[traced_test]
4073        async fn parallel_update_filetype_change_no_deadlock() -> Result<(), anyhow::Error> {
4074            let tmp_dir = testutils::create_temp_dir().await?;
4075            let src = tmp_dir.join("src");
4076            let update = tmp_dir.join("update");
4077            let dst = tmp_dir.join("dst");
4078            tokio::fs::create_dir(&src).await?;
4079            tokio::fs::create_dir(&update).await?;
4080            let n = 8;
4081            // src/eN: regular files. update/eN: directories with inner files.
4082            // file types differ -> link takes the !is_file_type_same branch
4083            // -> calls copy::copy(update/eN, dst/eN).
4084            for i in 0..n {
4085                tokio::fs::write(src.join(format!("e{}", i)), format!("src-{}", i)).await?;
4086                let upd_subdir = update.join(format!("e{}", i));
4087                tokio::fs::create_dir(&upd_subdir).await?;
4088                for j in 0..3 {
4089                    tokio::fs::write(
4090                        upd_subdir.join(format!("inner_{}.txt", j)),
4091                        format!("upd-{}-{}", i, j),
4092                    )
4093                    .await?;
4094                }
4095            }
4096            // saturate the open-files pool: spawn-time permits held by every
4097            // outer link task would block copy::copy's inner permit acquires.
4098            throttle::set_max_open_files(2);
4099            let summary = tokio::time::timeout(
4100                std::time::Duration::from_secs(30),
4101                link(
4102                    &PROGRESS,
4103                    tmp_dir.as_path(),
4104                    &src,
4105                    &dst,
4106                    &Some(update.clone()),
4107                    &common_settings(false, false),
4108                    false,
4109                ),
4110            )
4111            .await
4112            .context(
4113                "link timed out — caller-supplied open-files guard not released before copy::copy",
4114            )?
4115            .context("link failed")?;
4116            // every entry was a type-mismatch -> copied from update.
4117            // copy::copy on a directory creates the dir and copies inner files.
4118            assert_eq!(summary.copy_summary.directories_created, n + 1); // +1 for dst itself
4119            assert_eq!(summary.copy_summary.files_copied, n * 3);
4120            // verify content came from update, not src
4121            for i in 0..n {
4122                for j in 0..3 {
4123                    let content =
4124                        tokio::fs::read_to_string(dst.join(format!("e{}/inner_{}.txt", i, j)))
4125                            .await?;
4126                    assert_eq!(content, format!("upd-{}-{}", i, j));
4127                }
4128            }
4129            Ok(())
4130        }
4131
4132        /// Regression: the "update-only entries" spawn loop must not deadlock
4133        /// against copy::copy's open-files OR against rm::rm's pending-meta.
4134        ///
4135        /// Scenario: update has many regular files that don't exist in src.
4136        /// The loop at site 3 spawns a copy::copy task per entry under a
4137        /// saturated open-files pool. copy::copy's internal acquires must
4138        /// proceed normally — site 3 must not be holding open-files.
4139        #[tokio::test]
4140        #[traced_test]
4141        async fn update_only_entries_bounded_no_deadlock() -> Result<(), anyhow::Error> {
4142            let tmp_dir = testutils::create_temp_dir().await?;
4143            let src = tmp_dir.join("src");
4144            let update = tmp_dir.join("update");
4145            let dst = tmp_dir.join("dst");
4146            tokio::fs::create_dir(&src).await?;
4147            tokio::fs::create_dir(&update).await?;
4148            // src is empty; update has many regular files. Every update entry
4149            // is "missing in src" -> hits the site-3 spawn loop.
4150            let n = 50;
4151            for i in 0..n {
4152                tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
4153            }
4154            throttle::set_max_open_files(2);
4155            let summary = tokio::time::timeout(
4156                std::time::Duration::from_secs(30),
4157                link(
4158                    &PROGRESS,
4159                    tmp_dir.as_path(),
4160                    &src,
4161                    &dst,
4162                    &Some(update.clone()),
4163                    &common_settings(false, false),
4164                    false,
4165                ),
4166            )
4167            .await
4168            .context("link timed out — site-3 spawn loop deadlock")?
4169            .context("link failed")?;
4170            // dst gets the src directory plus a copy of every update file
4171            assert_eq!(summary.copy_summary.directories_created, 1);
4172            assert_eq!(summary.copy_summary.files_copied, n);
4173            for i in 0..n {
4174                let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
4175                assert_eq!(content, format!("upd-{}", i));
4176            }
4177            Ok(())
4178        }
4179
4180        /// Regression for the link site-3 ↔ rm pending-meta self-deadlock.
4181        ///
4182        /// Scenario: update has many entries not in src; dst already has
4183        /// directories at those same names; the user passes --overwrite. Each
4184        /// site-3 task runs copy::copy → copy_file → rm::rm to remove the
4185        /// preexisting dst directory before placing the regular-file copy.
4186        /// rm::rm draws from the pending-meta pool. If site 3 also held
4187        /// pending-meta across copy::copy, every running task would hold a
4188        /// permit while waiting on inner rm to acquire one — classic
4189        /// self-deadlock once the pool is saturated.
4190        #[tokio::test]
4191        #[traced_test]
4192        async fn update_only_overwrite_preexisting_dirs_no_deadlock() -> Result<(), anyhow::Error> {
4193            let tmp_dir = testutils::create_temp_dir().await?;
4194            let src = tmp_dir.join("src");
4195            let update = tmp_dir.join("update");
4196            let dst = tmp_dir.join("dst");
4197            tokio::fs::create_dir(&src).await?;
4198            tokio::fs::create_dir(&update).await?;
4199            tokio::fs::create_dir(&dst).await?;
4200            let n = 12;
4201            for i in 0..n {
4202                // update/uN is a regular file (site 3 will copy it).
4203                tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
4204                // dst/uN is a preexisting directory with inner files. With
4205                // --overwrite, copy_file calls rm::rm to wipe it, which
4206                // recurses into pending-meta.
4207                let dst_subdir = dst.join(format!("u{}", i));
4208                tokio::fs::create_dir(&dst_subdir).await?;
4209                for j in 0..3 {
4210                    tokio::fs::write(
4211                        dst_subdir.join(format!("inner_{}.txt", j)),
4212                        format!("old-{}-{}", i, j),
4213                    )
4214                    .await?;
4215                }
4216            }
4217            // saturate both pools to force the deadlock if the cycle existed.
4218            throttle::set_max_open_files(2);
4219            let summary = tokio::time::timeout(
4220                std::time::Duration::from_secs(30),
4221                link(
4222                    &PROGRESS,
4223                    tmp_dir.as_path(),
4224                    &src,
4225                    &dst,
4226                    &Some(update.clone()),
4227                    &common_settings(false, true), // overwrite=true
4228                    false,
4229                ),
4230            )
4231            .await
4232            .context("link timed out — pending-meta self-deadlock between site 3 and inner rm")?
4233            .context("link failed")?;
4234            // each preexisting dst/uN directory gets removed and replaced
4235            // with a regular-file copy from update/uN.
4236            assert_eq!(summary.copy_summary.files_copied, n);
4237            assert_eq!(summary.copy_summary.rm_summary.files_removed, n * 3);
4238            assert_eq!(summary.copy_summary.rm_summary.directories_removed, n);
4239            // verify content came from update
4240            for i in 0..n {
4241                let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
4242                assert_eq!(content, format!("upd-{}", i));
4243            }
4244            Ok(())
4245        }
4246    }
4247
4248    /// TOCTOU hardening: a source entry being hard-linked is concurrently swapped between a real
4249    /// regular file and a symlink to a sentinel OUTSIDE the source tree. rlink classifies the entry
4250    /// via `child` (fstat) before acting and links the pinned inode inode-exactly
4251    /// (`hard_link_handle_at`), so a swap is either caught (the entry is linked/copied as a symlink,
4252    /// or the op fails closed) or the real file is hard-linked. The sentinel's secret content must
4253    /// NEVER appear at the destination as a regular file, and the sentinel inode must never gain a
4254    /// new hard link.
4255    mod race_tests {
4256        use super::*;
4257
4258        // Repeatedly swap `dir/entry_name` between a real regular file (content `REAL_CONTENT`) and
4259        // a symlink pointing at `sentinel`, using rename so each individual state is atomic. Runs on
4260        // a dedicated OS thread so it makes progress regardless of the tokio runtime's scheduling.
4261        fn spawn_file_symlink_swapper(
4262            dir: std::path::PathBuf,
4263            entry_name: &'static str,
4264            sentinel: std::path::PathBuf,
4265            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
4266        ) -> std::thread::JoinHandle<()> {
4267            std::thread::spawn(move || {
4268                let entry = dir.join(entry_name);
4269                let staged_real = dir.join("__staged_real");
4270                let staged_link = dir.join("__staged_link");
4271                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
4272                    let _ = std::fs::remove_file(&staged_real);
4273                    if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
4274                        continue;
4275                    }
4276                    let _ = std::fs::rename(&staged_real, &entry);
4277                    let _ = std::fs::remove_file(&staged_link);
4278                    let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
4279                    let _ = std::fs::rename(&staged_link, &entry);
4280                }
4281            })
4282        }
4283
4284        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4285        #[traced_test]
4286        async fn hard_link_entry_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
4287            let tmp_dir = testutils::create_temp_dir().await?;
4288            let test_path = tmp_dir.as_path();
4289            // sentinel lives OUTSIDE the source tree with distinctive content; we also track its
4290            // hard-link count to prove `linkat(flags=0)` never gives it a new hard link.
4291            let sentinel = test_path.join("sentinel_secret");
4292            tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
4293            let sentinel_links_before = {
4294                use std::os::unix::fs::MetadataExt;
4295                tokio::fs::symlink_metadata(&sentinel).await?.nlink()
4296            };
4297            let src = test_path.join("src");
4298            let sub = src.join("sub");
4299            tokio::fs::create_dir(&src).await?;
4300            tokio::fs::create_dir(&sub).await?;
4301            tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
4302
4303            let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4304            let swapper =
4305                spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
4306
4307            // overwrite=true so each iteration's destination need not be empty; no update tree, so
4308            // `src/sub/entry` takes the hard-link path (or copy-as-symlink when caught mid-swap).
4309            let settings = common_settings(false, true);
4310            let mut caught_swaps = 0usize;
4311            let mut linked_real = 0usize;
4312            for i in 0..200 {
4313                let dst = test_path.join(format!("dst_{i}"));
4314                let result = tokio::time::timeout(
4315                    std::time::Duration::from_secs(30),
4316                    link(&PROGRESS, test_path, &src, &dst, &None, &settings, false),
4317                )
4318                .await
4319                .expect("link must not hang under concurrent swapping");
4320                match result {
4321                    Ok(_) => {}
4322                    Err(_) => caught_swaps += 1, // a swap was caught mid-link (failed closed)
4323                }
4324                // CORE ASSERTION: if a regular file landed at the destination it holds the REAL
4325                // content — never the sentinel's secret. The entry may instead be a symlink
4326                // (linkat made a hard link to the symlink inode, or copy reproduced the symlink) or
4327                // be absent. A symlink that resolves to the sentinel is fine: it is a link, not a
4328                // copy of the secret bytes, and it did not give the sentinel a new hard link.
4329                let entry_dst = dst.join("sub").join("entry");
4330                if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
4331                    && md.file_type().is_file()
4332                {
4333                    let content = tokio::fs::read_to_string(&entry_dst).await?;
4334                    assert_ne!(
4335                        content, "SENTINEL_SECRET_CONTENT",
4336                        "iteration {i}: sentinel content leaked into the destination as a regular file"
4337                    );
4338                    assert_eq!(
4339                        content, "REAL_CONTENT",
4340                        "iteration {i}: a regular destination file must hold the real content"
4341                    );
4342                    linked_real += 1;
4343                }
4344                let _ = tokio::fs::remove_dir_all(&dst).await;
4345            }
4346
4347            stop.store(true, std::sync::atomic::Ordering::Relaxed);
4348            swapper.join().expect("swapper thread panicked");
4349
4350            // the sentinel must never have gained a hard link from a `linkat` that followed the
4351            // swapped-in symlink (flags=0 links the symlink inode itself, not its target).
4352            let sentinel_links_after = {
4353                use std::os::unix::fs::MetadataExt;
4354                tokio::fs::symlink_metadata(&sentinel).await?.nlink()
4355            };
4356            assert_eq!(
4357                sentinel_links_after, sentinel_links_before,
4358                "the sentinel file must never gain a hard link (linkat must not follow the symlink)"
4359            );
4360            // sanity: the run did observable work (this is not the safety assertion — the safety
4361            // assertions above hold on every iteration regardless of timing).
4362            tracing::info!(
4363                "link file/symlink swap: caught_swaps={caught_swaps}, linked_real={linked_real}"
4364            );
4365            assert!(
4366                caught_swaps + linked_real > 0,
4367                "expected at least one observable outcome across 200 iterations"
4368            );
4369            Ok(())
4370        }
4371    }
4372}