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!(
342                !is_fresh,
343                "unexpected error creating directory: {dst:?}: {error}"
344            );
345            if settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
346                // check if the destination is a directory - if so, leave it
347                //
348                // N.B. the permissions may prevent us from writing to it but the alternative is to open up the directory
349                // while we're writing to it which isn't safe
350                let dst_metadata = tokio::fs::metadata(dst)
351                    .await
352                    .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
353                    .map_err(|err| Error::new(err, Default::default()))?;
354                if dst_metadata.is_dir() {
355                    tracing::debug!("'dst' is a directory, leaving it as is");
356                    prog_track.directories_unchanged.inc();
357                    Summary {
358                        directories_unchanged: 1,
359                        ..Default::default()
360                    }
361                } else {
362                    tracing::info!("'dst' is not a directory, removing and creating a new one");
363                    let rm_summary = rm::rm(
364                        prog_track,
365                        dst,
366                        &RmSettings {
367                            fail_early: settings.fail_early,
368                        },
369                    )
370                    .await
371                    .map_err(|err| {
372                        let rm_summary = err.summary;
373                        let copy_summary = Summary {
374                            rm_summary,
375                            ..Default::default()
376                        };
377                        Error::new(err.source, copy_summary)
378                    })?;
379                    tokio::fs::create_dir(dst)
380                        .await
381                        .with_context(|| format!("cannot create directory {dst:?}"))
382                        .map_err(|err| {
383                            let copy_summary = Summary {
384                                rm_summary,
385                                ..Default::default()
386                            };
387                            Error::new(anyhow::Error::msg(err), copy_summary)
388                        })?;
389                    // anything copied into dst may assume they don't need to check for conflicts
390                    is_fresh = true;
391                    prog_track.directories_created.inc();
392                    Summary {
393                        rm_summary,
394                        directories_created: 1,
395                        ..Default::default()
396                    }
397                }
398            } else {
399                tracing::error!("{:?}", &error);
400                return Err(Error::new(
401                    anyhow!("cannot create directory {:?}", dst),
402                    Default::default(),
403                ));
404            }
405        } else {
406            // new directory created, anything copied into dst may assume they don't need to check for conflicts
407            is_fresh = true;
408            prog_track.directories_created.inc();
409            Summary {
410                directories_created: 1,
411                ..Default::default()
412            }
413        }
414    };
415    let mut join_set = tokio::task::JoinSet::new();
416    let mut success = true;
417    while let Some(entry) = entries
418        .next_entry()
419        .await
420        .with_context(|| format!("failed traversing src directory {:?}", &src))
421        .map_err(|err| Error::new(err, copy_summary))?
422    {
423        // it's better to await the token here so that we throttle the syscalls generated by the
424        // DirEntry call. the ops-throttle will never cause a deadlock (unlike max-open-files limit)
425        // so it's safe to do here.
426        throttle::get_ops_token().await;
427        let entry_path = entry.path();
428        let entry_name = entry_path.file_name().unwrap();
429        let dst_path = dst.join(entry_name);
430        let settings = *settings;
431        let preserve = *preserve;
432        let do_copy = || async move {
433            copy(
434                prog_track,
435                &entry_path,
436                &dst_path,
437                &settings,
438                &preserve,
439                is_fresh,
440            )
441            .await
442        };
443        join_set.spawn(do_copy());
444    }
445    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
446    // one thing we CAN do however is to drop it as soon as we're done with it
447    drop(entries);
448    while let Some(res) = join_set.join_next().await {
449        match res {
450            Ok(result) => match result {
451                Ok(summary) => copy_summary = copy_summary + summary,
452                Err(error) => {
453                    tracing::error!("copy: {:?} -> {:?} failed with: {}", src, dst, &error);
454                    copy_summary = copy_summary + error.summary;
455                    if settings.fail_early {
456                        return Err(Error::new(error.source, copy_summary));
457                    }
458                    success = false;
459                }
460            },
461            Err(error) => {
462                if settings.fail_early {
463                    return Err(Error::new(anyhow::Error::msg(error), copy_summary));
464                }
465            }
466        }
467    }
468    if !success {
469        return Err(Error::new(
470            anyhow!("copy: {:?} -> {:?} failed!", src, dst),
471            copy_summary,
472        ))?;
473    }
474    tracing::debug!("set 'dst' directory metadata");
475    preserve::set_dir_metadata(preserve, &src_metadata, dst)
476        .await
477        .map_err(|err| Error::new(err, copy_summary))?;
478    Ok(copy_summary)
479}
480
481#[cfg(test)]
482mod copy_tests {
483    use crate::testutils;
484    use anyhow::Context;
485    use std::os::unix::fs::PermissionsExt;
486    use tracing_test::traced_test;
487
488    use super::*;
489
490    lazy_static! {
491        static ref PROGRESS: progress::Progress = progress::Progress::new();
492        static ref NO_PRESERVE_SETTINGS: preserve::Settings = preserve::preserve_default();
493        static ref DO_PRESERVE_SETTINGS: preserve::Settings = preserve::preserve_all();
494    }
495
496    #[tokio::test]
497    #[traced_test]
498    async fn check_basic_copy() -> Result<(), anyhow::Error> {
499        let tmp_dir = testutils::setup_test_dir().await?;
500        let test_path = tmp_dir.as_path();
501        let summary = copy(
502            &PROGRESS,
503            &test_path.join("foo"),
504            &test_path.join("bar"),
505            &Settings {
506                dereference: false,
507                fail_early: false,
508                overwrite: false,
509                overwrite_compare: filecmp::MetadataCmpSettings {
510                    size: true,
511                    mtime: true,
512                    ..Default::default()
513                },
514                chunk_size: 0,
515            },
516            &NO_PRESERVE_SETTINGS,
517            false,
518        )
519        .await?;
520        assert_eq!(summary.files_copied, 5);
521        assert_eq!(summary.symlinks_created, 2);
522        assert_eq!(summary.directories_created, 3);
523        testutils::check_dirs_identical(
524            &test_path.join("foo"),
525            &test_path.join("bar"),
526            testutils::FileEqualityCheck::Basic,
527        )
528        .await?;
529        Ok(())
530    }
531
532    #[tokio::test]
533    #[traced_test]
534    async fn no_read_permission() -> Result<(), anyhow::Error> {
535        let tmp_dir = testutils::setup_test_dir().await?;
536        let test_path = tmp_dir.as_path();
537        let filepaths = vec![
538            test_path.join("foo").join("0.txt"),
539            test_path.join("foo").join("baz"),
540        ];
541        for fpath in &filepaths {
542            // change file permissions to not readable
543            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
544        }
545        match copy(
546            &PROGRESS,
547            &test_path.join("foo"),
548            &test_path.join("bar"),
549            &Settings {
550                dereference: false,
551                fail_early: false,
552                overwrite: false,
553                overwrite_compare: filecmp::MetadataCmpSettings {
554                    size: true,
555                    mtime: true,
556                    ..Default::default()
557                },
558                chunk_size: 0,
559            },
560            &NO_PRESERVE_SETTINGS,
561            false,
562        )
563        .await
564        {
565            Ok(_) => panic!("Expected the copy to error!"),
566            Err(error) => {
567                tracing::info!("{}", &error);
568                // foo
569                // |- 0.txt  // <- no read permission
570                // |- bar
571                //    |- 1.txt
572                //    |- 2.txt
573                //    |- 3.txt
574                // |- baz   // <- no read permission
575                //    |- 4.txt
576                //    |- 5.txt -> ../bar/2.txt
577                //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
578                assert_eq!(error.summary.files_copied, 3);
579                assert_eq!(error.summary.symlinks_created, 0);
580                assert_eq!(error.summary.directories_created, 2);
581            }
582        }
583        // make source directory same as what we expect destination to be
584        for fpath in &filepaths {
585            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
586            if tokio::fs::symlink_metadata(fpath).await?.is_file() {
587                tokio::fs::remove_file(fpath).await?;
588            } else {
589                tokio::fs::remove_dir_all(fpath).await?;
590            }
591        }
592        testutils::check_dirs_identical(
593            &test_path.join("foo"),
594            &test_path.join("bar"),
595            testutils::FileEqualityCheck::Basic,
596        )
597        .await?;
598        Ok(())
599    }
600
601    #[tokio::test]
602    #[traced_test]
603    async fn check_default_mode() -> Result<(), anyhow::Error> {
604        let tmp_dir = testutils::setup_test_dir().await?;
605        // set file to executable
606        tokio::fs::set_permissions(
607            tmp_dir.join("foo").join("0.txt"),
608            std::fs::Permissions::from_mode(0o700),
609        )
610        .await?;
611        // set file executable AND also set sticky bit, setuid and setgid
612        let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
613        tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
614            .await?;
615        let test_path = tmp_dir.as_path();
616        let summary = copy(
617            &PROGRESS,
618            &test_path.join("foo"),
619            &test_path.join("bar"),
620            &Settings {
621                dereference: false,
622                fail_early: false,
623                overwrite: false,
624                overwrite_compare: filecmp::MetadataCmpSettings {
625                    size: true,
626                    mtime: true,
627                    ..Default::default()
628                },
629                chunk_size: 0,
630            },
631            &NO_PRESERVE_SETTINGS,
632            false,
633        )
634        .await?;
635        assert_eq!(summary.files_copied, 5);
636        assert_eq!(summary.symlinks_created, 2);
637        assert_eq!(summary.directories_created, 3);
638        // clear the setuid, setgid and sticky bit for comparison
639        tokio::fs::set_permissions(
640            &exec_sticky_file,
641            std::fs::Permissions::from_mode(
642                std::fs::symlink_metadata(&exec_sticky_file)?
643                    .permissions()
644                    .mode()
645                    & 0o0777,
646            ),
647        )
648        .await?;
649        testutils::check_dirs_identical(
650            &test_path.join("foo"),
651            &test_path.join("bar"),
652            testutils::FileEqualityCheck::Basic,
653        )
654        .await?;
655        Ok(())
656    }
657
658    #[tokio::test]
659    #[traced_test]
660    async fn no_write_permission() -> Result<(), anyhow::Error> {
661        let tmp_dir = testutils::setup_test_dir().await?;
662        let test_path = tmp_dir.as_path();
663        // directory - readable and non-executable
664        let non_exec_dir = test_path.join("foo").join("bogey");
665        tokio::fs::create_dir(&non_exec_dir).await?;
666        tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
667        // directory - readable and executable
668        tokio::fs::set_permissions(
669            &test_path.join("foo").join("baz"),
670            std::fs::Permissions::from_mode(0o500),
671        )
672        .await?;
673        // file
674        tokio::fs::set_permissions(
675            &test_path.join("foo").join("baz").join("4.txt"),
676            std::fs::Permissions::from_mode(0o440),
677        )
678        .await?;
679        let summary = copy(
680            &PROGRESS,
681            &test_path.join("foo"),
682            &test_path.join("bar"),
683            &Settings {
684                dereference: false,
685                fail_early: false,
686                overwrite: false,
687                overwrite_compare: filecmp::MetadataCmpSettings {
688                    size: true,
689                    mtime: true,
690                    ..Default::default()
691                },
692                chunk_size: 0,
693            },
694            &NO_PRESERVE_SETTINGS,
695            false,
696        )
697        .await?;
698        assert_eq!(summary.files_copied, 5);
699        assert_eq!(summary.symlinks_created, 2);
700        assert_eq!(summary.directories_created, 4);
701        testutils::check_dirs_identical(
702            &test_path.join("foo"),
703            &test_path.join("bar"),
704            testutils::FileEqualityCheck::Basic,
705        )
706        .await?;
707        Ok(())
708    }
709
710    #[tokio::test]
711    #[traced_test]
712    async fn dereference() -> Result<(), anyhow::Error> {
713        let tmp_dir = testutils::setup_test_dir().await?;
714        let test_path = tmp_dir.as_path();
715        // make files pointed to by symlinks have different permissions than the symlink itself
716        let src1 = &test_path.join("foo").join("bar").join("2.txt");
717        let src2 = &test_path.join("foo").join("bar").join("3.txt");
718        let test_mode = 0o440;
719        for f in [src1, src2] {
720            tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
721        }
722        let summary = copy(
723            &PROGRESS,
724            &test_path.join("foo"),
725            &test_path.join("bar"),
726            &Settings {
727                dereference: true, // <- important!
728                fail_early: false,
729                overwrite: false,
730                overwrite_compare: filecmp::MetadataCmpSettings {
731                    size: true,
732                    mtime: true,
733                    ..Default::default()
734                },
735                chunk_size: 0,
736            },
737            &NO_PRESERVE_SETTINGS,
738            false,
739        )
740        .await?;
741        assert_eq!(summary.files_copied, 7);
742        assert_eq!(summary.symlinks_created, 0);
743        assert_eq!(summary.directories_created, 3);
744        // ...
745        // |- baz
746        //    |- 4.txt
747        //    |- 5.txt -> ../bar/2.txt
748        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
749        let dst1 = &test_path.join("bar").join("baz").join("5.txt");
750        let dst2 = &test_path.join("bar").join("baz").join("6.txt");
751        for f in [dst1, dst2] {
752            let metadata = tokio::fs::symlink_metadata(f)
753                .await
754                .with_context(|| format!("failed reading metadata from {:?}", &f))?;
755            assert!(metadata.is_file());
756            // check that the permissions are the same as the source file modulo no sticky bit, setuid and setgid
757            assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
758        }
759        Ok(())
760    }
761
762    async fn cp_compare(
763        cp_args: &[&str],
764        rcp_settings: &Settings,
765        preserve: bool,
766    ) -> Result<(), anyhow::Error> {
767        let tmp_dir = testutils::setup_test_dir().await?;
768        let test_path = tmp_dir.as_path();
769        // run a cp command to copy the files
770        let cp_output = tokio::process::Command::new("cp")
771            .args(cp_args)
772            .arg(test_path.join("foo"))
773            .arg(test_path.join("bar"))
774            .output()
775            .await?;
776        assert!(cp_output.status.success());
777        // now run rcp
778        let summary = copy(
779            &PROGRESS,
780            &test_path.join("foo"),
781            &test_path.join("baz"),
782            rcp_settings,
783            if preserve {
784                &DO_PRESERVE_SETTINGS
785            } else {
786                &NO_PRESERVE_SETTINGS
787            },
788            false,
789        )
790        .await?;
791        if rcp_settings.dereference {
792            assert_eq!(summary.files_copied, 7);
793            assert_eq!(summary.symlinks_created, 0);
794        } else {
795            assert_eq!(summary.files_copied, 5);
796            assert_eq!(summary.symlinks_created, 2);
797        }
798        assert_eq!(summary.directories_created, 3);
799        testutils::check_dirs_identical(
800            &test_path.join("bar"),
801            &test_path.join("baz"),
802            if preserve {
803                testutils::FileEqualityCheck::Timestamp
804            } else {
805                testutils::FileEqualityCheck::Basic
806            },
807        )
808        .await?;
809        Ok(())
810    }
811
812    #[tokio::test]
813    #[traced_test]
814    async fn test_cp_compat() -> Result<(), anyhow::Error> {
815        cp_compare(
816            &["-r"],
817            &Settings {
818                dereference: false,
819                fail_early: false,
820                overwrite: false,
821                overwrite_compare: filecmp::MetadataCmpSettings {
822                    size: true,
823                    mtime: true,
824                    ..Default::default()
825                },
826                chunk_size: 0,
827            },
828            false,
829        )
830        .await?;
831        Ok(())
832    }
833
834    #[tokio::test]
835    #[traced_test]
836    async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
837        cp_compare(
838            &["-r", "-p"],
839            &Settings {
840                dereference: false,
841                fail_early: false,
842                overwrite: false,
843                overwrite_compare: filecmp::MetadataCmpSettings {
844                    size: true,
845                    mtime: true,
846                    ..Default::default()
847                },
848                chunk_size: 0,
849            },
850            true,
851        )
852        .await?;
853        Ok(())
854    }
855
856    #[tokio::test]
857    #[traced_test]
858    async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
859        cp_compare(
860            &["-r", "-L"],
861            &Settings {
862                dereference: true,
863                fail_early: false,
864                overwrite: false,
865                overwrite_compare: filecmp::MetadataCmpSettings {
866                    size: true,
867                    mtime: true,
868                    ..Default::default()
869                },
870                chunk_size: 0,
871            },
872            false,
873        )
874        .await?;
875        Ok(())
876    }
877
878    #[tokio::test]
879    #[traced_test]
880    async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
881        cp_compare(
882            &["-r", "-p", "-L"],
883            &Settings {
884                dereference: true,
885                fail_early: false,
886                overwrite: false,
887                overwrite_compare: filecmp::MetadataCmpSettings {
888                    size: true,
889                    mtime: true,
890                    ..Default::default()
891                },
892                chunk_size: 0,
893            },
894            true,
895        )
896        .await?;
897        Ok(())
898    }
899
900    async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
901        let tmp_dir = testutils::setup_test_dir().await?;
902        let test_path = tmp_dir.as_path();
903        let summary = copy(
904            &PROGRESS,
905            &test_path.join("foo"),
906            &test_path.join("bar"),
907            &Settings {
908                dereference: false,
909                fail_early: false,
910                overwrite: false,
911                overwrite_compare: filecmp::MetadataCmpSettings {
912                    size: true,
913                    mtime: true,
914                    ..Default::default()
915                },
916                chunk_size: 0,
917            },
918            &DO_PRESERVE_SETTINGS,
919            false,
920        )
921        .await?;
922        assert_eq!(summary.files_copied, 5);
923        assert_eq!(summary.symlinks_created, 2);
924        assert_eq!(summary.directories_created, 3);
925        Ok(tmp_dir)
926    }
927
928    #[tokio::test]
929    #[traced_test]
930    async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
931        let tmp_dir = setup_test_dir_and_copy().await?;
932        let output_path = &tmp_dir.join("bar");
933        {
934            // bar
935            // |- 0.txt
936            // |- bar  <---------------------------------------- REMOVE
937            //    |- 1.txt  <----------------------------------- REMOVE
938            //    |- 2.txt  <----------------------------------- REMOVE
939            //    |- 3.txt  <----------------------------------- REMOVE
940            // |- baz
941            //    |- 4.txt
942            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
943            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
944            let summary = rm::rm(
945                &PROGRESS,
946                &output_path.join("bar"),
947                &RmSettings { fail_early: false },
948            )
949            .await?
950                + rm::rm(
951                    &PROGRESS,
952                    &output_path.join("baz").join("5.txt"),
953                    &RmSettings { fail_early: false },
954                )
955                .await?;
956            assert_eq!(summary.files_removed, 3);
957            assert_eq!(summary.symlinks_removed, 1);
958            assert_eq!(summary.directories_removed, 1);
959        }
960        let summary = copy(
961            &PROGRESS,
962            &tmp_dir.join("foo"),
963            output_path,
964            &Settings {
965                dereference: false,
966                fail_early: false,
967                overwrite: true, // <- important!
968                overwrite_compare: filecmp::MetadataCmpSettings {
969                    size: true,
970                    mtime: true,
971                    ..Default::default()
972                },
973                chunk_size: 0,
974            },
975            &DO_PRESERVE_SETTINGS,
976            false,
977        )
978        .await?;
979        assert_eq!(summary.files_copied, 3);
980        assert_eq!(summary.symlinks_created, 1);
981        assert_eq!(summary.directories_created, 1);
982        testutils::check_dirs_identical(
983            &tmp_dir.join("foo"),
984            output_path,
985            testutils::FileEqualityCheck::Timestamp,
986        )
987        .await?;
988        Ok(())
989    }
990
991    #[tokio::test]
992    #[traced_test]
993    async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
994        let tmp_dir = setup_test_dir_and_copy().await?;
995        let output_path = &tmp_dir.join("bar");
996        {
997            // bar
998            // |- 0.txt
999            // |- bar
1000            //    |- 1.txt  <------------------------------------- REMOVE
1001            //    |- 2.txt
1002            //    |- 3.txt
1003            // |- baz  <------------------------------------------ REMOVE
1004            //    |- 4.txt  <------------------------------------- REMOVE
1005            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1006            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt <- REMOVE
1007            let summary = rm::rm(
1008                &PROGRESS,
1009                &output_path.join("bar").join("1.txt"),
1010                &RmSettings { fail_early: false },
1011            )
1012            .await?
1013                + rm::rm(
1014                    &PROGRESS,
1015                    &output_path.join("baz"),
1016                    &RmSettings { fail_early: false },
1017                )
1018                .await?;
1019            assert_eq!(summary.files_removed, 2);
1020            assert_eq!(summary.symlinks_removed, 2);
1021            assert_eq!(summary.directories_removed, 1);
1022        }
1023        {
1024            // replace bar/1.txt file with a directory
1025            tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
1026            // replace baz directory with a file
1027            tokio::fs::write(&output_path.join("baz"), "baz").await?;
1028        }
1029        let summary = copy(
1030            &PROGRESS,
1031            &tmp_dir.join("foo"),
1032            output_path,
1033            &Settings {
1034                dereference: false,
1035                fail_early: false,
1036                overwrite: true, // <- important!
1037                overwrite_compare: filecmp::MetadataCmpSettings {
1038                    size: true,
1039                    mtime: true,
1040                    ..Default::default()
1041                },
1042                chunk_size: 0,
1043            },
1044            &DO_PRESERVE_SETTINGS,
1045            false,
1046        )
1047        .await?;
1048        assert_eq!(summary.rm_summary.files_removed, 1);
1049        assert_eq!(summary.rm_summary.symlinks_removed, 0);
1050        assert_eq!(summary.rm_summary.directories_removed, 1);
1051        assert_eq!(summary.files_copied, 2);
1052        assert_eq!(summary.symlinks_created, 2);
1053        assert_eq!(summary.directories_created, 1);
1054        testutils::check_dirs_identical(
1055            &tmp_dir.join("foo"),
1056            output_path,
1057            testutils::FileEqualityCheck::Timestamp,
1058        )
1059        .await?;
1060        Ok(())
1061    }
1062
1063    #[tokio::test]
1064    #[traced_test]
1065    async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
1066        let tmp_dir = setup_test_dir_and_copy().await?;
1067        let output_path = &tmp_dir.join("bar");
1068        {
1069            // bar
1070            // |- 0.txt
1071            // |- baz
1072            //    |- 4.txt  <------------------------------------- REMOVE
1073            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1074            // ...
1075            let summary = rm::rm(
1076                &PROGRESS,
1077                &output_path.join("baz").join("4.txt"),
1078                &RmSettings { fail_early: false },
1079            )
1080            .await?
1081                + rm::rm(
1082                    &PROGRESS,
1083                    &output_path.join("baz").join("5.txt"),
1084                    &RmSettings { fail_early: false },
1085                )
1086                .await?;
1087            assert_eq!(summary.files_removed, 1);
1088            assert_eq!(summary.symlinks_removed, 1);
1089            assert_eq!(summary.directories_removed, 0);
1090        }
1091        {
1092            // replace baz/4.txt file with a symlink
1093            tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
1094            // replace baz/5.txt symlink with a file
1095            tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
1096        }
1097        let summary = copy(
1098            &PROGRESS,
1099            &tmp_dir.join("foo"),
1100            output_path,
1101            &Settings {
1102                dereference: false,
1103                fail_early: false,
1104                overwrite: true, // <- important!
1105                overwrite_compare: filecmp::MetadataCmpSettings {
1106                    size: true,
1107                    mtime: true,
1108                    ..Default::default()
1109                },
1110                chunk_size: 0,
1111            },
1112            &DO_PRESERVE_SETTINGS,
1113            false,
1114        )
1115        .await?;
1116        assert_eq!(summary.rm_summary.files_removed, 1);
1117        assert_eq!(summary.rm_summary.symlinks_removed, 1);
1118        assert_eq!(summary.rm_summary.directories_removed, 0);
1119        assert_eq!(summary.files_copied, 1);
1120        assert_eq!(summary.symlinks_created, 1);
1121        assert_eq!(summary.directories_created, 0);
1122        testutils::check_dirs_identical(
1123            &tmp_dir.join("foo"),
1124            output_path,
1125            testutils::FileEqualityCheck::Timestamp,
1126        )
1127        .await?;
1128        Ok(())
1129    }
1130
1131    #[tokio::test]
1132    #[traced_test]
1133    async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
1134        let tmp_dir = setup_test_dir_and_copy().await?;
1135        let output_path = &tmp_dir.join("bar");
1136        {
1137            // bar
1138            // |- 0.txt
1139            // |- bar  <------------------------------------------ REMOVE
1140            //    |- 1.txt  <------------------------------------- REMOVE
1141            //    |- 2.txt  <------------------------------------- REMOVE
1142            //    |- 3.txt  <------------------------------------- REMOVE
1143            // |- baz
1144            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1145            // ...
1146            let summary = rm::rm(
1147                &PROGRESS,
1148                &output_path.join("bar"),
1149                &RmSettings { fail_early: false },
1150            )
1151            .await?
1152                + rm::rm(
1153                    &PROGRESS,
1154                    &output_path.join("baz").join("5.txt"),
1155                    &RmSettings { fail_early: false },
1156                )
1157                .await?;
1158            assert_eq!(summary.files_removed, 3);
1159            assert_eq!(summary.symlinks_removed, 1);
1160            assert_eq!(summary.directories_removed, 1);
1161        }
1162        {
1163            // replace bar directory with a symlink
1164            tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
1165            // replace baz/5.txt symlink with a directory
1166            tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
1167        }
1168        let summary = copy(
1169            &PROGRESS,
1170            &tmp_dir.join("foo"),
1171            output_path,
1172            &Settings {
1173                dereference: false,
1174                fail_early: false,
1175                overwrite: true, // <- important!
1176                overwrite_compare: filecmp::MetadataCmpSettings {
1177                    size: true,
1178                    mtime: true,
1179                    ..Default::default()
1180                },
1181                chunk_size: 0,
1182            },
1183            &DO_PRESERVE_SETTINGS,
1184            false,
1185        )
1186        .await?;
1187        assert_eq!(summary.rm_summary.files_removed, 0);
1188        assert_eq!(summary.rm_summary.symlinks_removed, 1);
1189        assert_eq!(summary.rm_summary.directories_removed, 1);
1190        assert_eq!(summary.files_copied, 3);
1191        assert_eq!(summary.symlinks_created, 1);
1192        assert_eq!(summary.directories_created, 1);
1193        assert_eq!(summary.files_unchanged, 2);
1194        assert_eq!(summary.symlinks_unchanged, 1);
1195        assert_eq!(summary.directories_unchanged, 2);
1196        testutils::check_dirs_identical(
1197            &tmp_dir.join("foo"),
1198            output_path,
1199            testutils::FileEqualityCheck::Timestamp,
1200        )
1201        .await?;
1202        Ok(())
1203    }
1204
1205    #[tokio::test]
1206    #[traced_test]
1207    async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
1208        let tmp_dir = testutils::setup_test_dir().await?;
1209        let test_path = tmp_dir.as_path();
1210        let summary = copy(
1211            &PROGRESS,
1212            &test_path.join("foo"),
1213            &test_path.join("bar"),
1214            &Settings {
1215                dereference: false,
1216                fail_early: false,
1217                overwrite: false,
1218                overwrite_compare: filecmp::MetadataCmpSettings {
1219                    size: true,
1220                    mtime: true,
1221                    ..Default::default()
1222                },
1223                chunk_size: 0,
1224            },
1225            &NO_PRESERVE_SETTINGS, // we want timestamps to differ!
1226            false,
1227        )
1228        .await?;
1229        assert_eq!(summary.files_copied, 5);
1230        assert_eq!(summary.symlinks_created, 2);
1231        assert_eq!(summary.directories_created, 3);
1232        let source_path = &test_path.join("foo");
1233        let output_path = &tmp_dir.join("bar");
1234        // unreadable
1235        tokio::fs::set_permissions(
1236            &source_path.join("bar"),
1237            std::fs::Permissions::from_mode(0o000),
1238        )
1239        .await?;
1240        tokio::fs::set_permissions(
1241            &source_path.join("baz").join("4.txt"),
1242            std::fs::Permissions::from_mode(0o000),
1243        )
1244        .await?;
1245        // bar
1246        // |- 0.txt
1247        // |- bar  <---------------------------------------- NON READABLE
1248        // |- baz
1249        //    |- 4.txt  <----------------------------------- NON READABLE
1250        //    |- 5.txt -> ../bar/2.txt
1251        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1252        match copy(
1253            &PROGRESS,
1254            &tmp_dir.join("foo"),
1255            output_path,
1256            &Settings {
1257                dereference: false,
1258                fail_early: false,
1259                overwrite: true, // <- important!
1260                overwrite_compare: filecmp::MetadataCmpSettings {
1261                    size: true,
1262                    mtime: true,
1263                    ..Default::default()
1264                },
1265                chunk_size: 0,
1266            },
1267            &DO_PRESERVE_SETTINGS,
1268            false,
1269        )
1270        .await
1271        {
1272            Ok(_) => panic!("Expected the copy to error!"),
1273            Err(error) => {
1274                tracing::info!("{}", &error);
1275                assert_eq!(error.summary.files_copied, 1);
1276                assert_eq!(error.summary.symlinks_created, 2);
1277                assert_eq!(error.summary.directories_created, 0);
1278                assert_eq!(error.summary.rm_summary.files_removed, 2);
1279                assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
1280                assert_eq!(error.summary.rm_summary.directories_removed, 0);
1281            }
1282        }
1283        Ok(())
1284    }
1285
1286    #[tokio::test]
1287    #[traced_test]
1288    async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
1289        // Create a fresh temporary directory to avoid conflicts
1290        let tmp_dir = testutils::create_temp_dir().await?;
1291        let test_path = tmp_dir.as_path();
1292        // Create a chain of symlinks: foo -> bar -> baz (actual file)
1293        let baz_file = test_path.join("baz_file.txt");
1294        tokio::fs::write(&baz_file, "final content").await?;
1295        let bar_link = test_path.join("bar_link");
1296        let foo_link = test_path.join("foo_link");
1297        // Create chain: foo_link -> bar_link -> baz_file.txt
1298        tokio::fs::symlink(&baz_file, &bar_link).await?;
1299        tokio::fs::symlink(&bar_link, &foo_link).await?;
1300        // Create source directory with the symlink chain
1301        let src_dir = test_path.join("src_chain");
1302        tokio::fs::create_dir(&src_dir).await?;
1303        // Copy the chain into the source directory
1304        tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
1305        tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
1306        tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
1307        // Test with dereference - should copy 3 files with same content
1308        let summary = copy(
1309            &PROGRESS,
1310            &src_dir,
1311            &test_path.join("dst_with_deref"),
1312            &Settings {
1313                dereference: true, // <- important!
1314                fail_early: false,
1315                overwrite: false,
1316                overwrite_compare: filecmp::MetadataCmpSettings {
1317                    size: true,
1318                    mtime: true,
1319                    ..Default::default()
1320                },
1321                chunk_size: 0,
1322            },
1323            &NO_PRESERVE_SETTINGS,
1324            false,
1325        )
1326        .await?;
1327        assert_eq!(summary.files_copied, 3); // foo, bar, baz all copied as files
1328        assert_eq!(summary.symlinks_created, 0); // dereference is set
1329        assert_eq!(summary.directories_created, 1);
1330        let dst_dir = test_path.join("dst_with_deref");
1331        // Verify all three are now regular files with the same content
1332        let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
1333        let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
1334        let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
1335        assert_eq!(foo_content, "final content");
1336        assert_eq!(bar_content, "final content");
1337        assert_eq!(baz_content, "final content");
1338        // Verify they are all regular files, not symlinks
1339        assert!(dst_dir.join("foo").is_file());
1340        assert!(dst_dir.join("bar").is_file());
1341        assert!(dst_dir.join("baz").is_file());
1342        assert!(!dst_dir.join("foo").is_symlink());
1343        assert!(!dst_dir.join("bar").is_symlink());
1344        assert!(!dst_dir.join("baz").is_symlink());
1345        Ok(())
1346    }
1347
1348    #[tokio::test]
1349    #[traced_test]
1350    async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
1351        let tmp_dir = testutils::create_temp_dir().await?;
1352        let test_path = tmp_dir.as_path();
1353        // Create a directory with specific permissions and content
1354        let target_dir = test_path.join("target_dir");
1355        tokio::fs::create_dir(&target_dir).await?;
1356        tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
1357        // Add some files to the directory
1358        tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
1359        tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
1360        tokio::fs::set_permissions(
1361            &target_dir.join("file1.txt"),
1362            std::fs::Permissions::from_mode(0o644),
1363        )
1364        .await?;
1365        tokio::fs::set_permissions(
1366            &target_dir.join("file2.txt"),
1367            std::fs::Permissions::from_mode(0o600),
1368        )
1369        .await?;
1370        // Create a symlink pointing to the directory
1371        let dir_symlink = test_path.join("dir_symlink");
1372        tokio::fs::symlink(&target_dir, &dir_symlink).await?;
1373        // Test copying the symlink with dereference - should copy as a directory
1374        let summary = copy(
1375            &PROGRESS,
1376            &dir_symlink,
1377            &test_path.join("copied_dir"),
1378            &Settings {
1379                dereference: true, // <- important!
1380                fail_early: false,
1381                overwrite: false,
1382                overwrite_compare: filecmp::MetadataCmpSettings {
1383                    size: true,
1384                    mtime: true,
1385                    ..Default::default()
1386                },
1387                chunk_size: 0,
1388            },
1389            &DO_PRESERVE_SETTINGS,
1390            false,
1391        )
1392        .await?;
1393        assert_eq!(summary.files_copied, 2); // file1.txt, file2.txt
1394        assert_eq!(summary.symlinks_created, 0); // dereference is set
1395        assert_eq!(summary.directories_created, 1); // copied_dir
1396        let copied_dir = test_path.join("copied_dir");
1397        // Verify the directory and its contents were copied
1398        assert!(copied_dir.is_dir());
1399        assert!(!copied_dir.is_symlink()); // Should be a real directory, not a symlink
1400                                           // Verify files were copied with correct content
1401        let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
1402        let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
1403        assert_eq!(file1_content, "content1");
1404        assert_eq!(file2_content, "content2");
1405        // Verify permissions were preserved
1406        let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
1407        let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
1408        let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
1409        assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
1410        assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
1411        assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
1412        Ok(())
1413    }
1414
1415    #[tokio::test]
1416    #[traced_test]
1417    async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
1418        let tmp_dir = testutils::create_temp_dir().await?;
1419        let test_path = tmp_dir.as_path();
1420        // Create files with specific permissions
1421        let file1 = test_path.join("file1.txt");
1422        let file2 = test_path.join("file2.txt");
1423        tokio::fs::write(&file1, "content1").await?;
1424        tokio::fs::write(&file2, "content2").await?;
1425        tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
1426        tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
1427        // Create symlinks pointing to these files
1428        let symlink1 = test_path.join("symlink1");
1429        let symlink2 = test_path.join("symlink2");
1430        tokio::fs::symlink(&file1, &symlink1).await?;
1431        tokio::fs::symlink(&file2, &symlink2).await?;
1432        // Test copying symlinks with dereference and preserve
1433        let summary1 = copy(
1434            &PROGRESS,
1435            &symlink1,
1436            &test_path.join("copied_file1.txt"),
1437            &Settings {
1438                dereference: true, // <- important!
1439                fail_early: false,
1440                overwrite: false,
1441                overwrite_compare: filecmp::MetadataCmpSettings::default(),
1442                chunk_size: 0,
1443            },
1444            &DO_PRESERVE_SETTINGS, // <- important!
1445            false,
1446        )
1447        .await?;
1448        let summary2 = copy(
1449            &PROGRESS,
1450            &symlink2,
1451            &test_path.join("copied_file2.txt"),
1452            &Settings {
1453                dereference: true,
1454                fail_early: false,
1455                overwrite: false,
1456                overwrite_compare: filecmp::MetadataCmpSettings::default(),
1457                chunk_size: 0,
1458            },
1459            &DO_PRESERVE_SETTINGS,
1460            false,
1461        )
1462        .await?;
1463        assert_eq!(summary1.files_copied, 1);
1464        assert_eq!(summary1.symlinks_created, 0);
1465        assert_eq!(summary2.files_copied, 1);
1466        assert_eq!(summary2.symlinks_created, 0);
1467        let copied1 = test_path.join("copied_file1.txt");
1468        let copied2 = test_path.join("copied_file2.txt");
1469        // Verify files are regular files, not symlinks
1470        assert!(copied1.is_file());
1471        assert!(!copied1.is_symlink());
1472        assert!(copied2.is_file());
1473        assert!(!copied2.is_symlink());
1474        // Verify content was copied correctly
1475        let content1 = tokio::fs::read_to_string(&copied1).await?;
1476        let content2 = tokio::fs::read_to_string(&copied2).await?;
1477        assert_eq!(content1, "content1");
1478        assert_eq!(content2, "content2");
1479        // Verify permissions from the target files were preserved (not symlink permissions)
1480        let copied1_metadata = tokio::fs::metadata(&copied1).await?;
1481        let copied2_metadata = tokio::fs::metadata(&copied2).await?;
1482        assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
1483        assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
1484        Ok(())
1485    }
1486
1487    #[tokio::test]
1488    #[traced_test]
1489    async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
1490        let tmp_dir = testutils::setup_test_dir().await?;
1491        // symlink bar to bar-link
1492        tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
1493        // symlink bar-link to bar-link-link
1494        tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
1495        let summary = copy(
1496            &PROGRESS,
1497            &tmp_dir.join("foo"),
1498            &tmp_dir.join("bar"),
1499            &Settings {
1500                dereference: true, // <- important!
1501                fail_early: false,
1502                overwrite: false,
1503                overwrite_compare: filecmp::MetadataCmpSettings {
1504                    size: true,
1505                    mtime: true,
1506                    ..Default::default()
1507                },
1508                chunk_size: 0,
1509            },
1510            &DO_PRESERVE_SETTINGS,
1511            false,
1512        )
1513        .await?;
1514        assert_eq!(summary.files_copied, 13); // 0.txt, 3x bar/(1.txt, 2.txt, 3.txt), baz/(4.txt, 5.txt, 6.txt)
1515        assert_eq!(summary.symlinks_created, 0); // dereference is set
1516        assert_eq!(summary.directories_created, 5);
1517        // check_dirs_identical doesn't handle dereference so let's do it manually
1518        tokio::process::Command::new("cp")
1519            .args(["-r", "-L"])
1520            .arg(tmp_dir.join("foo"))
1521            .arg(tmp_dir.join("bar-cp"))
1522            .output()
1523            .await?;
1524        testutils::check_dirs_identical(
1525            &tmp_dir.join("bar"),
1526            &tmp_dir.join("bar-cp"),
1527            testutils::FileEqualityCheck::Basic,
1528        )
1529        .await?;
1530        Ok(())
1531    }
1532}