Skip to main content

common/
copy.rs

1use std::os::unix::fs::MetadataExt;
2
3use anyhow::{Context, anyhow};
4use async_recursion::async_recursion;
5use throttle::get_file_iops_tokens;
6use tracing::instrument;
7
8use crate::config::DryRunMode;
9use crate::filecmp;
10use crate::preserve;
11use crate::progress;
12use crate::rm;
13use crate::rm::{Settings as RmSettings, Summary as RmSummary};
14use crate::walk::{self, EntryKind};
15
16/// Error type for copy operations. See [`crate::error::OperationError`] for
17/// logging conventions and rationale.
18pub type Error = crate::error::OperationError<Summary>;
19
20/// Filter condition for overwrite operations.
21///
22/// Used with `--overwrite-filter` to skip overwriting files that match
23/// a directional condition (e.g., destination is newer than source).
24#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
25pub enum OverwriteFilter {
26    /// Skip overwriting if the destination file is strictly newer (by mtime).
27    #[value(name = "newer")]
28    Newer,
29}
30
31impl std::fmt::Display for OverwriteFilter {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            OverwriteFilter::Newer => write!(f, "newer"),
35        }
36    }
37}
38
39/// Settings controlling rsync-style `--delete` (mirror) behavior.
40///
41/// Present (`Some`) only when `--delete` was requested. `None` means the
42/// destination is never enumerated and no pruning work is done, so the default
43/// copy path pays nothing for this feature.
44#[derive(Debug, Clone)]
45pub struct DeleteSettings {
46    /// Also remove destination entries that match an exclude pattern
47    /// (rsync `--delete-excluded`). When false, excluded entries are protected.
48    pub delete_excluded: bool,
49}
50
51#[derive(Debug, Clone)]
52pub struct Settings {
53    pub dereference: bool,
54    pub fail_early: bool,
55    pub overwrite: bool,
56    pub overwrite_compare: filecmp::MetadataCmpSettings,
57    pub overwrite_filter: Option<OverwriteFilter>,
58    pub ignore_existing: bool,
59    pub chunk_size: u64,
60    /// Skip special files (sockets, FIFOs, devices) without error.
61    pub skip_specials: bool,
62    /// Buffer size for remote copy file transfer operations in bytes.
63    ///
64    /// This is only used for remote copy operations and controls the buffer size
65    /// when copying data between files and network streams. The actual buffer is
66    /// capped to the file size to avoid over-allocation for small files.
67    pub remote_copy_buffer_size: usize,
68    /// filter settings for include/exclude patterns
69    pub filter: Option<crate::filter::FilterSettings>,
70    /// dry-run mode for previewing operations
71    pub dry_run: Option<crate::config::DryRunMode>,
72    /// rsync-style `--delete` settings; `None` disables deletion entirely.
73    pub delete: Option<DeleteSettings>,
74}
75
76/// Summary with the appropriate `*_skipped` counter set to 1 for the given entry kind.
77/// Special files count as `files_skipped` to match the historical mapping used
78/// when filters skip an entry (`specials_skipped` is reserved for `--skip-specials`).
79fn skipped_summary_for(kind: EntryKind) -> Summary {
80    match kind {
81        EntryKind::Dir => Summary {
82            directories_skipped: 1,
83            ..Default::default()
84        },
85        EntryKind::Symlink => Summary {
86            symlinks_skipped: 1,
87            ..Default::default()
88        },
89        EntryKind::File | EntryKind::Special => Summary {
90            files_skipped: 1,
91            ..Default::default()
92        },
93    }
94}
95
96/// Result of checking if an empty directory should be cleaned up.
97/// Used when filtering is active and a directory we created ended up empty.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum EmptyDirAction {
100    /// keep the directory (directly matched or no filter active)
101    Keep,
102    /// directory was only traversed, remove it
103    Remove,
104    /// dry-run mode, don't count this directory in summary
105    DryRunSkip,
106}
107
108/// Determine what to do with an empty directory when filtering is active.
109///
110/// This is called when we created a directory but nothing was copied into it
111/// (no files, symlinks, or child directories). The decision depends on whether
112/// the directory itself was directly matched by an include pattern, or if we
113/// only entered it to look for potential matches inside.
114///
115/// # Arguments
116/// * `filter` - the active filter settings (None means no filtering)
117/// * `we_created_dir` - whether we created this directory (vs it already existed)
118/// * `anything_copied` - whether any content was copied into this directory
119/// * `relative_path` - path relative to the source root (for pattern matching)
120/// * `is_root` - whether this is the root (user-specified) source directory
121/// * `is_dry_run` - whether we're in dry-run mode
122pub fn check_empty_dir_cleanup(
123    filter: Option<&crate::filter::FilterSettings>,
124    we_created_dir: bool,
125    anything_copied: bool,
126    relative_path: &std::path::Path,
127    is_root: bool,
128    is_dry_run: bool,
129) -> EmptyDirAction {
130    // if no filter active or something was copied, keep the directory
131    if filter.is_none() || anything_copied {
132        return EmptyDirAction::Keep;
133    }
134    // if we didn't create this directory, don't remove it
135    if !we_created_dir {
136        return EmptyDirAction::Keep;
137    }
138    // never remove the root directory — it's the user-specified source
139    if is_root {
140        return EmptyDirAction::Keep;
141    }
142    // filter is guaranteed to be Some here (checked above)
143    let f = filter.unwrap();
144    // check if directory directly matches include pattern
145    if f.directly_matches_include(relative_path, true) {
146        return EmptyDirAction::Keep;
147    }
148    // directory was only traversed for potential matches
149    if is_dry_run {
150        EmptyDirAction::DryRunSkip
151    } else {
152        EmptyDirAction::Remove
153    }
154}
155
156#[instrument]
157pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
158    let ft1 = md1.file_type();
159    let ft2 = md2.file_type();
160    ft1.is_dir() == ft2.is_dir()
161        && ft1.is_file() == ft2.is_file()
162        && ft1.is_symlink() == ft2.is_symlink()
163}
164
165#[instrument(skip(prog_track, src_metadata, settings, preserve))]
166pub async fn copy_file(
167    prog_track: &'static progress::Progress,
168    src: &std::path::Path,
169    dst: &std::path::Path,
170    src_metadata: &std::fs::Metadata,
171    settings: &Settings,
172    preserve: &preserve::Settings,
173    is_fresh: bool,
174) -> Result<Summary, Error> {
175    // check --ignore-existing before dry-run so dry-run output reflects actual behavior.
176    // use symlink_metadata to detect dangling symlinks too (Path::exists follows symlinks)
177    if !is_fresh
178        && settings.ignore_existing
179        && crate::walk::run_metadata_probed(
180            congestion::Side::Destination,
181            congestion::MetadataOp::Stat,
182            tokio::fs::symlink_metadata(dst),
183        )
184        .await
185        .is_ok()
186    {
187        if let Some(mode) = settings.dry_run {
188            match mode {
189                DryRunMode::Brief => {}
190                DryRunMode::All => println!("skip file {:?}", dst),
191                DryRunMode::Explain => println!("skip file {:?} (destination exists)", dst),
192            }
193        }
194        tracing::debug!("destination exists, skipping (--ignore-existing)");
195        prog_track.files_unchanged.inc();
196        return Ok(Summary {
197            files_unchanged: 1,
198            ..Default::default()
199        });
200    }
201    // handle dry-run mode for files
202    if settings.dry_run.is_some() {
203        crate::dry_run::report_action("copy", src, Some(dst), "file");
204        return Ok(Summary {
205            files_copied: 1,
206            bytes_copied: src_metadata.len(),
207            ..Default::default()
208        });
209    }
210    tracing::debug!("opening 'src' for reading and 'dst' for writing");
211    get_file_iops_tokens(settings.chunk_size, src_metadata.size()).await;
212    let mut rm_summary = RmSummary::default();
213    if !is_fresh && dst.exists() {
214        if settings.overwrite {
215            tracing::debug!("file exists, check if it's identical");
216            let dst_metadata = crate::walk::run_metadata_probed(
217                congestion::Side::Destination,
218                congestion::MetadataOp::Stat,
219                tokio::fs::symlink_metadata(dst),
220            )
221            .await
222            .with_context(|| format!("failed reading metadata from {:?}", &dst))
223            .map_err(|err| Error::new(err, Default::default()))?;
224            if is_file_type_same(src_metadata, &dst_metadata) {
225                if filecmp::metadata_equal(&settings.overwrite_compare, src_metadata, &dst_metadata)
226                {
227                    tracing::debug!("file is identical, skipping");
228                    prog_track.files_unchanged.inc();
229                    return Ok(Summary {
230                        files_unchanged: 1,
231                        ..Default::default()
232                    });
233                }
234                if let Some(OverwriteFilter::Newer) = settings.overwrite_filter
235                    && filecmp::dest_is_newer(src_metadata, &dst_metadata)
236                {
237                    tracing::debug!("dest is newer than source, skipping");
238                    prog_track.files_unchanged.inc();
239                    return Ok(Summary {
240                        files_unchanged: 1,
241                        ..Default::default()
242                    });
243                }
244            }
245            tracing::info!("file is different, removing existing file");
246            // note tokio::fs::overwrite cannot handle this path being e.g. a directory
247            rm_summary = rm::rm(
248                prog_track,
249                dst,
250                &RmSettings {
251                    fail_early: settings.fail_early,
252                    filter: None,
253                    dry_run: None,
254                    time_filter: None,
255                },
256            )
257            .await
258            .map_err(|err| {
259                let rm_summary = err.summary;
260                let copy_summary = Summary {
261                    rm_summary,
262                    ..Default::default()
263                };
264                Error::new(err.source, copy_summary)
265            })?;
266        } else {
267            return Err(Error::new(
268                anyhow!(
269                    "destination {:?} already exists, did you intend to specify --overwrite?",
270                    dst
271                ),
272                Default::default(),
273            ));
274        }
275    }
276    tracing::debug!("copying data");
277    let mut copy_summary = Summary {
278        rm_summary,
279        ..Default::default()
280    };
281    tokio::fs::copy(src, dst)
282        .await
283        .with_context(|| format!("failed copying {:?} to {:?}", &src, &dst))
284        .map_err(|err| Error::new(err, copy_summary))?;
285    prog_track.files_copied.inc();
286    prog_track.bytes_copied.add(src_metadata.len());
287    tracing::debug!("setting permissions");
288    preserve::set_file_metadata(preserve, src_metadata, dst)
289        .await
290        .map_err(|err| Error::new(err, copy_summary))?;
291    // we mark files as "copied" only after all metadata is set as well
292    copy_summary.bytes_copied += src_metadata.len();
293    copy_summary.files_copied += 1;
294    Ok(copy_summary)
295}
296
297#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
298pub struct Summary {
299    pub bytes_copied: u64,
300    pub files_copied: usize,
301    pub symlinks_created: usize,
302    pub directories_created: usize,
303    pub files_unchanged: usize,
304    pub symlinks_unchanged: usize,
305    pub directories_unchanged: usize,
306    pub files_skipped: usize,
307    pub symlinks_skipped: usize,
308    pub directories_skipped: usize,
309    pub specials_skipped: usize,
310    pub rm_summary: RmSummary,
311}
312
313impl std::ops::Add for Summary {
314    type Output = Self;
315    fn add(self, other: Self) -> Self {
316        Self {
317            bytes_copied: self.bytes_copied + other.bytes_copied,
318            files_copied: self.files_copied + other.files_copied,
319            symlinks_created: self.symlinks_created + other.symlinks_created,
320            directories_created: self.directories_created + other.directories_created,
321            files_unchanged: self.files_unchanged + other.files_unchanged,
322            symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
323            directories_unchanged: self.directories_unchanged + other.directories_unchanged,
324            files_skipped: self.files_skipped + other.files_skipped,
325            symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
326            directories_skipped: self.directories_skipped + other.directories_skipped,
327            specials_skipped: self.specials_skipped + other.specials_skipped,
328            rm_summary: self.rm_summary + other.rm_summary,
329        }
330    }
331}
332
333impl std::fmt::Display for Summary {
334    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
335        write!(
336            f,
337            "copy:\n\
338            -----\n\
339            bytes copied: {}\n\
340            files copied: {}\n\
341            symlinks created: {}\n\
342            directories created: {}\n\
343            files unchanged: {}\n\
344            symlinks unchanged: {}\n\
345            directories unchanged: {}\n\
346            files skipped: {}\n\
347            symlinks skipped: {}\n\
348            directories skipped: {}\n\
349            specials skipped: {}\n\
350            \n\
351            delete:\n\
352            -------\n\
353            {}",
354            bytesize::ByteSize(self.bytes_copied),
355            self.files_copied,
356            self.symlinks_created,
357            self.directories_created,
358            self.files_unchanged,
359            self.symlinks_unchanged,
360            self.directories_unchanged,
361            self.files_skipped,
362            self.symlinks_skipped,
363            self.directories_skipped,
364            self.specials_skipped,
365            &self.rm_summary,
366        )
367    }
368}
369
370/// Public entry point for copy operations.
371/// Internally delegates to copy_internal with source_root tracking for proper filter matching.
372#[instrument(skip(prog_track, settings, preserve))]
373pub async fn copy(
374    prog_track: &'static progress::Progress,
375    src: &std::path::Path,
376    dst: &std::path::Path,
377    settings: &Settings,
378    preserve: &preserve::Settings,
379    is_fresh: bool,
380) -> Result<Summary, Error> {
381    copy_with_filter_base(
382        prog_track,
383        src,
384        dst,
385        settings,
386        preserve,
387        is_fresh,
388        std::path::Path::new(""),
389    )
390    .await
391}
392
393/// Like [`copy`], but treats `src` as living at `filter_base` relative to the original filter
394/// root. Used when `rlink` delegates an update-only entry to `copy`: `--delete` pruning inside
395/// the delegated subtree then matches the include/exclude filter at the entry's true relative
396/// path (e.g. `cache/*.log`) instead of relative to the delegated root.
397#[instrument(skip(prog_track, settings, preserve))]
398#[allow(clippy::too_many_arguments)]
399pub async fn copy_with_filter_base(
400    prog_track: &'static progress::Progress,
401    src: &std::path::Path,
402    dst: &std::path::Path,
403    settings: &Settings,
404    preserve: &preserve::Settings,
405    is_fresh: bool,
406    filter_base: &std::path::Path,
407) -> Result<Summary, Error> {
408    // check filter for top-level source (files, directories, and symlinks)
409    if let Some(ref filter) = settings.filter {
410        let src_name = src.file_name().map(std::path::Path::new);
411        if let Some(name) = src_name {
412            let src_metadata = crate::walk::run_metadata_probed(
413                congestion::Side::Source,
414                congestion::MetadataOp::Stat,
415                tokio::fs::symlink_metadata(src),
416            )
417            .await
418            .with_context(|| format!("failed reading metadata from src: {:?}", &src))
419            .map_err(|err| Error::new(err, Default::default()))?;
420            let is_dir = src_metadata.is_dir();
421            // for a delegated subtree (non-empty filter_base) the source is not the true filter
422            // root, so match it at its logical path with nested semantics; for a normal copy use
423            // root-item semantics (anchored patterns don't apply to the root itself).
424            let result = if filter_base.as_os_str().is_empty() {
425                filter.should_include_root_item(name, is_dir)
426            } else {
427                filter.should_include(filter_base, is_dir)
428            };
429            match result {
430                crate::filter::FilterResult::Included => {}
431                result => {
432                    let kind = EntryKind::from_metadata(&src_metadata);
433                    if let Some(mode) = settings.dry_run {
434                        crate::dry_run::report_skip(src, &result, mode, kind.label_long());
435                    }
436                    kind.inc_skipped(prog_track);
437                    return Ok(skipped_summary_for(kind));
438                }
439            }
440        }
441    }
442    copy_internal(
443        prog_track,
444        src,
445        dst,
446        src,
447        settings,
448        preserve,
449        is_fresh,
450        None,
451        filter_base,
452    )
453    .await
454}
455
456#[instrument(skip(prog_track, settings, preserve, open_file_guard))]
457#[async_recursion]
458#[allow(clippy::too_many_arguments)]
459async fn copy_internal(
460    prog_track: &'static progress::Progress,
461    src: &std::path::Path,
462    dst: &std::path::Path,
463    source_root: &std::path::Path,
464    settings: &Settings,
465    preserve: &preserve::Settings,
466    mut is_fresh: bool,
467    open_file_guard: Option<throttle::OpenFileGuard>,
468    filter_base: &std::path::Path,
469) -> Result<Summary, Error> {
470    let _ops_guard = prog_track.ops.guard();
471    tracing::debug!("reading source metadata");
472    let src_metadata = crate::walk::run_metadata_probed(
473        congestion::Side::Source,
474        congestion::MetadataOp::Stat,
475        tokio::fs::symlink_metadata(src),
476    )
477    .await
478    .with_context(|| format!("failed reading metadata from src: {:?}", &src))
479    .map_err(|err| Error::new(err, Default::default()))?;
480    if settings.dereference && src_metadata.is_symlink() {
481        debug_assert!(
482            open_file_guard.is_none(),
483            "open file guard should not be pre-acquired for symlinks"
484        );
485        let link = crate::walk::run_metadata_probed(
486            congestion::Side::Source,
487            congestion::MetadataOp::Stat,
488            tokio::fs::canonicalize(&src),
489        )
490        .await
491        .with_context(|| format!("failed reading src symlink {:?}", &src))
492        .map_err(|err| Error::new(err, Default::default()))?;
493        return copy(prog_track, &link, dst, settings, preserve, is_fresh).await;
494    }
495    if src_metadata.is_file() {
496        // acquire permit if not pre-acquired by the caller
497        let _guard = match open_file_guard {
498            Some(g) => g,
499            None => throttle::open_file_permit().await,
500        };
501        return copy_file(
502            prog_track,
503            src,
504            dst,
505            &src_metadata,
506            settings,
507            preserve,
508            is_fresh,
509        )
510        .await;
511    }
512    debug_assert!(
513        open_file_guard.is_none(),
514        "open file guard should not be pre-acquired for directories or symlinks"
515    );
516    if src_metadata.is_symlink() {
517        // check --ignore-existing before dry-run so dry-run output reflects actual behavior.
518        // use symlink_metadata to detect dangling symlinks too (Path::exists follows symlinks)
519        if !is_fresh
520            && settings.ignore_existing
521            && crate::walk::run_metadata_probed(
522                congestion::Side::Destination,
523                congestion::MetadataOp::Stat,
524                tokio::fs::symlink_metadata(dst),
525            )
526            .await
527            .is_ok()
528        {
529            if let Some(mode) = settings.dry_run {
530                match mode {
531                    DryRunMode::Brief => {}
532                    DryRunMode::All => println!("skip symlink {:?}", dst),
533                    DryRunMode::Explain => println!("skip symlink {:?} (destination exists)", dst),
534                }
535            }
536            tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
537            prog_track.symlinks_unchanged.inc();
538            return Ok(Summary {
539                symlinks_unchanged: 1,
540                ..Default::default()
541            });
542        }
543        // handle dry-run mode for symlinks
544        if settings.dry_run.is_some() {
545            crate::dry_run::report_action("copy", src, Some(dst), "symlink");
546            return Ok(Summary {
547                symlinks_created: 1,
548                ..Default::default()
549            });
550        }
551        let mut rm_summary = RmSummary::default();
552        let link = crate::walk::run_metadata_probed(
553            congestion::Side::Source,
554            congestion::MetadataOp::ReadLink,
555            tokio::fs::read_link(src),
556        )
557        .await
558        .with_context(|| format!("failed reading symlink {:?}", &src))
559        .map_err(|err| Error::new(err, Default::default()))?;
560        // try creating a symlink, if dst path exists and overwrite is set - remove and try again
561        if let Err(error) = crate::walk::run_metadata_probed(
562            congestion::Side::Destination,
563            congestion::MetadataOp::Symlink,
564            tokio::fs::symlink(&link, dst),
565        )
566        .await
567        {
568            if settings.ignore_existing && error.kind() == std::io::ErrorKind::AlreadyExists {
569                tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
570                prog_track.symlinks_unchanged.inc();
571                return Ok(Summary {
572                    symlinks_unchanged: 1,
573                    ..Default::default()
574                });
575            }
576            if settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
577                let dst_metadata = crate::walk::run_metadata_probed(
578                    congestion::Side::Destination,
579                    congestion::MetadataOp::Stat,
580                    tokio::fs::symlink_metadata(dst),
581                )
582                .await
583                .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
584                .map_err(|err| Error::new(err, Default::default()))?;
585                if is_file_type_same(&src_metadata, &dst_metadata) {
586                    let dst_link = crate::walk::run_metadata_probed(
587                        congestion::Side::Destination,
588                        congestion::MetadataOp::ReadLink,
589                        tokio::fs::read_link(dst),
590                    )
591                    .await
592                    .with_context(|| format!("failed reading dst symlink: {:?}", &dst))
593                    .map_err(|err| Error::new(err, Default::default()))?;
594                    if link == dst_link {
595                        tracing::debug!(
596                            "'dst' is a symlink and points to the same location as 'src'"
597                        );
598                        if preserve.symlink.any() {
599                            // do we need to update the metadata for this symlink?
600                            let dst_metadata = crate::walk::run_metadata_probed(
601                                congestion::Side::Destination,
602                                congestion::MetadataOp::Stat,
603                                tokio::fs::symlink_metadata(dst),
604                            )
605                            .await
606                            .with_context(|| {
607                                format!("failed reading metadata from dst: {:?}", &dst)
608                            })
609                            .map_err(|err| Error::new(err, Default::default()))?;
610                            if !filecmp::metadata_equal(
611                                &settings.overwrite_compare,
612                                &src_metadata,
613                                &dst_metadata,
614                            ) {
615                                tracing::debug!("'dst' metadata is different, updating");
616                                preserve::set_symlink_metadata(preserve, &src_metadata, dst)
617                                    .await
618                                    .map_err(|err| Error::new(err, Default::default()))?;
619                                prog_track.symlinks_removed.inc();
620                                prog_track.symlinks_created.inc();
621                                return Ok(Summary {
622                                    rm_summary: RmSummary {
623                                        symlinks_removed: 1,
624                                        ..Default::default()
625                                    },
626                                    symlinks_created: 1,
627                                    ..Default::default()
628                                });
629                            }
630                        }
631                        tracing::debug!("symlink already exists, skipping");
632                        prog_track.symlinks_unchanged.inc();
633                        return Ok(Summary {
634                            symlinks_unchanged: 1,
635                            ..Default::default()
636                        });
637                    }
638                    tracing::debug!("'dst' is a symlink but points to a different path, updating");
639                } else {
640                    tracing::info!("'dst' is not a symlink, updating");
641                }
642                rm_summary = rm::rm(
643                    prog_track,
644                    dst,
645                    &RmSettings {
646                        fail_early: settings.fail_early,
647                        filter: None,
648                        dry_run: None,
649                        time_filter: None,
650                    },
651                )
652                .await
653                .map_err(|err| {
654                    let rm_summary = err.summary;
655                    let copy_summary = Summary {
656                        rm_summary,
657                        ..Default::default()
658                    };
659                    Error::new(err.source, copy_summary)
660                })?;
661                crate::walk::run_metadata_probed(
662                    congestion::Side::Destination,
663                    congestion::MetadataOp::Symlink,
664                    tokio::fs::symlink(&link, dst),
665                )
666                .await
667                .with_context(|| format!("failed creating symlink {:?}", &dst))
668                .map_err(|err| {
669                    let copy_summary = Summary {
670                        rm_summary,
671                        ..Default::default()
672                    };
673                    Error::new(err, copy_summary)
674                })?;
675            } else {
676                return Err(Error::new(
677                    anyhow!("failed creating symlink {:?}", &dst),
678                    Default::default(),
679                ));
680            }
681        }
682        preserve::set_symlink_metadata(preserve, &src_metadata, dst)
683            .await
684            .map_err(|err| {
685                let copy_summary = Summary {
686                    rm_summary,
687                    ..Default::default()
688                };
689                Error::new(err, copy_summary)
690            })?;
691        prog_track.symlinks_created.inc();
692        return Ok(Summary {
693            rm_summary,
694            symlinks_created: 1,
695            ..Default::default()
696        });
697    }
698    if !src_metadata.is_dir() {
699        if settings.skip_specials {
700            tracing::debug!(
701                "skipping special file {:?} (type: {:?})",
702                src,
703                src_metadata.file_type()
704            );
705            if let Some(mode) = settings.dry_run {
706                match mode {
707                    DryRunMode::Brief => {}
708                    DryRunMode::All => println!("skip special {:?}", src),
709                    DryRunMode::Explain => {
710                        println!(
711                            "skip special {:?} (unsupported file type: {:?})",
712                            src,
713                            src_metadata.file_type()
714                        );
715                    }
716                }
717            }
718            prog_track.specials_skipped.inc();
719            return Ok(Summary {
720                specials_skipped: 1,
721                ..Default::default()
722            });
723        }
724        return Err(Error::new(
725            anyhow!(
726                "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
727                src,
728                dst,
729                src_metadata.file_type()
730            ),
731            Default::default(),
732        ));
733    }
734    // handle dry-run mode for directories at the top level
735    if settings.dry_run.is_some() {
736        if settings.ignore_existing
737            && !is_fresh
738            && crate::walk::run_metadata_probed(
739                congestion::Side::Destination,
740                congestion::MetadataOp::Stat,
741                tokio::fs::symlink_metadata(dst),
742            )
743            .await
744            .is_ok()
745            && !dst.is_dir()
746        {
747            // destination is not a directory - would skip entire subtree
748            if let Some(mode) = settings.dry_run {
749                match mode {
750                    DryRunMode::Brief => {}
751                    DryRunMode::All => println!("skip dir {:?}", dst),
752                    DryRunMode::Explain => {
753                        println!("skip dir {:?} (destination exists, not a directory)", dst);
754                    }
755                }
756            }
757            return Ok(Summary {
758                directories_unchanged: 1,
759                ..Default::default()
760            });
761        }
762        crate::dry_run::report_action("copy", src, Some(dst), "dir");
763        // still need to recurse to show contents
764    }
765    tracing::debug!("process contents of 'src' directory");
766    let mut entries = tokio::fs::read_dir(src)
767        .await
768        .with_context(|| format!("cannot open directory {src:?} for reading"))
769        .map_err(|err| Error::new(err, Default::default()))?;
770    // in dry-run mode, skip directory creation but still traverse contents
771    let mut copy_summary = if settings.dry_run.is_some() {
772        Summary {
773            directories_created: 1, // report as would be created
774            ..Default::default()
775        }
776    } else if let Err(error) = crate::walk::run_metadata_probed(
777        congestion::Side::Destination,
778        congestion::MetadataOp::MkDir,
779        tokio::fs::create_dir(dst),
780    )
781    .await
782    {
783        assert!(
784            !is_fresh,
785            "unexpected error creating directory: {dst:?}: {error}"
786        );
787        if (settings.overwrite || settings.ignore_existing)
788            && error.kind() == std::io::ErrorKind::AlreadyExists
789        {
790            // check if the destination is a directory - if so, leave it
791            //
792            // N.B. the permissions may prevent us from writing to it but the alternative is to open up the directory
793            // while we're writing to it which isn't safe
794            let dst_metadata = crate::walk::run_metadata_probed(
795                congestion::Side::Destination,
796                congestion::MetadataOp::Stat,
797                tokio::fs::symlink_metadata(dst),
798            )
799            .await
800            .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
801            .map_err(|err| Error::new(err, Default::default()))?;
802            if dst_metadata.is_dir() {
803                tracing::debug!("'dst' is a directory, leaving it as is");
804                prog_track.directories_unchanged.inc();
805                Summary {
806                    directories_unchanged: 1,
807                    ..Default::default()
808                }
809            } else if settings.ignore_existing {
810                // destination is not a directory but something exists at this path;
811                // with --ignore-existing we skip the entire subtree
812                tracing::debug!(
813                    "destination exists but is not a directory, skipping subtree (--ignore-existing)"
814                );
815                prog_track.directories_unchanged.inc();
816                return Ok(Summary {
817                    directories_unchanged: 1,
818                    ..Default::default()
819                });
820            } else {
821                tracing::info!("'dst' is not a directory, removing and creating a new one");
822                let rm_summary = rm::rm(
823                    prog_track,
824                    dst,
825                    &RmSettings {
826                        fail_early: settings.fail_early,
827                        filter: None,
828                        dry_run: None,
829                        time_filter: None,
830                    },
831                )
832                .await
833                .map_err(|err| {
834                    let rm_summary = err.summary;
835                    let copy_summary = Summary {
836                        rm_summary,
837                        ..Default::default()
838                    };
839                    Error::new(err.source, copy_summary)
840                })?;
841                crate::walk::run_metadata_probed(
842                    congestion::Side::Destination,
843                    congestion::MetadataOp::MkDir,
844                    tokio::fs::create_dir(dst),
845                )
846                .await
847                .with_context(|| format!("cannot create directory {dst:?}"))
848                .map_err(|err| {
849                    let copy_summary = Summary {
850                        rm_summary,
851                        ..Default::default()
852                    };
853                    Error::new(err, copy_summary)
854                })?;
855                // anything copied into dst may assume they don't need to check for conflicts
856                is_fresh = true;
857                prog_track.directories_created.inc();
858                Summary {
859                    rm_summary,
860                    directories_created: 1,
861                    ..Default::default()
862                }
863            }
864        } else {
865            let error = Err::<(), std::io::Error>(error)
866                .with_context(|| format!("cannot create directory {:?}", dst))
867                .unwrap_err();
868            tracing::error!("{:#}", &error);
869            return Err(Error::new(error, Default::default()));
870        }
871    } else {
872        // new directory created, anything copied into dst may assume they don't need to check for conflicts
873        is_fresh = true;
874        prog_track.directories_created.inc();
875        Summary {
876            directories_created: 1,
877            ..Default::default()
878        }
879    };
880    // track whether we created this directory (vs it already existing)
881    // this is used later to decide if we should clean up an empty directory
882    let we_created_this_dir = copy_summary.directories_created == 1;
883    let mut join_set = tokio::task::JoinSet::new();
884    // names of source entries that pass the filter; used by --delete to decide
885    // which destination entries are extraneous. only populated when --delete is
886    // active; an empty HashSet has zero heap cost until the first insert, so the
887    // default path pays only a stack word.
888    let mut keep_set: std::collections::HashSet<std::ffi::OsString> =
889        std::collections::HashSet::new();
890    let errors = crate::error_collector::ErrorCollector::default();
891    loop {
892        let Some((entry, entry_file_type)) =
893            crate::walk::next_entry_probed(&mut entries, congestion::Side::Source, || {
894                format!("failed traversing src directory {:?}", &src)
895            })
896            .await
897            .map_err(|err| Error::new(err, copy_summary))?
898        else {
899            break;
900        };
901        let entry_path = entry.path();
902        let entry_name = entry_path.file_name().unwrap();
903        let dst_path = dst.join(entry_name);
904        let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
905        let entry_is_dir = entry_kind == EntryKind::Dir;
906        // compute the path relative to the original filter root: `filter_base` is empty for a
907        // normal copy and non-empty for an rlink-delegated update subtree, so copy-side filtering
908        // matches the same logical path that delete pruning uses (e.g. `cache/keep.txt`).
909        let relative_path = filter_base.join(walk::relative_to_root(&entry_path, source_root));
910        // apply filter if configured
911        if let Some(skip_result) =
912            walk::should_skip_entry(&settings.filter, &relative_path, entry_is_dir)
913        {
914            if let Some(mode) = settings.dry_run {
915                crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
916            }
917            tracing::debug!("skipping {:?} due to filter", &entry_path);
918            copy_summary = copy_summary + skipped_summary_for(entry_kind);
919            entry_kind.inc_skipped(prog_track);
920            continue;
921        }
922        // record this name as authoritative for --delete: it has a source counterpart, so its
923        // destination counterpart must not be pruned — even when --skip-specials skips copying
924        // it (computed before the skip-specials check below for exactly that reason).
925        if settings.delete.is_some() {
926            keep_set.insert(entry_name.to_owned());
927        }
928        // skip special files (sockets, FIFOs, devices) when --skip-specials is set
929        if settings.skip_specials && entry_kind == EntryKind::Special {
930            tracing::debug!("skipping special file {:?}", &entry_path);
931            if let Some(mode) = settings.dry_run {
932                match mode {
933                    DryRunMode::Brief => {}
934                    DryRunMode::All => println!("skip special {:?}", &entry_path),
935                    DryRunMode::Explain => {
936                        println!(
937                            "skip special {:?} (unsupported file type: {:?})",
938                            &entry_path,
939                            entry_file_type.unwrap()
940                        );
941                    }
942                }
943            }
944            copy_summary.specials_skipped += 1;
945            prog_track.specials_skipped.inc();
946            continue;
947        }
948        // for regular files (not dirs, not symlinks), acquire the open file permit before
949        // spawning the task. this provides backpressure so we don't create unbounded tasks.
950        // it's safe because files are leaf nodes that never recurse, so no deadlock is possible.
951        // we don't acquire for symlinks because with --dereference they could resolve to
952        // directories, which would risk deadlock. when file_type() fails we also skip
953        // pre-acquisition since we can't be sure it's a file — copy_internal will acquire
954        // the permit if needed after reading the actual metadata.
955        let entry_is_regular_file = entry_file_type.as_ref().is_some_and(|ft| ft.is_file());
956        let open_file_guard = if entry_is_regular_file {
957            Some(throttle::open_file_permit().await)
958        } else {
959            None
960        };
961        // spawn recursive call - dry-run reporting is handled by copy_internal
962        // (copy_file, symlink handling, and directory handling all have their own dry-run reporting)
963        let settings = settings.clone();
964        let preserve = *preserve;
965        let source_root = source_root.to_owned();
966        let filter_base = filter_base.to_owned();
967        let do_copy = || async move {
968            copy_internal(
969                prog_track,
970                &entry_path,
971                &dst_path,
972                &source_root,
973                &settings,
974                &preserve,
975                is_fresh,
976                open_file_guard,
977                &filter_base,
978            )
979            .await
980        };
981        join_set.spawn(do_copy());
982    }
983    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
984    // one thing we CAN do however is to drop it as soon as we're done with it
985    drop(entries);
986    while let Some(res) = join_set.join_next().await {
987        match res {
988            Ok(result) => match result {
989                Ok(summary) => copy_summary = copy_summary + summary,
990                Err(error) => {
991                    tracing::error!("copy: {:?} -> {:?} failed with: {:#}", src, dst, &error);
992                    copy_summary = copy_summary + error.summary;
993                    if settings.fail_early {
994                        return Err(Error::new(error.source, copy_summary));
995                    }
996                    errors.push(error.source);
997                }
998            },
999            Err(error) => {
1000                if settings.fail_early {
1001                    return Err(Error::new(error.into(), copy_summary));
1002                }
1003                errors.push(error.into());
1004            }
1005        }
1006    }
1007    // rsync-style --delete: remove destination entries with no source counterpart.
1008    // runs only when --delete was requested; reaching here means the full set of
1009    // source children for this directory is known (all spawned tasks have joined).
1010    if let Some(delete_settings) = &settings.delete {
1011        if errors.has_errors() {
1012            // rsync-style safety: skip pruning when this subtree's copy reported errors —
1013            // deleting based on a run that did not fully succeed could remove data
1014            // unexpectedly. (rsync likewise skips --delete on I/O errors.)
1015            tracing::warn!(
1016                "skipping --delete pruning of {:?} because the copy reported errors",
1017                dst
1018            );
1019        } else {
1020            let relative_dir = filter_base.join(walk::relative_to_root(src, source_root));
1021            match crate::delete::prune_extraneous(
1022                prog_track,
1023                dst,
1024                &relative_dir,
1025                &keep_set,
1026                settings.filter.as_ref(),
1027                delete_settings,
1028                settings.fail_early,
1029                settings.dry_run,
1030            )
1031            .await
1032            {
1033                Ok(rm_summary) => copy_summary.rm_summary = copy_summary.rm_summary + rm_summary,
1034                Err(err) => {
1035                    copy_summary.rm_summary = copy_summary.rm_summary + err.summary;
1036                    if settings.fail_early {
1037                        return Err(Error::new(err.source, copy_summary));
1038                    }
1039                    errors.push(err.source);
1040                }
1041            }
1042        }
1043    }
1044    // when filtering is active and we created this directory, check if anything was actually
1045    // copied into it. if nothing was copied, we may need to clean up the empty directory.
1046    let this_dir_count = usize::from(we_created_this_dir);
1047    let child_dirs_created = copy_summary
1048        .directories_created
1049        .saturating_sub(this_dir_count);
1050    let anything_copied = copy_summary.files_copied > 0
1051        || copy_summary.symlinks_created > 0
1052        || child_dirs_created > 0;
1053    let relative_path = filter_base.join(walk::relative_to_root(src, source_root));
1054    let is_root = src == source_root;
1055    match check_empty_dir_cleanup(
1056        settings.filter.as_ref(),
1057        we_created_this_dir,
1058        anything_copied,
1059        &relative_path,
1060        is_root,
1061        settings.dry_run.is_some(),
1062    ) {
1063        EmptyDirAction::Keep => { /* proceed with metadata application */ }
1064        EmptyDirAction::DryRunSkip => {
1065            tracing::debug!(
1066                "dry-run: directory {:?} would not be created (nothing to copy inside)",
1067                &dst
1068            );
1069            copy_summary.directories_created = 0;
1070            return Ok(copy_summary);
1071        }
1072        EmptyDirAction::Remove => {
1073            tracing::debug!(
1074                "directory {:?} has nothing to copy inside, removing empty directory",
1075                &dst
1076            );
1077            match crate::walk::run_metadata_probed(
1078                congestion::Side::Destination,
1079                congestion::MetadataOp::RmDir,
1080                tokio::fs::remove_dir(dst),
1081            )
1082            .await
1083            {
1084                Ok(()) => {
1085                    copy_summary.directories_created = 0;
1086                    return Ok(copy_summary);
1087                }
1088                Err(err) => {
1089                    // removal failed (not empty, permission error, etc.) — keep directory
1090                    tracing::debug!(
1091                        "failed to remove empty directory {:?}: {:#}, keeping",
1092                        &dst,
1093                        &err
1094                    );
1095                    // fall through to apply metadata
1096                }
1097            }
1098        }
1099    }
1100    // apply directory metadata regardless of whether all children copied successfully.
1101    // the directory itself was created earlier in this function (we would have returned
1102    // early if create_dir failed), so we should preserve the source metadata.
1103    // skip metadata setting in dry-run mode since directory wasn't actually created
1104    tracing::debug!("set 'dst' directory metadata");
1105    let metadata_result = if settings.dry_run.is_some() {
1106        Ok(()) // skip metadata setting in dry-run mode
1107    } else {
1108        preserve::set_dir_metadata(preserve, &src_metadata, dst).await
1109    };
1110    if errors.has_errors() {
1111        // child failures take precedence - log metadata error if it also failed
1112        if let Err(metadata_err) = metadata_result {
1113            tracing::error!(
1114                "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
1115                src,
1116                dst,
1117                &metadata_err
1118            );
1119        }
1120        // unwrap is safe: has_errors() guarantees into_error() returns Some
1121        return Err(Error::new(errors.into_error().unwrap(), copy_summary));
1122    }
1123    // no child failures, so metadata error is the primary error
1124    metadata_result.map_err(|err| Error::new(err, copy_summary))?;
1125    Ok(copy_summary)
1126}
1127
1128#[cfg(test)]
1129mod copy_tests {
1130    use crate::testutils;
1131    use anyhow::Context;
1132    use std::os::unix::fs::MetadataExt;
1133    use std::os::unix::fs::PermissionsExt;
1134    use tracing_test::traced_test;
1135
1136    use super::*;
1137
1138    static PROGRESS: std::sync::LazyLock<progress::Progress> =
1139        std::sync::LazyLock::new(progress::Progress::new);
1140    static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1141        std::sync::LazyLock::new(preserve::preserve_none);
1142    static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1143        std::sync::LazyLock::new(preserve::preserve_all);
1144
1145    fn settings_with_delete(delete: Option<DeleteSettings>) -> Settings {
1146        Settings {
1147            dereference: false,
1148            fail_early: false,
1149            overwrite: delete.is_some(), // --delete implies --overwrite
1150            overwrite_compare: filecmp::MetadataCmpSettings {
1151                size: true,
1152                mtime: true,
1153                ..Default::default()
1154            },
1155            overwrite_filter: None,
1156            ignore_existing: false,
1157            chunk_size: 0,
1158            skip_specials: false,
1159            remote_copy_buffer_size: 0,
1160            filter: None,
1161            dry_run: None,
1162            delete,
1163        }
1164    }
1165
1166    fn delete_on() -> Option<DeleteSettings> {
1167        Some(DeleteSettings {
1168            delete_excluded: false,
1169        })
1170    }
1171
1172    #[tokio::test]
1173    #[traced_test]
1174    async fn delete_protects_skipped_special_name() -> Result<(), anyhow::Error> {
1175        let tmp_dir = testutils::setup_test_dir().await?;
1176        let test_path = tmp_dir.as_path();
1177        let src = test_path.join("src_dir");
1178        let dst = test_path.join("dst_dir");
1179        tokio::fs::create_dir(&src).await?;
1180        tokio::fs::write(src.join("file.txt"), "hello").await?;
1181        // a special file in the source that --skip-specials will skip copying
1182        nix::unistd::mkfifo(
1183            &src.join("pipe"),
1184            nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
1185        )?;
1186        // pre-existing destination: a counterpart for the skipped special, plus a genuine extra
1187        tokio::fs::create_dir(&dst).await?;
1188        tokio::fs::write(dst.join("pipe"), "old").await?;
1189        tokio::fs::write(dst.join("stale.txt"), "junk").await?;
1190
1191        let mut settings = settings_with_delete(delete_on());
1192        settings.skip_specials = true;
1193        let summary = copy(
1194            &PROGRESS,
1195            &src,
1196            &dst,
1197            &settings,
1198            &DO_PRESERVE_SETTINGS,
1199            false,
1200        )
1201        .await?;
1202
1203        assert_eq!(summary.specials_skipped, 1);
1204        assert!(dst.join("file.txt").exists());
1205        assert!(
1206            dst.join("pipe").exists(),
1207            "a destination entry matching a skipped special must not be pruned (it has a source counterpart)"
1208        );
1209        assert!(!dst.join("stale.txt").exists()); // genuine extra removed
1210        Ok(())
1211    }
1212
1213    #[tokio::test]
1214    #[traced_test]
1215    async fn delete_removes_extraneous_destination_entries() -> Result<(), anyhow::Error> {
1216        let tmp_dir = testutils::setup_test_dir().await?;
1217        let test_path = tmp_dir.as_path();
1218        let src = test_path.join("foo");
1219        let dst = test_path.join("bar");
1220        // initial copy (no delete)
1221        copy(
1222            &PROGRESS,
1223            &src,
1224            &dst,
1225            &settings_with_delete(None),
1226            &DO_PRESERVE_SETTINGS,
1227            false,
1228        )
1229        .await?;
1230        // introduce extraneous entries at the destination
1231        tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
1232        tokio::fs::create_dir(dst.join("extra_dir")).await?;
1233        tokio::fs::write(dst.join("extra_dir").join("nested.txt"), b"junk").await?;
1234        // re-copy with --delete
1235        let summary = copy(
1236            &PROGRESS,
1237            &src,
1238            &dst,
1239            &settings_with_delete(delete_on()),
1240            &DO_PRESERVE_SETTINGS,
1241            false,
1242        )
1243        .await?;
1244        assert_eq!(summary.rm_summary.files_removed, 2); // extraneous.txt + extra_dir/nested.txt
1245        assert_eq!(summary.rm_summary.directories_removed, 1); // extra_dir
1246        assert!(!dst.join("extraneous.txt").exists());
1247        assert!(!dst.join("extra_dir").exists());
1248        testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
1249        Ok(())
1250    }
1251
1252    #[tokio::test]
1253    #[traced_test]
1254    async fn delete_prunes_extraneous_at_depth() -> Result<(), anyhow::Error> {
1255        let tmp_dir = testutils::setup_test_dir().await?;
1256        let test_path = tmp_dir.as_path();
1257        let src = test_path.join("foo");
1258        let dst = test_path.join("bar");
1259        copy(
1260            &PROGRESS,
1261            &src,
1262            &dst,
1263            &settings_with_delete(None),
1264            &DO_PRESERVE_SETTINGS,
1265            false,
1266        )
1267        .await?;
1268        // foo/bar is a common subdirectory; place a stale file inside the dst copy of it
1269        let nested = dst.join("bar");
1270        assert!(
1271            nested.is_dir(),
1272            "expected common subdirectory bar/ to exist at destination"
1273        );
1274        tokio::fs::write(nested.join("stale_nested.txt"), b"junk").await?;
1275        let summary = copy(
1276            &PROGRESS,
1277            &src,
1278            &dst,
1279            &settings_with_delete(delete_on()),
1280            &DO_PRESERVE_SETTINGS,
1281            false,
1282        )
1283        .await?;
1284        assert!(
1285            !nested.join("stale_nested.txt").exists(),
1286            "stale entry inside a common subdirectory must be pruned"
1287        );
1288        assert!(summary.rm_summary.files_removed >= 1);
1289        testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
1290        Ok(())
1291    }
1292
1293    #[tokio::test]
1294    #[traced_test]
1295    async fn delete_removes_extraneous_symlink() -> Result<(), anyhow::Error> {
1296        let tmp_dir = testutils::setup_test_dir().await?;
1297        let test_path = tmp_dir.as_path();
1298        let src = test_path.join("foo");
1299        let dst = test_path.join("bar");
1300        copy(
1301            &PROGRESS,
1302            &src,
1303            &dst,
1304            &settings_with_delete(None),
1305            &DO_PRESERVE_SETTINGS,
1306            false,
1307        )
1308        .await?;
1309        // an extraneous symlink at the destination root (no source counterpart)
1310        tokio::fs::symlink("/nonexistent/target", dst.join("stale_link")).await?;
1311        let summary = copy(
1312            &PROGRESS,
1313            &src,
1314            &dst,
1315            &settings_with_delete(delete_on()),
1316            &DO_PRESERVE_SETTINGS,
1317            false,
1318        )
1319        .await?;
1320        assert!(
1321            tokio::fs::symlink_metadata(dst.join("stale_link"))
1322                .await
1323                .is_err(),
1324            "extraneous symlink must be removed"
1325        );
1326        assert_eq!(summary.rm_summary.symlinks_removed, 1);
1327        Ok(())
1328    }
1329
1330    #[tokio::test]
1331    #[traced_test]
1332    async fn delete_skips_pruning_when_copy_has_errors() -> Result<(), anyhow::Error> {
1333        let tmp_dir = testutils::setup_test_dir().await?;
1334        let test_path = tmp_dir.as_path();
1335        let src = test_path.join("foo");
1336        let dst = test_path.join("bar");
1337        // baseline copy establishes the destination
1338        copy(
1339            &PROGRESS,
1340            &src,
1341            &dst,
1342            &settings_with_delete(None),
1343            &DO_PRESERVE_SETTINGS,
1344            false,
1345        )
1346        .await?;
1347        // an extraneous file that --delete would normally prune
1348        tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
1349        // make a source sub-directory unreadable so traversal fails (fail_early is false).
1350        // a directory (not a file) is used because --overwrite with mtime-equal files skips
1351        // copying identical files; a directory's read_dir fails unconditionally when mode is 0o000.
1352        let unreadable = src.join("baz");
1353        let original = tokio::fs::metadata(&unreadable).await?.permissions();
1354        tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
1355
1356        let result = copy(
1357            &PROGRESS,
1358            &src,
1359            &dst,
1360            &settings_with_delete(delete_on()),
1361            &DO_PRESERVE_SETTINGS,
1362            false,
1363        )
1364        .await;
1365
1366        tokio::fs::set_permissions(&unreadable, original).await?;
1367
1368        assert!(
1369            result.is_err(),
1370            "copy of the unreadable directory should fail"
1371        );
1372        assert!(
1373            dst.join("extraneous.txt").exists(),
1374            "pruning must be skipped when the copy reported errors"
1375        );
1376        Ok(())
1377    }
1378
1379    #[tokio::test]
1380    #[traced_test]
1381    async fn check_basic_copy() -> Result<(), anyhow::Error> {
1382        let tmp_dir = testutils::setup_test_dir().await?;
1383        let test_path = tmp_dir.as_path();
1384        let summary = copy(
1385            &PROGRESS,
1386            &test_path.join("foo"),
1387            &test_path.join("bar"),
1388            &Settings {
1389                dereference: false,
1390                fail_early: false,
1391                overwrite: false,
1392                overwrite_compare: filecmp::MetadataCmpSettings {
1393                    size: true,
1394                    mtime: true,
1395                    ..Default::default()
1396                },
1397                overwrite_filter: None,
1398                ignore_existing: false,
1399                chunk_size: 0,
1400                skip_specials: false,
1401                remote_copy_buffer_size: 0,
1402                filter: None,
1403                dry_run: None,
1404                delete: None,
1405            },
1406            &NO_PRESERVE_SETTINGS,
1407            false,
1408        )
1409        .await?;
1410        assert_eq!(summary.files_copied, 5);
1411        assert_eq!(summary.symlinks_created, 2);
1412        assert_eq!(summary.directories_created, 3);
1413        testutils::check_dirs_identical(
1414            &test_path.join("foo"),
1415            &test_path.join("bar"),
1416            testutils::FileEqualityCheck::Basic,
1417        )
1418        .await?;
1419        Ok(())
1420    }
1421
1422    #[tokio::test]
1423    #[traced_test]
1424    async fn no_read_permission() -> Result<(), anyhow::Error> {
1425        let tmp_dir = testutils::setup_test_dir().await?;
1426        let test_path = tmp_dir.as_path();
1427        let filepaths = vec![
1428            test_path.join("foo").join("0.txt"),
1429            test_path.join("foo").join("baz"),
1430        ];
1431        for fpath in &filepaths {
1432            // change file permissions to not readable
1433            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
1434        }
1435        match copy(
1436            &PROGRESS,
1437            &test_path.join("foo"),
1438            &test_path.join("bar"),
1439            &Settings {
1440                dereference: false,
1441                fail_early: false,
1442                overwrite: false,
1443                overwrite_compare: filecmp::MetadataCmpSettings {
1444                    size: true,
1445                    mtime: true,
1446                    ..Default::default()
1447                },
1448                overwrite_filter: None,
1449                ignore_existing: false,
1450                chunk_size: 0,
1451                skip_specials: false,
1452                remote_copy_buffer_size: 0,
1453                filter: None,
1454                dry_run: None,
1455                delete: None,
1456            },
1457            &NO_PRESERVE_SETTINGS,
1458            false,
1459        )
1460        .await
1461        {
1462            Ok(_) => panic!("Expected the copy to error!"),
1463            Err(error) => {
1464                tracing::info!("{}", &error);
1465                // foo
1466                // |- 0.txt  // <- no read permission
1467                // |- bar
1468                //    |- 1.txt
1469                //    |- 2.txt
1470                //    |- 3.txt
1471                // |- baz   // <- no read permission
1472                //    |- 4.txt
1473                //    |- 5.txt -> ../bar/2.txt
1474                //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1475                assert_eq!(error.summary.files_copied, 3);
1476                assert_eq!(error.summary.symlinks_created, 0);
1477                assert_eq!(error.summary.directories_created, 2);
1478            }
1479        }
1480        // make source directory same as what we expect destination to be
1481        for fpath in &filepaths {
1482            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
1483            if tokio::fs::symlink_metadata(fpath).await?.is_file() {
1484                tokio::fs::remove_file(fpath).await?;
1485            } else {
1486                tokio::fs::remove_dir_all(fpath).await?;
1487            }
1488        }
1489        testutils::check_dirs_identical(
1490            &test_path.join("foo"),
1491            &test_path.join("bar"),
1492            testutils::FileEqualityCheck::Basic,
1493        )
1494        .await?;
1495        Ok(())
1496    }
1497
1498    #[tokio::test]
1499    #[traced_test]
1500    async fn check_default_mode() -> Result<(), anyhow::Error> {
1501        let tmp_dir = testutils::setup_test_dir().await?;
1502        // set file to executable
1503        tokio::fs::set_permissions(
1504            tmp_dir.join("foo").join("0.txt"),
1505            std::fs::Permissions::from_mode(0o700),
1506        )
1507        .await?;
1508        // set file executable AND also set sticky bit, setuid and setgid
1509        let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
1510        tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
1511            .await?;
1512        let test_path = tmp_dir.as_path();
1513        let summary = copy(
1514            &PROGRESS,
1515            &test_path.join("foo"),
1516            &test_path.join("bar"),
1517            &Settings {
1518                dereference: false,
1519                fail_early: false,
1520                overwrite: false,
1521                overwrite_compare: filecmp::MetadataCmpSettings {
1522                    size: true,
1523                    mtime: true,
1524                    ..Default::default()
1525                },
1526                overwrite_filter: None,
1527                ignore_existing: false,
1528                chunk_size: 0,
1529                skip_specials: false,
1530                remote_copy_buffer_size: 0,
1531                filter: None,
1532                dry_run: None,
1533                delete: None,
1534            },
1535            &NO_PRESERVE_SETTINGS,
1536            false,
1537        )
1538        .await?;
1539        assert_eq!(summary.files_copied, 5);
1540        assert_eq!(summary.symlinks_created, 2);
1541        assert_eq!(summary.directories_created, 3);
1542        // clear the setuid, setgid and sticky bit for comparison
1543        tokio::fs::set_permissions(
1544            &exec_sticky_file,
1545            std::fs::Permissions::from_mode(
1546                std::fs::symlink_metadata(&exec_sticky_file)?
1547                    .permissions()
1548                    .mode()
1549                    & 0o0777,
1550            ),
1551        )
1552        .await?;
1553        testutils::check_dirs_identical(
1554            &test_path.join("foo"),
1555            &test_path.join("bar"),
1556            testutils::FileEqualityCheck::Basic,
1557        )
1558        .await?;
1559        Ok(())
1560    }
1561
1562    #[tokio::test]
1563    #[traced_test]
1564    async fn no_write_permission() -> Result<(), anyhow::Error> {
1565        let tmp_dir = testutils::setup_test_dir().await?;
1566        let test_path = tmp_dir.as_path();
1567        // directory - readable and non-executable
1568        let non_exec_dir = test_path.join("foo").join("bogey");
1569        tokio::fs::create_dir(&non_exec_dir).await?;
1570        tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
1571        // directory - readable and executable
1572        tokio::fs::set_permissions(
1573            &test_path.join("foo").join("baz"),
1574            std::fs::Permissions::from_mode(0o500),
1575        )
1576        .await?;
1577        // file
1578        tokio::fs::set_permissions(
1579            &test_path.join("foo").join("baz").join("4.txt"),
1580            std::fs::Permissions::from_mode(0o440),
1581        )
1582        .await?;
1583        let summary = copy(
1584            &PROGRESS,
1585            &test_path.join("foo"),
1586            &test_path.join("bar"),
1587            &Settings {
1588                dereference: false,
1589                fail_early: false,
1590                overwrite: false,
1591                overwrite_compare: filecmp::MetadataCmpSettings {
1592                    size: true,
1593                    mtime: true,
1594                    ..Default::default()
1595                },
1596                overwrite_filter: None,
1597                ignore_existing: false,
1598                chunk_size: 0,
1599                skip_specials: false,
1600                remote_copy_buffer_size: 0,
1601                filter: None,
1602                dry_run: None,
1603                delete: None,
1604            },
1605            &NO_PRESERVE_SETTINGS,
1606            false,
1607        )
1608        .await?;
1609        assert_eq!(summary.files_copied, 5);
1610        assert_eq!(summary.symlinks_created, 2);
1611        assert_eq!(summary.directories_created, 4);
1612        testutils::check_dirs_identical(
1613            &test_path.join("foo"),
1614            &test_path.join("bar"),
1615            testutils::FileEqualityCheck::Basic,
1616        )
1617        .await?;
1618        Ok(())
1619    }
1620
1621    #[tokio::test]
1622    #[traced_test]
1623    async fn dereference() -> Result<(), anyhow::Error> {
1624        let tmp_dir = testutils::setup_test_dir().await?;
1625        let test_path = tmp_dir.as_path();
1626        // make files pointed to by symlinks have different permissions than the symlink itself
1627        let src1 = &test_path.join("foo").join("bar").join("2.txt");
1628        let src2 = &test_path.join("foo").join("bar").join("3.txt");
1629        let test_mode = 0o440;
1630        for f in [src1, src2] {
1631            tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
1632        }
1633        let summary = copy(
1634            &PROGRESS,
1635            &test_path.join("foo"),
1636            &test_path.join("bar"),
1637            &Settings {
1638                dereference: true, // <- important!
1639                fail_early: false,
1640                overwrite: false,
1641                overwrite_compare: filecmp::MetadataCmpSettings {
1642                    size: true,
1643                    mtime: true,
1644                    ..Default::default()
1645                },
1646                overwrite_filter: None,
1647                ignore_existing: false,
1648                chunk_size: 0,
1649                skip_specials: false,
1650                remote_copy_buffer_size: 0,
1651                filter: None,
1652                dry_run: None,
1653                delete: None,
1654            },
1655            &NO_PRESERVE_SETTINGS,
1656            false,
1657        )
1658        .await?;
1659        assert_eq!(summary.files_copied, 7);
1660        assert_eq!(summary.symlinks_created, 0);
1661        assert_eq!(summary.directories_created, 3);
1662        // ...
1663        // |- baz
1664        //    |- 4.txt
1665        //    |- 5.txt -> ../bar/2.txt
1666        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1667        let dst1 = &test_path.join("bar").join("baz").join("5.txt");
1668        let dst2 = &test_path.join("bar").join("baz").join("6.txt");
1669        for f in [dst1, dst2] {
1670            let metadata = tokio::fs::symlink_metadata(f)
1671                .await
1672                .with_context(|| format!("failed reading metadata from {:?}", &f))?;
1673            assert!(metadata.is_file());
1674            // check that the permissions are the same as the source file modulo no sticky bit, setuid and setgid
1675            assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
1676        }
1677        Ok(())
1678    }
1679
1680    async fn cp_compare(
1681        cp_args: &[&str],
1682        rcp_settings: &Settings,
1683        preserve: bool,
1684    ) -> Result<(), anyhow::Error> {
1685        let tmp_dir = testutils::setup_test_dir().await?;
1686        let test_path = tmp_dir.as_path();
1687        // run a cp command to copy the files
1688        let cp_output = tokio::process::Command::new("cp")
1689            .args(cp_args)
1690            .arg(test_path.join("foo"))
1691            .arg(test_path.join("bar"))
1692            .output()
1693            .await?;
1694        assert!(cp_output.status.success());
1695        // now run rcp
1696        let summary = copy(
1697            &PROGRESS,
1698            &test_path.join("foo"),
1699            &test_path.join("baz"),
1700            rcp_settings,
1701            if preserve {
1702                &DO_PRESERVE_SETTINGS
1703            } else {
1704                &NO_PRESERVE_SETTINGS
1705            },
1706            false,
1707        )
1708        .await?;
1709        if rcp_settings.dereference {
1710            assert_eq!(summary.files_copied, 7);
1711            assert_eq!(summary.symlinks_created, 0);
1712        } else {
1713            assert_eq!(summary.files_copied, 5);
1714            assert_eq!(summary.symlinks_created, 2);
1715        }
1716        assert_eq!(summary.directories_created, 3);
1717        testutils::check_dirs_identical(
1718            &test_path.join("bar"),
1719            &test_path.join("baz"),
1720            if preserve {
1721                testutils::FileEqualityCheck::Timestamp
1722            } else {
1723                testutils::FileEqualityCheck::Basic
1724            },
1725        )
1726        .await?;
1727        Ok(())
1728    }
1729
1730    #[tokio::test]
1731    #[traced_test]
1732    async fn test_cp_compat() -> Result<(), anyhow::Error> {
1733        cp_compare(
1734            &["-r"],
1735            &Settings {
1736                dereference: false,
1737                fail_early: false,
1738                overwrite: false,
1739                overwrite_compare: filecmp::MetadataCmpSettings {
1740                    size: true,
1741                    mtime: true,
1742                    ..Default::default()
1743                },
1744                overwrite_filter: None,
1745                ignore_existing: false,
1746                chunk_size: 0,
1747                skip_specials: false,
1748                remote_copy_buffer_size: 0,
1749                filter: None,
1750                dry_run: None,
1751                delete: None,
1752            },
1753            false,
1754        )
1755        .await?;
1756        Ok(())
1757    }
1758
1759    #[tokio::test]
1760    #[traced_test]
1761    async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
1762        cp_compare(
1763            &["-r", "-p"],
1764            &Settings {
1765                dereference: false,
1766                fail_early: false,
1767                overwrite: false,
1768                overwrite_compare: filecmp::MetadataCmpSettings {
1769                    size: true,
1770                    mtime: true,
1771                    ..Default::default()
1772                },
1773                overwrite_filter: None,
1774                ignore_existing: false,
1775                chunk_size: 0,
1776                skip_specials: false,
1777                remote_copy_buffer_size: 0,
1778                filter: None,
1779                dry_run: None,
1780                delete: None,
1781            },
1782            true,
1783        )
1784        .await?;
1785        Ok(())
1786    }
1787
1788    #[tokio::test]
1789    #[traced_test]
1790    async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
1791        cp_compare(
1792            &["-r", "-L"],
1793            &Settings {
1794                dereference: true,
1795                fail_early: false,
1796                overwrite: false,
1797                overwrite_compare: filecmp::MetadataCmpSettings {
1798                    size: true,
1799                    mtime: true,
1800                    ..Default::default()
1801                },
1802                overwrite_filter: None,
1803                ignore_existing: false,
1804                chunk_size: 0,
1805                skip_specials: false,
1806                remote_copy_buffer_size: 0,
1807                filter: None,
1808                dry_run: None,
1809                delete: None,
1810            },
1811            false,
1812        )
1813        .await?;
1814        Ok(())
1815    }
1816
1817    #[tokio::test]
1818    #[traced_test]
1819    async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
1820        cp_compare(
1821            &["-r", "-p", "-L"],
1822            &Settings {
1823                dereference: true,
1824                fail_early: false,
1825                overwrite: false,
1826                overwrite_compare: filecmp::MetadataCmpSettings {
1827                    size: true,
1828                    mtime: true,
1829                    ..Default::default()
1830                },
1831                overwrite_filter: None,
1832                ignore_existing: false,
1833                chunk_size: 0,
1834                skip_specials: false,
1835                remote_copy_buffer_size: 0,
1836                filter: None,
1837                dry_run: None,
1838                delete: None,
1839            },
1840            true,
1841        )
1842        .await?;
1843        Ok(())
1844    }
1845
1846    async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
1847        let tmp_dir = testutils::setup_test_dir().await?;
1848        let test_path = tmp_dir.as_path();
1849        let summary = copy(
1850            &PROGRESS,
1851            &test_path.join("foo"),
1852            &test_path.join("bar"),
1853            &Settings {
1854                dereference: false,
1855                fail_early: false,
1856                overwrite: false,
1857                overwrite_compare: filecmp::MetadataCmpSettings {
1858                    size: true,
1859                    mtime: true,
1860                    ..Default::default()
1861                },
1862                overwrite_filter: None,
1863                ignore_existing: false,
1864                chunk_size: 0,
1865                skip_specials: false,
1866                remote_copy_buffer_size: 0,
1867                filter: None,
1868                dry_run: None,
1869                delete: None,
1870            },
1871            &DO_PRESERVE_SETTINGS,
1872            false,
1873        )
1874        .await?;
1875        assert_eq!(summary.files_copied, 5);
1876        assert_eq!(summary.symlinks_created, 2);
1877        assert_eq!(summary.directories_created, 3);
1878        Ok(tmp_dir)
1879    }
1880
1881    #[tokio::test]
1882    #[traced_test]
1883    async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
1884        let tmp_dir = setup_test_dir_and_copy().await?;
1885        let output_path = &tmp_dir.join("bar");
1886        {
1887            // bar
1888            // |- 0.txt
1889            // |- bar  <---------------------------------------- REMOVE
1890            //    |- 1.txt  <----------------------------------- REMOVE
1891            //    |- 2.txt  <----------------------------------- REMOVE
1892            //    |- 3.txt  <----------------------------------- REMOVE
1893            // |- baz
1894            //    |- 4.txt
1895            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
1896            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1897            let summary = rm::rm(
1898                &PROGRESS,
1899                &output_path.join("bar"),
1900                &RmSettings {
1901                    fail_early: false,
1902                    filter: None,
1903                    dry_run: None,
1904                    time_filter: None,
1905                },
1906            )
1907            .await?
1908                + rm::rm(
1909                    &PROGRESS,
1910                    &output_path.join("baz").join("5.txt"),
1911                    &RmSettings {
1912                        fail_early: false,
1913                        filter: None,
1914                        dry_run: None,
1915                        time_filter: None,
1916                    },
1917                )
1918                .await?;
1919            assert_eq!(summary.files_removed, 3);
1920            assert_eq!(summary.symlinks_removed, 1);
1921            assert_eq!(summary.directories_removed, 1);
1922        }
1923        let summary = copy(
1924            &PROGRESS,
1925            &tmp_dir.join("foo"),
1926            output_path,
1927            &Settings {
1928                dereference: false,
1929                fail_early: false,
1930                overwrite: true, // <- important!
1931                overwrite_compare: filecmp::MetadataCmpSettings {
1932                    size: true,
1933                    mtime: true,
1934                    ..Default::default()
1935                },
1936                overwrite_filter: None,
1937                ignore_existing: false,
1938                chunk_size: 0,
1939                skip_specials: false,
1940                remote_copy_buffer_size: 0,
1941                filter: None,
1942                dry_run: None,
1943                delete: None,
1944            },
1945            &DO_PRESERVE_SETTINGS,
1946            false,
1947        )
1948        .await?;
1949        assert_eq!(summary.files_copied, 3);
1950        assert_eq!(summary.symlinks_created, 1);
1951        assert_eq!(summary.directories_created, 1);
1952        testutils::check_dirs_identical(
1953            &tmp_dir.join("foo"),
1954            output_path,
1955            testutils::FileEqualityCheck::Timestamp,
1956        )
1957        .await?;
1958        Ok(())
1959    }
1960
1961    #[tokio::test]
1962    #[traced_test]
1963    async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
1964        let tmp_dir = setup_test_dir_and_copy().await?;
1965        let output_path = &tmp_dir.join("bar");
1966        {
1967            // bar
1968            // |- 0.txt
1969            // |- bar
1970            //    |- 1.txt  <------------------------------------- REMOVE
1971            //    |- 2.txt
1972            //    |- 3.txt
1973            // |- baz  <------------------------------------------ REMOVE
1974            //    |- 4.txt  <------------------------------------- REMOVE
1975            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1976            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt <- REMOVE
1977            let summary = rm::rm(
1978                &PROGRESS,
1979                &output_path.join("bar").join("1.txt"),
1980                &RmSettings {
1981                    fail_early: false,
1982                    filter: None,
1983                    dry_run: None,
1984                    time_filter: None,
1985                },
1986            )
1987            .await?
1988                + rm::rm(
1989                    &PROGRESS,
1990                    &output_path.join("baz"),
1991                    &RmSettings {
1992                        fail_early: false,
1993                        filter: None,
1994                        dry_run: None,
1995                        time_filter: None,
1996                    },
1997                )
1998                .await?;
1999            assert_eq!(summary.files_removed, 2);
2000            assert_eq!(summary.symlinks_removed, 2);
2001            assert_eq!(summary.directories_removed, 1);
2002        }
2003        {
2004            // replace bar/1.txt file with a directory
2005            tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
2006            // replace baz directory with a file
2007            tokio::fs::write(&output_path.join("baz"), "baz").await?;
2008        }
2009        let summary = copy(
2010            &PROGRESS,
2011            &tmp_dir.join("foo"),
2012            output_path,
2013            &Settings {
2014                dereference: false,
2015                fail_early: false,
2016                overwrite: true, // <- important!
2017                overwrite_compare: filecmp::MetadataCmpSettings {
2018                    size: true,
2019                    mtime: true,
2020                    ..Default::default()
2021                },
2022                overwrite_filter: None,
2023                ignore_existing: false,
2024                chunk_size: 0,
2025                skip_specials: false,
2026                remote_copy_buffer_size: 0,
2027                filter: None,
2028                dry_run: None,
2029                delete: None,
2030            },
2031            &DO_PRESERVE_SETTINGS,
2032            false,
2033        )
2034        .await?;
2035        assert_eq!(summary.rm_summary.files_removed, 1);
2036        assert_eq!(summary.rm_summary.symlinks_removed, 0);
2037        assert_eq!(summary.rm_summary.directories_removed, 1);
2038        assert_eq!(summary.files_copied, 2);
2039        assert_eq!(summary.symlinks_created, 2);
2040        assert_eq!(summary.directories_created, 1);
2041        testutils::check_dirs_identical(
2042            &tmp_dir.join("foo"),
2043            output_path,
2044            testutils::FileEqualityCheck::Timestamp,
2045        )
2046        .await?;
2047        Ok(())
2048    }
2049
2050    #[tokio::test]
2051    #[traced_test]
2052    async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
2053        let tmp_dir = setup_test_dir_and_copy().await?;
2054        let output_path = &tmp_dir.join("bar");
2055        {
2056            // bar
2057            // |- 0.txt
2058            // |- baz
2059            //    |- 4.txt  <------------------------------------- REMOVE
2060            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
2061            // ...
2062            let summary = rm::rm(
2063                &PROGRESS,
2064                &output_path.join("baz").join("4.txt"),
2065                &RmSettings {
2066                    fail_early: false,
2067                    filter: None,
2068                    dry_run: None,
2069                    time_filter: None,
2070                },
2071            )
2072            .await?
2073                + rm::rm(
2074                    &PROGRESS,
2075                    &output_path.join("baz").join("5.txt"),
2076                    &RmSettings {
2077                        fail_early: false,
2078                        filter: None,
2079                        dry_run: None,
2080                        time_filter: None,
2081                    },
2082                )
2083                .await?;
2084            assert_eq!(summary.files_removed, 1);
2085            assert_eq!(summary.symlinks_removed, 1);
2086            assert_eq!(summary.directories_removed, 0);
2087        }
2088        {
2089            // replace baz/4.txt file with a symlink
2090            tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
2091            // replace baz/5.txt symlink with a file
2092            tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
2093        }
2094        let summary = copy(
2095            &PROGRESS,
2096            &tmp_dir.join("foo"),
2097            output_path,
2098            &Settings {
2099                dereference: false,
2100                fail_early: false,
2101                overwrite: true, // <- important!
2102                overwrite_compare: filecmp::MetadataCmpSettings {
2103                    size: true,
2104                    mtime: true,
2105                    ..Default::default()
2106                },
2107                overwrite_filter: None,
2108                ignore_existing: false,
2109                chunk_size: 0,
2110                skip_specials: false,
2111                remote_copy_buffer_size: 0,
2112                filter: None,
2113                dry_run: None,
2114                delete: None,
2115            },
2116            &DO_PRESERVE_SETTINGS,
2117            false,
2118        )
2119        .await?;
2120        assert_eq!(summary.rm_summary.files_removed, 1);
2121        assert_eq!(summary.rm_summary.symlinks_removed, 1);
2122        assert_eq!(summary.rm_summary.directories_removed, 0);
2123        assert_eq!(summary.files_copied, 1);
2124        assert_eq!(summary.symlinks_created, 1);
2125        assert_eq!(summary.directories_created, 0);
2126        testutils::check_dirs_identical(
2127            &tmp_dir.join("foo"),
2128            output_path,
2129            testutils::FileEqualityCheck::Timestamp,
2130        )
2131        .await?;
2132        Ok(())
2133    }
2134
2135    #[tokio::test]
2136    #[traced_test]
2137    async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
2138        let tmp_dir = setup_test_dir_and_copy().await?;
2139        let output_path = &tmp_dir.join("bar");
2140        {
2141            // bar
2142            // |- 0.txt
2143            // |- bar  <------------------------------------------ REMOVE
2144            //    |- 1.txt  <------------------------------------- REMOVE
2145            //    |- 2.txt  <------------------------------------- REMOVE
2146            //    |- 3.txt  <------------------------------------- REMOVE
2147            // |- baz
2148            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
2149            // ...
2150            let summary = rm::rm(
2151                &PROGRESS,
2152                &output_path.join("bar"),
2153                &RmSettings {
2154                    fail_early: false,
2155                    filter: None,
2156                    dry_run: None,
2157                    time_filter: None,
2158                },
2159            )
2160            .await?
2161                + rm::rm(
2162                    &PROGRESS,
2163                    &output_path.join("baz").join("5.txt"),
2164                    &RmSettings {
2165                        fail_early: false,
2166                        filter: None,
2167                        dry_run: None,
2168                        time_filter: None,
2169                    },
2170                )
2171                .await?;
2172            assert_eq!(summary.files_removed, 3);
2173            assert_eq!(summary.symlinks_removed, 1);
2174            assert_eq!(summary.directories_removed, 1);
2175        }
2176        {
2177            // replace bar directory with a symlink
2178            tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
2179            // replace baz/5.txt symlink with a directory
2180            tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
2181        }
2182        let summary = copy(
2183            &PROGRESS,
2184            &tmp_dir.join("foo"),
2185            output_path,
2186            &Settings {
2187                dereference: false,
2188                fail_early: false,
2189                overwrite: true, // <- important!
2190                overwrite_compare: filecmp::MetadataCmpSettings {
2191                    size: true,
2192                    mtime: true,
2193                    ..Default::default()
2194                },
2195                overwrite_filter: None,
2196                ignore_existing: false,
2197                chunk_size: 0,
2198                skip_specials: false,
2199                remote_copy_buffer_size: 0,
2200                filter: None,
2201                dry_run: None,
2202                delete: None,
2203            },
2204            &DO_PRESERVE_SETTINGS,
2205            false,
2206        )
2207        .await?;
2208        assert_eq!(summary.rm_summary.files_removed, 0);
2209        assert_eq!(summary.rm_summary.symlinks_removed, 1);
2210        assert_eq!(summary.rm_summary.directories_removed, 1);
2211        assert_eq!(summary.files_copied, 3);
2212        assert_eq!(summary.symlinks_created, 1);
2213        assert_eq!(summary.directories_created, 1);
2214        assert_eq!(summary.files_unchanged, 2);
2215        assert_eq!(summary.symlinks_unchanged, 1);
2216        assert_eq!(summary.directories_unchanged, 2);
2217        testutils::check_dirs_identical(
2218            &tmp_dir.join("foo"),
2219            output_path,
2220            testutils::FileEqualityCheck::Timestamp,
2221        )
2222        .await?;
2223        Ok(())
2224    }
2225
2226    #[tokio::test]
2227    #[traced_test]
2228    async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
2229        let tmp_dir = testutils::setup_test_dir().await?;
2230        let test_path = tmp_dir.as_path();
2231        let summary = copy(
2232            &PROGRESS,
2233            &test_path.join("foo"),
2234            &test_path.join("bar"),
2235            &Settings {
2236                dereference: false,
2237                fail_early: false,
2238                overwrite: false,
2239                overwrite_compare: filecmp::MetadataCmpSettings {
2240                    size: true,
2241                    mtime: true,
2242                    ..Default::default()
2243                },
2244                overwrite_filter: None,
2245                ignore_existing: false,
2246                chunk_size: 0,
2247                skip_specials: false,
2248                remote_copy_buffer_size: 0,
2249                filter: None,
2250                dry_run: None,
2251                delete: None,
2252            },
2253            &NO_PRESERVE_SETTINGS, // we want timestamps to differ!
2254            false,
2255        )
2256        .await?;
2257        assert_eq!(summary.files_copied, 5);
2258        assert_eq!(summary.symlinks_created, 2);
2259        assert_eq!(summary.directories_created, 3);
2260        let source_path = &test_path.join("foo");
2261        let output_path = &tmp_dir.join("bar");
2262        // unreadable
2263        tokio::fs::set_permissions(
2264            &source_path.join("bar"),
2265            std::fs::Permissions::from_mode(0o000),
2266        )
2267        .await?;
2268        tokio::fs::set_permissions(
2269            &source_path.join("baz").join("4.txt"),
2270            std::fs::Permissions::from_mode(0o000),
2271        )
2272        .await?;
2273        // bar
2274        // |- 0.txt
2275        // |- bar  <---------------------------------------- NON READABLE
2276        // |- baz
2277        //    |- 4.txt  <----------------------------------- NON READABLE
2278        //    |- 5.txt -> ../bar/2.txt
2279        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
2280        match copy(
2281            &PROGRESS,
2282            &tmp_dir.join("foo"),
2283            output_path,
2284            &Settings {
2285                dereference: false,
2286                fail_early: false,
2287                overwrite: true, // <- important!
2288                overwrite_compare: filecmp::MetadataCmpSettings {
2289                    size: true,
2290                    mtime: true,
2291                    ..Default::default()
2292                },
2293                overwrite_filter: None,
2294                ignore_existing: false,
2295                chunk_size: 0,
2296                skip_specials: false,
2297                remote_copy_buffer_size: 0,
2298                filter: None,
2299                dry_run: None,
2300                delete: None,
2301            },
2302            &DO_PRESERVE_SETTINGS,
2303            false,
2304        )
2305        .await
2306        {
2307            Ok(_) => panic!("Expected the copy to error!"),
2308            Err(error) => {
2309                tracing::info!("{}", &error);
2310                assert_eq!(error.summary.files_copied, 1);
2311                assert_eq!(error.summary.symlinks_created, 2);
2312                assert_eq!(error.summary.directories_created, 0);
2313                assert_eq!(error.summary.rm_summary.files_removed, 2);
2314                assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
2315                assert_eq!(error.summary.rm_summary.directories_removed, 0);
2316            }
2317        }
2318        Ok(())
2319    }
2320
2321    #[tokio::test]
2322    #[traced_test]
2323    async fn overwrite_filter_newer_skips_when_dest_is_newer() -> Result<(), anyhow::Error> {
2324        let tmp_dir = testutils::create_temp_dir().await?;
2325        let test_path = tmp_dir.as_path();
2326        let src_file = test_path.join("src.txt");
2327        let dst_file = test_path.join("dst.txt");
2328        // create dest first with older content, then source
2329        tokio::fs::write(&dst_file, "newer content").await?;
2330        // set dest mtime to the future so it's strictly newer than source
2331        let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
2332        filetime::set_file_mtime(&dst_file, future_time)?;
2333        tokio::fs::write(&src_file, "older content").await?;
2334        let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
2335        filetime::set_file_mtime(&src_file, past_time)?;
2336        let summary = copy_file(
2337            &PROGRESS,
2338            &src_file,
2339            &dst_file,
2340            &tokio::fs::metadata(&src_file).await?,
2341            &Settings {
2342                dereference: false,
2343                fail_early: false,
2344                overwrite: true,
2345                overwrite_compare: filecmp::MetadataCmpSettings {
2346                    size: true,
2347                    mtime: true,
2348                    ..Default::default()
2349                },
2350                overwrite_filter: Some(OverwriteFilter::Newer),
2351                ignore_existing: false,
2352                chunk_size: 0,
2353                skip_specials: false,
2354                remote_copy_buffer_size: 0,
2355                filter: None,
2356                dry_run: None,
2357                delete: None,
2358            },
2359            &NO_PRESERVE_SETTINGS,
2360            false,
2361        )
2362        .await?;
2363        assert_eq!(summary.files_unchanged, 1);
2364        assert_eq!(summary.files_copied, 0);
2365        // dest should still have original content
2366        let content = tokio::fs::read_to_string(&dst_file).await?;
2367        assert_eq!(content, "newer content");
2368        Ok(())
2369    }
2370
2371    #[tokio::test]
2372    #[traced_test]
2373    async fn overwrite_filter_newer_copies_when_dest_is_older() -> Result<(), anyhow::Error> {
2374        let tmp_dir = testutils::create_temp_dir().await?;
2375        let test_path = tmp_dir.as_path();
2376        let src_file = test_path.join("src.txt");
2377        let dst_file = test_path.join("dst.txt");
2378        // create dest with old mtime, source with newer mtime
2379        tokio::fs::write(&dst_file, "old content").await?;
2380        let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
2381        filetime::set_file_mtime(&dst_file, past_time)?;
2382        tokio::fs::write(&src_file, "new content").await?;
2383        let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
2384        filetime::set_file_mtime(&src_file, future_time)?;
2385        let summary = copy_file(
2386            &PROGRESS,
2387            &src_file,
2388            &dst_file,
2389            &tokio::fs::metadata(&src_file).await?,
2390            &Settings {
2391                dereference: false,
2392                fail_early: false,
2393                overwrite: true,
2394                overwrite_compare: filecmp::MetadataCmpSettings {
2395                    size: true,
2396                    mtime: true,
2397                    ..Default::default()
2398                },
2399                overwrite_filter: Some(OverwriteFilter::Newer),
2400                ignore_existing: false,
2401                chunk_size: 0,
2402                skip_specials: false,
2403                remote_copy_buffer_size: 0,
2404                filter: None,
2405                dry_run: None,
2406                delete: None,
2407            },
2408            &NO_PRESERVE_SETTINGS,
2409            false,
2410        )
2411        .await?;
2412        assert_eq!(summary.files_copied, 1);
2413        assert_eq!(summary.files_unchanged, 0);
2414        // dest should now have source content
2415        let content = tokio::fs::read_to_string(&dst_file).await?;
2416        assert_eq!(content, "new content");
2417        Ok(())
2418    }
2419
2420    #[tokio::test]
2421    #[traced_test]
2422    async fn overwrite_filter_newer_copies_when_same_mtime() -> Result<(), anyhow::Error> {
2423        let tmp_dir = testutils::create_temp_dir().await?;
2424        let test_path = tmp_dir.as_path();
2425        let src_file = test_path.join("src.txt");
2426        let dst_file = test_path.join("dst.txt");
2427        // create both files with the same mtime but different size
2428        tokio::fs::write(&dst_file, "old").await?;
2429        tokio::fs::write(&src_file, "new content").await?;
2430        let same_time = filetime::FileTime::from_unix_time(1_500_000_000, 0);
2431        filetime::set_file_mtime(&dst_file, same_time)?;
2432        filetime::set_file_mtime(&src_file, same_time)?;
2433        let summary = copy_file(
2434            &PROGRESS,
2435            &src_file,
2436            &dst_file,
2437            &tokio::fs::metadata(&src_file).await?,
2438            &Settings {
2439                dereference: false,
2440                fail_early: false,
2441                overwrite: true,
2442                overwrite_compare: filecmp::MetadataCmpSettings {
2443                    size: true,
2444                    mtime: true,
2445                    ..Default::default()
2446                },
2447                overwrite_filter: Some(OverwriteFilter::Newer),
2448                ignore_existing: false,
2449                chunk_size: 0,
2450                skip_specials: false,
2451                remote_copy_buffer_size: 0,
2452                filter: None,
2453                dry_run: None,
2454                delete: None,
2455            },
2456            &NO_PRESERVE_SETTINGS,
2457            false,
2458        )
2459        .await?;
2460        // same mtime means NOT newer, so the file should be overwritten
2461        assert_eq!(summary.files_copied, 1);
2462        assert_eq!(summary.files_unchanged, 0);
2463        let content = tokio::fs::read_to_string(&dst_file).await?;
2464        assert_eq!(content, "new content");
2465        Ok(())
2466    }
2467
2468    #[tokio::test]
2469    #[traced_test]
2470    async fn overwrite_without_filter_copies_when_dest_is_newer() -> Result<(), anyhow::Error> {
2471        let tmp_dir = testutils::create_temp_dir().await?;
2472        let test_path = tmp_dir.as_path();
2473        let src_file = test_path.join("src.txt");
2474        let dst_file = test_path.join("dst.txt");
2475        // dest is newer, but no filter set so it should still overwrite
2476        tokio::fs::write(&dst_file, "newer content").await?;
2477        let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
2478        filetime::set_file_mtime(&dst_file, future_time)?;
2479        tokio::fs::write(&src_file, "older content").await?;
2480        let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
2481        filetime::set_file_mtime(&src_file, past_time)?;
2482        let summary = copy_file(
2483            &PROGRESS,
2484            &src_file,
2485            &dst_file,
2486            &tokio::fs::metadata(&src_file).await?,
2487            &Settings {
2488                dereference: false,
2489                fail_early: false,
2490                overwrite: true,
2491                overwrite_compare: filecmp::MetadataCmpSettings {
2492                    size: true,
2493                    mtime: true,
2494                    ..Default::default()
2495                },
2496                overwrite_filter: None,
2497                ignore_existing: false,
2498                chunk_size: 0,
2499                skip_specials: false,
2500                remote_copy_buffer_size: 0,
2501                filter: None,
2502                dry_run: None,
2503                delete: None,
2504            },
2505            &NO_PRESERVE_SETTINGS,
2506            false,
2507        )
2508        .await?;
2509        // without filter, file should be overwritten regardless
2510        assert_eq!(summary.files_copied, 1);
2511        let content = tokio::fs::read_to_string(&dst_file).await?;
2512        assert_eq!(content, "older content");
2513        Ok(())
2514    }
2515
2516    #[tokio::test]
2517    #[traced_test]
2518    async fn ignore_existing_skips_when_dest_exists() -> Result<(), anyhow::Error> {
2519        let tmp_dir = testutils::create_temp_dir().await?;
2520        let test_path = tmp_dir.as_path();
2521        let src_file = test_path.join("src.txt");
2522        let dst_file = test_path.join("dst.txt");
2523        tokio::fs::write(&src_file, "source content").await?;
2524        tokio::fs::write(&dst_file, "dest content").await?;
2525        let summary = copy_file(
2526            &PROGRESS,
2527            &src_file,
2528            &dst_file,
2529            &tokio::fs::metadata(&src_file).await?,
2530            &Settings {
2531                dereference: false,
2532                fail_early: false,
2533                overwrite: false,
2534                overwrite_compare: Default::default(),
2535                overwrite_filter: None,
2536                ignore_existing: true,
2537                chunk_size: 0,
2538                skip_specials: false,
2539                remote_copy_buffer_size: 0,
2540                filter: None,
2541                dry_run: None,
2542                delete: None,
2543            },
2544            &NO_PRESERVE_SETTINGS,
2545            false,
2546        )
2547        .await?;
2548        assert_eq!(summary.files_unchanged, 1);
2549        assert_eq!(summary.files_copied, 0);
2550        // dest should still have original content
2551        let content = tokio::fs::read_to_string(&dst_file).await?;
2552        assert_eq!(content, "dest content");
2553        Ok(())
2554    }
2555
2556    #[tokio::test]
2557    #[traced_test]
2558    async fn ignore_existing_skips_when_dest_is_different_type() -> Result<(), anyhow::Error> {
2559        let tmp_dir = testutils::create_temp_dir().await?;
2560        let test_path = tmp_dir.as_path();
2561        let src_file = test_path.join("src.txt");
2562        let dst_dir = test_path.join("dst.txt");
2563        tokio::fs::write(&src_file, "source content").await?;
2564        // destination is a directory, not a file
2565        tokio::fs::create_dir(&dst_dir).await?;
2566        let summary = copy_file(
2567            &PROGRESS,
2568            &src_file,
2569            &dst_dir,
2570            &tokio::fs::metadata(&src_file).await?,
2571            &Settings {
2572                dereference: false,
2573                fail_early: false,
2574                overwrite: false,
2575                overwrite_compare: Default::default(),
2576                overwrite_filter: None,
2577                ignore_existing: true,
2578                chunk_size: 0,
2579                skip_specials: false,
2580                remote_copy_buffer_size: 0,
2581                filter: None,
2582                dry_run: None,
2583                delete: None,
2584            },
2585            &NO_PRESERVE_SETTINGS,
2586            false,
2587        )
2588        .await?;
2589        assert_eq!(summary.files_unchanged, 1);
2590        assert_eq!(summary.files_copied, 0);
2591        // dest directory should still exist
2592        assert!(dst_dir.is_dir());
2593        Ok(())
2594    }
2595
2596    #[tokio::test]
2597    #[traced_test]
2598    async fn ignore_existing_copies_when_dest_missing() -> Result<(), anyhow::Error> {
2599        let tmp_dir = testutils::create_temp_dir().await?;
2600        let test_path = tmp_dir.as_path();
2601        let src_file = test_path.join("src.txt");
2602        let dst_file = test_path.join("dst.txt");
2603        tokio::fs::write(&src_file, "source content").await?;
2604        let summary = copy_file(
2605            &PROGRESS,
2606            &src_file,
2607            &dst_file,
2608            &tokio::fs::metadata(&src_file).await?,
2609            &Settings {
2610                dereference: false,
2611                fail_early: false,
2612                overwrite: false,
2613                overwrite_compare: Default::default(),
2614                overwrite_filter: None,
2615                ignore_existing: true,
2616                chunk_size: 0,
2617                skip_specials: false,
2618                remote_copy_buffer_size: 0,
2619                filter: None,
2620                dry_run: None,
2621                delete: None,
2622            },
2623            &NO_PRESERVE_SETTINGS,
2624            false,
2625        )
2626        .await?;
2627        assert_eq!(summary.files_copied, 1);
2628        let content = tokio::fs::read_to_string(&dst_file).await?;
2629        assert_eq!(content, "source content");
2630        Ok(())
2631    }
2632
2633    #[tokio::test]
2634    #[traced_test]
2635    async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
2636        // Create a fresh temporary directory to avoid conflicts
2637        let tmp_dir = testutils::create_temp_dir().await?;
2638        let test_path = tmp_dir.as_path();
2639        // Create a chain of symlinks: foo -> bar -> baz (actual file)
2640        let baz_file = test_path.join("baz_file.txt");
2641        tokio::fs::write(&baz_file, "final content").await?;
2642        let bar_link = test_path.join("bar_link");
2643        let foo_link = test_path.join("foo_link");
2644        // Create chain: foo_link -> bar_link -> baz_file.txt
2645        tokio::fs::symlink(&baz_file, &bar_link).await?;
2646        tokio::fs::symlink(&bar_link, &foo_link).await?;
2647        // Create source directory with the symlink chain
2648        let src_dir = test_path.join("src_chain");
2649        tokio::fs::create_dir(&src_dir).await?;
2650        // Copy the chain into the source directory
2651        tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
2652        tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
2653        tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
2654        // Test with dereference - should copy 3 files with same content
2655        let summary = copy(
2656            &PROGRESS,
2657            &src_dir,
2658            &test_path.join("dst_with_deref"),
2659            &Settings {
2660                dereference: true, // <- important!
2661                fail_early: false,
2662                overwrite: false,
2663                overwrite_compare: filecmp::MetadataCmpSettings {
2664                    size: true,
2665                    mtime: true,
2666                    ..Default::default()
2667                },
2668                overwrite_filter: None,
2669                ignore_existing: false,
2670                chunk_size: 0,
2671                skip_specials: false,
2672                remote_copy_buffer_size: 0,
2673                filter: None,
2674                dry_run: None,
2675                delete: None,
2676            },
2677            &NO_PRESERVE_SETTINGS,
2678            false,
2679        )
2680        .await?;
2681        assert_eq!(summary.files_copied, 3); // foo, bar, baz all copied as files
2682        assert_eq!(summary.symlinks_created, 0); // dereference is set
2683        assert_eq!(summary.directories_created, 1);
2684        let dst_dir = test_path.join("dst_with_deref");
2685        // Verify all three are now regular files with the same content
2686        let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
2687        let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
2688        let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
2689        assert_eq!(foo_content, "final content");
2690        assert_eq!(bar_content, "final content");
2691        assert_eq!(baz_content, "final content");
2692        // Verify they are all regular files, not symlinks
2693        assert!(dst_dir.join("foo").is_file());
2694        assert!(dst_dir.join("bar").is_file());
2695        assert!(dst_dir.join("baz").is_file());
2696        assert!(!dst_dir.join("foo").is_symlink());
2697        assert!(!dst_dir.join("bar").is_symlink());
2698        assert!(!dst_dir.join("baz").is_symlink());
2699        Ok(())
2700    }
2701
2702    #[tokio::test]
2703    #[traced_test]
2704    async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
2705        let tmp_dir = testutils::create_temp_dir().await?;
2706        let test_path = tmp_dir.as_path();
2707        // Create a directory with specific permissions and content
2708        let target_dir = test_path.join("target_dir");
2709        tokio::fs::create_dir(&target_dir).await?;
2710        tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
2711        // Add some files to the directory
2712        tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
2713        tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
2714        tokio::fs::set_permissions(
2715            &target_dir.join("file1.txt"),
2716            std::fs::Permissions::from_mode(0o644),
2717        )
2718        .await?;
2719        tokio::fs::set_permissions(
2720            &target_dir.join("file2.txt"),
2721            std::fs::Permissions::from_mode(0o600),
2722        )
2723        .await?;
2724        // Create a symlink pointing to the directory
2725        let dir_symlink = test_path.join("dir_symlink");
2726        tokio::fs::symlink(&target_dir, &dir_symlink).await?;
2727        // Test copying the symlink with dereference - should copy as a directory
2728        let summary = copy(
2729            &PROGRESS,
2730            &dir_symlink,
2731            &test_path.join("copied_dir"),
2732            &Settings {
2733                dereference: true, // <- important!
2734                fail_early: false,
2735                overwrite: false,
2736                overwrite_compare: filecmp::MetadataCmpSettings {
2737                    size: true,
2738                    mtime: true,
2739                    ..Default::default()
2740                },
2741                overwrite_filter: None,
2742                ignore_existing: false,
2743                chunk_size: 0,
2744                skip_specials: false,
2745                remote_copy_buffer_size: 0,
2746                filter: None,
2747                dry_run: None,
2748                delete: None,
2749            },
2750            &DO_PRESERVE_SETTINGS,
2751            false,
2752        )
2753        .await?;
2754        assert_eq!(summary.files_copied, 2); // file1.txt, file2.txt
2755        assert_eq!(summary.symlinks_created, 0); // dereference is set
2756        assert_eq!(summary.directories_created, 1); // copied_dir
2757        let copied_dir = test_path.join("copied_dir");
2758        // Verify the directory and its contents were copied
2759        assert!(copied_dir.is_dir());
2760        assert!(!copied_dir.is_symlink()); // Should be a real directory, not a symlink
2761        // Verify files were copied with correct content
2762        let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
2763        let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
2764        assert_eq!(file1_content, "content1");
2765        assert_eq!(file2_content, "content2");
2766        // Verify permissions were preserved
2767        let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
2768        let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
2769        let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
2770        assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
2771        assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
2772        assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
2773        Ok(())
2774    }
2775
2776    #[tokio::test]
2777    #[traced_test]
2778    async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
2779        let tmp_dir = testutils::create_temp_dir().await?;
2780        let test_path = tmp_dir.as_path();
2781        // Create files with specific permissions
2782        let file1 = test_path.join("file1.txt");
2783        let file2 = test_path.join("file2.txt");
2784        tokio::fs::write(&file1, "content1").await?;
2785        tokio::fs::write(&file2, "content2").await?;
2786        tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
2787        tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
2788        // Create symlinks pointing to these files
2789        let symlink1 = test_path.join("symlink1");
2790        let symlink2 = test_path.join("symlink2");
2791        tokio::fs::symlink(&file1, &symlink1).await?;
2792        tokio::fs::symlink(&file2, &symlink2).await?;
2793        // Test copying symlinks with dereference and preserve
2794        let summary1 = copy(
2795            &PROGRESS,
2796            &symlink1,
2797            &test_path.join("copied_file1.txt"),
2798            &Settings {
2799                dereference: true, // <- important!
2800                fail_early: false,
2801                overwrite: false,
2802                overwrite_compare: filecmp::MetadataCmpSettings::default(),
2803                overwrite_filter: None,
2804                ignore_existing: false,
2805                chunk_size: 0,
2806                skip_specials: false,
2807                remote_copy_buffer_size: 0,
2808                filter: None,
2809                dry_run: None,
2810                delete: None,
2811            },
2812            &DO_PRESERVE_SETTINGS, // <- important!
2813            false,
2814        )
2815        .await?;
2816        let summary2 = copy(
2817            &PROGRESS,
2818            &symlink2,
2819            &test_path.join("copied_file2.txt"),
2820            &Settings {
2821                dereference: true,
2822                fail_early: false,
2823                overwrite: false,
2824                overwrite_compare: filecmp::MetadataCmpSettings::default(),
2825                overwrite_filter: None,
2826                ignore_existing: false,
2827                chunk_size: 0,
2828                skip_specials: false,
2829                remote_copy_buffer_size: 0,
2830                filter: None,
2831                dry_run: None,
2832                delete: None,
2833            },
2834            &DO_PRESERVE_SETTINGS,
2835            false,
2836        )
2837        .await?;
2838        assert_eq!(summary1.files_copied, 1);
2839        assert_eq!(summary1.symlinks_created, 0);
2840        assert_eq!(summary2.files_copied, 1);
2841        assert_eq!(summary2.symlinks_created, 0);
2842        let copied1 = test_path.join("copied_file1.txt");
2843        let copied2 = test_path.join("copied_file2.txt");
2844        // Verify files are regular files, not symlinks
2845        assert!(copied1.is_file());
2846        assert!(!copied1.is_symlink());
2847        assert!(copied2.is_file());
2848        assert!(!copied2.is_symlink());
2849        // Verify content was copied correctly
2850        let content1 = tokio::fs::read_to_string(&copied1).await?;
2851        let content2 = tokio::fs::read_to_string(&copied2).await?;
2852        assert_eq!(content1, "content1");
2853        assert_eq!(content2, "content2");
2854        // Verify permissions from the target files were preserved (not symlink permissions)
2855        let copied1_metadata = tokio::fs::metadata(&copied1).await?;
2856        let copied2_metadata = tokio::fs::metadata(&copied2).await?;
2857        assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
2858        assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
2859        Ok(())
2860    }
2861
2862    #[tokio::test]
2863    #[traced_test]
2864    async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
2865        let tmp_dir = testutils::setup_test_dir().await?;
2866        // symlink bar to bar-link
2867        tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
2868        // symlink bar-link to bar-link-link
2869        tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
2870        let summary = copy(
2871            &PROGRESS,
2872            &tmp_dir.join("foo"),
2873            &tmp_dir.join("bar"),
2874            &Settings {
2875                dereference: true, // <- important!
2876                fail_early: false,
2877                overwrite: false,
2878                overwrite_compare: filecmp::MetadataCmpSettings {
2879                    size: true,
2880                    mtime: true,
2881                    ..Default::default()
2882                },
2883                overwrite_filter: None,
2884                ignore_existing: false,
2885                chunk_size: 0,
2886                skip_specials: false,
2887                remote_copy_buffer_size: 0,
2888                filter: None,
2889                dry_run: None,
2890                delete: None,
2891            },
2892            &DO_PRESERVE_SETTINGS,
2893            false,
2894        )
2895        .await?;
2896        assert_eq!(summary.files_copied, 13); // 0.txt, 3x bar/(1.txt, 2.txt, 3.txt), baz/(4.txt, 5.txt, 6.txt)
2897        assert_eq!(summary.symlinks_created, 0); // dereference is set
2898        assert_eq!(summary.directories_created, 5);
2899        // check_dirs_identical doesn't handle dereference so let's do it manually
2900        tokio::process::Command::new("cp")
2901            .args(["-r", "-L"])
2902            .arg(tmp_dir.join("foo"))
2903            .arg(tmp_dir.join("bar-cp"))
2904            .output()
2905            .await?;
2906        testutils::check_dirs_identical(
2907            &tmp_dir.join("bar"),
2908            &tmp_dir.join("bar-cp"),
2909            testutils::FileEqualityCheck::Basic,
2910        )
2911        .await?;
2912        Ok(())
2913    }
2914
2915    /// Tests to verify error messages include root causes for debugging
2916    mod error_message_tests {
2917        use super::*;
2918
2919        /// Helper to extract full error message with chain
2920        fn get_full_error_message(error: &Error) -> String {
2921            format!("{:#}", error.source)
2922        }
2923
2924        #[tokio::test]
2925        #[traced_test]
2926        async fn test_permission_error_includes_root_cause() -> Result<(), anyhow::Error> {
2927            let tmp_dir = testutils::create_temp_dir().await?;
2928            let unreadable = tmp_dir.join("unreadable.txt");
2929            tokio::fs::write(&unreadable, "test").await?;
2930            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2931
2932            // symlink_metadata succeeds even without read permission
2933            let src_metadata = tokio::fs::symlink_metadata(&unreadable).await?;
2934            let result = copy_file(
2935                &PROGRESS,
2936                &unreadable,
2937                &tmp_dir.join("dest.txt"),
2938                &src_metadata,
2939                &Settings {
2940                    dereference: false,
2941                    fail_early: false,
2942                    overwrite: false,
2943                    overwrite_compare: Default::default(),
2944                    overwrite_filter: None,
2945                    ignore_existing: false,
2946                    chunk_size: 0,
2947                    skip_specials: false,
2948                    remote_copy_buffer_size: 0,
2949                    filter: None,
2950                    dry_run: None,
2951                    delete: None,
2952                },
2953                &NO_PRESERVE_SETTINGS,
2954                false,
2955            )
2956            .await;
2957
2958            assert!(result.is_err(), "Should fail with permission error");
2959            let err_msg = get_full_error_message(&result.unwrap_err());
2960
2961            // The error message MUST include the root cause
2962            assert!(
2963                err_msg.to_lowercase().contains("permission")
2964                    || err_msg.contains("EACCES")
2965                    || err_msg.contains("denied"),
2966                "Error message must include permission-related text. Got: {}",
2967                err_msg
2968            );
2969            Ok(())
2970        }
2971
2972        #[tokio::test]
2973        #[traced_test]
2974        async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
2975            let tmp_dir = testutils::create_temp_dir().await?;
2976
2977            let result = copy(
2978                &PROGRESS,
2979                &tmp_dir.join("does_not_exist.txt"),
2980                &tmp_dir.join("dest.txt"),
2981                &Settings {
2982                    dereference: false,
2983                    fail_early: false,
2984                    overwrite: false,
2985                    overwrite_compare: Default::default(),
2986                    overwrite_filter: None,
2987                    ignore_existing: false,
2988                    chunk_size: 0,
2989                    skip_specials: false,
2990                    remote_copy_buffer_size: 0,
2991                    filter: None,
2992                    dry_run: None,
2993                    delete: None,
2994                },
2995                &NO_PRESERVE_SETTINGS,
2996                false,
2997            )
2998            .await;
2999
3000            assert!(result.is_err());
3001            let err_msg = get_full_error_message(&result.unwrap_err());
3002
3003            assert!(
3004                err_msg.to_lowercase().contains("no such file")
3005                    || err_msg.to_lowercase().contains("not found")
3006                    || err_msg.contains("ENOENT"),
3007                "Error message must include file not found text. Got: {}",
3008                err_msg
3009            );
3010            Ok(())
3011        }
3012
3013        #[tokio::test]
3014        #[traced_test]
3015        async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
3016            let tmp_dir = testutils::create_temp_dir().await?;
3017            let unreadable_dir = tmp_dir.join("unreadable_dir");
3018            tokio::fs::create_dir(&unreadable_dir).await?;
3019            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
3020                .await?;
3021
3022            let result = copy(
3023                &PROGRESS,
3024                &unreadable_dir,
3025                &tmp_dir.join("dest"),
3026                &Settings {
3027                    dereference: false,
3028                    fail_early: true,
3029                    overwrite: false,
3030                    overwrite_compare: Default::default(),
3031                    overwrite_filter: None,
3032                    ignore_existing: false,
3033                    chunk_size: 0,
3034                    skip_specials: false,
3035                    remote_copy_buffer_size: 0,
3036                    filter: None,
3037                    dry_run: None,
3038                    delete: None,
3039                },
3040                &NO_PRESERVE_SETTINGS,
3041                false,
3042            )
3043            .await;
3044
3045            assert!(result.is_err());
3046            let err_msg = get_full_error_message(&result.unwrap_err());
3047
3048            assert!(
3049                err_msg.to_lowercase().contains("permission")
3050                    || err_msg.contains("EACCES")
3051                    || err_msg.contains("denied"),
3052                "Error message must include permission-related text. Got: {}",
3053                err_msg
3054            );
3055
3056            // Clean up - restore permissions so cleanup can remove it
3057            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
3058                .await?;
3059            Ok(())
3060        }
3061
3062        #[tokio::test]
3063        #[traced_test]
3064        async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
3065        {
3066            let tmp_dir = testutils::setup_test_dir().await?;
3067            let test_path = tmp_dir.as_path();
3068            let readonly_parent = test_path.join("readonly_dest");
3069            tokio::fs::create_dir(&readonly_parent).await?;
3070            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3071                .await?;
3072
3073            let result = copy(
3074                &PROGRESS,
3075                &test_path.join("foo"),
3076                &readonly_parent.join("copy"),
3077                &Settings {
3078                    dereference: false,
3079                    fail_early: true,
3080                    overwrite: false,
3081                    overwrite_compare: Default::default(),
3082                    overwrite_filter: None,
3083                    ignore_existing: false,
3084                    chunk_size: 0,
3085                    skip_specials: false,
3086                    remote_copy_buffer_size: 0,
3087                    filter: None,
3088                    dry_run: None,
3089                    delete: None,
3090                },
3091                &NO_PRESERVE_SETTINGS,
3092                false,
3093            )
3094            .await;
3095
3096            // restore permissions so cleanup succeeds even when copy fails
3097            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
3098                .await?;
3099
3100            assert!(result.is_err(), "copy into read-only parent should fail");
3101            let err_msg = get_full_error_message(&result.unwrap_err());
3102
3103            assert!(
3104                err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
3105                "Error message must include permission denied text. Got: {}",
3106                err_msg
3107            );
3108            Ok(())
3109        }
3110    }
3111
3112    mod empty_dir_cleanup_tests {
3113        use super::*;
3114        use crate::filter::FilterSettings;
3115        use std::path::Path;
3116        #[test]
3117        fn test_check_empty_dir_cleanup_no_filter() {
3118            // when no filter, always keep
3119            assert_eq!(
3120                check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
3121                EmptyDirAction::Keep
3122            );
3123        }
3124        #[test]
3125        fn test_check_empty_dir_cleanup_something_copied() {
3126            // when content was copied, keep
3127            let mut filter = FilterSettings::new();
3128            filter.add_include("*.txt").unwrap();
3129            assert_eq!(
3130                check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
3131                EmptyDirAction::Keep
3132            );
3133        }
3134        #[test]
3135        fn test_check_empty_dir_cleanup_not_created() {
3136            // when we didn't create the directory, keep
3137            let mut filter = FilterSettings::new();
3138            filter.add_include("*.txt").unwrap();
3139            assert_eq!(
3140                check_empty_dir_cleanup(
3141                    Some(&filter),
3142                    false,
3143                    false,
3144                    Path::new("any"),
3145                    false,
3146                    false
3147                ),
3148                EmptyDirAction::Keep
3149            );
3150        }
3151        #[test]
3152        fn test_check_empty_dir_cleanup_directly_matched() {
3153            // when directory directly matches include pattern, keep
3154            let mut filter = FilterSettings::new();
3155            filter.add_include("target/").unwrap();
3156            assert_eq!(
3157                check_empty_dir_cleanup(
3158                    Some(&filter),
3159                    true,
3160                    false,
3161                    Path::new("target"),
3162                    false,
3163                    false
3164                ),
3165                EmptyDirAction::Keep
3166            );
3167        }
3168        #[test]
3169        fn test_check_empty_dir_cleanup_traversed_only() {
3170            // when directory was only traversed, remove
3171            let mut filter = FilterSettings::new();
3172            filter.add_include("*.txt").unwrap();
3173            assert_eq!(
3174                check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
3175                EmptyDirAction::Remove
3176            );
3177        }
3178        #[test]
3179        fn test_check_empty_dir_cleanup_dry_run() {
3180            // in dry-run mode, skip instead of remove
3181            let mut filter = FilterSettings::new();
3182            filter.add_include("*.txt").unwrap();
3183            assert_eq!(
3184                check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
3185                EmptyDirAction::DryRunSkip
3186            );
3187        }
3188        #[test]
3189        fn test_check_empty_dir_cleanup_root_always_kept() {
3190            // root directory is never removed, even with filter and nothing copied
3191            let mut filter = FilterSettings::new();
3192            filter.add_include("*.txt").unwrap();
3193            assert_eq!(
3194                check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
3195                EmptyDirAction::Keep
3196            );
3197        }
3198        #[test]
3199        fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
3200            // root directory is kept even in dry-run mode
3201            let mut filter = FilterSettings::new();
3202            filter.add_include("*.txt").unwrap();
3203            assert_eq!(
3204                check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
3205                EmptyDirAction::Keep
3206            );
3207        }
3208    }
3209
3210    /// Verify that directory metadata is applied even when child operations fail.
3211    /// This is a regression test for a bug where directory permissions were not preserved
3212    /// when copying with fail_early=false and some children failed to copy.
3213    #[tokio::test]
3214    #[traced_test]
3215    async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
3216        let tmp_dir = testutils::create_temp_dir().await?;
3217        let test_path = tmp_dir.as_path();
3218        // create source directory with specific permissions
3219        let src_dir = test_path.join("src");
3220        tokio::fs::create_dir(&src_dir).await?;
3221        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
3222        // create a readable file and an unreadable file inside
3223        let readable_file = src_dir.join("readable.txt");
3224        tokio::fs::write(&readable_file, "content").await?;
3225        let unreadable_file = src_dir.join("unreadable.txt");
3226        tokio::fs::write(&unreadable_file, "secret").await?;
3227        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3228            .await?;
3229        let dst_dir = test_path.join("dst");
3230        // copy with fail_early=false and preserve=all
3231        let result = copy(
3232            &PROGRESS,
3233            &src_dir,
3234            &dst_dir,
3235            &Settings {
3236                dereference: false,
3237                fail_early: false,
3238                overwrite: false,
3239                overwrite_compare: Default::default(),
3240                overwrite_filter: None,
3241                ignore_existing: false,
3242                chunk_size: 0,
3243                skip_specials: false,
3244                remote_copy_buffer_size: 0,
3245                filter: None,
3246                dry_run: None,
3247                delete: None,
3248            },
3249            &DO_PRESERVE_SETTINGS,
3250            false,
3251        )
3252        .await;
3253        // restore permissions so cleanup can succeed
3254        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
3255            .await?;
3256        // verify the operation returned an error (unreadable file should fail)
3257        assert!(result.is_err(), "copy should fail due to unreadable file");
3258        let error = result.unwrap_err();
3259        // verify some files were copied (the readable one)
3260        assert_eq!(error.summary.files_copied, 1);
3261        assert_eq!(error.summary.directories_created, 1);
3262        // verify the destination directory exists and has the correct permissions
3263        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3264        assert!(dst_metadata.is_dir());
3265        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
3266        assert_eq!(
3267            actual_mode, 0o750,
3268            "directory should have preserved source permissions (0o750), got {:o}",
3269            actual_mode
3270        );
3271        Ok(())
3272    }
3273
3274    /// Verify that fail-early does not apply parent directory metadata after a child fails.
3275    #[tokio::test]
3276    #[traced_test]
3277    async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error()
3278    -> Result<(), anyhow::Error> {
3279        let tmp_dir = testutils::create_temp_dir().await?;
3280        let test_path = tmp_dir.as_path();
3281        let src_dir = test_path.join("src");
3282        tokio::fs::create_dir(&src_dir).await?;
3283        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
3284        let unreadable_file = src_dir.join("unreadable.txt");
3285        tokio::fs::write(&unreadable_file, "secret").await?;
3286        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3287            .await?;
3288        let fixed_secs = 946684800;
3289        let fixed_nsec = 123_456_789;
3290        let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
3291        nix::sys::stat::utimensat(
3292            nix::fcntl::AT_FDCWD,
3293            &src_dir,
3294            &fixed_time,
3295            &fixed_time,
3296            nix::sys::stat::UtimensatFlags::NoFollowSymlink,
3297        )?;
3298        let src_metadata = tokio::fs::metadata(&src_dir).await?;
3299        let dst_dir = test_path.join("dst");
3300        let result = copy(
3301            &PROGRESS,
3302            &src_dir,
3303            &dst_dir,
3304            &Settings {
3305                dereference: false,
3306                fail_early: true,
3307                overwrite: false,
3308                overwrite_compare: Default::default(),
3309                overwrite_filter: None,
3310                ignore_existing: false,
3311                chunk_size: 0,
3312                skip_specials: false,
3313                remote_copy_buffer_size: 0,
3314                filter: None,
3315                dry_run: None,
3316                delete: None,
3317            },
3318            &DO_PRESERVE_SETTINGS,
3319            false,
3320        )
3321        .await;
3322        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
3323            .await?;
3324        assert!(result.is_err(), "copy should fail due to unreadable file");
3325        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3326        assert!(dst_metadata.is_dir());
3327        assert_ne!(
3328            (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
3329            (src_metadata.mtime(), src_metadata.mtime_nsec()),
3330            "fail-early should return before applying preserved directory timestamps"
3331        );
3332        Ok(())
3333    }
3334    mod filter_tests {
3335        use super::*;
3336        use crate::filter::FilterSettings;
3337        /// Test that path-based patterns (with /) work correctly with nested paths.
3338        /// This test exposes the bug where only entry_name is passed to the filter
3339        /// instead of the relative path.
3340        #[tokio::test]
3341        #[traced_test]
3342        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
3343            let tmp_dir = testutils::setup_test_dir().await?;
3344            let test_path = tmp_dir.as_path();
3345            // test directory structure from setup_test_dir:
3346            // foo/
3347            //   0.txt
3348            //   bar/
3349            //     1.txt
3350            //     2.txt
3351            //   baz/
3352            //     3.txt -> ../0.txt (symlink)
3353            //     4.txt
3354            //     5 -> ../bar (symlink)
3355            // create filter that should match bar/*.txt (files in bar directory)
3356            let mut filter = FilterSettings::new();
3357            filter.add_include("bar/*.txt").unwrap();
3358            let summary = copy(
3359                &PROGRESS,
3360                &test_path.join("foo"),
3361                &test_path.join("dst"),
3362                &Settings {
3363                    dereference: false,
3364                    fail_early: false,
3365                    overwrite: false,
3366                    overwrite_compare: Default::default(),
3367                    overwrite_filter: None,
3368                    ignore_existing: false,
3369                    chunk_size: 0,
3370                    skip_specials: false,
3371                    remote_copy_buffer_size: 0,
3372                    filter: Some(filter),
3373                    dry_run: None,
3374                    delete: None,
3375                },
3376                &NO_PRESERVE_SETTINGS,
3377                false,
3378            )
3379            .await?;
3380            // should only copy files matching bar/*.txt pattern
3381            // bar/1.txt, bar/2.txt, and bar/3.txt should be copied
3382            assert_eq!(
3383                summary.files_copied, 3,
3384                "should copy 3 files matching bar/*.txt"
3385            );
3386            // verify the right files exist
3387            assert!(
3388                test_path.join("dst/bar/1.txt").exists(),
3389                "bar/1.txt should be copied"
3390            );
3391            assert!(
3392                test_path.join("dst/bar/2.txt").exists(),
3393                "bar/2.txt should be copied"
3394            );
3395            assert!(
3396                test_path.join("dst/bar/3.txt").exists(),
3397                "bar/3.txt should be copied"
3398            );
3399            // verify files outside the pattern don't exist
3400            assert!(
3401                !test_path.join("dst/0.txt").exists(),
3402                "0.txt should not be copied"
3403            );
3404            Ok(())
3405        }
3406        /// Test that anchored patterns (starting with /) match only at root.
3407        #[tokio::test]
3408        #[traced_test]
3409        async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
3410            let tmp_dir = testutils::setup_test_dir().await?;
3411            let test_path = tmp_dir.as_path();
3412            // create filter that should match /bar/** (bar directory and all its contents)
3413            let mut filter = FilterSettings::new();
3414            filter.add_include("/bar/**").unwrap();
3415            let summary = copy(
3416                &PROGRESS,
3417                &test_path.join("foo"),
3418                &test_path.join("dst"),
3419                &Settings {
3420                    dereference: false,
3421                    fail_early: false,
3422                    overwrite: false,
3423                    overwrite_compare: Default::default(),
3424                    overwrite_filter: None,
3425                    ignore_existing: false,
3426                    chunk_size: 0,
3427                    skip_specials: false,
3428                    remote_copy_buffer_size: 0,
3429                    filter: Some(filter),
3430                    dry_run: None,
3431                    delete: None,
3432                },
3433                &NO_PRESERVE_SETTINGS,
3434                false,
3435            )
3436            .await?;
3437            // should only copy bar directory and its contents
3438            assert!(
3439                test_path.join("dst/bar").exists(),
3440                "bar directory should be copied"
3441            );
3442            assert!(
3443                !test_path.join("dst/baz").exists(),
3444                "baz directory should not be copied"
3445            );
3446            assert!(
3447                !test_path.join("dst/0.txt").exists(),
3448                "0.txt should not be copied"
3449            );
3450            // verify summary counts
3451            assert_eq!(
3452                summary.files_copied, 3,
3453                "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
3454            );
3455            assert_eq!(
3456                summary.directories_created, 2,
3457                "should create 2 directories (root dst + bar)"
3458            );
3459            // skipped: 0.txt (file) and baz (directory) - baz contents not counted since dir is skipped
3460            assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
3461            assert_eq!(
3462                summary.directories_skipped, 1,
3463                "should skip 1 directory (baz)"
3464            );
3465            Ok(())
3466        }
3467        /// Test that double-star patterns (**) match across directories.
3468        #[tokio::test]
3469        #[traced_test]
3470        async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
3471            let tmp_dir = testutils::setup_test_dir().await?;
3472            let test_path = tmp_dir.as_path();
3473            // create filter that should match all .txt files at any depth
3474            let mut filter = FilterSettings::new();
3475            filter.add_include("**/*.txt").unwrap();
3476            let summary = copy(
3477                &PROGRESS,
3478                &test_path.join("foo"),
3479                &test_path.join("dst"),
3480                &Settings {
3481                    dereference: false,
3482                    fail_early: false,
3483                    overwrite: false,
3484                    overwrite_compare: Default::default(),
3485                    overwrite_filter: None,
3486                    ignore_existing: false,
3487                    chunk_size: 0,
3488                    skip_specials: false,
3489                    remote_copy_buffer_size: 0,
3490                    filter: Some(filter),
3491                    dry_run: None,
3492                    delete: None,
3493                },
3494                &NO_PRESERVE_SETTINGS,
3495                false,
3496            )
3497            .await?;
3498            // should copy all .txt files: 0.txt, bar/1.txt, bar/2.txt, bar/3.txt, baz/4.txt
3499            assert_eq!(
3500                summary.files_copied, 5,
3501                "should copy all 5 .txt files with **/*.txt pattern"
3502            );
3503            Ok(())
3504        }
3505        /// Test that filters are applied to top-level file arguments.
3506        #[tokio::test]
3507        #[traced_test]
3508        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
3509            let tmp_dir = testutils::setup_test_dir().await?;
3510            let test_path = tmp_dir.as_path();
3511            // create filter that excludes .txt files
3512            let mut filter = FilterSettings::new();
3513            filter.add_exclude("*.txt").unwrap();
3514            let result = copy(
3515                &PROGRESS,
3516                &test_path.join("foo/0.txt"), // single file source
3517                &test_path.join("dst.txt"),
3518                &Settings {
3519                    dereference: false,
3520                    fail_early: false,
3521                    overwrite: false,
3522                    overwrite_compare: Default::default(),
3523                    overwrite_filter: None,
3524                    ignore_existing: false,
3525                    chunk_size: 0,
3526                    skip_specials: false,
3527                    remote_copy_buffer_size: 0,
3528                    filter: Some(filter),
3529                    dry_run: None,
3530                    delete: None,
3531                },
3532                &NO_PRESERVE_SETTINGS,
3533                false,
3534            )
3535            .await?;
3536            // the file should NOT be copied because it matches the exclude pattern
3537            assert_eq!(
3538                result.files_copied, 0,
3539                "file matching exclude pattern should not be copied"
3540            );
3541            assert!(
3542                !test_path.join("dst.txt").exists(),
3543                "excluded file should not exist at destination"
3544            );
3545            Ok(())
3546        }
3547        /// Test that filters apply to root directories with simple exclude patterns.
3548        #[tokio::test]
3549        #[traced_test]
3550        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
3551            let test_path = testutils::create_temp_dir().await?;
3552            // create a directory that should be excluded
3553            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
3554            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
3555            // create filter that excludes *_dir/ directories
3556            let mut filter = FilterSettings::new();
3557            filter.add_exclude("*_dir/").unwrap();
3558            let result = copy(
3559                &PROGRESS,
3560                &test_path.join("excluded_dir"),
3561                &test_path.join("dst"),
3562                &Settings {
3563                    dereference: false,
3564                    fail_early: false,
3565                    overwrite: false,
3566                    overwrite_compare: Default::default(),
3567                    overwrite_filter: None,
3568                    ignore_existing: false,
3569                    chunk_size: 0,
3570                    skip_specials: false,
3571                    remote_copy_buffer_size: 0,
3572                    filter: Some(filter),
3573                    dry_run: None,
3574                    delete: None,
3575                },
3576                &NO_PRESERVE_SETTINGS,
3577                false,
3578            )
3579            .await?;
3580            // directory should NOT be copied because it matches exclude pattern
3581            assert_eq!(
3582                result.directories_created, 0,
3583                "root directory matching exclude should not be created"
3584            );
3585            assert!(
3586                !test_path.join("dst").exists(),
3587                "excluded root directory should not exist at destination"
3588            );
3589            Ok(())
3590        }
3591        /// Test that filters apply to root symlinks with simple exclude patterns.
3592        #[tokio::test]
3593        #[traced_test]
3594        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
3595            let test_path = testutils::create_temp_dir().await?;
3596            // create a target file and a symlink to it
3597            tokio::fs::write(test_path.join("target.txt"), "content").await?;
3598            tokio::fs::symlink(
3599                test_path.join("target.txt"),
3600                test_path.join("excluded_link"),
3601            )
3602            .await?;
3603            // create filter that excludes *_link
3604            let mut filter = FilterSettings::new();
3605            filter.add_exclude("*_link").unwrap();
3606            let result = copy(
3607                &PROGRESS,
3608                &test_path.join("excluded_link"),
3609                &test_path.join("dst"),
3610                &Settings {
3611                    dereference: false,
3612                    fail_early: false,
3613                    overwrite: false,
3614                    overwrite_compare: Default::default(),
3615                    overwrite_filter: None,
3616                    ignore_existing: false,
3617                    chunk_size: 0,
3618                    skip_specials: false,
3619                    remote_copy_buffer_size: 0,
3620                    filter: Some(filter),
3621                    dry_run: None,
3622                    delete: None,
3623                },
3624                &NO_PRESERVE_SETTINGS,
3625                false,
3626            )
3627            .await?;
3628            // symlink should NOT be copied because it matches exclude pattern
3629            assert_eq!(
3630                result.symlinks_created, 0,
3631                "root symlink matching exclude should not be created"
3632            );
3633            assert!(
3634                !test_path.join("dst").exists(),
3635                "excluded root symlink should not exist at destination"
3636            );
3637            Ok(())
3638        }
3639        /// Test combined include and exclude patterns (exclude takes precedence).
3640        #[tokio::test]
3641        #[traced_test]
3642        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
3643            let tmp_dir = testutils::setup_test_dir().await?;
3644            let test_path = tmp_dir.as_path();
3645            // test structure from setup_test_dir:
3646            // foo/
3647            //   0.txt
3648            //   bar/ (1.txt, 2.txt, 3.txt)
3649            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
3650            // include all .txt files, but exclude bar/2.txt specifically
3651            let mut filter = FilterSettings::new();
3652            filter.add_include("**/*.txt").unwrap();
3653            filter.add_exclude("bar/2.txt").unwrap();
3654            let summary = copy(
3655                &PROGRESS,
3656                &test_path.join("foo"),
3657                &test_path.join("dst"),
3658                &Settings {
3659                    dereference: false,
3660                    fail_early: false,
3661                    overwrite: false,
3662                    overwrite_compare: Default::default(),
3663                    overwrite_filter: None,
3664                    ignore_existing: false,
3665                    chunk_size: 0,
3666                    skip_specials: false,
3667                    remote_copy_buffer_size: 0,
3668                    filter: Some(filter),
3669                    dry_run: None,
3670                    delete: None,
3671                },
3672                &NO_PRESERVE_SETTINGS,
3673                false,
3674            )
3675            .await?;
3676            // should copy: 0.txt, bar/1.txt, bar/3.txt, baz/4.txt = 4 files
3677            // should skip: bar/2.txt (excluded by pattern) = 1 file
3678            // symlinks 5.txt and 6.txt don't match *.txt include pattern (symlinks, not files)
3679            assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
3680            assert_eq!(
3681                summary.files_skipped, 1,
3682                "should skip 1 file (bar/2.txt excluded)"
3683            );
3684            // verify specific files
3685            assert!(
3686                test_path.join("dst/bar/1.txt").exists(),
3687                "bar/1.txt should be copied"
3688            );
3689            assert!(
3690                !test_path.join("dst/bar/2.txt").exists(),
3691                "bar/2.txt should be excluded"
3692            );
3693            assert!(
3694                test_path.join("dst/bar/3.txt").exists(),
3695                "bar/3.txt should be copied"
3696            );
3697            Ok(())
3698        }
3699        /// Test that skipped counts accurately reflect what was filtered.
3700        #[tokio::test]
3701        #[traced_test]
3702        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
3703            let tmp_dir = testutils::setup_test_dir().await?;
3704            let test_path = tmp_dir.as_path();
3705            // test structure from setup_test_dir:
3706            // foo/
3707            //   0.txt
3708            //   bar/ (1.txt, 2.txt, 3.txt)
3709            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
3710            // exclude bar/ directory entirely
3711            let mut filter = FilterSettings::new();
3712            filter.add_exclude("bar/").unwrap();
3713            let summary = copy(
3714                &PROGRESS,
3715                &test_path.join("foo"),
3716                &test_path.join("dst"),
3717                &Settings {
3718                    dereference: false,
3719                    fail_early: false,
3720                    overwrite: false,
3721                    overwrite_compare: Default::default(),
3722                    overwrite_filter: None,
3723                    ignore_existing: false,
3724                    chunk_size: 0,
3725                    skip_specials: false,
3726                    remote_copy_buffer_size: 0,
3727                    filter: Some(filter),
3728                    dry_run: None,
3729                    delete: None,
3730                },
3731                &NO_PRESERVE_SETTINGS,
3732                false,
3733            )
3734            .await?;
3735            // copied: 0.txt (1 file), baz/4.txt (1 file), 5.txt symlink, 6.txt symlink
3736            // skipped: bar directory (1 dir) - contents not counted since whole dir skipped
3737            // directories: foo (root), baz = 2
3738            assert_eq!(summary.files_copied, 2, "should copy 2 files");
3739            assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
3740            assert_eq!(
3741                summary.directories_created, 2,
3742                "should create 2 directories"
3743            );
3744            assert_eq!(
3745                summary.directories_skipped, 1,
3746                "should skip 1 directory (bar)"
3747            );
3748            assert_eq!(
3749                summary.files_skipped, 0,
3750                "no files skipped (bar contents not counted)"
3751            );
3752            Ok(())
3753        }
3754        /// Test that empty directories are not created when they were only traversed to look
3755        /// for matches (regression test for bug where --include='foo' would create empty dir baz).
3756        #[tokio::test]
3757        #[traced_test]
3758        async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
3759            let test_path = testutils::create_temp_dir().await?;
3760            // create structure:
3761            // src/
3762            //   foo (file)
3763            //   bar (file)
3764            //   baz/ (empty directory)
3765            let src_path = test_path.join("src");
3766            tokio::fs::create_dir(&src_path).await?;
3767            tokio::fs::write(src_path.join("foo"), "content").await?;
3768            tokio::fs::write(src_path.join("bar"), "content").await?;
3769            tokio::fs::create_dir(src_path.join("baz")).await?;
3770            // include only 'foo' file
3771            let mut filter = FilterSettings::new();
3772            filter.add_include("foo").unwrap();
3773            let summary = copy(
3774                &PROGRESS,
3775                &src_path,
3776                &test_path.join("dst"),
3777                &Settings {
3778                    dereference: false,
3779                    fail_early: false,
3780                    overwrite: false,
3781                    overwrite_compare: Default::default(),
3782                    overwrite_filter: None,
3783                    ignore_existing: false,
3784                    chunk_size: 0,
3785                    skip_specials: false,
3786                    remote_copy_buffer_size: 0,
3787                    filter: Some(filter),
3788                    dry_run: None,
3789                    delete: None,
3790                },
3791                &NO_PRESERVE_SETTINGS,
3792                false,
3793            )
3794            .await?;
3795            // only 'foo' should be copied
3796            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3797            assert_eq!(
3798                summary.directories_created, 1,
3799                "should create only root directory (not empty 'baz')"
3800            );
3801            // verify foo was copied
3802            assert!(
3803                test_path.join("dst").join("foo").exists(),
3804                "foo should be copied"
3805            );
3806            // verify bar was not copied (not matching include pattern)
3807            assert!(
3808                !test_path.join("dst").join("bar").exists(),
3809                "bar should not be copied"
3810            );
3811            // verify empty baz directory was NOT created
3812            assert!(
3813                !test_path.join("dst").join("baz").exists(),
3814                "empty baz directory should NOT be created"
3815            );
3816            Ok(())
3817        }
3818        /// Test that directories with only non-matching content are not created at destination.
3819        /// This is different from empty directories - the source dir has content but none matches.
3820        #[tokio::test]
3821        #[traced_test]
3822        async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
3823            let test_path = testutils::create_temp_dir().await?;
3824            // create structure:
3825            // src/
3826            //   foo (file)
3827            //   baz/
3828            //     qux (file - doesn't match 'foo')
3829            //     quux (file - doesn't match 'foo')
3830            let src_path = test_path.join("src");
3831            tokio::fs::create_dir(&src_path).await?;
3832            tokio::fs::write(src_path.join("foo"), "content").await?;
3833            tokio::fs::create_dir(src_path.join("baz")).await?;
3834            tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
3835            tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
3836            // include only 'foo' file
3837            let mut filter = FilterSettings::new();
3838            filter.add_include("foo").unwrap();
3839            let summary = copy(
3840                &PROGRESS,
3841                &src_path,
3842                &test_path.join("dst"),
3843                &Settings {
3844                    dereference: false,
3845                    fail_early: false,
3846                    overwrite: false,
3847                    overwrite_compare: Default::default(),
3848                    overwrite_filter: None,
3849                    ignore_existing: false,
3850                    chunk_size: 0,
3851                    skip_specials: false,
3852                    remote_copy_buffer_size: 0,
3853                    filter: Some(filter),
3854                    dry_run: None,
3855                    delete: None,
3856                },
3857                &NO_PRESERVE_SETTINGS,
3858                false,
3859            )
3860            .await?;
3861            // only 'foo' should be copied
3862            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3863            assert_eq!(
3864                summary.files_skipped, 2,
3865                "should skip 2 files (qux and quux)"
3866            );
3867            assert_eq!(
3868                summary.directories_created, 1,
3869                "should create only root directory (not 'baz' with non-matching content)"
3870            );
3871            // verify foo was copied
3872            assert!(
3873                test_path.join("dst").join("foo").exists(),
3874                "foo should be copied"
3875            );
3876            // verify baz directory was NOT created (even though source baz has content)
3877            assert!(
3878                !test_path.join("dst").join("baz").exists(),
3879                "baz directory should NOT be created (no matching content inside)"
3880            );
3881            Ok(())
3882        }
3883        /// Test that empty directories are not reported as created in dry-run mode
3884        /// when they were only traversed.
3885        #[tokio::test]
3886        #[traced_test]
3887        async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
3888            let test_path = testutils::create_temp_dir().await?;
3889            // create structure:
3890            // src/
3891            //   foo (file)
3892            //   bar (file)
3893            //   baz/ (empty directory)
3894            let src_path = test_path.join("src");
3895            tokio::fs::create_dir(&src_path).await?;
3896            tokio::fs::write(src_path.join("foo"), "content").await?;
3897            tokio::fs::write(src_path.join("bar"), "content").await?;
3898            tokio::fs::create_dir(src_path.join("baz")).await?;
3899            // include only 'foo' file
3900            let mut filter = FilterSettings::new();
3901            filter.add_include("foo").unwrap();
3902            let summary = copy(
3903                &PROGRESS,
3904                &src_path,
3905                &test_path.join("dst"),
3906                &Settings {
3907                    dereference: false,
3908                    fail_early: false,
3909                    overwrite: false,
3910                    overwrite_compare: Default::default(),
3911                    overwrite_filter: None,
3912                    ignore_existing: false,
3913                    chunk_size: 0,
3914                    skip_specials: false,
3915                    remote_copy_buffer_size: 0,
3916                    filter: Some(filter),
3917                    dry_run: Some(crate::config::DryRunMode::Explain),
3918                    delete: None,
3919                },
3920                &NO_PRESERVE_SETTINGS,
3921                false,
3922            )
3923            .await?;
3924            // only 'foo' should be reported as would-be-copied
3925            assert_eq!(
3926                summary.files_copied, 1,
3927                "should report only 'foo' would be copied"
3928            );
3929            assert_eq!(
3930                summary.directories_created, 1,
3931                "should report only root directory would be created (not empty 'baz')"
3932            );
3933            // verify nothing was actually created (dry-run mode)
3934            assert!(
3935                !test_path.join("dst").exists(),
3936                "dst should not exist in dry-run"
3937            );
3938            Ok(())
3939        }
3940        /// Test that existing directories are NOT removed when using --overwrite,
3941        /// even if nothing is copied into them due to filters.
3942        #[tokio::test]
3943        #[traced_test]
3944        async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
3945            let test_path = testutils::create_temp_dir().await?;
3946            // create source structure:
3947            // src/
3948            //   foo (file)
3949            //   bar (file)
3950            //   baz/ (empty directory)
3951            let src_path = test_path.join("src");
3952            tokio::fs::create_dir(&src_path).await?;
3953            tokio::fs::write(src_path.join("foo"), "content").await?;
3954            tokio::fs::write(src_path.join("bar"), "content").await?;
3955            tokio::fs::create_dir(src_path.join("baz")).await?;
3956            // create destination with baz directory already existing
3957            let dst_path = test_path.join("dst");
3958            tokio::fs::create_dir(&dst_path).await?;
3959            tokio::fs::create_dir(dst_path.join("baz")).await?;
3960            // add a marker file inside dst/baz to verify we don't touch it
3961            tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
3962            // include only 'foo' file - baz should not match
3963            let mut filter = FilterSettings::new();
3964            filter.add_include("foo").unwrap();
3965            let summary = copy(
3966                &PROGRESS,
3967                &src_path,
3968                &dst_path,
3969                &Settings {
3970                    dereference: false,
3971                    fail_early: false,
3972                    overwrite: true, // enable overwrite mode
3973                    overwrite_compare: Default::default(),
3974                    overwrite_filter: None,
3975                    ignore_existing: false,
3976                    chunk_size: 0,
3977                    skip_specials: false,
3978                    remote_copy_buffer_size: 0,
3979                    filter: Some(filter),
3980                    dry_run: None,
3981                    delete: None,
3982                },
3983                &NO_PRESERVE_SETTINGS,
3984                false,
3985            )
3986            .await?;
3987            // foo should be copied
3988            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3989            // dst and baz should be unchanged (both already existed)
3990            assert_eq!(
3991                summary.directories_unchanged, 2,
3992                "root dst and baz directories should be unchanged"
3993            );
3994            assert_eq!(
3995                summary.directories_created, 0,
3996                "should not create any directories"
3997            );
3998            // verify foo was copied
3999            assert!(dst_path.join("foo").exists(), "foo should be copied");
4000            // verify bar was NOT copied
4001            assert!(!dst_path.join("bar").exists(), "bar should not be copied");
4002            // verify existing baz directory still exists with its content
4003            assert!(
4004                dst_path.join("baz").exists(),
4005                "existing baz directory should still exist"
4006            );
4007            assert!(
4008                dst_path.join("baz").join("marker.txt").exists(),
4009                "existing content in baz should still exist"
4010            );
4011            Ok(())
4012        }
4013    }
4014    mod dry_run_tests {
4015        use super::*;
4016        use crate::filter::FilterSettings;
4017        /// Test that dry-run mode for directories doesn't create the destination
4018        /// and doesn't try to set metadata on non-existent directories.
4019        #[tokio::test]
4020        #[traced_test]
4021        async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
4022            let tmp_dir = testutils::setup_test_dir().await?;
4023            let test_path = tmp_dir.as_path();
4024            let dst_path = test_path.join("nonexistent_dst");
4025            // verify destination doesn't exist
4026            assert!(
4027                !dst_path.exists(),
4028                "destination should not exist before dry-run"
4029            );
4030            let summary = copy(
4031                &PROGRESS,
4032                &test_path.join("foo"),
4033                &dst_path,
4034                &Settings {
4035                    dereference: false,
4036                    fail_early: false,
4037                    overwrite: false,
4038                    overwrite_compare: Default::default(),
4039                    overwrite_filter: None,
4040                    ignore_existing: false,
4041                    chunk_size: 0,
4042                    skip_specials: false,
4043                    remote_copy_buffer_size: 0,
4044                    filter: None,
4045                    dry_run: Some(crate::config::DryRunMode::Brief),
4046                    delete: None,
4047                },
4048                &NO_PRESERVE_SETTINGS,
4049                false,
4050            )
4051            .await?;
4052            // verify destination still doesn't exist
4053            assert!(
4054                !dst_path.exists(),
4055                "dry-run should not create destination directory"
4056            );
4057            // verify summary reports what would be created
4058            assert!(
4059                summary.directories_created > 0,
4060                "dry-run should report directories that would be created"
4061            );
4062            assert!(
4063                summary.files_copied > 0,
4064                "dry-run should report files that would be copied"
4065            );
4066            Ok(())
4067        }
4068        /// Test that root directory is always created even when nothing matches
4069        /// the include pattern. The root is the user-specified source — it should
4070        /// never be removed/skipped due to empty-dir cleanup.
4071        #[tokio::test]
4072        #[traced_test]
4073        async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
4074            let test_path = testutils::create_temp_dir().await?;
4075            // create structure:
4076            // src/
4077            //   bar.log (doesn't match *.txt)
4078            //   baz/ (empty directory)
4079            let src_path = test_path.join("src");
4080            tokio::fs::create_dir(&src_path).await?;
4081            tokio::fs::write(src_path.join("bar.log"), "content").await?;
4082            tokio::fs::create_dir(src_path.join("baz")).await?;
4083            // include only *.txt - nothing in source matches
4084            let mut filter = FilterSettings::new();
4085            filter.add_include("*.txt").unwrap();
4086            let dst_path = test_path.join("dst");
4087            let summary = copy(
4088                &PROGRESS,
4089                &src_path,
4090                &dst_path,
4091                &Settings {
4092                    dereference: false,
4093                    fail_early: false,
4094                    overwrite: false,
4095                    overwrite_compare: Default::default(),
4096                    overwrite_filter: None,
4097                    ignore_existing: false,
4098                    chunk_size: 0,
4099                    skip_specials: false,
4100                    remote_copy_buffer_size: 0,
4101                    filter: Some(filter),
4102                    dry_run: None,
4103                    delete: None,
4104                },
4105                &NO_PRESERVE_SETTINGS,
4106                false,
4107            )
4108            .await?;
4109            // no files should be copied
4110            assert_eq!(summary.files_copied, 0, "no files match *.txt");
4111            // root directory should still be created
4112            assert_eq!(
4113                summary.directories_created, 1,
4114                "root directory should always be created"
4115            );
4116            assert!(dst_path.exists(), "root destination directory should exist");
4117            // non-matching subdirectories should not be created
4118            assert!(
4119                !dst_path.join("baz").exists(),
4120                "empty baz should not be created"
4121            );
4122            Ok(())
4123        }
4124        /// Test that root directory is counted in dry-run even when nothing matches.
4125        #[tokio::test]
4126        #[traced_test]
4127        async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
4128        {
4129            let test_path = testutils::create_temp_dir().await?;
4130            let src_path = test_path.join("src");
4131            tokio::fs::create_dir(&src_path).await?;
4132            tokio::fs::write(src_path.join("bar.log"), "content").await?;
4133            // include only *.txt - nothing matches
4134            let mut filter = FilterSettings::new();
4135            filter.add_include("*.txt").unwrap();
4136            let dst_path = test_path.join("dst");
4137            let summary = copy(
4138                &PROGRESS,
4139                &src_path,
4140                &dst_path,
4141                &Settings {
4142                    dereference: false,
4143                    fail_early: false,
4144                    overwrite: false,
4145                    overwrite_compare: Default::default(),
4146                    overwrite_filter: None,
4147                    ignore_existing: false,
4148                    chunk_size: 0,
4149                    skip_specials: false,
4150                    remote_copy_buffer_size: 0,
4151                    filter: Some(filter),
4152                    dry_run: Some(crate::config::DryRunMode::Explain),
4153                    delete: None,
4154                },
4155                &NO_PRESERVE_SETTINGS,
4156                false,
4157            )
4158            .await?;
4159            assert_eq!(summary.files_copied, 0, "no files match *.txt");
4160            assert_eq!(
4161                summary.directories_created, 1,
4162                "root directory should be counted in dry-run"
4163            );
4164            assert!(
4165                !dst_path.exists(),
4166                "nothing should be created in dry-run mode"
4167            );
4168            Ok(())
4169        }
4170    }
4171
4172    /// stress tests exercising max-open-files saturation during copy
4173    mod max_open_files_tests {
4174        use super::*;
4175
4176        /// wide copy: many files with a very low open-files limit.
4177        /// verifies all files are copied correctly under permit saturation.
4178        #[tokio::test]
4179        #[traced_test]
4180        async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
4181            let tmp_dir = testutils::create_temp_dir().await?;
4182            let src = tmp_dir.join("src");
4183            let dst = tmp_dir.join("dst");
4184            tokio::fs::create_dir(&src).await?;
4185            let file_count = 200;
4186            for i in 0..file_count {
4187                tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
4188            }
4189            // set a very low limit to force permit contention
4190            throttle::set_max_open_files(4);
4191            let summary = copy(
4192                &PROGRESS,
4193                &src,
4194                &dst,
4195                &Settings {
4196                    dereference: false,
4197                    fail_early: true,
4198                    overwrite: false,
4199                    overwrite_compare: Default::default(),
4200                    overwrite_filter: None,
4201                    ignore_existing: false,
4202                    chunk_size: 0,
4203                    skip_specials: false,
4204                    remote_copy_buffer_size: 0,
4205                    filter: None,
4206                    dry_run: None,
4207                    delete: None,
4208                },
4209                &NO_PRESERVE_SETTINGS,
4210                false,
4211            )
4212            .await?;
4213            assert_eq!(summary.files_copied, file_count);
4214            assert_eq!(summary.directories_created, 1);
4215            for i in 0..file_count {
4216                let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
4217                assert_eq!(content, format!("content-{}", i));
4218            }
4219            Ok(())
4220        }
4221
4222        /// deep + wide copy: directory tree deeper than the open-files limit, with files
4223        /// at every level. verifies no deadlock occurs (directories don't consume permits).
4224        #[tokio::test]
4225        #[traced_test]
4226        async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
4227            let tmp_dir = testutils::create_temp_dir().await?;
4228            let src = tmp_dir.join("src");
4229            let dst = tmp_dir.join("dst");
4230            let depth = 20;
4231            let files_per_level = 5;
4232            let limit = 4;
4233            // create a directory chain deeper than the permit limit, with files at each level
4234            let mut dir = src.clone();
4235            for level in 0..depth {
4236                tokio::fs::create_dir_all(&dir).await?;
4237                for f in 0..files_per_level {
4238                    tokio::fs::write(
4239                        dir.join(format!("f{}_{}.txt", level, f)),
4240                        format!("L{}F{}", level, f),
4241                    )
4242                    .await?;
4243                }
4244                dir = dir.join(format!("d{}", level));
4245            }
4246            throttle::set_max_open_files(limit);
4247            let summary = tokio::time::timeout(
4248                std::time::Duration::from_secs(30),
4249                copy(
4250                    &PROGRESS,
4251                    &src,
4252                    &dst,
4253                    &Settings {
4254                        dereference: false,
4255                        fail_early: true,
4256                        overwrite: false,
4257                        overwrite_compare: Default::default(),
4258                        overwrite_filter: None,
4259                        ignore_existing: false,
4260                        chunk_size: 0,
4261                        skip_specials: false,
4262                        remote_copy_buffer_size: 0,
4263                        filter: None,
4264                        dry_run: None,
4265                        delete: None,
4266                    },
4267                    &NO_PRESERVE_SETTINGS,
4268                    false,
4269                ),
4270            )
4271            .await
4272            .context("copy timed out — possible deadlock")?
4273            .context("copy failed")?;
4274            assert_eq!(summary.files_copied, depth * files_per_level);
4275            assert_eq!(summary.directories_created, depth);
4276            // spot-check content at a few levels
4277            let mut check_dir = dst.clone();
4278            for level in 0..depth {
4279                let content =
4280                    tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
4281                assert_eq!(content, format!("L{}F0", level));
4282                check_dir = check_dir.join(format!("d{}", level));
4283            }
4284            Ok(())
4285        }
4286
4287        /// Regression: copy_file → rm cross-pool deadlock.
4288        ///
4289        /// Scenario: many parallel copies overwrite destinations that are
4290        /// directories (so each copy_file path takes the
4291        /// "remove existing then copy" branch, and rm recurses). Each copy
4292        /// task holds an open-files permit during copy_file; if rm also
4293        /// drew permits from the open-files pool, a saturated pool would
4294        /// deadlock — every permit held by a copy task waiting for rm to
4295        /// release one. Decoupling rm onto pending-meta avoids that.
4296        #[tokio::test]
4297        #[traced_test]
4298        async fn parallel_overwrite_dir_with_file_no_deadlock() -> Result<(), anyhow::Error> {
4299            let tmp_dir = testutils::create_temp_dir().await?;
4300            let src = tmp_dir.join("src");
4301            let dst = tmp_dir.join("dst");
4302            tokio::fs::create_dir(&src).await?;
4303            tokio::fs::create_dir(&dst).await?;
4304            // 8 sources are regular files; the 8 corresponding destinations
4305            // are directories with nested files — copy with --overwrite
4306            // forces rm of each dst directory tree from inside copy_file.
4307            let n = 8;
4308            for i in 0..n {
4309                tokio::fs::write(src.join(format!("e{}", i)), format!("file-{}", i)).await?;
4310                let dst_subdir = dst.join(format!("e{}", i));
4311                tokio::fs::create_dir(&dst_subdir).await?;
4312                for j in 0..3 {
4313                    tokio::fs::write(
4314                        dst_subdir.join(format!("inner_{}.txt", j)),
4315                        format!("inner-{}-{}", i, j),
4316                    )
4317                    .await?;
4318                }
4319            }
4320            // Saturate the open-files pool: if rm shared this pool, every
4321            // outer copy task would hold its single permit and the inner rm
4322            // recursion would block forever.
4323            throttle::set_max_open_files(2);
4324            let summary = tokio::time::timeout(
4325                std::time::Duration::from_secs(30),
4326                copy(
4327                    &PROGRESS,
4328                    &src,
4329                    &dst,
4330                    &Settings {
4331                        dereference: false,
4332                        fail_early: true,
4333                        overwrite: true,
4334                        overwrite_compare: Default::default(),
4335                        overwrite_filter: None,
4336                        ignore_existing: false,
4337                        chunk_size: 0,
4338                        skip_specials: false,
4339                        remote_copy_buffer_size: 0,
4340                        filter: None,
4341                        dry_run: None,
4342                        delete: None,
4343                    },
4344                    &NO_PRESERVE_SETTINGS,
4345                    false,
4346                ),
4347            )
4348            .await
4349            .context(
4350                "copy timed out — deadlock between copy_file's open-files permit and inner rm",
4351            )?
4352            .context("copy failed")?;
4353            assert_eq!(summary.files_copied, n);
4354            assert_eq!(summary.rm_summary.files_removed, n * 3);
4355            assert_eq!(summary.rm_summary.directories_removed, n);
4356            for i in 0..n {
4357                let path = dst.join(format!("e{}", i));
4358                let content = tokio::fs::read_to_string(&path).await?;
4359                assert_eq!(content, format!("file-{}", i));
4360            }
4361            Ok(())
4362        }
4363    }
4364
4365    mod skip_specials_tests {
4366        use super::*;
4367
4368        #[tokio::test]
4369        #[traced_test]
4370        async fn skip_specials_skips_socket_in_directory() -> Result<(), anyhow::Error> {
4371            let tmp_dir = testutils::setup_test_dir().await?;
4372            let test_path = tmp_dir.as_path();
4373            let src = test_path.join("src_dir");
4374            let dst = test_path.join("dst_dir");
4375            tokio::fs::create_dir(&src).await?;
4376            tokio::fs::write(src.join("file.txt"), "hello").await?;
4377            // create a unix socket inside the source directory
4378            let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
4379            let summary = copy(
4380                &PROGRESS,
4381                &src,
4382                &dst,
4383                &Settings {
4384                    dereference: false,
4385                    fail_early: false,
4386                    overwrite: false,
4387                    overwrite_compare: Default::default(),
4388                    overwrite_filter: None,
4389                    ignore_existing: false,
4390                    chunk_size: 0,
4391                    skip_specials: true,
4392                    remote_copy_buffer_size: 0,
4393                    filter: None,
4394                    dry_run: None,
4395                    delete: None,
4396                },
4397                &NO_PRESERVE_SETTINGS,
4398                false,
4399            )
4400            .await?;
4401            assert_eq!(summary.files_copied, 1);
4402            assert_eq!(summary.specials_skipped, 1);
4403            assert!(dst.join("file.txt").exists());
4404            assert!(!dst.join("test.sock").exists());
4405            Ok(())
4406        }
4407
4408        #[tokio::test]
4409        #[traced_test]
4410        async fn skip_specials_skips_fifo_in_directory() -> Result<(), anyhow::Error> {
4411            let tmp_dir = testutils::setup_test_dir().await?;
4412            let test_path = tmp_dir.as_path();
4413            let src = test_path.join("src_dir");
4414            let dst = test_path.join("dst_dir");
4415            tokio::fs::create_dir(&src).await?;
4416            tokio::fs::write(src.join("file.txt"), "hello").await?;
4417            // create a FIFO inside the source directory
4418            nix::unistd::mkfifo(
4419                &src.join("test.fifo"),
4420                nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
4421            )?;
4422            let summary = copy(
4423                &PROGRESS,
4424                &src,
4425                &dst,
4426                &Settings {
4427                    dereference: false,
4428                    fail_early: false,
4429                    overwrite: false,
4430                    overwrite_compare: Default::default(),
4431                    overwrite_filter: None,
4432                    ignore_existing: false,
4433                    chunk_size: 0,
4434                    skip_specials: true,
4435                    remote_copy_buffer_size: 0,
4436                    filter: None,
4437                    dry_run: None,
4438                    delete: None,
4439                },
4440                &NO_PRESERVE_SETTINGS,
4441                false,
4442            )
4443            .await?;
4444            assert_eq!(summary.files_copied, 1);
4445            assert_eq!(summary.specials_skipped, 1);
4446            assert!(dst.join("file.txt").exists());
4447            assert!(!dst.join("test.fifo").exists());
4448            Ok(())
4449        }
4450
4451        #[tokio::test]
4452        #[traced_test]
4453        async fn special_file_errors_without_skip_specials() -> Result<(), anyhow::Error> {
4454            let tmp_dir = testutils::setup_test_dir().await?;
4455            let test_path = tmp_dir.as_path();
4456            let src = test_path.join("src_dir");
4457            let dst = test_path.join("dst_dir");
4458            tokio::fs::create_dir(&src).await?;
4459            tokio::fs::write(src.join("file.txt"), "hello").await?;
4460            let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
4461            let result = copy(
4462                &PROGRESS,
4463                &src,
4464                &dst,
4465                &Settings {
4466                    dereference: false,
4467                    fail_early: false,
4468                    overwrite: false,
4469                    overwrite_compare: Default::default(),
4470                    overwrite_filter: None,
4471                    ignore_existing: false,
4472                    chunk_size: 0,
4473                    skip_specials: false,
4474                    remote_copy_buffer_size: 0,
4475                    filter: None,
4476                    dry_run: None,
4477                    delete: None,
4478                },
4479                &NO_PRESERVE_SETTINGS,
4480                false,
4481            )
4482            .await;
4483            assert!(result.is_err());
4484            let err = result.unwrap_err();
4485            assert!(
4486                format!("{:#}", err).contains("unsupported src file type"),
4487                "error should mention unsupported file type, got: {:#}",
4488                err
4489            );
4490            Ok(())
4491        }
4492
4493        #[tokio::test]
4494        #[traced_test]
4495        async fn skip_specials_top_level_socket() -> Result<(), anyhow::Error> {
4496            let tmp_dir = testutils::setup_test_dir().await?;
4497            let test_path = tmp_dir.as_path();
4498            let src_socket = test_path.join("test.sock");
4499            let dst = test_path.join("dst.sock");
4500            let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
4501            let summary = copy(
4502                &PROGRESS,
4503                &src_socket,
4504                &dst,
4505                &Settings {
4506                    dereference: false,
4507                    fail_early: false,
4508                    overwrite: false,
4509                    overwrite_compare: Default::default(),
4510                    overwrite_filter: None,
4511                    ignore_existing: false,
4512                    chunk_size: 0,
4513                    skip_specials: true,
4514                    remote_copy_buffer_size: 0,
4515                    filter: None,
4516                    dry_run: None,
4517                    delete: None,
4518                },
4519                &NO_PRESERVE_SETTINGS,
4520                false,
4521            )
4522            .await?;
4523            assert_eq!(summary.specials_skipped, 1);
4524            assert_eq!(summary.files_copied, 0);
4525            assert!(!dst.exists());
4526            Ok(())
4527        }
4528    }
4529
4530    #[tokio::test]
4531    #[traced_test]
4532    async fn delete_protects_excluded_then_removes_with_delete_excluded()
4533    -> Result<(), anyhow::Error> {
4534        let tmp_dir = testutils::setup_test_dir().await?;
4535        let test_path = tmp_dir.as_path();
4536        let src = test_path.join("foo");
4537        let dst = test_path.join("bar");
4538        copy(
4539            &PROGRESS,
4540            &src,
4541            &dst,
4542            &settings_with_delete(None),
4543            &DO_PRESERVE_SETTINGS,
4544            false,
4545        )
4546        .await?;
4547        // a destination-only file that matches an exclude pattern
4548        tokio::fs::write(dst.join("keep.log"), b"protected").await?;
4549
4550        let mut filter = crate::filter::FilterSettings::new();
4551        filter.add_exclude("*.log")?;
4552
4553        // default --delete: keep.log is protected
4554        let mut settings = settings_with_delete(delete_on());
4555        settings.filter = Some(filter.clone());
4556        copy(
4557            &PROGRESS,
4558            &src,
4559            &dst,
4560            &settings,
4561            &DO_PRESERVE_SETTINGS,
4562            false,
4563        )
4564        .await?;
4565        assert!(
4566            dst.join("keep.log").exists(),
4567            "*.log must be protected by default"
4568        );
4569
4570        // --delete-excluded: keep.log is removed
4571        let mut settings = settings_with_delete(Some(DeleteSettings {
4572            delete_excluded: true,
4573        }));
4574        settings.filter = Some(filter);
4575        copy(
4576            &PROGRESS,
4577            &src,
4578            &dst,
4579            &settings,
4580            &DO_PRESERVE_SETTINGS,
4581            false,
4582        )
4583        .await?;
4584        assert!(!dst.join("keep.log").exists());
4585        Ok(())
4586    }
4587
4588    #[tokio::test]
4589    #[traced_test]
4590    async fn delete_dry_run_reports_without_removing() -> Result<(), anyhow::Error> {
4591        let tmp_dir = testutils::setup_test_dir().await?;
4592        let test_path = tmp_dir.as_path();
4593        let src = test_path.join("foo");
4594        let dst = test_path.join("bar");
4595        copy(
4596            &PROGRESS,
4597            &src,
4598            &dst,
4599            &settings_with_delete(None),
4600            &DO_PRESERVE_SETTINGS,
4601            false,
4602        )
4603        .await?;
4604        tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
4605
4606        let mut settings = settings_with_delete(delete_on());
4607        settings.dry_run = Some(crate::config::DryRunMode::Brief);
4608        let summary = copy(
4609            &PROGRESS,
4610            &src,
4611            &dst,
4612            &settings,
4613            &DO_PRESERVE_SETTINGS,
4614            false,
4615        )
4616        .await?;
4617
4618        // the key invariant: dry-run must NOT remove anything
4619        assert!(
4620            dst.join("stale.txt").exists(),
4621            "dry-run must not remove anything"
4622        );
4623        // rm's dry-run does count would-be removals in files_removed
4624        assert_eq!(summary.rm_summary.files_removed, 1);
4625        Ok(())
4626    }
4627
4628    #[tokio::test]
4629    #[traced_test]
4630    async fn delete_does_not_prune_when_source_unreadable() -> Result<(), anyhow::Error> {
4631        let tmp_dir = testutils::setup_test_dir().await?;
4632        let test_path = tmp_dir.as_path();
4633        let src = test_path.join("foo");
4634        let dst = test_path.join("bar");
4635        copy(
4636            &PROGRESS,
4637            &src,
4638            &dst,
4639            &settings_with_delete(None),
4640            &DO_PRESERVE_SETTINGS,
4641            false,
4642        )
4643        .await?;
4644        tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
4645        // make the source directory unreadable so enumeration fails
4646        let original = tokio::fs::metadata(&src).await?.permissions();
4647        tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
4648
4649        let result = copy(
4650            &PROGRESS,
4651            &src,
4652            &dst,
4653            &settings_with_delete(delete_on()),
4654            &DO_PRESERVE_SETTINGS,
4655            false,
4656        )
4657        .await;
4658
4659        // restore permissions before asserting (so the temp dir cleans up)
4660        tokio::fs::set_permissions(&src, original).await?;
4661
4662        assert!(result.is_err(), "unreadable source must error");
4663        assert!(
4664            dst.join("stale.txt").exists(),
4665            "destination must not be pruned when source enumeration fails"
4666        );
4667        Ok(())
4668    }
4669}