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