Skip to main content

common/
link.rs

1use anyhow::{Context, anyhow};
2use async_recursion::async_recursion;
3use std::os::linux::fs::MetadataExt as LinuxMetadataExt;
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::rm;
14use crate::walk::{self, EntryKind};
15
16/// Error type for link operations. See [`crate::error::OperationError`] for
17/// logging conventions and rationale.
18pub type Error = crate::error::OperationError<Summary>;
19
20#[derive(Debug, Clone)]
21pub struct Settings {
22    pub copy_settings: CopySettings,
23    pub update_compare: filecmp::MetadataCmpSettings,
24    pub update_exclusive: bool,
25    /// filter settings for include/exclude patterns
26    pub filter: Option<crate::filter::FilterSettings>,
27    /// dry-run mode for previewing operations
28    pub dry_run: Option<crate::config::DryRunMode>,
29    /// metadata preservation settings
30    pub preserve: preserve::Settings,
31}
32
33/// Summary with the appropriate `*_skipped` counter set to 1 for the given entry kind.
34/// Special files count as `files_skipped` to match the historical mapping used
35/// when filters skip an entry (`specials_skipped` is reserved for `--skip-specials`).
36fn skipped_summary_for(kind: EntryKind) -> Summary {
37    let copy_summary = match kind {
38        EntryKind::Dir => CopySummary {
39            directories_skipped: 1,
40            ..Default::default()
41        },
42        EntryKind::Symlink => CopySummary {
43            symlinks_skipped: 1,
44            ..Default::default()
45        },
46        EntryKind::File | EntryKind::Special => CopySummary {
47            files_skipped: 1,
48            ..Default::default()
49        },
50    };
51    Summary {
52        copy_summary,
53        ..Default::default()
54    }
55}
56
57#[derive(Copy, Clone, Debug, Default)]
58pub struct Summary {
59    pub hard_links_created: usize,
60    pub hard_links_unchanged: usize,
61    pub copy_summary: CopySummary,
62}
63
64impl std::ops::Add for Summary {
65    type Output = Self;
66    fn add(self, other: Self) -> Self {
67        Self {
68            hard_links_created: self.hard_links_created + other.hard_links_created,
69            hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
70            copy_summary: self.copy_summary + other.copy_summary,
71        }
72    }
73}
74
75impl std::fmt::Display for Summary {
76    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
77        write!(
78            f,
79            "{}\n\
80            link:\n\
81            -----\n\
82            hard-links created: {}\n\
83            hard links unchanged: {}\n",
84            &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
85        )
86    }
87}
88
89fn is_hard_link(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
90    copy::is_file_type_same(md1, md2)
91        && md2.st_dev() == md1.st_dev()
92        && md2.st_ino() == md1.st_ino()
93}
94
95#[instrument(skip(prog_track, settings))]
96async fn hard_link_helper(
97    prog_track: &'static progress::Progress,
98    src: &std::path::Path,
99    src_metadata: &std::fs::Metadata,
100    dst: &std::path::Path,
101    settings: &Settings,
102) -> Result<Summary, Error> {
103    let mut link_summary = Summary::default();
104    match crate::walk::run_metadata_probed(
105        congestion::Side::Destination,
106        congestion::MetadataOp::HardLink,
107        tokio::fs::hard_link(src, dst),
108    )
109    .await
110    {
111        Ok(()) => {}
112        Err(error)
113            if settings.copy_settings.overwrite
114                && error.kind() == std::io::ErrorKind::AlreadyExists =>
115        {
116            tracing::debug!("'dst' already exists, check if we need to update");
117            let dst_metadata = crate::walk::run_metadata_probed(
118                congestion::Side::Destination,
119                congestion::MetadataOp::Stat,
120                tokio::fs::symlink_metadata(dst),
121            )
122            .await
123            .with_context(|| format!("cannot read {dst:?} metadata"))
124            .map_err(|err| Error::new(err, Default::default()))?;
125            if is_hard_link(src_metadata, &dst_metadata) {
126                tracing::debug!("no change, leaving file as is");
127                prog_track.hard_links_unchanged.inc();
128                return Ok(Summary {
129                    hard_links_unchanged: 1,
130                    ..Default::default()
131                });
132            }
133            tracing::info!("'dst' file type changed, removing and hard-linking");
134            let rm_summary = rm::rm(
135                prog_track,
136                dst,
137                &rm::Settings {
138                    fail_early: settings.copy_settings.fail_early,
139                    filter: None,
140                    dry_run: None,
141                    time_filter: None,
142                },
143            )
144            .await
145            .map_err(|err| {
146                let rm_summary = err.summary;
147                link_summary.copy_summary.rm_summary = rm_summary;
148                Error::new(err.source, link_summary)
149            })?;
150            link_summary.copy_summary.rm_summary = rm_summary;
151            crate::walk::run_metadata_probed(
152                congestion::Side::Destination,
153                congestion::MetadataOp::HardLink,
154                tokio::fs::hard_link(src, dst),
155            )
156            .await
157            .with_context(|| format!("failed to hard link {src:?} to {dst:?}"))
158            .map_err(|err| Error::new(err, link_summary))?;
159        }
160        Err(error) => {
161            return Err(Error::new(
162                anyhow::Error::from(error)
163                    .context(format!("failed to hard link {src:?} to {dst:?}")),
164                link_summary,
165            ));
166        }
167    }
168    prog_track.hard_links_created.inc();
169    link_summary.hard_links_created = 1;
170    Ok(link_summary)
171}
172
173/// Public entry point for link operations.
174/// Internally delegates to link_internal with source_root tracking for proper filter matching.
175#[instrument(skip(prog_track, settings))]
176pub async fn link(
177    prog_track: &'static progress::Progress,
178    cwd: &std::path::Path,
179    src: &std::path::Path,
180    dst: &std::path::Path,
181    update: &Option<std::path::PathBuf>,
182    settings: &Settings,
183    is_fresh: bool,
184) -> Result<Summary, Error> {
185    // A missing --update root is destructive under both --update-exclusive (materialized set =
186    // update set, so nothing materializes) AND --delete (the source-only keep_set makes any dst
187    // entry the missing update tree WOULD have protected look extraneous, and prune wipes it).
188    // In either case `link_internal` hits the recursive early-return / silent `None` fallback
189    // before that destruction would happen, so rlink reports success — silently preserving
190    // stale dst (--update-exclusive) or silently pruning would-be-protected entries (--delete).
191    // Reject at the public entry so a typo'd --update can't quietly do the wrong thing. The
192    // plain "--update without --delete or --update-exclusive" case still falls back to no-update
193    // mode (long-standing behavior), and recursive child-level "update missing" cases stay
194    // handled inside link_internal — they correctly no-op so the parent's prune removes their
195    // dst counterpart per the documented semantics.
196    if let Some(update_path) = update.as_ref()
197        && (settings.update_exclusive || settings.copy_settings.delete.is_some())
198    {
199        match crate::walk::run_metadata_probed(
200            congestion::Side::Source,
201            congestion::MetadataOp::Stat,
202            tokio::fs::symlink_metadata(update_path),
203        )
204        .await
205        {
206            Ok(_) => {}
207            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
208                return Err(Error::new(
209                    anyhow!(
210                        "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
211                        update_path
212                    ),
213                    Default::default(),
214                ));
215            }
216            Err(err) => {
217                return Err(Error::new(
218                    anyhow::Error::new(err).context(format!(
219                        "failed reading metadata from update {:?}",
220                        update_path
221                    )),
222                    Default::default(),
223                ));
224            }
225        }
226    }
227    // check filter for top-level source (files, directories, and symlinks)
228    if let Some(ref filter) = settings.filter {
229        let src_name = src.file_name().map(std::path::Path::new);
230        if let Some(name) = src_name {
231            let src_metadata = crate::walk::run_metadata_probed(
232                congestion::Side::Source,
233                congestion::MetadataOp::Stat,
234                tokio::fs::symlink_metadata(src),
235            )
236            .await
237            .with_context(|| format!("failed reading metadata from {:?}", &src))
238            .map_err(|err| Error::new(err, Default::default()))?;
239            let is_dir = src_metadata.is_dir();
240            let result = filter.should_include_root_item(name, is_dir);
241            match result {
242                crate::filter::FilterResult::Included => {}
243                result => {
244                    let kind = EntryKind::from_metadata(&src_metadata);
245                    if let Some(mode) = settings.dry_run {
246                        crate::dry_run::report_skip(src, &result, mode, kind.label_long());
247                    }
248                    kind.inc_skipped(prog_track);
249                    return Ok(skipped_summary_for(kind));
250                }
251            }
252        }
253    }
254    link_internal(
255        prog_track, cwd, src, dst, src, update, settings, is_fresh, None,
256    )
257    .await
258}
259/// Tracks which child names will be materialized at the destination for a single directory
260/// pass, used by `--delete` to decide what to prune. Operations are named after their
261/// semantic intent so the call sites don't repeat the gating conditions (delete-on-vs-off,
262/// `--update-exclusive` carve-out, skip-special-vs-real materialization).
263///
264/// When `--delete` is off the inner set is `None` and every method is a no-op — zero heap
265/// cost in the hot path.
266struct DeleteKeepSet {
267    inner: Option<std::collections::HashSet<std::ffi::OsString>>,
268    /// Under `--update-exclusive` with an active update tree, the source loop must NOT
269    /// register source-only entries — only the update set materializes.
270    src_records_disabled: bool,
271}
272
273impl DeleteKeepSet {
274    fn new(
275        delete: Option<&copy::DeleteSettings>,
276        update_exclusive: bool,
277        update_present: bool,
278    ) -> Self {
279        Self {
280            inner: delete.is_some().then(std::collections::HashSet::new),
281            src_records_disabled: update_exclusive && update_present,
282        }
283    }
284    /// Source loop: this src entry passed the filter. Called even when `--skip-specials`
285    /// will skip materialization — the dst counterpart still needs to be retained.
286    fn record_src(&mut self, name: &std::ffi::OsStr) {
287        if let Some(set) = &mut self.inner
288            && !self.src_records_disabled
289        {
290            set.insert(name.to_owned());
291        }
292    }
293    /// Update loop: this update entry passed the filter at its logical path.
294    fn record_update(&mut self, name: &std::ffi::OsStr) {
295        if let Some(set) = &mut self.inner {
296            set.insert(name.to_owned());
297        }
298    }
299    /// Update loop, filtered-out branch: an update entry at this name is filtered out, so
300    /// nothing materializes from the update side. Drop a src-side registration ONLY if the
301    /// source loop actually materialized something (caller tracks this via `processed_files`).
302    /// Skipped specials stay registered — their `record_src` happened, but `processed_files`
303    /// was not populated, so their dst counterpart is retained per `--skip-specials` semantics.
304    fn drop_src_when_update_filtered(&mut self, name: &std::ffi::OsStr, src_materialized: bool) {
305        if let Some(set) = &mut self.inner
306            && src_materialized
307        {
308            set.remove(name);
309        }
310    }
311    /// Borrow the underlying set for `prune_extraneous`. `None` means `--delete` is off and
312    /// the caller should skip the prune entirely.
313    fn as_set(&self) -> Option<&std::collections::HashSet<std::ffi::OsString>> {
314        self.inner.as_ref()
315    }
316}
317
318#[instrument(skip(prog_track, settings, open_file_guard))]
319#[async_recursion]
320#[allow(clippy::too_many_arguments)]
321async fn link_internal(
322    prog_track: &'static progress::Progress,
323    cwd: &std::path::Path,
324    src: &std::path::Path,
325    dst: &std::path::Path,
326    source_root: &std::path::Path,
327    update: &Option<std::path::PathBuf>,
328    settings: &Settings,
329    mut is_fresh: bool,
330    open_file_guard: Option<throttle::OpenFileGuard>,
331) -> Result<Summary, Error> {
332    let _prog_guard = prog_track.ops.guard();
333    tracing::debug!("reading source metadata");
334    let src_metadata = crate::walk::run_metadata_probed(
335        congestion::Side::Source,
336        congestion::MetadataOp::Stat,
337        tokio::fs::symlink_metadata(src),
338    )
339    .await
340    .with_context(|| format!("failed reading metadata from {:?}", &src))
341    .map_err(|err| Error::new(err, Default::default()))?;
342    let update_metadata_opt = match update {
343        Some(update) => {
344            tracing::debug!("reading 'update' metadata");
345            let update_metadata_res = crate::walk::run_metadata_probed(
346                congestion::Side::Source,
347                congestion::MetadataOp::Stat,
348                tokio::fs::symlink_metadata(update),
349            )
350            .await;
351            match update_metadata_res {
352                Ok(update_metadata) => Some(update_metadata),
353                Err(error) => {
354                    if error.kind() == std::io::ErrorKind::NotFound {
355                        if settings.update_exclusive {
356                            // the path is missing from update, we're done
357                            return Ok(Default::default());
358                        }
359                        None
360                    } else {
361                        return Err(Error::new(
362                            anyhow!("failed reading metadata from {:?}", &update),
363                            Default::default(),
364                        ));
365                    }
366                }
367            }
368        }
369        None => None,
370    };
371    if let Some(update_metadata) = update_metadata_opt.as_ref() {
372        let update = update.as_ref().unwrap();
373        if !copy::is_file_type_same(&src_metadata, update_metadata) {
374            // file type changed, just copy the updated one
375            tracing::debug!(
376                "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
377                src,
378                src_metadata.file_type(),
379                update,
380                update_metadata.file_type()
381            );
382            // release any caller-supplied open-files permit before delegating
383            // to copy::copy. The permit was acquired for the src entry's file
384            // type at the spawn site, but here `update` has a *different* file
385            // type (we just checked `!is_file_type_same`), so the permit is
386            // mismatched. More importantly, copy::copy → copy_internal will
387            // acquire its own open-files permit for any file it copies; if we
388            // were still holding one here, a saturated pool would deadlock the
389            // inner acquire.
390            drop(open_file_guard);
391            // delegate at this entry's logical path (relative to the link root) so that, under
392            // --delete, pruning inside the delegated subtree matches include/exclude descendants
393            // at the correct filter root (e.g. `node/*.log`) — mirroring the update-only
394            // delegation. With an empty base, a path-anchored exclude would fail to protect a
395            // descendant like `node/keep.log` and delete it.
396            let filter_base = walk::relative_to_root(src, source_root);
397            let copy_summary = copy::copy_with_filter_base(
398                prog_track,
399                update,
400                dst,
401                &settings.copy_settings,
402                &settings.preserve,
403                is_fresh,
404                filter_base,
405            )
406            .await
407            .map_err(|err| {
408                let copy_summary = err.summary;
409                let link_summary = Summary {
410                    copy_summary,
411                    ..Default::default()
412                };
413                Error::new(err.source, link_summary)
414            })?;
415            return Ok(Summary {
416                copy_summary,
417                ..Default::default()
418            });
419        }
420        if update_metadata.is_file() {
421            // check if the file is unchanged and if so hard-link, otherwise copy from the updated one
422            if filecmp::metadata_equal(&settings.update_compare, &src_metadata, update_metadata) {
423                tracing::debug!("no change, hard link 'src'");
424                return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
425            }
426            tracing::debug!(
427                "link: {:?} metadata has changed, copying from {:?}",
428                src,
429                update
430            );
431            // use the caller's pre-acquired permit (the spawn loop pre-acquires
432            // for regular-file entries so this is the common path); fall back to
433            // acquiring a new one for callers that don't pre-acquire (top-level
434            // `link` and the file-type-changed path above).
435            let _guard = match open_file_guard {
436                Some(g) => g,
437                None => throttle::open_file_permit().await,
438            };
439            return Ok(Summary {
440                copy_summary: copy::copy_file(
441                    prog_track,
442                    update,
443                    dst,
444                    update_metadata,
445                    &settings.copy_settings,
446                    &settings.preserve,
447                    is_fresh,
448                )
449                .await
450                .map_err(|err| {
451                    let copy_summary = err.summary;
452                    let link_summary = Summary {
453                        copy_summary,
454                        ..Default::default()
455                    };
456                    Error::new(err.source, link_summary)
457                })?,
458                ..Default::default()
459            });
460        }
461        if update_metadata.is_symlink() {
462            tracing::debug!("'update' is a symlink so just symlink that");
463            // delegate at this entry's logical path (relative to the link root) so the inner
464            // filter re-check in copy_with_filter_base uses nested semantics. With an empty
465            // filter_base it would fall back to should_include_root_item on the bare basename
466            // and reject a path-anchored include like `dir/link`, leaving the entry unmaterialized
467            // while the outer loop's keep_set entry still shielded the stale dst from pruning.
468            let filter_base = walk::relative_to_root(src, source_root);
469            let copy_summary = copy::copy_with_filter_base(
470                prog_track,
471                update,
472                dst,
473                &settings.copy_settings,
474                &settings.preserve,
475                is_fresh,
476                filter_base,
477            )
478            .await
479            .map_err(|err| {
480                let copy_summary = err.summary;
481                let link_summary = Summary {
482                    copy_summary,
483                    ..Default::default()
484                };
485                Error::new(err.source, link_summary)
486            })?;
487            return Ok(Summary {
488                copy_summary,
489                ..Default::default()
490            });
491        }
492    } else {
493        // update hasn't been specified, if this is a file just hard-link the source or symlink if it's a symlink
494        tracing::debug!("no 'update' specified");
495        if src_metadata.is_file() {
496            // handle dry-run mode for top-level files
497            if settings.dry_run.is_some() {
498                crate::dry_run::report_action("link", src, Some(dst), "file");
499                return Ok(Summary {
500                    hard_links_created: 1,
501                    ..Default::default()
502                });
503            }
504            return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
505        }
506        if src_metadata.is_symlink() {
507            tracing::debug!("'src' is a symlink so just symlink that");
508            // delegate at this entry's logical path so the inner filter re-check uses nested
509            // semantics — see the matching comment above on the update-symlink branch.
510            let filter_base = walk::relative_to_root(src, source_root);
511            let copy_summary = copy::copy_with_filter_base(
512                prog_track,
513                src,
514                dst,
515                &settings.copy_settings,
516                &settings.preserve,
517                is_fresh,
518                filter_base,
519            )
520            .await
521            .map_err(|err| {
522                let copy_summary = err.summary;
523                let link_summary = Summary {
524                    copy_summary,
525                    ..Default::default()
526                };
527                Error::new(err.source, link_summary)
528            })?;
529            return Ok(Summary {
530                copy_summary,
531                ..Default::default()
532            });
533        }
534    }
535    if !src_metadata.is_dir() {
536        if settings.copy_settings.skip_specials {
537            tracing::debug!(
538                "skipping special file {:?} (type: {:?})",
539                src,
540                src_metadata.file_type()
541            );
542            if let Some(mode) = settings.dry_run {
543                match mode {
544                    crate::config::DryRunMode::Brief => {}
545                    crate::config::DryRunMode::All => println!("skip special {:?}", src),
546                    crate::config::DryRunMode::Explain => {
547                        println!(
548                            "skip special {:?} (unsupported file type: {:?})",
549                            src,
550                            src_metadata.file_type()
551                        );
552                    }
553                }
554            }
555            prog_track.specials_skipped.inc();
556            return Ok(Summary {
557                copy_summary: CopySummary {
558                    specials_skipped: 1,
559                    ..Default::default()
560                },
561                ..Default::default()
562            });
563        }
564        return Err(Error::new(
565            anyhow!(
566                "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
567                src,
568                dst,
569                src_metadata.file_type()
570            ),
571            Default::default(),
572        ));
573    }
574    assert!(update_metadata_opt.is_none() || update_metadata_opt.as_ref().unwrap().is_dir());
575    tracing::debug!("process contents of 'src' directory");
576    let mut src_entries = tokio::fs::read_dir(src)
577        .await
578        .with_context(|| format!("cannot open directory {src:?} for reading"))
579        .map_err(|err| Error::new(err, Default::default()))?;
580    // handle dry-run mode for directories at the top level
581    if settings.dry_run.is_some() {
582        crate::dry_run::report_action("link", src, Some(dst), "dir");
583        // still need to recurse to show contents
584    }
585    let copy_summary = if settings.dry_run.is_some() {
586        // skip actual directory creation in dry-run mode
587        CopySummary {
588            directories_created: 1,
589            ..Default::default()
590        }
591    } else if let Err(error) = crate::walk::run_metadata_probed(
592        congestion::Side::Destination,
593        congestion::MetadataOp::MkDir,
594        tokio::fs::create_dir(dst),
595    )
596    .await
597    {
598        assert!(!is_fresh, "unexpected error creating directory: {:?}", &dst);
599        if settings.copy_settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
600            // check if the destination is a directory - if so, leave it
601            //
602            // N.B. the permissions may prevent us from writing to it but the alternative is to open up the directory
603            // while we're writing to it which isn't safe
604            let dst_metadata = crate::walk::run_metadata_probed(
605                congestion::Side::Destination,
606                congestion::MetadataOp::Stat,
607                // symlink_metadata (not metadata): do not follow a destination symlink. A
608                // symlinked directory is then treated as "not a directory" below and replaced,
609                // rather than copied/pruned *through* — which under --delete could delete files
610                // outside the destination tree. Mirrors copy.rs.
611                tokio::fs::symlink_metadata(dst),
612            )
613            .await
614            .with_context(|| format!("failed reading metadata from {:?}", &dst))
615            .map_err(|err| Error::new(err, Default::default()))?;
616            if dst_metadata.is_dir() {
617                tracing::debug!("'dst' is a directory, leaving it as is");
618                CopySummary {
619                    directories_unchanged: 1,
620                    ..Default::default()
621                }
622            } else {
623                tracing::info!("'dst' is not a directory, removing and creating a new one");
624                let mut copy_summary = CopySummary::default();
625                let rm_summary = rm::rm(
626                    prog_track,
627                    dst,
628                    &rm::Settings {
629                        fail_early: settings.copy_settings.fail_early,
630                        filter: None,
631                        dry_run: None,
632                        time_filter: None,
633                    },
634                )
635                .await
636                .map_err(|err| {
637                    let rm_summary = err.summary;
638                    copy_summary.rm_summary = rm_summary;
639                    Error::new(
640                        err.source,
641                        Summary {
642                            copy_summary,
643                            ..Default::default()
644                        },
645                    )
646                })?;
647                crate::walk::run_metadata_probed(
648                    congestion::Side::Destination,
649                    congestion::MetadataOp::MkDir,
650                    tokio::fs::create_dir(dst),
651                )
652                .await
653                .with_context(|| format!("cannot create directory {dst:?}"))
654                .map_err(|err| {
655                    copy_summary.rm_summary = rm_summary;
656                    Error::new(
657                        err,
658                        Summary {
659                            copy_summary,
660                            ..Default::default()
661                        },
662                    )
663                })?;
664                // anything copied into dst may assume they don't need to check for conflicts
665                is_fresh = true;
666                CopySummary {
667                    rm_summary,
668                    directories_created: 1,
669                    ..Default::default()
670                }
671            }
672        } else {
673            return Err(error)
674                .with_context(|| format!("cannot create directory {dst:?}"))
675                .map_err(|err| Error::new(err, Default::default()))?;
676        }
677    } else {
678        // new directory created, anything copied into dst may assume they don't need to check for conflicts
679        is_fresh = true;
680        CopySummary {
681            directories_created: 1,
682            ..Default::default()
683        }
684    };
685    // track whether we created this directory (vs it already existing)
686    // this is used later to decide if we should clean up an empty directory
687    let we_created_this_dir = copy_summary.directories_created == 1;
688    let mut link_summary = Summary {
689        copy_summary,
690        ..Default::default()
691    };
692    let mut join_set = tokio::task::JoinSet::new();
693    let errors = crate::error_collector::ErrorCollector::default();
694    // create a set of all the files we already processed
695    let mut processed_files = std::collections::HashSet::new();
696    // Keep-set for --delete: names that will be materialized at the destination. See
697    // `DeleteKeepSet` for the semantics of `record_src` / `record_update` /
698    // `drop_src_when_update_filtered`. No-op when --delete is off, so the call sites stay
699    // unconditional in the hot path.
700    let mut keep_set = DeleteKeepSet::new(
701        settings.copy_settings.delete.as_ref(),
702        settings.update_exclusive,
703        update.is_some(),
704    );
705    // iterate through src entries and recursively call "link" on each one
706    loop {
707        let Some((src_entry, entry_file_type)) =
708            crate::walk::next_entry_probed(&mut src_entries, congestion::Side::Source, || {
709                format!("failed traversing directory {:?}", &src)
710            })
711            .await
712            .map_err(|err| Error::new(err, link_summary))?
713        else {
714            break;
715        };
716        let cwd_path = cwd.to_owned();
717        let entry_path = src_entry.path();
718        let entry_name = entry_path.file_name().unwrap();
719        let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
720        let entry_is_dir = entry_kind == EntryKind::Dir;
721        let entry_is_symlink = entry_kind == EntryKind::Symlink;
722        // compute relative path from source_root for filter matching
723        let relative_path = walk::relative_to_root(&entry_path, source_root);
724        // apply filter if configured
725        if let Some(skip_result) =
726            walk::should_skip_entry(&settings.filter, relative_path, entry_is_dir)
727        {
728            if let Some(mode) = settings.dry_run {
729                crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
730            }
731            tracing::debug!("skipping {:?} due to filter", &entry_path);
732            link_summary = link_summary + skipped_summary_for(entry_kind);
733            entry_kind.inc_skipped(prog_track);
734            continue;
735        }
736        // keep-set: a source entry has a destination counterpart that must not be pruned, even
737        // when --skip-specials skips copying it (computed before the skip-specials check below).
738        keep_set.record_src(entry_name);
739        // skip special files (sockets, FIFOs, devices) when --skip-specials is set
740        if settings.copy_settings.skip_specials && entry_kind == EntryKind::Special {
741            tracing::debug!("skipping special file {:?}", &entry_path);
742            if let Some(mode) = settings.dry_run {
743                match mode {
744                    crate::config::DryRunMode::Brief => {}
745                    crate::config::DryRunMode::All => {
746                        println!("skip special {:?}", &entry_path)
747                    }
748                    crate::config::DryRunMode::Explain => {
749                        println!(
750                            "skip special {:?} (unsupported file type: {:?})",
751                            &entry_path,
752                            entry_file_type.unwrap()
753                        );
754                    }
755                }
756            }
757            link_summary.copy_summary.specials_skipped += 1;
758            prog_track.specials_skipped.inc();
759            continue;
760        }
761        processed_files.insert(entry_name.to_owned());
762        let dst_path = dst.join(entry_name);
763        let update_path = update.as_ref().map(|s| s.join(entry_name));
764        // handle dry-run mode for link operations
765        if let Some(_mode) = settings.dry_run {
766            crate::dry_run::report_action("link", &entry_path, Some(&dst_path), entry_kind.label());
767            // for directories in dry-run, still need to recurse to show all entries
768            if entry_is_dir {
769                let settings = settings.clone();
770                let source_root = source_root.to_owned();
771                let do_link = || async move {
772                    link_internal(
773                        prog_track,
774                        &cwd_path,
775                        &entry_path,
776                        &dst_path,
777                        &source_root,
778                        &update_path,
779                        &settings,
780                        true,
781                        None,
782                    )
783                    .await
784                };
785                join_set.spawn(do_link());
786            } else if entry_is_symlink {
787                // for symlinks in dry-run, count as symlink (in copy_summary)
788                link_summary.copy_summary.symlinks_created += 1;
789            } else {
790                // for files in dry-run, count the "would be created" hard link
791                link_summary.hard_links_created += 1;
792            }
793            continue;
794        }
795        let settings = settings.clone();
796        let source_root = source_root.to_owned();
797        // for regular-file entries, acquire the open file permit BEFORE spawning so
798        // we don't create unbounded tasks. mirrors the pattern in copy.rs.
799        // directories must NOT pre-acquire because they recurse and would deadlock
800        // against a saturated semaphore. symlinks aren't pre-acquired because they
801        // can pass through to copy::copy which handles permits internally.
802        let entry_is_regular_file = entry_file_type.as_ref().is_some_and(|ft| ft.is_file());
803        let open_file_guard = if entry_is_regular_file {
804            Some(throttle::open_file_permit().await)
805        } else {
806            None
807        };
808        let do_link = || async move {
809            link_internal(
810                prog_track,
811                &cwd_path,
812                &entry_path,
813                &dst_path,
814                &source_root,
815                &update_path,
816                &settings,
817                is_fresh,
818                open_file_guard,
819            )
820            .await
821        };
822        join_set.spawn(do_link());
823    }
824    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
825    // one thing we CAN do however is to drop it as soon as we're done with it
826    drop(src_entries);
827    // only process update if the path was provided and the directory is present
828    if update_metadata_opt.is_some() {
829        let update = update.as_ref().unwrap();
830        tracing::debug!("process contents of 'update' directory");
831        let mut update_entries = tokio::fs::read_dir(update)
832            .await
833            .with_context(|| format!("cannot open directory {:?} for reading", &update))
834            .map_err(|err| Error::new(err, link_summary))?;
835        // Iterate through update entries and for each one that's not present in src call "copy".
836        //
837        // We deliberately do NOT pre-acquire any permit here. Two cycles rule out the
838        // straightforward options:
839        //   * `open_file_permit`: copy::copy → copy_internal re-acquires open-files for
840        //     each file; a saturated pool would deadlock the inner acquire if we held one
841        //     across the call.
842        //   * `pending_meta_permit`: with --overwrite, copy::copy → copy_file → rm::rm
843        //     drains pending_meta for child entries (rm.rs spawn loop). N tasks here each
844        //     holding a pending_meta permit would deadlock waiting on each other's inner rm.
845        //
846        // The spawn count at this site is naturally bounded by the number of update-only
847        // entries (user input — typically modest) and per-task tokio overhead is small.
848        // Each spawned task's actual work is throttled by copy::copy's own internal
849        // open-files backpressure inside copy_internal's spawn loop.
850        loop {
851            let Some((update_entry, entry_file_type)) = crate::walk::next_entry_probed(
852                &mut update_entries,
853                congestion::Side::Source,
854                || format!("failed traversing directory {:?}", &update),
855            )
856            .await
857            .map_err(|err| Error::new(err, link_summary))?
858            else {
859                break;
860            };
861            let entry_path = update_entry.path();
862            let entry_name = entry_path.file_name().unwrap();
863            // keep-set: every filter-passing update entry is materialized at the destination
864            // (entries also in `src` are linked, update-only entries are copied). Computed
865            // before the dedup `continue` so entries also present in `src` are covered — this
866            // is what makes --update-exclusive mirror the update set exactly.
867            if settings.copy_settings.delete.is_some() {
868                let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
869                let relative_path = walk::relative_to_root(src, source_root).join(entry_name);
870                let filtered_out = walk::should_skip_entry(
871                    &settings.filter,
872                    &relative_path,
873                    entry_kind == EntryKind::Dir,
874                )
875                .is_some();
876                if filtered_out {
877                    // The update entry at this name is filtered out, so nothing materializes
878                    // from the update side. `drop_src_when_update_filtered` undoes a src-side
879                    // registration ONLY when the source loop actually materialized something —
880                    // a `--skip-specials` source special stays registered (its dst counterpart
881                    // must be retained per --skip-specials semantics).
882                    keep_set.drop_src_when_update_filtered(
883                        entry_name,
884                        processed_files.contains(entry_name),
885                    );
886                } else {
887                    keep_set.record_update(entry_name);
888                }
889            }
890            if processed_files.contains(entry_name) {
891                // we already must have considered this file, skip it
892                continue;
893            }
894            tracing::debug!("found a new entry in the 'update' directory");
895            let dst_path = dst.join(entry_name);
896            let update_path = update.join(entry_name);
897            // filter-base for the delegated copy: this update entry's path relative to the
898            // source root, so any --delete pruning inside it matches the include/exclude filter
899            // at the entry's true relative path (e.g. cache/*.log), not relative to the entry.
900            let filter_base = walk::relative_to_root(src, source_root).join(entry_name);
901            let settings = settings.clone();
902            let do_copy = || async move {
903                let copy_summary = copy::copy_with_filter_base(
904                    prog_track,
905                    &update_path,
906                    &dst_path,
907                    &settings.copy_settings,
908                    &settings.preserve,
909                    is_fresh,
910                    &filter_base,
911                )
912                .await
913                .map_err(|err| {
914                    link_summary.copy_summary = link_summary.copy_summary + err.summary;
915                    Error::new(err.source, link_summary)
916                })?;
917                Ok(Summary {
918                    copy_summary,
919                    ..Default::default()
920                })
921            };
922            join_set.spawn(do_copy());
923        }
924        // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
925        // one thing we CAN do however is to drop it as soon as we're done with it
926        drop(update_entries);
927    }
928    while let Some(res) = join_set.join_next().await {
929        match res {
930            Ok(result) => match result {
931                Ok(summary) => link_summary = link_summary + summary,
932                Err(error) => {
933                    tracing::error!(
934                        "link: {:?} {:?} -> {:?} failed with: {:#}",
935                        src,
936                        update,
937                        dst,
938                        &error
939                    );
940                    link_summary = link_summary + error.summary;
941                    if settings.copy_settings.fail_early {
942                        return Err(Error::new(error.source, link_summary));
943                    }
944                    errors.push(error.source);
945                }
946            },
947            Err(error) => {
948                if settings.copy_settings.fail_early {
949                    return Err(Error::new(error.into(), link_summary));
950                }
951                errors.push(error.into());
952            }
953        }
954    }
955    // rsync-style --delete for rlink: remove destination entries the link operation did not
956    // materialize. `keep_set` holds exactly the materialized names: src ∪ update normally, or
957    // just the update set under --update-exclusive (where source-only entries are not
958    // materialized and so are pruned, matching `rsync --link-dest --delete`).
959    if let Some(delete_settings) = &settings.copy_settings.delete {
960        if errors.has_errors() {
961            // rsync-style safety: skip pruning when this subtree's link/update pass reported
962            // errors — deleting based on a run that did not fully succeed could remove data
963            // unexpectedly. (rsync likewise skips --delete on I/O errors.)
964            tracing::warn!(
965                "skipping --delete pruning of {:?} because the link/update pass reported errors",
966                dst
967            );
968        } else {
969            let relative_dir = walk::relative_to_root(src, source_root);
970            match crate::delete::prune_extraneous(
971                prog_track,
972                dst,
973                relative_dir,
974                keep_set
975                    .as_set()
976                    .expect("--delete is on, so DeleteKeepSet is active"),
977                settings.filter.as_ref(),
978                delete_settings,
979                settings.copy_settings.fail_early,
980                settings.dry_run,
981            )
982            .await
983            {
984                Ok(rm_summary) => {
985                    link_summary.copy_summary.rm_summary =
986                        link_summary.copy_summary.rm_summary + rm_summary;
987                }
988                Err(err) => {
989                    link_summary.copy_summary.rm_summary =
990                        link_summary.copy_summary.rm_summary + err.summary;
991                    if settings.copy_settings.fail_early {
992                        return Err(Error::new(err.source, link_summary));
993                    }
994                    errors.push(err.source);
995                }
996            }
997        }
998    }
999    // when filtering is active and we created this directory, check if anything was actually
1000    // linked/copied into it. if nothing was linked, we may need to clean up the empty directory.
1001    let this_dir_count = usize::from(we_created_this_dir);
1002    let child_dirs_created = link_summary
1003        .copy_summary
1004        .directories_created
1005        .saturating_sub(this_dir_count);
1006    let anything_linked = link_summary.hard_links_created > 0
1007        || link_summary.copy_summary.files_copied > 0
1008        || link_summary.copy_summary.symlinks_created > 0
1009        || child_dirs_created > 0;
1010    let relative_path = walk::relative_to_root(src, source_root);
1011    let is_root = src == source_root;
1012    match check_empty_dir_cleanup(
1013        settings.filter.as_ref(),
1014        we_created_this_dir,
1015        anything_linked,
1016        relative_path,
1017        is_root,
1018        settings.dry_run.is_some(),
1019    ) {
1020        EmptyDirAction::Keep => { /* proceed with metadata application */ }
1021        EmptyDirAction::DryRunSkip => {
1022            tracing::debug!(
1023                "dry-run: directory {:?} would not be created (nothing to link inside)",
1024                &dst
1025            );
1026            link_summary.copy_summary.directories_created = 0;
1027            return Ok(link_summary);
1028        }
1029        EmptyDirAction::Remove => {
1030            tracing::debug!(
1031                "directory {:?} has nothing to link inside, removing empty directory",
1032                &dst
1033            );
1034            match crate::walk::run_metadata_probed(
1035                congestion::Side::Destination,
1036                congestion::MetadataOp::RmDir,
1037                tokio::fs::remove_dir(dst),
1038            )
1039            .await
1040            {
1041                Ok(()) => {
1042                    link_summary.copy_summary.directories_created = 0;
1043                    return Ok(link_summary);
1044                }
1045                Err(err) => {
1046                    // removal failed (not empty, permission error, etc.) — keep directory
1047                    tracing::debug!(
1048                        "failed to remove empty directory {:?}: {:#}, keeping",
1049                        &dst,
1050                        &err
1051                    );
1052                    // fall through to apply metadata
1053                }
1054            }
1055        }
1056    }
1057    // apply directory metadata regardless of whether all children linked successfully.
1058    // the directory itself was created earlier in this function (we would have returned
1059    // early if create_dir failed), so we should preserve the source metadata.
1060    // skip metadata setting in dry-run mode since directory wasn't actually created
1061    tracing::debug!("set 'dst' directory metadata");
1062    let metadata_result = if settings.dry_run.is_some() {
1063        Ok(()) // skip metadata setting in dry-run mode
1064    } else {
1065        let preserve_metadata = if let Some(update_metadata) = update_metadata_opt.as_ref() {
1066            update_metadata
1067        } else {
1068            &src_metadata
1069        };
1070        preserve::set_dir_metadata(&settings.preserve, preserve_metadata, dst).await
1071    };
1072    if errors.has_errors() {
1073        // child failures take precedence - log metadata error if it also failed
1074        if let Err(metadata_err) = metadata_result {
1075            tracing::error!(
1076                "link: {:?} {:?} -> {:?} failed to set directory metadata: {:#}",
1077                src,
1078                update,
1079                dst,
1080                &metadata_err
1081            );
1082        }
1083        // unwrap is safe: has_errors() guarantees into_error() returns Some
1084        return Err(Error::new(errors.into_error().unwrap(), link_summary));
1085    }
1086    // no child failures, so metadata error is the primary error
1087    metadata_result.map_err(|err| Error::new(err, link_summary))?;
1088    Ok(link_summary)
1089}
1090
1091#[cfg(test)]
1092mod link_tests {
1093    use crate::testutils;
1094    use std::os::unix::fs::PermissionsExt;
1095    use tracing_test::traced_test;
1096
1097    use super::*;
1098
1099    static PROGRESS: std::sync::LazyLock<progress::Progress> =
1100        std::sync::LazyLock::new(progress::Progress::new);
1101
1102    mod delete_keep_set_tests {
1103        //! Pure-logic unit tests for `DeleteKeepSet`. No filesystem needed — these pin the
1104        //! src-vs-update materialization rules so a future refactor can't silently break them.
1105
1106        use super::super::DeleteKeepSet;
1107        use crate::copy::DeleteSettings;
1108        use std::ffi::{OsStr, OsString};
1109
1110        fn delete_on() -> DeleteSettings {
1111            DeleteSettings {
1112                delete_excluded: false,
1113            }
1114        }
1115
1116        #[test]
1117        fn record_src_no_op_when_delete_off() {
1118            let mut k = DeleteKeepSet::new(None, false, false);
1119            k.record_src(OsStr::new("foo"));
1120            assert!(k.as_set().is_none());
1121        }
1122
1123        #[test]
1124        fn record_src_no_op_under_update_exclusive_with_update() {
1125            // `--update-exclusive` with an active update tree means the materialized set is
1126            // the update set; source-only entries must NOT be retained.
1127            let d = delete_on();
1128            let mut k = DeleteKeepSet::new(Some(&d), true, true);
1129            k.record_src(OsStr::new("src_only"));
1130            assert!(!k.as_set().unwrap().contains(OsStr::new("src_only")));
1131        }
1132
1133        #[test]
1134        fn record_src_records_when_update_exclusive_without_update() {
1135            // `--update-exclusive` is a no-op (carve-out doesn't apply) when no `--update`
1136            // path is given.
1137            let d = delete_on();
1138            let mut k = DeleteKeepSet::new(Some(&d), true, false);
1139            k.record_src(OsStr::new("foo"));
1140            assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1141        }
1142
1143        #[test]
1144        fn record_src_records_in_normal_delete_mode() {
1145            let d = delete_on();
1146            let mut k = DeleteKeepSet::new(Some(&d), false, false);
1147            k.record_src(OsStr::new("foo"));
1148            assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1149        }
1150
1151        #[test]
1152        fn record_update_always_records_when_delete_on() {
1153            // The update loop registers ALL filter-passing update entries, irrespective of
1154            // `--update-exclusive` — the update set IS the materialized set in that mode.
1155            let d = delete_on();
1156            let mut k = DeleteKeepSet::new(Some(&d), true, true);
1157            k.record_update(OsStr::new("from_update"));
1158            assert!(k.as_set().unwrap().contains(OsStr::new("from_update")));
1159        }
1160
1161        #[test]
1162        fn record_update_no_op_when_delete_off() {
1163            let mut k = DeleteKeepSet::new(None, false, false);
1164            k.record_update(OsStr::new("from_update"));
1165            assert!(k.as_set().is_none());
1166        }
1167
1168        #[test]
1169        fn drop_src_when_update_filtered_drops_materialized_src_entry() {
1170            // The type-change case: src had a regular file at `node`, update has an excluded
1171            // dir at `node/`. Source materialized — drop the keep-set entry so the stale dst
1172            // is pruned.
1173            let d = delete_on();
1174            let mut k = DeleteKeepSet::new(Some(&d), false, true);
1175            k.record_src(OsStr::new("node"));
1176            assert!(k.as_set().unwrap().contains(OsStr::new("node")));
1177            k.drop_src_when_update_filtered(OsStr::new("node"), /* src_materialized */ true);
1178            assert!(!k.as_set().unwrap().contains(OsStr::new("node")));
1179        }
1180
1181        #[test]
1182        fn drop_src_when_update_filtered_keeps_skipped_special() {
1183            // The skip-special case: source loop ran `record_src` but never reached
1184            // `processed_files.insert` (it `continue`d on the skip-special branch). The dst
1185            // counterpart must be retained per --skip-specials semantics.
1186            let d = delete_on();
1187            let mut k = DeleteKeepSet::new(Some(&d), false, true);
1188            k.record_src(OsStr::new("pipe"));
1189            k.drop_src_when_update_filtered(OsStr::new("pipe"), /* src_materialized */ false);
1190            assert!(k.as_set().unwrap().contains(OsStr::new("pipe")));
1191        }
1192
1193        #[test]
1194        fn drop_src_when_update_filtered_no_op_when_delete_off() {
1195            let mut k = DeleteKeepSet::new(None, false, false);
1196            // Should not panic, and as_set stays None.
1197            k.drop_src_when_update_filtered(OsStr::new("foo"), true);
1198            assert!(k.as_set().is_none());
1199        }
1200
1201        #[test]
1202        fn full_directory_pass_matches_old_keep_set_semantics() {
1203            // Models the union of src + update under plain `--delete --update` (no
1204            // --update-exclusive). Names: src has `keep`, `pipe` (special, skipped),
1205            // `node` (file). update has `from_upd`, `node` (excluded dir).
1206            let d = delete_on();
1207            let mut k = DeleteKeepSet::new(Some(&d), false, true);
1208
1209            // source loop
1210            k.record_src(OsStr::new("keep"));
1211            k.record_src(OsStr::new("pipe")); // --skip-specials: continues, processed_files NOT populated
1212            k.record_src(OsStr::new("node"));
1213
1214            // update loop
1215            k.record_update(OsStr::new("from_upd"));
1216            // `node` filtered out in update; processed_files HAS `node` (source materialized it).
1217            k.drop_src_when_update_filtered(OsStr::new("node"), true);
1218
1219            let set: std::collections::HashSet<OsString> = k.as_set().unwrap().clone();
1220            let expected: std::collections::HashSet<OsString> = ["keep", "pipe", "from_upd"]
1221                .into_iter()
1222                .map(OsString::from)
1223                .collect();
1224            assert_eq!(set, expected);
1225        }
1226    }
1227
1228    fn common_settings(dereference: bool, overwrite: bool) -> Settings {
1229        Settings {
1230            copy_settings: CopySettings {
1231                dereference,
1232                fail_early: false,
1233                overwrite,
1234                overwrite_compare: filecmp::MetadataCmpSettings {
1235                    size: true,
1236                    mtime: true,
1237                    ..Default::default()
1238                },
1239                overwrite_filter: None,
1240                ignore_existing: false,
1241                chunk_size: 0,
1242                skip_specials: false,
1243                remote_copy_buffer_size: 0,
1244                filter: None,
1245                dry_run: None,
1246                delete: None,
1247            },
1248            update_compare: filecmp::MetadataCmpSettings {
1249                size: true,
1250                mtime: true,
1251                ..Default::default()
1252            },
1253            update_exclusive: false,
1254            filter: None,
1255            dry_run: None,
1256            preserve: preserve::preserve_all(),
1257        }
1258    }
1259
1260    #[tokio::test]
1261    #[traced_test]
1262    async fn test_basic_link() -> Result<(), anyhow::Error> {
1263        let tmp_dir = testutils::setup_test_dir().await?;
1264        let test_path = tmp_dir.as_path();
1265        let summary = link(
1266            &PROGRESS,
1267            test_path,
1268            &test_path.join("foo"),
1269            &test_path.join("bar"),
1270            &None,
1271            &common_settings(false, false),
1272            false,
1273        )
1274        .await?;
1275        assert_eq!(summary.hard_links_created, 5);
1276        assert_eq!(summary.copy_summary.files_copied, 0);
1277        assert_eq!(summary.copy_summary.symlinks_created, 2);
1278        assert_eq!(summary.copy_summary.directories_created, 3);
1279        testutils::check_dirs_identical(
1280            &test_path.join("foo"),
1281            &test_path.join("bar"),
1282            testutils::FileEqualityCheck::Timestamp,
1283        )
1284        .await?;
1285        Ok(())
1286    }
1287
1288    #[tokio::test]
1289    #[traced_test]
1290    async fn test_basic_link_update() -> Result<(), anyhow::Error> {
1291        let tmp_dir = testutils::setup_test_dir().await?;
1292        let test_path = tmp_dir.as_path();
1293        let summary = link(
1294            &PROGRESS,
1295            test_path,
1296            &test_path.join("foo"),
1297            &test_path.join("bar"),
1298            &Some(test_path.join("foo")),
1299            &common_settings(false, false),
1300            false,
1301        )
1302        .await?;
1303        assert_eq!(summary.hard_links_created, 5);
1304        assert_eq!(summary.copy_summary.files_copied, 0);
1305        assert_eq!(summary.copy_summary.symlinks_created, 2);
1306        assert_eq!(summary.copy_summary.directories_created, 3);
1307        testutils::check_dirs_identical(
1308            &test_path.join("foo"),
1309            &test_path.join("bar"),
1310            testutils::FileEqualityCheck::Timestamp,
1311        )
1312        .await?;
1313        Ok(())
1314    }
1315
1316    #[tokio::test]
1317    #[traced_test]
1318    async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
1319        let tmp_dir = testutils::setup_test_dir().await?;
1320        tokio::fs::create_dir(tmp_dir.join("baz")).await?;
1321        let test_path = tmp_dir.as_path();
1322        let summary = link(
1323            &PROGRESS,
1324            test_path,
1325            &test_path.join("baz"), // empty source
1326            &test_path.join("bar"),
1327            &Some(test_path.join("foo")),
1328            &common_settings(false, false),
1329            false,
1330        )
1331        .await?;
1332        assert_eq!(summary.hard_links_created, 0);
1333        assert_eq!(summary.copy_summary.files_copied, 5);
1334        assert_eq!(summary.copy_summary.symlinks_created, 2);
1335        assert_eq!(summary.copy_summary.directories_created, 3);
1336        testutils::check_dirs_identical(
1337            &test_path.join("foo"),
1338            &test_path.join("bar"),
1339            testutils::FileEqualityCheck::Timestamp,
1340        )
1341        .await?;
1342        Ok(())
1343    }
1344
1345    #[tokio::test]
1346    #[traced_test]
1347    async fn test_link_destination_permission_error_includes_root_cause()
1348    -> Result<(), anyhow::Error> {
1349        let tmp_dir = testutils::setup_test_dir().await?;
1350        let test_path = tmp_dir.as_path();
1351        let readonly_parent = test_path.join("readonly_dest");
1352        tokio::fs::create_dir(&readonly_parent).await?;
1353        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
1354            .await?;
1355
1356        let mut settings = common_settings(false, false);
1357        settings.copy_settings.fail_early = true;
1358
1359        let result = link(
1360            &PROGRESS,
1361            test_path,
1362            &test_path.join("foo"),
1363            &readonly_parent.join("bar"),
1364            &None,
1365            &settings,
1366            false,
1367        )
1368        .await;
1369
1370        // restore permissions to allow temporary directory cleanup
1371        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
1372            .await?;
1373
1374        assert!(result.is_err(), "link into read-only parent should fail");
1375        let err = result.unwrap_err();
1376        let err_msg = format!("{:#}", err.source);
1377        assert!(
1378            err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
1379            "Error message must include permission denied text. Got: {}",
1380            err_msg
1381        );
1382        Ok(())
1383    }
1384
1385    #[tokio::test]
1386    #[traced_test]
1387    async fn hard_link_file_into_readonly_parent_returns_error() -> Result<(), anyhow::Error> {
1388        // regression: hard_link_helper used to silently ignore non-AlreadyExists errors
1389        // and report hard_links_created=1 when the underlying hard_link call had failed
1390        let tmp_dir = testutils::setup_test_dir().await?;
1391        let src = tmp_dir.join("src.txt");
1392        tokio::fs::write(&src, "content").await?;
1393        let readonly_parent = tmp_dir.join("readonly_parent");
1394        tokio::fs::create_dir(&readonly_parent).await?;
1395        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
1396            .await?;
1397        let dst = readonly_parent.join("dst.txt");
1398        let settings = common_settings(false, false);
1399        let result = link(&PROGRESS, &tmp_dir, &src, &dst, &None, &settings, false).await;
1400        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
1401            .await?;
1402        let err = result.expect_err("link into read-only parent should fail");
1403        assert_eq!(err.summary.hard_links_created, 0);
1404        let err_msg = format!("{:#}", err.source);
1405        assert!(
1406            err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
1407            "error should include root cause, got: {err_msg}"
1408        );
1409        Ok(())
1410    }
1411
1412    pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
1413        // update
1414        // |- 0.txt
1415        // |- bar
1416        //    |- 1.txt
1417        //    |- 2.txt -> ../0.txt
1418        let foo_path = tmp_dir.join("update");
1419        tokio::fs::create_dir(&foo_path).await.unwrap();
1420        tokio::fs::write(foo_path.join("0.txt"), "0-new")
1421            .await
1422            .unwrap();
1423        let bar_path = foo_path.join("bar");
1424        tokio::fs::create_dir(&bar_path).await.unwrap();
1425        tokio::fs::write(bar_path.join("1.txt"), "1-new")
1426            .await
1427            .unwrap();
1428        tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
1429            .await
1430            .unwrap();
1431        tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1432        Ok(())
1433    }
1434
1435    #[tokio::test]
1436    #[traced_test]
1437    async fn test_link_update() -> Result<(), anyhow::Error> {
1438        let tmp_dir = testutils::setup_test_dir().await?;
1439        setup_update_dir(&tmp_dir).await?;
1440        let test_path = tmp_dir.as_path();
1441        let summary = link(
1442            &PROGRESS,
1443            test_path,
1444            &test_path.join("foo"),
1445            &test_path.join("bar"),
1446            &Some(test_path.join("update")),
1447            &common_settings(false, false),
1448            false,
1449        )
1450        .await?;
1451        assert_eq!(summary.hard_links_created, 2);
1452        assert_eq!(summary.copy_summary.files_copied, 2);
1453        assert_eq!(summary.copy_summary.symlinks_created, 3);
1454        assert_eq!(summary.copy_summary.directories_created, 3);
1455        // compare subset of src and dst
1456        testutils::check_dirs_identical(
1457            &test_path.join("foo").join("baz"),
1458            &test_path.join("bar").join("baz"),
1459            testutils::FileEqualityCheck::HardLink,
1460        )
1461        .await?;
1462        // compare update and dst
1463        testutils::check_dirs_identical(
1464            &test_path.join("update"),
1465            &test_path.join("bar"),
1466            testutils::FileEqualityCheck::Timestamp,
1467        )
1468        .await?;
1469        Ok(())
1470    }
1471
1472    #[tokio::test]
1473    #[traced_test]
1474    async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
1475        let tmp_dir = testutils::setup_test_dir().await?;
1476        setup_update_dir(&tmp_dir).await?;
1477        let test_path = tmp_dir.as_path();
1478        let mut settings = common_settings(false, false);
1479        settings.update_exclusive = true;
1480        let summary = link(
1481            &PROGRESS,
1482            test_path,
1483            &test_path.join("foo"),
1484            &test_path.join("bar"),
1485            &Some(test_path.join("update")),
1486            &settings,
1487            false,
1488        )
1489        .await?;
1490        // we should end up with same directory as the update
1491        // |- 0.txt
1492        // |- bar
1493        //    |- 1.txt
1494        //    |- 2.txt -> ../0.txt
1495        assert_eq!(summary.hard_links_created, 0);
1496        assert_eq!(summary.copy_summary.files_copied, 2);
1497        assert_eq!(summary.copy_summary.symlinks_created, 1);
1498        assert_eq!(summary.copy_summary.directories_created, 2);
1499        // compare update and dst
1500        testutils::check_dirs_identical(
1501            &test_path.join("update"),
1502            &test_path.join("bar"),
1503            testutils::FileEqualityCheck::Timestamp,
1504        )
1505        .await?;
1506        Ok(())
1507    }
1508
1509    async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
1510        let tmp_dir = testutils::setup_test_dir().await?;
1511        let test_path = tmp_dir.as_path();
1512        let summary = link(
1513            &PROGRESS,
1514            test_path,
1515            &test_path.join("foo"),
1516            &test_path.join("bar"),
1517            &None,
1518            &common_settings(false, false),
1519            false,
1520        )
1521        .await?;
1522        assert_eq!(summary.hard_links_created, 5);
1523        assert_eq!(summary.copy_summary.symlinks_created, 2);
1524        assert_eq!(summary.copy_summary.directories_created, 3);
1525        Ok(tmp_dir)
1526    }
1527
1528    #[tokio::test]
1529    #[traced_test]
1530    async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
1531        let tmp_dir = setup_test_dir_and_link().await?;
1532        let output_path = &tmp_dir.join("bar");
1533        {
1534            // bar
1535            // |- 0.txt
1536            // |- bar  <---------------------------------------- REMOVE
1537            //    |- 1.txt  <----------------------------------- REMOVE
1538            //    |- 2.txt  <----------------------------------- REMOVE
1539            //    |- 3.txt  <----------------------------------- REMOVE
1540            // |- baz
1541            //    |- 4.txt
1542            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
1543            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1544            let summary = rm::rm(
1545                &PROGRESS,
1546                &output_path.join("bar"),
1547                &rm::Settings {
1548                    fail_early: false,
1549                    filter: None,
1550                    dry_run: None,
1551                    time_filter: None,
1552                },
1553            )
1554            .await?
1555                + rm::rm(
1556                    &PROGRESS,
1557                    &output_path.join("baz").join("5.txt"),
1558                    &rm::Settings {
1559                        fail_early: false,
1560                        filter: None,
1561                        dry_run: None,
1562                        time_filter: None,
1563                    },
1564                )
1565                .await?;
1566            assert_eq!(summary.files_removed, 3);
1567            assert_eq!(summary.symlinks_removed, 1);
1568            assert_eq!(summary.directories_removed, 1);
1569        }
1570        let summary = link(
1571            &PROGRESS,
1572            &tmp_dir,
1573            &tmp_dir.join("foo"),
1574            output_path,
1575            &None,
1576            &common_settings(false, true), // overwrite!
1577            false,
1578        )
1579        .await?;
1580        assert_eq!(summary.hard_links_created, 3);
1581        assert_eq!(summary.copy_summary.symlinks_created, 1);
1582        assert_eq!(summary.copy_summary.directories_created, 1);
1583        testutils::check_dirs_identical(
1584            &tmp_dir.join("foo"),
1585            output_path,
1586            testutils::FileEqualityCheck::Timestamp,
1587        )
1588        .await?;
1589        Ok(())
1590    }
1591
1592    #[tokio::test]
1593    #[traced_test]
1594    async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
1595        let tmp_dir = setup_test_dir_and_link().await?;
1596        let output_path = &tmp_dir.join("bar");
1597        {
1598            // bar
1599            // |- 0.txt
1600            // |- bar  <---------------------------------------- REMOVE
1601            //    |- 1.txt  <----------------------------------- REMOVE
1602            //    |- 2.txt  <----------------------------------- REMOVE
1603            //    |- 3.txt  <----------------------------------- REMOVE
1604            // |- baz
1605            //    |- 4.txt
1606            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
1607            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1608            let summary = rm::rm(
1609                &PROGRESS,
1610                &output_path.join("bar"),
1611                &rm::Settings {
1612                    fail_early: false,
1613                    filter: None,
1614                    dry_run: None,
1615                    time_filter: None,
1616                },
1617            )
1618            .await?
1619                + rm::rm(
1620                    &PROGRESS,
1621                    &output_path.join("baz").join("5.txt"),
1622                    &rm::Settings {
1623                        fail_early: false,
1624                        filter: None,
1625                        dry_run: None,
1626                        time_filter: None,
1627                    },
1628                )
1629                .await?;
1630            assert_eq!(summary.files_removed, 3);
1631            assert_eq!(summary.symlinks_removed, 1);
1632            assert_eq!(summary.directories_removed, 1);
1633        }
1634        setup_update_dir(&tmp_dir).await?;
1635        // update
1636        // |- 0.txt
1637        // |- bar
1638        //    |- 1.txt
1639        //    |- 2.txt -> ../0.txt
1640        let summary = link(
1641            &PROGRESS,
1642            &tmp_dir,
1643            &tmp_dir.join("foo"),
1644            output_path,
1645            &Some(tmp_dir.join("update")),
1646            &common_settings(false, true), // overwrite!
1647            false,
1648        )
1649        .await?;
1650        assert_eq!(summary.hard_links_created, 1); // 3.txt
1651        assert_eq!(summary.copy_summary.files_copied, 2); // 0.txt, 1.txt
1652        assert_eq!(summary.copy_summary.symlinks_created, 2); // 2.txt, 5.txt
1653        assert_eq!(summary.copy_summary.directories_created, 1);
1654        // compare subset of src and dst
1655        testutils::check_dirs_identical(
1656            &tmp_dir.join("foo").join("baz"),
1657            &tmp_dir.join("bar").join("baz"),
1658            testutils::FileEqualityCheck::HardLink,
1659        )
1660        .await?;
1661        // compare update and dst
1662        testutils::check_dirs_identical(
1663            &tmp_dir.join("update"),
1664            &tmp_dir.join("bar"),
1665            testutils::FileEqualityCheck::Timestamp,
1666        )
1667        .await?;
1668        Ok(())
1669    }
1670
1671    #[tokio::test]
1672    #[traced_test]
1673    async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
1674        let tmp_dir = setup_test_dir_and_link().await?;
1675        let output_path = &tmp_dir.join("bar");
1676        {
1677            // bar
1678            // |- 0.txt
1679            // |- bar
1680            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
1681            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
1682            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
1683            // |- baz    <-------------------------------------- REPLACE W/ FILE
1684            //    |- ...
1685            let bar_path = output_path.join("bar");
1686            let summary = rm::rm(
1687                &PROGRESS,
1688                &bar_path.join("1.txt"),
1689                &rm::Settings {
1690                    fail_early: false,
1691                    filter: None,
1692                    dry_run: None,
1693                    time_filter: None,
1694                },
1695            )
1696            .await?
1697                + rm::rm(
1698                    &PROGRESS,
1699                    &bar_path.join("2.txt"),
1700                    &rm::Settings {
1701                        fail_early: false,
1702                        filter: None,
1703                        dry_run: None,
1704                        time_filter: None,
1705                    },
1706                )
1707                .await?
1708                + rm::rm(
1709                    &PROGRESS,
1710                    &bar_path.join("3.txt"),
1711                    &rm::Settings {
1712                        fail_early: false,
1713                        filter: None,
1714                        dry_run: None,
1715                        time_filter: None,
1716                    },
1717                )
1718                .await?
1719                + rm::rm(
1720                    &PROGRESS,
1721                    &output_path.join("baz"),
1722                    &rm::Settings {
1723                        fail_early: false,
1724                        filter: None,
1725                        dry_run: None,
1726                        time_filter: None,
1727                    },
1728                )
1729                .await?;
1730            assert_eq!(summary.files_removed, 4);
1731            assert_eq!(summary.symlinks_removed, 2);
1732            assert_eq!(summary.directories_removed, 1);
1733            // REPLACE with a file, a symlink, a directory and a file
1734            tokio::fs::write(bar_path.join("1.txt"), "1-new")
1735                .await
1736                .unwrap();
1737            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1738                .await
1739                .unwrap();
1740            tokio::fs::create_dir(&bar_path.join("3.txt"))
1741                .await
1742                .unwrap();
1743            tokio::fs::write(&output_path.join("baz"), "baz")
1744                .await
1745                .unwrap();
1746        }
1747        let summary = link(
1748            &PROGRESS,
1749            &tmp_dir,
1750            &tmp_dir.join("foo"),
1751            output_path,
1752            &None,
1753            &common_settings(false, true), // overwrite!
1754            false,
1755        )
1756        .await?;
1757        assert_eq!(summary.hard_links_created, 4);
1758        assert_eq!(summary.copy_summary.files_copied, 0);
1759        assert_eq!(summary.copy_summary.symlinks_created, 2);
1760        assert_eq!(summary.copy_summary.directories_created, 1);
1761        testutils::check_dirs_identical(
1762            &tmp_dir.join("foo"),
1763            &tmp_dir.join("bar"),
1764            testutils::FileEqualityCheck::HardLink,
1765        )
1766        .await?;
1767        Ok(())
1768    }
1769
1770    #[tokio::test]
1771    #[traced_test]
1772    async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
1773        let tmp_dir = setup_test_dir_and_link().await?;
1774        let output_path = &tmp_dir.join("bar");
1775        {
1776            // bar
1777            // |- 0.txt
1778            // |- bar
1779            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
1780            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
1781            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
1782            // |- baz    <-------------------------------------- REPLACE W/ FILE
1783            //    |- ...
1784            let bar_path = output_path.join("bar");
1785            let summary = rm::rm(
1786                &PROGRESS,
1787                &bar_path.join("1.txt"),
1788                &rm::Settings {
1789                    fail_early: false,
1790                    filter: None,
1791                    dry_run: None,
1792                    time_filter: None,
1793                },
1794            )
1795            .await?
1796                + rm::rm(
1797                    &PROGRESS,
1798                    &bar_path.join("2.txt"),
1799                    &rm::Settings {
1800                        fail_early: false,
1801                        filter: None,
1802                        dry_run: None,
1803                        time_filter: None,
1804                    },
1805                )
1806                .await?
1807                + rm::rm(
1808                    &PROGRESS,
1809                    &bar_path.join("3.txt"),
1810                    &rm::Settings {
1811                        fail_early: false,
1812                        filter: None,
1813                        dry_run: None,
1814                        time_filter: None,
1815                    },
1816                )
1817                .await?
1818                + rm::rm(
1819                    &PROGRESS,
1820                    &output_path.join("baz"),
1821                    &rm::Settings {
1822                        fail_early: false,
1823                        filter: None,
1824                        dry_run: None,
1825                        time_filter: None,
1826                    },
1827                )
1828                .await?;
1829            assert_eq!(summary.files_removed, 4);
1830            assert_eq!(summary.symlinks_removed, 2);
1831            assert_eq!(summary.directories_removed, 1);
1832            // REPLACE with a file, a symlink, a directory and a file
1833            tokio::fs::write(bar_path.join("1.txt"), "1-new")
1834                .await
1835                .unwrap();
1836            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1837                .await
1838                .unwrap();
1839            tokio::fs::create_dir(&bar_path.join("3.txt"))
1840                .await
1841                .unwrap();
1842            tokio::fs::write(&output_path.join("baz"), "baz")
1843                .await
1844                .unwrap();
1845        }
1846        let source_path = &tmp_dir.join("foo");
1847        // unreadable
1848        tokio::fs::set_permissions(
1849            &source_path.join("baz"),
1850            std::fs::Permissions::from_mode(0o000),
1851        )
1852        .await?;
1853        // bar
1854        // |- ...
1855        // |- baz <- NON READABLE
1856        match link(
1857            &PROGRESS,
1858            &tmp_dir,
1859            &tmp_dir.join("foo"),
1860            output_path,
1861            &None,
1862            &common_settings(false, true), // overwrite!
1863            false,
1864        )
1865        .await
1866        {
1867            Ok(_) => panic!("Expected the link to error!"),
1868            Err(error) => {
1869                tracing::info!("{}", &error);
1870                assert_eq!(error.summary.hard_links_created, 3);
1871                assert_eq!(error.summary.copy_summary.files_copied, 0);
1872                assert_eq!(error.summary.copy_summary.symlinks_created, 0);
1873                assert_eq!(error.summary.copy_summary.directories_created, 0);
1874                assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
1875                assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
1876                assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
1877            }
1878        }
1879        Ok(())
1880    }
1881
1882    /// Verify that directory metadata is applied even when child link operations fail.
1883    /// This is a regression test for a bug where directory permissions were not preserved
1884    /// when linking with fail_early=false and some children failed to link.
1885    #[tokio::test]
1886    #[traced_test]
1887    async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
1888        let tmp_dir = testutils::create_temp_dir().await?;
1889        let test_path = tmp_dir.as_path();
1890        // create source directory with specific permissions
1891        let src_dir = test_path.join("src");
1892        tokio::fs::create_dir(&src_dir).await?;
1893        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
1894        // create a readable file (will be linked successfully)
1895        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
1896        // create a subdirectory with a file, then make the subdirectory unreadable
1897        // this will cause the recursive walk to fail when trying to read subdirectory contents
1898        let unreadable_subdir = src_dir.join("unreadable_subdir");
1899        tokio::fs::create_dir(&unreadable_subdir).await?;
1900        tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
1901        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
1902            .await?;
1903        let dst_dir = test_path.join("dst");
1904        // link with fail_early=false
1905        let result = link(
1906            &PROGRESS,
1907            test_path,
1908            &src_dir,
1909            &dst_dir,
1910            &None,
1911            &common_settings(false, false),
1912            false,
1913        )
1914        .await;
1915        // restore permissions so cleanup can succeed
1916        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
1917            .await?;
1918        // verify the operation returned an error (unreadable subdirectory should fail)
1919        assert!(
1920            result.is_err(),
1921            "link should fail due to unreadable subdirectory"
1922        );
1923        let error = result.unwrap_err();
1924        // verify the readable file was linked successfully
1925        assert_eq!(error.summary.hard_links_created, 1);
1926        // verify the destination directory exists and has the correct permissions
1927        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
1928        assert!(dst_metadata.is_dir());
1929        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
1930        assert_eq!(
1931            actual_mode, 0o750,
1932            "directory should have preserved source permissions (0o750), got {:o}",
1933            actual_mode
1934        );
1935        Ok(())
1936    }
1937    mod filter_tests {
1938        use super::*;
1939        use crate::filter::FilterSettings;
1940        /// Test that path-based patterns (with /) work correctly with nested paths.
1941        #[tokio::test]
1942        #[traced_test]
1943        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
1944            let tmp_dir = testutils::setup_test_dir().await?;
1945            let test_path = tmp_dir.as_path();
1946            // create filter that should only link files in bar/ directory
1947            let mut filter = FilterSettings::new();
1948            filter.add_include("bar/*.txt").unwrap();
1949            let summary = link(
1950                &PROGRESS,
1951                test_path,
1952                &test_path.join("foo"),
1953                &test_path.join("dst"),
1954                &None,
1955                &Settings {
1956                    copy_settings: CopySettings {
1957                        dereference: false,
1958                        fail_early: false,
1959                        overwrite: false,
1960                        overwrite_compare: Default::default(),
1961                        overwrite_filter: None,
1962                        ignore_existing: false,
1963                        chunk_size: 0,
1964                        skip_specials: false,
1965                        remote_copy_buffer_size: 0,
1966                        filter: None,
1967                        dry_run: None,
1968                        delete: None,
1969                    },
1970                    update_compare: Default::default(),
1971                    update_exclusive: false,
1972                    filter: Some(filter),
1973                    dry_run: None,
1974                    preserve: preserve::preserve_all(),
1975                },
1976                false,
1977            )
1978            .await?;
1979            // should only link files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
1980            assert_eq!(
1981                summary.hard_links_created, 3,
1982                "should link 3 files matching bar/*.txt"
1983            );
1984            // verify the right files were linked
1985            assert!(
1986                test_path.join("dst/bar/1.txt").exists(),
1987                "bar/1.txt should be linked"
1988            );
1989            assert!(
1990                test_path.join("dst/bar/2.txt").exists(),
1991                "bar/2.txt should be linked"
1992            );
1993            assert!(
1994                test_path.join("dst/bar/3.txt").exists(),
1995                "bar/3.txt should be linked"
1996            );
1997            // verify files outside the pattern don't exist
1998            assert!(
1999                !test_path.join("dst/0.txt").exists(),
2000                "0.txt should not be linked"
2001            );
2002            Ok(())
2003        }
2004        /// Test that filters are applied to top-level file arguments.
2005        #[tokio::test]
2006        #[traced_test]
2007        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
2008            let tmp_dir = testutils::setup_test_dir().await?;
2009            let test_path = tmp_dir.as_path();
2010            // create filter that excludes .txt files
2011            let mut filter = FilterSettings::new();
2012            filter.add_exclude("*.txt").unwrap();
2013            let summary = link(
2014                &PROGRESS,
2015                test_path,
2016                &test_path.join("foo/0.txt"), // single file source
2017                &test_path.join("dst/0.txt"),
2018                &None,
2019                &Settings {
2020                    copy_settings: CopySettings {
2021                        dereference: false,
2022                        fail_early: false,
2023                        overwrite: false,
2024                        overwrite_compare: Default::default(),
2025                        overwrite_filter: None,
2026                        ignore_existing: false,
2027                        chunk_size: 0,
2028                        skip_specials: false,
2029                        remote_copy_buffer_size: 0,
2030                        filter: None,
2031                        dry_run: None,
2032                        delete: None,
2033                    },
2034                    update_compare: Default::default(),
2035                    update_exclusive: false,
2036                    filter: Some(filter),
2037                    dry_run: None,
2038                    preserve: preserve::preserve_all(),
2039                },
2040                false,
2041            )
2042            .await?;
2043            // the file should NOT be linked because it matches the exclude pattern
2044            assert_eq!(
2045                summary.hard_links_created, 0,
2046                "file matching exclude pattern should not be linked"
2047            );
2048            assert!(
2049                !test_path.join("dst/0.txt").exists(),
2050                "excluded file should not exist at destination"
2051            );
2052            Ok(())
2053        }
2054        /// Test that filters apply to root directories with simple exclude patterns.
2055        #[tokio::test]
2056        #[traced_test]
2057        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
2058            let test_path = testutils::create_temp_dir().await?;
2059            // create a directory that should be excluded
2060            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
2061            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
2062            // create filter that excludes *_dir/ directories
2063            let mut filter = FilterSettings::new();
2064            filter.add_exclude("*_dir/").unwrap();
2065            let result = link(
2066                &PROGRESS,
2067                &test_path,
2068                &test_path.join("excluded_dir"),
2069                &test_path.join("dst"),
2070                &None,
2071                &Settings {
2072                    copy_settings: CopySettings {
2073                        dereference: false,
2074                        fail_early: false,
2075                        overwrite: false,
2076                        overwrite_compare: Default::default(),
2077                        overwrite_filter: None,
2078                        ignore_existing: false,
2079                        chunk_size: 0,
2080                        skip_specials: false,
2081                        remote_copy_buffer_size: 0,
2082                        filter: None,
2083                        dry_run: None,
2084                        delete: None,
2085                    },
2086                    update_compare: Default::default(),
2087                    update_exclusive: false,
2088                    filter: Some(filter),
2089                    dry_run: None,
2090                    preserve: preserve::preserve_all(),
2091                },
2092                false,
2093            )
2094            .await?;
2095            // directory should NOT be linked because it matches exclude pattern
2096            assert_eq!(
2097                result.copy_summary.directories_created, 0,
2098                "root directory matching exclude should not be created"
2099            );
2100            assert!(
2101                !test_path.join("dst").exists(),
2102                "excluded root directory should not exist at destination"
2103            );
2104            Ok(())
2105        }
2106        /// Test that filters apply to root symlinks with simple exclude patterns.
2107        #[tokio::test]
2108        #[traced_test]
2109        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
2110            let test_path = testutils::create_temp_dir().await?;
2111            // create a target file and a symlink to it
2112            tokio::fs::write(test_path.join("target.txt"), "content").await?;
2113            tokio::fs::symlink(
2114                test_path.join("target.txt"),
2115                test_path.join("excluded_link"),
2116            )
2117            .await?;
2118            // create filter that excludes *_link
2119            let mut filter = FilterSettings::new();
2120            filter.add_exclude("*_link").unwrap();
2121            let result = link(
2122                &PROGRESS,
2123                &test_path,
2124                &test_path.join("excluded_link"),
2125                &test_path.join("dst"),
2126                &None,
2127                &Settings {
2128                    copy_settings: CopySettings {
2129                        dereference: false,
2130                        fail_early: false,
2131                        overwrite: false,
2132                        overwrite_compare: Default::default(),
2133                        overwrite_filter: None,
2134                        ignore_existing: false,
2135                        chunk_size: 0,
2136                        skip_specials: false,
2137                        remote_copy_buffer_size: 0,
2138                        filter: None,
2139                        dry_run: None,
2140                        delete: None,
2141                    },
2142                    update_compare: Default::default(),
2143                    update_exclusive: false,
2144                    filter: Some(filter),
2145                    dry_run: None,
2146                    preserve: preserve::preserve_all(),
2147                },
2148                false,
2149            )
2150            .await?;
2151            // symlink should NOT be copied because it matches exclude pattern
2152            assert_eq!(
2153                result.copy_summary.symlinks_created, 0,
2154                "root symlink matching exclude should not be created"
2155            );
2156            assert!(
2157                !test_path.join("dst").exists(),
2158                "excluded root symlink should not exist at destination"
2159            );
2160            Ok(())
2161        }
2162        /// Test combined include and exclude patterns (exclude takes precedence).
2163        #[tokio::test]
2164        #[traced_test]
2165        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
2166            let tmp_dir = testutils::setup_test_dir().await?;
2167            let test_path = tmp_dir.as_path();
2168            // test structure from setup_test_dir:
2169            // foo/
2170            //   0.txt
2171            //   bar/ (1.txt, 2.txt, 3.txt)
2172            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
2173            // include all .txt files in bar/, but exclude 2.txt specifically
2174            let mut filter = FilterSettings::new();
2175            filter.add_include("bar/*.txt").unwrap();
2176            filter.add_exclude("bar/2.txt").unwrap();
2177            let summary = link(
2178                &PROGRESS,
2179                test_path,
2180                &test_path.join("foo"),
2181                &test_path.join("dst"),
2182                &None,
2183                &Settings {
2184                    copy_settings: CopySettings {
2185                        dereference: false,
2186                        fail_early: false,
2187                        overwrite: false,
2188                        overwrite_compare: Default::default(),
2189                        overwrite_filter: None,
2190                        ignore_existing: false,
2191                        chunk_size: 0,
2192                        skip_specials: false,
2193                        remote_copy_buffer_size: 0,
2194                        filter: None,
2195                        dry_run: None,
2196                        delete: None,
2197                    },
2198                    update_compare: Default::default(),
2199                    update_exclusive: false,
2200                    filter: Some(filter),
2201                    dry_run: None,
2202                    preserve: preserve::preserve_all(),
2203                },
2204                false,
2205            )
2206            .await?;
2207            // should link: bar/1.txt, bar/3.txt = 2 hard links
2208            // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
2209            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
2210            assert_eq!(
2211                summary.copy_summary.files_skipped, 2,
2212                "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
2213            );
2214            // verify
2215            assert!(
2216                test_path.join("dst/bar/1.txt").exists(),
2217                "bar/1.txt should be linked"
2218            );
2219            assert!(
2220                !test_path.join("dst/bar/2.txt").exists(),
2221                "bar/2.txt should be excluded"
2222            );
2223            assert!(
2224                test_path.join("dst/bar/3.txt").exists(),
2225                "bar/3.txt should be linked"
2226            );
2227            Ok(())
2228        }
2229        /// Test that skipped counts accurately reflect what was filtered.
2230        #[tokio::test]
2231        #[traced_test]
2232        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
2233            let tmp_dir = testutils::setup_test_dir().await?;
2234            let test_path = tmp_dir.as_path();
2235            // test structure from setup_test_dir:
2236            // foo/
2237            //   0.txt
2238            //   bar/ (1.txt, 2.txt, 3.txt)
2239            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
2240            // exclude bar/ directory entirely
2241            let mut filter = FilterSettings::new();
2242            filter.add_exclude("bar/").unwrap();
2243            let summary = link(
2244                &PROGRESS,
2245                test_path,
2246                &test_path.join("foo"),
2247                &test_path.join("dst"),
2248                &None,
2249                &Settings {
2250                    copy_settings: CopySettings {
2251                        dereference: false,
2252                        fail_early: false,
2253                        overwrite: false,
2254                        overwrite_compare: Default::default(),
2255                        overwrite_filter: None,
2256                        ignore_existing: false,
2257                        chunk_size: 0,
2258                        skip_specials: false,
2259                        remote_copy_buffer_size: 0,
2260                        filter: None,
2261                        dry_run: None,
2262                        delete: None,
2263                    },
2264                    update_compare: Default::default(),
2265                    update_exclusive: false,
2266                    filter: Some(filter),
2267                    dry_run: None,
2268                    preserve: preserve::preserve_all(),
2269                },
2270                false,
2271            )
2272            .await?;
2273            // linked: 0.txt (1 hard link), baz/4.txt (1 hard link)
2274            // symlinks copied: 5.txt, 6.txt
2275            // skipped: bar directory (1 dir)
2276            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
2277            assert_eq!(
2278                summary.copy_summary.symlinks_created, 2,
2279                "should copy 2 symlinks"
2280            );
2281            assert_eq!(
2282                summary.copy_summary.directories_skipped, 1,
2283                "should skip 1 directory (bar)"
2284            );
2285            // bar should not exist in dst
2286            assert!(
2287                !test_path.join("dst/bar").exists(),
2288                "bar directory should not be linked"
2289            );
2290            Ok(())
2291        }
2292        /// Test that empty directories are not created when they were only traversed to look
2293        /// for matches (regression test for bug where --include='foo' would create empty dir baz).
2294        #[tokio::test]
2295        #[traced_test]
2296        async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
2297            let test_path = testutils::create_temp_dir().await?;
2298            // create structure:
2299            // src/
2300            //   foo (file)
2301            //   bar (file)
2302            //   baz/ (empty directory)
2303            let src_path = test_path.join("src");
2304            tokio::fs::create_dir(&src_path).await?;
2305            tokio::fs::write(src_path.join("foo"), "content").await?;
2306            tokio::fs::write(src_path.join("bar"), "content").await?;
2307            tokio::fs::create_dir(src_path.join("baz")).await?;
2308            // include only 'foo' file
2309            let mut filter = FilterSettings::new();
2310            filter.add_include("foo").unwrap();
2311            let summary = link(
2312                &PROGRESS,
2313                &test_path,
2314                &src_path,
2315                &test_path.join("dst"),
2316                &None,
2317                &Settings {
2318                    copy_settings: copy::Settings {
2319                        dereference: false,
2320                        fail_early: false,
2321                        overwrite: false,
2322                        overwrite_compare: Default::default(),
2323                        overwrite_filter: None,
2324                        ignore_existing: false,
2325                        chunk_size: 0,
2326                        skip_specials: false,
2327                        remote_copy_buffer_size: 0,
2328                        filter: None,
2329                        dry_run: None,
2330                        delete: None,
2331                    },
2332                    update_compare: Default::default(),
2333                    update_exclusive: false,
2334                    filter: Some(filter),
2335                    dry_run: None,
2336                    preserve: preserve::preserve_all(),
2337                },
2338                false,
2339            )
2340            .await?;
2341            // only 'foo' should be linked
2342            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2343            assert_eq!(
2344                summary.copy_summary.directories_created, 1,
2345                "should create only root directory (not empty 'baz')"
2346            );
2347            // verify foo was linked
2348            assert!(
2349                test_path.join("dst").join("foo").exists(),
2350                "foo should be linked"
2351            );
2352            // verify bar was not linked (not matching include pattern)
2353            assert!(
2354                !test_path.join("dst").join("bar").exists(),
2355                "bar should not be linked"
2356            );
2357            // verify empty baz directory was NOT created
2358            assert!(
2359                !test_path.join("dst").join("baz").exists(),
2360                "empty baz directory should NOT be created"
2361            );
2362            Ok(())
2363        }
2364        /// Test that directories with only non-matching content are not created at destination.
2365        /// This is different from empty directories - the source dir has content but none matches.
2366        #[tokio::test]
2367        #[traced_test]
2368        async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
2369            let test_path = testutils::create_temp_dir().await?;
2370            // create structure:
2371            // src/
2372            //   foo (file)
2373            //   baz/
2374            //     qux (file - doesn't match 'foo')
2375            //     quux (file - doesn't match 'foo')
2376            let src_path = test_path.join("src");
2377            tokio::fs::create_dir(&src_path).await?;
2378            tokio::fs::write(src_path.join("foo"), "content").await?;
2379            tokio::fs::create_dir(src_path.join("baz")).await?;
2380            tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
2381            tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
2382            // include only 'foo' file
2383            let mut filter = FilterSettings::new();
2384            filter.add_include("foo").unwrap();
2385            let summary = link(
2386                &PROGRESS,
2387                &test_path,
2388                &src_path,
2389                &test_path.join("dst"),
2390                &None,
2391                &Settings {
2392                    copy_settings: copy::Settings {
2393                        dereference: false,
2394                        fail_early: false,
2395                        overwrite: false,
2396                        overwrite_compare: Default::default(),
2397                        overwrite_filter: None,
2398                        ignore_existing: false,
2399                        chunk_size: 0,
2400                        skip_specials: false,
2401                        remote_copy_buffer_size: 0,
2402                        filter: None,
2403                        dry_run: None,
2404                        delete: None,
2405                    },
2406                    update_compare: Default::default(),
2407                    update_exclusive: false,
2408                    filter: Some(filter),
2409                    dry_run: None,
2410                    preserve: preserve::preserve_all(),
2411                },
2412                false,
2413            )
2414            .await?;
2415            // only 'foo' should be linked
2416            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2417            assert_eq!(
2418                summary.copy_summary.files_skipped, 2,
2419                "should skip 2 files (qux and quux)"
2420            );
2421            assert_eq!(
2422                summary.copy_summary.directories_created, 1,
2423                "should create only root directory (not 'baz' with non-matching content)"
2424            );
2425            // verify foo was linked
2426            assert!(
2427                test_path.join("dst").join("foo").exists(),
2428                "foo should be linked"
2429            );
2430            // verify baz directory was NOT created (even though source baz has content)
2431            assert!(
2432                !test_path.join("dst").join("baz").exists(),
2433                "baz directory should NOT be created (no matching content inside)"
2434            );
2435            Ok(())
2436        }
2437        /// Test that empty directories are not reported as created in dry-run mode
2438        /// when they were only traversed.
2439        #[tokio::test]
2440        #[traced_test]
2441        async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
2442            let test_path = testutils::create_temp_dir().await?;
2443            // create structure:
2444            // src/
2445            //   foo (file)
2446            //   bar (file)
2447            //   baz/ (empty directory)
2448            let src_path = test_path.join("src");
2449            tokio::fs::create_dir(&src_path).await?;
2450            tokio::fs::write(src_path.join("foo"), "content").await?;
2451            tokio::fs::write(src_path.join("bar"), "content").await?;
2452            tokio::fs::create_dir(src_path.join("baz")).await?;
2453            // include only 'foo' file
2454            let mut filter = FilterSettings::new();
2455            filter.add_include("foo").unwrap();
2456            let summary = link(
2457                &PROGRESS,
2458                &test_path,
2459                &src_path,
2460                &test_path.join("dst"),
2461                &None,
2462                &Settings {
2463                    copy_settings: copy::Settings {
2464                        dereference: false,
2465                        fail_early: false,
2466                        overwrite: false,
2467                        overwrite_compare: Default::default(),
2468                        overwrite_filter: None,
2469                        ignore_existing: false,
2470                        chunk_size: 0,
2471                        skip_specials: false,
2472                        remote_copy_buffer_size: 0,
2473                        filter: None,
2474                        dry_run: None,
2475                        delete: None,
2476                    },
2477                    update_compare: Default::default(),
2478                    update_exclusive: false,
2479                    filter: Some(filter),
2480                    dry_run: Some(crate::config::DryRunMode::Explain),
2481                    preserve: preserve::preserve_all(),
2482                },
2483                false,
2484            )
2485            .await?;
2486            // only 'foo' should be reported as would-be-linked
2487            assert_eq!(
2488                summary.hard_links_created, 1,
2489                "should report only 'foo' would be linked"
2490            );
2491            assert_eq!(
2492                summary.copy_summary.directories_created, 1,
2493                "should report only root directory would be created (not empty 'baz')"
2494            );
2495            // verify nothing was actually created (dry-run mode)
2496            assert!(
2497                !test_path.join("dst").exists(),
2498                "dst should not exist in dry-run"
2499            );
2500            Ok(())
2501        }
2502        /// Test that existing directories are NOT removed when using --overwrite,
2503        /// even if nothing is linked into them due to filters.
2504        #[tokio::test]
2505        #[traced_test]
2506        async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
2507            let test_path = testutils::create_temp_dir().await?;
2508            // create source structure:
2509            // src/
2510            //   foo (file)
2511            //   bar (file)
2512            //   baz/ (empty directory)
2513            let src_path = test_path.join("src");
2514            tokio::fs::create_dir(&src_path).await?;
2515            tokio::fs::write(src_path.join("foo"), "content").await?;
2516            tokio::fs::write(src_path.join("bar"), "content").await?;
2517            tokio::fs::create_dir(src_path.join("baz")).await?;
2518            // create destination with baz directory already existing
2519            let dst_path = test_path.join("dst");
2520            tokio::fs::create_dir(&dst_path).await?;
2521            tokio::fs::create_dir(dst_path.join("baz")).await?;
2522            // add a marker file inside dst/baz to verify we don't touch it
2523            tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
2524            // include only 'foo' file - baz should not match
2525            let mut filter = FilterSettings::new();
2526            filter.add_include("foo").unwrap();
2527            let summary = link(
2528                &PROGRESS,
2529                &test_path,
2530                &src_path,
2531                &dst_path,
2532                &None,
2533                &Settings {
2534                    copy_settings: copy::Settings {
2535                        dereference: false,
2536                        fail_early: false,
2537                        overwrite: true, // enable overwrite mode
2538                        overwrite_compare: Default::default(),
2539                        overwrite_filter: None,
2540                        ignore_existing: false,
2541                        chunk_size: 0,
2542                        skip_specials: false,
2543                        remote_copy_buffer_size: 0,
2544                        filter: None,
2545                        dry_run: None,
2546                        delete: None,
2547                    },
2548                    update_compare: Default::default(),
2549                    update_exclusive: false,
2550                    filter: Some(filter),
2551                    dry_run: None,
2552                    preserve: preserve::preserve_all(),
2553                },
2554                false,
2555            )
2556            .await?;
2557            // foo should be linked
2558            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2559            // dst and baz should be unchanged (both already existed)
2560            assert_eq!(
2561                summary.copy_summary.directories_unchanged, 2,
2562                "root dst and baz directories should be unchanged"
2563            );
2564            assert_eq!(
2565                summary.copy_summary.directories_created, 0,
2566                "should not create any directories"
2567            );
2568            // verify foo was linked
2569            assert!(dst_path.join("foo").exists(), "foo should be linked");
2570            // verify bar was NOT linked
2571            assert!(!dst_path.join("bar").exists(), "bar should not be linked");
2572            // verify existing baz directory still exists with its content
2573            assert!(
2574                dst_path.join("baz").exists(),
2575                "existing baz directory should still exist"
2576            );
2577            assert!(
2578                dst_path.join("baz").join("marker.txt").exists(),
2579                "existing content in baz should still exist"
2580            );
2581            Ok(())
2582        }
2583    }
2584    mod dry_run_tests {
2585        use super::*;
2586        /// Test that dry-run mode for files doesn't create hard links.
2587        #[tokio::test]
2588        #[traced_test]
2589        async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
2590            let tmp_dir = testutils::setup_test_dir().await?;
2591            let test_path = tmp_dir.as_path();
2592            let src_file = test_path.join("foo/0.txt");
2593            let dst_file = test_path.join("dst_link.txt");
2594            // verify destination doesn't exist
2595            assert!(
2596                !dst_file.exists(),
2597                "destination should not exist before dry-run"
2598            );
2599            let summary = link(
2600                &PROGRESS,
2601                test_path,
2602                &src_file,
2603                &dst_file,
2604                &None,
2605                &Settings {
2606                    copy_settings: CopySettings {
2607                        dereference: false,
2608                        fail_early: false,
2609                        overwrite: false,
2610                        overwrite_compare: Default::default(),
2611                        overwrite_filter: None,
2612                        ignore_existing: false,
2613                        chunk_size: 0,
2614                        skip_specials: false,
2615                        remote_copy_buffer_size: 0,
2616                        filter: None,
2617                        dry_run: None,
2618                        delete: None,
2619                    },
2620                    update_compare: Default::default(),
2621                    update_exclusive: false,
2622                    filter: None,
2623                    dry_run: Some(crate::config::DryRunMode::Brief),
2624                    preserve: preserve::preserve_all(),
2625                },
2626                false,
2627            )
2628            .await?;
2629            // verify destination still doesn't exist
2630            assert!(!dst_file.exists(), "dry-run should not create hard link");
2631            // verify summary reports what would be created
2632            assert_eq!(
2633                summary.hard_links_created, 1,
2634                "dry-run should report 1 hard link that would be created"
2635            );
2636            Ok(())
2637        }
2638        /// Test that dry-run mode for directories doesn't create the destination directory.
2639        #[tokio::test]
2640        #[traced_test]
2641        async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
2642            let tmp_dir = testutils::setup_test_dir().await?;
2643            let test_path = tmp_dir.as_path();
2644            let dst_path = test_path.join("nonexistent_dst");
2645            // verify destination doesn't exist
2646            assert!(
2647                !dst_path.exists(),
2648                "destination should not exist before dry-run"
2649            );
2650            let summary = link(
2651                &PROGRESS,
2652                test_path,
2653                &test_path.join("foo"),
2654                &dst_path,
2655                &None,
2656                &Settings {
2657                    copy_settings: CopySettings {
2658                        dereference: false,
2659                        fail_early: false,
2660                        overwrite: false,
2661                        overwrite_compare: Default::default(),
2662                        overwrite_filter: None,
2663                        ignore_existing: false,
2664                        chunk_size: 0,
2665                        skip_specials: false,
2666                        remote_copy_buffer_size: 0,
2667                        filter: None,
2668                        dry_run: None,
2669                        delete: None,
2670                    },
2671                    update_compare: Default::default(),
2672                    update_exclusive: false,
2673                    filter: None,
2674                    dry_run: Some(crate::config::DryRunMode::Brief),
2675                    preserve: preserve::preserve_all(),
2676                },
2677                false,
2678            )
2679            .await?;
2680            // verify destination still doesn't exist
2681            assert!(
2682                !dst_path.exists(),
2683                "dry-run should not create destination directory"
2684            );
2685            // verify summary reports what would be created
2686            assert!(
2687                summary.hard_links_created > 0,
2688                "dry-run should report hard links that would be created"
2689            );
2690            Ok(())
2691        }
2692        /// Test that dry-run mode correctly reports symlinks (not as hard links).
2693        #[tokio::test]
2694        #[traced_test]
2695        async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
2696            let tmp_dir = testutils::setup_test_dir().await?;
2697            let test_path = tmp_dir.as_path();
2698            // baz contains: 4.txt (file), 5.txt (symlink), 6.txt (symlink)
2699            let src_path = test_path.join("foo/baz");
2700            let dst_path = test_path.join("dst_baz");
2701            // verify destination doesn't exist
2702            assert!(
2703                !dst_path.exists(),
2704                "destination should not exist before dry-run"
2705            );
2706            let summary = link(
2707                &PROGRESS,
2708                test_path,
2709                &src_path,
2710                &dst_path,
2711                &None,
2712                &Settings {
2713                    copy_settings: CopySettings {
2714                        dereference: false,
2715                        fail_early: false,
2716                        overwrite: false,
2717                        overwrite_compare: Default::default(),
2718                        overwrite_filter: None,
2719                        ignore_existing: false,
2720                        chunk_size: 0,
2721                        skip_specials: false,
2722                        remote_copy_buffer_size: 0,
2723                        filter: None,
2724                        dry_run: None,
2725                        delete: None,
2726                    },
2727                    update_compare: Default::default(),
2728                    update_exclusive: false,
2729                    filter: None,
2730                    dry_run: Some(crate::config::DryRunMode::Brief),
2731                    preserve: preserve::preserve_all(),
2732                },
2733                false,
2734            )
2735            .await?;
2736            // verify destination still doesn't exist
2737            assert!(!dst_path.exists(), "dry-run should not create destination");
2738            // baz contains 1 regular file (4.txt) and 2 symlinks (5.txt, 6.txt)
2739            assert_eq!(
2740                summary.hard_links_created, 1,
2741                "dry-run should report 1 hard link (for 4.txt)"
2742            );
2743            assert_eq!(
2744                summary.copy_summary.symlinks_created, 2,
2745                "dry-run should report 2 symlinks (5.txt and 6.txt)"
2746            );
2747            Ok(())
2748        }
2749    }
2750
2751    /// Verify that fail-early preserves the summary from the failing subtree.
2752    ///
2753    /// Regression test: the fail-early return path in the join loop must
2754    /// accumulate error.summary from the failing child into the parent's
2755    /// link_summary. Without this, directories_created from the child subtree
2756    /// would be lost.
2757    #[tokio::test]
2758    #[traced_test]
2759    async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
2760        let tmp_dir = testutils::create_temp_dir().await?;
2761        let test_path = tmp_dir.as_path();
2762        // src/sub/  has a file and an unreadable subdirectory:
2763        //   src/sub/good.txt            <-- links successfully
2764        //   src/sub/unreadable_dir/     <-- mode 000, can't be traversed
2765        //     src/sub/unreadable_dir/f.txt
2766        let src_dir = test_path.join("src");
2767        let sub_dir = src_dir.join("sub");
2768        let bad_dir = sub_dir.join("unreadable_dir");
2769        tokio::fs::create_dir_all(&bad_dir).await?;
2770        tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
2771        tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
2772        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
2773        let dst_dir = test_path.join("dst");
2774        let result = link(
2775            &PROGRESS,
2776            test_path,
2777            &src_dir,
2778            &dst_dir,
2779            &None,
2780            &Settings {
2781                copy_settings: CopySettings {
2782                    fail_early: true,
2783                    ..common_settings(false, false).copy_settings
2784                },
2785                ..common_settings(false, false)
2786            },
2787            false,
2788        )
2789        .await;
2790        // restore permissions for cleanup
2791        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
2792        let error = result.expect_err("link should fail due to unreadable directory");
2793        // sub/'s link_internal created dst/sub/ (directories_created=1) before
2794        // its join loop encountered the unreadable_dir error. that directory
2795        // creation must be reflected in the error summary propagated up to the
2796        // top-level caller.
2797        assert!(
2798            error.summary.copy_summary.directories_created >= 2,
2799            "fail-early summary should include directories from the failing subtree, \
2800             got directories_created={} (expected >= 2: dst/ and dst/sub/)",
2801            error.summary.copy_summary.directories_created
2802        );
2803        Ok(())
2804    }
2805
2806    #[tokio::test]
2807    #[traced_test]
2808    async fn skip_specials_skips_socket_in_link() -> Result<(), anyhow::Error> {
2809        let tmp_dir = testutils::setup_test_dir().await?;
2810        let test_path = tmp_dir.as_path();
2811        let src = test_path.join("src_dir");
2812        let dst = test_path.join("dst_dir");
2813        tokio::fs::create_dir(&src).await?;
2814        tokio::fs::write(src.join("file.txt"), "hello").await?;
2815        let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
2816        let mut settings = common_settings(false, false);
2817        settings.copy_settings.skip_specials = true;
2818        let summary = link(&PROGRESS, test_path, &src, &dst, &None, &settings, false).await?;
2819        assert_eq!(summary.hard_links_created, 1);
2820        assert_eq!(summary.copy_summary.specials_skipped, 1);
2821        assert!(dst.join("file.txt").exists());
2822        assert!(!dst.join("test.sock").exists());
2823        Ok(())
2824    }
2825
2826    #[tokio::test]
2827    #[traced_test]
2828    async fn delete_skips_pruning_when_link_has_errors() -> Result<(), anyhow::Error> {
2829        let tmp_dir = testutils::setup_test_dir().await?;
2830        let test_path = tmp_dir.as_path();
2831        let src = test_path.join("foo");
2832        let dst = test_path.join("bar");
2833        // baseline link establishes the destination (no delete)
2834        link(
2835            &PROGRESS,
2836            test_path,
2837            &src,
2838            &dst,
2839            &None,
2840            &common_settings(false, false),
2841            false,
2842        )
2843        .await?;
2844        // an extraneous file that --delete would normally prune
2845        tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2846        // make a source sub-directory unreadable so traversal fails (fail_early is false).
2847        // a directory is used because --overwrite with mtime-equal files skips copying
2848        // identical files; a directory's read_dir fails unconditionally when mode is 0o000.
2849        let unreadable = src.join("baz");
2850        let original = tokio::fs::metadata(&unreadable).await?.permissions();
2851        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2852
2853        let delete_settings = Settings {
2854            copy_settings: CopySettings {
2855                overwrite: true,
2856                fail_early: false,
2857                delete: Some(copy::DeleteSettings {
2858                    delete_excluded: false,
2859                }),
2860                ..common_settings(false, true).copy_settings
2861            },
2862            ..common_settings(false, true)
2863        };
2864        let result = link(
2865            &PROGRESS,
2866            test_path,
2867            &src,
2868            &dst,
2869            &None,
2870            &delete_settings,
2871            false,
2872        )
2873        .await;
2874
2875        tokio::fs::set_permissions(&unreadable, original).await?;
2876
2877        assert!(
2878            result.is_err(),
2879            "link of the unreadable directory should fail"
2880        );
2881        assert!(
2882            dst.join("extraneous.txt").exists(),
2883            "pruning must be skipped when the link/update pass reported errors"
2884        );
2885        Ok(())
2886    }
2887
2888    #[tokio::test]
2889    #[traced_test]
2890    async fn skip_specials_top_level_socket_in_link() -> Result<(), anyhow::Error> {
2891        let tmp_dir = testutils::setup_test_dir().await?;
2892        let test_path = tmp_dir.as_path();
2893        let src_socket = test_path.join("test.sock");
2894        let dst = test_path.join("dst.sock");
2895        let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
2896        let mut settings = common_settings(false, false);
2897        settings.copy_settings.skip_specials = true;
2898        let summary = link(
2899            &PROGRESS,
2900            test_path,
2901            &src_socket,
2902            &dst,
2903            &None,
2904            &settings,
2905            false,
2906        )
2907        .await?;
2908        assert_eq!(summary.copy_summary.specials_skipped, 1);
2909        assert_eq!(summary.hard_links_created, 0);
2910        assert!(!dst.exists());
2911        Ok(())
2912    }
2913
2914    /// Stress tests exercising max-open-files saturation during link.
2915    mod max_open_files_tests {
2916        use super::*;
2917
2918        /// deep + wide link: directory tree deeper than the open-files limit, with files
2919        /// at every level. verifies no deadlock occurs (directories don't consume permits).
2920        #[tokio::test]
2921        #[traced_test]
2922        async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
2923            let tmp_dir = testutils::create_temp_dir().await?;
2924            let src = tmp_dir.join("src");
2925            let dst = tmp_dir.join("dst");
2926            let depth = 20;
2927            let files_per_level = 5;
2928            let limit = 4;
2929            // create a directory chain deeper than the permit limit, with files at each level
2930            let mut dir = src.clone();
2931            for level in 0..depth {
2932                tokio::fs::create_dir_all(&dir).await?;
2933                for f in 0..files_per_level {
2934                    tokio::fs::write(
2935                        dir.join(format!("f{}_{}.txt", level, f)),
2936                        format!("L{}F{}", level, f),
2937                    )
2938                    .await?;
2939                }
2940                dir = dir.join(format!("d{}", level));
2941            }
2942            throttle::set_max_open_files(limit);
2943            let summary = tokio::time::timeout(
2944                std::time::Duration::from_secs(30),
2945                link(
2946                    &PROGRESS,
2947                    tmp_dir.as_path(),
2948                    &src,
2949                    &dst,
2950                    &None,
2951                    &common_settings(false, false),
2952                    false,
2953                ),
2954            )
2955            .await
2956            .context("link timed out — possible deadlock")?
2957            .context("link failed")?;
2958            assert_eq!(summary.hard_links_created, depth * files_per_level);
2959            assert_eq!(summary.copy_summary.directories_created, depth);
2960            // spot-check that hard links work by reading content at a few levels
2961            let mut check_dir = dst.clone();
2962            for level in 0..depth {
2963                let content =
2964                    tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
2965                assert_eq!(content, format!("L{}F0", level));
2966                check_dir = check_dir.join(format!("d{}", level));
2967            }
2968            Ok(())
2969        }
2970
2971        /// Regression: link_internal's spawn-time guard must be released before
2972        /// delegating to copy::copy on the file-type-changed path.
2973        ///
2974        /// Scenario: many src entries are regular files (so the spawn loop
2975        /// pre-acquires open-files permits for them), but the corresponding
2976        /// `update` entries are directories (file types differ). link_internal
2977        /// then calls copy::copy on the update directory, which enters
2978        /// copy_internal. If the spawn-time permit were still held while
2979        /// copy::copy ran, copy_internal's own open-files acquire for any
2980        /// inner file would deadlock against a saturated pool.
2981        #[tokio::test]
2982        #[traced_test]
2983        async fn parallel_update_filetype_change_no_deadlock() -> Result<(), anyhow::Error> {
2984            let tmp_dir = testutils::create_temp_dir().await?;
2985            let src = tmp_dir.join("src");
2986            let update = tmp_dir.join("update");
2987            let dst = tmp_dir.join("dst");
2988            tokio::fs::create_dir(&src).await?;
2989            tokio::fs::create_dir(&update).await?;
2990            let n = 8;
2991            // src/eN: regular files. update/eN: directories with inner files.
2992            // file types differ -> link takes the !is_file_type_same branch
2993            // -> calls copy::copy(update/eN, dst/eN).
2994            for i in 0..n {
2995                tokio::fs::write(src.join(format!("e{}", i)), format!("src-{}", i)).await?;
2996                let upd_subdir = update.join(format!("e{}", i));
2997                tokio::fs::create_dir(&upd_subdir).await?;
2998                for j in 0..3 {
2999                    tokio::fs::write(
3000                        upd_subdir.join(format!("inner_{}.txt", j)),
3001                        format!("upd-{}-{}", i, j),
3002                    )
3003                    .await?;
3004                }
3005            }
3006            // saturate the open-files pool: spawn-time permits held by every
3007            // outer link task would block copy::copy's inner permit acquires.
3008            throttle::set_max_open_files(2);
3009            let summary = tokio::time::timeout(
3010                std::time::Duration::from_secs(30),
3011                link(
3012                    &PROGRESS,
3013                    tmp_dir.as_path(),
3014                    &src,
3015                    &dst,
3016                    &Some(update.clone()),
3017                    &common_settings(false, false),
3018                    false,
3019                ),
3020            )
3021            .await
3022            .context(
3023                "link timed out — caller-supplied open-files guard not released before copy::copy",
3024            )?
3025            .context("link failed")?;
3026            // every entry was a type-mismatch -> copied from update.
3027            // copy::copy on a directory creates the dir and copies inner files.
3028            assert_eq!(summary.copy_summary.directories_created, n + 1); // +1 for dst itself
3029            assert_eq!(summary.copy_summary.files_copied, n * 3);
3030            // verify content came from update, not src
3031            for i in 0..n {
3032                for j in 0..3 {
3033                    let content =
3034                        tokio::fs::read_to_string(dst.join(format!("e{}/inner_{}.txt", i, j)))
3035                            .await?;
3036                    assert_eq!(content, format!("upd-{}-{}", i, j));
3037                }
3038            }
3039            Ok(())
3040        }
3041
3042        /// Regression: the "update-only entries" spawn loop must not deadlock
3043        /// against copy::copy's open-files OR against rm::rm's pending-meta.
3044        ///
3045        /// Scenario: update has many regular files that don't exist in src.
3046        /// The loop at site 3 spawns a copy::copy task per entry under a
3047        /// saturated open-files pool. copy::copy's internal acquires must
3048        /// proceed normally — site 3 must not be holding open-files.
3049        #[tokio::test]
3050        #[traced_test]
3051        async fn update_only_entries_bounded_no_deadlock() -> Result<(), anyhow::Error> {
3052            let tmp_dir = testutils::create_temp_dir().await?;
3053            let src = tmp_dir.join("src");
3054            let update = tmp_dir.join("update");
3055            let dst = tmp_dir.join("dst");
3056            tokio::fs::create_dir(&src).await?;
3057            tokio::fs::create_dir(&update).await?;
3058            // src is empty; update has many regular files. Every update entry
3059            // is "missing in src" -> hits the site-3 spawn loop.
3060            let n = 50;
3061            for i in 0..n {
3062                tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
3063            }
3064            throttle::set_max_open_files(2);
3065            let summary = tokio::time::timeout(
3066                std::time::Duration::from_secs(30),
3067                link(
3068                    &PROGRESS,
3069                    tmp_dir.as_path(),
3070                    &src,
3071                    &dst,
3072                    &Some(update.clone()),
3073                    &common_settings(false, false),
3074                    false,
3075                ),
3076            )
3077            .await
3078            .context("link timed out — site-3 spawn loop deadlock")?
3079            .context("link failed")?;
3080            // dst gets the src directory plus a copy of every update file
3081            assert_eq!(summary.copy_summary.directories_created, 1);
3082            assert_eq!(summary.copy_summary.files_copied, n);
3083            for i in 0..n {
3084                let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
3085                assert_eq!(content, format!("upd-{}", i));
3086            }
3087            Ok(())
3088        }
3089
3090        /// Regression for the link site-3 ↔ rm pending-meta self-deadlock.
3091        ///
3092        /// Scenario: update has many entries not in src; dst already has
3093        /// directories at those same names; the user passes --overwrite. Each
3094        /// site-3 task runs copy::copy → copy_file → rm::rm to remove the
3095        /// preexisting dst directory before placing the regular-file copy.
3096        /// rm::rm draws from the pending-meta pool. If site 3 also held
3097        /// pending-meta across copy::copy, every running task would hold a
3098        /// permit while waiting on inner rm to acquire one — classic
3099        /// self-deadlock once the pool is saturated.
3100        #[tokio::test]
3101        #[traced_test]
3102        async fn update_only_overwrite_preexisting_dirs_no_deadlock() -> Result<(), anyhow::Error> {
3103            let tmp_dir = testutils::create_temp_dir().await?;
3104            let src = tmp_dir.join("src");
3105            let update = tmp_dir.join("update");
3106            let dst = tmp_dir.join("dst");
3107            tokio::fs::create_dir(&src).await?;
3108            tokio::fs::create_dir(&update).await?;
3109            tokio::fs::create_dir(&dst).await?;
3110            let n = 12;
3111            for i in 0..n {
3112                // update/uN is a regular file (site 3 will copy it).
3113                tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
3114                // dst/uN is a preexisting directory with inner files. With
3115                // --overwrite, copy_file calls rm::rm to wipe it, which
3116                // recurses into pending-meta.
3117                let dst_subdir = dst.join(format!("u{}", i));
3118                tokio::fs::create_dir(&dst_subdir).await?;
3119                for j in 0..3 {
3120                    tokio::fs::write(
3121                        dst_subdir.join(format!("inner_{}.txt", j)),
3122                        format!("old-{}-{}", i, j),
3123                    )
3124                    .await?;
3125                }
3126            }
3127            // saturate both pools to force the deadlock if the cycle existed.
3128            throttle::set_max_open_files(2);
3129            let summary = tokio::time::timeout(
3130                std::time::Duration::from_secs(30),
3131                link(
3132                    &PROGRESS,
3133                    tmp_dir.as_path(),
3134                    &src,
3135                    &dst,
3136                    &Some(update.clone()),
3137                    &common_settings(false, true), // overwrite=true
3138                    false,
3139                ),
3140            )
3141            .await
3142            .context("link timed out — pending-meta self-deadlock between site 3 and inner rm")?
3143            .context("link failed")?;
3144            // each preexisting dst/uN directory gets removed and replaced
3145            // with a regular-file copy from update/uN.
3146            assert_eq!(summary.copy_summary.files_copied, n);
3147            assert_eq!(summary.copy_summary.rm_summary.files_removed, n * 3);
3148            assert_eq!(summary.copy_summary.rm_summary.directories_removed, n);
3149            // verify content came from update
3150            for i in 0..n {
3151                let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
3152                assert_eq!(content, format!("upd-{}", i));
3153            }
3154            Ok(())
3155        }
3156    }
3157}