common/
copy.rs

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