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