Skip to main content

common/
copy.rs

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