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