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