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::filecmp;
9use crate::preserve;
10use crate::progress;
11use crate::rm;
12use crate::rm::{Settings as RmSettings, Summary as RmSummary};
13
14/// Error type for copy operations that preserves operation summary even on failure.
15///
16/// # Logging Convention
17/// When logging this error, use `{:#}` or `{:?}` format to preserve the error chain:
18/// ```ignore
19/// tracing::error!("operation failed: {:#}", &error); // ✅ Shows full chain
20/// tracing::error!("operation failed: {:?}", &error); // ✅ Shows full chain
21/// ```
22/// The Display implementation also shows the full chain, but workspace linting enforces `{:#}`
23/// for consistency.
24#[derive(Debug, thiserror::Error)]
25#[error("{source:#}")]
26pub struct Error {
27    #[source]
28    pub source: anyhow::Error,
29    pub summary: Summary,
30}
31
32impl Error {
33    #[must_use]
34    pub fn new(source: anyhow::Error, summary: Summary) -> Self {
35        Error { source, summary }
36    }
37}
38
39#[derive(Debug, Copy, Clone)]
40pub struct Settings {
41    pub dereference: bool,
42    pub fail_early: bool,
43    pub overwrite: bool,
44    pub overwrite_compare: filecmp::MetadataCmpSettings,
45    pub chunk_size: u64,
46    /// Buffer size for remote copy file transfer operations in bytes.
47    ///
48    /// This is only used for remote copy operations and controls the buffer size
49    /// when copying data between files and network streams. The actual buffer is
50    /// capped to the file size to avoid over-allocation for small files.
51    pub remote_copy_buffer_size: usize,
52}
53
54#[instrument]
55pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
56    let ft1 = md1.file_type();
57    let ft2 = md2.file_type();
58    ft1.is_dir() == ft2.is_dir()
59        && ft1.is_file() == ft2.is_file()
60        && ft1.is_symlink() == ft2.is_symlink()
61}
62
63#[instrument(skip(prog_track))]
64pub async fn copy_file(
65    prog_track: &'static progress::Progress,
66    src: &std::path::Path,
67    dst: &std::path::Path,
68    settings: &Settings,
69    preserve: &preserve::Settings,
70    is_fresh: bool,
71) -> Result<Summary, Error> {
72    let _open_file_guard = throttle::open_file_permit().await;
73    tracing::debug!("opening 'src' for reading and 'dst' for writing");
74    let src_metadata = tokio::fs::symlink_metadata(src)
75        .await
76        .with_context(|| format!("failed reading metadata from {:?}", &src))
77        .map_err(|err| Error::new(err, Default::default()))?;
78    get_file_iops_tokens(settings.chunk_size, src_metadata.size()).await;
79    let mut rm_summary = RmSummary::default();
80    if !is_fresh && dst.exists() {
81        if settings.overwrite {
82            tracing::debug!("file exists, check if it's identical");
83            let dst_metadata = tokio::fs::symlink_metadata(dst)
84                .await
85                .with_context(|| format!("failed reading metadata from {:?}", &dst))
86                .map_err(|err| Error::new(err, Default::default()))?;
87            if is_file_type_same(&src_metadata, &dst_metadata)
88                && filecmp::metadata_equal(
89                    &settings.overwrite_compare,
90                    &src_metadata,
91                    &dst_metadata,
92                )
93            {
94                tracing::debug!("file is identical, skipping");
95                prog_track.files_unchanged.inc();
96                return Ok(Summary {
97                    files_unchanged: 1,
98                    ..Default::default()
99                });
100            }
101            tracing::info!("file is different, removing existing file");
102            // note tokio::fs::overwrite cannot handle this path being e.g. a directory
103            rm_summary = rm::rm(
104                prog_track,
105                dst,
106                &RmSettings {
107                    fail_early: settings.fail_early,
108                },
109            )
110            .await
111            .map_err(|err| {
112                let rm_summary = err.summary;
113                let copy_summary = Summary {
114                    rm_summary,
115                    ..Default::default()
116                };
117                Error::new(err.source, copy_summary)
118            })?;
119        } else {
120            return Err(Error::new(
121                anyhow!(
122                    "destination {:?} already exists, did you intend to specify --overwrite?",
123                    dst
124                ),
125                Default::default(),
126            ));
127        }
128    }
129    tracing::debug!("copying data");
130    let mut copy_summary = Summary {
131        rm_summary,
132        ..Default::default()
133    };
134    tokio::fs::copy(src, dst)
135        .await
136        .with_context(|| format!("failed copying {:?} to {:?}", &src, &dst))
137        .map_err(|err| Error::new(err, copy_summary))?;
138    prog_track.files_copied.inc();
139    prog_track.bytes_copied.add(src_metadata.len());
140    tracing::debug!("setting permissions");
141    preserve::set_file_metadata(preserve, &src_metadata, dst)
142        .await
143        .map_err(|err| Error::new(err, copy_summary))?;
144    // we mark files as "copied" only after all metadata is set as well
145    copy_summary.bytes_copied += src_metadata.len();
146    copy_summary.files_copied += 1;
147    Ok(copy_summary)
148}
149
150#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
151pub struct Summary {
152    pub bytes_copied: u64,
153    pub files_copied: usize,
154    pub symlinks_created: usize,
155    pub directories_created: usize,
156    pub files_unchanged: usize,
157    pub symlinks_unchanged: usize,
158    pub directories_unchanged: usize,
159    pub rm_summary: RmSummary,
160}
161
162impl std::ops::Add for Summary {
163    type Output = Self;
164    fn add(self, other: Self) -> Self {
165        Self {
166            bytes_copied: self.bytes_copied + other.bytes_copied,
167            files_copied: self.files_copied + other.files_copied,
168            symlinks_created: self.symlinks_created + other.symlinks_created,
169            directories_created: self.directories_created + other.directories_created,
170            files_unchanged: self.files_unchanged + other.files_unchanged,
171            symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
172            directories_unchanged: self.directories_unchanged + other.directories_unchanged,
173            rm_summary: self.rm_summary + other.rm_summary,
174        }
175    }
176}
177
178impl std::fmt::Display for Summary {
179    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
180        write!(
181            f,
182            "bytes copied: {}\n\
183            files copied: {}\n\
184            symlinks created: {}\n\
185            directories created: {}\n\
186            files unchanged: {}\n\
187            symlinks unchanged: {}\n\
188            directories unchanged: {}\n\
189            {}",
190            bytesize::ByteSize(self.bytes_copied),
191            self.files_copied,
192            self.symlinks_created,
193            self.directories_created,
194            self.files_unchanged,
195            self.symlinks_unchanged,
196            self.directories_unchanged,
197            &self.rm_summary,
198        )
199    }
200}
201
202#[instrument(skip(prog_track))]
203#[async_recursion]
204pub async fn copy(
205    prog_track: &'static progress::Progress,
206    src: &std::path::Path,
207    dst: &std::path::Path,
208    settings: &Settings,
209    preserve: &preserve::Settings,
210    mut is_fresh: bool,
211) -> Result<Summary, Error> {
212    let _ops_guard = prog_track.ops.guard();
213    tracing::debug!("reading source metadata");
214    let src_metadata = tokio::fs::symlink_metadata(src)
215        .await
216        .with_context(|| format!("failed reading metadata from src: {:?}", &src))
217        .map_err(|err| Error::new(err, Default::default()))?;
218    if settings.dereference && src_metadata.is_symlink() {
219        let link = tokio::fs::canonicalize(&src)
220            .await
221            .with_context(|| format!("failed reading src symlink {:?}", &src))
222            .map_err(|err| Error::new(err, Default::default()))?;
223        return copy(prog_track, &link, dst, settings, preserve, is_fresh).await;
224    }
225    if src_metadata.is_file() {
226        return copy_file(prog_track, src, dst, settings, preserve, is_fresh).await;
227    }
228    if src_metadata.is_symlink() {
229        let mut rm_summary = RmSummary::default();
230        let link = tokio::fs::read_link(src)
231            .await
232            .with_context(|| format!("failed reading symlink {:?}", &src))
233            .map_err(|err| Error::new(err, Default::default()))?;
234        // try creating a symlink, if dst path exists and overwrite is set - remove and try again
235        if let Err(error) = tokio::fs::symlink(&link, dst).await {
236            if settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
237                let dst_metadata = tokio::fs::symlink_metadata(dst)
238                    .await
239                    .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
240                    .map_err(|err| Error::new(err, Default::default()))?;
241                if is_file_type_same(&src_metadata, &dst_metadata) {
242                    let dst_link = tokio::fs::read_link(dst)
243                        .await
244                        .with_context(|| format!("failed reading dst symlink: {:?}", &dst))
245                        .map_err(|err| Error::new(err, Default::default()))?;
246                    if link == dst_link {
247                        tracing::debug!(
248                            "'dst' is a symlink and points to the same location as 'src'"
249                        );
250                        if preserve.symlink.any() {
251                            // do we need to update the metadata for this symlink?
252                            let dst_metadata = tokio::fs::symlink_metadata(dst)
253                                .await
254                                .with_context(|| {
255                                    format!("failed reading metadata from dst: {:?}", &dst)
256                                })
257                                .map_err(|err| Error::new(err, Default::default()))?;
258                            if !filecmp::metadata_equal(
259                                &settings.overwrite_compare,
260                                &src_metadata,
261                                &dst_metadata,
262                            ) {
263                                tracing::debug!("'dst' metadata is different, updating");
264                                preserve::set_symlink_metadata(preserve, &src_metadata, dst)
265                                    .await
266                                    .map_err(|err| Error::new(err, Default::default()))?;
267                                prog_track.symlinks_removed.inc();
268                                prog_track.symlinks_created.inc();
269                                return Ok(Summary {
270                                    rm_summary: RmSummary {
271                                        symlinks_removed: 1,
272                                        ..Default::default()
273                                    },
274                                    symlinks_created: 1,
275                                    ..Default::default()
276                                });
277                            }
278                        }
279                        tracing::debug!("symlink already exists, skipping");
280                        prog_track.symlinks_unchanged.inc();
281                        return Ok(Summary {
282                            symlinks_unchanged: 1,
283                            ..Default::default()
284                        });
285                    }
286                    tracing::debug!("'dst' is a symlink but points to a different path, updating");
287                } else {
288                    tracing::info!("'dst' is not a symlink, updating");
289                }
290                rm_summary = rm::rm(
291                    prog_track,
292                    dst,
293                    &RmSettings {
294                        fail_early: settings.fail_early,
295                    },
296                )
297                .await
298                .map_err(|err| {
299                    let rm_summary = err.summary;
300                    let copy_summary = Summary {
301                        rm_summary,
302                        ..Default::default()
303                    };
304                    Error::new(err.source, copy_summary)
305                })?;
306                tokio::fs::symlink(&link, dst)
307                    .await
308                    .with_context(|| format!("failed creating symlink {:?}", &dst))
309                    .map_err(|err| {
310                        let copy_summary = Summary {
311                            rm_summary,
312                            ..Default::default()
313                        };
314                        Error::new(err, copy_summary)
315                    })?;
316            } else {
317                return Err(Error::new(
318                    anyhow!("failed creating symlink {:?}", &dst),
319                    Default::default(),
320                ));
321            }
322        }
323        preserve::set_symlink_metadata(preserve, &src_metadata, dst)
324            .await
325            .map_err(|err| {
326                let copy_summary = Summary {
327                    rm_summary,
328                    ..Default::default()
329                };
330                Error::new(err, copy_summary)
331            })?;
332        prog_track.symlinks_created.inc();
333        return Ok(Summary {
334            rm_summary,
335            symlinks_created: 1,
336            ..Default::default()
337        });
338    }
339    if !src_metadata.is_dir() {
340        return Err(Error::new(
341            anyhow!(
342                "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
343                src,
344                dst,
345                src_metadata.file_type()
346            ),
347            Default::default(),
348        ));
349    }
350    tracing::debug!("process contents of 'src' directory");
351    let mut entries = tokio::fs::read_dir(src)
352        .await
353        .with_context(|| format!("cannot open directory {src:?} for reading"))
354        .map_err(|err| Error::new(err, Default::default()))?;
355    let mut copy_summary = {
356        if let Err(error) = tokio::fs::create_dir(dst).await {
357            assert!(
358                !is_fresh,
359                "unexpected error creating directory: {dst:?}: {error}"
360            );
361            if settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
362                // check if the destination is a directory - if so, leave it
363                //
364                // N.B. the permissions may prevent us from writing to it but the alternative is to open up the directory
365                // while we're writing to it which isn't safe
366                let dst_metadata = tokio::fs::metadata(dst)
367                    .await
368                    .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
369                    .map_err(|err| Error::new(err, Default::default()))?;
370                if dst_metadata.is_dir() {
371                    tracing::debug!("'dst' is a directory, leaving it as is");
372                    prog_track.directories_unchanged.inc();
373                    Summary {
374                        directories_unchanged: 1,
375                        ..Default::default()
376                    }
377                } else {
378                    tracing::info!("'dst' is not a directory, removing and creating a new one");
379                    let rm_summary = rm::rm(
380                        prog_track,
381                        dst,
382                        &RmSettings {
383                            fail_early: settings.fail_early,
384                        },
385                    )
386                    .await
387                    .map_err(|err| {
388                        let rm_summary = err.summary;
389                        let copy_summary = Summary {
390                            rm_summary,
391                            ..Default::default()
392                        };
393                        Error::new(err.source, copy_summary)
394                    })?;
395                    tokio::fs::create_dir(dst)
396                        .await
397                        .with_context(|| format!("cannot create directory {dst:?}"))
398                        .map_err(|err| {
399                            let copy_summary = Summary {
400                                rm_summary,
401                                ..Default::default()
402                            };
403                            Error::new(err, copy_summary)
404                        })?;
405                    // anything copied into dst may assume they don't need to check for conflicts
406                    is_fresh = true;
407                    prog_track.directories_created.inc();
408                    Summary {
409                        rm_summary,
410                        directories_created: 1,
411                        ..Default::default()
412                    }
413                }
414            } else {
415                let error = Err::<(), std::io::Error>(error)
416                    .with_context(|| format!("cannot create directory {:?}", dst))
417                    .unwrap_err();
418                tracing::error!("{:#}", &error);
419                return Err(Error::new(error, Default::default()));
420            }
421        } else {
422            // new directory created, anything copied into dst may assume they don't need to check for conflicts
423            is_fresh = true;
424            prog_track.directories_created.inc();
425            Summary {
426                directories_created: 1,
427                ..Default::default()
428            }
429        }
430    };
431    let mut join_set = tokio::task::JoinSet::new();
432    let mut success = true;
433    while let Some(entry) = entries
434        .next_entry()
435        .await
436        .with_context(|| format!("failed traversing src directory {:?}", &src))
437        .map_err(|err| Error::new(err, copy_summary))?
438    {
439        // it's better to await the token here so that we throttle the syscalls generated by the
440        // DirEntry call. the ops-throttle will never cause a deadlock (unlike max-open-files limit)
441        // so it's safe to do here.
442        throttle::get_ops_token().await;
443        let entry_path = entry.path();
444        let entry_name = entry_path.file_name().unwrap();
445        let dst_path = dst.join(entry_name);
446        let settings = *settings;
447        let preserve = *preserve;
448        let do_copy = || async move {
449            copy(
450                prog_track,
451                &entry_path,
452                &dst_path,
453                &settings,
454                &preserve,
455                is_fresh,
456            )
457            .await
458        };
459        join_set.spawn(do_copy());
460    }
461    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
462    // one thing we CAN do however is to drop it as soon as we're done with it
463    drop(entries);
464    while let Some(res) = join_set.join_next().await {
465        match res {
466            Ok(result) => match result {
467                Ok(summary) => copy_summary = copy_summary + summary,
468                Err(error) => {
469                    tracing::error!("copy: {:?} -> {:?} failed with: {:#}", src, dst, &error);
470                    copy_summary = copy_summary + error.summary;
471                    if settings.fail_early {
472                        return Err(Error::new(error.source, copy_summary));
473                    }
474                    success = false;
475                }
476            },
477            Err(error) => {
478                if settings.fail_early {
479                    return Err(Error::new(error.into(), copy_summary));
480                }
481            }
482        }
483    }
484    if !success {
485        return Err(Error::new(
486            anyhow!("copy: {:?} -> {:?} failed!", src, dst),
487            copy_summary,
488        ))?;
489    }
490    tracing::debug!("set 'dst' directory metadata");
491    preserve::set_dir_metadata(preserve, &src_metadata, dst)
492        .await
493        .map_err(|err| Error::new(err, copy_summary))?;
494    Ok(copy_summary)
495}
496
497#[cfg(test)]
498mod copy_tests {
499    use crate::testutils;
500    use anyhow::Context;
501    use std::os::unix::fs::PermissionsExt;
502    use tracing_test::traced_test;
503
504    use super::*;
505
506    lazy_static! {
507        static ref PROGRESS: progress::Progress = progress::Progress::new();
508        static ref NO_PRESERVE_SETTINGS: preserve::Settings = preserve::preserve_default();
509        static ref DO_PRESERVE_SETTINGS: preserve::Settings = preserve::preserve_all();
510    }
511
512    #[tokio::test]
513    #[traced_test]
514    async fn check_basic_copy() -> Result<(), anyhow::Error> {
515        let tmp_dir = testutils::setup_test_dir().await?;
516        let test_path = tmp_dir.as_path();
517        let summary = copy(
518            &PROGRESS,
519            &test_path.join("foo"),
520            &test_path.join("bar"),
521            &Settings {
522                dereference: false,
523                fail_early: false,
524                overwrite: false,
525                overwrite_compare: filecmp::MetadataCmpSettings {
526                    size: true,
527                    mtime: true,
528                    ..Default::default()
529                },
530                chunk_size: 0,
531                remote_copy_buffer_size: 0,
532            },
533            &NO_PRESERVE_SETTINGS,
534            false,
535        )
536        .await?;
537        assert_eq!(summary.files_copied, 5);
538        assert_eq!(summary.symlinks_created, 2);
539        assert_eq!(summary.directories_created, 3);
540        testutils::check_dirs_identical(
541            &test_path.join("foo"),
542            &test_path.join("bar"),
543            testutils::FileEqualityCheck::Basic,
544        )
545        .await?;
546        Ok(())
547    }
548
549    #[tokio::test]
550    #[traced_test]
551    async fn no_read_permission() -> Result<(), anyhow::Error> {
552        let tmp_dir = testutils::setup_test_dir().await?;
553        let test_path = tmp_dir.as_path();
554        let filepaths = vec![
555            test_path.join("foo").join("0.txt"),
556            test_path.join("foo").join("baz"),
557        ];
558        for fpath in &filepaths {
559            // change file permissions to not readable
560            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
561        }
562        match copy(
563            &PROGRESS,
564            &test_path.join("foo"),
565            &test_path.join("bar"),
566            &Settings {
567                dereference: false,
568                fail_early: false,
569                overwrite: false,
570                overwrite_compare: filecmp::MetadataCmpSettings {
571                    size: true,
572                    mtime: true,
573                    ..Default::default()
574                },
575                chunk_size: 0,
576                remote_copy_buffer_size: 0,
577            },
578            &NO_PRESERVE_SETTINGS,
579            false,
580        )
581        .await
582        {
583            Ok(_) => panic!("Expected the copy to error!"),
584            Err(error) => {
585                tracing::info!("{}", &error);
586                // foo
587                // |- 0.txt  // <- no read permission
588                // |- bar
589                //    |- 1.txt
590                //    |- 2.txt
591                //    |- 3.txt
592                // |- baz   // <- no read permission
593                //    |- 4.txt
594                //    |- 5.txt -> ../bar/2.txt
595                //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
596                assert_eq!(error.summary.files_copied, 3);
597                assert_eq!(error.summary.symlinks_created, 0);
598                assert_eq!(error.summary.directories_created, 2);
599            }
600        }
601        // make source directory same as what we expect destination to be
602        for fpath in &filepaths {
603            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
604            if tokio::fs::symlink_metadata(fpath).await?.is_file() {
605                tokio::fs::remove_file(fpath).await?;
606            } else {
607                tokio::fs::remove_dir_all(fpath).await?;
608            }
609        }
610        testutils::check_dirs_identical(
611            &test_path.join("foo"),
612            &test_path.join("bar"),
613            testutils::FileEqualityCheck::Basic,
614        )
615        .await?;
616        Ok(())
617    }
618
619    #[tokio::test]
620    #[traced_test]
621    async fn check_default_mode() -> Result<(), anyhow::Error> {
622        let tmp_dir = testutils::setup_test_dir().await?;
623        // set file to executable
624        tokio::fs::set_permissions(
625            tmp_dir.join("foo").join("0.txt"),
626            std::fs::Permissions::from_mode(0o700),
627        )
628        .await?;
629        // set file executable AND also set sticky bit, setuid and setgid
630        let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
631        tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
632            .await?;
633        let test_path = tmp_dir.as_path();
634        let summary = copy(
635            &PROGRESS,
636            &test_path.join("foo"),
637            &test_path.join("bar"),
638            &Settings {
639                dereference: false,
640                fail_early: false,
641                overwrite: false,
642                overwrite_compare: filecmp::MetadataCmpSettings {
643                    size: true,
644                    mtime: true,
645                    ..Default::default()
646                },
647                chunk_size: 0,
648                remote_copy_buffer_size: 0,
649            },
650            &NO_PRESERVE_SETTINGS,
651            false,
652        )
653        .await?;
654        assert_eq!(summary.files_copied, 5);
655        assert_eq!(summary.symlinks_created, 2);
656        assert_eq!(summary.directories_created, 3);
657        // clear the setuid, setgid and sticky bit for comparison
658        tokio::fs::set_permissions(
659            &exec_sticky_file,
660            std::fs::Permissions::from_mode(
661                std::fs::symlink_metadata(&exec_sticky_file)?
662                    .permissions()
663                    .mode()
664                    & 0o0777,
665            ),
666        )
667        .await?;
668        testutils::check_dirs_identical(
669            &test_path.join("foo"),
670            &test_path.join("bar"),
671            testutils::FileEqualityCheck::Basic,
672        )
673        .await?;
674        Ok(())
675    }
676
677    #[tokio::test]
678    #[traced_test]
679    async fn no_write_permission() -> Result<(), anyhow::Error> {
680        let tmp_dir = testutils::setup_test_dir().await?;
681        let test_path = tmp_dir.as_path();
682        // directory - readable and non-executable
683        let non_exec_dir = test_path.join("foo").join("bogey");
684        tokio::fs::create_dir(&non_exec_dir).await?;
685        tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
686        // directory - readable and executable
687        tokio::fs::set_permissions(
688            &test_path.join("foo").join("baz"),
689            std::fs::Permissions::from_mode(0o500),
690        )
691        .await?;
692        // file
693        tokio::fs::set_permissions(
694            &test_path.join("foo").join("baz").join("4.txt"),
695            std::fs::Permissions::from_mode(0o440),
696        )
697        .await?;
698        let summary = copy(
699            &PROGRESS,
700            &test_path.join("foo"),
701            &test_path.join("bar"),
702            &Settings {
703                dereference: false,
704                fail_early: false,
705                overwrite: false,
706                overwrite_compare: filecmp::MetadataCmpSettings {
707                    size: true,
708                    mtime: true,
709                    ..Default::default()
710                },
711                chunk_size: 0,
712                remote_copy_buffer_size: 0,
713            },
714            &NO_PRESERVE_SETTINGS,
715            false,
716        )
717        .await?;
718        assert_eq!(summary.files_copied, 5);
719        assert_eq!(summary.symlinks_created, 2);
720        assert_eq!(summary.directories_created, 4);
721        testutils::check_dirs_identical(
722            &test_path.join("foo"),
723            &test_path.join("bar"),
724            testutils::FileEqualityCheck::Basic,
725        )
726        .await?;
727        Ok(())
728    }
729
730    #[tokio::test]
731    #[traced_test]
732    async fn dereference() -> Result<(), anyhow::Error> {
733        let tmp_dir = testutils::setup_test_dir().await?;
734        let test_path = tmp_dir.as_path();
735        // make files pointed to by symlinks have different permissions than the symlink itself
736        let src1 = &test_path.join("foo").join("bar").join("2.txt");
737        let src2 = &test_path.join("foo").join("bar").join("3.txt");
738        let test_mode = 0o440;
739        for f in [src1, src2] {
740            tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
741        }
742        let summary = copy(
743            &PROGRESS,
744            &test_path.join("foo"),
745            &test_path.join("bar"),
746            &Settings {
747                dereference: true, // <- important!
748                fail_early: false,
749                overwrite: false,
750                overwrite_compare: filecmp::MetadataCmpSettings {
751                    size: true,
752                    mtime: true,
753                    ..Default::default()
754                },
755                chunk_size: 0,
756                remote_copy_buffer_size: 0,
757            },
758            &NO_PRESERVE_SETTINGS,
759            false,
760        )
761        .await?;
762        assert_eq!(summary.files_copied, 7);
763        assert_eq!(summary.symlinks_created, 0);
764        assert_eq!(summary.directories_created, 3);
765        // ...
766        // |- baz
767        //    |- 4.txt
768        //    |- 5.txt -> ../bar/2.txt
769        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
770        let dst1 = &test_path.join("bar").join("baz").join("5.txt");
771        let dst2 = &test_path.join("bar").join("baz").join("6.txt");
772        for f in [dst1, dst2] {
773            let metadata = tokio::fs::symlink_metadata(f)
774                .await
775                .with_context(|| format!("failed reading metadata from {:?}", &f))?;
776            assert!(metadata.is_file());
777            // check that the permissions are the same as the source file modulo no sticky bit, setuid and setgid
778            assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
779        }
780        Ok(())
781    }
782
783    async fn cp_compare(
784        cp_args: &[&str],
785        rcp_settings: &Settings,
786        preserve: bool,
787    ) -> Result<(), anyhow::Error> {
788        let tmp_dir = testutils::setup_test_dir().await?;
789        let test_path = tmp_dir.as_path();
790        // run a cp command to copy the files
791        let cp_output = tokio::process::Command::new("cp")
792            .args(cp_args)
793            .arg(test_path.join("foo"))
794            .arg(test_path.join("bar"))
795            .output()
796            .await?;
797        assert!(cp_output.status.success());
798        // now run rcp
799        let summary = copy(
800            &PROGRESS,
801            &test_path.join("foo"),
802            &test_path.join("baz"),
803            rcp_settings,
804            if preserve {
805                &DO_PRESERVE_SETTINGS
806            } else {
807                &NO_PRESERVE_SETTINGS
808            },
809            false,
810        )
811        .await?;
812        if rcp_settings.dereference {
813            assert_eq!(summary.files_copied, 7);
814            assert_eq!(summary.symlinks_created, 0);
815        } else {
816            assert_eq!(summary.files_copied, 5);
817            assert_eq!(summary.symlinks_created, 2);
818        }
819        assert_eq!(summary.directories_created, 3);
820        testutils::check_dirs_identical(
821            &test_path.join("bar"),
822            &test_path.join("baz"),
823            if preserve {
824                testutils::FileEqualityCheck::Timestamp
825            } else {
826                testutils::FileEqualityCheck::Basic
827            },
828        )
829        .await?;
830        Ok(())
831    }
832
833    #[tokio::test]
834    #[traced_test]
835    async fn test_cp_compat() -> Result<(), anyhow::Error> {
836        cp_compare(
837            &["-r"],
838            &Settings {
839                dereference: false,
840                fail_early: false,
841                overwrite: false,
842                overwrite_compare: filecmp::MetadataCmpSettings {
843                    size: true,
844                    mtime: true,
845                    ..Default::default()
846                },
847                chunk_size: 0,
848                remote_copy_buffer_size: 0,
849            },
850            false,
851        )
852        .await?;
853        Ok(())
854    }
855
856    #[tokio::test]
857    #[traced_test]
858    async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
859        cp_compare(
860            &["-r", "-p"],
861            &Settings {
862                dereference: false,
863                fail_early: false,
864                overwrite: false,
865                overwrite_compare: filecmp::MetadataCmpSettings {
866                    size: true,
867                    mtime: true,
868                    ..Default::default()
869                },
870                chunk_size: 0,
871                remote_copy_buffer_size: 0,
872            },
873            true,
874        )
875        .await?;
876        Ok(())
877    }
878
879    #[tokio::test]
880    #[traced_test]
881    async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
882        cp_compare(
883            &["-r", "-L"],
884            &Settings {
885                dereference: true,
886                fail_early: false,
887                overwrite: false,
888                overwrite_compare: filecmp::MetadataCmpSettings {
889                    size: true,
890                    mtime: true,
891                    ..Default::default()
892                },
893                chunk_size: 0,
894                remote_copy_buffer_size: 0,
895            },
896            false,
897        )
898        .await?;
899        Ok(())
900    }
901
902    #[tokio::test]
903    #[traced_test]
904    async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
905        cp_compare(
906            &["-r", "-p", "-L"],
907            &Settings {
908                dereference: true,
909                fail_early: false,
910                overwrite: false,
911                overwrite_compare: filecmp::MetadataCmpSettings {
912                    size: true,
913                    mtime: true,
914                    ..Default::default()
915                },
916                chunk_size: 0,
917                remote_copy_buffer_size: 0,
918            },
919            true,
920        )
921        .await?;
922        Ok(())
923    }
924
925    async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
926        let tmp_dir = testutils::setup_test_dir().await?;
927        let test_path = tmp_dir.as_path();
928        let summary = copy(
929            &PROGRESS,
930            &test_path.join("foo"),
931            &test_path.join("bar"),
932            &Settings {
933                dereference: false,
934                fail_early: false,
935                overwrite: false,
936                overwrite_compare: filecmp::MetadataCmpSettings {
937                    size: true,
938                    mtime: true,
939                    ..Default::default()
940                },
941                chunk_size: 0,
942                remote_copy_buffer_size: 0,
943            },
944            &DO_PRESERVE_SETTINGS,
945            false,
946        )
947        .await?;
948        assert_eq!(summary.files_copied, 5);
949        assert_eq!(summary.symlinks_created, 2);
950        assert_eq!(summary.directories_created, 3);
951        Ok(tmp_dir)
952    }
953
954    #[tokio::test]
955    #[traced_test]
956    async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
957        let tmp_dir = setup_test_dir_and_copy().await?;
958        let output_path = &tmp_dir.join("bar");
959        {
960            // bar
961            // |- 0.txt
962            // |- bar  <---------------------------------------- REMOVE
963            //    |- 1.txt  <----------------------------------- REMOVE
964            //    |- 2.txt  <----------------------------------- REMOVE
965            //    |- 3.txt  <----------------------------------- REMOVE
966            // |- baz
967            //    |- 4.txt
968            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
969            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
970            let summary = rm::rm(
971                &PROGRESS,
972                &output_path.join("bar"),
973                &RmSettings { fail_early: false },
974            )
975            .await?
976                + rm::rm(
977                    &PROGRESS,
978                    &output_path.join("baz").join("5.txt"),
979                    &RmSettings { fail_early: false },
980                )
981                .await?;
982            assert_eq!(summary.files_removed, 3);
983            assert_eq!(summary.symlinks_removed, 1);
984            assert_eq!(summary.directories_removed, 1);
985        }
986        let summary = copy(
987            &PROGRESS,
988            &tmp_dir.join("foo"),
989            output_path,
990            &Settings {
991                dereference: false,
992                fail_early: false,
993                overwrite: true, // <- important!
994                overwrite_compare: filecmp::MetadataCmpSettings {
995                    size: true,
996                    mtime: true,
997                    ..Default::default()
998                },
999                chunk_size: 0,
1000                remote_copy_buffer_size: 0,
1001            },
1002            &DO_PRESERVE_SETTINGS,
1003            false,
1004        )
1005        .await?;
1006        assert_eq!(summary.files_copied, 3);
1007        assert_eq!(summary.symlinks_created, 1);
1008        assert_eq!(summary.directories_created, 1);
1009        testutils::check_dirs_identical(
1010            &tmp_dir.join("foo"),
1011            output_path,
1012            testutils::FileEqualityCheck::Timestamp,
1013        )
1014        .await?;
1015        Ok(())
1016    }
1017
1018    #[tokio::test]
1019    #[traced_test]
1020    async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
1021        let tmp_dir = setup_test_dir_and_copy().await?;
1022        let output_path = &tmp_dir.join("bar");
1023        {
1024            // bar
1025            // |- 0.txt
1026            // |- bar
1027            //    |- 1.txt  <------------------------------------- REMOVE
1028            //    |- 2.txt
1029            //    |- 3.txt
1030            // |- baz  <------------------------------------------ REMOVE
1031            //    |- 4.txt  <------------------------------------- REMOVE
1032            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1033            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt <- REMOVE
1034            let summary = rm::rm(
1035                &PROGRESS,
1036                &output_path.join("bar").join("1.txt"),
1037                &RmSettings { fail_early: false },
1038            )
1039            .await?
1040                + rm::rm(
1041                    &PROGRESS,
1042                    &output_path.join("baz"),
1043                    &RmSettings { fail_early: false },
1044                )
1045                .await?;
1046            assert_eq!(summary.files_removed, 2);
1047            assert_eq!(summary.symlinks_removed, 2);
1048            assert_eq!(summary.directories_removed, 1);
1049        }
1050        {
1051            // replace bar/1.txt file with a directory
1052            tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
1053            // replace baz directory with a file
1054            tokio::fs::write(&output_path.join("baz"), "baz").await?;
1055        }
1056        let summary = copy(
1057            &PROGRESS,
1058            &tmp_dir.join("foo"),
1059            output_path,
1060            &Settings {
1061                dereference: false,
1062                fail_early: false,
1063                overwrite: true, // <- important!
1064                overwrite_compare: filecmp::MetadataCmpSettings {
1065                    size: true,
1066                    mtime: true,
1067                    ..Default::default()
1068                },
1069                chunk_size: 0,
1070                remote_copy_buffer_size: 0,
1071            },
1072            &DO_PRESERVE_SETTINGS,
1073            false,
1074        )
1075        .await?;
1076        assert_eq!(summary.rm_summary.files_removed, 1);
1077        assert_eq!(summary.rm_summary.symlinks_removed, 0);
1078        assert_eq!(summary.rm_summary.directories_removed, 1);
1079        assert_eq!(summary.files_copied, 2);
1080        assert_eq!(summary.symlinks_created, 2);
1081        assert_eq!(summary.directories_created, 1);
1082        testutils::check_dirs_identical(
1083            &tmp_dir.join("foo"),
1084            output_path,
1085            testutils::FileEqualityCheck::Timestamp,
1086        )
1087        .await?;
1088        Ok(())
1089    }
1090
1091    #[tokio::test]
1092    #[traced_test]
1093    async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
1094        let tmp_dir = setup_test_dir_and_copy().await?;
1095        let output_path = &tmp_dir.join("bar");
1096        {
1097            // bar
1098            // |- 0.txt
1099            // |- baz
1100            //    |- 4.txt  <------------------------------------- REMOVE
1101            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1102            // ...
1103            let summary = rm::rm(
1104                &PROGRESS,
1105                &output_path.join("baz").join("4.txt"),
1106                &RmSettings { fail_early: false },
1107            )
1108            .await?
1109                + rm::rm(
1110                    &PROGRESS,
1111                    &output_path.join("baz").join("5.txt"),
1112                    &RmSettings { fail_early: false },
1113                )
1114                .await?;
1115            assert_eq!(summary.files_removed, 1);
1116            assert_eq!(summary.symlinks_removed, 1);
1117            assert_eq!(summary.directories_removed, 0);
1118        }
1119        {
1120            // replace baz/4.txt file with a symlink
1121            tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
1122            // replace baz/5.txt symlink with a file
1123            tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
1124        }
1125        let summary = copy(
1126            &PROGRESS,
1127            &tmp_dir.join("foo"),
1128            output_path,
1129            &Settings {
1130                dereference: false,
1131                fail_early: false,
1132                overwrite: true, // <- important!
1133                overwrite_compare: filecmp::MetadataCmpSettings {
1134                    size: true,
1135                    mtime: true,
1136                    ..Default::default()
1137                },
1138                chunk_size: 0,
1139                remote_copy_buffer_size: 0,
1140            },
1141            &DO_PRESERVE_SETTINGS,
1142            false,
1143        )
1144        .await?;
1145        assert_eq!(summary.rm_summary.files_removed, 1);
1146        assert_eq!(summary.rm_summary.symlinks_removed, 1);
1147        assert_eq!(summary.rm_summary.directories_removed, 0);
1148        assert_eq!(summary.files_copied, 1);
1149        assert_eq!(summary.symlinks_created, 1);
1150        assert_eq!(summary.directories_created, 0);
1151        testutils::check_dirs_identical(
1152            &tmp_dir.join("foo"),
1153            output_path,
1154            testutils::FileEqualityCheck::Timestamp,
1155        )
1156        .await?;
1157        Ok(())
1158    }
1159
1160    #[tokio::test]
1161    #[traced_test]
1162    async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
1163        let tmp_dir = setup_test_dir_and_copy().await?;
1164        let output_path = &tmp_dir.join("bar");
1165        {
1166            // bar
1167            // |- 0.txt
1168            // |- bar  <------------------------------------------ REMOVE
1169            //    |- 1.txt  <------------------------------------- REMOVE
1170            //    |- 2.txt  <------------------------------------- REMOVE
1171            //    |- 3.txt  <------------------------------------- REMOVE
1172            // |- baz
1173            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1174            // ...
1175            let summary = rm::rm(
1176                &PROGRESS,
1177                &output_path.join("bar"),
1178                &RmSettings { fail_early: false },
1179            )
1180            .await?
1181                + rm::rm(
1182                    &PROGRESS,
1183                    &output_path.join("baz").join("5.txt"),
1184                    &RmSettings { fail_early: false },
1185                )
1186                .await?;
1187            assert_eq!(summary.files_removed, 3);
1188            assert_eq!(summary.symlinks_removed, 1);
1189            assert_eq!(summary.directories_removed, 1);
1190        }
1191        {
1192            // replace bar directory with a symlink
1193            tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
1194            // replace baz/5.txt symlink with a directory
1195            tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
1196        }
1197        let summary = copy(
1198            &PROGRESS,
1199            &tmp_dir.join("foo"),
1200            output_path,
1201            &Settings {
1202                dereference: false,
1203                fail_early: false,
1204                overwrite: true, // <- important!
1205                overwrite_compare: filecmp::MetadataCmpSettings {
1206                    size: true,
1207                    mtime: true,
1208                    ..Default::default()
1209                },
1210                chunk_size: 0,
1211                remote_copy_buffer_size: 0,
1212            },
1213            &DO_PRESERVE_SETTINGS,
1214            false,
1215        )
1216        .await?;
1217        assert_eq!(summary.rm_summary.files_removed, 0);
1218        assert_eq!(summary.rm_summary.symlinks_removed, 1);
1219        assert_eq!(summary.rm_summary.directories_removed, 1);
1220        assert_eq!(summary.files_copied, 3);
1221        assert_eq!(summary.symlinks_created, 1);
1222        assert_eq!(summary.directories_created, 1);
1223        assert_eq!(summary.files_unchanged, 2);
1224        assert_eq!(summary.symlinks_unchanged, 1);
1225        assert_eq!(summary.directories_unchanged, 2);
1226        testutils::check_dirs_identical(
1227            &tmp_dir.join("foo"),
1228            output_path,
1229            testutils::FileEqualityCheck::Timestamp,
1230        )
1231        .await?;
1232        Ok(())
1233    }
1234
1235    #[tokio::test]
1236    #[traced_test]
1237    async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
1238        let tmp_dir = testutils::setup_test_dir().await?;
1239        let test_path = tmp_dir.as_path();
1240        let summary = copy(
1241            &PROGRESS,
1242            &test_path.join("foo"),
1243            &test_path.join("bar"),
1244            &Settings {
1245                dereference: false,
1246                fail_early: false,
1247                overwrite: false,
1248                overwrite_compare: filecmp::MetadataCmpSettings {
1249                    size: true,
1250                    mtime: true,
1251                    ..Default::default()
1252                },
1253                chunk_size: 0,
1254                remote_copy_buffer_size: 0,
1255            },
1256            &NO_PRESERVE_SETTINGS, // we want timestamps to differ!
1257            false,
1258        )
1259        .await?;
1260        assert_eq!(summary.files_copied, 5);
1261        assert_eq!(summary.symlinks_created, 2);
1262        assert_eq!(summary.directories_created, 3);
1263        let source_path = &test_path.join("foo");
1264        let output_path = &tmp_dir.join("bar");
1265        // unreadable
1266        tokio::fs::set_permissions(
1267            &source_path.join("bar"),
1268            std::fs::Permissions::from_mode(0o000),
1269        )
1270        .await?;
1271        tokio::fs::set_permissions(
1272            &source_path.join("baz").join("4.txt"),
1273            std::fs::Permissions::from_mode(0o000),
1274        )
1275        .await?;
1276        // bar
1277        // |- 0.txt
1278        // |- bar  <---------------------------------------- NON READABLE
1279        // |- baz
1280        //    |- 4.txt  <----------------------------------- NON READABLE
1281        //    |- 5.txt -> ../bar/2.txt
1282        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1283        match copy(
1284            &PROGRESS,
1285            &tmp_dir.join("foo"),
1286            output_path,
1287            &Settings {
1288                dereference: false,
1289                fail_early: false,
1290                overwrite: true, // <- important!
1291                overwrite_compare: filecmp::MetadataCmpSettings {
1292                    size: true,
1293                    mtime: true,
1294                    ..Default::default()
1295                },
1296                chunk_size: 0,
1297                remote_copy_buffer_size: 0,
1298            },
1299            &DO_PRESERVE_SETTINGS,
1300            false,
1301        )
1302        .await
1303        {
1304            Ok(_) => panic!("Expected the copy to error!"),
1305            Err(error) => {
1306                tracing::info!("{}", &error);
1307                assert_eq!(error.summary.files_copied, 1);
1308                assert_eq!(error.summary.symlinks_created, 2);
1309                assert_eq!(error.summary.directories_created, 0);
1310                assert_eq!(error.summary.rm_summary.files_removed, 2);
1311                assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
1312                assert_eq!(error.summary.rm_summary.directories_removed, 0);
1313            }
1314        }
1315        Ok(())
1316    }
1317
1318    #[tokio::test]
1319    #[traced_test]
1320    async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
1321        // Create a fresh temporary directory to avoid conflicts
1322        let tmp_dir = testutils::create_temp_dir().await?;
1323        let test_path = tmp_dir.as_path();
1324        // Create a chain of symlinks: foo -> bar -> baz (actual file)
1325        let baz_file = test_path.join("baz_file.txt");
1326        tokio::fs::write(&baz_file, "final content").await?;
1327        let bar_link = test_path.join("bar_link");
1328        let foo_link = test_path.join("foo_link");
1329        // Create chain: foo_link -> bar_link -> baz_file.txt
1330        tokio::fs::symlink(&baz_file, &bar_link).await?;
1331        tokio::fs::symlink(&bar_link, &foo_link).await?;
1332        // Create source directory with the symlink chain
1333        let src_dir = test_path.join("src_chain");
1334        tokio::fs::create_dir(&src_dir).await?;
1335        // Copy the chain into the source directory
1336        tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
1337        tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
1338        tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
1339        // Test with dereference - should copy 3 files with same content
1340        let summary = copy(
1341            &PROGRESS,
1342            &src_dir,
1343            &test_path.join("dst_with_deref"),
1344            &Settings {
1345                dereference: true, // <- important!
1346                fail_early: false,
1347                overwrite: false,
1348                overwrite_compare: filecmp::MetadataCmpSettings {
1349                    size: true,
1350                    mtime: true,
1351                    ..Default::default()
1352                },
1353                chunk_size: 0,
1354                remote_copy_buffer_size: 0,
1355            },
1356            &NO_PRESERVE_SETTINGS,
1357            false,
1358        )
1359        .await?;
1360        assert_eq!(summary.files_copied, 3); // foo, bar, baz all copied as files
1361        assert_eq!(summary.symlinks_created, 0); // dereference is set
1362        assert_eq!(summary.directories_created, 1);
1363        let dst_dir = test_path.join("dst_with_deref");
1364        // Verify all three are now regular files with the same content
1365        let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
1366        let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
1367        let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
1368        assert_eq!(foo_content, "final content");
1369        assert_eq!(bar_content, "final content");
1370        assert_eq!(baz_content, "final content");
1371        // Verify they are all regular files, not symlinks
1372        assert!(dst_dir.join("foo").is_file());
1373        assert!(dst_dir.join("bar").is_file());
1374        assert!(dst_dir.join("baz").is_file());
1375        assert!(!dst_dir.join("foo").is_symlink());
1376        assert!(!dst_dir.join("bar").is_symlink());
1377        assert!(!dst_dir.join("baz").is_symlink());
1378        Ok(())
1379    }
1380
1381    #[tokio::test]
1382    #[traced_test]
1383    async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
1384        let tmp_dir = testutils::create_temp_dir().await?;
1385        let test_path = tmp_dir.as_path();
1386        // Create a directory with specific permissions and content
1387        let target_dir = test_path.join("target_dir");
1388        tokio::fs::create_dir(&target_dir).await?;
1389        tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
1390        // Add some files to the directory
1391        tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
1392        tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
1393        tokio::fs::set_permissions(
1394            &target_dir.join("file1.txt"),
1395            std::fs::Permissions::from_mode(0o644),
1396        )
1397        .await?;
1398        tokio::fs::set_permissions(
1399            &target_dir.join("file2.txt"),
1400            std::fs::Permissions::from_mode(0o600),
1401        )
1402        .await?;
1403        // Create a symlink pointing to the directory
1404        let dir_symlink = test_path.join("dir_symlink");
1405        tokio::fs::symlink(&target_dir, &dir_symlink).await?;
1406        // Test copying the symlink with dereference - should copy as a directory
1407        let summary = copy(
1408            &PROGRESS,
1409            &dir_symlink,
1410            &test_path.join("copied_dir"),
1411            &Settings {
1412                dereference: true, // <- important!
1413                fail_early: false,
1414                overwrite: false,
1415                overwrite_compare: filecmp::MetadataCmpSettings {
1416                    size: true,
1417                    mtime: true,
1418                    ..Default::default()
1419                },
1420                chunk_size: 0,
1421                remote_copy_buffer_size: 0,
1422            },
1423            &DO_PRESERVE_SETTINGS,
1424            false,
1425        )
1426        .await?;
1427        assert_eq!(summary.files_copied, 2); // file1.txt, file2.txt
1428        assert_eq!(summary.symlinks_created, 0); // dereference is set
1429        assert_eq!(summary.directories_created, 1); // copied_dir
1430        let copied_dir = test_path.join("copied_dir");
1431        // Verify the directory and its contents were copied
1432        assert!(copied_dir.is_dir());
1433        assert!(!copied_dir.is_symlink()); // Should be a real directory, not a symlink
1434                                           // Verify files were copied with correct content
1435        let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
1436        let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
1437        assert_eq!(file1_content, "content1");
1438        assert_eq!(file2_content, "content2");
1439        // Verify permissions were preserved
1440        let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
1441        let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
1442        let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
1443        assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
1444        assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
1445        assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
1446        Ok(())
1447    }
1448
1449    #[tokio::test]
1450    #[traced_test]
1451    async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
1452        let tmp_dir = testutils::create_temp_dir().await?;
1453        let test_path = tmp_dir.as_path();
1454        // Create files with specific permissions
1455        let file1 = test_path.join("file1.txt");
1456        let file2 = test_path.join("file2.txt");
1457        tokio::fs::write(&file1, "content1").await?;
1458        tokio::fs::write(&file2, "content2").await?;
1459        tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
1460        tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
1461        // Create symlinks pointing to these files
1462        let symlink1 = test_path.join("symlink1");
1463        let symlink2 = test_path.join("symlink2");
1464        tokio::fs::symlink(&file1, &symlink1).await?;
1465        tokio::fs::symlink(&file2, &symlink2).await?;
1466        // Test copying symlinks with dereference and preserve
1467        let summary1 = copy(
1468            &PROGRESS,
1469            &symlink1,
1470            &test_path.join("copied_file1.txt"),
1471            &Settings {
1472                dereference: true, // <- important!
1473                fail_early: false,
1474                overwrite: false,
1475                overwrite_compare: filecmp::MetadataCmpSettings::default(),
1476                chunk_size: 0,
1477                remote_copy_buffer_size: 0,
1478            },
1479            &DO_PRESERVE_SETTINGS, // <- important!
1480            false,
1481        )
1482        .await?;
1483        let summary2 = copy(
1484            &PROGRESS,
1485            &symlink2,
1486            &test_path.join("copied_file2.txt"),
1487            &Settings {
1488                dereference: true,
1489                fail_early: false,
1490                overwrite: false,
1491                overwrite_compare: filecmp::MetadataCmpSettings::default(),
1492                chunk_size: 0,
1493                remote_copy_buffer_size: 0,
1494            },
1495            &DO_PRESERVE_SETTINGS,
1496            false,
1497        )
1498        .await?;
1499        assert_eq!(summary1.files_copied, 1);
1500        assert_eq!(summary1.symlinks_created, 0);
1501        assert_eq!(summary2.files_copied, 1);
1502        assert_eq!(summary2.symlinks_created, 0);
1503        let copied1 = test_path.join("copied_file1.txt");
1504        let copied2 = test_path.join("copied_file2.txt");
1505        // Verify files are regular files, not symlinks
1506        assert!(copied1.is_file());
1507        assert!(!copied1.is_symlink());
1508        assert!(copied2.is_file());
1509        assert!(!copied2.is_symlink());
1510        // Verify content was copied correctly
1511        let content1 = tokio::fs::read_to_string(&copied1).await?;
1512        let content2 = tokio::fs::read_to_string(&copied2).await?;
1513        assert_eq!(content1, "content1");
1514        assert_eq!(content2, "content2");
1515        // Verify permissions from the target files were preserved (not symlink permissions)
1516        let copied1_metadata = tokio::fs::metadata(&copied1).await?;
1517        let copied2_metadata = tokio::fs::metadata(&copied2).await?;
1518        assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
1519        assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
1520        Ok(())
1521    }
1522
1523    #[tokio::test]
1524    #[traced_test]
1525    async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
1526        let tmp_dir = testutils::setup_test_dir().await?;
1527        // symlink bar to bar-link
1528        tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
1529        // symlink bar-link to bar-link-link
1530        tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
1531        let summary = copy(
1532            &PROGRESS,
1533            &tmp_dir.join("foo"),
1534            &tmp_dir.join("bar"),
1535            &Settings {
1536                dereference: true, // <- important!
1537                fail_early: false,
1538                overwrite: false,
1539                overwrite_compare: filecmp::MetadataCmpSettings {
1540                    size: true,
1541                    mtime: true,
1542                    ..Default::default()
1543                },
1544                chunk_size: 0,
1545                remote_copy_buffer_size: 0,
1546            },
1547            &DO_PRESERVE_SETTINGS,
1548            false,
1549        )
1550        .await?;
1551        assert_eq!(summary.files_copied, 13); // 0.txt, 3x bar/(1.txt, 2.txt, 3.txt), baz/(4.txt, 5.txt, 6.txt)
1552        assert_eq!(summary.symlinks_created, 0); // dereference is set
1553        assert_eq!(summary.directories_created, 5);
1554        // check_dirs_identical doesn't handle dereference so let's do it manually
1555        tokio::process::Command::new("cp")
1556            .args(["-r", "-L"])
1557            .arg(tmp_dir.join("foo"))
1558            .arg(tmp_dir.join("bar-cp"))
1559            .output()
1560            .await?;
1561        testutils::check_dirs_identical(
1562            &tmp_dir.join("bar"),
1563            &tmp_dir.join("bar-cp"),
1564            testutils::FileEqualityCheck::Basic,
1565        )
1566        .await?;
1567        Ok(())
1568    }
1569
1570    /// Tests to verify error messages include root causes for debugging
1571    mod error_message_tests {
1572        use super::*;
1573
1574        /// Helper to extract full error message with chain
1575        fn get_full_error_message(error: &Error) -> String {
1576            format!("{:#}", error.source)
1577        }
1578
1579        #[tokio::test]
1580        #[traced_test]
1581        async fn test_permission_error_includes_root_cause() -> Result<(), anyhow::Error> {
1582            let tmp_dir = testutils::create_temp_dir().await?;
1583            let unreadable = tmp_dir.join("unreadable.txt");
1584            tokio::fs::write(&unreadable, "test").await?;
1585            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
1586
1587            let result = copy_file(
1588                &PROGRESS,
1589                &unreadable,
1590                &tmp_dir.join("dest.txt"),
1591                &Settings {
1592                    dereference: false,
1593                    fail_early: false,
1594                    overwrite: false,
1595                    overwrite_compare: Default::default(),
1596                    chunk_size: 0,
1597                    remote_copy_buffer_size: 0,
1598                },
1599                &NO_PRESERVE_SETTINGS,
1600                false,
1601            )
1602            .await;
1603
1604            assert!(result.is_err(), "Should fail with permission error");
1605            let err_msg = get_full_error_message(&result.unwrap_err());
1606
1607            // The error message MUST include the root cause
1608            assert!(
1609                err_msg.to_lowercase().contains("permission")
1610                    || err_msg.contains("EACCES")
1611                    || err_msg.contains("denied"),
1612                "Error message must include permission-related text. Got: {}",
1613                err_msg
1614            );
1615            Ok(())
1616        }
1617
1618        #[tokio::test]
1619        #[traced_test]
1620        async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
1621            let tmp_dir = testutils::create_temp_dir().await?;
1622
1623            let result = copy_file(
1624                &PROGRESS,
1625                &tmp_dir.join("does_not_exist.txt"),
1626                &tmp_dir.join("dest.txt"),
1627                &Settings {
1628                    dereference: false,
1629                    fail_early: false,
1630                    overwrite: false,
1631                    overwrite_compare: Default::default(),
1632                    chunk_size: 0,
1633                    remote_copy_buffer_size: 0,
1634                },
1635                &NO_PRESERVE_SETTINGS,
1636                false,
1637            )
1638            .await;
1639
1640            assert!(result.is_err());
1641            let err_msg = get_full_error_message(&result.unwrap_err());
1642
1643            assert!(
1644                err_msg.to_lowercase().contains("no such file")
1645                    || err_msg.to_lowercase().contains("not found")
1646                    || err_msg.contains("ENOENT"),
1647                "Error message must include file not found text. Got: {}",
1648                err_msg
1649            );
1650            Ok(())
1651        }
1652
1653        #[tokio::test]
1654        #[traced_test]
1655        async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
1656            let tmp_dir = testutils::create_temp_dir().await?;
1657            let unreadable_dir = tmp_dir.join("unreadable_dir");
1658            tokio::fs::create_dir(&unreadable_dir).await?;
1659            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
1660                .await?;
1661
1662            let result = copy(
1663                &PROGRESS,
1664                &unreadable_dir,
1665                &tmp_dir.join("dest"),
1666                &Settings {
1667                    dereference: false,
1668                    fail_early: true,
1669                    overwrite: false,
1670                    overwrite_compare: Default::default(),
1671                    chunk_size: 0,
1672                    remote_copy_buffer_size: 0,
1673                },
1674                &NO_PRESERVE_SETTINGS,
1675                false,
1676            )
1677            .await;
1678
1679            assert!(result.is_err());
1680            let err_msg = get_full_error_message(&result.unwrap_err());
1681
1682            assert!(
1683                err_msg.to_lowercase().contains("permission")
1684                    || err_msg.contains("EACCES")
1685                    || err_msg.contains("denied"),
1686                "Error message must include permission-related text. Got: {}",
1687                err_msg
1688            );
1689
1690            // Clean up - restore permissions so cleanup can remove it
1691            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
1692                .await?;
1693            Ok(())
1694        }
1695
1696        #[tokio::test]
1697        #[traced_test]
1698        async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
1699        {
1700            let tmp_dir = testutils::setup_test_dir().await?;
1701            let test_path = tmp_dir.as_path();
1702            let readonly_parent = test_path.join("readonly_dest");
1703            tokio::fs::create_dir(&readonly_parent).await?;
1704            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
1705                .await?;
1706
1707            let result = copy(
1708                &PROGRESS,
1709                &test_path.join("foo"),
1710                &readonly_parent.join("copy"),
1711                &Settings {
1712                    dereference: false,
1713                    fail_early: true,
1714                    overwrite: false,
1715                    overwrite_compare: Default::default(),
1716                    chunk_size: 0,
1717                    remote_copy_buffer_size: 0,
1718                },
1719                &NO_PRESERVE_SETTINGS,
1720                false,
1721            )
1722            .await;
1723
1724            // restore permissions so cleanup succeeds even when copy fails
1725            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
1726                .await?;
1727
1728            assert!(result.is_err(), "copy into read-only parent should fail");
1729            let err_msg = get_full_error_message(&result.unwrap_err());
1730
1731            assert!(
1732                err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
1733                "Error message must include permission denied text. Got: {}",
1734                err_msg
1735            );
1736            Ok(())
1737        }
1738    }
1739}