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