Skip to main content

common/
link.rs

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