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::{self, Dir, Handle};
14use crate::walk::{
15    self, AdmittedEntry, AdmittedLeaf, EntryAdmission, EntryKind, LeafPermit, PermitKind,
16};
17
18/// Error type for link operations. See [`crate::error::OperationError`] for
19/// logging conventions and rationale.
20pub type Error = crate::error::OperationError<Summary>;
21
22#[derive(Debug, Clone)]
23pub struct Settings {
24    pub copy_settings: CopySettings,
25    pub update_compare: filecmp::MetadataCmpSettings,
26    pub update_exclusive: bool,
27    /// filter settings for include/exclude patterns
28    pub filter: Option<crate::filter::FilterSettings>,
29    /// dry-run mode for previewing operations
30    pub dry_run: Option<crate::config::DryRunMode>,
31    /// metadata preservation settings
32    pub preserve: preserve::Settings,
33}
34
35#[derive(Copy, Clone, Debug, Eq, PartialEq)]
36enum UpdateRootRequirement {
37    Optional,
38    RequiredForDestructiveOperation,
39}
40
41impl UpdateRootRequirement {
42    fn from_settings(settings: &Settings) -> Self {
43        if settings.update_exclusive || settings.copy_settings.delete.is_some() {
44            Self::RequiredForDestructiveOperation
45        } else {
46            Self::Optional
47        }
48    }
49
50    fn is_required(self) -> bool {
51        self == Self::RequiredForDestructiveOperation
52    }
53
54    fn for_entry(self, rel_path: &std::path::Path) -> Self {
55        if rel_path.as_os_str().is_empty() {
56            self
57        } else {
58            Self::Optional
59        }
60    }
61
62    #[allow(clippy::result_large_err)]
63    fn classify<T>(
64        self,
65        result: std::io::Result<T>,
66        update_path: &std::path::Path,
67    ) -> Result<Option<T>, Error> {
68        match result {
69            Ok(value) => Ok(Some(value)),
70            Err(error) if error.kind() == std::io::ErrorKind::NotFound && !self.is_required() => {
71                Ok(None)
72            }
73            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
74                Err(missing_destructive_update_error(update_path))
75            }
76            Err(error) => Err(Error::new(
77                anyhow::Error::new(error)
78                    .context(format!("failed reading metadata from {update_path:?}")),
79                Default::default(),
80            )),
81        }
82    }
83}
84
85fn missing_destructive_update_error(update_path: &std::path::Path) -> Error {
86    Error::new(
87        anyhow!(
88            "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
89            update_path
90        ),
91        Default::default(),
92    )
93}
94
95/// Summary with the appropriate `*_skipped` counter set to 1 for the given entry kind.
96/// Special files count as `files_skipped` to match the historical mapping used
97/// when filters skip an entry (`specials_skipped` is reserved for `--skip-specials`).
98fn skipped_summary_for(kind: EntryKind) -> Summary {
99    let copy_summary = match kind {
100        EntryKind::Dir => CopySummary {
101            directories_skipped: 1,
102            ..Default::default()
103        },
104        EntryKind::Symlink => CopySummary {
105            symlinks_skipped: 1,
106            ..Default::default()
107        },
108        EntryKind::File | EntryKind::Special => CopySummary {
109            files_skipped: 1,
110            ..Default::default()
111        },
112    };
113    Summary {
114        copy_summary,
115        ..Default::default()
116    }
117}
118
119#[derive(Copy, Clone, Debug, Default)]
120pub struct Summary {
121    pub hard_links_created: usize,
122    pub hard_links_unchanged: usize,
123    pub copy_summary: CopySummary,
124}
125
126impl std::ops::Add for Summary {
127    type Output = Self;
128    fn add(self, other: Self) -> Self {
129        Self {
130            hard_links_created: self.hard_links_created + other.hard_links_created,
131            hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
132            copy_summary: self.copy_summary + other.copy_summary,
133        }
134    }
135}
136
137impl std::fmt::Display for Summary {
138    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
139        write!(
140            f,
141            "{}\n\
142            link:\n\
143            -----\n\
144            hard-links created: {}\n\
145            hard links unchanged: {}\n",
146            &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
147        )
148    }
149}
150
151/// Hard-link the already-classified source entry (pinned by `src_handle`) to `dst_name` within
152/// `dst_dir`, fd-relative and inode-exact.
153///
154/// `dst_dir.hard_link_handle_at` links the EXACT inode `src_handle` pins (via its `O_PATH` fd,
155/// using `linkat(.., "/proc/self/fd/N", .., AT_SYMLINK_FOLLOW)`) rather than re-resolving the
156/// source by name. On an actor-writable source, `name` can be swapped to a different inode
157/// (symlink, FIFO, another file) after classification; linking the pinned inode ensures rlink either
158/// hard-links the exact classified regular file or fails closed (`ENOENT` when its last link was
159/// removed), never the replacement. `linkat` still refuses to hard-link a directory (`EPERM`).
160///
161/// On `EEXIST` under `--overwrite`, the existing destination is re-classified through `dst_dir`'s
162/// fd and, if it is an identical hard link (same dev+ino), left as is; otherwise it is removed via
163/// fd-relative [`copy::remove_existing`] and the link is retried — mirroring copy's overwrite
164/// branches.
165///
166/// No metadata is applied here, and `f:acl` in particular must never reach this path: a hard-linked
167/// destination SHARES the source's inode, so writing an ACL "to the destination" would rewrite the
168/// SOURCE's permissions. There is nothing to apply anyway — the shared inode already carries the
169/// source's metadata verbatim, which is the whole point of a hard link. `f:acl` therefore applies
170/// only on rlink's real copy path ([`copy::copy_child`], for changed files under `--update`).
171#[instrument(skip(prog_track, settings))]
172#[allow(clippy::too_many_arguments)]
173async fn hard_link_entry_fd(
174    prog_track: &'static progress::Progress,
175    src_handle: &crate::safedir::Handle,
176    dst_dir: &Arc<Dir>,
177    dst_name: &std::ffi::OsStr,
178    dst_path: &std::path::Path,
179    settings: &Settings,
180) -> Result<Summary, Error> {
181    let mut link_summary = Summary::default();
182    match dst_dir.hard_link_handle_at(src_handle, dst_name).await {
183        Ok(()) => {}
184        Err(error)
185            if settings.copy_settings.overwrite
186                && error.kind() == std::io::ErrorKind::AlreadyExists =>
187        {
188            tracing::debug!("'dst' already exists, check if we need to update");
189            let dst_handle = dst_dir
190                .child(dst_name)
191                .await
192                .with_context(|| format!("cannot read {dst_path:?} metadata"))
193                .map_err(|err| Error::new(err, Default::default()))?;
194            // identical hard link: same file type and same (dev, ino) as the source entry. Both
195            // handles pin their inodes (O_PATH), so a matching (dev, ino) genuinely proves the two
196            // names already resolve to the same inode — no change needed.
197            if dst_handle.kind() == src_handle.kind()
198                && dst_handle.dev() == src_handle.dev()
199                && dst_handle.ino() == src_handle.ino()
200            {
201                tracing::debug!("no change, leaving file as is");
202                prog_track.hard_links_unchanged.inc();
203                return Ok(Summary {
204                    hard_links_unchanged: 1,
205                    ..Default::default()
206                });
207            }
208            tracing::info!("'dst' file type changed, removing and hard-linking");
209            // fd-relative removal is contained to dst_dir (mirrors copy.rs).
210            let rm_summary = copy::remove_existing(
211                prog_track,
212                dst_dir,
213                dst_name,
214                dst_path,
215                dst_handle.into_removal_snapshot(),
216                &settings.copy_settings,
217            )
218            .await
219            .map_err(|err| {
220                link_summary.copy_summary.rm_summary = err.summary.rm_summary;
221                Error::new(err.source, link_summary)
222            })?;
223            link_summary.copy_summary.rm_summary = rm_summary;
224            dst_dir
225                .hard_link_handle_at(src_handle, dst_name)
226                .await
227                .with_context(|| format!("failed to hard link to {dst_path:?}"))
228                .map_err(|err| Error::new(err, link_summary))?;
229        }
230        Err(error) => {
231            return Err(Error::new(
232                anyhow::Error::from(error).context(format!("failed to hard link to {dst_path:?}")),
233                link_summary,
234            ));
235        }
236    }
237    prog_track.hard_links_created.inc();
238    link_summary.hard_links_created = 1;
239    Ok(link_summary)
240}
241
242/// Public entry point for link operations.
243///
244/// The dual-tree link walk is fd-based: the source, optional `update`, and destination roots are
245/// opened relative to their parent directories and every per-entry operation is performed through
246/// file-descriptor-relative syscalls (see [`crate::safedir`]). Hard links are made inode-exact
247/// through the already-classified source `Handle` (`linkat` via `/proc/self/fd/N` with
248/// `AT_SYMLINK_FOLLOW`), so the link targets the exact regular file that was classified, even if
249/// its directory entry is concurrently swapped — never a re-resolved name; entries that must be
250/// copied instead of hard-linked are delegated to `copy::copy_child` with the held parent `Dir`s —
251/// no path is re-resolved from a root. The held parents prevent full-path redirection and contain
252/// each by-name operation, while the pinned source handle makes hard-link creation inode-exact.
253/// `--dereference` is the one exception — copy still resolves symlinks by path (`canonicalize`) and
254/// is not hardened.
255#[instrument(skip(prog_track, settings))]
256pub async fn link(
257    prog_track: &'static progress::Progress,
258    cwd: &std::path::Path,
259    src: &std::path::Path,
260    dst: &std::path::Path,
261    update: &Option<std::path::PathBuf>,
262    settings: &Settings,
263    is_fresh: bool,
264) -> Result<Summary, Error> {
265    crate::walk_driver::scope_tasks(link_inner(
266        prog_track, cwd, src, dst, update, settings, is_fresh,
267    ))
268    .await
269}
270
271#[cfg(test)]
272type AfterUpdatePrecheckHook = Box<dyn FnOnce() + Send>;
273
274#[cfg(test)]
275static AFTER_UPDATE_PRECHECK_HOOKS: std::sync::LazyLock<
276    std::sync::Mutex<std::collections::HashMap<std::path::PathBuf, AfterUpdatePrecheckHook>>,
277> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
278
279#[cfg(test)]
280struct AfterUpdatePrecheckHookRegistration {
281    update_path: std::path::PathBuf,
282}
283
284#[cfg(test)]
285impl Drop for AfterUpdatePrecheckHookRegistration {
286    fn drop(&mut self) {
287        AFTER_UPDATE_PRECHECK_HOOKS
288            .lock()
289            .unwrap_or_else(std::sync::PoisonError::into_inner)
290            .remove(&self.update_path);
291    }
292}
293
294#[cfg(test)]
295fn install_after_update_precheck_hook(
296    update_path: std::path::PathBuf,
297    hook: AfterUpdatePrecheckHook,
298) -> AfterUpdatePrecheckHookRegistration {
299    let replaced = AFTER_UPDATE_PRECHECK_HOOKS
300        .lock()
301        .unwrap_or_else(std::sync::PoisonError::into_inner)
302        .insert(update_path.clone(), hook);
303    assert!(replaced.is_none(), "update precheck hook already installed");
304    AfterUpdatePrecheckHookRegistration { update_path }
305}
306
307#[cfg(test)]
308fn run_after_update_precheck_hook(update_path: &std::path::Path) {
309    let hook = AFTER_UPDATE_PRECHECK_HOOKS
310        .lock()
311        .unwrap_or_else(std::sync::PoisonError::into_inner)
312        .remove(update_path);
313    if let Some(hook) = hook {
314        hook();
315    }
316}
317
318async fn link_inner(
319    prog_track: &'static progress::Progress,
320    cwd: &std::path::Path,
321    src: &std::path::Path,
322    dst: &std::path::Path,
323    update: &Option<std::path::PathBuf>,
324    settings: &Settings,
325    is_fresh: bool,
326) -> Result<Summary, Error> {
327    // `cwd` is retained for API/signature parity (callers still pass it) but the fd-based walk
328    // reconstructs every path from the explicit roots, so it is no longer threaded into the walk.
329    let _ = cwd;
330    let update_root_requirement = UpdateRootRequirement::from_settings(settings);
331    // reserve root admission before any strict probe or operand-parent open, then transfer it into
332    // link_internal. an authoritative directory releases it before dual-tree descent.
333    let permit = walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
334    enum LinkRootSetup {
335        Complete(Summary),
336        Ready {
337            src_operand: crate::walk::RootOperand,
338            src_parent: Arc<Dir>,
339            dst_parent: Option<Arc<Dir>>,
340            update_parent: Option<(Arc<Dir>, std::ffi::OsString)>,
341            admission: LinkEntryAdmission,
342        },
343    }
344    let setup_admission = permit.as_ref().map(LeafPermit::admission);
345    let setup = safedir::with_optional_fd_admission(setup_admission, async move {
346        // a missing --update root is destructive under both --update-exclusive (materialized set =
347        // update set, so nothing materializes) AND --delete (the source-only keep_set makes any dst
348        // entry the missing update tree WOULD have protected look extraneous, and prune wipes it).
349        // in either case `link_internal` hits the recursive early-return / silent `None` fallback
350        // before that destruction would happen, so rlink reports success — silently preserving
351        // stale dst (--update-exclusive) or silently pruning would-be-protected entries (--delete).
352        // reject up front so the usual error ordering catches a typo'd --update before later operand
353        // work. The same typed requirement is retained through the authoritative root classification,
354        // closing the race after this probe. Plain "--update without --delete or --update-exclusive"
355        // still falls back to no-update mode, and recursive child-level "update missing" cases remain
356        // ordinary no-op/source-only decisions.
357        if let Some(update_path) = update.as_ref() {
358            if crate::safedir::strict_operand_resolution() {
359                // under strict operand resolution, validate the update operand prefix UP FRONT
360                // (fd-relative, `openat2(RESOLVE_NO_SYMLINKS)`), unconditionally for ALL --update — so a
361                // plain --update with an excluded source (which returns before the update parent would
362                // otherwise be opened) cannot slip a symlinked update prefix through. A symlink in a
363                // directory component fails closed (ELOOP); this also serves the destructive-mode
364                // existence check below.
365                let kind =
366                    crate::safedir::strict_probe_dst_kind(update_path, congestion::Side::Source)
367                        .await
368                        .map_err(|err| {
369                            Error::new(
370                                anyhow::Error::new(err).context(format!(
371                                    "failed reading metadata from update {update_path:?}"
372                                )),
373                                Default::default(),
374                            )
375                        })?;
376                if update_root_requirement.is_required() && kind.is_none() {
377                    return Err(missing_destructive_update_error(update_path));
378                }
379            } else if update_root_requirement.is_required() {
380                match crate::walk::run_metadata_probed(
381                    congestion::Side::Source,
382                    congestion::MetadataOp::Stat,
383                    tokio::fs::symlink_metadata(update_path),
384                )
385                .await
386                {
387                    Ok(_) => {}
388                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
389                        return Err(missing_destructive_update_error(update_path));
390                    }
391                    Err(err) => {
392                        return Err(Error::new(
393                            anyhow::Error::new(err).context(format!(
394                                "failed reading metadata from update {:?}",
395                                update_path
396                            )),
397                            Default::default(),
398                        ));
399                    }
400                }
401            }
402        }
403        #[cfg(test)]
404        if let Some(update_path) = update.as_ref() {
405            run_after_update_precheck_hook(update_path);
406        }
407        // source: decompose via the shared helper so `.`/`..` operands (e.g. `rlink . dst`, `rlink
408        // tree/.. dst`) are canonicalized to a real directory + basename instead of being rejected; `/`
409        // is still rejected. (The destination and `--update` operands keep their direct split below.)
410        let src_operand = crate::walk::split_root_operand(src)
411            .await
412            .map_err(|err| Error::new(err, Default::default()))?;
413        let src = src_operand.display.as_path();
414        let src_name = src_operand.name.as_os_str();
415        // under strict operand resolution (--require-toctou-safe) the parent prefix is opened BEFORE
416        // the root filter check, so a symlinked source prefix fails closed even when the root would be
417        // skipped by a filter, and the root is classified fd-relative. On the default path the filter
418        // check keeps the historical path-based `symlink_metadata` classification and runs FIRST: an
419        // excluded root under an execute-only (0111, searchable-not-readable) parent must skip cleanly,
420        // and the O_RDONLY parent open would fail EACCES there.
421        let strict_src_parent: Option<Arc<Dir>> = if crate::safedir::strict_operand_resolution() {
422            let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
423                .await
424                .with_context(|| {
425                    format!(
426                        "cannot open source parent directory {:?}",
427                        src_operand.parent
428                    )
429                })
430                .map_err(|err| Error::new(err, Default::default()))?;
431            // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
432            Some(Arc::new(parent.into_tree()))
433        } else {
434            None
435        };
436        // the destination's parent path, split leniently for the up-front strict validation below (a
437        // `.`/`..`/`/` destination has no distinct parent+name; the authoritative split — which rejects
438        // such a destination — runs AFTER the filter, preserving default-mode ordering so a filtered-out
439        // source still skips cleanly). empty parent (a single-component relative path) means ".".
440        let dst_parent_path_opt: Option<&std::path::Path> = match (dst.parent(), dst.file_name()) {
441            (Some(parent), Some(_)) if parent.as_os_str().is_empty() => {
442                Some(std::path::Path::new("."))
443            }
444            (Some(parent), Some(_)) => Some(parent),
445            _ => None,
446        };
447        // under strict operand resolution, resolve the destination parent UP FRONT — before the source
448        // root-filter early-return, unconditionally — so a symlinked destination prefix fails closed in
449        // every mode regardless of filters/flags (see copy::copy for the same pattern). An absent parent
450        // (dry-run previewing into a not-yet-created tree, or a filtered-out root) is not a symlink
451        // violation; only a symlinked prefix component (ELOOP) fails closed here.
452        let strict_dst_parent: Option<Arc<Dir>> = match (
453            crate::safedir::strict_operand_resolution(),
454            dst_parent_path_opt,
455        ) {
456            (true, Some(dst_parent_path)) => {
457                match Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination).await {
458                    Ok(parent) => Some(Arc::new(parent.into_tree())),
459                    Err(err)
460                        if err.kind() == std::io::ErrorKind::NotFound
461                            || err.raw_os_error() == Some(libc::ENOTDIR) =>
462                    {
463                        None
464                    }
465                    Err(err) => {
466                        return Err(Error::new(
467                            anyhow::Error::new(err).context(format!(
468                                "cannot open destination parent directory {dst_parent_path:?}"
469                            )),
470                            Default::default(),
471                        ));
472                    }
473                }
474            }
475            // default mode, or a degenerate destination (rejected by the authoritative split below)
476            _ => None,
477        };
478        // in default mode a dual root may take the same metadata-only exclusion fast path as a
479        // source-only root. Both physical operands are one logical item in the source namespace,
480        // so evaluate both types under `src_name`; the update operand's basename is irrelevant.
481        // This path may only exclude. Every provisional include falls through to the fd-relative
482        // joint recheck below, and metadata errors also fall through so the existing parent-open /
483        // authoritative-classification error precedence remains unchanged.
484        if strict_src_parent.is_none()
485            && let (Some(filter), Some(update_path)) = (settings.filter.as_ref(), update.as_ref())
486        {
487            let src_metadata = crate::walk::run_metadata_probed(
488                congestion::Side::Source,
489                congestion::MetadataOp::Stat,
490                tokio::fs::symlink_metadata(src),
491            )
492            .await;
493            let update_metadata = crate::walk::run_metadata_probed(
494                congestion::Side::Source,
495                congestion::MetadataOp::Stat,
496                tokio::fs::symlink_metadata(update_path),
497            )
498            .await;
499            if let (Ok(src_metadata), Ok(update_metadata)) = (src_metadata, update_metadata)
500                && let RootFilterSelection::Skip { kind, result } = select_filtered_root(
501                    filter,
502                    src_name,
503                    EntryKind::from_metadata(&src_metadata),
504                    Some(EntryKind::from_metadata(&update_metadata)),
505                    settings.update_exclusive,
506                )
507            {
508                if let Some(mode) = settings.dry_run {
509                    crate::dry_run::report_skip(src, &result, mode, kind.label_long());
510                }
511                kind.inc_skipped(prog_track);
512                return Ok(LinkRootSetup::Complete(skipped_summary_for(kind)));
513            }
514        }
515        // a source-only root preserves the historical cheap early filter. a dual root that was not
516        // conclusively excluded above defers to the joint fd-relative setup decision after both
517        // handles are classified.
518        if update.is_none()
519            && let Some(ref filter) = settings.filter
520        {
521            let (kind, is_dir) = match &strict_src_parent {
522                // strict: classify via the held parent fd (O_NOFOLLOW), never by path
523                Some(parent) => {
524                    let root_handle = parent
525                        .child(src_name)
526                        .await
527                        .with_context(|| format!("failed reading metadata from {:?}", &src))
528                        .map_err(|err| Error::new(err, Default::default()))?;
529                    (root_handle.kind(), root_handle.kind() == EntryKind::Dir)
530                }
531                None => {
532                    let src_metadata = crate::walk::run_metadata_probed(
533                        congestion::Side::Source,
534                        congestion::MetadataOp::Stat,
535                        tokio::fs::symlink_metadata(src),
536                    )
537                    .await
538                    .with_context(|| format!("failed reading metadata from {:?}", &src))
539                    .map_err(|err| Error::new(err, Default::default()))?;
540                    (
541                        EntryKind::from_metadata(&src_metadata),
542                        src_metadata.is_dir(),
543                    )
544                }
545            };
546            let result = filter.should_include_root_item(std::path::Path::new(src_name), is_dir);
547            match result {
548                crate::filter::FilterResult::Included => {}
549                result => {
550                    if let Some(mode) = settings.dry_run {
551                        crate::dry_run::report_skip(src, &result, mode, kind.label_long());
552                    }
553                    kind.inc_skipped(prog_track);
554                    return Ok(LinkRootSetup::Complete(skipped_summary_for(kind)));
555                }
556            }
557        }
558        // open the parent directories of the source and (optional) update roots for the
559        // walk so each root entry is opened and classified relative to a directory fd — the same
560        // fd-relative path every nested entry takes. The roots are then handed to `link_internal` by
561        // their basenames, exactly like child entries. under strict operand resolution the source
562        // parent was already opened and validated above, so reuse its held fd here.
563        let src_parent = match strict_src_parent {
564            Some(parent) => parent,
565            None => {
566                let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
567                    .await
568                    .with_context(|| {
569                        format!(
570                            "cannot open source parent directory {:?}",
571                            src_operand.parent
572                        )
573                    })
574                    .map_err(|err| Error::new(err, Default::default()))?;
575                // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
576                Arc::new(parent.into_tree())
577            }
578        };
579        // the update tree (if present) is rooted at `update`; open its parent and remember the root
580        // basename so setup can classify it via the held fd. A missing root/parent silently falls
581        // back only for optional plain update; destructive delete/update or update-exclusive is
582        // enforced again by the authoritative typed requirement.
583        //
584        // for plain `--update` (no `--delete`, no `--update-exclusive`) a missing parent is treated the
585        // same as a missing update root: fall back silently to no-update mode. This preserves the long-
586        // standing behavior where `rlink --update /tmp/no/such src dst` (with `/tmp/no` absent) proceeds
587        // by linking from `src` rather than erroring — the existing missing-update-root fallback already
588        // applies to that case; the parent-open merely must not ENOENT-fail before `link_internal` can
589        // apply it. Under `--delete` or `--update-exclusive`, parent failure propagates and root absence
590        // is rejected again by whichever authoritative root classification site retains the handle.
591        let update_parent = match update.as_ref() {
592            Some(update_path) => {
593                // decompose the update operand the same way as the source: the update tree is a READ
594                // tree, so `.`/`..`/`dir/..` are meaningful and `split_root_operand` canonicalizes them
595                // (and rejects `/`). This makes `rlink --update . src dst` / `--update tree/.. src dst`
596                // work instead of erroring, matching the source-operand handling. The helper already
597                // normalizes an empty parent to ".", so no `resolve_parent` is needed here.
598                let update_operand = crate::walk::split_root_operand(update_path)
599                    .await
600                    .map_err(|err| Error::new(err, Default::default()))?;
601                let update_parent_path = update_operand.parent;
602                let update_name = update_operand.name;
603                // the update tree's TRUSTED parent prefix is resolved following symlinks (see the
604                // source parent above): a symlinked update container must be followed into the real dir.
605                let fallback_eligible = !update_root_requirement.is_required();
606                match Dir::open_parent_dir(&update_parent_path, congestion::Side::Source).await {
607                    // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below).
608                    Ok(dir) => Some((Arc::new(dir.into_tree()), update_name)),
609                    Err(err)
610                        if fallback_eligible
611                            && (matches!(
612                                err.kind(),
613                                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
614                            ) || err.raw_os_error() == Some(libc::ENOTDIR)) =>
615                    {
616                        // the update path's parent (or an ancestor) doesn't exist — treat the whole
617                        // update tree as absent and fall back to no-update mode, exactly as when the
618                        // update ROOT itself is missing (handled inside link_internal).
619                        tracing::debug!(
620                            "update parent {:?} not found ({:#}); falling back to no-update mode",
621                            update_parent_path,
622                            err
623                        );
624                        None
625                    }
626                    Err(err) => {
627                        return Err(Error::new(
628                            anyhow::Error::new(err).context(format!(
629                                "cannot open update parent directory {:?}",
630                                update_parent_path
631                            )),
632                            Default::default(),
633                        ));
634                    }
635                }
636            }
637            None => None,
638        };
639        // a dual root must be filtered before destination validation, and its type authorization must
640        // stay bound to the exact handles that informed it. classify both roots once, select the
641        // surviving side, and thread those handles into link_internal; same-class file/directory
642        // payloads retain their documented by-name semantics below that authorization boundary.
643        let admission = match (settings.filter.as_ref(), update.as_ref()) {
644            (Some(filter), Some(update_path)) => {
645                let src_handle = src_parent
646                    .child(src_name)
647                    .await
648                    .with_context(|| format!("failed reading metadata from {src:?}"))
649                    .map_err(|err| Error::new(err, Default::default()))?;
650                let mut entry = AdmittedLinkEntry::new(src_handle, permit);
651                entry.update_handle = match update_parent.as_ref() {
652                    Some((parent, update_name)) => update_root_requirement
653                        .classify(parent.child(update_name).await, update_path)?,
654                    None => None,
655                };
656                if settings.update_exclusive && entry.update_handle.is_none() {
657                    return Ok(LinkRootSetup::Complete(Default::default()));
658                }
659                match select_filtered_root(
660                    filter,
661                    src_name,
662                    entry.src_handle.kind(),
663                    entry.update_handle.as_ref().map(Handle::kind),
664                    settings.update_exclusive,
665                ) {
666                    RootFilterSelection::SourceOnly => entry.update_handle = None,
667                    RootFilterSelection::WithUpdate => {}
668                    RootFilterSelection::Skip { kind, result } => {
669                        if let Some(mode) = settings.dry_run {
670                            crate::dry_run::report_skip(src, &result, mode, kind.label_long());
671                        }
672                        kind.inc_skipped(prog_track);
673                        return Ok(LinkRootSetup::Complete(skipped_summary_for(kind)));
674                    }
675                }
676                LinkEntryAdmission::Filtered(entry)
677            }
678            _ => LinkEntryAdmission::from(EntryAdmission::from(permit)),
679        };
680        // authoritative destination split runs only after every applicable root filter. a `.`/`..`/`/`
681        // destination is not a meaningful link target, and rejecting it avoids clobbering the cwd.
682        // empty parent (a single-component relative path) means the current directory.
683        let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
684            return Err(Error::new(
685                anyhow!(
686                    "link destination {:?} has no parent directory or file name",
687                    dst
688                ),
689                Default::default(),
690            ));
691        };
692        let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
693            std::path::Path::new(".")
694        } else {
695            dst_parent_path
696        };
697        // one `listxattr` on the source ROOT per run — a constant, not a per-entry probe — warning that
698        // a source root carrying an ACL is about to be linked by settings that drop it. WITHOUT
699        // `--update`, files count as already safe whatever `f:acl` says: a hard-linked destination
700        // file IS the source inode, so its ACL cannot be dropped (and must never be written through,
701        // which is why `f:acl` applies only on rlink's real copy path). WITH `--update` that certainty
702        // is gone — a changed file is materialized by COPYING the update-tree entry, which drops its
703        // acl unless `f:acl` is on — so the file half then defers to the setting like any copy. (the
704        // probe still examines the SOURCE root; whether the update entry differs — and so whether the
705        // copy path actually runs — is not known yet, matching the probe's documented
706        // root-only-heuristic character.) whether the notice is wanted AT ALL still comes from the
707        // preserve settings, which for rlink default to `all` — metadata fidelity is what the tool is
708        // for — so a bare `rlink` asks for it where a bare `rcp` does not.
709        let root_acl_notice = crate::safedir::RootAclNotice {
710            file_acl_preserved: update.is_none() || settings.preserve.file.acl,
711            ..crate::safedir::RootAclNotice::for_preserve(&settings.preserve)
712        };
713        match &admission {
714            LinkEntryAdmission::Filtered(entry) => {
715                crate::safedir::warn_if_root_acl_unpreserved(
716                    &entry.src_handle,
717                    src,
718                    root_acl_notice,
719                )
720                .await;
721            }
722            LinkEntryAdmission::Unclassified { .. } => {
723                crate::safedir::warn_if_root_acl_unpreserved_at(
724                    &src_parent,
725                    src_name,
726                    src,
727                    root_acl_notice,
728                )
729                .await;
730            }
731        }
732        // in dry-run we never touch the destination, so we don't open its parent at all (it may not
733        // even exist). `dst_parent == None` is the signal throughout the walk that destination
734        // operations must be skipped. in a real link, reuse the strict-validated parent, or open it
735        // following symlinks (default mode; a symlinked destination container is followed into the real
736        // dir).
737        let dst_parent = if settings.dry_run.is_some() {
738            None
739        } else {
740            match strict_dst_parent {
741                Some(parent) => Some(parent),
742                None => {
743                    let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
744                        .await
745                        .with_context(|| {
746                            format!(
747                                "cannot open destination parent directory {:?}",
748                                dst_parent_path
749                            )
750                        })
751                        .map_err(|err| Error::new(err, Default::default()))?;
752                    // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below).
753                    Some(Arc::new(dir.into_tree()))
754                }
755            }
756        };
757        Ok(LinkRootSetup::Ready {
758            src_operand,
759            src_parent,
760            dst_parent,
761            update_parent,
762            admission,
763        })
764    })
765    .await?;
766    let (src_operand, src_parent, dst_parent, update_parent, admission) = match setup {
767        LinkRootSetup::Complete(summary) => return Ok(summary),
768        LinkRootSetup::Ready {
769            src_operand,
770            src_parent,
771            dst_parent,
772            update_parent,
773            admission,
774        } => (
775            src_operand,
776            src_parent,
777            dst_parent,
778            update_parent,
779            admission,
780        ),
781    };
782    let src = src_operand.display.as_path();
783    let src_name = src_operand.name.as_os_str();
784    let update_ref = update_parent
785        .as_ref()
786        .map(|(dir, name)| (dir, name.as_os_str()));
787    link_internal(
788        prog_track,
789        &src_parent,
790        update_ref,
791        dst_parent.as_ref(),
792        src_name,
793        src,
794        dst,
795        update.as_deref(),
796        std::path::Path::new(""),
797        update_root_requirement,
798        settings,
799        is_fresh,
800        admission,
801    )
802    .await
803    .map(|result| result.summary)
804}
805/// Tracks which child names exact entry decisions protect from `--delete` for one directory pass.
806/// Source and update-only workers fold their final exact selection here, including terminal filter
807/// and special skips.
808///
809/// When `--delete` is off the inner set is `None` and every method is a no-op — zero heap
810/// cost in the hot path.
811struct DeleteKeepSet {
812    inner: Option<std::collections::HashSet<std::ffi::OsString>>,
813}
814
815impl DeleteKeepSet {
816    fn new(delete: Option<&copy::DeleteSettings>) -> Self {
817        Self {
818            inner: delete.is_some().then(std::collections::HashSet::new),
819        }
820    }
821    /// Exact worker result: retain a name only when the final joint decision selected an action.
822    fn record_exact(&mut self, name: std::ffi::OsString) {
823        if let Some(set) = &mut self.inner {
824            set.insert(name);
825        }
826    }
827    /// Borrow the underlying set for `prune_extraneous`. `None` means `--delete` is off and
828    /// the caller should skip the prune entirely.
829    fn as_set(&self) -> Option<&std::collections::HashSet<std::ffi::OsString>> {
830        self.inner.as_ref()
831    }
832}
833
834/// Keeps both comparison handles structurally ahead of their shared admission permit.
835struct AdmittedLinkEntry<S = Handle, U = Handle, P = LeafPermit> {
836    src_handle: S,
837    update_handle: Option<U>,
838    permit: Option<P>,
839}
840
841/// Owns exactly one link entry's classification and admission state.
842enum LinkEntryAdmission {
843    Unclassified {
844        admission: EntryAdmission,
845        hinted_src_handle: Option<Handle>,
846    },
847    Filtered(AdmittedLinkEntry),
848}
849
850/// One exact link-entry decision and the destination-protection bit it authorizes.
851struct LinkEntryResult {
852    summary: Summary,
853    protect_destination: bool,
854}
855
856impl LinkEntryResult {
857    fn selected(summary: Summary) -> Self {
858        Self {
859            summary,
860            protect_destination: true,
861        }
862    }
863
864    fn filtered(summary: Summary) -> Self {
865        Self {
866            summary,
867            protect_destination: false,
868        }
869    }
870}
871
872/// Result folded from one spawned source/update entry.
873struct LinkTaskResult {
874    summary: Summary,
875    keep_name: Option<std::ffi::OsString>,
876}
877
878impl LinkTaskResult {
879    fn from_link(name: std::ffi::OsString, result: LinkEntryResult) -> Self {
880        Self {
881            summary: result.summary,
882            keep_name: result.protect_destination.then_some(name),
883        }
884    }
885
886    fn from_update(name: std::ffi::OsString, result: LinkEntryResult) -> Self {
887        Self::from_link(name, result)
888    }
889
890    fn fold(self, summary: &mut Summary, keep_set: &mut DeleteKeepSet) {
891        if let Some(name) = self.keep_name {
892            keep_set.record_exact(name);
893        }
894        *summary = *summary + self.summary;
895    }
896}
897
898impl LinkEntryAdmission {
899    fn admission(&self) -> Option<throttle::FdAdmission> {
900        match self {
901            Self::Unclassified { admission, .. } => admission.admission(),
902            Self::Filtered(entry) => entry.permit.as_ref().map(LeafPermit::admission),
903        }
904    }
905}
906
907impl From<EntryAdmission> for LinkEntryAdmission {
908    fn from(admission: EntryAdmission) -> Self {
909        Self::Unclassified {
910            admission,
911            hinted_src_handle: None,
912        }
913    }
914}
915
916/// Select which side of a dual-root entry survives its joint filter decision.
917enum RootFilterSelection {
918    SourceOnly,
919    WithUpdate,
920    Skip {
921        kind: EntryKind,
922        result: crate::filter::FilterResult,
923    },
924}
925
926/// Filter a link root once in its source-root-relative namespace using both authoritative types.
927fn select_filtered_root(
928    filter: &crate::filter::FilterSettings,
929    logical_name: &std::ffi::OsStr,
930    src_kind: EntryKind,
931    update_kind: Option<EntryKind>,
932    update_exclusive: bool,
933) -> RootFilterSelection {
934    let logical_name = std::path::Path::new(logical_name);
935    let src_result = filter.should_include_root_item(logical_name, src_kind == EntryKind::Dir);
936    let src_included = matches!(&src_result, crate::filter::FilterResult::Included);
937    let Some(update_kind) = update_kind else {
938        return if src_included {
939            RootFilterSelection::SourceOnly
940        } else {
941            RootFilterSelection::Skip {
942                kind: src_kind,
943                result: src_result,
944            }
945        };
946    };
947    let update_result =
948        filter.should_include_root_item(logical_name, update_kind == EntryKind::Dir);
949    if matches!(&update_result, crate::filter::FilterResult::Included) {
950        RootFilterSelection::WithUpdate
951    } else if !update_exclusive && src_included {
952        RootFilterSelection::SourceOnly
953    } else {
954        let (kind, result) = if update_exclusive {
955            (update_kind, update_result)
956        } else {
957            (src_kind, src_result)
958        };
959        RootFilterSelection::Skip { kind, result }
960    }
961}
962
963/// Select which exact side of a nested dual-tree entry survives its filter decision.
964fn select_filtered_nested(
965    filter: &crate::filter::FilterSettings,
966    logical_path: &std::path::Path,
967    src_kind: EntryKind,
968    update_kind: Option<EntryKind>,
969    update_exclusive: bool,
970) -> RootFilterSelection {
971    let src_result = filter.should_include(logical_path, src_kind == EntryKind::Dir);
972    let src_included = matches!(&src_result, crate::filter::FilterResult::Included);
973    let Some(update_kind) = update_kind else {
974        return if src_included {
975            RootFilterSelection::SourceOnly
976        } else {
977            RootFilterSelection::Skip {
978                kind: src_kind,
979                result: src_result,
980            }
981        };
982    };
983    let update_result = filter.should_include(logical_path, update_kind == EntryKind::Dir);
984    if matches!(&update_result, crate::filter::FilterResult::Included) {
985        RootFilterSelection::WithUpdate
986    } else if !update_exclusive && src_included {
987        RootFilterSelection::SourceOnly
988    } else {
989        let (kind, result) = if update_exclusive {
990            (update_kind, update_result)
991        } else {
992            (src_kind, src_result)
993        };
994        RootFilterSelection::Skip { kind, result }
995    }
996}
997
998fn drop_link_handles_before_permit<S, U, P>(
999    src_handle: S,
1000    update_handle: Option<U>,
1001    permit: Option<P>,
1002) {
1003    drop(src_handle);
1004    drop(update_handle);
1005    drop(permit);
1006}
1007
1008impl AdmittedLinkEntry<Handle, Handle, LeafPermit> {
1009    fn new(src_handle: Handle, permit: Option<LeafPermit>) -> Self {
1010        Self {
1011            src_handle,
1012            update_handle: None,
1013            permit,
1014        }
1015    }
1016
1017    // the directory outcome returns the original private bundle so its two handles can be closed
1018    // before the permit. boxing that expected recursive transition would allocate per directory.
1019    #[allow(clippy::result_large_err)]
1020    fn into_source_leaf(self) -> Result<AdmittedLeaf, Self> {
1021        let Self {
1022            src_handle,
1023            update_handle,
1024            permit,
1025        } = self;
1026        match AdmittedLeaf::try_new(src_handle, permit) {
1027            Ok(leaf) => {
1028                drop(update_handle);
1029                Ok(leaf)
1030            }
1031            Err((src_handle, permit)) => Err(Self {
1032                src_handle,
1033                update_handle,
1034                permit,
1035            }),
1036        }
1037    }
1038
1039    fn into_update_entry(self) -> AdmittedEntry {
1040        let Self {
1041            src_handle,
1042            update_handle,
1043            permit,
1044        } = self;
1045        drop(src_handle);
1046        let update_handle =
1047            update_handle.expect("update action requires a classified update entry");
1048        AdmittedEntry::new(update_handle, permit)
1049    }
1050
1051    fn into_source_entry(self) -> AdmittedEntry {
1052        let Self {
1053            src_handle,
1054            update_handle,
1055            permit,
1056        } = self;
1057        drop(update_handle);
1058        AdmittedEntry::new(src_handle, permit)
1059    }
1060
1061    fn close_for_directory(self) -> bool {
1062        let Self {
1063            src_handle,
1064            update_handle,
1065            permit,
1066        } = self;
1067        let has_update_dir = update_handle
1068            .as_ref()
1069            .is_some_and(|handle| handle.kind() == EntryKind::Dir);
1070        drop_link_handles_before_permit(src_handle, update_handle, permit);
1071        has_update_dir
1072    }
1073}
1074
1075/// Process one entry in the dual source/update tree.
1076///
1077/// The source entry is classified via `src_parent.child(name)` (fstat-authoritative; the getdents
1078/// hint is only a spawn-loop heuristic). When an update entry exists at this name it is classified
1079/// too, and the hard-link-vs-copy decision mirrors the old `--update` overlay logic exactly:
1080/// a type mismatch, changed file, or symlink in the update tree is copied from the update version
1081/// via [`copy::copy_child`]; an unchanged file is hard-linked from the source; a directory recurses
1082/// through the dual tree. With no update tree, a source file is hard-linked, a source symlink is
1083/// copied, and a directory recurses.
1084///
1085/// `admission` is the explicit scheduling state supplied by the directory loop. Every
1086/// leaf-dispatch branch retains a held permit through its final fd-bearing work, including nested
1087/// overwrite removal; direct directory recursion releases it before descent, while delegated copy
1088/// consumes the already-classified update entry and owns its leaf-or-directory transition.
1089#[instrument(skip(prog_track, src_parent, update, dst_parent, settings, admission))]
1090#[async_recursion]
1091#[allow(clippy::too_many_arguments)]
1092async fn link_internal(
1093    prog_track: &'static progress::Progress,
1094    src_parent: &Arc<Dir>,
1095    update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
1096    dst_parent: Option<&Arc<Dir>>,
1097    name: &std::ffi::OsStr,
1098    src_root: &std::path::Path,
1099    dst_root: &std::path::Path,
1100    update_root: Option<&std::path::Path>,
1101    rel_path: &std::path::Path,
1102    update_root_requirement: UpdateRootRequirement,
1103    settings: &Settings,
1104    is_fresh: bool,
1105    admission: LinkEntryAdmission,
1106) -> Result<LinkEntryResult, Error> {
1107    let _prog_guard = prog_track.ops.guard();
1108    // real filesystem paths reconstructed from the roots + accumulated relative path. used for
1109    // diagnostics, the path-based `--delete` prune scan / `rm`, the `--dereference` canonicalize
1110    // fallback inside copy, and to derive `dst_name`. joining an empty `rel_path` (the root entry)
1111    // would append a trailing separator, so use the root verbatim when `rel_path` is empty.
1112    let (src_path, dst_path) = if rel_path.as_os_str().is_empty() {
1113        (src_root.to_path_buf(), dst_root.to_path_buf())
1114    } else {
1115        (src_root.join(rel_path), dst_root.join(rel_path))
1116    };
1117    let update_path = update_root.map(|root| {
1118        if rel_path.as_os_str().is_empty() {
1119            root.to_path_buf()
1120        } else {
1121            root.join(rel_path)
1122        }
1123    });
1124    // the destination entry's name within `dst_parent`. for nested entries this equals the source
1125    // `name`, but for the root the source and destination basenames differ (e.g. linking `foo` to
1126    // `bar`), so destination operations must use this name.
1127    let dst_name = dst_path
1128        .file_name()
1129        .ok_or_else(|| {
1130            Error::new(
1131                anyhow!("link destination {:?} has no file name", &dst_path),
1132                Default::default(),
1133            )
1134        })?
1135        .to_owned();
1136    let mut admission = admission;
1137    if let LinkEntryAdmission::Unclassified {
1138        admission: entry_admission,
1139        hinted_src_handle,
1140    } = &mut admission
1141    {
1142        let current = std::mem::replace(entry_admission, EntryAdmission::RootOrDelegated);
1143        *entry_admission = if update.is_some() {
1144            current.require_admission()
1145        } else {
1146            current
1147        };
1148        // a positive source-directory hint gets one unadmitted classification only when no
1149        // separate update entry participates in the decision. if stale, close the first handle
1150        // before acquiring and reclassify inside the admitted worker region below.
1151        if matches!(entry_admission, EntryAdmission::HintedDirectory) {
1152            tracing::debug!("classifying hinted source entry");
1153            let handle = walk::classify_entry(src_parent, name)
1154                .await
1155                .with_context(|| format!("failed reading metadata from {:?}", &src_path))
1156                .map_err(|err| Error::new(err, Default::default()))?;
1157            if handle.kind() == EntryKind::Dir {
1158                *hinted_src_handle = Some(handle);
1159            } else {
1160                drop(handle);
1161                *entry_admission = EntryAdmission::RootOrDelegated;
1162            }
1163        }
1164        if hinted_src_handle.is_none() || update.is_some() {
1165            let current = std::mem::replace(entry_admission, EntryAdmission::RootOrDelegated);
1166            *entry_admission = walk::ensure_entry_admission(PermitKind::OpenFile, current).await;
1167        }
1168    }
1169    let weak_admission = admission.admission();
1170
1171    enum LinkDispatch {
1172        Complete(LinkEntryResult),
1173        Directory { has_update_dir: bool },
1174    }
1175
1176    let dispatch = safedir::with_optional_fd_admission(weak_admission, async {
1177        let (mut entry, root_filter_applied) = match admission {
1178            LinkEntryAdmission::Filtered(entry) => (entry, true),
1179            LinkEntryAdmission::Unclassified {
1180                admission,
1181                hinted_src_handle,
1182            } => {
1183                let src_handle = match hinted_src_handle {
1184                    Some(handle) => handle,
1185                    None => {
1186                        tracing::debug!("classifying source entry");
1187                        walk::classify_entry(src_parent, name)
1188                            .await
1189                            .with_context(|| {
1190                                format!("failed reading metadata from {:?}", &src_path)
1191                            })
1192                            .map_err(|err| Error::new(err, Default::default()))?
1193                    }
1194                };
1195                let mut entry = AdmittedLinkEntry::new(src_handle, admission.into_permit());
1196                // classify the update entry at this name (if an update tree is present at this
1197                // level). root absence is rejected when destructive settings require the operand;
1198                // nested absence remains the ordinary source-only / update-exclusive no-op case.
1199                entry.update_handle = match update {
1200                    Some((update_dir, update_name)) => {
1201                        tracing::debug!("classifying 'update' entry");
1202                        let update_path = update_path
1203                            .as_deref()
1204                            .expect("update directory present implies update path present");
1205                        update_root_requirement
1206                            .for_entry(rel_path)
1207                            .classify(update_dir.child(update_name).await, update_path)?
1208                    }
1209                    None => None,
1210                };
1211                if update.is_some() && settings.update_exclusive && entry.update_handle.is_none() {
1212                    return Ok(LinkDispatch::Complete(LinkEntryResult::filtered(
1213                        Default::default(),
1214                    )));
1215                }
1216                (entry, false)
1217            }
1218        };
1219        // a root is one logical item in the source namespace even when the update operand has another
1220        // basename. decide from both fd-classified types together: normal mode is their filtered union,
1221        // while --update-exclusive selects only a filter-passing update side.
1222        if !root_filter_applied
1223            && rel_path.as_os_str().is_empty()
1224            && let Some(filter) = settings.filter.as_ref()
1225        {
1226            match select_filtered_root(
1227                filter,
1228                name,
1229                entry.src_handle.kind(),
1230                entry.update_handle.as_ref().map(Handle::kind),
1231                settings.update_exclusive,
1232            ) {
1233                RootFilterSelection::SourceOnly => entry.update_handle = None,
1234                RootFilterSelection::WithUpdate => {}
1235                RootFilterSelection::Skip { kind, result } => {
1236                    if let Some(mode) = settings.dry_run {
1237                        crate::dry_run::report_skip(&src_path, &result, mode, kind.label_long());
1238                    }
1239                    kind.inc_skipped(prog_track);
1240                    return Ok(LinkDispatch::Complete(LinkEntryResult::filtered(
1241                        skipped_summary_for(kind),
1242                    )));
1243                }
1244            }
1245        // a nested source loop may have filtered from an advisory hint. Re-evaluate BOTH sides from
1246        // the exact handles that can drive materialization, so a later type swap cannot inherit that
1247        // earlier decision.
1248        } else if !rel_path.as_os_str().is_empty()
1249            && let Some(filter) = settings.filter.as_ref()
1250        {
1251            match select_filtered_nested(
1252                filter,
1253                rel_path,
1254                entry.src_handle.kind(),
1255                entry.update_handle.as_ref().map(Handle::kind),
1256                settings.update_exclusive,
1257            ) {
1258                RootFilterSelection::SourceOnly => entry.update_handle = None,
1259                RootFilterSelection::WithUpdate => {}
1260                RootFilterSelection::Skip { kind, result } => {
1261                    if let Some(mode) = settings.dry_run {
1262                        crate::dry_run::report_skip(&src_path, &result, mode, kind.label_long());
1263                    }
1264                    kind.inc_skipped(prog_track);
1265                    return Ok(LinkDispatch::Complete(LinkEntryResult::filtered(
1266                        skipped_summary_for(kind),
1267                    )));
1268                }
1269            }
1270        }
1271        // from this point every leaf-dispatch action retains the private entry bundle or an
1272        // `AdmittedLeaf` through its final fd-bearing work, including nested overwrite removal. A
1273        // delegated update action closes the comparison source handle, then transfers the selected
1274        // update handle and permit together as one AdmittedEntry. copy consumes that exact owner through
1275        // the shared driver, including its directory drop-before-recursion transition. Direct dual-tree
1276        // directory recursion closes admission and both handles here.
1277        if let Some(update_entry) = entry.update_handle.as_ref() {
1278            let (update_dir, update_name) = update.unwrap();
1279            let update_path = update_path.as_deref().unwrap();
1280            if update_entry.kind() != entry.src_handle.kind() {
1281                // file type changed, just copy the updated one
1282                tracing::debug!(
1283                    "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
1284                    src_path,
1285                    entry.src_handle.kind(),
1286                    update_path,
1287                    update_entry.kind()
1288                );
1289                // delegate at this entry's logical path so that, under --delete, pruning inside the
1290                // delegated subtree matches include/exclude descendants at the correct filter root
1291                // (e.g. `node/*.log`). Pass the held update parent + name and transfer the exact selected
1292                // update handle with its admission. The shared copy driver releases provisional
1293                // admission itself when that handle is an authoritative directory.
1294                let admission = entry.into_update_entry();
1295                return delegate_copy(
1296                    prog_track,
1297                    update_dir,
1298                    dst_parent,
1299                    update_name,
1300                    update_path,
1301                    &dst_path,
1302                    rel_path,
1303                    copy::DeleteScanAnchor::new(dst_root, rel_path),
1304                    settings,
1305                    is_fresh,
1306                    admission,
1307                )
1308                .await
1309                .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1310            }
1311            if update_entry.kind() == EntryKind::File {
1312                // check if the file is unchanged and if so hard-link, otherwise copy from the updated one
1313                if filecmp::metadata_equal(
1314                    &settings.update_compare,
1315                    entry.src_handle.meta(),
1316                    update_entry.meta(),
1317                ) {
1318                    // unchanged file: hard-link from src while retaining admission across `linkat`.
1319                    tracing::debug!("no change, hard link 'src'");
1320                    let leaf = match entry.into_source_leaf() {
1321                        Ok(leaf) => leaf,
1322                        Err(entry) => {
1323                            return Ok(LinkDispatch::Directory {
1324                                has_update_dir: entry.close_for_directory(),
1325                            });
1326                        }
1327                    };
1328                    if settings.dry_run.is_some() {
1329                        crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
1330                        return Ok(LinkDispatch::Complete(LinkEntryResult::selected(Summary {
1331                            hard_links_created: 1,
1332                            ..Default::default()
1333                        })));
1334                    }
1335                    let dst_dir =
1336                        dst_parent.expect("destination parent must be open for a real hard link");
1337                    return hard_link_entry_fd(
1338                        prog_track,
1339                        leaf.handle(),
1340                        dst_dir,
1341                        &dst_name,
1342                        &dst_path,
1343                        settings,
1344                    )
1345                    .await
1346                    .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1347                }
1348                tracing::debug!(
1349                    "link: {:?} metadata has changed, copying from {:?}",
1350                    src_path,
1351                    update_path
1352                );
1353                // changed file: delegate to copy, transferring the same admission guard.
1354                let admission = entry.into_update_entry();
1355                return delegate_copy(
1356                    prog_track,
1357                    update_dir,
1358                    dst_parent,
1359                    update_name,
1360                    update_path,
1361                    &dst_path,
1362                    rel_path,
1363                    copy::DeleteScanAnchor::new(dst_root, rel_path),
1364                    settings,
1365                    is_fresh,
1366                    admission,
1367                )
1368                .await
1369                .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1370            }
1371            if update_entry.kind() == EntryKind::Symlink {
1372                // update symlink: copy it under the same leaf admission guard, including any overwrite
1373                // removal it triggers
1374                tracing::debug!("'update' is a symlink so just symlink that");
1375                let admission = entry.into_update_entry();
1376                return delegate_copy(
1377                    prog_track,
1378                    update_dir,
1379                    dst_parent,
1380                    update_name,
1381                    update_path,
1382                    &dst_path,
1383                    rel_path,
1384                    copy::DeleteScanAnchor::new(dst_root, rel_path),
1385                    settings,
1386                    is_fresh,
1387                    admission,
1388                )
1389                .await
1390                .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1391            }
1392        } else {
1393            // update hasn't been specified (or is absent at this name): hard-link a source file or copy
1394            // a source symlink, retaining admission across all leaf work and nested overwrite removal
1395            tracing::debug!("no 'update' entry");
1396            if entry.src_handle.kind() == EntryKind::File {
1397                let leaf = match entry.into_source_leaf() {
1398                    Ok(leaf) => leaf,
1399                    Err(entry) => {
1400                        return Ok(LinkDispatch::Directory {
1401                            has_update_dir: entry.close_for_directory(),
1402                        });
1403                    }
1404                };
1405                if settings.dry_run.is_some() {
1406                    crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
1407                    return Ok(LinkDispatch::Complete(LinkEntryResult::selected(Summary {
1408                        hard_links_created: 1,
1409                        ..Default::default()
1410                    })));
1411                }
1412                let dst_dir =
1413                    dst_parent.expect("destination parent must be open for a real hard link");
1414                return hard_link_entry_fd(
1415                    prog_track,
1416                    leaf.handle(),
1417                    dst_dir,
1418                    &dst_name,
1419                    &dst_path,
1420                    settings,
1421                )
1422                .await
1423                .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1424            }
1425            if entry.src_handle.kind() == EntryKind::Symlink {
1426                tracing::debug!("'src' is a symlink so just symlink that");
1427                let admission = entry.into_source_entry();
1428                return delegate_copy(
1429                    prog_track,
1430                    src_parent,
1431                    dst_parent,
1432                    name,
1433                    &src_path,
1434                    &dst_path,
1435                    rel_path,
1436                    copy::DeleteScanAnchor::new(dst_root, rel_path),
1437                    settings,
1438                    is_fresh,
1439                    admission,
1440                )
1441                .await
1442                .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1443            }
1444        }
1445        if entry.src_handle.kind() != EntryKind::Dir {
1446            // special file (or unsupported type): retain admission until this branch returns and drops
1447            // the source/update classification handles.
1448            if settings.copy_settings.skip_specials {
1449                tracing::debug!(
1450                    "skipping special file {:?} (kind: {:?})",
1451                    src_path,
1452                    entry.src_handle.kind()
1453                );
1454                if let Some(mode) = settings.dry_run {
1455                    match mode {
1456                        crate::config::DryRunMode::Brief => {}
1457                        crate::config::DryRunMode::All => println!("skip special {:?}", src_path),
1458                        crate::config::DryRunMode::Explain => {
1459                            println!(
1460                                "skip special {:?} (unsupported file type: {:?})",
1461                                src_path,
1462                                entry.src_handle.kind()
1463                            );
1464                        }
1465                    }
1466                }
1467                prog_track.specials_skipped.inc();
1468                return Ok(LinkDispatch::Complete(LinkEntryResult::selected(Summary {
1469                    copy_summary: CopySummary {
1470                        specials_skipped: 1,
1471                        ..Default::default()
1472                    },
1473                    ..Default::default()
1474                })));
1475            }
1476            return Err(Error::new(
1477                anyhow!(
1478                    "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
1479                    src_path,
1480                    dst_path,
1481                    entry.src_handle.kind()
1482                ),
1483                Default::default(),
1484            ));
1485        }
1486        // directory: release before recursing the dual tree. this is rlink's direct counterpart to the
1487        // shared driver's drop-before-recurse boundary.
1488        debug_assert!(
1489            entry.update_handle.is_none()
1490                || entry.update_handle.as_ref().unwrap().kind() == EntryKind::Dir
1491        );
1492        // only drive the dual-tree update walk when an update directory entry actually exists at this
1493        // name. If `update_handle` is None (the update tree has no counterpart for this src dir, the
1494        // recursive "update missing" case), process this subtree in no-update mode: hard-link the whole
1495        // source subtree. Passing the parent update tuple here would make `link_dir_entry` try to
1496        // `open_dir` a non-existent update child.
1497        let has_update_dir = entry.close_for_directory();
1498        Ok(LinkDispatch::Directory { has_update_dir })
1499    })
1500    .await?;
1501    let has_update_dir = match dispatch {
1502        LinkDispatch::Complete(summary) => return Ok(summary),
1503        LinkDispatch::Directory { has_update_dir } => has_update_dir,
1504    };
1505    let update_for_dir = update.filter(|_| has_update_dir);
1506    let update_root_for_dir = update_root.filter(|_| has_update_dir);
1507    let update_path_for_dir = update_path.as_deref().filter(|_| has_update_dir);
1508    link_dir_entry(
1509        prog_track,
1510        src_parent,
1511        update_for_dir,
1512        dst_parent,
1513        name,
1514        &dst_name,
1515        src_root,
1516        dst_root,
1517        update_root_for_dir,
1518        rel_path,
1519        &src_path,
1520        &dst_path,
1521        update_path_for_dir,
1522        settings,
1523        is_fresh,
1524    )
1525    .await
1526    .map(LinkEntryResult::selected)
1527}
1528
1529/// Delegate a single entry to the fd-based copy ([`copy::copy_child`]), passing the HELD parent
1530/// directory handles plus the entry `name` — never re-resolving a path. `filter_base` for the
1531/// delegation is the entry's logical relative path (so `--delete` pruning inside the subtree
1532/// matches include/exclude patterns at the entry's true path). `delete_scan_anchor` independently
1533/// retains the original outer destination operand and physical subtree base. The returned copy
1534/// summary is folded into a link `Summary`.
1535#[allow(clippy::too_many_arguments)]
1536async fn delegate_copy(
1537    prog_track: &'static progress::Progress,
1538    src_parent: &Arc<Dir>,
1539    dst_parent: Option<&Arc<Dir>>,
1540    name: &std::ffi::OsStr,
1541    src_path: &std::path::Path,
1542    dst_path: &std::path::Path,
1543    filter_base: &std::path::Path,
1544    delete_scan_anchor: copy::DeleteScanAnchor,
1545    settings: &Settings,
1546    is_fresh: bool,
1547    admission: AdmittedEntry,
1548) -> Result<Summary, Error> {
1549    // link's dry-run mode owns whether a destination handle exists. Keep delegated copy behavior
1550    // aligned even for direct API callers that did not duplicate the mode into `copy_settings`;
1551    // the normal CLI path already agrees and pays no clone.
1552    let normalized_copy_settings =
1553        (settings.copy_settings.dry_run != settings.dry_run).then(|| {
1554            let mut copy_settings = settings.copy_settings.clone();
1555            copy_settings.dry_run = settings.dry_run;
1556            copy_settings
1557        });
1558    let copy_settings = normalized_copy_settings
1559        .as_ref()
1560        .unwrap_or(&settings.copy_settings);
1561    let copy_summary = copy::copy_child(
1562        prog_track,
1563        src_parent,
1564        dst_parent,
1565        name,
1566        src_path,
1567        dst_path,
1568        filter_base,
1569        copy_settings,
1570        &settings.preserve,
1571        is_fresh,
1572        delete_scan_anchor,
1573        copy::CopyEntryAdmission::Filtered(admission),
1574    )
1575    .await
1576    .map_err(|err| {
1577        let copy_summary = err.summary;
1578        Error::new(
1579            err.source,
1580            Summary {
1581                copy_summary,
1582                ..Default::default()
1583            },
1584        )
1585    })?;
1586    Ok(Summary {
1587        copy_summary,
1588        ..Default::default()
1589    })
1590}
1591
1592/// Resolve (create / reuse / overwrite) the destination directory fd-relative, open the source
1593/// (and update) directories, then recurse via [`link_dir_contents`]. Mirrors copy's
1594/// [`copy::resolve_dst_dir`] for the overwrite branches (fd-relative, parent-contained removal).
1595#[allow(clippy::too_many_arguments)]
1596async fn link_dir_entry(
1597    prog_track: &'static progress::Progress,
1598    src_parent: &Arc<Dir>,
1599    update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
1600    dst_parent: Option<&Arc<Dir>>,
1601    name: &std::ffi::OsStr,
1602    dst_name: &std::ffi::OsStr,
1603    src_root: &std::path::Path,
1604    dst_root: &std::path::Path,
1605    update_root: Option<&std::path::Path>,
1606    rel_path: &std::path::Path,
1607    src_path: &std::path::Path,
1608    dst_path: &std::path::Path,
1609    update_path: Option<&std::path::Path>,
1610    settings: &Settings,
1611    is_fresh: bool,
1612) -> Result<Summary, Error> {
1613    let src_dir = src_parent
1614        .open_dir(name)
1615        .await
1616        .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1617        .map_err(|err| Error::new(err, Default::default()))?;
1618    let src_dir = Arc::new(src_dir);
1619    // open the update directory too (it has the same file type as src here — both are dirs).
1620    let update_dir = match update {
1621        Some((update_parent, update_name)) => {
1622            let dir = update_parent
1623                .open_dir(update_name)
1624                .await
1625                .with_context(|| {
1626                    format!("cannot open update directory {:?} for reading", update_path)
1627                })
1628                .map_err(|err| Error::new(err, Default::default()))?;
1629            Some(Arc::new(dir))
1630        }
1631        None => None,
1632    };
1633    // dry-run: report the directory and traverse its contents, but never create a destination dir.
1634    if settings.dry_run.is_some() {
1635        crate::dry_run::report_action("link", src_path, Some(dst_path), "dir");
1636        let base = Summary {
1637            copy_summary: CopySummary {
1638                directories_created: 1, // report as would-be-created
1639                ..Default::default()
1640            },
1641            ..Default::default()
1642        };
1643        return link_dir_contents(
1644            prog_track,
1645            &src_dir,
1646            update_dir.as_ref(),
1647            None, // dry-run: no destination dir
1648            None, // dry-run: no destination parent
1649            dst_name,
1650            src_root,
1651            dst_root,
1652            update_root,
1653            rel_path,
1654            src_path,
1655            dst_path,
1656            true, // treat as "created" so empty-dir cleanup can suppress the dry-run count
1657            is_fresh,
1658            None, // dry-run: no destination dir is opened or locked, so nothing to restore
1659            settings,
1660            base,
1661            SourceEntries::Live,
1662            UpdateEntries::Live,
1663        )
1664        .await;
1665    }
1666    // real link: dst_parent is Some.
1667    let dst_parent = dst_parent.expect("destination parent must be open for a real link");
1668    let copy::DirSlot {
1669        dir: dst_dir,
1670        summary: base,
1671        is_fresh: child_is_fresh,
1672        we_created,
1673        reused_lock,
1674    } = match copy::resolve_dst_dir(
1675        prog_track,
1676        dst_parent,
1677        dst_name,
1678        dst_path,
1679        &settings.copy_settings,
1680    )
1681    .await
1682    .map_err(|err| {
1683        Error::new(
1684            err.source,
1685            Summary {
1686                copy_summary: err.summary,
1687                ..Default::default()
1688            },
1689        )
1690    })? {
1691        copy::DirResolution::Skip(summary) => {
1692            return Ok(Summary {
1693                copy_summary: summary,
1694                ..Default::default()
1695            });
1696        }
1697        copy::DirResolution::Proceed(slot) => slot,
1698    };
1699    link_dir_contents(
1700        prog_track,
1701        &src_dir,
1702        update_dir.as_ref(),
1703        Some(&dst_dir),
1704        Some(dst_parent),
1705        dst_name,
1706        src_root,
1707        dst_root,
1708        update_root,
1709        rel_path,
1710        src_path,
1711        dst_path,
1712        we_created,
1713        child_is_fresh,
1714        reused_lock,
1715        settings,
1716        Summary {
1717            copy_summary: base,
1718            ..Default::default()
1719        },
1720        SourceEntries::Live,
1721        UpdateEntries::Live,
1722    )
1723    .await
1724}
1725
1726/// Derive source-entry admission from its type hint and whether an update tree participates.
1727///
1728/// A source hint says nothing about a separate update entry, so a dual-tree decision always needs
1729/// admission before either entry is classified.
1730fn source_entry_admission(hint: Option<EntryKind>, has_update: bool) -> EntryAdmission {
1731    let admission = EntryAdmission::from_hint(hint);
1732    if has_update {
1733        // the source hint says nothing about the separate update entry, so the combined decision
1734        // must acquire before it is spawned or opens either classification handle.
1735        admission.require_admission()
1736    } else {
1737        admission
1738    }
1739}
1740
1741/// Whether a preliminary hint decision must defer to the exact worker outcome.
1742fn requires_exact_entry_outcome(settings: &Settings) -> bool {
1743    settings.copy_settings.delete.is_some() || settings.dry_run.is_some()
1744}
1745
1746/// Advisory source-loop decision made before the exact link worker runs.
1747enum SourceEntryDecision {
1748    /// The cheap filter decision is terminal in an ordinary real run.
1749    Filtered {
1750        kind: EntryKind,
1751        result: crate::filter::FilterResult,
1752    },
1753    /// The entry still needs its exact worker decision.
1754    Dispatch(EntryAdmission),
1755}
1756
1757/// Apply reliable source-hint exclusions and return the admission state to transfer into the
1758/// worker. An unknown hint always defers type-sensitive filtering and accounting to the exact
1759/// worker classification.
1760fn select_source_for_dispatch(
1761    hint: Option<EntryKind>,
1762    relative_path: &std::path::Path,
1763    settings: &Settings,
1764    has_update: bool,
1765) -> SourceEntryDecision {
1766    if let Some(kind) = hint
1767        && let Some(result) = walk::should_skip_entry_ref(
1768            settings.filter.as_ref(),
1769            relative_path,
1770            kind == EntryKind::Dir,
1771        )
1772        && !requires_exact_entry_outcome(settings)
1773    {
1774        return SourceEntryDecision::Filtered { kind, result };
1775    }
1776    SourceEntryDecision::Dispatch(source_entry_admission(hint, has_update))
1777}
1778
1779/// Terminal exact selection for an update-only directory entry.
1780enum UpdateOnlyDecision {
1781    /// The authoritative classification excludes this entry.
1782    Skipped {
1783        kind: EntryKind,
1784        result: crate::filter::FilterResult,
1785    },
1786    /// The exact classification and its admission are ready to transfer into copy.
1787    Admitted(AdmittedEntry),
1788}
1789
1790fn select_exact_update_only_entry(
1791    entry: AdmittedEntry,
1792    relative_path: &std::path::Path,
1793    filter: Option<&crate::filter::FilterSettings>,
1794) -> UpdateOnlyDecision {
1795    let kind = entry.kind();
1796    match walk::should_skip_entry_ref(filter, relative_path, kind == EntryKind::Dir) {
1797        Some(result) => UpdateOnlyDecision::Skipped { kind, result },
1798        None => UpdateOnlyDecision::Admitted(entry),
1799    }
1800}
1801
1802/// Advisory update-only decision made before the exact worker runs.
1803enum UpdateOnlyDispatch {
1804    /// A reliable hint excludes this entry in a non-observable ordinary run.
1805    Skipped {
1806        kind: EntryKind,
1807        result: crate::filter::FilterResult,
1808    },
1809    /// The entry still needs its exact worker decision.
1810    Dispatch(EntryAdmission),
1811}
1812
1813/// Apply the cheap reliable-hint exclusion fast path and derive worker admission.
1814fn select_update_only_for_dispatch(
1815    hint: Option<EntryKind>,
1816    relative_path: &std::path::Path,
1817    settings: &Settings,
1818) -> UpdateOnlyDispatch {
1819    if let Some(kind) = hint
1820        && let Some(result) = walk::should_skip_entry_ref(
1821            settings.filter.as_ref(),
1822            relative_path,
1823            kind == EntryKind::Dir,
1824        )
1825        && !requires_exact_entry_outcome(settings)
1826    {
1827        return UpdateOnlyDispatch::Skipped { kind, result };
1828    }
1829    UpdateOnlyDispatch::Dispatch(EntryAdmission::from_hint(hint))
1830}
1831
1832/// Classify one update-only entry inside its worker and retain the exact owner used for dispatch.
1833///
1834/// A positive directory hint gets one unadmitted classification. If it is stale and resolves to a
1835/// leaf, that handle is closed before waiting for admission and classifying again.
1836async fn select_exact_update_only_for_dispatch(
1837    update_dir: &Arc<Dir>,
1838    name: &std::ffi::OsStr,
1839    mut admission: EntryAdmission,
1840    relative_path: &std::path::Path,
1841    filter: Option<&crate::filter::FilterSettings>,
1842) -> std::io::Result<UpdateOnlyDecision> {
1843    let entry = if matches!(admission, EntryAdmission::HintedDirectory) {
1844        let handle = walk::classify_entry(update_dir, name).await?;
1845        if handle.kind() == EntryKind::Dir {
1846            AdmittedEntry::new(handle, None)
1847        } else {
1848            drop(handle);
1849            admission = EntryAdmission::RootOrDelegated;
1850            let admission = walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
1851            walk::classify_admitted_entry(update_dir, name, admission.into_permit()).await?
1852        }
1853    } else {
1854        let admission = walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
1855        walk::classify_admitted_entry(update_dir, name, admission.into_permit()).await?
1856    };
1857    Ok(select_exact_update_only_entry(entry, relative_path, filter))
1858}
1859
1860/// Process one scheduled update-only entry from exact classification through its terminal keep
1861/// decision. Every spawned child returns a [`LinkTaskResult`], including filter and special skips.
1862#[allow(clippy::too_many_arguments)]
1863async fn process_update_only_entry(
1864    prog_track: &'static progress::Progress,
1865    update_parent: Arc<Dir>,
1866    dst_parent: Option<Arc<Dir>>,
1867    entry_name: std::ffi::OsString,
1868    update_entry_path: std::path::PathBuf,
1869    dst_entry_path: std::path::PathBuf,
1870    entry_rel: std::path::PathBuf,
1871    delete_scan_anchor: copy::DeleteScanAnchor,
1872    settings: Settings,
1873    is_fresh: bool,
1874    admission: EntryAdmission,
1875) -> Result<LinkTaskResult, Error> {
1876    let decision = select_exact_update_only_for_dispatch(
1877        &update_parent,
1878        &entry_name,
1879        admission,
1880        &entry_rel,
1881        settings.filter.as_ref(),
1882    )
1883    .await
1884    .with_context(|| format!("failed reading metadata from {update_entry_path:?}"))
1885    .map_err(|err| Error::new(err, Default::default()))?;
1886    let entry = match decision {
1887        UpdateOnlyDecision::Skipped { kind, result } => {
1888            if let Some(mode) = settings.dry_run {
1889                crate::dry_run::report_skip(&update_entry_path, &result, mode, kind.label());
1890            }
1891            tracing::debug!(
1892                "skipping update entry {:?} due to filter",
1893                &update_entry_path
1894            );
1895            kind.inc_skipped(prog_track);
1896            return Ok(LinkTaskResult::from_update(
1897                entry_name,
1898                LinkEntryResult::filtered(skipped_summary_for(kind)),
1899            ));
1900        }
1901        UpdateOnlyDecision::Admitted(entry) => entry,
1902    };
1903    let entry_kind = entry.kind();
1904    if settings.copy_settings.skip_specials && entry_kind == EntryKind::Special {
1905        tracing::debug!("skipping special file {:?}", &update_entry_path);
1906        if let Some(mode) = settings.dry_run {
1907            match mode {
1908                crate::config::DryRunMode::Brief => {}
1909                crate::config::DryRunMode::All => println!("skip special {:?}", &update_entry_path),
1910                crate::config::DryRunMode::Explain => {
1911                    println!(
1912                        "skip special {:?} (unsupported file type: {:?})",
1913                        &update_entry_path, entry_kind
1914                    );
1915                }
1916            }
1917        }
1918        prog_track.specials_skipped.inc();
1919        return Ok(LinkTaskResult::from_update(
1920            entry_name,
1921            LinkEntryResult::selected(Summary {
1922                copy_summary: CopySummary {
1923                    specials_skipped: 1,
1924                    ..Default::default()
1925                },
1926                ..Default::default()
1927            }),
1928        ));
1929    }
1930    tracing::debug!("found a new entry in the 'update' directory");
1931    let summary = delegate_copy(
1932        prog_track,
1933        &update_parent,
1934        dst_parent.as_ref(),
1935        &entry_name,
1936        &update_entry_path,
1937        &dst_entry_path,
1938        &entry_rel,
1939        delete_scan_anchor,
1940        &settings,
1941        is_fresh,
1942        entry,
1943    )
1944    .await?;
1945    Ok(LinkTaskResult::from_update(
1946        entry_name,
1947        LinkEntryResult::selected(summary),
1948    ))
1949}
1950
1951fn fold_link_task_result(
1952    result: Result<Result<LinkTaskResult, Error>, tokio::task::JoinError>,
1953    link_summary: &mut Summary,
1954    keep_set: &mut DeleteKeepSet,
1955    errors: &crate::error_collector::ErrorCollector,
1956    fail_early: bool,
1957    src_path: &std::path::Path,
1958    dst_path: &std::path::Path,
1959) -> Result<(), anyhow::Error> {
1960    match result {
1961        Ok(Ok(result)) => result.fold(link_summary, keep_set),
1962        Ok(Err(error)) => {
1963            tracing::error!(
1964                "link: {:?} -> {:?} failed with: {:#}",
1965                src_path,
1966                dst_path,
1967                &error
1968            );
1969            *link_summary = *link_summary + error.summary;
1970            if fail_early {
1971                return Err(error.source);
1972            }
1973            errors.push(error.source);
1974        }
1975        Err(join_error) => {
1976            if fail_early {
1977                return Err(join_error.into());
1978            }
1979            errors.push(join_error.into());
1980        }
1981    }
1982    Ok(())
1983}
1984
1985/// Await link-entry admission while reaping ready children. A completed fail-early error wins a tie
1986/// with newly available capacity, so another permanently pending worker cannot consume that
1987/// capacity and strand the unread error. Both source and update-only producers use this boundary.
1988#[allow(clippy::too_many_arguments)]
1989async fn ensure_link_admission(
1990    admission: EntryAdmission,
1991    join_set: &mut tokio::task::JoinSet<Result<LinkTaskResult, Error>>,
1992    link_summary: &mut Summary,
1993    keep_set: &mut DeleteKeepSet,
1994    errors: &crate::error_collector::ErrorCollector,
1995    fail_early: bool,
1996    src_path: &std::path::Path,
1997    dst_path: &std::path::Path,
1998) -> Result<EntryAdmission, anyhow::Error> {
1999    let admission = walk::ensure_entry_admission(PermitKind::OpenFile, admission);
2000    tokio::pin!(admission);
2001    loop {
2002        let has_tasks = !join_set.is_empty();
2003        tokio::select! {
2004            biased;
2005            result = join_set.join_next(), if has_tasks => {
2006                fold_link_task_result(
2007                    result.expect("a non-empty link JoinSet must yield one task result"),
2008                    link_summary,
2009                    keep_set,
2010                    errors,
2011                    fail_early,
2012                    src_path,
2013                    dst_path,
2014                )?;
2015            }
2016            admission = &mut admission => return Ok(admission),
2017        }
2018    }
2019}
2020
2021enum SourceEntries {
2022    Live,
2023    #[cfg(test)]
2024    Injected(Vec<(std::ffi::OsString, Option<EntryKind>)>),
2025}
2026
2027enum UpdateEntries {
2028    Live,
2029    #[cfg(test)]
2030    Injected(Vec<(std::ffi::OsString, Option<EntryKind>)>),
2031}
2032
2033/// The dual-tree body of a directory link: enumerate the source entries (hard-linking unchanged
2034/// files, delegating copies, recursing into subdirectories), then enumerate the update entries and
2035/// copy those not present in the source, then run `--delete` pruning, empty-directory cleanup, and
2036/// finally apply the directory's own metadata.
2037///
2038/// `dst_dir == None` / `dst_parent == None` means dry-run (no destination mutation). `base` carries
2039/// the `directories_created`/`directories_unchanged` contribution from resolving this directory.
2040#[allow(clippy::too_many_arguments)]
2041async fn link_dir_contents(
2042    prog_track: &'static progress::Progress,
2043    src_dir: &Arc<Dir>,
2044    update_dir: Option<&Arc<Dir>>,
2045    dst_dir: Option<&Arc<Dir>>,
2046    dst_parent: Option<&Arc<Dir>>,
2047    dst_name: &std::ffi::OsStr,
2048    src_root: &std::path::Path,
2049    dst_root: &std::path::Path,
2050    update_root: Option<&std::path::Path>,
2051    rel_path: &std::path::Path,
2052    src_path: &std::path::Path,
2053    dst_path: &std::path::Path,
2054    we_created_this_dir: bool,
2055    is_fresh: bool,
2056    reused_lock: Option<crate::safedir::ReusedDirLock>,
2057    settings: &Settings,
2058    base: Summary,
2059    source_entries: SourceEntries,
2060    update_entries: UpdateEntries,
2061) -> Result<Summary, Error> {
2062    tracing::debug!("process contents of 'src' directory");
2063    let src_entries = match source_entries {
2064        SourceEntries::Live => src_dir
2065            .read_entries()
2066            .await
2067            .with_context(|| format!("cannot open directory {src_path:?} for reading"))
2068            .map_err(|err| Error::new(err, base))?,
2069        #[cfg(test)]
2070        SourceEntries::Injected(entries) => entries,
2071    };
2072    let mut link_summary = base;
2073    let mut join_set = tokio::task::JoinSet::new();
2074    let errors = crate::error_collector::ErrorCollector::default();
2075    // source names suppress only duplicate update-only scheduling; worker results own --delete keep
2076    // protection after their exact final selection.
2077    let mut processed_files = std::collections::HashSet::new();
2078    // keep-set for --delete: every spawned source/update worker folds its final exact outcome.
2079    let mut keep_set = DeleteKeepSet::new(settings.copy_settings.delete.as_ref());
2080    // iterate through src entries and recursively call "link" on each one
2081    for (entry_name, hint) in src_entries {
2082        let entry_rel = rel_path.join(&entry_name);
2083        let entry_path = src_path.join(&entry_name);
2084        let decision = select_source_for_dispatch(hint, &entry_rel, settings, update_dir.is_some());
2085        let admission = match decision {
2086            SourceEntryDecision::Filtered { kind, result } => {
2087                if let Some(mode) = settings.dry_run {
2088                    crate::dry_run::report_skip(&entry_path, &result, mode, kind.label());
2089                }
2090                tracing::debug!("skipping {:?} due to filter", &entry_path);
2091                link_summary = link_summary + skipped_summary_for(kind);
2092                kind.inc_skipped(prog_track);
2093                continue;
2094            }
2095            SourceEntryDecision::Dispatch(admission) => admission,
2096        };
2097        processed_files.insert(entry_name.clone());
2098        let admission = match ensure_link_admission(
2099            admission,
2100            &mut join_set,
2101            &mut link_summary,
2102            &mut keep_set,
2103            &errors,
2104            settings.copy_settings.fail_early,
2105            src_path,
2106            dst_path,
2107        )
2108        .await
2109        {
2110            Ok(admission) => admission,
2111            Err(error) => {
2112                crate::walk_driver::abort_and_join(&mut join_set).await;
2113                return Err(Error::new(error, link_summary));
2114            }
2115        };
2116        // acquire-then-IMMEDIATELY-spawn (the permit is moved into `do_link` and spawned on the next
2117        // line, in the same loop step) is load-bearing: collecting a Vec of pre-acquired permits and
2118        // spawning later would hold N permits before any task runs and self-deadlock a saturated pool.
2119        // this mirrors the shared driver's incremental acquire-then-spawn loop; both reap ready
2120        // workers into their exact-result folds while admission waits.
2121        let src_parent = Arc::clone(src_dir);
2122        let dst_parent = dst_dir.map(Arc::clone);
2123        let update_parent = update_dir.map(Arc::clone);
2124        let settings = settings.clone();
2125        let src_root = src_root.to_owned();
2126        let dst_root = dst_root.to_owned();
2127        let update_root = update_root.map(std::path::Path::to_path_buf);
2128        let do_link = move || async move {
2129            let update_ref = update_parent
2130                .as_ref()
2131                .map(|dir| (dir, entry_name.as_os_str()));
2132            link_internal(
2133                prog_track,
2134                &src_parent,
2135                update_ref,
2136                dst_parent.as_ref(),
2137                &entry_name,
2138                &src_root,
2139                &dst_root,
2140                update_root.as_deref(),
2141                &entry_rel,
2142                UpdateRootRequirement::Optional,
2143                &settings,
2144                is_fresh,
2145                admission.into(),
2146            )
2147            .await
2148            .map(|result| LinkTaskResult::from_link(entry_name, result))
2149        };
2150        crate::walk_driver::spawn_tracked(&mut join_set, do_link());
2151    }
2152    // only process update if the path was provided and the directory is present
2153    if let Some(update_dir) = update_dir {
2154        let update_root = update_root.expect("update_dir present implies update_root present");
2155        tracing::debug!("process contents of 'update' directory");
2156        let update_entries = match update_entries {
2157            UpdateEntries::Live => update_dir
2158                .read_entries()
2159                .await
2160                .with_context(|| {
2161                    format!(
2162                        "cannot open directory {:?} for reading",
2163                        update_path_dbg(update_root, rel_path)
2164                    )
2165                })
2166                .map_err(|err| Error::new(err, link_summary))?,
2167            #[cfg(test)]
2168            UpdateEntries::Injected(entries) => entries,
2169        };
2170        // iterate through update entries and copy names absent from src. reliable excluded hints
2171        // keep their cheap skip in ordinary real runs; delete and preview runs reclassify before an
2172        // observable decision. every entry that can reach copy transfers its exact handle and
2173        // admission.
2174        for (entry_name, hint) in update_entries {
2175            let entry_rel = rel_path.join(&entry_name);
2176            let update_entry_path = update_root.join(&entry_rel);
2177            if processed_files.contains(&entry_name) {
2178                // the source worker owns the exact joint source/update selection and reports its
2179                // destination-protection decision when it completes.
2180                continue;
2181            }
2182            let admission = match select_update_only_for_dispatch(hint, &entry_rel, settings) {
2183                UpdateOnlyDispatch::Skipped { kind, result } => {
2184                    if let Some(mode) = settings.dry_run {
2185                        crate::dry_run::report_skip(
2186                            &update_entry_path,
2187                            &result,
2188                            mode,
2189                            kind.label(),
2190                        );
2191                    }
2192                    tracing::debug!(
2193                        "skipping update entry {:?} due to filter",
2194                        &update_entry_path
2195                    );
2196                    link_summary = link_summary + skipped_summary_for(kind);
2197                    kind.inc_skipped(prog_track);
2198                    continue;
2199                }
2200                UpdateOnlyDispatch::Dispatch(admission) => admission,
2201            };
2202            let admission = match ensure_link_admission(
2203                admission,
2204                &mut join_set,
2205                &mut link_summary,
2206                &mut keep_set,
2207                &errors,
2208                settings.copy_settings.fail_early,
2209                src_path,
2210                dst_path,
2211            )
2212            .await
2213            {
2214                Ok(admission) => admission,
2215                Err(error) => {
2216                    crate::walk_driver::abort_and_join(&mut join_set).await;
2217                    return Err(Error::new(error, link_summary));
2218                }
2219            };
2220            // acquire-then-IMMEDIATELY-spawn is load-bearing here for the same reason as the source
2221            // loop: the worker owns authoritative classification and must be able to release the
2222            // permit before scheduling waits for another one
2223            let dst_entry_path = dst_path.join(&entry_name);
2224            let update_parent = Arc::clone(update_dir);
2225            let dst_parent = dst_dir.map(Arc::clone);
2226            let settings = settings.clone();
2227            let delete_scan_anchor = copy::DeleteScanAnchor::new(dst_root, &entry_rel);
2228            let do_copy = move || {
2229                // filter-base for the delegated copy: this update entry's path relative to the
2230                // source root, so any --delete pruning inside it matches the include/exclude filter
2231                // at the entry's true relative path (e.g. cache/*.log), not relative to the entry.
2232                process_update_only_entry(
2233                    prog_track,
2234                    update_parent,
2235                    dst_parent,
2236                    entry_name,
2237                    update_entry_path,
2238                    dst_entry_path,
2239                    entry_rel,
2240                    delete_scan_anchor,
2241                    settings,
2242                    is_fresh,
2243                    admission,
2244                )
2245            };
2246            crate::walk_driver::spawn_tracked(&mut join_set, do_copy());
2247        }
2248    }
2249    while let Some(res) = join_set.join_next().await {
2250        if let Err(error) = fold_link_task_result(
2251            res,
2252            &mut link_summary,
2253            &mut keep_set,
2254            &errors,
2255            settings.copy_settings.fail_early,
2256            src_path,
2257            dst_path,
2258        ) {
2259            crate::walk_driver::abort_and_join(&mut join_set).await;
2260            return Err(Error::new(error, link_summary));
2261        }
2262    }
2263    // rsync-style --delete for rlink: remove destination entries the final exact decisions did not
2264    // protect. `keep_set` holds the selected src ∪ update names normally, or only selected update
2265    // names under --update-exclusive; deliberately skipped specials retain their historical
2266    // protection even though no new entry is materialized.
2267    if let Some(delete_settings) = &settings.copy_settings.delete {
2268        if errors.has_errors() {
2269            // rsync-style safety: skip pruning when this subtree's link/update pass reported errors
2270            // — deleting based on a run that did not fully succeed could remove data unexpectedly.
2271            tracing::warn!(
2272                "skipping --delete pruning of {:?} because the link/update pass reported errors",
2273                dst_path
2274            );
2275        } else {
2276            // a real link already holds the destination directory. Dry-run descends from the
2277            // original named operand one `O_NOFOLLOW` component at a time, so the reconstructed
2278            // `dst_path` remains diagnostics-only and a symlinked operand root cannot become an
2279            // intermediate redirect into another tree.
2280            let prune_dir: Option<Arc<Dir>> = match dst_dir {
2281                Some(dir) => Some(Arc::clone(dir)),
2282                None => match crate::safedir::open_existing_dir_beneath_operand(
2283                    dst_root,
2284                    rel_path,
2285                    congestion::Side::Destination,
2286                )
2287                .await
2288                {
2289                    Ok(Some(dir)) => Some(Arc::new(dir)),
2290                    Ok(None) => {
2291                        tracing::debug!(
2292                            "skipping --delete pruning of {:?}: not a real directory",
2293                            dst_path
2294                        );
2295                        None
2296                    }
2297                    Err(err) => {
2298                        let err = anyhow::Error::new(err).context(format!(
2299                            "cannot open destination {dst_path:?} for delete scan"
2300                        ));
2301                        if settings.copy_settings.fail_early {
2302                            return Err(Error::new(err, link_summary));
2303                        }
2304                        errors.push(err);
2305                        None
2306                    }
2307                },
2308            };
2309            if let Some(prune_dir) = prune_dir {
2310                match crate::delete::prune_extraneous(
2311                    prog_track,
2312                    &prune_dir,
2313                    rel_path,
2314                    keep_set
2315                        .as_set()
2316                        .expect("--delete is on, so DeleteKeepSet is active"),
2317                    settings.filter.as_ref(),
2318                    delete_settings,
2319                    settings.copy_settings.fail_early,
2320                    settings.dry_run,
2321                )
2322                .await
2323                {
2324                    Ok(rm_summary) => {
2325                        link_summary.copy_summary.rm_summary =
2326                            link_summary.copy_summary.rm_summary + rm_summary;
2327                    }
2328                    Err(err) => {
2329                        link_summary.copy_summary.rm_summary =
2330                            link_summary.copy_summary.rm_summary + err.summary;
2331                        if settings.copy_settings.fail_early {
2332                            return Err(Error::new(err.source, link_summary));
2333                        }
2334                        errors.push(err.source);
2335                    }
2336                }
2337            }
2338        }
2339    }
2340    // when filtering is active and we created this directory, check if anything was actually
2341    // linked/copied into it. if nothing was linked, we may need to clean up the empty directory.
2342    let this_dir_count = usize::from(we_created_this_dir);
2343    let child_dirs_created = link_summary
2344        .copy_summary
2345        .directories_created
2346        .saturating_sub(this_dir_count);
2347    let anything_linked = link_summary.hard_links_created > 0
2348        || link_summary.copy_summary.files_copied > 0
2349        || link_summary.copy_summary.symlinks_created > 0
2350        || child_dirs_created > 0;
2351    let is_root = rel_path.as_os_str().is_empty();
2352    match check_empty_dir_cleanup(
2353        settings.filter.as_ref(),
2354        we_created_this_dir,
2355        anything_linked,
2356        rel_path,
2357        is_root,
2358        settings.dry_run.is_some(),
2359    ) {
2360        EmptyDirAction::Keep => { /* proceed with metadata application */ }
2361        EmptyDirAction::DryRunSkip => {
2362            tracing::debug!(
2363                "dry-run: directory {:?} would not be created (nothing to link inside)",
2364                dst_path
2365            );
2366            link_summary.copy_summary.directories_created = 0;
2367            // a child error collected during the walk must still surface — otherwise a
2368            // traversal-only directory whose only child FAILED becomes "empty", is skipped here, and
2369            // the failed link is reported as success (mirrors copy::finalize_dir).
2370            if errors.has_errors() {
2371                return Err(Error::new(errors.into_error().unwrap(), link_summary));
2372            }
2373            return Ok(link_summary);
2374        }
2375        EmptyDirAction::Remove => {
2376            tracing::debug!(
2377                "directory {:?} has nothing to link inside, removing empty directory",
2378                dst_path
2379            );
2380            // remove the empty directory fd-relative, through its parent dir handle: `rmdir_at`
2381            // operates on `dst_name` within the held `dst_parent` fd (never by path) and only
2382            // succeeds on an empty directory, so it is contained to `dst_parent`. `dst_parent` is
2383            // always Some here (None only in dry-run, where this arm is unreachable).
2384            let rmdir_result = match dst_parent {
2385                Some(dst_parent) => dst_parent.rmdir_at(dst_name).await,
2386                None => {
2387                    crate::walk::run_metadata_probed(
2388                        congestion::Side::Destination,
2389                        congestion::MetadataOp::RmDir,
2390                        tokio::fs::remove_dir(dst_path),
2391                    )
2392                    .await
2393                }
2394            };
2395            match rmdir_result {
2396                Ok(()) => {
2397                    link_summary.copy_summary.directories_created = 0;
2398                    // surface a collected child error even though the empty directory was removed,
2399                    // so a failed child link is never reported as success (mirrors
2400                    // copy::finalize_dir).
2401                    if errors.has_errors() {
2402                        return Err(Error::new(errors.into_error().unwrap(), link_summary));
2403                    }
2404                    return Ok(link_summary);
2405                }
2406                Err(err) => {
2407                    // removal failed (not empty, permission error, etc.) — keep directory
2408                    tracing::debug!(
2409                        "failed to remove empty directory {:?}: {:#}, keeping",
2410                        dst_path,
2411                        &err
2412                    );
2413                    // fall through to apply metadata
2414                }
2415            }
2416        }
2417    }
2418    // apply directory metadata regardless of whether all children linked successfully. the
2419    // directory itself was created/opened above. skipped in dry-run (no directory exists). prefer
2420    // the update directory's metadata when an update tree is present at this level (it is the
2421    // materialized version, matching the old `update_metadata_opt` preference), else the source
2422    // directory's. The metadata is read from the SAME fd whose contents were enumerated (read-side
2423    // fidelity, docs/tocttou.md), not the classify handles.
2424    tracing::debug!("set 'dst' directory metadata");
2425    let metadata_result = match dst_dir {
2426        Some(dst_dir) => {
2427            let meta_dir = update_dir.unwrap_or(src_dir);
2428            // for a reused directory locked down under strict mode, put back the ACLs the
2429            // lockdown stripped and restore the original owner component-wise, then apply source
2430            // metadata (see set_reused_dir_metadata_fd — no transient window hands the directory to
2431            // a hostile prior owner); None for fresh dirs.
2432            async {
2433                let preserve_meta = meta_dir.meta().await?;
2434                // the ACLs come from the same fd as the metadata, and only when `d:acl` was asked
2435                // for — rlink creates its directories fresh, so unlike files there is no shared
2436                // inode and applying them is ordinary destination work.
2437                let preserve_acls = if settings.preserve.dir.acl {
2438                    Some(meta_dir.read_acls().await?)
2439                } else {
2440                    None
2441                };
2442                crate::safedir::set_reused_dir_metadata_fd(
2443                    &settings.preserve,
2444                    &preserve_meta,
2445                    preserve_acls.as_ref(),
2446                    reused_lock,
2447                    dst_dir,
2448                )
2449                .await
2450            }
2451            .await
2452        }
2453        None => Ok(()),
2454    };
2455    if errors.has_errors() {
2456        // child failures take precedence - log metadata error if it also failed
2457        if let Err(metadata_err) = metadata_result {
2458            tracing::error!(
2459                "link: {:?} -> {:?} failed to set directory metadata: {:#}",
2460                src_path,
2461                dst_path,
2462                &metadata_err
2463            );
2464        }
2465        // unwrap is safe: has_errors() guarantees into_error() returns Some
2466        return Err(Error::new(errors.into_error().unwrap(), link_summary));
2467    }
2468    // no child failures, so metadata error is the primary error
2469    metadata_result
2470        .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
2471        .map_err(|err| Error::new(err, link_summary))?;
2472    Ok(link_summary)
2473}
2474
2475/// Reconstruct an update entry's path purely for a diagnostic message.
2476fn update_path_dbg(
2477    update_root: &std::path::Path,
2478    rel_path: &std::path::Path,
2479) -> std::path::PathBuf {
2480    if rel_path.as_os_str().is_empty() {
2481        update_root.to_path_buf()
2482    } else {
2483        update_root.join(rel_path)
2484    }
2485}
2486
2487#[cfg(test)]
2488mod link_tests {
2489    use crate::rm;
2490    use crate::testutils;
2491    use std::os::unix::fs::PermissionsExt;
2492    use tracing_test::traced_test;
2493
2494    use super::*;
2495
2496    static PROGRESS: std::sync::LazyLock<progress::Progress> =
2497        std::sync::LazyLock::new(progress::Progress::new);
2498
2499    #[test]
2500    fn directory_close_primitive_drops_both_handles_before_permit() {
2501        let events = testutils::DropEvents::default();
2502        drop_link_handles_before_permit(
2503            events.probe("source handle"),
2504            Some(events.probe("update handle")),
2505            Some(events.probe("permit")),
2506        );
2507        assert_eq!(
2508            events.snapshot(),
2509            ["source handle", "update handle", "permit"]
2510        );
2511    }
2512
2513    #[test]
2514    fn admitted_link_entry_drops_both_handles_before_permit() {
2515        let events = testutils::DropEvents::default();
2516        let entry = AdmittedLinkEntry {
2517            src_handle: events.probe("source handle"),
2518            update_handle: Some(events.probe("update handle")),
2519            permit: Some(events.probe("permit")),
2520        };
2521        drop(entry);
2522        assert_eq!(
2523            events.snapshot(),
2524            ["source handle", "update handle", "permit"]
2525        );
2526    }
2527
2528    mod delete_keep_set_tests {
2529        //! Pure-logic unit tests for `DeleteKeepSet`. No filesystem needed — these pin exact
2530        //! worker ownership so a future refactor cannot silently restore hint-based keeps.
2531
2532        use super::super::DeleteKeepSet;
2533        use crate::copy::DeleteSettings;
2534        use std::ffi::{OsStr, OsString};
2535
2536        fn delete_on() -> DeleteSettings {
2537            DeleteSettings {
2538                delete_excluded: false,
2539            }
2540        }
2541
2542        #[test]
2543        fn exact_result_no_op_when_delete_off() {
2544            let mut k = DeleteKeepSet::new(None);
2545            k.record_exact(OsString::from("foo"));
2546            assert!(k.as_set().is_none());
2547        }
2548
2549        #[test]
2550        fn exact_materialized_update_exclusive_duplicate_is_retained() {
2551            let d = delete_on();
2552            let mut k = DeleteKeepSet::new(Some(&d));
2553            let mut summary = super::super::Summary::default();
2554            super::super::LinkTaskResult::from_link(
2555                OsString::from("node"),
2556                super::super::LinkEntryResult::selected(Default::default()),
2557            )
2558            .fold(&mut summary, &mut k);
2559            assert!(
2560                k.as_set().unwrap().contains(OsStr::new("node")),
2561                "the exact worker's materialized update selection must own the keep decision"
2562            );
2563        }
2564
2565        #[test]
2566        fn selected_source_entry_is_retained_in_normal_mode() {
2567            let d = delete_on();
2568            let mut k = DeleteKeepSet::new(Some(&d));
2569            let mut summary = super::super::Summary::default();
2570            super::super::LinkTaskResult::from_link(
2571                OsString::from("node"),
2572                super::super::LinkEntryResult::selected(Default::default()),
2573            )
2574            .fold(&mut summary, &mut k);
2575            assert!(
2576                k.as_set().unwrap().contains(OsStr::new("node")),
2577                "the exact worker's selected source outcome must protect its destination"
2578            );
2579        }
2580
2581        #[test]
2582        fn full_directory_pass_keep_set_union_semantics() {
2583            // Models the union of src + update under plain `--delete --update` (no
2584            // --update-exclusive). Names: src has `keep`, `pipe` (special, skipped),
2585            // `node` (file). update has `from_upd`, `node` (a dir excluded by the dir-only
2586            // `node/` pattern). Under union semantics the excluded update `node/` does not
2587            // displace the src `node` file: `link_internal` materializes the src version, so
2588            // `node` STAYS in the keep-set (the filtered-out update branch records/removes
2589            // nothing). This is the corrected behavior versus the old type-mismatch bug, where
2590            // the excluded update dir was copied AND the src keep-set entry was dropped.
2591            let d = delete_on();
2592            let mut k = DeleteKeepSet::new(Some(&d));
2593
2594            let mut summary = super::super::Summary::default();
2595            for name in ["keep", "pipe", "node"] {
2596                super::super::LinkTaskResult::from_link(
2597                    OsString::from(name),
2598                    super::super::LinkEntryResult::selected(Default::default()),
2599                )
2600                .fold(&mut summary, &mut k);
2601            }
2602            super::super::LinkTaskResult::from_update(
2603                OsString::from("from_upd"),
2604                super::super::LinkEntryResult::selected(Default::default()),
2605            )
2606            .fold(&mut summary, &mut k);
2607
2608            let set: std::collections::HashSet<OsString> = k.as_set().unwrap().clone();
2609            let expected: std::collections::HashSet<OsString> =
2610                ["keep", "pipe", "node", "from_upd"]
2611                    .into_iter()
2612                    .map(OsString::from)
2613                    .collect();
2614            assert_eq!(set, expected);
2615        }
2616    }
2617
2618    fn common_settings(dereference: bool, overwrite: bool) -> Settings {
2619        Settings {
2620            copy_settings: CopySettings {
2621                dereference,
2622                fail_early: false,
2623                overwrite,
2624                overwrite_compare: filecmp::MetadataCmpSettings {
2625                    size: true,
2626                    mtime: true,
2627                    ..Default::default()
2628                },
2629                overwrite_filter: None,
2630                ignore_existing: false,
2631                chunk_size: 0,
2632                skip_specials: false,
2633                remote_copy_buffer_size: 0,
2634                filter: None,
2635                dry_run: None,
2636                delete: None,
2637            },
2638            update_compare: filecmp::MetadataCmpSettings {
2639                size: true,
2640                mtime: true,
2641                ..Default::default()
2642            },
2643            update_exclusive: false,
2644            filter: None,
2645            dry_run: None,
2646            preserve: preserve::preserve_all(),
2647        }
2648    }
2649
2650    struct RestoreRenamedUpdateRoot {
2651        original: std::path::PathBuf,
2652        renamed: std::path::PathBuf,
2653    }
2654
2655    impl Drop for RestoreRenamedUpdateRoot {
2656        fn drop(&mut self) {
2657            if self.renamed.exists() {
2658                let _ = std::fs::rename(&self.renamed, &self.original);
2659            }
2660        }
2661    }
2662
2663    struct DisappearingUpdateRootHook {
2664        _registration: AfterUpdatePrecheckHookRegistration,
2665        _cleanup: std::sync::Arc<std::sync::Mutex<Option<RestoreRenamedUpdateRoot>>>,
2666    }
2667
2668    fn disappear_update_root_after_precheck(
2669        update: &std::path::Path,
2670    ) -> DisappearingUpdateRootHook {
2671        let original = update.to_owned();
2672        let renamed = update.with_extension("removed-after-precheck");
2673        let cleanup = std::sync::Arc::new(std::sync::Mutex::new(None));
2674        let hook_cleanup = Arc::clone(&cleanup);
2675        let registration = install_after_update_precheck_hook(
2676            original.clone(),
2677            Box::new(move || {
2678                std::fs::rename(&original, &renamed)
2679                    .expect("rename update root after its early precheck");
2680                *hook_cleanup
2681                    .lock()
2682                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
2683                    Some(RestoreRenamedUpdateRoot { original, renamed });
2684            }),
2685        );
2686        DisappearingUpdateRootHook {
2687            _registration: registration,
2688            _cleanup: cleanup,
2689        }
2690    }
2691
2692    async fn setup_disappearing_update_root_case() -> Result<
2693        (
2694            std::path::PathBuf,
2695            std::path::PathBuf,
2696            std::path::PathBuf,
2697            std::path::PathBuf,
2698        ),
2699        anyhow::Error,
2700    > {
2701        let test_path = testutils::create_temp_dir().await?;
2702        let src = test_path.join("src");
2703        let update = test_path.join("update");
2704        let dst = test_path.join("dst");
2705        tokio::fs::create_dir(&src).await?;
2706        tokio::fs::create_dir(&update).await?;
2707        tokio::fs::create_dir(&dst).await?;
2708        tokio::fs::write(src.join("source-only.txt"), "SOURCE").await?;
2709        tokio::fs::write(update.join("protected.txt"), "UPDATE").await?;
2710        tokio::fs::write(dst.join("source-only.txt"), "DESTINATION").await?;
2711        tokio::fs::write(dst.join("protected.txt"), "PROTECTED DESTINATION").await?;
2712        Ok((test_path, src, update, dst))
2713    }
2714
2715    fn assert_missing_destructive_update_error(error: &Error) {
2716        let message = format!("{:#}", error.source);
2717        assert!(
2718            message.contains("--update path") && message.contains("does not exist"),
2719            "missing destructive update error was unclear: {message}"
2720        );
2721    }
2722
2723    #[tokio::test]
2724    async fn update_exclusive_errors_if_update_root_disappears_after_precheck()
2725    -> Result<(), anyhow::Error> {
2726        let (test_path, src, update, dst) = setup_disappearing_update_root_case().await?;
2727        let _hook = disappear_update_root_after_precheck(&update);
2728        let mut settings = common_settings(false, true);
2729        settings.update_exclusive = true;
2730        let result = link(
2731            &PROGRESS,
2732            &test_path,
2733            &src,
2734            &dst,
2735            &Some(update.clone()),
2736            &settings,
2737            false,
2738        )
2739        .await;
2740        assert_eq!(
2741            tokio::fs::read_to_string(dst.join("source-only.txt")).await?,
2742            "DESTINATION"
2743        );
2744        assert_eq!(
2745            tokio::fs::read_to_string(dst.join("protected.txt")).await?,
2746            "PROTECTED DESTINATION"
2747        );
2748        let error = result.expect_err("a vanished update root must fail update-exclusive");
2749        assert_missing_destructive_update_error(&error);
2750        Ok(())
2751    }
2752
2753    #[tokio::test]
2754    async fn delete_update_does_not_prune_if_update_root_disappears_after_precheck()
2755    -> Result<(), anyhow::Error> {
2756        let (test_path, src, update, dst) = setup_disappearing_update_root_case().await?;
2757        let _hook = disappear_update_root_after_precheck(&update);
2758        let mut settings = common_settings(false, true);
2759        settings.copy_settings.delete = Some(copy::DeleteSettings {
2760            delete_excluded: false,
2761        });
2762        settings.filter = Some(crate::filter::FilterSettings::new());
2763        let result = link(
2764            &PROGRESS,
2765            &test_path,
2766            &src,
2767            &dst,
2768            &Some(update.clone()),
2769            &settings,
2770            false,
2771        )
2772        .await;
2773        assert_eq!(
2774            tokio::fs::read_to_string(dst.join("source-only.txt")).await?,
2775            "DESTINATION"
2776        );
2777        assert_eq!(
2778            tokio::fs::read_to_string(dst.join("protected.txt")).await?,
2779            "PROTECTED DESTINATION"
2780        );
2781        let error = result.expect_err("a vanished update root must stop delete pruning");
2782        assert_missing_destructive_update_error(&error);
2783        Ok(())
2784    }
2785
2786    #[tokio::test]
2787    async fn plain_update_falls_back_if_update_root_disappears_after_precheck()
2788    -> Result<(), anyhow::Error> {
2789        let (test_path, src, update, dst) = setup_disappearing_update_root_case().await?;
2790        let _hook = disappear_update_root_after_precheck(&update);
2791        link(
2792            &PROGRESS,
2793            &test_path,
2794            &src,
2795            &dst,
2796            &Some(update),
2797            &common_settings(false, true),
2798            false,
2799        )
2800        .await?;
2801        assert_eq!(
2802            tokio::fs::read_to_string(dst.join("source-only.txt")).await?,
2803            "SOURCE"
2804        );
2805        assert_eq!(
2806            tokio::fs::read_to_string(dst.join("protected.txt")).await?,
2807            "PROTECTED DESTINATION"
2808        );
2809        Ok(())
2810    }
2811
2812    #[tokio::test]
2813    async fn update_exclusive_without_update_links_the_source_normally() -> Result<(), anyhow::Error>
2814    {
2815        use std::os::unix::fs::MetadataExt;
2816        let test_path = testutils::create_temp_dir().await?;
2817        let src = test_path.join("src");
2818        let dst = test_path.join("dst");
2819        tokio::fs::write(&src, "SOURCE").await?;
2820        let mut settings = common_settings(false, false);
2821        settings.update_exclusive = true;
2822        let summary = link(&PROGRESS, &test_path, &src, &dst, &None, &settings, false).await?;
2823        assert_eq!(summary.hard_links_created, 1);
2824        assert_eq!(tokio::fs::read_to_string(&dst).await?, "SOURCE");
2825        let src_metadata = tokio::fs::metadata(&src).await?;
2826        let dst_metadata = tokio::fs::metadata(&dst).await?;
2827        assert_eq!(
2828            (src_metadata.dev(), src_metadata.ino()),
2829            (dst_metadata.dev(), dst_metadata.ino())
2830        );
2831        Ok(())
2832    }
2833
2834    #[tokio::test]
2835    #[traced_test]
2836    async fn test_basic_link() -> Result<(), anyhow::Error> {
2837        let tmp_dir = testutils::setup_test_dir().await?;
2838        let test_path = tmp_dir.as_path();
2839        let summary = link(
2840            &PROGRESS,
2841            test_path,
2842            &test_path.join("foo"),
2843            &test_path.join("bar"),
2844            &None,
2845            &common_settings(false, false),
2846            false,
2847        )
2848        .await?;
2849        assert_eq!(summary.hard_links_created, 5);
2850        assert_eq!(summary.copy_summary.files_copied, 0);
2851        assert_eq!(summary.copy_summary.symlinks_created, 2);
2852        assert_eq!(summary.copy_summary.directories_created, 3);
2853        testutils::check_dirs_identical(
2854            &test_path.join("foo"),
2855            &test_path.join("bar"),
2856            testutils::FileEqualityCheck::Timestamp,
2857        )
2858        .await?;
2859        Ok(())
2860    }
2861
2862    // regression: a source operand whose final component is `.`/`..` (e.g. `rlink tree/.. dst`)
2863    // must be linked, not rejected — `split_root_operand` canonicalizes it. Uses `tree/sub/..`
2864    // (== `tree`) rather than `.` to avoid touching the process-wide cwd.
2865    #[tokio::test]
2866    async fn links_dot_dot_source_operand() -> Result<(), anyhow::Error> {
2867        use std::os::unix::fs::MetadataExt;
2868        let tmp = testutils::create_temp_dir().await?;
2869        let tree = tmp.join("tree");
2870        tokio::fs::create_dir(&tree).await?;
2871        tokio::fs::write(tree.join("a.txt"), "hello").await?;
2872        tokio::fs::create_dir(tree.join("sub")).await?;
2873        let src = tree.join("sub").join(".."); // == tree
2874        let dst = tmp.join("dst");
2875        let summary = link(
2876            &PROGRESS,
2877            &tmp,
2878            &src,
2879            &dst,
2880            &None,
2881            &common_settings(false, false),
2882            false,
2883        )
2884        .await?;
2885        assert_eq!(
2886            summary.hard_links_created, 1,
2887            "the dot-dot source's file must be hard-linked"
2888        );
2889        assert!(
2890            dst.join("sub").is_dir(),
2891            "the dot-dot source's subdir must be created"
2892        );
2893        // the dst file shares the src inode (a hard link, not a copy).
2894        let src_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
2895        let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
2896        assert_eq!(src_ino, dst_ino, "dst must be a hard link to the src inode");
2897        Ok(())
2898    }
2899
2900    // Regression: an `--update` operand whose final component is `.`/`..` (e.g.
2901    // `rlink --update tree/.. src dst`) must be accepted, not rejected — the update tree is a READ
2902    // tree, so `split_root_operand` canonicalizes it the same as the source. Uses `tree/sub/..`
2903    // (== `tree`) rather than `.` to avoid touching the process-wide cwd; src == update == tree so
2904    // the file links deterministically from the update tree.
2905    #[tokio::test]
2906    async fn links_dot_dot_update_operand() -> Result<(), anyhow::Error> {
2907        use std::os::unix::fs::MetadataExt;
2908        let tmp = testutils::create_temp_dir().await?;
2909        let tree = tmp.join("tree");
2910        tokio::fs::create_dir(&tree).await?;
2911        tokio::fs::write(tree.join("a.txt"), "hello").await?;
2912        tokio::fs::create_dir(tree.join("sub")).await?;
2913        let dst = tmp.join("dst");
2914        // the --update operand spelled with a trailing `..` (== tree); it must be canonicalized and
2915        // used, not rejected with "has no parent directory or file name".
2916        let update_operand = tree.join("sub").join(".."); // == tree
2917        let summary = link(
2918            &PROGRESS,
2919            &tmp,
2920            &tree,
2921            &dst,
2922            &Some(update_operand),
2923            &common_settings(false, false),
2924            false,
2925        )
2926        .await?;
2927        assert_eq!(
2928            summary.hard_links_created, 1,
2929            "the file must be hard-linked from the dot-dot update tree"
2930        );
2931        // the dst file shares the update tree's inode (linked from it, not copied).
2932        let update_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
2933        let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
2934        assert_eq!(
2935            update_ino, dst_ino,
2936            "dst must be hard-linked from the update tree inode"
2937        );
2938        Ok(())
2939    }
2940
2941    #[tokio::test]
2942    #[traced_test]
2943    async fn test_basic_link_update() -> Result<(), anyhow::Error> {
2944        let tmp_dir = testutils::setup_test_dir().await?;
2945        let test_path = tmp_dir.as_path();
2946        let summary = link(
2947            &PROGRESS,
2948            test_path,
2949            &test_path.join("foo"),
2950            &test_path.join("bar"),
2951            &Some(test_path.join("foo")),
2952            &common_settings(false, false),
2953            false,
2954        )
2955        .await?;
2956        assert_eq!(summary.hard_links_created, 5);
2957        assert_eq!(summary.copy_summary.files_copied, 0);
2958        assert_eq!(summary.copy_summary.symlinks_created, 2);
2959        assert_eq!(summary.copy_summary.directories_created, 3);
2960        testutils::check_dirs_identical(
2961            &test_path.join("foo"),
2962            &test_path.join("bar"),
2963            testutils::FileEqualityCheck::Timestamp,
2964        )
2965        .await?;
2966        Ok(())
2967    }
2968
2969    #[tokio::test]
2970    #[traced_test]
2971    async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
2972        let tmp_dir = testutils::setup_test_dir().await?;
2973        tokio::fs::create_dir(tmp_dir.join("baz")).await?;
2974        let test_path = tmp_dir.as_path();
2975        let summary = link(
2976            &PROGRESS,
2977            test_path,
2978            &test_path.join("baz"), // empty source
2979            &test_path.join("bar"),
2980            &Some(test_path.join("foo")),
2981            &common_settings(false, false),
2982            false,
2983        )
2984        .await?;
2985        assert_eq!(summary.hard_links_created, 0);
2986        assert_eq!(summary.copy_summary.files_copied, 5);
2987        assert_eq!(summary.copy_summary.symlinks_created, 2);
2988        assert_eq!(summary.copy_summary.directories_created, 3);
2989        testutils::check_dirs_identical(
2990            &test_path.join("foo"),
2991            &test_path.join("bar"),
2992            testutils::FileEqualityCheck::Timestamp,
2993        )
2994        .await?;
2995        Ok(())
2996    }
2997
2998    #[tokio::test]
2999    #[traced_test]
3000    async fn test_link_destination_permission_error_includes_root_cause()
3001    -> Result<(), anyhow::Error> {
3002        let tmp_dir = testutils::setup_test_dir().await?;
3003        let test_path = tmp_dir.as_path();
3004        let readonly_parent = test_path.join("readonly_dest");
3005        tokio::fs::create_dir(&readonly_parent).await?;
3006        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3007            .await?;
3008
3009        let mut settings = common_settings(false, false);
3010        settings.copy_settings.fail_early = true;
3011
3012        let result = link(
3013            &PROGRESS,
3014            test_path,
3015            &test_path.join("foo"),
3016            &readonly_parent.join("bar"),
3017            &None,
3018            &settings,
3019            false,
3020        )
3021        .await;
3022
3023        // restore permissions to allow temporary directory cleanup
3024        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
3025            .await?;
3026
3027        assert!(result.is_err(), "link into read-only parent should fail");
3028        let err = result.unwrap_err();
3029        let err_msg = format!("{:#}", err.source);
3030        assert!(
3031            err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
3032            "Error message must include permission denied text. Got: {}",
3033            err_msg
3034        );
3035        Ok(())
3036    }
3037
3038    #[tokio::test]
3039    #[traced_test]
3040    async fn hard_link_file_into_readonly_parent_returns_error() -> Result<(), anyhow::Error> {
3041        // regression: hard_link_helper used to silently ignore non-AlreadyExists errors
3042        // and report hard_links_created=1 when the underlying hard_link call had failed
3043        let tmp_dir = testutils::setup_test_dir().await?;
3044        let src = tmp_dir.join("src.txt");
3045        tokio::fs::write(&src, "content").await?;
3046        let readonly_parent = tmp_dir.join("readonly_parent");
3047        tokio::fs::create_dir(&readonly_parent).await?;
3048        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3049            .await?;
3050        let dst = readonly_parent.join("dst.txt");
3051        let settings = common_settings(false, false);
3052        let result = link(&PROGRESS, &tmp_dir, &src, &dst, &None, &settings, false).await;
3053        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
3054            .await?;
3055        let err = result.expect_err("link into read-only parent should fail");
3056        assert_eq!(err.summary.hard_links_created, 0);
3057        let err_msg = format!("{:#}", err.source);
3058        assert!(
3059            err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
3060            "error should include root cause, got: {err_msg}"
3061        );
3062        Ok(())
3063    }
3064
3065    pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
3066        // update
3067        // |- 0.txt
3068        // |- bar
3069        //    |- 1.txt
3070        //    |- 2.txt -> ../0.txt
3071        let foo_path = tmp_dir.join("update");
3072        tokio::fs::create_dir(&foo_path).await.unwrap();
3073        tokio::fs::write(foo_path.join("0.txt"), "0-new")
3074            .await
3075            .unwrap();
3076        let bar_path = foo_path.join("bar");
3077        tokio::fs::create_dir(&bar_path).await.unwrap();
3078        tokio::fs::write(bar_path.join("1.txt"), "1-new")
3079            .await
3080            .unwrap();
3081        tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
3082            .await
3083            .unwrap();
3084        tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
3085        Ok(())
3086    }
3087
3088    #[tokio::test]
3089    #[traced_test]
3090    async fn test_link_update() -> Result<(), anyhow::Error> {
3091        let tmp_dir = testutils::setup_test_dir().await?;
3092        setup_update_dir(&tmp_dir).await?;
3093        let test_path = tmp_dir.as_path();
3094        let summary = link(
3095            &PROGRESS,
3096            test_path,
3097            &test_path.join("foo"),
3098            &test_path.join("bar"),
3099            &Some(test_path.join("update")),
3100            &common_settings(false, false),
3101            false,
3102        )
3103        .await?;
3104        assert_eq!(summary.hard_links_created, 2);
3105        assert_eq!(summary.copy_summary.files_copied, 2);
3106        assert_eq!(summary.copy_summary.symlinks_created, 3);
3107        assert_eq!(summary.copy_summary.directories_created, 3);
3108        // compare subset of src and dst
3109        testutils::check_dirs_identical(
3110            &test_path.join("foo").join("baz"),
3111            &test_path.join("bar").join("baz"),
3112            testutils::FileEqualityCheck::HardLink,
3113        )
3114        .await?;
3115        // compare update and dst
3116        testutils::check_dirs_identical(
3117            &test_path.join("update"),
3118            &test_path.join("bar"),
3119            testutils::FileEqualityCheck::Timestamp,
3120        )
3121        .await?;
3122        Ok(())
3123    }
3124
3125    #[tokio::test]
3126    #[traced_test]
3127    async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
3128        let tmp_dir = testutils::setup_test_dir().await?;
3129        setup_update_dir(&tmp_dir).await?;
3130        let test_path = tmp_dir.as_path();
3131        let mut settings = common_settings(false, false);
3132        settings.update_exclusive = true;
3133        let summary = link(
3134            &PROGRESS,
3135            test_path,
3136            &test_path.join("foo"),
3137            &test_path.join("bar"),
3138            &Some(test_path.join("update")),
3139            &settings,
3140            false,
3141        )
3142        .await?;
3143        // we should end up with same directory as the update
3144        // |- 0.txt
3145        // |- bar
3146        //    |- 1.txt
3147        //    |- 2.txt -> ../0.txt
3148        assert_eq!(summary.hard_links_created, 0);
3149        assert_eq!(summary.copy_summary.files_copied, 2);
3150        assert_eq!(summary.copy_summary.symlinks_created, 1);
3151        assert_eq!(summary.copy_summary.directories_created, 2);
3152        // compare update and dst
3153        testutils::check_dirs_identical(
3154            &test_path.join("update"),
3155            &test_path.join("bar"),
3156            testutils::FileEqualityCheck::Timestamp,
3157        )
3158        .await?;
3159        Ok(())
3160    }
3161
3162    async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
3163        let tmp_dir = testutils::setup_test_dir().await?;
3164        let test_path = tmp_dir.as_path();
3165        let summary = link(
3166            &PROGRESS,
3167            test_path,
3168            &test_path.join("foo"),
3169            &test_path.join("bar"),
3170            &None,
3171            &common_settings(false, false),
3172            false,
3173        )
3174        .await?;
3175        assert_eq!(summary.hard_links_created, 5);
3176        assert_eq!(summary.copy_summary.symlinks_created, 2);
3177        assert_eq!(summary.copy_summary.directories_created, 3);
3178        Ok(tmp_dir)
3179    }
3180
3181    #[tokio::test]
3182    #[traced_test]
3183    async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
3184        let tmp_dir = setup_test_dir_and_link().await?;
3185        let output_path = &tmp_dir.join("bar");
3186        {
3187            // bar
3188            // |- 0.txt
3189            // |- bar  <---------------------------------------- REMOVE
3190            //    |- 1.txt  <----------------------------------- REMOVE
3191            //    |- 2.txt  <----------------------------------- REMOVE
3192            //    |- 3.txt  <----------------------------------- REMOVE
3193            // |- baz
3194            //    |- 4.txt
3195            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
3196            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
3197            let summary = rm::rm(
3198                &PROGRESS,
3199                &output_path.join("bar"),
3200                &rm::Settings {
3201                    fail_early: false,
3202                    filter: None,
3203                    dry_run: None,
3204                    time_filter: None,
3205                },
3206            )
3207            .await?
3208                + rm::rm(
3209                    &PROGRESS,
3210                    &output_path.join("baz").join("5.txt"),
3211                    &rm::Settings {
3212                        fail_early: false,
3213                        filter: None,
3214                        dry_run: None,
3215                        time_filter: None,
3216                    },
3217                )
3218                .await?;
3219            assert_eq!(summary.files_removed, 3);
3220            assert_eq!(summary.symlinks_removed, 1);
3221            assert_eq!(summary.directories_removed, 1);
3222        }
3223        let summary = link(
3224            &PROGRESS,
3225            &tmp_dir,
3226            &tmp_dir.join("foo"),
3227            output_path,
3228            &None,
3229            &common_settings(false, true), // overwrite!
3230            false,
3231        )
3232        .await?;
3233        assert_eq!(summary.hard_links_created, 3);
3234        assert_eq!(summary.copy_summary.symlinks_created, 1);
3235        assert_eq!(summary.copy_summary.directories_created, 1);
3236        testutils::check_dirs_identical(
3237            &tmp_dir.join("foo"),
3238            output_path,
3239            testutils::FileEqualityCheck::Timestamp,
3240        )
3241        .await?;
3242        Ok(())
3243    }
3244
3245    #[tokio::test]
3246    #[traced_test]
3247    async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
3248        let tmp_dir = setup_test_dir_and_link().await?;
3249        let output_path = &tmp_dir.join("bar");
3250        {
3251            // bar
3252            // |- 0.txt
3253            // |- bar  <---------------------------------------- REMOVE
3254            //    |- 1.txt  <----------------------------------- REMOVE
3255            //    |- 2.txt  <----------------------------------- REMOVE
3256            //    |- 3.txt  <----------------------------------- REMOVE
3257            // |- baz
3258            //    |- 4.txt
3259            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
3260            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
3261            let summary = rm::rm(
3262                &PROGRESS,
3263                &output_path.join("bar"),
3264                &rm::Settings {
3265                    fail_early: false,
3266                    filter: None,
3267                    dry_run: None,
3268                    time_filter: None,
3269                },
3270            )
3271            .await?
3272                + rm::rm(
3273                    &PROGRESS,
3274                    &output_path.join("baz").join("5.txt"),
3275                    &rm::Settings {
3276                        fail_early: false,
3277                        filter: None,
3278                        dry_run: None,
3279                        time_filter: None,
3280                    },
3281                )
3282                .await?;
3283            assert_eq!(summary.files_removed, 3);
3284            assert_eq!(summary.symlinks_removed, 1);
3285            assert_eq!(summary.directories_removed, 1);
3286        }
3287        setup_update_dir(&tmp_dir).await?;
3288        // update
3289        // |- 0.txt
3290        // |- bar
3291        //    |- 1.txt
3292        //    |- 2.txt -> ../0.txt
3293        let summary = link(
3294            &PROGRESS,
3295            &tmp_dir,
3296            &tmp_dir.join("foo"),
3297            output_path,
3298            &Some(tmp_dir.join("update")),
3299            &common_settings(false, true), // overwrite!
3300            false,
3301        )
3302        .await?;
3303        assert_eq!(summary.hard_links_created, 1); // 3.txt
3304        assert_eq!(summary.copy_summary.files_copied, 2); // 0.txt, 1.txt
3305        assert_eq!(summary.copy_summary.symlinks_created, 2); // 2.txt, 5.txt
3306        assert_eq!(summary.copy_summary.directories_created, 1);
3307        // compare subset of src and dst
3308        testutils::check_dirs_identical(
3309            &tmp_dir.join("foo").join("baz"),
3310            &tmp_dir.join("bar").join("baz"),
3311            testutils::FileEqualityCheck::HardLink,
3312        )
3313        .await?;
3314        // compare update and dst
3315        testutils::check_dirs_identical(
3316            &tmp_dir.join("update"),
3317            &tmp_dir.join("bar"),
3318            testutils::FileEqualityCheck::Timestamp,
3319        )
3320        .await?;
3321        Ok(())
3322    }
3323
3324    #[tokio::test]
3325    #[traced_test]
3326    async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
3327        let tmp_dir = setup_test_dir_and_link().await?;
3328        let output_path = &tmp_dir.join("bar");
3329        {
3330            // bar
3331            // |- 0.txt
3332            // |- bar
3333            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
3334            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
3335            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
3336            // |- baz    <-------------------------------------- REPLACE W/ FILE
3337            //    |- ...
3338            let bar_path = output_path.join("bar");
3339            let summary = rm::rm(
3340                &PROGRESS,
3341                &bar_path.join("1.txt"),
3342                &rm::Settings {
3343                    fail_early: false,
3344                    filter: None,
3345                    dry_run: None,
3346                    time_filter: None,
3347                },
3348            )
3349            .await?
3350                + rm::rm(
3351                    &PROGRESS,
3352                    &bar_path.join("2.txt"),
3353                    &rm::Settings {
3354                        fail_early: false,
3355                        filter: None,
3356                        dry_run: None,
3357                        time_filter: None,
3358                    },
3359                )
3360                .await?
3361                + rm::rm(
3362                    &PROGRESS,
3363                    &bar_path.join("3.txt"),
3364                    &rm::Settings {
3365                        fail_early: false,
3366                        filter: None,
3367                        dry_run: None,
3368                        time_filter: None,
3369                    },
3370                )
3371                .await?
3372                + rm::rm(
3373                    &PROGRESS,
3374                    &output_path.join("baz"),
3375                    &rm::Settings {
3376                        fail_early: false,
3377                        filter: None,
3378                        dry_run: None,
3379                        time_filter: None,
3380                    },
3381                )
3382                .await?;
3383            assert_eq!(summary.files_removed, 4);
3384            assert_eq!(summary.symlinks_removed, 2);
3385            assert_eq!(summary.directories_removed, 1);
3386            // REPLACE with a file, a symlink, a directory and a file
3387            tokio::fs::write(bar_path.join("1.txt"), "1-new")
3388                .await
3389                .unwrap();
3390            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
3391                .await
3392                .unwrap();
3393            tokio::fs::create_dir(&bar_path.join("3.txt"))
3394                .await
3395                .unwrap();
3396            tokio::fs::write(&output_path.join("baz"), "baz")
3397                .await
3398                .unwrap();
3399        }
3400        let summary = link(
3401            &PROGRESS,
3402            &tmp_dir,
3403            &tmp_dir.join("foo"),
3404            output_path,
3405            &None,
3406            &common_settings(false, true), // overwrite!
3407            false,
3408        )
3409        .await?;
3410        assert_eq!(summary.hard_links_created, 4);
3411        assert_eq!(summary.copy_summary.files_copied, 0);
3412        assert_eq!(summary.copy_summary.symlinks_created, 2);
3413        assert_eq!(summary.copy_summary.directories_created, 1);
3414        testutils::check_dirs_identical(
3415            &tmp_dir.join("foo"),
3416            &tmp_dir.join("bar"),
3417            testutils::FileEqualityCheck::HardLink,
3418        )
3419        .await?;
3420        Ok(())
3421    }
3422
3423    #[tokio::test]
3424    #[traced_test]
3425    async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
3426        let tmp_dir = setup_test_dir_and_link().await?;
3427        let output_path = &tmp_dir.join("bar");
3428        {
3429            // bar
3430            // |- 0.txt
3431            // |- bar
3432            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
3433            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
3434            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
3435            // |- baz    <-------------------------------------- REPLACE W/ FILE
3436            //    |- ...
3437            let bar_path = output_path.join("bar");
3438            let summary = rm::rm(
3439                &PROGRESS,
3440                &bar_path.join("1.txt"),
3441                &rm::Settings {
3442                    fail_early: false,
3443                    filter: None,
3444                    dry_run: None,
3445                    time_filter: None,
3446                },
3447            )
3448            .await?
3449                + rm::rm(
3450                    &PROGRESS,
3451                    &bar_path.join("2.txt"),
3452                    &rm::Settings {
3453                        fail_early: false,
3454                        filter: None,
3455                        dry_run: None,
3456                        time_filter: None,
3457                    },
3458                )
3459                .await?
3460                + rm::rm(
3461                    &PROGRESS,
3462                    &bar_path.join("3.txt"),
3463                    &rm::Settings {
3464                        fail_early: false,
3465                        filter: None,
3466                        dry_run: None,
3467                        time_filter: None,
3468                    },
3469                )
3470                .await?
3471                + rm::rm(
3472                    &PROGRESS,
3473                    &output_path.join("baz"),
3474                    &rm::Settings {
3475                        fail_early: false,
3476                        filter: None,
3477                        dry_run: None,
3478                        time_filter: None,
3479                    },
3480                )
3481                .await?;
3482            assert_eq!(summary.files_removed, 4);
3483            assert_eq!(summary.symlinks_removed, 2);
3484            assert_eq!(summary.directories_removed, 1);
3485            // REPLACE with a file, a symlink, a directory and a file
3486            tokio::fs::write(bar_path.join("1.txt"), "1-new")
3487                .await
3488                .unwrap();
3489            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
3490                .await
3491                .unwrap();
3492            tokio::fs::create_dir(&bar_path.join("3.txt"))
3493                .await
3494                .unwrap();
3495            tokio::fs::write(&output_path.join("baz"), "baz")
3496                .await
3497                .unwrap();
3498        }
3499        let source_path = &tmp_dir.join("foo");
3500        // unreadable
3501        tokio::fs::set_permissions(
3502            &source_path.join("baz"),
3503            std::fs::Permissions::from_mode(0o000),
3504        )
3505        .await?;
3506        // bar
3507        // |- ...
3508        // |- baz <- NON READABLE
3509        match link(
3510            &PROGRESS,
3511            &tmp_dir,
3512            &tmp_dir.join("foo"),
3513            output_path,
3514            &None,
3515            &common_settings(false, true), // overwrite!
3516            false,
3517        )
3518        .await
3519        {
3520            Ok(_) => panic!("Expected the link to error!"),
3521            Err(error) => {
3522                tracing::info!("{:#}", &error);
3523                assert_eq!(error.summary.hard_links_created, 3);
3524                assert_eq!(error.summary.copy_summary.files_copied, 0);
3525                assert_eq!(error.summary.copy_summary.symlinks_created, 0);
3526                assert_eq!(error.summary.copy_summary.directories_created, 0);
3527                assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
3528                assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
3529                assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
3530            }
3531        }
3532        Ok(())
3533    }
3534
3535    /// Verify that directory metadata is applied even when child link operations fail.
3536    /// This is a regression test for a bug where directory permissions were not preserved
3537    /// when linking with fail_early=false and some children failed to link.
3538    #[tokio::test]
3539    #[traced_test]
3540    async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
3541        let tmp_dir = testutils::create_temp_dir().await?;
3542        let test_path = tmp_dir.as_path();
3543        // create source directory with specific permissions
3544        let src_dir = test_path.join("src");
3545        tokio::fs::create_dir(&src_dir).await?;
3546        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
3547        // create a readable file (will be linked successfully)
3548        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
3549        // create a subdirectory with a file, then make the subdirectory unreadable
3550        // this will cause the recursive walk to fail when trying to read subdirectory contents
3551        let unreadable_subdir = src_dir.join("unreadable_subdir");
3552        tokio::fs::create_dir(&unreadable_subdir).await?;
3553        tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
3554        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
3555            .await?;
3556        let dst_dir = test_path.join("dst");
3557        // link with fail_early=false
3558        let result = link(
3559            &PROGRESS,
3560            test_path,
3561            &src_dir,
3562            &dst_dir,
3563            &None,
3564            &common_settings(false, false),
3565            false,
3566        )
3567        .await;
3568        // restore permissions so cleanup can succeed
3569        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
3570            .await?;
3571        // verify the operation returned an error (unreadable subdirectory should fail)
3572        assert!(
3573            result.is_err(),
3574            "link should fail due to unreadable subdirectory"
3575        );
3576        let error = result.unwrap_err();
3577        // verify the readable file was linked successfully
3578        assert_eq!(error.summary.hard_links_created, 1);
3579        // verify the destination directory exists and has the correct permissions
3580        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3581        assert!(dst_metadata.is_dir());
3582        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
3583        assert_eq!(
3584            actual_mode, 0o750,
3585            "directory should have preserved source permissions (0o750), got {:o}",
3586            actual_mode
3587        );
3588        Ok(())
3589    }
3590    mod filter_tests {
3591        use super::*;
3592        use crate::filter::FilterSettings;
3593
3594        async fn run_with_open_file_cleanup<F>(
3595            admission: &testutils::AdmissionLimit,
3596            operation: F,
3597        ) -> Result<F::Output, anyhow::Error>
3598        where
3599            F: std::future::Future,
3600        {
3601            let output = admission
3602                .run_with_timeout(std::time::Duration::from_secs(20), operation)
3603                .await
3604                .context("admission-sensitive filter operation did not finish")?;
3605            let permit = admission
3606                .run_with_timeout(
3607                    std::time::Duration::from_secs(20),
3608                    throttle::open_file_permit(),
3609                )
3610                .await
3611                .context("filter operation did not return open-file capacity")?;
3612            drop(permit);
3613            Ok(output)
3614        }
3615
3616        /// Test that path-based patterns (with /) work correctly with nested paths.
3617        #[tokio::test]
3618        #[traced_test]
3619        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
3620            let tmp_dir = testutils::setup_test_dir().await?;
3621            let test_path = tmp_dir.as_path();
3622            // create filter that should only link files in bar/ directory
3623            let mut filter = FilterSettings::new();
3624            filter.add_include("bar/*.txt").unwrap();
3625            let summary = link(
3626                &PROGRESS,
3627                test_path,
3628                &test_path.join("foo"),
3629                &test_path.join("dst"),
3630                &None,
3631                &Settings {
3632                    copy_settings: CopySettings {
3633                        dereference: false,
3634                        fail_early: false,
3635                        overwrite: false,
3636                        overwrite_compare: Default::default(),
3637                        overwrite_filter: None,
3638                        ignore_existing: false,
3639                        chunk_size: 0,
3640                        skip_specials: false,
3641                        remote_copy_buffer_size: 0,
3642                        filter: None,
3643                        dry_run: None,
3644                        delete: None,
3645                    },
3646                    update_compare: Default::default(),
3647                    update_exclusive: false,
3648                    filter: Some(filter),
3649                    dry_run: None,
3650                    preserve: preserve::preserve_all(),
3651                },
3652                false,
3653            )
3654            .await?;
3655            // should only link files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
3656            assert_eq!(
3657                summary.hard_links_created, 3,
3658                "should link 3 files matching bar/*.txt"
3659            );
3660            // verify the right files were linked
3661            assert!(
3662                test_path.join("dst/bar/1.txt").exists(),
3663                "bar/1.txt should be linked"
3664            );
3665            assert!(
3666                test_path.join("dst/bar/2.txt").exists(),
3667                "bar/2.txt should be linked"
3668            );
3669            assert!(
3670                test_path.join("dst/bar/3.txt").exists(),
3671                "bar/3.txt should be linked"
3672            );
3673            // verify files outside the pattern don't exist
3674            assert!(
3675                !test_path.join("dst/0.txt").exists(),
3676                "0.txt should not be linked"
3677            );
3678            Ok(())
3679        }
3680        /// Regression: with a filter active and `fail_early = false`, a directory whose only
3681        /// traversed child FAILS becomes "empty" and is pruned by the empty-dir cleanup — the child
3682        /// failure must still surface, not be masked as success. copy.rs's `finalize_dir` guards
3683        /// this in its DryRunSkip/Remove arms; `link_dir_contents` must do the same.
3684        #[tokio::test]
3685        #[traced_test]
3686        async fn test_filter_pruned_empty_dir_surfaces_child_error() -> Result<(), anyhow::Error> {
3687            let tmp_dir = testutils::create_temp_dir().await?;
3688            let test_path = tmp_dir.as_path();
3689            // src/ is the root; src/sub/ is traversal-only under the filter (it does not directly
3690            // match), and its sole child `unreadable/` (mode 0o000) fails to open during the walk.
3691            // nothing links into sub/, so the empty-dir cleanup prunes it.
3692            let src_dir = test_path.join("src");
3693            let unreadable = src_dir.join("sub").join("unreadable");
3694            tokio::fs::create_dir_all(&unreadable).await?;
3695            tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
3696            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3697            // an include pattern that matches nothing present forces traversal of sub/ and
3698            // unreadable/ without directly matching sub/, so sub/ is "traversal-only".
3699            let mut filter = FilterSettings::new();
3700            filter.add_include("*.match").unwrap();
3701            let mut settings = common_settings(false, false);
3702            settings.filter = Some(filter);
3703            let result = link(
3704                &PROGRESS,
3705                test_path,
3706                &src_dir,
3707                &test_path.join("dst"),
3708                &None,
3709                &settings,
3710                false,
3711            )
3712            .await;
3713            // restore perms so the temp dir can be cleaned up
3714            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
3715            assert!(
3716                result.is_err(),
3717                "a child link failure inside a filter-pruned empty directory must surface as an \
3718                 error, not be masked as success"
3719            );
3720            Ok(())
3721        }
3722        /// As above but in dry-run mode, which hits the `DryRunSkip` arm instead of `Remove`: a
3723        /// collected child error must still surface rather than being reported as a clean dry run.
3724        #[tokio::test]
3725        #[traced_test]
3726        async fn test_filter_pruned_empty_dir_surfaces_child_error_dry_run()
3727        -> Result<(), anyhow::Error> {
3728            let tmp_dir = testutils::create_temp_dir().await?;
3729            let test_path = tmp_dir.as_path();
3730            let src_dir = test_path.join("src");
3731            let unreadable = src_dir.join("sub").join("unreadable");
3732            tokio::fs::create_dir_all(&unreadable).await?;
3733            tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
3734            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3735            let mut filter = FilterSettings::new();
3736            filter.add_include("*.match").unwrap();
3737            let mut settings = common_settings(false, false);
3738            settings.filter = Some(filter);
3739            settings.dry_run = Some(crate::config::DryRunMode::Brief);
3740            settings.copy_settings.dry_run = Some(crate::config::DryRunMode::Brief);
3741            let result = link(
3742                &PROGRESS,
3743                test_path,
3744                &src_dir,
3745                &test_path.join("dst"),
3746                &None,
3747                &settings,
3748                false,
3749            )
3750            .await;
3751            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
3752            assert!(
3753                result.is_err(),
3754                "dry-run must also surface the child error, not report a clean run"
3755            );
3756            Ok(())
3757        }
3758        /// Test that filters are applied to top-level file arguments.
3759        #[tokio::test]
3760        #[traced_test]
3761        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
3762            let tmp_dir = testutils::setup_test_dir().await?;
3763            let test_path = tmp_dir.as_path();
3764            // create filter that excludes .txt files
3765            let mut filter = FilterSettings::new();
3766            filter.add_exclude("*.txt").unwrap();
3767            let summary = link(
3768                &PROGRESS,
3769                test_path,
3770                &test_path.join("foo/0.txt"), // single file source
3771                &test_path.join("dst/0.txt"),
3772                &None,
3773                &Settings {
3774                    copy_settings: CopySettings {
3775                        dereference: false,
3776                        fail_early: false,
3777                        overwrite: false,
3778                        overwrite_compare: Default::default(),
3779                        overwrite_filter: None,
3780                        ignore_existing: false,
3781                        chunk_size: 0,
3782                        skip_specials: false,
3783                        remote_copy_buffer_size: 0,
3784                        filter: None,
3785                        dry_run: None,
3786                        delete: None,
3787                    },
3788                    update_compare: Default::default(),
3789                    update_exclusive: false,
3790                    filter: Some(filter),
3791                    dry_run: None,
3792                    preserve: preserve::preserve_all(),
3793                },
3794                false,
3795            )
3796            .await?;
3797            // the file should NOT be linked because it matches the exclude pattern
3798            assert_eq!(
3799                summary.hard_links_created, 0,
3800                "file matching exclude pattern should not be linked"
3801            );
3802            assert!(
3803                !test_path.join("dst/0.txt").exists(),
3804                "excluded file should not exist at destination"
3805            );
3806            Ok(())
3807        }
3808        /// An update root is filtered under the source root's logical name in union mode.
3809        #[tokio::test]
3810        #[traced_test]
3811        async fn update_root_filter_uses_source_name_in_union_mode() -> Result<(), anyhow::Error> {
3812            let test_path = testutils::create_temp_dir().await?;
3813            let src = test_path.join("foo");
3814            let update = test_path.join("bar");
3815            let dst = test_path.join("dst");
3816            tokio::fs::write(&src, "SRC").await?;
3817            tokio::fs::write(&update, "UPDATED").await?;
3818            let mut filter = FilterSettings::new();
3819            filter.add_include("foo").unwrap();
3820            let mut settings = common_settings(false, false);
3821            settings.copy_settings.filter = Some(filter.clone());
3822            settings.filter = Some(filter);
3823            let summary = link(
3824                &PROGRESS,
3825                &test_path,
3826                &src,
3827                &dst,
3828                &Some(update),
3829                &settings,
3830                false,
3831            )
3832            .await?;
3833            assert_eq!(tokio::fs::read_to_string(&dst).await?, "UPDATED");
3834            assert_eq!(summary.copy_summary.files_copied, 1);
3835            Ok(())
3836        }
3837        /// An update root is filtered under the source root's logical name in exclusive mode.
3838        #[tokio::test]
3839        #[traced_test]
3840        async fn update_root_filter_uses_source_name_in_exclusive_mode() -> Result<(), anyhow::Error>
3841        {
3842            let test_path = testutils::create_temp_dir().await?;
3843            let src = test_path.join("foo");
3844            let update = test_path.join("bar");
3845            let dst = test_path.join("dst");
3846            tokio::fs::write(&src, "SRC").await?;
3847            tokio::fs::write(&update, "UPDATED").await?;
3848            let mut filter = FilterSettings::new();
3849            filter.add_include("foo").unwrap();
3850            let mut settings = common_settings(false, false);
3851            settings.update_exclusive = true;
3852            settings.copy_settings.filter = Some(filter.clone());
3853            settings.filter = Some(filter);
3854            let summary = link(
3855                &PROGRESS,
3856                &test_path,
3857                &src,
3858                &dst,
3859                &Some(update),
3860                &settings,
3861                false,
3862            )
3863            .await?;
3864            assert_eq!(tokio::fs::read_to_string(&dst).await?, "UPDATED");
3865            assert_eq!(summary.copy_summary.files_copied, 1);
3866            Ok(())
3867        }
3868        /// A filter-passing update directory keeps a root excluded for the source-file type.
3869        #[tokio::test]
3870        #[traced_test]
3871        async fn update_directory_can_include_root_excluded_for_source_file()
3872        -> Result<(), anyhow::Error> {
3873            let test_path = testutils::create_temp_dir().await?;
3874            let src_parent = test_path.join("source");
3875            let update_parent = test_path.join("update");
3876            let src = src_parent.join("data");
3877            let update = update_parent.join("data");
3878            let dst = test_path.join("dst");
3879            tokio::fs::create_dir(&src_parent).await?;
3880            tokio::fs::create_dir(&update_parent).await?;
3881            tokio::fs::write(&src, "SOURCE-FILE").await?;
3882            tokio::fs::create_dir(&update).await?;
3883            tokio::fs::write(update.join("inner.txt"), "UPDATE-DIR").await?;
3884            let mut filter = FilterSettings::new();
3885            filter.add_include("data/").unwrap();
3886            filter.add_include("data/**").unwrap();
3887            let mut settings = common_settings(false, false);
3888            settings.filter = Some(filter);
3889            let summary = link(
3890                &PROGRESS,
3891                &test_path,
3892                &src,
3893                &dst,
3894                &Some(update),
3895                &settings,
3896                false,
3897            )
3898            .await?;
3899            assert_eq!(
3900                tokio::fs::read_to_string(dst.join("inner.txt")).await?,
3901                "UPDATE-DIR"
3902            );
3903            assert_eq!(summary.copy_summary.files_copied, 1);
3904            Ok(())
3905        }
3906        /// A filter-selected update file cannot authorize a replacement directory.
3907        #[tokio::test]
3908        #[traced_test]
3909        async fn filtered_update_file_swap_to_directory_never_materializes_replacement()
3910        -> Result<(), anyhow::Error> {
3911            let test_path = testutils::create_temp_dir().await?;
3912            let src = test_path.join("src");
3913            let update = test_path.join("update");
3914            let dst = test_path.join("dst");
3915            tokio::fs::write(&src, "SRC").await?;
3916            tokio::fs::write(&update, "SELECTED-UPDATE-FILE").await?;
3917
3918            let admission_limits = testutils::AdmissionLimit::new().await;
3919            admission_limits.set_files_in_flight(1);
3920            let permit = walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
3921            let src_parent = Arc::new(
3922                Dir::open_parent_dir(&test_path, congestion::Side::Source)
3923                    .await?
3924                    .into_tree(),
3925            );
3926            let dst_parent = Arc::new(
3927                Dir::open_parent_dir(&test_path, congestion::Side::Destination)
3928                    .await?
3929                    .into_tree(),
3930            );
3931            let src_handle = src_parent.child(std::ffi::OsStr::new("src")).await?;
3932            let update_handle = src_parent.child(std::ffi::OsStr::new("update")).await?;
3933
3934            let mut filter = FilterSettings::new();
3935            filter.add_exclude("src/")?;
3936            assert!(matches!(
3937                select_filtered_root(
3938                    &filter,
3939                    std::ffi::OsStr::new("src"),
3940                    src_handle.kind(),
3941                    Some(update_handle.kind()),
3942                    false,
3943                ),
3944                RootFilterSelection::WithUpdate
3945            ));
3946            let mut entry = AdmittedLinkEntry::new(src_handle, permit);
3947            entry.update_handle = Some(update_handle);
3948
3949            // replace the selected file after filtering. the replacement directory is excluded by
3950            // the same root rule and must never become the object delegated to copy.
3951            tokio::fs::remove_file(&update).await?;
3952            tokio::fs::create_dir(&update).await?;
3953            tokio::fs::write(update.join("replacement.txt"), "UNSELECTED").await?;
3954            let mut settings = common_settings(false, false);
3955            settings.filter = Some(filter);
3956
3957            let result = run_with_open_file_cleanup(
3958                &admission_limits,
3959                link_internal(
3960                    &PROGRESS,
3961                    &src_parent,
3962                    Some((&src_parent, std::ffi::OsStr::new("update"))),
3963                    Some(&dst_parent),
3964                    std::ffi::OsStr::new("src"),
3965                    &src,
3966                    &dst,
3967                    Some(&update),
3968                    std::path::Path::new(""),
3969                    UpdateRootRequirement::Optional,
3970                    &settings,
3971                    false,
3972                    LinkEntryAdmission::Filtered(entry),
3973                ),
3974            )
3975            .await?;
3976
3977            assert!(
3978                result.is_err(),
3979                "a selected file reopened as a directory must fail closed"
3980            );
3981            assert!(
3982                !dst.exists(),
3983                "a failed-closed type swap must not create a destination"
3984            );
3985            Ok(())
3986        }
3987
3988        /// A source-only symlink selected by type cannot authorize a replacement directory.
3989        #[tokio::test]
3990        #[traced_test]
3991        async fn filtered_source_only_symlink_swap_to_directory_never_materializes_replacement()
3992        -> Result<(), anyhow::Error> {
3993            let test_path = testutils::create_temp_dir().await?;
3994            let src = test_path.join("src");
3995            let update = test_path.join("update");
3996            let dst = test_path.join("dst");
3997            tokio::fs::write(test_path.join("target"), "TARGET").await?;
3998            tokio::fs::symlink("target", &src).await?;
3999            tokio::fs::create_dir(&update).await?;
4000
4001            let admission_limits = testutils::AdmissionLimit::new().await;
4002            admission_limits.set_files_in_flight(1);
4003            let permit = walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
4004            let parent = Arc::new(
4005                Dir::open_parent_dir(&test_path, congestion::Side::Source)
4006                    .await?
4007                    .into_tree(),
4008            );
4009            let dst_parent = Arc::new(
4010                Dir::open_parent_dir(&test_path, congestion::Side::Destination)
4011                    .await?
4012                    .into_tree(),
4013            );
4014            let src_handle = parent.child(std::ffi::OsStr::new("src")).await?;
4015            let update_handle = parent.child(std::ffi::OsStr::new("update")).await?;
4016            let mut filter = FilterSettings::new();
4017            filter.add_exclude("src/")?;
4018            assert!(matches!(
4019                select_filtered_root(
4020                    &filter,
4021                    std::ffi::OsStr::new("src"),
4022                    src_handle.kind(),
4023                    Some(update_handle.kind()),
4024                    false,
4025                ),
4026                RootFilterSelection::SourceOnly
4027            ));
4028            drop(update_handle);
4029            let entry = AdmittedLinkEntry::new(src_handle, permit);
4030
4031            // replace the selected symlink after the joint filter decision. the replacement
4032            // directory is excluded by that same logical root rule.
4033            tokio::fs::remove_file(&src).await?;
4034            tokio::fs::create_dir(&src).await?;
4035            tokio::fs::write(src.join("replacement.txt"), "UNSELECTED").await?;
4036            let mut settings = common_settings(false, false);
4037            settings.filter = Some(filter.clone());
4038            settings.copy_settings.filter = Some(filter);
4039
4040            run_with_open_file_cleanup(
4041                &admission_limits,
4042                link_internal(
4043                    &PROGRESS,
4044                    &parent,
4045                    Some((&parent, std::ffi::OsStr::new("update"))),
4046                    Some(&dst_parent),
4047                    std::ffi::OsStr::new("src"),
4048                    &src,
4049                    &dst,
4050                    Some(&update),
4051                    std::path::Path::new(""),
4052                    UpdateRootRequirement::Optional,
4053                    &settings,
4054                    false,
4055                    LinkEntryAdmission::Filtered(entry),
4056                ),
4057            )
4058            .await??;
4059
4060            assert!(
4061                !dst.is_dir(),
4062                "the replacement directory passed the selected symlink's filter decision"
4063            );
4064            assert_eq!(
4065                tokio::fs::read_link(&dst).await?,
4066                std::path::Path::new("target")
4067            );
4068            Ok(())
4069        }
4070
4071        /// A same-type replacement remains the by-name payload copied after admitted selection.
4072        #[tokio::test]
4073        #[traced_test]
4074        async fn admitted_update_file_swap_to_file_copies_replacement_content()
4075        -> Result<(), anyhow::Error> {
4076            let test_path = testutils::create_temp_dir().await?;
4077            let src = test_path.join("src");
4078            let update = test_path.join("update");
4079            let dst = test_path.join("dst");
4080            tokio::fs::write(&src, "S").await?;
4081            tokio::fs::write(&update, "SELECTED-UPDATE").await?;
4082
4083            let admission_limits = testutils::AdmissionLimit::new().await;
4084            admission_limits.set_files_in_flight(1);
4085            let permit = walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
4086            let parent = Arc::new(
4087                Dir::open_parent_dir(&test_path, congestion::Side::Source)
4088                    .await?
4089                    .into_tree(),
4090            );
4091            let dst_parent = Arc::new(
4092                Dir::open_parent_dir(&test_path, congestion::Side::Destination)
4093                    .await?
4094                    .into_tree(),
4095            );
4096            let src_handle = parent.child(std::ffi::OsStr::new("src")).await?;
4097            let update_handle = parent.child(std::ffi::OsStr::new("update")).await?;
4098            let mut entry = AdmittedLinkEntry::new(src_handle, permit);
4099            entry.update_handle = Some(update_handle);
4100
4101            tokio::fs::remove_file(&update).await?;
4102            tokio::fs::write(&update, "REPLACEMENT-CONTENT").await?;
4103            run_with_open_file_cleanup(
4104                &admission_limits,
4105                link_internal(
4106                    &PROGRESS,
4107                    &parent,
4108                    Some((&parent, std::ffi::OsStr::new("update"))),
4109                    Some(&dst_parent),
4110                    std::ffi::OsStr::new("src"),
4111                    &src,
4112                    &dst,
4113                    Some(&update),
4114                    std::path::Path::new(""),
4115                    UpdateRootRequirement::Optional,
4116                    &common_settings(false, false),
4117                    false,
4118                    LinkEntryAdmission::Filtered(entry),
4119                ),
4120            )
4121            .await??;
4122
4123            assert_eq!(
4124                tokio::fs::read_to_string(&dst).await?,
4125                "REPLACEMENT-CONTENT"
4126            );
4127            Ok(())
4128        }
4129
4130        /// A forced-unknown update-only file cannot authorize an excluded replacement directory.
4131        #[tokio::test]
4132        #[traced_test]
4133        async fn update_only_unknown_file_swap_to_excluded_directory_never_materializes_replacement()
4134        -> Result<(), anyhow::Error> {
4135            let test_path = testutils::create_temp_dir().await?;
4136            let update = test_path.join("update");
4137            let dst = test_path.join("dst");
4138            let admission_limits = testutils::AdmissionLimit::new().await;
4139            admission_limits.set_files_in_flight(1);
4140            tokio::fs::create_dir(&update).await?;
4141            tokio::fs::create_dir(&dst).await?;
4142            let entry_path = update.join("node");
4143            let dst_path = dst.join("node");
4144            tokio::fs::write(&entry_path, "SELECTED-UPDATE-FILE").await?;
4145            let update_parent =
4146                Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
4147            let dst_parent =
4148                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4149            let mut filter = FilterSettings::new();
4150            filter.add_exclude("node/")?;
4151            let mut settings = common_settings(false, false);
4152            settings.filter = Some(filter.clone());
4153            settings.copy_settings.filter = Some(filter);
4154            let admission =
4155                walk::ensure_entry_admission(PermitKind::OpenFile, EntryAdmission::RootOrDelegated)
4156                    .await;
4157            let selected = select_exact_update_only_for_dispatch(
4158                &update_parent,
4159                std::ffi::OsStr::new("node"),
4160                admission,
4161                std::path::Path::new("node"),
4162                settings.filter.as_ref(),
4163            )
4164            .await?;
4165            let UpdateOnlyDecision::Admitted(entry) = selected else {
4166                panic!("the forced-unknown file must pass the non-directory filter")
4167            };
4168            assert_eq!(entry.kind(), EntryKind::File);
4169
4170            tokio::fs::remove_file(&entry_path).await?;
4171            tokio::fs::create_dir(&entry_path).await?;
4172            tokio::fs::write(entry_path.join("replacement.txt"), "UNSELECTED").await?;
4173            let result = run_with_open_file_cleanup(
4174                &admission_limits,
4175                delegate_copy(
4176                    &PROGRESS,
4177                    &update_parent,
4178                    Some(&dst_parent),
4179                    std::ffi::OsStr::new("node"),
4180                    &entry_path,
4181                    &dst_path,
4182                    std::path::Path::new("node"),
4183                    copy::DeleteScanAnchor::new(&dst, std::path::Path::new("node")),
4184                    &settings,
4185                    false,
4186                    entry,
4187                ),
4188            )
4189            .await?;
4190
4191            assert!(
4192                result.is_err(),
4193                "a selected file reopened as a directory must fail closed"
4194            );
4195            assert!(
4196                !dst_path.exists(),
4197                "a failed-closed type swap must not create a destination"
4198            );
4199            Ok(())
4200        }
4201
4202        /// A nested source rechecks the exact type after a `DT_UNKNOWN` filter probe.
4203        #[tokio::test]
4204        #[traced_test]
4205        async fn nested_source_unknown_file_swap_to_excluded_directory_is_rechecked()
4206        -> Result<(), anyhow::Error> {
4207            let test_path = testutils::create_temp_dir().await?;
4208            let src = test_path.join("src");
4209            let dst = test_path.join("dst");
4210            tokio::fs::create_dir(&src).await?;
4211            tokio::fs::create_dir(&dst).await?;
4212            let entry_path = src.join("node");
4213            tokio::fs::write(&entry_path, "SELECTED-FILE").await?;
4214            tokio::fs::write(dst.join("node"), "STALE-DESTINATION").await?;
4215            let src_parent =
4216                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
4217            let dst_parent =
4218                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4219            let mut filter = FilterSettings::new();
4220            filter.add_exclude("node/")?;
4221            let initially_is_dir = walk::filter_is_dir(
4222                Some(&filter),
4223                &src_parent,
4224                std::ffi::OsStr::new("node"),
4225                None,
4226                false,
4227            )
4228            .await?;
4229            assert!(!initially_is_dir);
4230            assert!(
4231                walk::should_skip_entry(&Some(filter.clone()), std::path::Path::new("node"), false)
4232                    .is_none()
4233            );
4234
4235            tokio::fs::remove_file(&entry_path).await?;
4236            tokio::fs::create_dir(&entry_path).await?;
4237            tokio::fs::write(entry_path.join("replacement.txt"), "UNSELECTED").await?;
4238            let mut settings = common_settings(false, false);
4239            settings.filter = Some(filter.clone());
4240            settings.copy_settings.filter = Some(filter);
4241            let delete_settings = copy::DeleteSettings {
4242                delete_excluded: true,
4243            };
4244            let mut keep_set = DeleteKeepSet::new(Some(&delete_settings));
4245            let result = crate::walk_driver::scope_tasks(link_internal(
4246                &PROGRESS,
4247                &src_parent,
4248                None,
4249                Some(&dst_parent),
4250                std::ffi::OsStr::new("node"),
4251                &src,
4252                &dst,
4253                None,
4254                std::path::Path::new("node"),
4255                UpdateRootRequirement::Optional,
4256                &settings,
4257                false,
4258                EntryAdmission::RootOrDelegated.into(),
4259            ))
4260            .await?;
4261            let mut summary = Summary::default();
4262            LinkTaskResult::from_link(std::ffi::OsString::from("node"), result)
4263                .fold(&mut summary, &mut keep_set);
4264            crate::delete::prune_extraneous(
4265                &PROGRESS,
4266                &dst_parent,
4267                std::path::Path::new(""),
4268                keep_set.as_set().expect("delete is enabled"),
4269                settings.filter.as_ref(),
4270                &delete_settings,
4271                false,
4272                None,
4273            )
4274            .await?;
4275
4276            assert!(
4277                !dst.join("node").exists(),
4278                "the exact filtered result did not remove the stale outer keep decision"
4279            );
4280            assert_eq!(summary.copy_summary.directories_skipped, 1);
4281            Ok(())
4282        }
4283
4284        /// A stale excluded-directory hint cannot suppress an included file when delete will consume
4285        /// the resulting keep-set.
4286        #[tokio::test]
4287        #[traced_test]
4288        async fn destructive_source_stale_directory_hint_rechecks_included_file()
4289        -> Result<(), anyhow::Error> {
4290            let test_path = testutils::create_temp_dir().await?;
4291            let src = test_path.join("src");
4292            let dst = test_path.join("dst");
4293            tokio::fs::create_dir(&src).await?;
4294            tokio::fs::create_dir(&dst).await?;
4295            tokio::fs::write(src.join("node"), "CURRENT-SOURCE").await?;
4296            tokio::fs::write(dst.join("node"), "STALE-DESTINATION").await?;
4297            let src_dir =
4298                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
4299            let dst_dir =
4300                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4301
4302            let mut filter = FilterSettings::new();
4303            filter.add_exclude("node/")?;
4304            let delete_settings = copy::DeleteSettings {
4305                delete_excluded: false,
4306            };
4307            let mut settings = common_settings(false, true);
4308            settings.filter = Some(filter.clone());
4309            settings.copy_settings.filter = Some(filter);
4310            settings.copy_settings.delete = Some(delete_settings.clone());
4311            let decision = select_source_for_dispatch(
4312                Some(EntryKind::Dir),
4313                std::path::Path::new("node"),
4314                &settings,
4315                false,
4316            );
4317
4318            let mut summary = Summary::default();
4319            let mut keep_set = DeleteKeepSet::new(Some(&delete_settings));
4320            match decision {
4321                SourceEntryDecision::Filtered { kind, .. } => {
4322                    summary = summary + skipped_summary_for(kind);
4323                }
4324                SourceEntryDecision::Dispatch(admission) => {
4325                    let admission =
4326                        walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
4327                    let result = crate::walk_driver::scope_tasks(link_internal(
4328                        &PROGRESS,
4329                        &src_dir,
4330                        None,
4331                        Some(&dst_dir),
4332                        std::ffi::OsStr::new("node"),
4333                        &src,
4334                        &dst,
4335                        None,
4336                        std::path::Path::new("node"),
4337                        UpdateRootRequirement::Optional,
4338                        &settings,
4339                        false,
4340                        admission.into(),
4341                    ))
4342                    .await?;
4343                    LinkTaskResult::from_link(std::ffi::OsString::from("node"), result)
4344                        .fold(&mut summary, &mut keep_set);
4345                }
4346            }
4347            crate::delete::prune_extraneous(
4348                &PROGRESS,
4349                &dst_dir,
4350                std::path::Path::new(""),
4351                keep_set.as_set().expect("delete is enabled"),
4352                settings.filter.as_ref(),
4353                &delete_settings,
4354                false,
4355                None,
4356            )
4357            .await?;
4358
4359            assert_eq!(
4360                tokio::fs::read_to_string(dst.join("node")).await?,
4361                "CURRENT-SOURCE",
4362                "the exact included file must materialize and protect its destination name"
4363            );
4364            assert_eq!(summary.hard_links_created, 1);
4365            Ok(())
4366        }
4367
4368        /// Dry-run reporting must come from the exact type rather than a stale excluded-directory
4369        /// hint.
4370        #[tokio::test]
4371        #[traced_test]
4372        async fn dry_run_source_stale_directory_hint_reports_included_file()
4373        -> Result<(), anyhow::Error> {
4374            let test_path = testutils::create_temp_dir().await?;
4375            let src = test_path.join("src");
4376            let dst = test_path.join("dst");
4377            tokio::fs::create_dir(&src).await?;
4378            tokio::fs::write(src.join("node"), "CURRENT-SOURCE").await?;
4379            let src_dir =
4380                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
4381
4382            let mut filter = FilterSettings::new();
4383            filter.add_exclude("node/")?;
4384            let mut settings = common_settings(false, false);
4385            settings.filter = Some(filter.clone());
4386            settings.copy_settings.filter = Some(filter);
4387            settings.dry_run = Some(crate::config::DryRunMode::Brief);
4388            let decision = select_source_for_dispatch(
4389                Some(EntryKind::Dir),
4390                std::path::Path::new("node"),
4391                &settings,
4392                false,
4393            );
4394
4395            let mut summary = Summary::default();
4396            let mut keep_set = DeleteKeepSet::new(None);
4397            match decision {
4398                SourceEntryDecision::Filtered { kind, .. } => {
4399                    summary = summary + skipped_summary_for(kind);
4400                }
4401                SourceEntryDecision::Dispatch(admission) => {
4402                    let admission =
4403                        walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
4404                    let result = crate::walk_driver::scope_tasks(link_internal(
4405                        &PROGRESS,
4406                        &src_dir,
4407                        None,
4408                        None,
4409                        std::ffi::OsStr::new("node"),
4410                        &src,
4411                        &dst,
4412                        None,
4413                        std::path::Path::new("node"),
4414                        UpdateRootRequirement::Optional,
4415                        &settings,
4416                        false,
4417                        admission.into(),
4418                    ))
4419                    .await?;
4420                    LinkTaskResult::from_link(std::ffi::OsString::from("node"), result)
4421                        .fold(&mut summary, &mut keep_set);
4422                }
4423            }
4424
4425            assert_eq!(summary.hard_links_created, 1);
4426            assert_eq!(summary.copy_summary.directories_skipped, 0);
4427            assert!(!dst.join("node").exists());
4428            Ok(())
4429        }
4430
4431        /// A stale special-file hint cannot suppress a regular file that the exact worker would
4432        /// materialize.
4433        #[tokio::test]
4434        #[traced_test]
4435        async fn skip_specials_stale_special_hint_rechecks_regular_file()
4436        -> Result<(), anyhow::Error> {
4437            let test_path = testutils::create_temp_dir().await?;
4438            let src = test_path.join("src");
4439            let dst = test_path.join("dst");
4440            tokio::fs::create_dir(&src).await?;
4441            tokio::fs::create_dir(&dst).await?;
4442            tokio::fs::write(src.join("node"), "CURRENT-SOURCE").await?;
4443            tokio::fs::write(dst.join("node"), "STALE-DESTINATION").await?;
4444            let src_dir =
4445                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
4446            let dst_dir =
4447                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4448
4449            let delete_settings = copy::DeleteSettings {
4450                delete_excluded: false,
4451            };
4452            let mut settings = common_settings(false, true);
4453            settings.copy_settings.skip_specials = true;
4454            settings.copy_settings.delete = Some(delete_settings.clone());
4455            let decision = select_source_for_dispatch(
4456                Some(EntryKind::Special),
4457                std::path::Path::new("node"),
4458                &settings,
4459                false,
4460            );
4461
4462            let mut summary = Summary::default();
4463            let mut keep_set = DeleteKeepSet::new(Some(&delete_settings));
4464            match decision {
4465                SourceEntryDecision::Filtered { kind, .. } => {
4466                    summary = summary + skipped_summary_for(kind);
4467                }
4468                SourceEntryDecision::Dispatch(admission) => {
4469                    let admission =
4470                        walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
4471                    let result = crate::walk_driver::scope_tasks(link_internal(
4472                        &PROGRESS,
4473                        &src_dir,
4474                        None,
4475                        Some(&dst_dir),
4476                        std::ffi::OsStr::new("node"),
4477                        &src,
4478                        &dst,
4479                        None,
4480                        std::path::Path::new("node"),
4481                        UpdateRootRequirement::Optional,
4482                        &settings,
4483                        false,
4484                        admission.into(),
4485                    ))
4486                    .await?;
4487                    LinkTaskResult::from_link(std::ffi::OsString::from("node"), result)
4488                        .fold(&mut summary, &mut keep_set);
4489                }
4490            }
4491            crate::delete::prune_extraneous(
4492                &PROGRESS,
4493                &dst_dir,
4494                std::path::Path::new(""),
4495                keep_set.as_set().expect("delete is enabled"),
4496                None,
4497                &delete_settings,
4498                false,
4499                None,
4500            )
4501            .await?;
4502
4503            assert_eq!(
4504                tokio::fs::read_to_string(dst.join("node")).await?,
4505                "CURRENT-SOURCE",
4506                "the exact regular file must replace stale destination content"
4507            );
4508            assert_eq!(summary.hard_links_created, 1);
4509            assert_eq!(summary.copy_summary.specials_skipped, 0);
4510            Ok(())
4511        }
4512
4513        /// An update-only stale excluded-directory hint must be classified exactly before a delete
4514        /// keep-set is finalized.
4515        #[tokio::test]
4516        #[traced_test]
4517        async fn destructive_update_stale_directory_hint_rechecks_included_file()
4518        -> Result<(), anyhow::Error> {
4519            let test_path = testutils::create_temp_dir().await?;
4520            let update = test_path.join("update");
4521            let dst = test_path.join("dst");
4522            tokio::fs::create_dir(&update).await?;
4523            tokio::fs::create_dir(&dst).await?;
4524            tokio::fs::write(update.join("node"), "CURRENT-UPDATE").await?;
4525            tokio::fs::write(dst.join("node"), "STALE-DESTINATION").await?;
4526            let update_dir =
4527                Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
4528            let dst_dir =
4529                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4530
4531            let mut filter = FilterSettings::new();
4532            filter.add_exclude("node/")?;
4533            let delete_settings = copy::DeleteSettings {
4534                delete_excluded: false,
4535            };
4536            let mut settings = common_settings(false, true);
4537            settings.filter = Some(filter.clone());
4538            settings.copy_settings.filter = Some(filter);
4539            settings.copy_settings.delete = Some(delete_settings.clone());
4540            let decision = select_exact_update_only_for_dispatch(
4541                &update_dir,
4542                std::ffi::OsStr::new("node"),
4543                EntryAdmission::HintedDirectory,
4544                std::path::Path::new("node"),
4545                settings.filter.as_ref(),
4546            )
4547            .await?;
4548
4549            let mut summary = Summary::default();
4550            let mut keep_set = DeleteKeepSet::new(Some(&delete_settings));
4551            match decision {
4552                UpdateOnlyDecision::Skipped { kind, .. } => {
4553                    summary = summary + skipped_summary_for(kind);
4554                }
4555                UpdateOnlyDecision::Admitted(entry) => {
4556                    let result = delegate_copy(
4557                        &PROGRESS,
4558                        &update_dir,
4559                        Some(&dst_dir),
4560                        std::ffi::OsStr::new("node"),
4561                        &update.join("node"),
4562                        &dst.join("node"),
4563                        std::path::Path::new("node"),
4564                        copy::DeleteScanAnchor::new(&dst, std::path::Path::new("node")),
4565                        &settings,
4566                        false,
4567                        entry,
4568                    )
4569                    .await?;
4570                    LinkTaskResult::from_update(
4571                        std::ffi::OsString::from("node"),
4572                        LinkEntryResult::selected(result),
4573                    )
4574                    .fold(&mut summary, &mut keep_set);
4575                }
4576            }
4577            crate::delete::prune_extraneous(
4578                &PROGRESS,
4579                &dst_dir,
4580                std::path::Path::new(""),
4581                keep_set.as_set().expect("delete is enabled"),
4582                settings.filter.as_ref(),
4583                &delete_settings,
4584                false,
4585                None,
4586            )
4587            .await?;
4588
4589            assert_eq!(
4590                tokio::fs::read_to_string(dst.join("node")).await?,
4591                "CURRENT-UPDATE",
4592                "the exact included update file must materialize and protect its destination name"
4593            );
4594            assert_eq!(summary.copy_summary.files_copied, 1);
4595            Ok(())
4596        }
4597
4598        /// Update-only preview reporting must also reclassify a stale excluded-directory hint.
4599        #[tokio::test]
4600        #[traced_test]
4601        async fn dry_run_update_stale_directory_hint_reports_included_file()
4602        -> Result<(), anyhow::Error> {
4603            let test_path = testutils::create_temp_dir().await?;
4604            let update = test_path.join("update");
4605            let dst = test_path.join("dst");
4606            tokio::fs::create_dir(&update).await?;
4607            tokio::fs::write(update.join("node"), "CURRENT-UPDATE").await?;
4608            let update_dir =
4609                Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
4610
4611            let mut filter = FilterSettings::new();
4612            filter.add_exclude("node/")?;
4613            let mut settings = common_settings(false, false);
4614            settings.filter = Some(filter.clone());
4615            settings.copy_settings.filter = Some(filter);
4616            settings.dry_run = Some(crate::config::DryRunMode::Brief);
4617            let decision = select_exact_update_only_for_dispatch(
4618                &update_dir,
4619                std::ffi::OsStr::new("node"),
4620                EntryAdmission::HintedDirectory,
4621                std::path::Path::new("node"),
4622                settings.filter.as_ref(),
4623            )
4624            .await?;
4625
4626            let mut summary = Summary::default();
4627            let mut keep_set = DeleteKeepSet::new(None);
4628            match decision {
4629                UpdateOnlyDecision::Skipped { kind, .. } => {
4630                    summary = summary + skipped_summary_for(kind);
4631                }
4632                UpdateOnlyDecision::Admitted(entry) => {
4633                    let result = delegate_copy(
4634                        &PROGRESS,
4635                        &update_dir,
4636                        None,
4637                        std::ffi::OsStr::new("node"),
4638                        &update.join("node"),
4639                        &dst.join("node"),
4640                        std::path::Path::new("node"),
4641                        copy::DeleteScanAnchor::new(&dst, std::path::Path::new("node")),
4642                        &settings,
4643                        false,
4644                        entry,
4645                    )
4646                    .await?;
4647                    LinkTaskResult::from_update(
4648                        std::ffi::OsString::from("node"),
4649                        LinkEntryResult::selected(result),
4650                    )
4651                    .fold(&mut summary, &mut keep_set);
4652                }
4653            }
4654
4655            assert_eq!(summary.copy_summary.files_copied, 1);
4656            assert_eq!(summary.copy_summary.directories_skipped, 0);
4657            assert!(!dst.join("node").exists());
4658            Ok(())
4659        }
4660
4661        /// A jointly excluded dual root skips before validating an unused destination.
4662        #[tokio::test]
4663        #[traced_test]
4664        async fn excluded_dual_root_skips_before_destination_validation()
4665        -> Result<(), anyhow::Error> {
4666            let test_path = testutils::create_temp_dir().await?;
4667            let src_parent = test_path.join("source");
4668            let update_parent = test_path.join("update");
4669            let src = src_parent.join("blocked");
4670            let update = update_parent.join("blocked");
4671            tokio::fs::create_dir(&src_parent).await?;
4672            tokio::fs::create_dir(&update_parent).await?;
4673            tokio::fs::write(&src, "SRC").await?;
4674            tokio::fs::write(&update, "UPDATE").await?;
4675            let mut filter = FilterSettings::new();
4676            filter.add_exclude("blocked").unwrap();
4677            let mut settings = common_settings(false, false);
4678            settings.filter = Some(filter);
4679            let summary = link(
4680                &PROGRESS,
4681                &test_path,
4682                &src,
4683                std::path::Path::new("/"),
4684                &Some(update),
4685                &settings,
4686                false,
4687            )
4688            .await?;
4689            assert_eq!(summary.copy_summary.files_skipped, 1);
4690            Ok(())
4691        }
4692
4693        /// An excluded source-only root under an execute-only parent must retain the default
4694        /// path's metadata-only filter exit instead of requiring a readable parent directory.
4695        #[tokio::test]
4696        async fn excluded_source_only_root_skips_under_execute_only_parent()
4697        -> Result<(), anyhow::Error> {
4698            let test_path = testutils::create_temp_dir().await?;
4699            let parent = test_path.join("parent");
4700            let src = parent.join("src");
4701            let dst = test_path.join("dst");
4702            tokio::fs::create_dir_all(&src).await?;
4703            tokio::fs::write(src.join("leaf"), b"leaf").await?;
4704            let mut filter = FilterSettings::new();
4705            filter.add_exclude("src")?;
4706            let mut settings = common_settings(false, false);
4707            settings.filter = Some(filter.clone());
4708            settings.copy_settings.filter = Some(filter);
4709            tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o111)).await?;
4710            let result = link(&PROGRESS, &test_path, &src, &dst, &None, &settings, false).await;
4711            tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).await?;
4712            let summary = result?;
4713            assert_eq!(summary.copy_summary.directories_skipped, 1);
4714            assert!(!dst.exists());
4715            Ok(())
4716        }
4717
4718        /// Test that filters apply to root directories with simple exclude patterns.
4719        #[tokio::test]
4720        #[traced_test]
4721        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
4722            let test_path = testutils::create_temp_dir().await?;
4723            // create a directory that should be excluded
4724            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
4725            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
4726            // create filter that excludes *_dir/ directories
4727            let mut filter = FilterSettings::new();
4728            filter.add_exclude("*_dir/").unwrap();
4729            let result = link(
4730                &PROGRESS,
4731                &test_path,
4732                &test_path.join("excluded_dir"),
4733                &test_path.join("dst"),
4734                &None,
4735                &Settings {
4736                    copy_settings: CopySettings {
4737                        dereference: false,
4738                        fail_early: false,
4739                        overwrite: false,
4740                        overwrite_compare: Default::default(),
4741                        overwrite_filter: None,
4742                        ignore_existing: false,
4743                        chunk_size: 0,
4744                        skip_specials: false,
4745                        remote_copy_buffer_size: 0,
4746                        filter: None,
4747                        dry_run: None,
4748                        delete: None,
4749                    },
4750                    update_compare: Default::default(),
4751                    update_exclusive: false,
4752                    filter: Some(filter),
4753                    dry_run: None,
4754                    preserve: preserve::preserve_all(),
4755                },
4756                false,
4757            )
4758            .await?;
4759            // directory should NOT be linked because it matches exclude pattern
4760            assert_eq!(
4761                result.copy_summary.directories_created, 0,
4762                "root directory matching exclude should not be created"
4763            );
4764            assert!(
4765                !test_path.join("dst").exists(),
4766                "excluded root directory should not exist at destination"
4767            );
4768            Ok(())
4769        }
4770        /// Test that filters apply to root symlinks with simple exclude patterns.
4771        #[tokio::test]
4772        #[traced_test]
4773        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
4774            let test_path = testutils::create_temp_dir().await?;
4775            // create a target file and a symlink to it
4776            tokio::fs::write(test_path.join("target.txt"), "content").await?;
4777            tokio::fs::symlink(
4778                test_path.join("target.txt"),
4779                test_path.join("excluded_link"),
4780            )
4781            .await?;
4782            // create filter that excludes *_link
4783            let mut filter = FilterSettings::new();
4784            filter.add_exclude("*_link").unwrap();
4785            let result = link(
4786                &PROGRESS,
4787                &test_path,
4788                &test_path.join("excluded_link"),
4789                &test_path.join("dst"),
4790                &None,
4791                &Settings {
4792                    copy_settings: CopySettings {
4793                        dereference: false,
4794                        fail_early: false,
4795                        overwrite: false,
4796                        overwrite_compare: Default::default(),
4797                        overwrite_filter: None,
4798                        ignore_existing: false,
4799                        chunk_size: 0,
4800                        skip_specials: false,
4801                        remote_copy_buffer_size: 0,
4802                        filter: None,
4803                        dry_run: None,
4804                        delete: None,
4805                    },
4806                    update_compare: Default::default(),
4807                    update_exclusive: false,
4808                    filter: Some(filter),
4809                    dry_run: None,
4810                    preserve: preserve::preserve_all(),
4811                },
4812                false,
4813            )
4814            .await?;
4815            // symlink should NOT be copied because it matches exclude pattern
4816            assert_eq!(
4817                result.copy_summary.symlinks_created, 0,
4818                "root symlink matching exclude should not be created"
4819            );
4820            assert!(
4821                !test_path.join("dst").exists(),
4822                "excluded root symlink should not exist at destination"
4823            );
4824            Ok(())
4825        }
4826        /// Test combined include and exclude patterns (exclude takes precedence).
4827        #[tokio::test]
4828        #[traced_test]
4829        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
4830            let tmp_dir = testutils::setup_test_dir().await?;
4831            let test_path = tmp_dir.as_path();
4832            // test structure from setup_test_dir:
4833            // foo/
4834            //   0.txt
4835            //   bar/ (1.txt, 2.txt, 3.txt)
4836            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
4837            // include all .txt files in bar/, but exclude 2.txt specifically
4838            let mut filter = FilterSettings::new();
4839            filter.add_include("bar/*.txt").unwrap();
4840            filter.add_exclude("bar/2.txt").unwrap();
4841            let summary = link(
4842                &PROGRESS,
4843                test_path,
4844                &test_path.join("foo"),
4845                &test_path.join("dst"),
4846                &None,
4847                &Settings {
4848                    copy_settings: CopySettings {
4849                        dereference: false,
4850                        fail_early: false,
4851                        overwrite: false,
4852                        overwrite_compare: Default::default(),
4853                        overwrite_filter: None,
4854                        ignore_existing: false,
4855                        chunk_size: 0,
4856                        skip_specials: false,
4857                        remote_copy_buffer_size: 0,
4858                        filter: None,
4859                        dry_run: None,
4860                        delete: None,
4861                    },
4862                    update_compare: Default::default(),
4863                    update_exclusive: false,
4864                    filter: Some(filter),
4865                    dry_run: None,
4866                    preserve: preserve::preserve_all(),
4867                },
4868                false,
4869            )
4870            .await?;
4871            // should link: bar/1.txt, bar/3.txt = 2 hard links
4872            // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
4873            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
4874            assert_eq!(
4875                summary.copy_summary.files_skipped, 2,
4876                "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
4877            );
4878            // verify
4879            assert!(
4880                test_path.join("dst/bar/1.txt").exists(),
4881                "bar/1.txt should be linked"
4882            );
4883            assert!(
4884                !test_path.join("dst/bar/2.txt").exists(),
4885                "bar/2.txt should be excluded"
4886            );
4887            assert!(
4888                test_path.join("dst/bar/3.txt").exists(),
4889                "bar/3.txt should be linked"
4890            );
4891            Ok(())
4892        }
4893        /// Test that skipped counts accurately reflect what was filtered.
4894        #[tokio::test]
4895        #[traced_test]
4896        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
4897            let tmp_dir = testutils::setup_test_dir().await?;
4898            let test_path = tmp_dir.as_path();
4899            // test structure from setup_test_dir:
4900            // foo/
4901            //   0.txt
4902            //   bar/ (1.txt, 2.txt, 3.txt)
4903            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
4904            // exclude bar/ directory entirely
4905            let mut filter = FilterSettings::new();
4906            filter.add_exclude("bar/").unwrap();
4907            let summary = link(
4908                &PROGRESS,
4909                test_path,
4910                &test_path.join("foo"),
4911                &test_path.join("dst"),
4912                &None,
4913                &Settings {
4914                    copy_settings: CopySettings {
4915                        dereference: false,
4916                        fail_early: false,
4917                        overwrite: false,
4918                        overwrite_compare: Default::default(),
4919                        overwrite_filter: None,
4920                        ignore_existing: false,
4921                        chunk_size: 0,
4922                        skip_specials: false,
4923                        remote_copy_buffer_size: 0,
4924                        filter: None,
4925                        dry_run: None,
4926                        delete: None,
4927                    },
4928                    update_compare: Default::default(),
4929                    update_exclusive: false,
4930                    filter: Some(filter),
4931                    dry_run: None,
4932                    preserve: preserve::preserve_all(),
4933                },
4934                false,
4935            )
4936            .await?;
4937            // linked: 0.txt (1 hard link), baz/4.txt (1 hard link)
4938            // symlinks copied: 5.txt, 6.txt
4939            // skipped: bar directory (1 dir)
4940            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
4941            assert_eq!(
4942                summary.copy_summary.symlinks_created, 2,
4943                "should copy 2 symlinks"
4944            );
4945            assert_eq!(
4946                summary.copy_summary.directories_skipped, 1,
4947                "should skip 1 directory (bar)"
4948            );
4949            // bar should not exist in dst
4950            assert!(
4951                !test_path.join("dst/bar").exists(),
4952                "bar directory should not be linked"
4953            );
4954            Ok(())
4955        }
4956        /// Test that empty directories are not created when they were only traversed to look
4957        /// for matches (regression test for bug where --include='foo' would create empty dir baz).
4958        #[tokio::test]
4959        #[traced_test]
4960        async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
4961            let test_path = testutils::create_temp_dir().await?;
4962            // create structure:
4963            // src/
4964            //   foo (file)
4965            //   bar (file)
4966            //   baz/ (empty directory)
4967            let src_path = test_path.join("src");
4968            tokio::fs::create_dir(&src_path).await?;
4969            tokio::fs::write(src_path.join("foo"), "content").await?;
4970            tokio::fs::write(src_path.join("bar"), "content").await?;
4971            tokio::fs::create_dir(src_path.join("baz")).await?;
4972            // include only 'foo' file
4973            let mut filter = FilterSettings::new();
4974            filter.add_include("foo").unwrap();
4975            let summary = link(
4976                &PROGRESS,
4977                &test_path,
4978                &src_path,
4979                &test_path.join("dst"),
4980                &None,
4981                &Settings {
4982                    copy_settings: copy::Settings {
4983                        dereference: false,
4984                        fail_early: false,
4985                        overwrite: false,
4986                        overwrite_compare: Default::default(),
4987                        overwrite_filter: None,
4988                        ignore_existing: false,
4989                        chunk_size: 0,
4990                        skip_specials: false,
4991                        remote_copy_buffer_size: 0,
4992                        filter: None,
4993                        dry_run: None,
4994                        delete: None,
4995                    },
4996                    update_compare: Default::default(),
4997                    update_exclusive: false,
4998                    filter: Some(filter),
4999                    dry_run: None,
5000                    preserve: preserve::preserve_all(),
5001                },
5002                false,
5003            )
5004            .await?;
5005            // only 'foo' should be linked
5006            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
5007            assert_eq!(
5008                summary.copy_summary.directories_created, 1,
5009                "should create only root directory (not empty 'baz')"
5010            );
5011            // verify foo was linked
5012            assert!(
5013                test_path.join("dst").join("foo").exists(),
5014                "foo should be linked"
5015            );
5016            // verify bar was not linked (not matching include pattern)
5017            assert!(
5018                !test_path.join("dst").join("bar").exists(),
5019                "bar should not be linked"
5020            );
5021            // verify empty baz directory was NOT created
5022            assert!(
5023                !test_path.join("dst").join("baz").exists(),
5024                "empty baz directory should NOT be created"
5025            );
5026            Ok(())
5027        }
5028        /// Test that directories with only non-matching content are not created at destination.
5029        /// This is different from empty directories - the source dir has content but none matches.
5030        #[tokio::test]
5031        #[traced_test]
5032        async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
5033            let test_path = testutils::create_temp_dir().await?;
5034            // create structure:
5035            // src/
5036            //   foo (file)
5037            //   baz/
5038            //     qux (file - doesn't match 'foo')
5039            //     quux (file - doesn't match 'foo')
5040            let src_path = test_path.join("src");
5041            tokio::fs::create_dir(&src_path).await?;
5042            tokio::fs::write(src_path.join("foo"), "content").await?;
5043            tokio::fs::create_dir(src_path.join("baz")).await?;
5044            tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
5045            tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
5046            // include only 'foo' file
5047            let mut filter = FilterSettings::new();
5048            filter.add_include("foo").unwrap();
5049            let summary = link(
5050                &PROGRESS,
5051                &test_path,
5052                &src_path,
5053                &test_path.join("dst"),
5054                &None,
5055                &Settings {
5056                    copy_settings: copy::Settings {
5057                        dereference: false,
5058                        fail_early: false,
5059                        overwrite: false,
5060                        overwrite_compare: Default::default(),
5061                        overwrite_filter: None,
5062                        ignore_existing: false,
5063                        chunk_size: 0,
5064                        skip_specials: false,
5065                        remote_copy_buffer_size: 0,
5066                        filter: None,
5067                        dry_run: None,
5068                        delete: None,
5069                    },
5070                    update_compare: Default::default(),
5071                    update_exclusive: false,
5072                    filter: Some(filter),
5073                    dry_run: None,
5074                    preserve: preserve::preserve_all(),
5075                },
5076                false,
5077            )
5078            .await?;
5079            // only 'foo' should be linked
5080            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
5081            assert_eq!(
5082                summary.copy_summary.files_skipped, 2,
5083                "should skip 2 files (qux and quux)"
5084            );
5085            assert_eq!(
5086                summary.copy_summary.directories_created, 1,
5087                "should create only root directory (not 'baz' with non-matching content)"
5088            );
5089            // verify foo was linked
5090            assert!(
5091                test_path.join("dst").join("foo").exists(),
5092                "foo should be linked"
5093            );
5094            // verify baz directory was NOT created (even though source baz has content)
5095            assert!(
5096                !test_path.join("dst").join("baz").exists(),
5097                "baz directory should NOT be created (no matching content inside)"
5098            );
5099            Ok(())
5100        }
5101        /// Test that empty directories are not reported as created in dry-run mode
5102        /// when they were only traversed.
5103        #[tokio::test]
5104        #[traced_test]
5105        async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
5106            let test_path = testutils::create_temp_dir().await?;
5107            // create structure:
5108            // src/
5109            //   foo (file)
5110            //   bar (file)
5111            //   baz/ (empty directory)
5112            let src_path = test_path.join("src");
5113            tokio::fs::create_dir(&src_path).await?;
5114            tokio::fs::write(src_path.join("foo"), "content").await?;
5115            tokio::fs::write(src_path.join("bar"), "content").await?;
5116            tokio::fs::create_dir(src_path.join("baz")).await?;
5117            // include only 'foo' file
5118            let mut filter = FilterSettings::new();
5119            filter.add_include("foo").unwrap();
5120            let summary = link(
5121                &PROGRESS,
5122                &test_path,
5123                &src_path,
5124                &test_path.join("dst"),
5125                &None,
5126                &Settings {
5127                    copy_settings: copy::Settings {
5128                        dereference: false,
5129                        fail_early: false,
5130                        overwrite: false,
5131                        overwrite_compare: Default::default(),
5132                        overwrite_filter: None,
5133                        ignore_existing: false,
5134                        chunk_size: 0,
5135                        skip_specials: false,
5136                        remote_copy_buffer_size: 0,
5137                        filter: None,
5138                        dry_run: None,
5139                        delete: None,
5140                    },
5141                    update_compare: Default::default(),
5142                    update_exclusive: false,
5143                    filter: Some(filter),
5144                    dry_run: Some(crate::config::DryRunMode::Explain),
5145                    preserve: preserve::preserve_all(),
5146                },
5147                false,
5148            )
5149            .await?;
5150            // only 'foo' should be reported as would-be-linked
5151            assert_eq!(
5152                summary.hard_links_created, 1,
5153                "should report only 'foo' would be linked"
5154            );
5155            assert_eq!(
5156                summary.copy_summary.directories_created, 1,
5157                "should report only root directory would be created (not empty 'baz')"
5158            );
5159            // verify nothing was actually created (dry-run mode)
5160            assert!(
5161                !test_path.join("dst").exists(),
5162                "dst should not exist in dry-run"
5163            );
5164            Ok(())
5165        }
5166        /// Test that existing directories are NOT removed when using --overwrite,
5167        /// even if nothing is linked into them due to filters.
5168        #[tokio::test]
5169        #[traced_test]
5170        async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
5171            let test_path = testutils::create_temp_dir().await?;
5172            // create source structure:
5173            // src/
5174            //   foo (file)
5175            //   bar (file)
5176            //   baz/ (empty directory)
5177            let src_path = test_path.join("src");
5178            tokio::fs::create_dir(&src_path).await?;
5179            tokio::fs::write(src_path.join("foo"), "content").await?;
5180            tokio::fs::write(src_path.join("bar"), "content").await?;
5181            tokio::fs::create_dir(src_path.join("baz")).await?;
5182            // create destination with baz directory already existing
5183            let dst_path = test_path.join("dst");
5184            tokio::fs::create_dir(&dst_path).await?;
5185            tokio::fs::create_dir(dst_path.join("baz")).await?;
5186            // add a marker file inside dst/baz to verify we don't touch it
5187            tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
5188            // include only 'foo' file - baz should not match
5189            let mut filter = FilterSettings::new();
5190            filter.add_include("foo").unwrap();
5191            let summary = link(
5192                &PROGRESS,
5193                &test_path,
5194                &src_path,
5195                &dst_path,
5196                &None,
5197                &Settings {
5198                    copy_settings: copy::Settings {
5199                        dereference: false,
5200                        fail_early: false,
5201                        overwrite: true, // enable overwrite mode
5202                        overwrite_compare: Default::default(),
5203                        overwrite_filter: None,
5204                        ignore_existing: false,
5205                        chunk_size: 0,
5206                        skip_specials: false,
5207                        remote_copy_buffer_size: 0,
5208                        filter: None,
5209                        dry_run: None,
5210                        delete: None,
5211                    },
5212                    update_compare: Default::default(),
5213                    update_exclusive: false,
5214                    filter: Some(filter),
5215                    dry_run: None,
5216                    preserve: preserve::preserve_all(),
5217                },
5218                false,
5219            )
5220            .await?;
5221            // foo should be linked
5222            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
5223            // dst and baz should be unchanged (both already existed)
5224            assert_eq!(
5225                summary.copy_summary.directories_unchanged, 2,
5226                "root dst and baz directories should be unchanged"
5227            );
5228            assert_eq!(
5229                summary.copy_summary.directories_created, 0,
5230                "should not create any directories"
5231            );
5232            // verify foo was linked
5233            assert!(dst_path.join("foo").exists(), "foo should be linked");
5234            // verify bar was NOT linked
5235            assert!(!dst_path.join("bar").exists(), "bar should not be linked");
5236            // verify existing baz directory still exists with its content
5237            assert!(
5238                dst_path.join("baz").exists(),
5239                "existing baz directory should still exist"
5240            );
5241            assert!(
5242                dst_path.join("baz").join("marker.txt").exists(),
5243                "existing content in baz should still exist"
5244            );
5245            Ok(())
5246        }
5247
5248        /// Regression: an update-only entry matching an `--exclude` pattern must NOT be copied to
5249        /// the destination when `--delete` is OFF. The fd-based link delegates update-only entries
5250        /// to `copy::copy_child` (which wraps `copy_internal` and does not re-apply a top-level
5251        /// filter), so the update loop must evaluate the filter itself — independently of `--delete`
5252        /// — and skip the delegation, matching the old path-based `copy_with_filter_base`.
5253        #[tokio::test]
5254        #[traced_test]
5255        async fn update_only_excluded_entry_not_copied_without_delete() -> Result<(), anyhow::Error>
5256        {
5257            let test_path = testutils::create_temp_dir().await?;
5258            // src has `keep.txt`; update has `keep.txt` (also in src) plus update-only `extra.txt`
5259            // and `wanted.txt`. With `--exclude extra.txt` and NO `--delete`, `extra.txt` must be
5260            // skipped while `wanted.txt` is copied.
5261            let src = test_path.join("src");
5262            let update = test_path.join("update");
5263            let dst = test_path.join("dst");
5264            tokio::fs::create_dir(&src).await?;
5265            tokio::fs::create_dir(&update).await?;
5266            tokio::fs::write(src.join("keep.txt"), "keep").await?;
5267            tokio::fs::write(update.join("keep.txt"), "keep").await?;
5268            tokio::fs::write(update.join("extra.txt"), "EXCLUDED").await?;
5269            tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
5270
5271            let mut filter = FilterSettings::new();
5272            filter.add_exclude("extra.txt").unwrap();
5273            let mut settings = common_settings(false, false);
5274            settings.filter = Some(filter);
5275            // --delete is OFF (the bug only manifests with delete off).
5276            assert!(settings.copy_settings.delete.is_none());
5277
5278            let summary = link(
5279                &PROGRESS,
5280                &test_path,
5281                &src,
5282                &dst,
5283                &Some(update.clone()),
5284                &settings,
5285                false,
5286            )
5287            .await?;
5288
5289            assert!(
5290                !dst.join("extra.txt").exists(),
5291                "update-only entry matching --exclude must NOT be copied when --delete is off"
5292            );
5293            assert!(
5294                dst.join("wanted.txt").exists(),
5295                "non-excluded update-only entry should be copied"
5296            );
5297            assert!(dst.join("keep.txt").exists(), "shared entry should exist");
5298            assert_eq!(
5299                summary.copy_summary.files_skipped, 1,
5300                "the excluded update-only file should be counted skipped"
5301            );
5302            Ok(())
5303        }
5304
5305        /// Verify a hard-link relationship between two paths by inode + device identity.
5306        fn are_hardlinked(a: &std::path::Path, b: &std::path::Path) -> bool {
5307            use std::os::unix::fs::MetadataExt;
5308            match (std::fs::symlink_metadata(a), std::fs::symlink_metadata(b)) {
5309                (Ok(ma), Ok(mb)) => ma.ino() == mb.ino() && ma.dev() == mb.dev(),
5310                _ => false,
5311            }
5312        }
5313
5314        /// The chatgpt-codex re-review scenario (PR #247): in rlink's dual-tree walk the source
5315        /// loop evaluates the filter against the SOURCE entry's type. When `src/cache` is a FILE
5316        /// and `update/cache` is a DIRECTORY, a dir-only exclude `cache/` passes the src file (a
5317        /// dir-only pattern doesn't match a file), so `link_internal` runs and hits its
5318        /// type-mismatch branch. Before the fix that branch unconditionally delegated a copy of the
5319        /// UPDATE entry — and `copy_child` does not re-apply the top-level filter to the delegated
5320        /// root — so the excluded `cache/` directory was copied. The fix re-checks the filter using
5321        /// the UPDATE entry's type; the excluded update is dropped and, under union (`--update`)
5322        /// semantics, the src `cache` FILE is materialized instead.
5323        ///
5324        /// This test FAILS without the fix: `dst/cache` is created as the excluded update directory
5325        /// (and the src file is not materialized).
5326        #[tokio::test]
5327        #[traced_test]
5328        async fn type_mismatch_excluded_update_dir_not_copied_src_file_kept()
5329        -> Result<(), anyhow::Error> {
5330            let test_path = testutils::create_temp_dir().await?;
5331            let src = test_path.join("src");
5332            let update = test_path.join("update");
5333            let dst = test_path.join("dst");
5334            tokio::fs::create_dir(&src).await?;
5335            tokio::fs::create_dir(&update).await?;
5336            // src `cache` is a FILE; update `cache` is a DIRECTORY (the type mismatch).
5337            tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
5338            tokio::fs::create_dir(update.join("cache")).await?;
5339            tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
5340            // a non-conflicting shared file to confirm normal linking still happens.
5341            tokio::fs::write(src.join("keep.txt"), "keep").await?;
5342            tokio::fs::write(update.join("keep.txt"), "keep").await?;
5343            // pin an identical mtime (incl. nsec) on both `keep.txt` copies so the
5344            // size+mtime `update_compare` deterministically treats them as unchanged and
5345            // hard-links from src. Two separate writes can otherwise land on different
5346            // nanoseconds, flakily comparing as changed and copying instead (the bytes are
5347            // identical either way) — see PR #247 CI flake on test-musl-debug.
5348            let keep_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
5349            filetime::set_file_mtime(src.join("keep.txt"), keep_mtime)?;
5350            filetime::set_file_mtime(update.join("keep.txt"), keep_mtime)?;
5351
5352            let mut filter = FilterSettings::new();
5353            filter.add_exclude("cache/").unwrap(); // dir-only: matches a dir `cache`, not a file
5354            let mut settings = common_settings(false, false);
5355            settings.filter = Some(filter);
5356            assert!(settings.copy_settings.delete.is_none());
5357
5358            let summary = link(
5359                &PROGRESS,
5360                &test_path,
5361                &src,
5362                &dst,
5363                &Some(update.clone()),
5364                &settings,
5365                false,
5366            )
5367            .await?;
5368
5369            // the excluded update directory must NOT be copied.
5370            assert!(
5371                !dst.join("cache").join("inner.dat").exists(),
5372                "excluded update directory `cache/` must not be copied"
5373            );
5374            assert!(
5375                !dst.join("cache").is_dir(),
5376                "dst/cache must not be the excluded update directory"
5377            );
5378            // the src `cache` FILE stands (union semantics) and is hard-linked from src.
5379            assert!(
5380                dst.join("cache").is_file(),
5381                "src `cache` file must be materialized when the update dir is excluded"
5382            );
5383            assert_eq!(
5384                tokio::fs::read_to_string(dst.join("cache")).await?,
5385                "SRC-FILE"
5386            );
5387            assert!(
5388                are_hardlinked(&src.join("cache"), &dst.join("cache")),
5389                "the src `cache` file must be hard-linked into the destination"
5390            );
5391            assert!(
5392                dst.join("keep.txt").exists(),
5393                "shared entry should still link"
5394            );
5395            // `cache` (src file, union) and `keep.txt` (unchanged) are both hard-linked from src;
5396            // nothing is copied. Exactly one directory is created — the `dst` root — proving the
5397            // excluded `cache/` subtree added no directory.
5398            assert_eq!(summary.hard_links_created, 2);
5399            assert_eq!(summary.copy_summary.files_copied, 0);
5400            assert_eq!(
5401                summary.copy_summary.directories_created, 1,
5402                "only the dst root is created; the excluded `cache/` dir must not be"
5403            );
5404            Ok(())
5405        }
5406
5407        /// The REVERSE type mismatch (the symmetric code path): `src/data` is a DIRECTORY and
5408        /// `update/data` is a FILE. The dir-only include `data/` matches the directory form of the
5409        /// name but not the file form, and `data/**` includes the directory's contents. So the src
5410        /// `data` directory (and its `inner.txt`) passes the filter and the src loop spawns the
5411        /// worker, while the update `data` FILE is `ExcludedByDefault` (no include matches a file
5412        /// named `data`). The type-mismatch branch re-checks the filter using the update FILE's
5413        /// type, finds it excluded, and (union semantics) materializes the src DIRECTORY instead of
5414        /// copying the excluded update file. Without the fix the excluded update file would replace
5415        /// the src directory at the destination.
5416        #[tokio::test]
5417        #[traced_test]
5418        async fn reverse_type_mismatch_excluded_update_file_not_copied_src_dir_kept()
5419        -> Result<(), anyhow::Error> {
5420            let test_path = testutils::create_temp_dir().await?;
5421            let src = test_path.join("src");
5422            let update = test_path.join("update");
5423            let dst = test_path.join("dst");
5424            tokio::fs::create_dir(&src).await?;
5425            tokio::fs::create_dir(&update).await?;
5426            // src `data` is a DIRECTORY (with a file inside); update `data` is a FILE.
5427            tokio::fs::create_dir(src.join("data")).await?;
5428            tokio::fs::write(src.join("data").join("inner.txt"), "SRC-DIR-CONTENT").await?;
5429            tokio::fs::write(update.join("data"), "UPDATE-FILE-EXCLUDED").await?;
5430
5431            // `data/` (dir-only) includes the directory form of the name; `data/**` includes its
5432            // contents. The update FILE `data` matches neither and is excluded by type — the
5433            // symmetric form of the bot's scenario.
5434            let mut filter = FilterSettings::new();
5435            filter.add_include("data/").unwrap();
5436            filter.add_include("data/**").unwrap();
5437            let mut settings = common_settings(false, false);
5438            settings.filter = Some(filter);
5439            assert!(settings.copy_settings.delete.is_none());
5440
5441            link(
5442                &PROGRESS,
5443                &test_path,
5444                &src,
5445                &dst,
5446                &Some(update.clone()),
5447                &settings,
5448                false,
5449            )
5450            .await?;
5451
5452            // the excluded update FILE must NOT overwrite/replace the src directory.
5453            assert!(
5454                dst.join("data").is_dir(),
5455                "src `data` directory must be materialized when the update file is excluded"
5456            );
5457            assert!(
5458                dst.join("data").join("inner.txt").exists(),
5459                "src directory contents must be linked through"
5460            );
5461            assert!(
5462                are_hardlinked(
5463                    &src.join("data").join("inner.txt"),
5464                    &dst.join("data").join("inner.txt")
5465                ),
5466                "src directory's file must be hard-linked into the destination"
5467            );
5468            Ok(())
5469        }
5470
5471        /// `--update-exclusive` + the type-mismatch scenario: src `cache` is a FILE, update `cache`
5472        /// is a DIRECTORY excluded by `cache/`. Under exclusive mode only the (filter-passing)
5473        /// update set materializes, so an EXCLUDED update entry materializes NOTHING — the src is
5474        /// not materialized (it is not a fallback under exclusivity), and no stale src copy is left.
5475        /// This mirrors the NotFound-under-exclusive case (`return Ok(Default::default())`).
5476        #[tokio::test]
5477        #[traced_test]
5478        async fn type_mismatch_excluded_update_dir_update_exclusive_materializes_nothing()
5479        -> Result<(), anyhow::Error> {
5480            let test_path = testutils::create_temp_dir().await?;
5481            let src = test_path.join("src");
5482            let update = test_path.join("update");
5483            let dst = test_path.join("dst");
5484            tokio::fs::create_dir(&src).await?;
5485            tokio::fs::create_dir(&update).await?;
5486            tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
5487            tokio::fs::create_dir(update.join("cache")).await?;
5488            tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
5489            // a filter-passing update-only file proves the rest of the exclusive copy still works.
5490            tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
5491
5492            let mut filter = FilterSettings::new();
5493            filter.add_exclude("cache/").unwrap();
5494            let mut settings = common_settings(false, false);
5495            settings.update_exclusive = true;
5496            settings.filter = Some(filter);
5497
5498            link(
5499                &PROGRESS,
5500                &test_path,
5501                &src,
5502                &dst,
5503                &Some(update.clone()),
5504                &settings,
5505                false,
5506            )
5507            .await?;
5508
5509            assert!(
5510                !dst.join("cache").exists(),
5511                "under --update-exclusive an excluded-update type-mismatch must materialize nothing \
5512                 (no excluded dir, no stale src file)"
5513            );
5514            assert!(
5515                dst.join("wanted.txt").exists(),
5516                "filter-passing update-only entries are still copied under --update-exclusive"
5517            );
5518            Ok(())
5519        }
5520
5521        /// `--delete` + the type-mismatch scenario under normal `--update`: the src `cache` FILE is
5522        /// materialized (union) and MUST be retained by the keep-set — never materialized-then-pruned
5523        /// — while a pre-existing extraneous dst entry is removed. Also confirms the excluded update
5524        /// directory leaves no leftover. `prune_extraneous` would otherwise prune the dst `cache`
5525        /// file (a dir-only `cache/` exclude does not protect a file), so correctness depends on
5526        /// `cache` staying in the keep-set.
5527        #[tokio::test]
5528        #[traced_test]
5529        async fn type_mismatch_excluded_update_dir_delete_keeps_src_file()
5530        -> Result<(), anyhow::Error> {
5531            let test_path = testutils::create_temp_dir().await?;
5532            let src = test_path.join("src");
5533            let update = test_path.join("update");
5534            let dst = test_path.join("dst");
5535            tokio::fs::create_dir(&src).await?;
5536            tokio::fs::create_dir(&update).await?;
5537            tokio::fs::create_dir(&dst).await?;
5538            tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
5539            tokio::fs::create_dir(update.join("cache")).await?;
5540            tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
5541            // pre-existing extraneous dst entry that --delete should prune.
5542            tokio::fs::write(dst.join("stale.txt"), "stale").await?;
5543
5544            let mut filter = FilterSettings::new();
5545            filter.add_exclude("cache/").unwrap();
5546            let mut settings = common_settings(false, true); // --delete implies --overwrite
5547            settings.filter = Some(filter);
5548            settings.copy_settings.delete = Some(copy::DeleteSettings {
5549                delete_excluded: false,
5550            });
5551
5552            link(
5553                &PROGRESS,
5554                &test_path,
5555                &src,
5556                &dst,
5557                &Some(update.clone()),
5558                &settings,
5559                false,
5560            )
5561            .await?;
5562
5563            assert!(
5564                dst.join("cache").is_file(),
5565                "src `cache` file must survive --delete (kept in the keep-set, not pruned)"
5566            );
5567            assert_eq!(
5568                tokio::fs::read_to_string(dst.join("cache")).await?,
5569                "SRC-FILE"
5570            );
5571            assert!(
5572                !dst.join("cache").is_dir(),
5573                "the excluded update directory must leave no leftover"
5574            );
5575            assert!(
5576                !dst.join("stale.txt").exists(),
5577                "extraneous dst entry must be pruned by --delete"
5578            );
5579            Ok(())
5580        }
5581    }
5582    mod dry_run_tests {
5583        use super::*;
5584
5585        #[tokio::test]
5586        async fn update_delete_preview_keeps_direct_and_delegated_pruning_beneath_outer_dst()
5587        -> Result<(), anyhow::Error> {
5588            let root = testutils::create_temp_dir().await?;
5589            let src = root.join("src");
5590            let update = root.join("update");
5591            let outside = root.join("outside");
5592            let dst = root.join("dst");
5593            tokio::fs::create_dir_all(src.join("direct")).await?;
5594            tokio::fs::create_dir_all(update.join("delegated")).await?;
5595            tokio::fs::create_dir_all(outside.join("direct")).await?;
5596            tokio::fs::create_dir_all(outside.join("delegated")).await?;
5597            tokio::fs::write(outside.join("direct/stale"), "DIRECT-OUTSIDE").await?;
5598            tokio::fs::write(outside.join("delegated/stale"), "DELEGATED-OUTSIDE").await?;
5599            tokio::fs::symlink(&outside, &dst).await?;
5600            let mut settings = common_settings(false, true);
5601            settings.dry_run = Some(crate::config::DryRunMode::Brief);
5602            settings.copy_settings.dry_run = settings.dry_run;
5603            settings.copy_settings.delete = Some(copy::DeleteSettings {
5604                delete_excluded: false,
5605            });
5606            let summary = link(
5607                &PROGRESS,
5608                &root,
5609                &src,
5610                &dst,
5611                &Some(update.clone()),
5612                &settings,
5613                false,
5614            )
5615            .await?;
5616            assert_eq!(summary.copy_summary.rm_summary.files_removed, 0);
5617            assert_eq!(
5618                tokio::fs::read_to_string(outside.join("direct/stale")).await?,
5619                "DIRECT-OUTSIDE"
5620            );
5621            assert_eq!(
5622                tokio::fs::read_to_string(outside.join("delegated/stale")).await?,
5623                "DELEGATED-OUTSIDE"
5624            );
5625
5626            let real_dst = root.join("real-dst");
5627            tokio::fs::create_dir_all(real_dst.join("direct")).await?;
5628            tokio::fs::create_dir_all(real_dst.join("delegated")).await?;
5629            tokio::fs::write(real_dst.join("delegated/stale"), "INSIDE-STALE").await?;
5630            let real_summary = link(
5631                &PROGRESS,
5632                &root,
5633                &src,
5634                &real_dst,
5635                &Some(update),
5636                &settings,
5637                false,
5638            )
5639            .await?;
5640            assert_eq!(real_summary.copy_summary.rm_summary.files_removed, 1);
5641            assert_eq!(
5642                tokio::fs::read_to_string(real_dst.join("delegated/stale")).await?,
5643                "INSIDE-STALE"
5644            );
5645            Ok(())
5646        }
5647
5648        #[tokio::test]
5649        async fn update_exclusive_delete_preview_keeps_selected_shared_file()
5650        -> Result<(), anyhow::Error> {
5651            let root = testutils::create_temp_dir().await?;
5652            let src = root.join("src");
5653            let update = root.join("update");
5654            let dst = root.join("dst");
5655            tokio::fs::create_dir(&src).await?;
5656            tokio::fs::create_dir(&update).await?;
5657            tokio::fs::create_dir(&dst).await?;
5658            tokio::fs::write(src.join("shared"), b"old").await?;
5659            tokio::fs::write(update.join("shared"), b"replacement").await?;
5660            tokio::fs::write(dst.join("shared"), b"destination").await?;
5661            tokio::fs::write(dst.join("stale"), b"stale").await?;
5662
5663            let mut settings = common_settings(false, false);
5664            settings.update_exclusive = true;
5665            settings.dry_run = Some(crate::config::DryRunMode::Brief);
5666            settings.copy_settings.dry_run = settings.dry_run;
5667            settings.copy_settings.delete = Some(copy::DeleteSettings {
5668                delete_excluded: false,
5669            });
5670
5671            let summary = link(
5672                &PROGRESS,
5673                &root,
5674                &src,
5675                &dst,
5676                &Some(update),
5677                &settings,
5678                false,
5679            )
5680            .await?;
5681
5682            assert_eq!(summary.hard_links_created, 0);
5683            assert_eq!(summary.copy_summary.files_copied, 1);
5684            assert_eq!(summary.copy_summary.rm_summary.files_removed, 1);
5685            assert!(dst.join("shared").exists());
5686            assert!(dst.join("stale").exists());
5687            Ok(())
5688        }
5689
5690        #[tokio::test]
5691        async fn update_exclusive_delete_preview_omits_source_only_file()
5692        -> Result<(), anyhow::Error> {
5693            let root = testutils::create_temp_dir().await?;
5694            let src = root.join("src");
5695            let update = root.join("update");
5696            let dst = root.join("dst");
5697            tokio::fs::create_dir(&src).await?;
5698            tokio::fs::create_dir(&update).await?;
5699            tokio::fs::create_dir(&dst).await?;
5700            tokio::fs::write(src.join("source-only"), b"source").await?;
5701            tokio::fs::write(dst.join("source-only"), b"destination").await?;
5702
5703            let mut settings = common_settings(false, false);
5704            settings.update_exclusive = true;
5705            settings.dry_run = Some(crate::config::DryRunMode::Brief);
5706            settings.copy_settings.dry_run = settings.dry_run;
5707            settings.copy_settings.delete = Some(copy::DeleteSettings {
5708                delete_excluded: false,
5709            });
5710
5711            let summary = link(
5712                &PROGRESS,
5713                &root,
5714                &src,
5715                &dst,
5716                &Some(update),
5717                &settings,
5718                false,
5719            )
5720            .await?;
5721
5722            assert_eq!(summary.hard_links_created, 0);
5723            assert_eq!(summary.copy_summary.files_copied, 0);
5724            assert_eq!(summary.copy_summary.rm_summary.files_removed, 1);
5725            assert!(dst.join("source-only").exists());
5726            Ok(())
5727        }
5728
5729        /// Test that dry-run mode for files doesn't create hard links.
5730        #[tokio::test]
5731        #[traced_test]
5732        async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
5733            let tmp_dir = testutils::setup_test_dir().await?;
5734            let test_path = tmp_dir.as_path();
5735            let src_file = test_path.join("foo/0.txt");
5736            let dst_file = test_path.join("dst_link.txt");
5737            // verify destination doesn't exist
5738            assert!(
5739                !dst_file.exists(),
5740                "destination should not exist before dry-run"
5741            );
5742            let summary = link(
5743                &PROGRESS,
5744                test_path,
5745                &src_file,
5746                &dst_file,
5747                &None,
5748                &Settings {
5749                    copy_settings: CopySettings {
5750                        dereference: false,
5751                        fail_early: false,
5752                        overwrite: false,
5753                        overwrite_compare: Default::default(),
5754                        overwrite_filter: None,
5755                        ignore_existing: false,
5756                        chunk_size: 0,
5757                        skip_specials: false,
5758                        remote_copy_buffer_size: 0,
5759                        filter: None,
5760                        dry_run: None,
5761                        delete: None,
5762                    },
5763                    update_compare: Default::default(),
5764                    update_exclusive: false,
5765                    filter: None,
5766                    dry_run: Some(crate::config::DryRunMode::Brief),
5767                    preserve: preserve::preserve_all(),
5768                },
5769                false,
5770            )
5771            .await?;
5772            // verify destination still doesn't exist
5773            assert!(!dst_file.exists(), "dry-run should not create hard link");
5774            // verify summary reports what would be created
5775            assert_eq!(
5776                summary.hard_links_created, 1,
5777                "dry-run should report 1 hard link that would be created"
5778            );
5779            Ok(())
5780        }
5781        /// Test that dry-run mode for directories doesn't create the destination directory.
5782        #[tokio::test]
5783        #[traced_test]
5784        async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
5785            let tmp_dir = testutils::setup_test_dir().await?;
5786            let test_path = tmp_dir.as_path();
5787            let dst_path = test_path.join("nonexistent_dst");
5788            // verify destination doesn't exist
5789            assert!(
5790                !dst_path.exists(),
5791                "destination should not exist before dry-run"
5792            );
5793            let summary = link(
5794                &PROGRESS,
5795                test_path,
5796                &test_path.join("foo"),
5797                &dst_path,
5798                &None,
5799                &Settings {
5800                    copy_settings: CopySettings {
5801                        dereference: false,
5802                        fail_early: false,
5803                        overwrite: false,
5804                        overwrite_compare: Default::default(),
5805                        overwrite_filter: None,
5806                        ignore_existing: false,
5807                        chunk_size: 0,
5808                        skip_specials: false,
5809                        remote_copy_buffer_size: 0,
5810                        filter: None,
5811                        dry_run: None,
5812                        delete: None,
5813                    },
5814                    update_compare: Default::default(),
5815                    update_exclusive: false,
5816                    filter: None,
5817                    dry_run: Some(crate::config::DryRunMode::Brief),
5818                    preserve: preserve::preserve_all(),
5819                },
5820                false,
5821            )
5822            .await?;
5823            // verify destination still doesn't exist
5824            assert!(
5825                !dst_path.exists(),
5826                "dry-run should not create destination directory"
5827            );
5828            // verify summary reports what would be created
5829            assert!(
5830                summary.hard_links_created > 0,
5831                "dry-run should report hard links that would be created"
5832            );
5833            Ok(())
5834        }
5835        /// Test that dry-run mode correctly reports symlinks (not as hard links).
5836        #[tokio::test]
5837        #[traced_test]
5838        async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
5839            let tmp_dir = testutils::setup_test_dir().await?;
5840            let test_path = tmp_dir.as_path();
5841            // baz contains: 4.txt (file), 5.txt (symlink), 6.txt (symlink)
5842            let src_path = test_path.join("foo/baz");
5843            let dst_path = test_path.join("dst_baz");
5844            // verify destination doesn't exist
5845            assert!(
5846                !dst_path.exists(),
5847                "destination should not exist before dry-run"
5848            );
5849            let summary = link(
5850                &PROGRESS,
5851                test_path,
5852                &src_path,
5853                &dst_path,
5854                &None,
5855                &Settings {
5856                    copy_settings: CopySettings {
5857                        dereference: false,
5858                        fail_early: false,
5859                        overwrite: false,
5860                        overwrite_compare: Default::default(),
5861                        overwrite_filter: None,
5862                        ignore_existing: false,
5863                        chunk_size: 0,
5864                        skip_specials: false,
5865                        remote_copy_buffer_size: 0,
5866                        filter: None,
5867                        dry_run: None,
5868                        delete: None,
5869                    },
5870                    update_compare: Default::default(),
5871                    update_exclusive: false,
5872                    filter: None,
5873                    dry_run: Some(crate::config::DryRunMode::Brief),
5874                    preserve: preserve::preserve_all(),
5875                },
5876                false,
5877            )
5878            .await?;
5879            // verify destination still doesn't exist
5880            assert!(!dst_path.exists(), "dry-run should not create destination");
5881            // baz contains 1 regular file (4.txt) and 2 symlinks (5.txt, 6.txt)
5882            assert_eq!(
5883                summary.hard_links_created, 1,
5884                "dry-run should report 1 hard link (for 4.txt)"
5885            );
5886            assert_eq!(
5887                summary.copy_summary.symlinks_created, 2,
5888                "dry-run should report 2 symlinks (5.txt and 6.txt)"
5889            );
5890            Ok(())
5891        }
5892    }
5893
5894    /// Verify that fail-early preserves the summary from the failing subtree.
5895    ///
5896    /// Regression test: the fail-early return path in the join loop must
5897    /// accumulate error.summary from the failing child into the parent's
5898    /// link_summary. Without this, directories_created from the child subtree
5899    /// would be lost.
5900    #[tokio::test]
5901    #[traced_test]
5902    async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
5903        let tmp_dir = testutils::create_temp_dir().await?;
5904        let test_path = tmp_dir.as_path();
5905        // src/sub/  has a file and an unreadable subdirectory:
5906        //   src/sub/good.txt            <-- links successfully
5907        //   src/sub/unreadable_dir/     <-- mode 000, can't be traversed
5908        //     src/sub/unreadable_dir/f.txt
5909        let src_dir = test_path.join("src");
5910        let sub_dir = src_dir.join("sub");
5911        let bad_dir = sub_dir.join("unreadable_dir");
5912        tokio::fs::create_dir_all(&bad_dir).await?;
5913        tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
5914        tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
5915        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
5916        let dst_dir = test_path.join("dst");
5917        let result = link(
5918            &PROGRESS,
5919            test_path,
5920            &src_dir,
5921            &dst_dir,
5922            &None,
5923            &Settings {
5924                copy_settings: CopySettings {
5925                    fail_early: true,
5926                    ..common_settings(false, false).copy_settings
5927                },
5928                ..common_settings(false, false)
5929            },
5930            false,
5931        )
5932        .await;
5933        // restore permissions for cleanup
5934        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
5935        let error = result.expect_err("link should fail due to unreadable directory");
5936        // sub/'s link_internal created dst/sub/ (directories_created=1) before
5937        // its join loop encountered the unreadable_dir error. that directory
5938        // creation must be reflected in the error summary propagated up to the
5939        // top-level caller.
5940        assert!(
5941            error.summary.copy_summary.directories_created >= 2,
5942            "fail-early summary should include directories from the failing subtree, \
5943             got directories_created={} (expected >= 2: dst/ and dst/sub/)",
5944            error.summary.copy_summary.directories_created
5945        );
5946        Ok(())
5947    }
5948
5949    #[tokio::test]
5950    #[traced_test]
5951    async fn skip_specials_skips_socket_in_link() -> Result<(), anyhow::Error> {
5952        let tmp_dir = testutils::setup_test_dir().await?;
5953        let test_path = tmp_dir.as_path();
5954        let src = test_path.join("src_dir");
5955        let dst = test_path.join("dst_dir");
5956        tokio::fs::create_dir(&src).await?;
5957        tokio::fs::write(src.join("file.txt"), "hello").await?;
5958        let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
5959        let mut settings = common_settings(false, false);
5960        settings.copy_settings.skip_specials = true;
5961        let summary = link(&PROGRESS, test_path, &src, &dst, &None, &settings, false).await?;
5962        assert_eq!(summary.hard_links_created, 1);
5963        assert_eq!(summary.copy_summary.specials_skipped, 1);
5964        assert!(dst.join("file.txt").exists());
5965        assert!(!dst.join("test.sock").exists());
5966        Ok(())
5967    }
5968
5969    #[tokio::test]
5970    #[traced_test]
5971    async fn delete_skips_pruning_when_link_has_errors() -> Result<(), anyhow::Error> {
5972        let tmp_dir = testutils::setup_test_dir().await?;
5973        let test_path = tmp_dir.as_path();
5974        let src = test_path.join("foo");
5975        let dst = test_path.join("bar");
5976        // baseline link establishes the destination (no delete)
5977        link(
5978            &PROGRESS,
5979            test_path,
5980            &src,
5981            &dst,
5982            &None,
5983            &common_settings(false, false),
5984            false,
5985        )
5986        .await?;
5987        // an extraneous file that --delete would normally prune
5988        tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
5989        // make a source sub-directory unreadable so traversal fails (fail_early is false).
5990        // a directory is used because --overwrite with mtime-equal files skips copying
5991        // identical files; a directory's read_dir fails unconditionally when mode is 0o000.
5992        let unreadable = src.join("baz");
5993        let original = tokio::fs::metadata(&unreadable).await?.permissions();
5994        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
5995
5996        let delete_settings = Settings {
5997            copy_settings: CopySettings {
5998                overwrite: true,
5999                fail_early: false,
6000                delete: Some(copy::DeleteSettings {
6001                    delete_excluded: false,
6002                }),
6003                ..common_settings(false, true).copy_settings
6004            },
6005            ..common_settings(false, true)
6006        };
6007        let result = link(
6008            &PROGRESS,
6009            test_path,
6010            &src,
6011            &dst,
6012            &None,
6013            &delete_settings,
6014            false,
6015        )
6016        .await;
6017
6018        tokio::fs::set_permissions(&unreadable, original).await?;
6019
6020        assert!(
6021            result.is_err(),
6022            "link of the unreadable directory should fail"
6023        );
6024        assert!(
6025            dst.join("extraneous.txt").exists(),
6026            "pruning must be skipped when the link/update pass reported errors"
6027        );
6028        Ok(())
6029    }
6030
6031    #[tokio::test]
6032    #[traced_test]
6033    async fn skip_specials_top_level_socket_in_link() -> Result<(), anyhow::Error> {
6034        let tmp_dir = testutils::setup_test_dir().await?;
6035        let test_path = tmp_dir.as_path();
6036        let src_socket = test_path.join("test.sock");
6037        let dst = test_path.join("dst.sock");
6038        let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
6039        let mut settings = common_settings(false, false);
6040        settings.copy_settings.skip_specials = true;
6041        let summary = link(
6042            &PROGRESS,
6043            test_path,
6044            &src_socket,
6045            &dst,
6046            &None,
6047            &settings,
6048            false,
6049        )
6050        .await?;
6051        assert_eq!(summary.copy_summary.specials_skipped, 1);
6052        assert_eq!(summary.hard_links_created, 0);
6053        assert!(!dst.exists());
6054        Ok(())
6055    }
6056
6057    /// Stress tests exercising max-files-in-flight saturation during link.
6058    mod max_files_in_flight_tests {
6059        use super::*;
6060
6061        fn available_open_file_capacity(capacity: usize) -> usize {
6062            let waker = futures::task::noop_waker();
6063            let mut context = std::task::Context::from_waker(&waker);
6064            let mut permits = Vec::new();
6065            for _ in 0..capacity {
6066                let mut permit = Box::pin(throttle::open_file_permit());
6067                match std::future::Future::poll(permit.as_mut(), &mut context) {
6068                    std::task::Poll::Ready(permit) => permits.push(permit),
6069                    std::task::Poll::Pending => break,
6070                }
6071            }
6072            permits.len()
6073        }
6074
6075        async fn reacquire_all_open_file_capacity(
6076            admission: &testutils::AdmissionLimit,
6077            capacity: usize,
6078        ) -> Result<(), anyhow::Error> {
6079            let permits = admission
6080                .run_with_timeout(std::time::Duration::from_secs(20), async {
6081                    let mut permits = Vec::new();
6082                    for _ in 0..capacity {
6083                        permits.push(throttle::open_file_permit().await);
6084                    }
6085                    permits
6086                })
6087                .await
6088                .context("link operation did not return all open-file capacity")?;
6089            drop(permits);
6090            Ok(())
6091        }
6092
6093        #[tokio::test]
6094        async fn update_directory_transition_retains_exact_handle_and_held_admission()
6095        -> anyhow::Result<()> {
6096            use std::os::fd::AsRawFd as _;
6097
6098            let root = testutils::create_temp_dir().await?;
6099            tokio::fs::write(root.join("source"), b"source").await?;
6100            tokio::fs::create_dir(root.join("update")).await?;
6101            let admission = testutils::AdmissionLimit::new().await;
6102            admission.set_files_in_flight(1);
6103            let parent = Dir::open_root_dir(&root, false, congestion::Side::Source).await?;
6104            let src_handle = parent.child(std::ffi::OsStr::new("source")).await?;
6105            let update_handle = parent.child(std::ffi::OsStr::new("update")).await?;
6106            let src_fd = src_handle.as_fd().as_raw_fd();
6107            let update_fd = update_handle.as_fd().as_raw_fd();
6108            let src_probe = testutils::FdIdentityProbe::capture(src_fd);
6109            let update_probe = testutils::FdIdentityProbe::capture(update_fd);
6110            let (src_probe, update_probe) = match (src_probe, update_probe) {
6111                (Ok(src_probe), Ok(update_probe)) => (src_probe, update_probe),
6112                (src_probe, update_probe) => {
6113                    let probe_error = match src_probe {
6114                        Err(error) => error,
6115                        Ok(_) => match update_probe {
6116                            Err(error) => error,
6117                            Ok(_) => {
6118                                unreachable!("at least one fd identity probe must have failed")
6119                            }
6120                        },
6121                    };
6122                    drop(src_handle);
6123                    drop(update_handle);
6124                    drop(parent);
6125                    drop(admission);
6126                    let cleanup_result = tokio::fs::remove_dir_all(root).await;
6127                    return match cleanup_result {
6128                        Ok(()) => Err(probe_error.into()),
6129                        Err(cleanup_error) => Err(anyhow::Error::new(probe_error).context(
6130                            format!("link fd-lifetime fixture did not clean up: {cleanup_error:#}"),
6131                        )),
6132                    };
6133                }
6134            };
6135            let mut entry = AdmittedLinkEntry::new(
6136                src_handle,
6137                Some(LeafPermit::OpenFile(throttle::open_file_permit().await)),
6138            );
6139            entry.update_handle = Some(update_handle);
6140
6141            let update_entry = entry.into_update_entry();
6142            let retained_directory = update_entry.kind() == EntryKind::Dir;
6143            let transferred_held_admission = update_entry.admission().is_some();
6144            // retain probe errors until the direct owner and its admission have both quiesced.
6145            let source_closed = src_probe.original_is_closed();
6146            let update_stayed_open = update_probe.original_is_closed().map(|closed| !closed);
6147            let mut next_permit = Box::pin(throttle::open_file_permit());
6148            let (capacity_stayed_held, early_permit) = match futures::poll!(next_permit.as_mut()) {
6149                std::task::Poll::Pending => (true, None),
6150                std::task::Poll::Ready(permit) => (false, Some(permit)),
6151            };
6152            drop(update_entry);
6153            let update_closed = update_probe.original_is_closed();
6154            let reacquire_result = match early_permit {
6155                Some(permit) => Ok(permit),
6156                None => {
6157                    tokio::time::timeout(std::time::Duration::from_secs(1), next_permit.as_mut())
6158                        .await
6159                        .context("the delegated directory did not return open-file capacity")
6160                }
6161            };
6162            let reacquire_result = reacquire_result.map(drop);
6163            drop(parent);
6164            drop(admission);
6165            let cleanup_result = tokio::fs::remove_dir_all(root).await;
6166            let source_closed = source_closed?;
6167            let update_stayed_open = update_stayed_open?;
6168            let update_closed = update_closed?;
6169            reacquire_result?;
6170            cleanup_result?;
6171            assert!(
6172                retained_directory,
6173                "the admitted update owner lost its authoritative directory type"
6174            );
6175            assert!(transferred_held_admission);
6176            assert!(source_closed);
6177            assert!(update_stayed_open);
6178            assert!(update_closed);
6179            assert!(
6180                capacity_stayed_held,
6181                "the admitted update owner released capacity before copy dispatch"
6182            );
6183            Ok(())
6184        }
6185
6186        /// A reliable file hint lets the rlink filter skip before open-file admission.
6187        #[tokio::test]
6188        async fn filtered_hinted_file_does_not_wait_for_admission() -> Result<(), anyhow::Error> {
6189            let root = testutils::create_temp_dir().await?;
6190            let src = root.join("src");
6191            tokio::fs::create_dir(&src).await?;
6192            tokio::fs::write(src.join("leaf"), b"x").await?;
6193            let admission = testutils::AdmissionLimit::new().await;
6194            admission.set_files_in_flight(1);
6195            let held = throttle::open_file_permit().await;
6196            let src_dir =
6197                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6198            let hints = src_dir.read_entries().await?;
6199            assert_eq!(
6200                hints,
6201                vec![(std::ffi::OsString::from("leaf"), Some(EntryKind::File))],
6202                "the fixture filesystem must provide the reliable hint this test exercises"
6203            );
6204            let mut filter = crate::filter::FilterSettings::new();
6205            filter.add_exclude("leaf")?;
6206            let mut settings = common_settings(false, false);
6207            settings.filter = Some(filter.clone());
6208            settings.copy_settings.filter = Some(filter);
6209            let dst = root.join("dst");
6210            let operation = link_dir_contents(
6211                &PROGRESS,
6212                &src_dir,
6213                None,
6214                None,
6215                None,
6216                std::ffi::OsStr::new("dst"),
6217                &src,
6218                &dst,
6219                None,
6220                std::path::Path::new(""),
6221                &src,
6222                &dst,
6223                false,
6224                false,
6225                None,
6226                &settings,
6227                Summary::default(),
6228                SourceEntries::Live,
6229                UpdateEntries::Live,
6230            );
6231            let result = admission
6232                .run_with_timeout(std::time::Duration::from_secs(1), operation)
6233                .await;
6234            drop(held);
6235            let summary = result.context("rlink filter skip waited for open-file admission")??;
6236            assert_eq!(summary.copy_summary.files_skipped, 1);
6237            assert_eq!(summary.hard_links_created, 0);
6238            Ok(())
6239        }
6240
6241        /// A reliable update-only file hint lets the filter skip before copy admission.
6242        #[tokio::test]
6243        async fn filtered_hinted_update_file_does_not_wait_for_admission()
6244        -> Result<(), anyhow::Error> {
6245            let root = testutils::create_temp_dir().await?;
6246            let src = root.join("src");
6247            let update = root.join("update");
6248            tokio::fs::create_dir(&src).await?;
6249            tokio::fs::create_dir(&update).await?;
6250            tokio::fs::write(update.join("leaf"), b"x").await?;
6251            let admission = testutils::AdmissionLimit::new().await;
6252            admission.set_files_in_flight(1);
6253            let held = throttle::open_file_permit().await;
6254            let src_dir =
6255                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6256            let update_dir =
6257                Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
6258            let hints = update_dir.read_entries().await?;
6259            assert_eq!(
6260                hints,
6261                vec![(std::ffi::OsString::from("leaf"), Some(EntryKind::File))],
6262                "the fixture filesystem must provide the reliable hint this test exercises"
6263            );
6264            let mut filter = crate::filter::FilterSettings::new();
6265            filter.add_exclude("leaf")?;
6266            let mut settings = common_settings(false, false);
6267            settings.filter = Some(filter.clone());
6268            settings.copy_settings.filter = Some(filter);
6269            let dst = root.join("dst");
6270            let operation = crate::walk_driver::scope_tasks(link_dir_contents(
6271                &PROGRESS,
6272                &src_dir,
6273                Some(&update_dir),
6274                None,
6275                None,
6276                std::ffi::OsStr::new("dst"),
6277                &src,
6278                &dst,
6279                Some(&update),
6280                std::path::Path::new(""),
6281                &src,
6282                &dst,
6283                false,
6284                false,
6285                None,
6286                &settings,
6287                Summary::default(),
6288                SourceEntries::Live,
6289                UpdateEntries::Live,
6290            ));
6291            let result = admission
6292                .run_with_timeout(std::time::Duration::from_secs(1), operation)
6293                .await;
6294            drop(held);
6295            let summary =
6296                result.context("rlink update filter skip waited for open-file admission")??;
6297            assert_eq!(summary.copy_summary.files_skipped, 1);
6298            assert_eq!(summary.copy_summary.files_copied, 0);
6299            Ok(())
6300        }
6301
6302        #[test]
6303        fn source_directory_hint_requires_admission_only_for_a_dual_tree_decision() {
6304            assert!(matches!(
6305                source_entry_admission(Some(EntryKind::Dir), true),
6306                EntryAdmission::RootOrDelegated
6307            ));
6308            assert!(matches!(
6309                source_entry_admission(Some(EntryKind::Dir), false),
6310                EntryAdmission::HintedDirectory
6311            ));
6312        }
6313
6314        /// Rlink preserves a positive directory hint through its worker dispatch.
6315        #[tokio::test]
6316        async fn hinted_directory_classifies_while_pool_is_saturated() -> Result<(), anyhow::Error>
6317        {
6318            let root = testutils::create_temp_dir().await?;
6319            let src_path = root.join("src");
6320            let dst_path = root.join("dst");
6321            tokio::fs::create_dir(&src_path).await?;
6322            let admission = testutils::AdmissionLimit::new().await;
6323            admission.set_files_in_flight(1);
6324            let held = throttle::open_file_permit().await;
6325            let src_parent = Arc::new(
6326                Dir::open_parent_dir(&root, congestion::Side::Source)
6327                    .await?
6328                    .into_tree(),
6329            );
6330            let mut settings = common_settings(false, false);
6331            settings.dry_run = Some(crate::config::DryRunMode::Brief);
6332            settings.copy_settings.dry_run = settings.dry_run;
6333            let result = admission
6334                .run_with_timeout(
6335                    std::time::Duration::from_secs(1),
6336                    link_internal(
6337                        &PROGRESS,
6338                        &src_parent,
6339                        None,
6340                        None,
6341                        std::ffi::OsStr::new("src"),
6342                        &src_path,
6343                        &dst_path,
6344                        None,
6345                        std::path::Path::new(""),
6346                        UpdateRootRequirement::Optional,
6347                        &settings,
6348                        false,
6349                        walk::EntryAdmission::HintedDirectory.into(),
6350                    ),
6351                )
6352                .await;
6353            drop(held);
6354            let summary = result.context("hinted rlink directory waited for admission")??;
6355            assert_eq!(summary.summary.copy_summary.directories_created, 1);
6356            Ok(())
6357        }
6358
6359        /// A separate update counterpart makes a source-directory hint insufficient to classify
6360        /// the combined link decision without leaf admission.
6361        #[tokio::test(flavor = "current_thread")]
6362        async fn hinted_source_with_update_admits_before_source_classification()
6363        -> Result<(), anyhow::Error> {
6364            let root = testutils::create_temp_dir().await?;
6365            let src_root = root.join("src");
6366            let update_root = root.join("update");
6367            let dst_root = root.join("dst");
6368            tokio::fs::create_dir(&src_root).await?;
6369            tokio::fs::create_dir(&update_root).await?;
6370            tokio::fs::create_dir(src_root.join("entry")).await?;
6371            tokio::fs::write(update_root.join("entry"), b"x").await?;
6372
6373            let admission = testutils::AdmissionLimit::new().await;
6374            admission.set_files_in_flight(1);
6375            let stat_resource =
6376                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6377            admission.set_max_ops_in_flight(stat_resource, 1);
6378            let src_parent =
6379                Arc::new(Dir::open_root_dir(&src_root, false, congestion::Side::Source).await?);
6380            let update_parent =
6381                Arc::new(Dir::open_root_dir(&update_root, false, congestion::Side::Source).await?);
6382            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6383            let mut settings = common_settings(false, false);
6384            settings.dry_run = Some(crate::config::DryRunMode::Brief);
6385            settings.copy_settings.dry_run = settings.dry_run;
6386
6387            let operation = link_internal(
6388                &PROGRESS,
6389                &src_parent,
6390                Some((&update_parent, std::ffi::OsStr::new("entry"))),
6391                None,
6392                std::ffi::OsStr::new("entry"),
6393                &src_root,
6394                &dst_root,
6395                Some(&update_root),
6396                std::path::Path::new("entry"),
6397                UpdateRootRequirement::Optional,
6398                &settings,
6399                false,
6400                EntryAdmission::HintedDirectory.into(),
6401            );
6402            tokio::pin!(operation);
6403            assert!(
6404                futures::poll!(operation.as_mut()).is_pending(),
6405                "source classification must stop at the saturated Stat gate"
6406            );
6407            let mut permit_probe = Box::pin(throttle::open_file_permit());
6408            let admitted_before_source = futures::poll!(permit_probe.as_mut()).is_pending();
6409            drop(permit_probe);
6410
6411            drop(held_stat);
6412            let summary = admission
6413                .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6414                .await
6415                .context("rlink did not resume after source classification was released")??;
6416            assert!(
6417                admitted_before_source,
6418                "the hinted source opened before admission for its separate update decision"
6419            );
6420            assert_eq!(summary.summary.copy_summary.files_copied, 1);
6421            Ok(())
6422        }
6423
6424        /// An update-only directory preserves its hint through delegation into copy.
6425        #[tokio::test]
6426        async fn hinted_update_directory_classifies_while_pool_is_saturated()
6427        -> Result<(), anyhow::Error> {
6428            let root = testutils::create_temp_dir().await?;
6429            let src = root.join("src");
6430            let update = root.join("update");
6431            let dst = root.join("dst");
6432            tokio::fs::create_dir(&src).await?;
6433            tokio::fs::create_dir(&update).await?;
6434            tokio::fs::create_dir(update.join("dir")).await?;
6435            let admission = testutils::AdmissionLimit::new().await;
6436            admission.set_files_in_flight(1);
6437            let held = throttle::open_file_permit().await;
6438            let src_dir =
6439                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6440            let update_dir =
6441                Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
6442            let mut settings = common_settings(false, false);
6443            settings.dry_run = Some(crate::config::DryRunMode::Brief);
6444            settings.copy_settings.dry_run = settings.dry_run;
6445            let operation = crate::walk_driver::scope_tasks(link_dir_contents(
6446                &PROGRESS,
6447                &src_dir,
6448                Some(&update_dir),
6449                None,
6450                None,
6451                std::ffi::OsStr::new("dst"),
6452                &src,
6453                &dst,
6454                Some(&update),
6455                std::path::Path::new(""),
6456                &src,
6457                &dst,
6458                false,
6459                false,
6460                None,
6461                &settings,
6462                Summary::default(),
6463                SourceEntries::Live,
6464                UpdateEntries::Live,
6465            ));
6466            let result = admission
6467                .run_with_timeout(std::time::Duration::from_secs(1), operation)
6468                .await;
6469            drop(held);
6470            let summary =
6471                result.context("hinted update directory waited for delegated copy admission")??;
6472            assert_eq!(summary.copy_summary.directories_created, 1);
6473            Ok(())
6474        }
6475
6476        /// Public root setup must reserve open-file capacity before strict probes or operand-parent
6477        /// opens. A filtered root otherwise returns before `link_internal` reaches its admission
6478        /// point, and concurrent callers can bypass the budget entirely.
6479        #[tokio::test]
6480        async fn filtered_public_root_waits_for_admission_before_setup() -> Result<(), anyhow::Error>
6481        {
6482            let root = testutils::create_temp_dir().await?;
6483            let src = root.join("src");
6484            let dst = root.join("dst");
6485            tokio::fs::write(&src, b"x").await?;
6486            let mut filter = crate::filter::FilterSettings::new();
6487            filter.add_exclude("src")?;
6488            let mut settings = common_settings(false, false);
6489            settings.filter = Some(filter.clone());
6490            settings.copy_settings.filter = Some(filter);
6491            let admission = testutils::AdmissionLimit::new().await;
6492            admission.set_files_in_flight(1);
6493            let stat_resource =
6494                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6495            admission.set_max_ops_in_flight(stat_resource, 1);
6496            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6497            let operation = link(&PROGRESS, &root, &src, &dst, &None, &settings, false);
6498            tokio::pin!(operation);
6499            let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
6500            let mut second_permit = Box::pin(throttle::open_file_permit());
6501            let setup_bypassed_admission = futures::poll!(second_permit.as_mut()).is_ready();
6502            drop(second_permit);
6503            drop(held_stat);
6504            let result = admission
6505                .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6506                .await;
6507            assert!(
6508                stopped_at_stat_gate,
6509                "rlink root did not reach the held stat gate"
6510            );
6511            let summary =
6512                result.context("rlink root did not resume after stat capacity was released")??;
6513            assert!(
6514                !setup_bypassed_admission,
6515                "rlink performed root setup before open-file admission"
6516            );
6517            assert_eq!(summary.copy_summary.files_skipped, 1);
6518            Ok(())
6519        }
6520
6521        /// A root or delegated rlink worker without a caller-supplied guard must reserve capacity
6522        /// before opening its source classification handle.
6523        #[tokio::test]
6524        async fn root_acquires_capacity_before_classification() -> Result<(), anyhow::Error> {
6525            let root = testutils::create_temp_dir().await?;
6526            let src_path = root.join("src");
6527            let dst_path = root.join("dst");
6528            tokio::fs::write(&src_path, b"x").await?;
6529            let admission = testutils::AdmissionLimit::new().await;
6530            let src_parent = Arc::new(
6531                Dir::open_parent_dir(&root, congestion::Side::Source)
6532                    .await?
6533                    .into_tree(),
6534            );
6535            let mut settings = common_settings(false, false);
6536            settings.dry_run = Some(crate::config::DryRunMode::Brief);
6537            settings.copy_settings.dry_run = settings.dry_run;
6538            admission.set_files_in_flight(1);
6539            let stat_resource =
6540                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6541            admission.set_max_ops_in_flight(stat_resource, 1);
6542            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6543            let operation = link_internal(
6544                &PROGRESS,
6545                &src_parent,
6546                None,
6547                None,
6548                std::ffi::OsStr::new("src"),
6549                &src_path,
6550                &dst_path,
6551                None,
6552                std::path::Path::new(""),
6553                UpdateRootRequirement::Optional,
6554                &settings,
6555                false,
6556                EntryAdmission::RootOrDelegated.into(),
6557            );
6558            tokio::pin!(operation);
6559            let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
6560            let mut second_permit = Box::pin(throttle::open_file_permit());
6561            let classification_bypassed_admission =
6562                futures::poll!(second_permit.as_mut()).is_ready();
6563            drop(second_permit);
6564            drop(held_stat);
6565            let result = admission
6566                .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6567                .await;
6568            assert!(
6569                stopped_at_stat_gate,
6570                "rlink root did not reach the held stat gate"
6571            );
6572            assert!(
6573                !classification_bypassed_admission,
6574                "rlink reached fd-based classification before open-file admission"
6575            );
6576            let summary = result.context(
6577                "rlink root did not resume after classification capacity was released",
6578            )??;
6579            assert_eq!(summary.summary.hard_links_created, 1);
6580            Ok(())
6581        }
6582
6583        /// A hard-link leaf must retain its admission guard through the actual
6584        /// fd-bearing `linkat`, not release it after source/update classification.
6585        #[tokio::test]
6586        async fn hard_link_holds_open_file_capacity_until_link_finishes()
6587        -> Result<(), anyhow::Error> {
6588            let root = testutils::create_temp_dir().await?;
6589            let src_root = root.join("src");
6590            let dst_root = root.join("dst");
6591            tokio::fs::create_dir(&src_root).await?;
6592            tokio::fs::create_dir(&dst_root).await?;
6593            tokio::fs::write(src_root.join("entry"), b"x").await?;
6594            let src_parent =
6595                Arc::new(Dir::open_root_dir(&src_root, false, congestion::Side::Source).await?);
6596            let dst_parent = Arc::new(
6597                Dir::open_root_dir(&dst_root, false, congestion::Side::Destination).await?,
6598            );
6599            let admission = testutils::AdmissionLimit::new().await;
6600            admission.set_files_in_flight(1);
6601            let permit = Some(LeafPermit::OpenFile(throttle::open_file_permit().await));
6602            let hard_link_resource = throttle::Resource::meta(
6603                throttle::Side::Destination,
6604                throttle::MetadataOp::HardLink,
6605            );
6606            admission.set_max_ops_in_flight(hard_link_resource, 1);
6607            let held_hard_link = throttle::ops_in_flight_permit(hard_link_resource).await;
6608            let dst_entry = dst_root.join("entry");
6609            let task_dst_entry = dst_entry.clone();
6610            let mut task = tokio::spawn(async move {
6611                link_internal(
6612                    &PROGRESS,
6613                    &src_parent,
6614                    None,
6615                    Some(&dst_parent),
6616                    std::ffi::OsStr::new("entry"),
6617                    &src_root.join("entry"),
6618                    &task_dst_entry,
6619                    None,
6620                    std::path::Path::new(""),
6621                    UpdateRootRequirement::Optional,
6622                    &common_settings(false, false),
6623                    true,
6624                    EntryAdmission::from(permit).into(),
6625                )
6626                .await
6627            });
6628            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6629            let second_permit = tokio::time::timeout(
6630                std::time::Duration::from_millis(500),
6631                throttle::open_file_permit(),
6632            )
6633            .await;
6634            let released_before_link_finished = second_permit.is_ok();
6635            drop(second_permit);
6636            drop(held_hard_link);
6637            let task_result = admission
6638                .run_with_timeout(std::time::Duration::from_secs(20), &mut task)
6639                .await
6640                .context("hard link did not resume after metadata capacity was released")?;
6641            let summary = task_result?.map_err(|error| error.source)?;
6642            assert!(
6643                !released_before_link_finished,
6644                "hard-link work released its open-file permit before linkat completed"
6645            );
6646            assert_eq!(summary.summary.hard_links_created, 1);
6647            assert!(dst_entry.exists());
6648            Ok(())
6649        }
6650
6651        /// Deep + wide link: a directory tree deeper than the files-in-flight limit, with files at every
6652        /// level. Verifies directories do not retain leaf admission across recursion.
6653        #[tokio::test]
6654        #[traced_test]
6655        async fn deep_tree_no_deadlock_under_files_in_flight_saturation()
6656        -> Result<(), anyhow::Error> {
6657            let tmp_dir = testutils::create_temp_dir().await?;
6658            let src = tmp_dir.join("src");
6659            let dst = tmp_dir.join("dst");
6660            let depth = 20;
6661            let files_per_level = 5;
6662            let limit = 1;
6663            // create a directory chain deeper than the permit limit, with files at each level
6664            let mut dir = src.clone();
6665            for level in 0..depth {
6666                tokio::fs::create_dir_all(&dir).await?;
6667                for f in 0..files_per_level {
6668                    tokio::fs::write(
6669                        dir.join(format!("f{}_{}.txt", level, f)),
6670                        format!("L{}F{}", level, f),
6671                    )
6672                    .await?;
6673                }
6674                dir = dir.join(format!("d{}", level));
6675            }
6676            let admission = testutils::AdmissionLimit::new().await;
6677            admission.set_files_in_flight(limit);
6678            let summary = admission
6679                .run_with_timeout(
6680                    std::time::Duration::from_secs(30),
6681                    link(
6682                        &PROGRESS,
6683                        tmp_dir.as_path(),
6684                        &src,
6685                        &dst,
6686                        &None,
6687                        &common_settings(false, false),
6688                        false,
6689                    ),
6690                )
6691                .await
6692                .context("link timed out — possible deadlock")?
6693                .context("link failed")?;
6694            assert_eq!(summary.hard_links_created, depth * files_per_level);
6695            assert_eq!(summary.copy_summary.directories_created, depth);
6696            // spot-check that hard links work by reading content at a few levels
6697            let mut check_dir = dst.clone();
6698            for level in 0..depth {
6699                let content =
6700                    tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
6701                assert_eq!(content, format!("L{}F0", level));
6702                check_dir = check_dir.join(format!("d{}", level));
6703            }
6704            Ok(())
6705        }
6706
6707        /// Matching source/update directories release cap-one admission before direct recursion.
6708        /// Retaining the permit in [`AdmittedLinkEntry::close_for_directory`] would leave the next
6709        /// directory in this chain waiting forever for the only slot.
6710        #[tokio::test]
6711        #[traced_test]
6712        async fn matching_dual_directory_chain_releases_admission_before_recursion()
6713        -> Result<(), anyhow::Error> {
6714            let tmp_dir = testutils::create_temp_dir().await?;
6715            let src = tmp_dir.join("src");
6716            let update = tmp_dir.join("update");
6717            let dst = tmp_dir.join("dst");
6718            tokio::fs::create_dir(&src).await?;
6719            tokio::fs::create_dir(&update).await?;
6720            let depth = 12;
6721            let mut src_dir = src.clone();
6722            let mut update_dir = update.clone();
6723            for level in 0..depth {
6724                let name = format!("d{level}");
6725                src_dir.push(&name);
6726                update_dir.push(&name);
6727                tokio::fs::create_dir(&src_dir).await?;
6728                tokio::fs::create_dir(&update_dir).await?;
6729            }
6730            let admission = testutils::AdmissionLimit::new().await;
6731            admission.set_files_in_flight(1);
6732            let summary = admission
6733                .run_with_timeout(
6734                    std::time::Duration::from_secs(5),
6735                    link(
6736                        &PROGRESS,
6737                        tmp_dir.as_path(),
6738                        &src,
6739                        &dst,
6740                        &Some(update),
6741                        &common_settings(false, false),
6742                        false,
6743                    ),
6744                )
6745                .await
6746                .context("link timed out — direct dual-directory recursion retained admission")?
6747                .context("link failed")?;
6748            assert_eq!(summary.copy_summary.directories_created, depth + 1);
6749            assert!(
6750                src_dir
6751                    .strip_prefix(&src)
6752                    .is_ok_and(|tail| dst.join(tail).is_dir())
6753            );
6754            Ok(())
6755        }
6756
6757        /// A file-type-changed directory must release transferred admission before recursion.
6758        ///
6759        /// Scenario: many src entries are regular files (so the spawn loop
6760        /// pre-acquires files-in-flight permits for them), but the corresponding
6761        /// `update` entries are directories (file types differ). link_internal
6762        /// then closes the source comparison handle and transfers the selected update handle plus
6763        /// admission into `copy_child`. The copy driver dispatches that exact classification and
6764        /// releases admission before directory descent; retaining it while children acquire would
6765        /// deadlock against a saturated pool.
6766        #[tokio::test]
6767        #[traced_test]
6768        async fn type_changed_directory_releases_admission_before_recursion()
6769        -> Result<(), anyhow::Error> {
6770            let tmp_dir = testutils::create_temp_dir().await?;
6771            let src = tmp_dir.join("src");
6772            let update = tmp_dir.join("update");
6773            let dst = tmp_dir.join("dst");
6774            tokio::fs::create_dir(&src).await?;
6775            tokio::fs::create_dir(&update).await?;
6776            let n = 8;
6777            // src/eN: regular files. update/eN: directories with inner files.
6778            // file types differ -> link takes the !is_file_type_same branch
6779            // -> calls copy::copy(update/eN, dst/eN).
6780            for i in 0..n {
6781                tokio::fs::write(src.join(format!("e{}", i)), format!("src-{}", i)).await?;
6782                let upd_subdir = update.join(format!("e{}", i));
6783                tokio::fs::create_dir(&upd_subdir).await?;
6784                for j in 0..3 {
6785                    tokio::fs::write(
6786                        upd_subdir.join(format!("inner_{}.txt", j)),
6787                        format!("upd-{}-{}", i, j),
6788                    )
6789                    .await?;
6790                }
6791            }
6792            // saturate the pool so retaining delegated admission during descent blocks child work.
6793            let admission = testutils::AdmissionLimit::new().await;
6794            admission.set_files_in_flight(2);
6795            let summary = admission
6796                .run_with_timeout(
6797                    std::time::Duration::from_secs(30),
6798                    link(
6799                        &PROGRESS,
6800                        tmp_dir.as_path(),
6801                        &src,
6802                        &dst,
6803                        &Some(update.clone()),
6804                        &common_settings(false, false),
6805                        false,
6806                    ),
6807                )
6808                .await
6809                .context(
6810                    "link timed out — delegated admission was retained during directory descent",
6811                )?
6812                .context("link failed")?;
6813            // every entry was a type-mismatch -> copied from update.
6814            // copy::copy on a directory creates the dir and copies inner files.
6815            assert_eq!(summary.copy_summary.directories_created, n + 1); // +1 for dst itself
6816            assert_eq!(summary.copy_summary.files_copied, n * 3);
6817            // verify content came from update, not src
6818            for i in 0..n {
6819                for j in 0..3 {
6820                    let content =
6821                        tokio::fs::read_to_string(dst.join(format!("e{}/inner_{}.txt", i, j)))
6822                            .await?;
6823                    assert_eq!(content, format!("upd-{}-{}", i, j));
6824                }
6825            }
6826            Ok(())
6827        }
6828
6829        /// A completed source error must be reaped while the producer waits to admit a later
6830        /// entry. Otherwise two blocked symlink workers can retain the whole pool forever.
6831        #[tokio::test(flavor = "current_thread")]
6832        async fn source_fail_early_reaps_error_while_later_admission_is_saturated()
6833        -> Result<(), anyhow::Error> {
6834            let root = testutils::create_temp_dir().await?;
6835            let src = root.join("src");
6836            let dst = root.join("dst");
6837            tokio::fs::create_dir(&src).await?;
6838            tokio::fs::create_dir(&dst).await?;
6839            tokio::fs::write(src.join("vanished"), b"vanished").await?;
6840            tokio::fs::symlink("target-a", src.join("blocked-a")).await?;
6841            tokio::fs::symlink("target-b", src.join("blocked-b")).await?;
6842            tokio::fs::write(src.join("later"), b"later").await?;
6843            tokio::fs::write(src.join("uninjected"), b"uninjected").await?;
6844            let admission = testutils::AdmissionLimit::new().await;
6845            admission.set_files_in_flight(2);
6846            let stat_resource =
6847                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6848            admission.set_max_ops_in_flight(stat_resource, 1);
6849            let readlink_resource =
6850                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::ReadLink);
6851            admission.set_max_ops_in_flight(readlink_resource, 1);
6852            let src_dir =
6853                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6854            let dst_dir =
6855                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
6856            let dst_parent =
6857                Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
6858            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6859            let mut held_readlink = Some(throttle::ops_in_flight_permit(readlink_resource).await);
6860            let mut filter = crate::filter::FilterSettings::new();
6861            filter.add_exclude("never/")?;
6862            let mut settings = common_settings(false, false);
6863            settings.filter = Some(filter.clone());
6864            settings.copy_settings.filter = Some(filter);
6865            settings.copy_settings.fail_early = true;
6866            let operation = crate::walk_driver::scope_tasks(link_dir_contents(
6867                &PROGRESS,
6868                &src_dir,
6869                None,
6870                Some(&dst_dir),
6871                Some(&dst_parent),
6872                std::ffi::OsStr::new("dst"),
6873                &src,
6874                &dst,
6875                None,
6876                std::path::Path::new(""),
6877                &src,
6878                &dst,
6879                false,
6880                false,
6881                None,
6882                &settings,
6883                Summary::default(),
6884                SourceEntries::Injected(vec![
6885                    (std::ffi::OsString::from("vanished"), Some(EntryKind::File)),
6886                    (
6887                        std::ffi::OsString::from("blocked-a"),
6888                        Some(EntryKind::Symlink),
6889                    ),
6890                    (
6891                        std::ffi::OsString::from("blocked-b"),
6892                        Some(EntryKind::Symlink),
6893                    ),
6894                    (std::ffi::OsString::from("later"), Some(EntryKind::File)),
6895                ]),
6896                UpdateEntries::Live,
6897            ));
6898            tokio::pin!(operation);
6899            let mut completed_while_gated = None;
6900            let saturated = tokio::time::timeout(std::time::Duration::from_secs(5), async {
6901                loop {
6902                    if let std::task::Poll::Ready(output) = futures::poll!(operation.as_mut()) {
6903                        completed_while_gated = Some(output);
6904                        return;
6905                    }
6906                    if available_open_file_capacity(2) == 0 {
6907                        return;
6908                    }
6909                    tokio::task::yield_now().await;
6910                }
6911            })
6912            .await;
6913            let remove_result = tokio::fs::remove_file(src.join("vanished")).await;
6914            drop(held_stat);
6915            if let Some(output) = completed_while_gated {
6916                drop(held_readlink.take());
6917                reacquire_all_open_file_capacity(&admission, 2).await?;
6918                return Err(anyhow!(
6919                    "source link completed before its gated workers saturated admission: {:?}",
6920                    output.err().map(|error| format!("{:#}", error.source))
6921                ));
6922            }
6923            if let Err(error) = saturated {
6924                drop(held_readlink.take());
6925                let _ = admission
6926                    .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6927                    .await;
6928                reacquire_all_open_file_capacity(&admission, 2).await?;
6929                return Err(error).context("source workers did not saturate open-file admission");
6930            }
6931            if let Err(error) = remove_result {
6932                drop(held_readlink.take());
6933                let _ = admission
6934                    .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6935                    .await;
6936                reacquire_all_open_file_capacity(&admission, 2).await?;
6937                return Err(error).context("failed to remove the injected source entry");
6938            }
6939            let result =
6940                tokio::time::timeout(std::time::Duration::from_secs(1), operation.as_mut()).await;
6941            drop(held_readlink.take());
6942            if result.is_err() {
6943                let _ = admission
6944                    .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6945                    .await;
6946            }
6947            reacquire_all_open_file_capacity(&admission, 2).await?;
6948            let error = result
6949                .context("fail-early source error was not reaped during admission")?
6950                .expect_err("the vanished source entry must fail the link");
6951            assert!(format!("{:#}", error.source).contains("vanished"));
6952            assert!(!dst.join("later").exists());
6953            assert!(!dst.join("uninjected").exists());
6954            Ok(())
6955        }
6956
6957        /// Unknown source classification errors are child errors, so keep-going must still run
6958        /// later siblings and fold the successful work into the combined result.
6959        #[tokio::test(flavor = "current_thread")]
6960        async fn source_unknown_classification_error_keeps_going_with_siblings()
6961        -> Result<(), anyhow::Error> {
6962            let root = testutils::create_temp_dir().await?;
6963            let src = root.join("src");
6964            let dst = root.join("dst");
6965            tokio::fs::create_dir(&src).await?;
6966            tokio::fs::create_dir(&dst).await?;
6967            tokio::fs::write(src.join("first"), b"first").await?;
6968            tokio::fs::write(src.join("last"), b"last").await?;
6969            tokio::fs::write(src.join("uninjected"), b"uninjected").await?;
6970            let admission = testutils::AdmissionLimit::new().await;
6971            admission.set_files_in_flight(3);
6972            let src_dir =
6973                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6974            let dst_dir =
6975                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
6976            let dst_parent =
6977                Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
6978            let mut filter = crate::filter::FilterSettings::new();
6979            filter.add_exclude("never/")?;
6980            let mut settings = common_settings(false, false);
6981            settings.filter = Some(filter.clone());
6982            settings.copy_settings.filter = Some(filter);
6983            let operation = crate::walk_driver::scope_tasks(link_dir_contents(
6984                &PROGRESS,
6985                &src_dir,
6986                None,
6987                Some(&dst_dir),
6988                Some(&dst_parent),
6989                std::ffi::OsStr::new("dst"),
6990                &src,
6991                &dst,
6992                None,
6993                std::path::Path::new(""),
6994                &src,
6995                &dst,
6996                false,
6997                false,
6998                None,
6999                &settings,
7000                Summary::default(),
7001                SourceEntries::Injected(vec![
7002                    (std::ffi::OsString::from("first"), None),
7003                    (std::ffi::OsString::from("vanished"), None),
7004                    (std::ffi::OsString::from("last"), None),
7005                ]),
7006                UpdateEntries::Live,
7007            ));
7008            let result = admission
7009                .run_with_timeout(std::time::Duration::from_secs(20), operation)
7010                .await;
7011            reacquire_all_open_file_capacity(&admission, 3).await?;
7012            let error = result
7013                .context("keep-going unknown-source link did not terminate")?
7014                .expect_err("the injected vanished source must fail");
7015            assert_eq!(error.summary.hard_links_created, 2);
7016            assert_eq!(tokio::fs::read(dst.join("first")).await?, b"first");
7017            assert_eq!(tokio::fs::read(dst.join("last")).await?, b"last");
7018            assert!(!dst.join("uninjected").exists());
7019            Ok(())
7020        }
7021
7022        /// An excluded unknown directory must be counted from its exact worker classification,
7023        /// never from a fallback file kind in the producer loop.
7024        #[tokio::test]
7025        async fn source_unknown_excluded_directory_uses_directory_skip_accounting()
7026        -> Result<(), anyhow::Error> {
7027            let root = testutils::create_temp_dir().await?;
7028            let src = root.join("src");
7029            let dst = root.join("dst");
7030            tokio::fs::create_dir(&src).await?;
7031            tokio::fs::create_dir(&dst).await?;
7032            tokio::fs::create_dir(src.join("node")).await?;
7033            tokio::fs::write(src.join("uninjected"), b"uninjected").await?;
7034            let src_dir =
7035                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
7036            let dst_dir =
7037                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
7038            let dst_parent =
7039                Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
7040            let mut filter = crate::filter::FilterSettings::new();
7041            filter.add_exclude("node/")?;
7042            let mut settings = common_settings(false, false);
7043            settings.filter = Some(filter.clone());
7044            settings.copy_settings.filter = Some(filter);
7045            let summary = crate::walk_driver::scope_tasks(link_dir_contents(
7046                &PROGRESS,
7047                &src_dir,
7048                None,
7049                Some(&dst_dir),
7050                Some(&dst_parent),
7051                std::ffi::OsStr::new("dst"),
7052                &src,
7053                &dst,
7054                None,
7055                std::path::Path::new(""),
7056                &src,
7057                &dst,
7058                false,
7059                false,
7060                None,
7061                &settings,
7062                Summary::default(),
7063                SourceEntries::Injected(vec![(std::ffi::OsString::from("node"), None)]),
7064                UpdateEntries::Live,
7065            ))
7066            .await?;
7067            assert_eq!(summary.copy_summary.directories_skipped, 1);
7068            assert_eq!(summary.copy_summary.files_skipped, 0);
7069            assert!(!dst.join("node").exists());
7070            assert!(!dst.join("uninjected").exists());
7071            Ok(())
7072        }
7073
7074        /// Unknown source entries must be admitted and spawned independently before either exact
7075        /// classification completes.
7076        #[tokio::test(flavor = "current_thread")]
7077        async fn source_unknown_workers_classify_concurrently() -> Result<(), anyhow::Error> {
7078            let root = testutils::create_temp_dir().await?;
7079            let src = root.join("src");
7080            let dst = root.join("dst");
7081            tokio::fs::create_dir(&src).await?;
7082            tokio::fs::create_dir(&dst).await?;
7083            tokio::fs::write(src.join("first"), b"first").await?;
7084            tokio::fs::write(src.join("second"), b"second").await?;
7085            tokio::fs::write(src.join("uninjected"), b"uninjected").await?;
7086            let admission = testutils::AdmissionLimit::new().await;
7087            admission.set_files_in_flight(2);
7088            let stat_resource =
7089                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
7090            admission.set_max_ops_in_flight(stat_resource, 1);
7091            let src_dir =
7092                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
7093            let dst_dir =
7094                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
7095            let dst_parent =
7096                Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
7097            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
7098            let mut filter = crate::filter::FilterSettings::new();
7099            filter.add_exclude("never/")?;
7100            let mut settings = common_settings(false, false);
7101            settings.filter = Some(filter.clone());
7102            settings.copy_settings.filter = Some(filter);
7103            let operation = crate::walk_driver::scope_tasks(link_dir_contents(
7104                &PROGRESS,
7105                &src_dir,
7106                None,
7107                Some(&dst_dir),
7108                Some(&dst_parent),
7109                std::ffi::OsStr::new("dst"),
7110                &src,
7111                &dst,
7112                None,
7113                std::path::Path::new(""),
7114                &src,
7115                &dst,
7116                false,
7117                false,
7118                None,
7119                &settings,
7120                Summary::default(),
7121                SourceEntries::Injected(vec![
7122                    (std::ffi::OsString::from("first"), None),
7123                    (std::ffi::OsString::from("second"), None),
7124                ]),
7125                UpdateEntries::Live,
7126            ));
7127            tokio::pin!(operation);
7128            let mut completed_while_gated = None;
7129            let concurrent = tokio::time::timeout(std::time::Duration::from_secs(5), async {
7130                loop {
7131                    if let std::task::Poll::Ready(output) = futures::poll!(operation.as_mut()) {
7132                        completed_while_gated = Some(output);
7133                        return;
7134                    }
7135                    if available_open_file_capacity(2) == 0 {
7136                        return;
7137                    }
7138                    tokio::task::yield_now().await;
7139                }
7140            })
7141            .await;
7142            drop(held_stat);
7143            let task_result = admission
7144                .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
7145                .await;
7146            reacquire_all_open_file_capacity(&admission, 2).await?;
7147            if let Some(output) = completed_while_gated {
7148                return Err(anyhow!(
7149                    "unknown source link completed before gated classifications: {:?}",
7150                    output.err().map(|error| format!("{:#}", error.source))
7151                ));
7152            }
7153            concurrent.context("unknown source workers did not classify concurrently")?;
7154            let summary = task_result
7155                .context("unknown source classifications did not resume after stat release")??;
7156            assert_eq!(summary.hard_links_created, 2);
7157            assert!(!dst.join("uninjected").exists());
7158            Ok(())
7159        }
7160
7161        /// Included update-only file hints must be admitted and spawned independently before
7162        /// either worker's exact classification completes.
7163        #[tokio::test(flavor = "current_thread")]
7164        async fn update_only_workers_classify_concurrently() -> Result<(), anyhow::Error> {
7165            let root = testutils::create_temp_dir().await?;
7166            let src = root.join("src");
7167            let update = root.join("update");
7168            let dst = root.join("dst");
7169            tokio::fs::create_dir(&src).await?;
7170            tokio::fs::create_dir(&update).await?;
7171            tokio::fs::create_dir(&dst).await?;
7172            tokio::fs::write(update.join("first"), b"first").await?;
7173            tokio::fs::write(update.join("second"), b"second").await?;
7174            tokio::fs::write(update.join("uninjected"), b"uninjected").await?;
7175
7176            let admission = testutils::AdmissionLimit::new().await;
7177            admission.set_files_in_flight(2);
7178            let src_dir =
7179                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
7180            let update_dir =
7181                Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
7182            let dst_dir =
7183                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
7184            let dst_parent =
7185                Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
7186            let stat_resource =
7187                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
7188            admission.set_max_ops_in_flight(stat_resource, 1);
7189            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
7190            let mut filter = crate::filter::FilterSettings::new();
7191            filter.add_exclude("never/")?;
7192            let mut settings = common_settings(false, false);
7193            settings.filter = Some(filter.clone());
7194            settings.copy_settings.filter = Some(filter);
7195            let operation = crate::walk_driver::scope_tasks(link_dir_contents(
7196                &PROGRESS,
7197                &src_dir,
7198                Some(&update_dir),
7199                Some(&dst_dir),
7200                Some(&dst_parent),
7201                std::ffi::OsStr::new("dst"),
7202                &src,
7203                &dst,
7204                Some(&update),
7205                std::path::Path::new(""),
7206                &src,
7207                &dst,
7208                false,
7209                false,
7210                None,
7211                &settings,
7212                Summary::default(),
7213                SourceEntries::Live,
7214                UpdateEntries::Injected(vec![
7215                    (std::ffi::OsString::from("first"), Some(EntryKind::File)),
7216                    (std::ffi::OsString::from("second"), Some(EntryKind::File)),
7217                ]),
7218            ));
7219            tokio::pin!(operation);
7220            let available_while_classifying = loop {
7221                assert!(
7222                    futures::poll!(operation.as_mut()).is_pending(),
7223                    "update-only link completed while exact classifications were gated"
7224                );
7225                let mut available = Vec::new();
7226                for _ in 0..2 {
7227                    let mut probe = Box::pin(throttle::open_file_permit());
7228                    match futures::poll!(probe.as_mut()) {
7229                        std::task::Poll::Ready(permit) => available.push(permit),
7230                        std::task::Poll::Pending => break,
7231                    }
7232                }
7233                let count = available.len();
7234                drop(available);
7235                if count < 2 {
7236                    break count;
7237                }
7238                tokio::task::yield_now().await;
7239            };
7240            drop(held_stat);
7241            assert!(
7242                available_while_classifying == 0,
7243                "the first exact update-only classification serialized its sibling"
7244            );
7245            let summary = admission
7246                .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
7247                .await
7248                .context("update-only classifications did not resume after stat release")??;
7249            assert_eq!(summary.copy_summary.files_copied, 2);
7250            Ok(())
7251        }
7252
7253        /// A vanished update-only classification is a child error: keep-going still materializes
7254        /// both successful siblings and folds their summaries into the returned combined error.
7255        #[tokio::test(flavor = "current_thread")]
7256        async fn update_only_classification_error_keeps_going_and_folds_siblings()
7257        -> Result<(), anyhow::Error> {
7258            let root = testutils::create_temp_dir().await?;
7259            let src = root.join("src");
7260            let update = root.join("update");
7261            let dst = root.join("dst");
7262            tokio::fs::create_dir(&src).await?;
7263            tokio::fs::create_dir(&update).await?;
7264            tokio::fs::create_dir(&dst).await?;
7265            for name in ["first", "middle", "last"] {
7266                tokio::fs::write(update.join(name), name.as_bytes()).await?;
7267            }
7268            tokio::fs::write(update.join("uninjected"), b"uninjected").await?;
7269
7270            let admission = testutils::AdmissionLimit::new().await;
7271            admission.set_files_in_flight(3);
7272            let src_dir =
7273                Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
7274            let update_dir =
7275                Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
7276            let dst_dir =
7277                Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
7278            let dst_parent =
7279                Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
7280            let stat_resource =
7281                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
7282            admission.set_max_ops_in_flight(stat_resource, 1);
7283            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
7284            let vanished = std::ffi::OsString::from("middle");
7285            let successful = [
7286                std::ffi::OsString::from("first"),
7287                std::ffi::OsString::from("last"),
7288            ];
7289            let settings = common_settings(false, false);
7290            let operation = crate::walk_driver::scope_tasks(link_dir_contents(
7291                &PROGRESS,
7292                &src_dir,
7293                Some(&update_dir),
7294                Some(&dst_dir),
7295                Some(&dst_parent),
7296                std::ffi::OsStr::new("dst"),
7297                &src,
7298                &dst,
7299                Some(&update),
7300                std::path::Path::new(""),
7301                &src,
7302                &dst,
7303                false,
7304                false,
7305                None,
7306                &settings,
7307                Summary::default(),
7308                SourceEntries::Live,
7309                UpdateEntries::Injected(vec![
7310                    (std::ffi::OsString::from("first"), Some(EntryKind::File)),
7311                    (std::ffi::OsString::from("middle"), Some(EntryKind::File)),
7312                    (std::ffi::OsString::from("last"), Some(EntryKind::File)),
7313                ]),
7314            ));
7315            tokio::pin!(operation);
7316            loop {
7317                assert!(
7318                    futures::poll!(operation.as_mut()).is_pending(),
7319                    "update-only link completed while exact classifications were gated"
7320                );
7321                let mut available = Vec::new();
7322                for _ in 0..3 {
7323                    let mut probe = Box::pin(throttle::open_file_permit());
7324                    match futures::poll!(probe.as_mut()) {
7325                        std::task::Poll::Ready(permit) => available.push(permit),
7326                        std::task::Poll::Pending => break,
7327                    }
7328                }
7329                let count = available.len();
7330                drop(available);
7331                if count < 3 {
7332                    break;
7333                }
7334                tokio::task::yield_now().await;
7335            }
7336            tokio::fs::remove_file(update.join(&vanished)).await?;
7337            drop(held_stat);
7338            let error = admission
7339                .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
7340                .await
7341                .context("keep-going update-only link did not terminate")?
7342                .expect_err("the vanished update-only entry classification must fail");
7343            assert_eq!(
7344                error.summary.copy_summary.files_copied, 2,
7345                "the combined error summary must include both successful siblings"
7346            );
7347            assert!(format!("{:#}", error.source).contains(vanished.to_string_lossy().as_ref()));
7348            for name in successful {
7349                assert_eq!(
7350                    tokio::fs::read(dst.join(&name)).await?,
7351                    name.as_encoded_bytes(),
7352                    "successful sibling {name:?} did not materialize"
7353                );
7354            }
7355            assert!(!dst.join(vanished).exists());
7356            Ok(())
7357        }
7358
7359        /// Update-only scheduling must stop before spawning a second fd-bearing copy when the
7360        /// first owns all open-file capacity. The ReadLink gate keeps that first worker live long
7361        /// enough to observe the number of started entry tasks deterministically.
7362        #[tokio::test]
7363        async fn update_only_spawning_stops_at_open_file_capacity() -> Result<(), anyhow::Error> {
7364            let tmp_dir = testutils::create_temp_dir().await?;
7365            let src = tmp_dir.join("src");
7366            let update = tmp_dir.join("update");
7367            let dst = tmp_dir.join("dst");
7368            tokio::fs::create_dir(&src).await?;
7369            tokio::fs::create_dir(&update).await?;
7370            let n = 8;
7371            for i in 0..n {
7372                tokio::fs::symlink(format!("target-{i}"), update.join(format!("u{i}"))).await?;
7373            }
7374            let progress: &'static progress::Progress =
7375                Box::leak(Box::new(progress::Progress::new()));
7376            let admission = testutils::AdmissionLimit::new().await;
7377            admission.set_files_in_flight(1);
7378            let readlink_resource =
7379                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::ReadLink);
7380            admission.set_max_ops_in_flight(readlink_resource, 1);
7381            let held_readlink = throttle::ops_in_flight_permit(readlink_resource).await;
7382            let mut task = tokio::spawn(async move {
7383                link(
7384                    progress,
7385                    &tmp_dir,
7386                    &src,
7387                    &dst,
7388                    &Some(update),
7389                    &common_settings(false, false),
7390                    false,
7391                )
7392                .await
7393            });
7394            let first_worker = tokio::time::timeout(std::time::Duration::from_secs(20), async {
7395                loop {
7396                    if progress.ops.get().started >= 2 {
7397                        break;
7398                    }
7399                    tokio::task::yield_now().await;
7400                }
7401            })
7402            .await;
7403            if let Err(error) = first_worker {
7404                drop(held_readlink);
7405                admission.quiesce(&mut task).await;
7406                return Err(error).context("the first update-only worker did not start");
7407            }
7408            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7409            let started_while_blocked = progress.ops.get().started;
7410            drop(held_readlink);
7411            let task_result = admission
7412                .run_with_timeout(std::time::Duration::from_secs(20), &mut task)
7413                .await
7414                .context("update-only copies did not resume after ReadLink was released")?;
7415            let summary = task_result?.map_err(|error| error.source)?;
7416            assert_eq!(
7417                started_while_blocked, 2,
7418                "root plus exactly one admitted update-only worker should be live"
7419            );
7420            assert_eq!(summary.copy_summary.symlinks_created, n);
7421            Ok(())
7422        }
7423
7424        /// Regression: the update-only entries loop must transfer open-file admission into
7425        /// copy_child without deadlocking against copy's own classification or rm's pending-meta.
7426        ///
7427        /// Scenario: update has many regular files that don't exist in src.
7428        /// The loop at site 3 spawns a copy::copy task per entry under a
7429        /// saturated files-in-flight pool. Each outer iteration transfers its guard into copy_child, so
7430        /// delegated classification does not reacquire while the caller retains capacity.
7431        #[tokio::test]
7432        #[traced_test]
7433        async fn update_only_entries_bounded_no_deadlock() -> Result<(), anyhow::Error> {
7434            let tmp_dir = testutils::create_temp_dir().await?;
7435            let src = tmp_dir.join("src");
7436            let update = tmp_dir.join("update");
7437            let dst = tmp_dir.join("dst");
7438            tokio::fs::create_dir(&src).await?;
7439            tokio::fs::create_dir(&update).await?;
7440            // src is empty; update has many regular files. Every update entry
7441            // is "missing in src" -> hits the site-3 spawn loop.
7442            let n = 50;
7443            for i in 0..n {
7444                tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
7445            }
7446            let admission = testutils::AdmissionLimit::new().await;
7447            admission.set_files_in_flight(2);
7448            let summary = admission
7449                .run_with_timeout(
7450                    std::time::Duration::from_secs(30),
7451                    link(
7452                        &PROGRESS,
7453                        tmp_dir.as_path(),
7454                        &src,
7455                        &dst,
7456                        &Some(update.clone()),
7457                        &common_settings(false, false),
7458                        false,
7459                    ),
7460                )
7461                .await
7462                .context("link timed out — site-3 spawn loop deadlock")?
7463                .context("link failed")?;
7464            // dst gets the src directory plus a copy of every update file
7465            assert_eq!(summary.copy_summary.directories_created, 1);
7466            assert_eq!(summary.copy_summary.files_copied, n);
7467            for i in 0..n {
7468                let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
7469                assert_eq!(content, format!("upd-{}", i));
7470            }
7471            Ok(())
7472        }
7473
7474        /// Regression for update-only copy ↔ rm cross-pool self-deadlock.
7475        ///
7476        /// Scenario: update has many entries not in src; dst already has
7477        /// directories at those same names; the user passes --overwrite. Each
7478        /// update-only task delegates through `copy_child` / `copy_file_fd`, whose
7479        /// `remove_existing` invokes `rm_child` to remove the preexisting destination directory.
7480        /// rm draws from PendingMeta. If the outer task also held PendingMeta across delegated copy,
7481        /// every running task would hold a permit while waiting on inner rm to acquire one — a
7482        /// self-deadlock once the pool is saturated.
7483        #[tokio::test]
7484        #[traced_test]
7485        async fn update_only_overwrite_preexisting_dirs_no_deadlock() -> Result<(), anyhow::Error> {
7486            let tmp_dir = testutils::create_temp_dir().await?;
7487            let src = tmp_dir.join("src");
7488            let update = tmp_dir.join("update");
7489            let dst = tmp_dir.join("dst");
7490            tokio::fs::create_dir(&src).await?;
7491            tokio::fs::create_dir(&update).await?;
7492            tokio::fs::create_dir(&dst).await?;
7493            let n = 12;
7494            for i in 0..n {
7495                // update/uN is a regular file (site 3 will copy it).
7496                tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
7497                // dst/uN is a preexisting directory with inner files. With
7498                // --overwrite, `remove_existing` invokes `rm_child` to wipe it, which recurses under
7499                // PendingMeta admission
7500                let dst_subdir = dst.join(format!("u{}", i));
7501                tokio::fs::create_dir(&dst_subdir).await?;
7502                for j in 0..3 {
7503                    tokio::fs::write(
7504                        dst_subdir.join(format!("inner_{}.txt", j)),
7505                        format!("old-{}-{}", i, j),
7506                    )
7507                    .await?;
7508                }
7509            }
7510            // saturate both pools to force the deadlock if the cycle existed.
7511            let admission = testutils::AdmissionLimit::new().await;
7512            admission.set_files_in_flight(2);
7513            let summary = admission
7514                .run_with_timeout(
7515                    std::time::Duration::from_secs(30),
7516                    link(
7517                        &PROGRESS,
7518                        tmp_dir.as_path(),
7519                        &src,
7520                        &dst,
7521                        &Some(update.clone()),
7522                        &common_settings(false, true), // overwrite=true
7523                        false,
7524                    ),
7525                )
7526                .await
7527                .context("link timed out — pending-meta self-deadlock between site 3 and inner rm")?
7528                .context("link failed")?;
7529            // each preexisting dst/uN directory gets removed and replaced
7530            // with a regular-file copy from update/uN.
7531            assert_eq!(summary.copy_summary.files_copied, n);
7532            assert_eq!(summary.copy_summary.rm_summary.files_removed, n * 3);
7533            assert_eq!(summary.copy_summary.rm_summary.directories_removed, n);
7534            // verify content came from update
7535            for i in 0..n {
7536                let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
7537                assert_eq!(content, format!("upd-{}", i));
7538            }
7539            Ok(())
7540        }
7541    }
7542
7543    /// TOCTOU hardening: a source entry being hard-linked is concurrently swapped between a real
7544    /// regular file and a symlink to a sentinel OUTSIDE the source tree. rlink classifies the entry
7545    /// via `child` (fstat) before acting and links the pinned inode inode-exactly
7546    /// (`hard_link_handle_at`), so a swap is either caught (the entry is linked/copied as a symlink,
7547    /// or the op fails closed) or the real file is hard-linked. The sentinel's secret content must
7548    /// NEVER appear at the destination as a regular file, and the sentinel inode must never gain a
7549    /// new hard link.
7550    mod race_tests {
7551        use super::*;
7552
7553        // Repeatedly swap `dir/entry_name` between a real regular file (content `REAL_CONTENT`) and
7554        // a symlink pointing at `sentinel`, using rename so each individual state is atomic. Runs on
7555        // a dedicated OS thread so it makes progress regardless of the tokio runtime's scheduling.
7556        fn spawn_file_symlink_swapper(
7557            dir: std::path::PathBuf,
7558            entry_name: &'static str,
7559            sentinel: std::path::PathBuf,
7560            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
7561        ) -> std::thread::JoinHandle<()> {
7562            std::thread::spawn(move || {
7563                let entry = dir.join(entry_name);
7564                let staged_real = dir.join("__staged_real");
7565                let staged_link = dir.join("__staged_link");
7566                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
7567                    let _ = std::fs::remove_file(&staged_real);
7568                    if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
7569                        continue;
7570                    }
7571                    let _ = std::fs::rename(&staged_real, &entry);
7572                    let _ = std::fs::remove_file(&staged_link);
7573                    let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
7574                    let _ = std::fs::rename(&staged_link, &entry);
7575                }
7576            })
7577        }
7578
7579        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7580        #[traced_test]
7581        async fn hard_link_entry_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
7582            let tmp_dir = testutils::create_temp_dir().await?;
7583            let test_path = tmp_dir.as_path();
7584            // sentinel lives OUTSIDE the source tree with distinctive content; we also track its
7585            // hard-link count to prove `linkat(flags=0)` never gives it a new hard link.
7586            let sentinel = test_path.join("sentinel_secret");
7587            tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
7588            let sentinel_links_before = {
7589                use std::os::unix::fs::MetadataExt;
7590                tokio::fs::symlink_metadata(&sentinel).await?.nlink()
7591            };
7592            let src = test_path.join("src");
7593            let sub = src.join("sub");
7594            tokio::fs::create_dir(&src).await?;
7595            tokio::fs::create_dir(&sub).await?;
7596            tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
7597
7598            let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7599            let swapper =
7600                spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
7601
7602            // overwrite=true so each iteration's destination need not be empty; no update tree, so
7603            // `src/sub/entry` takes the hard-link path (or copy-as-symlink when caught mid-swap).
7604            let settings = common_settings(false, true);
7605            let mut caught_swaps = 0usize;
7606            let mut linked_real = 0usize;
7607            for i in 0..200 {
7608                let dst = test_path.join(format!("dst_{i}"));
7609                let result = tokio::time::timeout(
7610                    std::time::Duration::from_secs(30),
7611                    link(&PROGRESS, test_path, &src, &dst, &None, &settings, false),
7612                )
7613                .await
7614                .expect("link must not hang under concurrent swapping");
7615                match result {
7616                    Ok(_) => {}
7617                    Err(_) => caught_swaps += 1, // a swap was caught mid-link (failed closed)
7618                }
7619                // CORE ASSERTION: if a regular file landed at the destination it holds the REAL
7620                // content — never the sentinel's secret. The entry may instead be a symlink
7621                // (linkat made a hard link to the symlink inode, or copy reproduced the symlink) or
7622                // be absent. A symlink that resolves to the sentinel is fine: it is a link, not a
7623                // copy of the secret bytes, and it did not give the sentinel a new hard link.
7624                let entry_dst = dst.join("sub").join("entry");
7625                if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
7626                    && md.file_type().is_file()
7627                {
7628                    let content = tokio::fs::read_to_string(&entry_dst).await?;
7629                    assert_ne!(
7630                        content, "SENTINEL_SECRET_CONTENT",
7631                        "iteration {i}: sentinel content leaked into the destination as a regular file"
7632                    );
7633                    assert_eq!(
7634                        content, "REAL_CONTENT",
7635                        "iteration {i}: a regular destination file must hold the real content"
7636                    );
7637                    linked_real += 1;
7638                }
7639                let _ = tokio::fs::remove_dir_all(&dst).await;
7640            }
7641
7642            stop.store(true, std::sync::atomic::Ordering::Relaxed);
7643            swapper.join().expect("swapper thread panicked");
7644
7645            // the sentinel must never have gained a hard link from a `linkat` that followed the
7646            // swapped-in symlink (flags=0 links the symlink inode itself, not its target).
7647            let sentinel_links_after = {
7648                use std::os::unix::fs::MetadataExt;
7649                tokio::fs::symlink_metadata(&sentinel).await?.nlink()
7650            };
7651            assert_eq!(
7652                sentinel_links_after, sentinel_links_before,
7653                "the sentinel file must never gain a hard link (linkat must not follow the symlink)"
7654            );
7655            // sanity: the run did observable work (this is not the safety assertion — the safety
7656            // assertions above hold on every iteration regardless of timing).
7657            tracing::info!(
7658                "link file/symlink swap: caught_swaps={caught_swaps}, linked_real={linked_real}"
7659            );
7660            assert!(
7661                caught_swaps + linked_real > 0,
7662                "expected at least one observable outcome across 200 iterations"
7663            );
7664            Ok(())
7665        }
7666    }
7667}