1use std::ffi::{OsStr, OsString};
2use std::os::fd::AsFd;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use anyhow::{Context, anyhow};
7use throttle::get_file_iops_tokens;
8use tracing::instrument;
9
10use crate::config::DryRunMode;
11use crate::copy_data::copy_file_range_all;
12use crate::filecmp;
13use crate::preserve;
14use crate::progress;
15use crate::rm;
16use crate::rm::{Settings as RmSettings, Summary as RmSummary};
17use crate::safedir::{self, Dir, FileMeta, Handle};
18use crate::walk::{EntryKind, LeafPermit, PermitKind};
19use crate::walk_driver::{
20 DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_entry,
21};
22
23pub type Error = crate::error::OperationError<Summary>;
26
27#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
32pub enum OverwriteFilter {
33 #[value(name = "newer")]
35 Newer,
36}
37
38impl std::fmt::Display for OverwriteFilter {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 OverwriteFilter::Newer => write!(f, "newer"),
42 }
43 }
44}
45
46pub struct ExistingDst<'a, M: crate::preserve::Metadata> {
48 pub meta: &'a M,
50 pub is_file: bool,
54}
55
56pub fn skip_unchanged_send<S, D>(
64 overwrite_compare: &filecmp::MetadataCmpSettings,
65 overwrite_filter: Option<OverwriteFilter>,
66 ignore_existing: bool,
67 src: &S,
68 dst: Option<ExistingDst<'_, D>>,
69) -> bool
70where
71 S: crate::preserve::Metadata + std::fmt::Debug,
72 D: crate::preserve::Metadata + std::fmt::Debug,
73{
74 let Some(dst) = dst else {
75 return false; };
77 if ignore_existing {
78 return true; }
80 if !dst.is_file {
81 return false; }
83 if filecmp::metadata_equal(overwrite_compare, src, dst.meta) {
84 return true; }
86 if overwrite_filter == Some(OverwriteFilter::Newer) && filecmp::dest_is_newer(src, dst.meta) {
87 return true; }
89 false
90}
91
92#[derive(Debug, Clone)]
98pub struct DeleteSettings {
99 pub delete_excluded: bool,
102}
103
104#[derive(Debug, Clone)]
105pub struct Settings {
106 pub dereference: bool,
107 pub fail_early: bool,
108 pub overwrite: bool,
109 pub overwrite_compare: filecmp::MetadataCmpSettings,
110 pub overwrite_filter: Option<OverwriteFilter>,
111 pub ignore_existing: bool,
112 pub chunk_size: u64,
113 pub skip_specials: bool,
115 pub remote_copy_buffer_size: usize,
121 pub filter: Option<crate::filter::FilterSettings>,
123 pub dry_run: Option<crate::config::DryRunMode>,
125 pub delete: Option<DeleteSettings>,
127}
128
129fn skipped_summary_for(kind: EntryKind) -> Summary {
133 match kind {
134 EntryKind::Dir => Summary {
135 directories_skipped: 1,
136 ..Default::default()
137 },
138 EntryKind::Symlink => Summary {
139 symlinks_skipped: 1,
140 ..Default::default()
141 },
142 EntryKind::File | EntryKind::Special => Summary {
143 files_skipped: 1,
144 ..Default::default()
145 },
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum EmptyDirAction {
153 Keep,
155 Remove,
157 DryRunSkip,
159}
160
161pub fn check_empty_dir_cleanup(
176 filter: Option<&crate::filter::FilterSettings>,
177 we_created_dir: bool,
178 anything_copied: bool,
179 relative_path: &std::path::Path,
180 is_root: bool,
181 is_dry_run: bool,
182) -> EmptyDirAction {
183 if filter.is_none() || anything_copied {
185 return EmptyDirAction::Keep;
186 }
187 if !we_created_dir {
189 return EmptyDirAction::Keep;
190 }
191 if is_root {
193 return EmptyDirAction::Keep;
194 }
195 let f = filter.unwrap();
197 if f.directly_matches_include(relative_path, true) {
199 return EmptyDirAction::Keep;
200 }
201 if is_dry_run {
203 EmptyDirAction::DryRunSkip
204 } else {
205 EmptyDirAction::Remove
206 }
207}
208
209#[instrument]
210pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
211 let ft1 = md1.file_type();
212 let ft2 = md2.file_type();
213 ft1.is_dir() == ft2.is_dir()
214 && ft1.is_file() == ft2.is_file()
215 && ft1.is_symlink() == ft2.is_symlink()
216}
217
218#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
219pub struct Summary {
220 pub bytes_copied: u64,
221 pub files_copied: usize,
222 pub symlinks_created: usize,
223 pub directories_created: usize,
224 pub files_unchanged: usize,
225 pub symlinks_unchanged: usize,
226 pub directories_unchanged: usize,
227 pub files_skipped: usize,
228 pub symlinks_skipped: usize,
229 pub directories_skipped: usize,
230 pub specials_skipped: usize,
231 pub rm_summary: RmSummary,
232}
233
234impl std::ops::Add for Summary {
235 type Output = Self;
236 fn add(self, other: Self) -> Self {
237 Self {
238 bytes_copied: self.bytes_copied + other.bytes_copied,
239 files_copied: self.files_copied + other.files_copied,
240 symlinks_created: self.symlinks_created + other.symlinks_created,
241 directories_created: self.directories_created + other.directories_created,
242 files_unchanged: self.files_unchanged + other.files_unchanged,
243 symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
244 directories_unchanged: self.directories_unchanged + other.directories_unchanged,
245 files_skipped: self.files_skipped + other.files_skipped,
246 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
247 directories_skipped: self.directories_skipped + other.directories_skipped,
248 specials_skipped: self.specials_skipped + other.specials_skipped,
249 rm_summary: self.rm_summary + other.rm_summary,
250 }
251 }
252}
253
254impl std::fmt::Display for Summary {
255 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
256 write!(
257 f,
258 "copy:\n\
259 -----\n\
260 bytes copied: {}\n\
261 files copied: {}\n\
262 symlinks created: {}\n\
263 directories created: {}\n\
264 files unchanged: {}\n\
265 symlinks unchanged: {}\n\
266 directories unchanged: {}\n\
267 files skipped: {}\n\
268 symlinks skipped: {}\n\
269 directories skipped: {}\n\
270 specials skipped: {}\n\
271 \n\
272 delete:\n\
273 -------\n\
274 {}",
275 bytesize::ByteSize(self.bytes_copied),
276 self.files_copied,
277 self.symlinks_created,
278 self.directories_created,
279 self.files_unchanged,
280 self.symlinks_unchanged,
281 self.directories_unchanged,
282 self.files_skipped,
283 self.symlinks_skipped,
284 self.directories_skipped,
285 self.specials_skipped,
286 &self.rm_summary,
287 )
288 }
289}
290
291#[instrument(skip(prog_track, settings, preserve))]
294pub async fn copy(
295 prog_track: &'static progress::Progress,
296 src: &std::path::Path,
297 dst: &std::path::Path,
298 settings: &Settings,
299 preserve: &preserve::Settings,
300 is_fresh: bool,
301) -> Result<Summary, Error> {
302 copy_with_filter_base(
303 prog_track,
304 src,
305 dst,
306 settings,
307 preserve,
308 is_fresh,
309 std::path::Path::new(""),
310 )
311 .await
312}
313
314#[instrument(skip(prog_track, settings, preserve))]
325#[allow(clippy::too_many_arguments)]
326pub async fn copy_with_filter_base(
327 prog_track: &'static progress::Progress,
328 src: &std::path::Path,
329 dst: &std::path::Path,
330 settings: &Settings,
331 preserve: &preserve::Settings,
332 is_fresh: bool,
333 filter_base: &std::path::Path,
334) -> Result<Summary, Error> {
335 let src_operand = crate::walk::split_root_operand(src)
339 .await
340 .map_err(|err| Error::new(err, Default::default()))?;
341 let src_parent_path = src_operand.parent.as_path();
342 let src_name = src_operand.name.as_os_str();
343 let src = src_operand.display.as_path();
344 let dst_parent_path_opt: Option<&std::path::Path> = match (dst.parent(), dst.file_name()) {
349 (Some(parent), Some(_)) if parent.as_os_str().is_empty() => Some(std::path::Path::new(".")),
350 (Some(parent), Some(_)) => Some(parent),
351 _ => None,
352 };
353 let strict = crate::safedir::strict_operand_resolution();
363 let strict_src_parent: Option<Arc<Dir>> = if strict {
364 let parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
365 .await
366 .with_context(|| format!("cannot open source parent directory {:?}", src_parent_path))
367 .map_err(|err| Error::new(err, Default::default()))?;
368 Some(Arc::new(parent.into_tree()))
370 } else {
371 None
372 };
373 let strict_dst_parent: Option<Arc<Dir>> = match (strict, dst_parent_path_opt) {
374 (true, Some(dst_parent_path)) => {
375 match Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination).await {
376 Ok(parent) => Some(Arc::new(parent.into_tree())),
377 Err(err)
381 if err.kind() == std::io::ErrorKind::NotFound
382 || err.raw_os_error() == Some(libc::ENOTDIR) =>
383 {
384 None
385 }
386 Err(err) => {
387 return Err(Error::new(
388 anyhow::Error::new(err).context(format!(
389 "cannot open destination parent directory {dst_parent_path:?}"
390 )),
391 Default::default(),
392 ));
393 }
394 }
395 }
396 _ => None,
398 };
399 if let Some(ref filter) = settings.filter {
401 let (kind, is_dir) = match &strict_src_parent {
402 Some(parent) => {
404 let root_handle = parent
405 .child(src_name)
406 .await
407 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
408 .map_err(|err| Error::new(err, Default::default()))?;
409 (root_handle.kind(), root_handle.kind() == EntryKind::Dir)
410 }
411 None => {
412 let src_metadata = crate::walk::run_metadata_probed(
413 congestion::Side::Source,
414 congestion::MetadataOp::Stat,
415 tokio::fs::symlink_metadata(src),
416 )
417 .await
418 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
419 .map_err(|err| Error::new(err, Default::default()))?;
420 (
421 EntryKind::from_metadata(&src_metadata),
422 src_metadata.is_dir(),
423 )
424 }
425 };
426 let result = if filter_base.as_os_str().is_empty() {
430 filter.should_include_root_item(std::path::Path::new(src_name), is_dir)
431 } else {
432 filter.should_include(filter_base, is_dir)
433 };
434 match result {
435 crate::filter::FilterResult::Included => {}
436 result => {
437 if let Some(mode) = settings.dry_run {
438 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
439 }
440 kind.inc_skipped(prog_track);
441 return Ok(skipped_summary_for(kind));
442 }
443 }
444 }
445 let src_parent = match strict_src_parent {
448 Some(parent) => parent,
449 None => {
450 let parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
451 .await
452 .with_context(|| {
453 format!("cannot open source parent directory {:?}", src_parent_path)
454 })
455 .map_err(|err| Error::new(err, Default::default()))?;
456 Arc::new(parent.into_tree())
457 }
458 };
459 crate::safedir::warn_if_root_acl_unpreserved_at(
463 &src_parent,
464 src_name,
465 src,
466 preserve.file.acl,
467 preserve.dir.acl,
468 )
469 .await;
470 let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
474 return Err(Error::new(
475 anyhow!(
476 "copy destination {:?} has no parent directory or file name",
477 dst
478 ),
479 Default::default(),
480 ));
481 };
482 let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
483 std::path::Path::new(".")
484 } else {
485 dst_parent_path
486 };
487 let dst_parent = if settings.dry_run.is_some() {
492 None
493 } else {
494 match strict_dst_parent {
495 Some(parent) => Some(parent),
496 None => {
497 let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
498 .await
499 .with_context(|| {
500 format!(
501 "cannot open destination parent directory {:?}",
502 dst_parent_path
503 )
504 })
505 .map_err(|err| Error::new(err, Default::default()))?;
506 Some(Arc::new(dir.into_tree()))
507 }
508 }
509 };
510 run_copy_root(
511 prog_track,
512 &src_parent,
513 dst_parent,
514 src_name,
515 src,
516 dst,
517 filter_base,
518 settings,
519 preserve,
520 is_fresh,
521 None,
522 )
523 .await
524}
525
526#[instrument(skip(
540 prog_track,
541 src_parent,
542 dst_parent,
543 settings,
544 preserve,
545 open_file_guard
546))]
547#[allow(clippy::too_many_arguments)]
548pub(crate) async fn copy_child(
549 prog_track: &'static progress::Progress,
550 src_parent: &Arc<Dir>,
551 dst_parent: Option<&Arc<Dir>>,
552 name: &OsStr,
553 src_path: &std::path::Path,
554 dst_path: &std::path::Path,
555 filter_base: &std::path::Path,
556 settings: &Settings,
557 preserve: &preserve::Settings,
558 is_fresh: bool,
559 open_file_guard: Option<throttle::OpenFileGuard>,
560) -> Result<Summary, Error> {
561 run_copy_root(
562 prog_track,
563 src_parent,
564 dst_parent.map(Arc::clone),
565 name,
566 src_path,
567 dst_path,
568 filter_base,
569 settings,
570 preserve,
571 is_fresh,
572 open_file_guard.map(LeafPermit::OpenFile),
573 )
574 .await
575}
576
577struct CopyVisitor {
587 prog_track: &'static progress::Progress,
588 dst_root: PathBuf,
593 filter_base: PathBuf,
595 settings: Settings,
596 preserve: preserve::Settings,
597 dst_parent: Option<Arc<Dir>>,
600 root_is_fresh: bool,
602}
603
604#[derive(Clone)]
609struct CopyDirContext {
610 dst_dir: Option<Arc<Dir>>,
611 is_fresh: bool,
612}
613
614struct CopyDirState {
618 dst_dir: Option<Arc<Dir>>,
620 dst_parent: Option<Arc<Dir>>,
623 dst_name: OsString,
624 we_created: bool,
626 reused_lock: Option<safedir::ReusedDirLock>,
629 src_meta: FileMeta,
631 src_acls: Option<safedir::Acls>,
635 is_root: bool,
637 base: Summary,
640}
641
642impl CopyVisitor {
643 fn dst_path_for(&self, cx: &EntryCx) -> PathBuf {
647 if cx.rel_path.as_os_str().is_empty() {
648 self.dst_root.clone()
649 } else {
650 self.dst_root.join(&cx.rel_path)
651 }
652 }
653
654 #[allow(clippy::result_large_err)]
662 fn dst_name_for(&self, cx: &EntryCx) -> Result<OsString, Error> {
663 if cx.rel_path.as_os_str().is_empty() {
664 self.dst_root
665 .file_name()
666 .map(OsStr::to_owned)
667 .ok_or_else(|| {
668 Error::new(
669 anyhow!("copy destination {:?} has no file name", &self.dst_root),
670 Default::default(),
671 )
672 })
673 } else {
674 Ok(cx.name.clone())
675 }
676 }
677
678 async fn prune_finished_dir(
689 &self,
690 copy_summary: &mut Summary,
691 child_error: &mut Option<anyhow::Error>,
692 processed: &ProcessedChildren,
693 dst_dir: &Option<Arc<Dir>>,
694 dst_path: &std::path::Path,
695 rel_path: &std::path::Path,
696 ) -> Result<(), Error> {
697 if child_error.is_some() && self.settings.delete.is_some() {
698 tracing::warn!(
699 "skipping --delete pruning of {:?} because the copy reported errors",
700 dst_path
701 );
702 }
703 let Some(delete_settings) = &self.settings.delete else {
704 return Ok(());
705 };
706 if child_error.is_some() {
707 return Ok(());
708 }
709 let keep_set: std::collections::HashSet<OsString> =
713 processed.names().iter().cloned().collect();
714 let relative_dir = self.filter_base.join(rel_path);
715 let prune_dir: Option<Arc<Dir>> = match dst_dir {
731 Some(dir) => Some(Arc::clone(dir)),
732 None => {
733 match Dir::open_root_dir(dst_path, false, congestion::Side::Destination).await {
734 Ok(dir) => Some(Arc::new(dir)),
735 Err(err)
736 if matches!(
737 err.kind(),
738 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
739 ) || err.raw_os_error() == Some(libc::ELOOP)
740 || err.raw_os_error() == Some(libc::ENOTDIR) =>
741 {
742 tracing::debug!(
743 "skipping --delete pruning of {:?}: not a real directory in dry-run",
744 dst_path
745 );
746 None
747 }
748 Err(err) => {
749 let err = anyhow::Error::new(err).context(format!(
750 "cannot open destination {dst_path:?} for delete scan"
751 ));
752 return Err(Error::new(err, *copy_summary));
756 }
757 }
758 }
759 };
760 let Some(prune_dir) = prune_dir else {
761 return Ok(());
762 };
763 match crate::delete::prune_extraneous(
764 self.prog_track,
765 &prune_dir,
766 &relative_dir,
767 &keep_set,
768 self.settings.filter.as_ref(),
769 delete_settings,
770 self.settings.fail_early,
771 self.settings.dry_run,
772 )
773 .await
774 {
775 Ok(rm_summary) => {
776 copy_summary.rm_summary = copy_summary.rm_summary + rm_summary;
777 }
778 Err(err) => {
779 copy_summary.rm_summary = copy_summary.rm_summary + err.summary;
780 if self.settings.fail_early {
781 return Err(Error::new(err.source, *copy_summary));
782 }
783 *child_error = Some(err.source);
787 }
788 }
789 Ok(())
790 }
791
792 async fn finalize_dir(&self, fin: FinalizeDir, cx: &EntryCx) -> Result<Summary, Error> {
798 let FinalizeDir {
799 mut copy_summary,
800 child_error,
801 dst_dir,
802 dst_parent,
803 dst_name,
804 src_meta,
805 src_acls,
806 we_created,
807 reused_lock,
808 is_root,
809 } = fin;
810 let src_path = &cx.real_path;
811 let dst_path = self.dst_path_for(cx);
812 let rel_path = &cx.rel_path;
813 let this_dir_count = usize::from(we_created);
816 let child_dirs_created = copy_summary
817 .directories_created
818 .saturating_sub(this_dir_count);
819 let anything_copied = copy_summary.files_copied > 0
820 || copy_summary.symlinks_created > 0
821 || child_dirs_created > 0;
822 let relative_path = self.filter_base.join(rel_path);
823 match check_empty_dir_cleanup(
824 self.settings.filter.as_ref(),
825 we_created,
826 anything_copied,
827 &relative_path,
828 is_root,
829 self.settings.dry_run.is_some(),
830 ) {
831 EmptyDirAction::Keep => { }
832 EmptyDirAction::DryRunSkip => {
833 tracing::debug!(
834 "dry-run: directory {:?} would not be created (nothing to copy inside)",
835 dst_path
836 );
837 copy_summary.directories_created = 0;
838 return match child_error {
842 Some(child_error) => Err(Error::new(child_error, copy_summary)),
843 None => Ok(copy_summary),
844 };
845 }
846 EmptyDirAction::Remove => {
847 tracing::debug!(
848 "directory {:?} has nothing to copy inside, removing empty directory",
849 dst_path
850 );
851 let rmdir_result = match &dst_parent {
858 Some(dst_parent) => dst_parent.rmdir_at(&dst_name).await,
859 None => {
860 crate::walk::run_metadata_probed(
861 congestion::Side::Destination,
862 congestion::MetadataOp::RmDir,
863 tokio::fs::remove_dir(&dst_path),
864 )
865 .await
866 }
867 };
868 match rmdir_result {
869 Ok(()) => {
870 copy_summary.directories_created = 0;
871 return match child_error {
876 Some(child_error) => Err(Error::new(child_error, copy_summary)),
877 None => Ok(copy_summary),
878 };
879 }
880 Err(err) => {
881 tracing::debug!(
882 "failed to remove empty directory {:?}: {:#}, keeping",
883 dst_path,
884 &err
885 );
886 }
888 }
889 }
890 }
891 tracing::debug!("set 'dst' directory metadata");
896 let metadata_result = match &dst_dir {
897 Some(dst_dir) => {
903 safedir::set_reused_dir_metadata_fd(
904 &self.preserve,
905 &src_meta,
906 src_acls.as_ref(),
907 reused_lock,
908 dst_dir,
909 )
910 .await
911 }
912 None => Ok(()),
913 };
914 if let Some(child_error) = child_error {
915 if let Err(metadata_err) = metadata_result {
916 tracing::error!(
917 "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
918 src_path,
919 dst_path,
920 &metadata_err
921 );
922 }
923 return Err(Error::new(child_error, copy_summary));
924 }
925 metadata_result
927 .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
928 .map_err(|err| Error::new(err, copy_summary))?;
929 Ok(copy_summary)
930 }
931}
932
933struct FinalizeDir {
937 copy_summary: Summary,
938 child_error: Option<anyhow::Error>,
939 dst_dir: Option<Arc<Dir>>,
940 dst_parent: Option<Arc<Dir>>,
941 dst_name: OsString,
942 src_meta: FileMeta,
943 src_acls: Option<safedir::Acls>,
944 we_created: bool,
945 reused_lock: Option<safedir::ReusedDirLock>,
946 is_root: bool,
947}
948
949#[allow(clippy::too_many_arguments)]
956async fn run_copy_root(
957 prog_track: &'static progress::Progress,
958 src_parent: &Arc<Dir>,
959 dst_parent: Option<Arc<Dir>>,
960 name: &OsStr,
961 src_root: &std::path::Path,
962 dst_root: &std::path::Path,
963 filter_base: &std::path::Path,
964 settings: &Settings,
965 preserve: &preserve::Settings,
966 is_fresh: bool,
967 permit: Option<LeafPermit>,
968) -> Result<Summary, Error> {
969 let visitor = Arc::new(CopyVisitor {
970 prog_track,
971 dst_root: dst_root.to_path_buf(),
972 filter_base: filter_base.to_path_buf(),
973 settings: settings.clone(),
974 preserve: *preserve,
975 dst_parent,
976 root_is_fresh: is_fresh,
977 });
978 let root_cx = EntryCx {
987 parent: Arc::clone(src_parent),
988 name: name.to_owned(),
989 rel_path: PathBuf::new(),
990 filter_path: filter_base.to_path_buf(),
991 real_path: src_root.to_path_buf(),
992 dry_run: settings.dry_run.is_some(),
993 prog_track,
994 };
995 let root_ctx = visitor.root_dir_context();
996 process_entry(visitor, root_cx, root_ctx, permit).await
997}
998
999impl WalkVisitor for CopyVisitor {
1000 type Summary = Summary;
1001 type DirContext = CopyDirContext;
1002 type DirState = CopyDirState;
1003
1004 fn root_dir_context(&self) -> CopyDirContext {
1005 CopyDirContext {
1006 dst_dir: self.dst_parent.clone(),
1007 is_fresh: self.root_is_fresh,
1008 }
1009 }
1010
1011 fn permit_kind(&self) -> PermitKind {
1012 PermitKind::OpenFile
1014 }
1015
1016 fn want_permit(&self, hint: Option<EntryKind>) -> bool {
1017 hint == Some(EntryKind::File)
1021 }
1022
1023 fn fail_early(&self) -> bool {
1024 self.settings.fail_early
1025 }
1026
1027 fn filter(&self) -> Option<&crate::filter::FilterSettings> {
1028 self.settings.filter.as_ref()
1029 }
1030
1031 fn on_skip(
1032 &self,
1033 cx: &EntryCx,
1034 kind: EntryKind,
1035 skip_result: &crate::filter::FilterResult,
1036 ) -> Summary {
1037 if let Some(mode) = self.settings.dry_run {
1040 crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
1041 }
1042 skipped_summary_for(kind)
1043 }
1044
1045 async fn visit_leaf(
1046 &self,
1047 cx: &EntryCx,
1048 parent_ctx: &CopyDirContext,
1049 handle: Handle,
1050 kind: EntryKind,
1051 permit: Option<LeafPermit>,
1052 ) -> Result<Summary, Error> {
1053 let src_parent = &cx.parent;
1054 let dst_parent = parent_ctx.dst_dir.as_ref();
1055 let name = cx.name.as_os_str();
1056 let src_path = &cx.real_path;
1057 let dst_path = self.dst_path_for(cx);
1058 let dst_name = self.dst_name_for(cx)?;
1059 let is_fresh = parent_ctx.is_fresh;
1060 if self.settings.dereference && kind == EntryKind::Symlink {
1065 drop(permit);
1066 debug_assert!(
1071 self.settings.dereference,
1072 "canonicalize reached with dereference == false; the non-dereference copy path \
1073 must never re-resolve a path"
1074 );
1075 let link = crate::walk::run_metadata_probed(
1076 congestion::Side::Source,
1077 congestion::MetadataOp::Stat,
1078 tokio::fs::canonicalize(src_path),
1079 )
1080 .await
1081 .with_context(|| format!("failed reading src symlink {:?}", src_path))
1082 .map_err(|err| Error::new(err, Default::default()))?;
1083 return copy(
1084 self.prog_track,
1085 &link,
1086 &dst_path,
1087 &self.settings,
1088 &self.preserve,
1089 is_fresh,
1090 )
1091 .await;
1092 }
1093 match kind {
1094 EntryKind::File => {
1095 let _guard = match permit {
1099 Some(p) => p,
1100 None => LeafPermit::OpenFile(throttle::open_file_permit().await),
1101 };
1102 copy_file_fd(
1103 self.prog_track,
1104 src_parent,
1105 dst_parent,
1106 name,
1107 &dst_name,
1108 &dst_path,
1109 src_path,
1110 &handle,
1111 &self.settings,
1112 &self.preserve,
1113 is_fresh,
1114 )
1115 .await
1116 }
1117 EntryKind::Symlink => {
1118 drop(permit);
1119 copy_symlink_fd(
1120 self.prog_track,
1121 src_parent,
1122 dst_parent,
1123 &dst_name,
1124 src_path,
1125 &dst_path,
1126 &handle,
1127 &self.settings,
1128 &self.preserve,
1129 is_fresh,
1130 )
1131 .await
1132 }
1133 EntryKind::Special => {
1134 drop(permit);
1135 if self.settings.skip_specials {
1136 tracing::debug!("skipping special file {:?}", src_path);
1137 if let Some(mode) = self.settings.dry_run {
1138 match mode {
1139 DryRunMode::Brief => {}
1140 DryRunMode::All => println!("skip special {:?}", src_path),
1141 DryRunMode::Explain => {
1142 println!("skip special {:?} (unsupported file type)", src_path);
1143 }
1144 }
1145 }
1146 self.prog_track.specials_skipped.inc();
1147 return Ok(Summary {
1148 specials_skipped: 1,
1149 ..Default::default()
1150 });
1151 }
1152 Err(Error::new(
1153 anyhow!(
1154 "copy: {:?} -> {:?} failed, unsupported src file type",
1155 src_path,
1156 dst_path
1157 ),
1158 Default::default(),
1159 ))
1160 }
1161 EntryKind::Dir => unreachable!("directory entries are handled by dir_pre/dir_post"),
1163 }
1164 }
1165
1166 async fn dir_pre(
1167 &self,
1168 cx: &EntryCx,
1169 parent_ctx: &CopyDirContext,
1170 _handle: &Handle,
1171 ) -> DirPreResult<Self> {
1172 let src_parent = &cx.parent;
1173 let name = cx.name.as_os_str();
1174 let src_path = &cx.real_path;
1175 let dst_path = self.dst_path_for(cx);
1176 let dst_name = self.dst_name_for(cx)?;
1177 let is_root = cx.rel_path.as_os_str().is_empty();
1178 let is_fresh = parent_ctx.is_fresh;
1179 let src_dir = src_parent
1181 .open_dir(name)
1182 .await
1183 .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1184 .map_err(|err| Error::new(err, Default::default()))?;
1185 let src_dir = Arc::new(src_dir);
1186 let src_meta = src_dir
1191 .meta()
1192 .await
1193 .with_context(|| format!("cannot read directory metadata from {:?}", src_path))
1194 .map_err(|err| Error::new(err, Default::default()))?;
1195 if self.settings.dry_run.is_some() {
1196 let dst_nondir_exists = if !self.settings.ignore_existing || is_fresh {
1200 false
1201 } else if crate::safedir::strict_operand_resolution() {
1202 matches!(strict_dry_run_dst_kind(&dst_path).await?, Some(kind) if kind != EntryKind::Dir)
1204 } else {
1205 match crate::walk::run_metadata_probed(
1206 congestion::Side::Destination,
1207 congestion::MetadataOp::Stat,
1208 tokio::fs::symlink_metadata(&dst_path),
1209 )
1210 .await
1211 {
1212 Ok(meta) => !meta.is_dir(),
1220 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1226 Err(error) => {
1227 return Err(Error::new(
1228 anyhow::Error::new(error)
1229 .context(format!("cannot probe destination {dst_path:?}")),
1230 Default::default(),
1231 ));
1232 }
1233 }
1234 };
1235 if dst_nondir_exists {
1236 match self.settings.dry_run {
1237 Some(DryRunMode::Brief) | None => {}
1238 Some(DryRunMode::All) => println!("skip dir {:?}", dst_path),
1239 Some(DryRunMode::Explain) => {
1240 println!(
1241 "skip dir {:?} (destination exists, not a directory)",
1242 dst_path
1243 );
1244 }
1245 }
1246 return Ok(DirAction::Skip(Summary {
1247 directories_unchanged: 1,
1248 ..Default::default()
1249 }));
1250 }
1251 crate::dry_run::report_action("copy", src_path, Some(&dst_path), "dir");
1252 let base = Summary {
1253 directories_created: 1, ..Default::default()
1255 };
1256 return Ok(DirAction::Descend {
1257 dir: src_dir,
1258 child_ctx: CopyDirContext {
1259 dst_dir: None, is_fresh,
1261 },
1262 state: CopyDirState {
1263 dst_dir: None,
1264 dst_parent: None,
1265 dst_name,
1266 we_created: true,
1268 reused_lock: None,
1270 src_meta,
1271 src_acls: None,
1273 is_root,
1274 base,
1275 },
1276 });
1277 }
1278 let src_acls = if self.preserve.dir.acl {
1283 Some(
1284 src_dir
1285 .read_acls()
1286 .await
1287 .with_context(|| format!("cannot read ACLs from directory {:?}", src_path))
1288 .map_err(|err| Error::new(err, Default::default()))?,
1289 )
1290 } else {
1291 None
1292 };
1293 let dst_parent = parent_ctx
1295 .dst_dir
1296 .as_ref()
1297 .expect("destination parent must be open for a real copy");
1298 let DirSlot {
1299 dir: dst_dir,
1300 summary: base,
1301 is_fresh: child_is_fresh,
1302 we_created,
1303 reused_lock,
1304 } = match resolve_dst_dir(
1305 self.prog_track,
1306 dst_parent,
1307 &dst_name,
1308 &dst_path,
1309 &self.settings,
1310 )
1311 .await?
1312 {
1313 DirResolution::Skip(summary) => return Ok(DirAction::Skip(summary)),
1314 DirResolution::Proceed(slot) => slot,
1315 };
1316 Ok(DirAction::Descend {
1317 dir: src_dir,
1318 child_ctx: CopyDirContext {
1319 dst_dir: Some(Arc::clone(&dst_dir)),
1320 is_fresh: child_is_fresh,
1321 },
1322 state: CopyDirState {
1323 dst_dir: Some(dst_dir),
1324 dst_parent: Some(Arc::clone(dst_parent)),
1325 dst_name,
1326 we_created,
1327 reused_lock,
1328 src_meta,
1329 src_acls,
1330 is_root,
1331 base,
1332 },
1333 })
1334 }
1335
1336 async fn dir_post(
1337 &self,
1338 cx: &EntryCx,
1339 state: CopyDirState,
1340 processed: &ProcessedChildren,
1341 child_result: Result<Summary, Error>,
1342 ) -> Result<Summary, Error> {
1343 let CopyDirState {
1344 dst_dir,
1345 dst_parent,
1346 dst_name,
1347 we_created,
1348 reused_lock,
1349 src_meta,
1350 src_acls,
1351 is_root,
1352 base,
1353 } = state;
1354 let (child_summary, mut child_error) = match child_result {
1360 Ok(summary) => (summary, None),
1361 Err(err) => (err.summary, Some(err.source)),
1362 };
1363 let mut copy_summary = base + child_summary;
1364 let dst_path = self.dst_path_for(cx);
1365 self.prune_finished_dir(
1368 &mut copy_summary,
1369 &mut child_error,
1370 processed,
1371 &dst_dir,
1372 &dst_path,
1373 &cx.rel_path,
1374 )
1375 .await?;
1376 self.finalize_dir(
1378 FinalizeDir {
1379 copy_summary,
1380 child_error,
1381 dst_dir,
1382 dst_parent,
1383 dst_name,
1384 src_meta,
1385 src_acls,
1386 we_created,
1387 reused_lock,
1388 is_root,
1389 },
1390 cx,
1391 )
1392 .await
1393 }
1394}
1395
1396async fn strict_dry_run_dst_kind(dst_path: &std::path::Path) -> Result<Option<EntryKind>, Error> {
1403 crate::safedir::strict_probe_dst_kind(dst_path, congestion::Side::Destination)
1404 .await
1405 .map_err(|err| {
1406 Error::new(
1407 anyhow::Error::new(err).context(format!("cannot probe destination {dst_path:?}")),
1408 Default::default(),
1409 )
1410 })
1411}
1412
1413async fn dry_run_dst_exists(dst_path: &std::path::Path) -> Result<bool, Error> {
1423 if crate::safedir::strict_operand_resolution() {
1424 return Ok(strict_dry_run_dst_kind(dst_path).await?.is_some());
1425 }
1426 match tokio::fs::symlink_metadata(dst_path).await {
1427 Ok(_) => Ok(true),
1428 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1435 Err(error) => Err(Error::new(
1436 anyhow::Error::new(error).context(format!("cannot probe destination {dst_path:?}")),
1437 Default::default(),
1438 )),
1439 }
1440}
1441
1442#[allow(clippy::too_many_arguments)]
1448async fn copy_file_fd(
1449 prog_track: &'static progress::Progress,
1450 src_parent: &Arc<Dir>,
1451 dst_parent: Option<&Arc<Dir>>,
1452 name: &OsStr,
1453 dst_name: &OsStr,
1454 dst_path: &std::path::Path,
1455 src_path: &std::path::Path,
1456 src_handle: &Handle,
1457 settings: &Settings,
1458 preserve: &preserve::Settings,
1459 is_fresh: bool,
1460) -> Result<Summary, Error> {
1461 use crate::preserve::Metadata as _;
1464 let src_meta = src_handle.meta();
1465 if dst_parent.is_none()
1471 && !is_fresh
1472 && settings.ignore_existing
1473 && dry_run_dst_exists(dst_path).await?
1474 {
1475 if let Some(mode) = settings.dry_run {
1476 match mode {
1477 DryRunMode::Brief => {}
1478 DryRunMode::All => println!("skip file {:?}", dst_path),
1479 DryRunMode::Explain => {
1480 println!("skip file {:?} (destination exists)", dst_path)
1481 }
1482 }
1483 }
1484 tracing::debug!("destination exists, skipping (--ignore-existing)");
1485 prog_track.files_unchanged.inc();
1486 return Ok(Summary {
1487 files_unchanged: 1,
1488 ..Default::default()
1489 });
1490 }
1491 if settings.dry_run.is_some() {
1493 crate::dry_run::report_action("copy", src_path, Some(dst_path), "file");
1494 return Ok(Summary {
1495 files_copied: 1,
1496 bytes_copied: src_meta.size(),
1497 ..Default::default()
1498 });
1499 }
1500 let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1502 let mut copy_summary = Summary::default();
1505 let plan = if is_fresh {
1525 FilePlan::Vacant
1526 } else {
1527 match plan_dst_file(
1528 prog_track, dst_parent, dst_name, dst_path, src_meta, settings,
1529 )
1530 .await?
1531 {
1532 FilePlan::Skip(summary) => return Ok(skip_summary(summary, ©_summary)),
1533 plan => plan,
1534 }
1535 };
1536 get_file_iops_tokens(settings.chunk_size, src_meta.size()).await;
1537 let (src_file, open_meta) = src_parent
1543 .open_file_read(name)
1544 .await
1545 .with_context(|| format!("failed opening src file {:?} for reading", src_path))
1546 .map_err(|err| Error::new(err, copy_summary))?;
1547 let src_acls = if preserve.file.acl {
1552 Some(
1553 safedir::read_acls_fd(src_file.as_fd(), src_parent.side(), false)
1554 .await
1555 .with_context(|| format!("failed reading ACLs from {:?}", src_path))
1556 .map_err(|err| Error::new(err, copy_summary))?,
1557 )
1558 } else {
1559 None
1560 };
1561 execute_dst_plan(
1569 prog_track,
1570 dst_parent,
1571 dst_name,
1572 dst_path,
1573 plan,
1574 settings,
1575 &mut copy_summary,
1576 )
1577 .await?;
1578 let dst_file = match dst_parent.create_file(dst_name).await {
1579 Ok(dst_file) => dst_file,
1580 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1590 let plan = match plan_dst_file(
1591 prog_track, dst_parent, dst_name, dst_path, src_meta, settings,
1592 )
1593 .await
1594 .map_err(|err| Error::new(err.source, err.summary + copy_summary))?
1596 {
1597 FilePlan::Skip(summary) => return Ok(skip_summary(summary, ©_summary)),
1598 plan => plan,
1599 };
1600 execute_dst_plan(
1601 prog_track,
1602 dst_parent,
1603 dst_name,
1604 dst_path,
1605 plan,
1606 settings,
1607 &mut copy_summary,
1608 )
1609 .await?;
1610 dst_parent
1614 .create_file(dst_name)
1615 .await
1616 .with_context(|| format!("failed creating {:?}", dst_path))
1617 .map_err(|err| Error::new(err, copy_summary))?
1618 }
1619 Err(error) => {
1620 return Err(Error::new(
1621 anyhow::Error::new(error).context(format!("failed creating {:?}", dst_path)),
1622 copy_summary,
1623 ));
1624 }
1625 };
1626 tracing::debug!("copying data");
1627 let len = open_meta.size();
1628 let (copied, dst_file) = tokio::task::spawn_blocking(move || {
1634 copy_file_range_all(&src_file, &dst_file, len).map(|copied| (copied, dst_file))
1635 })
1636 .await
1637 .map_err(std::io::Error::other)
1638 .and_then(|res| res)
1639 .with_context(|| format!("failed copying data to {:?}", dst_path))
1640 .map_err(|err| Error::new(err, copy_summary))?;
1641 prog_track.files_copied.inc();
1645 prog_track.bytes_copied.add(copied);
1646 tracing::debug!("setting permissions");
1647 safedir::set_file_metadata_fd(
1648 preserve,
1649 &open_meta,
1650 src_acls.as_ref(),
1651 dst_file.as_fd(),
1652 congestion::Side::Destination,
1653 )
1654 .await
1655 .with_context(|| format!("failed setting metadata on {:?}", dst_path))
1656 .map_err(|err| Error::new(err, copy_summary))?;
1657 copy_summary.bytes_copied += copied;
1659 copy_summary.files_copied += 1;
1660 Ok(copy_summary)
1661}
1662
1663#[allow(clippy::too_many_arguments)]
1666async fn copy_symlink_fd(
1667 prog_track: &'static progress::Progress,
1668 src_parent: &Arc<Dir>,
1669 dst_parent: Option<&Arc<Dir>>,
1670 dst_name: &OsStr,
1671 src_path: &std::path::Path,
1672 dst_path: &std::path::Path,
1673 src_handle: &Handle,
1674 settings: &Settings,
1675 preserve: &preserve::Settings,
1676 is_fresh: bool,
1677) -> Result<Summary, Error> {
1678 if dst_parent.is_none()
1684 && !is_fresh
1685 && settings.ignore_existing
1686 && dry_run_dst_exists(dst_path).await?
1687 {
1688 if let Some(mode) = settings.dry_run {
1689 match mode {
1690 DryRunMode::Brief => {}
1691 DryRunMode::All => println!("skip symlink {:?}", dst_path),
1692 DryRunMode::Explain => {
1693 println!("skip symlink {:?} (destination exists)", dst_path)
1694 }
1695 }
1696 }
1697 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1698 prog_track.symlinks_unchanged.inc();
1699 return Ok(Summary {
1700 symlinks_unchanged: 1,
1701 ..Default::default()
1702 });
1703 }
1704 if settings.dry_run.is_some() {
1705 crate::dry_run::report_action("copy", src_path, Some(dst_path), "symlink");
1706 return Ok(Summary {
1707 symlinks_created: 1,
1708 ..Default::default()
1709 });
1710 }
1711 let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1712 let (target, src_meta) = src_handle
1716 .read_symlink(src_parent.side())
1717 .await
1718 .with_context(|| format!("failed reading symlink {:?}", src_path))
1719 .map_err(|err| Error::new(err, Default::default()))?;
1720 match dst_parent.symlink_at(dst_name, &target).await {
1722 Ok(link_handle) => {
1723 safedir::set_symlink_metadata_fd(
1724 preserve,
1725 &src_meta,
1726 &link_handle,
1727 congestion::Side::Destination,
1728 )
1729 .await
1730 .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1731 .map_err(|err| Error::new(err, Default::default()))?;
1732 prog_track.symlinks_created.inc();
1733 Ok(Summary {
1734 symlinks_created: 1,
1735 ..Default::default()
1736 })
1737 }
1738 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1739 if settings.ignore_existing {
1740 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1741 prog_track.symlinks_unchanged.inc();
1742 return Ok(Summary {
1743 symlinks_unchanged: 1,
1744 ..Default::default()
1745 });
1746 }
1747 if !settings.overwrite {
1748 return Err(Error::new(
1749 anyhow!("failed creating symlink {:?}", dst_path),
1750 Default::default(),
1751 ));
1752 }
1753 let dst_handle = dst_parent
1754 .child(dst_name)
1755 .await
1756 .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
1757 .map_err(|err| Error::new(err, Default::default()))?;
1758 if dst_handle.kind() == EntryKind::Symlink {
1759 let dst_link = dst_parent
1760 .read_link_at(dst_name) .await
1762 .with_context(|| format!("failed reading dst symlink: {:?}", dst_path))
1763 .map_err(|err| Error::new(err, Default::default()))?;
1764 if dst_link == target {
1765 tracing::debug!("'dst' is a symlink and points to the same location as 'src'");
1766 if preserve.symlink.any()
1767 && !filecmp::metadata_equal(
1768 &settings.overwrite_compare,
1769 &src_meta,
1770 dst_handle.meta(),
1771 )
1772 {
1773 tracing::debug!("'dst' metadata is different, updating");
1774 safedir::set_symlink_metadata_fd(
1775 preserve,
1776 &src_meta,
1777 &dst_handle,
1778 congestion::Side::Destination,
1779 )
1780 .await
1781 .with_context(|| {
1782 format!("failed setting symlink metadata on {:?}", dst_path)
1783 })
1784 .map_err(|err| Error::new(err, Default::default()))?;
1785 prog_track.symlinks_removed.inc();
1786 prog_track.symlinks_created.inc();
1787 return Ok(Summary {
1788 rm_summary: RmSummary {
1789 symlinks_removed: 1,
1790 ..Default::default()
1791 },
1792 symlinks_created: 1,
1793 ..Default::default()
1794 });
1795 }
1796 tracing::debug!("symlink already exists, skipping");
1797 prog_track.symlinks_unchanged.inc();
1798 return Ok(Summary {
1799 symlinks_unchanged: 1,
1800 ..Default::default()
1801 });
1802 }
1803 tracing::debug!("'dst' is a symlink but points to a different path, updating");
1804 } else {
1805 tracing::info!("'dst' is not a symlink, updating");
1806 }
1807 let rm_summary = remove_existing(
1809 prog_track,
1810 dst_parent,
1811 dst_name,
1812 dst_path,
1813 &dst_handle,
1814 settings,
1815 )
1816 .await?;
1817 let copy_summary = Summary {
1818 rm_summary,
1819 ..Default::default()
1820 };
1821 let link_handle = dst_parent
1822 .symlink_at(dst_name, &target)
1823 .await
1824 .with_context(|| format!("failed creating symlink {:?}", dst_path))
1825 .map_err(|err| Error::new(err, copy_summary))?;
1826 safedir::set_symlink_metadata_fd(
1827 preserve,
1828 &src_meta,
1829 &link_handle,
1830 congestion::Side::Destination,
1831 )
1832 .await
1833 .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1834 .map_err(|err| Error::new(err, copy_summary))?;
1835 prog_track.symlinks_created.inc();
1836 Ok(Summary {
1837 rm_summary,
1838 symlinks_created: 1,
1839 ..Default::default()
1840 })
1841 }
1842 Err(error) => Err(Error::new(
1843 anyhow::Error::new(error).context(format!("failed creating symlink {:?}", dst_path)),
1844 Default::default(),
1845 )),
1846 }
1847}
1848
1849#[derive(Debug)]
1851enum FilePlan {
1852 Vacant,
1854 Skip(Summary),
1857 Replace(Handle),
1867}
1868
1869fn skip_summary(skip: Summary, copy_summary: &Summary) -> Summary {
1877 Summary {
1878 rm_summary: copy_summary.rm_summary + skip.rm_summary,
1879 ..skip
1880 }
1881}
1882
1883async fn execute_dst_plan(
1892 prog_track: &'static progress::Progress,
1893 dst_parent: &Arc<Dir>,
1894 dst_name: &OsStr,
1895 dst_path: &std::path::Path,
1896 plan: FilePlan,
1897 settings: &Settings,
1898 copy_summary: &mut Summary,
1899) -> Result<(), Error> {
1900 let dst_handle = match plan {
1901 FilePlan::Vacant => return Ok(()),
1902 FilePlan::Skip(_) => return Ok(()),
1905 FilePlan::Replace(dst_handle) => dst_handle,
1906 };
1907 match remove_existing(
1908 prog_track,
1909 dst_parent,
1910 dst_name,
1911 dst_path,
1912 &dst_handle,
1913 settings,
1914 )
1915 .await
1916 {
1917 Ok(rm_summary) => {
1918 copy_summary.rm_summary = copy_summary.rm_summary + rm_summary;
1919 Ok(())
1920 }
1921 Err(err) => Err(Error::new(err.source, err.summary + *copy_summary)),
1923 }
1924}
1925
1926async fn plan_dst_file(
1939 prog_track: &'static progress::Progress,
1940 dst_parent: &Arc<Dir>,
1941 dst_name: &OsStr,
1942 dst_path: &std::path::Path,
1943 src_meta: &FileMeta,
1944 settings: &Settings,
1945) -> Result<FilePlan, Error> {
1946 let dst_handle = match dst_parent.child(dst_name).await {
1947 Ok(dst_handle) => dst_handle,
1948 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1956 return Ok(FilePlan::Vacant);
1957 }
1958 Err(error) => {
1959 return Err(Error::new(
1960 anyhow::Error::new(error)
1961 .context(format!("failed looking up destination {:?}", dst_path)),
1962 Default::default(),
1963 ));
1964 }
1965 };
1966 if settings.ignore_existing {
1967 tracing::debug!("destination exists, skipping (--ignore-existing)");
1968 prog_track.files_unchanged.inc();
1969 return Ok(FilePlan::Skip(Summary {
1970 files_unchanged: 1,
1971 ..Default::default()
1972 }));
1973 }
1974 if !settings.overwrite {
1975 return Err(Error::new(
1976 anyhow!(
1977 "destination {:?} already exists, did you intend to specify --overwrite?",
1978 dst_path
1979 ),
1980 Default::default(),
1981 ));
1982 }
1983 tracing::debug!("file exists, check if it's identical");
1984 if dst_handle.kind() == EntryKind::File {
1985 if filecmp::metadata_equal(&settings.overwrite_compare, src_meta, dst_handle.meta()) {
1986 tracing::debug!("file is identical, skipping");
1987 prog_track.files_unchanged.inc();
1988 return Ok(FilePlan::Skip(Summary {
1989 files_unchanged: 1,
1990 ..Default::default()
1991 }));
1992 }
1993 if let Some(OverwriteFilter::Newer) = settings.overwrite_filter
1994 && filecmp::dest_is_newer(src_meta, dst_handle.meta())
1995 {
1996 tracing::debug!("dest is newer than source, skipping");
1997 prog_track.files_unchanged.inc();
1998 return Ok(FilePlan::Skip(Summary {
1999 files_unchanged: 1,
2000 ..Default::default()
2001 }));
2002 }
2003 }
2004 tracing::info!("destination differs, will replace existing entry");
2005 Ok(FilePlan::Replace(dst_handle))
2006}
2007
2008pub(crate) struct DirSlot {
2010 pub(crate) dir: Arc<Dir>,
2011 pub(crate) summary: Summary,
2012 pub(crate) is_fresh: bool,
2013 pub(crate) we_created: bool,
2014 pub(crate) reused_lock: Option<safedir::ReusedDirLock>,
2020}
2021
2022pub(crate) enum DirResolution {
2024 Proceed(DirSlot),
2026 Skip(Summary),
2028}
2029
2030pub(crate) async fn resolve_dst_dir(
2038 prog_track: &'static progress::Progress,
2039 dst_parent: &Arc<Dir>,
2040 name: &OsStr,
2041 dst_path: &std::path::Path,
2042 settings: &Settings,
2043) -> Result<DirResolution, Error> {
2044 match dst_parent
2045 .make_dir(name, safedir::DST_DIR_CREATE_MODE)
2046 .await
2047 {
2048 Ok(dir) => {
2049 prog_track.directories_created.inc();
2051 Ok(DirResolution::Proceed(DirSlot {
2052 dir: Arc::new(dir),
2053 summary: Summary {
2054 directories_created: 1,
2055 ..Default::default()
2056 },
2057 is_fresh: true,
2058 we_created: true,
2059 reused_lock: None,
2062 }))
2063 }
2064 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2065 let dst_handle = dst_parent
2072 .child(name)
2073 .await
2074 .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
2075 .map_err(|err| Error::new(err, Default::default()))?;
2076 if dst_handle.kind() == EntryKind::Dir {
2077 tracing::debug!("'dst' is a directory, reusing it");
2085 let dir = dst_parent
2086 .open_dir(name)
2087 .await
2088 .with_context(|| format!("cannot open existing directory {:?}", dst_path))
2089 .map_err(|err| Error::new(err, Default::default()))?;
2090 let reused_lock = safedir::lockdown_reused_dir(&dir, &dst_handle)
2095 .await
2096 .with_context(|| {
2097 format!("cannot secure reused destination directory {:?}", dst_path)
2098 })
2099 .map_err(|err| Error::new(err, Default::default()))?;
2100 prog_track.directories_unchanged.inc();
2101 Ok(DirResolution::Proceed(DirSlot {
2102 dir: Arc::new(dir),
2103 summary: Summary {
2104 directories_unchanged: 1,
2105 ..Default::default()
2106 },
2107 is_fresh: false,
2110 we_created: false,
2111 reused_lock,
2112 }))
2113 } else if settings.ignore_existing {
2114 tracing::debug!(
2116 "destination exists but is not a directory, skipping subtree (--ignore-existing)"
2117 );
2118 prog_track.directories_unchanged.inc();
2119 Ok(DirResolution::Skip(Summary {
2120 directories_unchanged: 1,
2121 ..Default::default()
2122 }))
2123 } else if settings.overwrite {
2124 tracing::info!("'dst' is not a directory, removing and creating a new one");
2126 let rm_summary = remove_existing(
2127 prog_track,
2128 dst_parent,
2129 name,
2130 dst_path,
2131 &dst_handle,
2132 settings,
2133 )
2134 .await?;
2135 let dir = dst_parent
2136 .make_dir(name, safedir::DST_DIR_CREATE_MODE)
2137 .await
2138 .with_context(|| format!("cannot create directory {:?}", dst_path))
2139 .map_err(|err| {
2140 Error::new(
2141 err,
2142 Summary {
2143 rm_summary,
2144 ..Default::default()
2145 },
2146 )
2147 })?;
2148 prog_track.directories_created.inc();
2149 Ok(DirResolution::Proceed(DirSlot {
2150 dir: Arc::new(dir),
2151 summary: Summary {
2152 rm_summary,
2153 directories_created: 1,
2154 ..Default::default()
2155 },
2156 is_fresh: true,
2157 we_created: true,
2158 reused_lock: None,
2161 }))
2162 } else {
2163 Err(Error::new(
2164 anyhow!(
2165 "destination {:?} already exists, did you intend to specify --overwrite?",
2166 dst_path
2167 ),
2168 Default::default(),
2169 ))
2170 }
2171 }
2172 Err(error) => {
2173 let error = anyhow::Error::new(error)
2174 .context(format!("cannot create directory {:?}", dst_path));
2175 tracing::error!("{:#}", &error);
2176 Err(Error::new(error, Default::default()))
2177 }
2178 }
2179}
2180
2181pub(crate) async fn remove_existing(
2206 prog_track: &'static progress::Progress,
2207 dst_parent: &Arc<Dir>,
2208 dst_name: &OsStr,
2209 dst_path: &std::path::Path,
2210 dst_handle: &Handle,
2211 settings: &Settings,
2212) -> Result<RmSummary, Error> {
2213 dst_parent
2215 .recheck(dst_name, dst_handle)
2216 .await
2217 .with_context(|| {
2218 format!(
2219 "destination {:?} changed identity before removal (possible TOCTOU swap)",
2220 dst_path
2221 )
2222 })
2223 .map_err(|err| Error::new(err, Default::default()))?;
2224 match dst_handle.kind() {
2225 EntryKind::File | EntryKind::Symlink | EntryKind::Special => {
2226 let removed_size = {
2229 use crate::preserve::Metadata as _;
2230 dst_handle.meta().size()
2231 };
2232 dst_parent
2234 .unlink_at(dst_name)
2235 .await
2236 .with_context(|| format!("failed removing existing destination {:?}", dst_path))
2237 .map_err(|err| Error::new(err, Default::default()))?;
2238 let is_symlink = dst_handle.kind() == EntryKind::Symlink;
2239 if is_symlink {
2240 prog_track.symlinks_removed.inc();
2241 } else {
2242 prog_track.files_removed.inc();
2243 prog_track.bytes_removed.add(removed_size);
2244 }
2245 Ok(RmSummary {
2246 bytes_removed: if is_symlink { 0 } else { removed_size },
2250 files_removed: usize::from(!is_symlink),
2251 symlinks_removed: usize::from(is_symlink),
2252 ..Default::default()
2253 })
2254 }
2255 EntryKind::Dir => {
2256 match dst_parent.rmdir_at(dst_name).await {
2258 Ok(()) => {
2259 prog_track.directories_removed.inc();
2260 Ok(RmSummary {
2261 directories_removed: 1,
2262 ..Default::default()
2263 })
2264 }
2265 Err(error)
2267 if matches!(
2268 error.raw_os_error(),
2269 Some(libc::ENOTEMPTY) | Some(libc::EEXIST)
2270 ) =>
2271 {
2272 rm::rm_child(
2276 prog_track,
2277 dst_parent,
2278 dst_name,
2279 dst_path,
2280 &RmSettings {
2281 fail_early: settings.fail_early,
2282 filter: None,
2283 dry_run: None,
2284 time_filter: None,
2285 },
2286 )
2287 .await
2288 .map_err(|err| {
2289 Error::new(
2290 err.source,
2291 Summary {
2292 rm_summary: err.summary,
2293 ..Default::default()
2294 },
2295 )
2296 })
2297 }
2298 Err(error) => Err(Error::new(
2299 anyhow::Error::new(error)
2300 .context(format!("failed removing existing directory {:?}", dst_path)),
2301 Default::default(),
2302 )),
2303 }
2304 }
2305 }
2306}
2307
2308#[cfg(test)]
2309mod copy_tests {
2310 use crate::testutils;
2311 use anyhow::Context;
2312 use std::os::unix::fs::MetadataExt;
2313 use std::os::unix::fs::PermissionsExt;
2314 use tracing_test::traced_test;
2315
2316 use super::*;
2317
2318 static PROGRESS: std::sync::LazyLock<progress::Progress> =
2319 std::sync::LazyLock::new(progress::Progress::new);
2320 static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
2321 std::sync::LazyLock::new(preserve::preserve_none);
2322 static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
2323 std::sync::LazyLock::new(preserve::preserve_all);
2324
2325 fn settings_with_delete(delete: Option<DeleteSettings>) -> Settings {
2326 Settings {
2327 dereference: false,
2328 fail_early: false,
2329 overwrite: delete.is_some(), overwrite_compare: filecmp::MetadataCmpSettings {
2331 size: true,
2332 mtime: true,
2333 ..Default::default()
2334 },
2335 overwrite_filter: None,
2336 ignore_existing: false,
2337 chunk_size: 0,
2338 skip_specials: false,
2339 remote_copy_buffer_size: 0,
2340 filter: None,
2341 dry_run: None,
2342 delete,
2343 }
2344 }
2345
2346 fn delete_on() -> Option<DeleteSettings> {
2347 Some(DeleteSettings {
2348 delete_excluded: false,
2349 })
2350 }
2351
2352 #[tokio::test]
2356 async fn copies_dot_dot_source_operand() -> Result<(), anyhow::Error> {
2357 let tmp = testutils::create_temp_dir().await?;
2358 let tree = tmp.join("tree");
2359 tokio::fs::create_dir(&tree).await?;
2360 tokio::fs::write(tree.join("a.txt"), "hello").await?;
2361 tokio::fs::create_dir(tree.join("sub")).await?;
2362 let src = tree.join("sub").join(".."); let dst = tmp.join("dst");
2364 let summary = copy(
2365 &PROGRESS,
2366 &src,
2367 &dst,
2368 &settings_with_delete(None),
2369 &NO_PRESERVE_SETTINGS,
2370 false,
2371 )
2372 .await?;
2373 assert_eq!(
2374 summary.files_copied, 1,
2375 "the dot-dot source's file must be copied"
2376 );
2377 assert_eq!(tokio::fs::read_to_string(dst.join("a.txt")).await?, "hello");
2378 assert!(
2379 dst.join("sub").is_dir(),
2380 "the dot-dot source's subdir must be copied"
2381 );
2382 Ok(())
2383 }
2384
2385 #[tokio::test]
2386 #[traced_test]
2387 async fn delete_protects_skipped_special_name() -> Result<(), anyhow::Error> {
2388 let tmp_dir = testutils::setup_test_dir().await?;
2389 let test_path = tmp_dir.as_path();
2390 let src = test_path.join("src_dir");
2391 let dst = test_path.join("dst_dir");
2392 tokio::fs::create_dir(&src).await?;
2393 tokio::fs::write(src.join("file.txt"), "hello").await?;
2394 nix::unistd::mkfifo(
2396 &src.join("pipe"),
2397 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
2398 )?;
2399 tokio::fs::create_dir(&dst).await?;
2401 tokio::fs::write(dst.join("pipe"), "old").await?;
2402 tokio::fs::write(dst.join("stale.txt"), "junk").await?;
2403
2404 let mut settings = settings_with_delete(delete_on());
2405 settings.skip_specials = true;
2406 let summary = copy(
2407 &PROGRESS,
2408 &src,
2409 &dst,
2410 &settings,
2411 &DO_PRESERVE_SETTINGS,
2412 false,
2413 )
2414 .await?;
2415
2416 assert_eq!(summary.specials_skipped, 1);
2417 assert!(dst.join("file.txt").exists());
2418 assert!(
2419 dst.join("pipe").exists(),
2420 "a destination entry matching a skipped special must not be pruned (it has a source counterpart)"
2421 );
2422 assert!(!dst.join("stale.txt").exists()); Ok(())
2424 }
2425
2426 #[tokio::test]
2431 #[traced_test]
2432 async fn copies_into_symlinked_destination_parent() -> Result<(), anyhow::Error> {
2433 let tmp_dir = testutils::setup_test_dir().await?;
2434 let test_path = tmp_dir.as_path();
2435 let src = test_path.join("src.txt");
2436 tokio::fs::write(&src, b"payload").await?;
2437 let real_dir = test_path.join("real_dst_dir");
2439 tokio::fs::create_dir(&real_dir).await?;
2440 let link_dir = test_path.join("link_dst_dir");
2441 tokio::fs::symlink(&real_dir, &link_dir).await?;
2442 let dst = link_dir.join("out.txt");
2444 let summary = copy(
2445 &PROGRESS,
2446 &src,
2447 &dst,
2448 &settings_with_delete(None),
2449 &DO_PRESERVE_SETTINGS,
2450 false,
2451 )
2452 .await?;
2453 assert_eq!(summary.files_copied, 1);
2454 let written = tokio::fs::read(real_dir.join("out.txt")).await?;
2456 assert_eq!(written, b"payload");
2457 Ok(())
2458 }
2459
2460 #[tokio::test]
2463 #[traced_test]
2464 async fn copies_from_symlinked_source_parent() -> Result<(), anyhow::Error> {
2465 let tmp_dir = testutils::setup_test_dir().await?;
2466 let test_path = tmp_dir.as_path();
2467 let real_src_dir = test_path.join("real_src_dir");
2469 tokio::fs::create_dir(&real_src_dir).await?;
2470 tokio::fs::write(real_src_dir.join("src.txt"), b"payload").await?;
2471 let link_src_dir = test_path.join("link_src_dir");
2472 tokio::fs::symlink(&real_src_dir, &link_src_dir).await?;
2473 let src = link_src_dir.join("src.txt");
2474 let dst = test_path.join("out.txt");
2475 let summary = copy(
2476 &PROGRESS,
2477 &src,
2478 &dst,
2479 &settings_with_delete(None),
2480 &DO_PRESERVE_SETTINGS,
2481 false,
2482 )
2483 .await?;
2484 assert_eq!(summary.files_copied, 1);
2485 let written = tokio::fs::read(&dst).await?;
2486 assert_eq!(written, b"payload");
2487 Ok(())
2488 }
2489
2490 #[tokio::test]
2491 #[traced_test]
2492 async fn delete_removes_extraneous_destination_entries() -> Result<(), anyhow::Error> {
2493 let tmp_dir = testutils::setup_test_dir().await?;
2494 let test_path = tmp_dir.as_path();
2495 let src = test_path.join("foo");
2496 let dst = test_path.join("bar");
2497 copy(
2499 &PROGRESS,
2500 &src,
2501 &dst,
2502 &settings_with_delete(None),
2503 &DO_PRESERVE_SETTINGS,
2504 false,
2505 )
2506 .await?;
2507 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2509 tokio::fs::create_dir(dst.join("extra_dir")).await?;
2510 tokio::fs::write(dst.join("extra_dir").join("nested.txt"), b"junk").await?;
2511 let summary = copy(
2513 &PROGRESS,
2514 &src,
2515 &dst,
2516 &settings_with_delete(delete_on()),
2517 &DO_PRESERVE_SETTINGS,
2518 false,
2519 )
2520 .await?;
2521 assert_eq!(summary.rm_summary.files_removed, 2); assert_eq!(summary.rm_summary.directories_removed, 1); assert!(!dst.join("extraneous.txt").exists());
2524 assert!(!dst.join("extra_dir").exists());
2525 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2526 Ok(())
2527 }
2528
2529 #[tokio::test]
2530 #[traced_test]
2531 async fn delete_prunes_extraneous_at_depth() -> Result<(), anyhow::Error> {
2532 let tmp_dir = testutils::setup_test_dir().await?;
2533 let test_path = tmp_dir.as_path();
2534 let src = test_path.join("foo");
2535 let dst = test_path.join("bar");
2536 copy(
2537 &PROGRESS,
2538 &src,
2539 &dst,
2540 &settings_with_delete(None),
2541 &DO_PRESERVE_SETTINGS,
2542 false,
2543 )
2544 .await?;
2545 let nested = dst.join("bar");
2547 assert!(
2548 nested.is_dir(),
2549 "expected common subdirectory bar/ to exist at destination"
2550 );
2551 tokio::fs::write(nested.join("stale_nested.txt"), b"junk").await?;
2552 let summary = copy(
2553 &PROGRESS,
2554 &src,
2555 &dst,
2556 &settings_with_delete(delete_on()),
2557 &DO_PRESERVE_SETTINGS,
2558 false,
2559 )
2560 .await?;
2561 assert!(
2562 !nested.join("stale_nested.txt").exists(),
2563 "stale entry inside a common subdirectory must be pruned"
2564 );
2565 assert!(summary.rm_summary.files_removed >= 1);
2566 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2567 Ok(())
2568 }
2569
2570 #[tokio::test]
2571 #[traced_test]
2572 async fn delete_removes_extraneous_symlink() -> Result<(), anyhow::Error> {
2573 let tmp_dir = testutils::setup_test_dir().await?;
2574 let test_path = tmp_dir.as_path();
2575 let src = test_path.join("foo");
2576 let dst = test_path.join("bar");
2577 copy(
2578 &PROGRESS,
2579 &src,
2580 &dst,
2581 &settings_with_delete(None),
2582 &DO_PRESERVE_SETTINGS,
2583 false,
2584 )
2585 .await?;
2586 tokio::fs::symlink("/nonexistent/target", dst.join("stale_link")).await?;
2588 let summary = copy(
2589 &PROGRESS,
2590 &src,
2591 &dst,
2592 &settings_with_delete(delete_on()),
2593 &DO_PRESERVE_SETTINGS,
2594 false,
2595 )
2596 .await?;
2597 assert!(
2598 tokio::fs::symlink_metadata(dst.join("stale_link"))
2599 .await
2600 .is_err(),
2601 "extraneous symlink must be removed"
2602 );
2603 assert_eq!(summary.rm_summary.symlinks_removed, 1);
2604 Ok(())
2605 }
2606
2607 #[tokio::test]
2608 #[traced_test]
2609 async fn delete_skips_pruning_when_copy_has_errors() -> Result<(), anyhow::Error> {
2610 let tmp_dir = testutils::setup_test_dir().await?;
2611 let test_path = tmp_dir.as_path();
2612 let src = test_path.join("foo");
2613 let dst = test_path.join("bar");
2614 copy(
2616 &PROGRESS,
2617 &src,
2618 &dst,
2619 &settings_with_delete(None),
2620 &DO_PRESERVE_SETTINGS,
2621 false,
2622 )
2623 .await?;
2624 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2626 let unreadable = src.join("baz");
2630 let original = tokio::fs::metadata(&unreadable).await?.permissions();
2631 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2632
2633 let result = copy(
2634 &PROGRESS,
2635 &src,
2636 &dst,
2637 &settings_with_delete(delete_on()),
2638 &DO_PRESERVE_SETTINGS,
2639 false,
2640 )
2641 .await;
2642
2643 tokio::fs::set_permissions(&unreadable, original).await?;
2644
2645 assert!(
2646 result.is_err(),
2647 "copy of the unreadable directory should fail"
2648 );
2649 assert!(
2650 dst.join("extraneous.txt").exists(),
2651 "pruning must be skipped when the copy reported errors"
2652 );
2653 Ok(())
2654 }
2655
2656 #[tokio::test]
2657 #[traced_test]
2658 async fn check_basic_copy() -> Result<(), anyhow::Error> {
2659 let tmp_dir = testutils::setup_test_dir().await?;
2660 let test_path = tmp_dir.as_path();
2661 let summary = copy(
2662 &PROGRESS,
2663 &test_path.join("foo"),
2664 &test_path.join("bar"),
2665 &Settings {
2666 dereference: false,
2667 fail_early: false,
2668 overwrite: false,
2669 overwrite_compare: filecmp::MetadataCmpSettings {
2670 size: true,
2671 mtime: true,
2672 ..Default::default()
2673 },
2674 overwrite_filter: None,
2675 ignore_existing: false,
2676 chunk_size: 0,
2677 skip_specials: false,
2678 remote_copy_buffer_size: 0,
2679 filter: None,
2680 dry_run: None,
2681 delete: None,
2682 },
2683 &NO_PRESERVE_SETTINGS,
2684 false,
2685 )
2686 .await?;
2687 assert_eq!(summary.files_copied, 5);
2688 assert_eq!(summary.symlinks_created, 2);
2689 assert_eq!(summary.directories_created, 3);
2690 testutils::check_dirs_identical(
2691 &test_path.join("foo"),
2692 &test_path.join("bar"),
2693 testutils::FileEqualityCheck::Basic,
2694 )
2695 .await?;
2696 Ok(())
2697 }
2698
2699 #[tokio::test]
2700 #[traced_test]
2701 async fn no_read_permission() -> Result<(), anyhow::Error> {
2702 let tmp_dir = testutils::setup_test_dir().await?;
2703 let test_path = tmp_dir.as_path();
2704 let filepaths = vec![
2705 test_path.join("foo").join("0.txt"),
2706 test_path.join("foo").join("baz"),
2707 ];
2708 for fpath in &filepaths {
2709 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
2711 }
2712 match copy(
2713 &PROGRESS,
2714 &test_path.join("foo"),
2715 &test_path.join("bar"),
2716 &Settings {
2717 dereference: false,
2718 fail_early: false,
2719 overwrite: false,
2720 overwrite_compare: filecmp::MetadataCmpSettings {
2721 size: true,
2722 mtime: true,
2723 ..Default::default()
2724 },
2725 overwrite_filter: None,
2726 ignore_existing: false,
2727 chunk_size: 0,
2728 skip_specials: false,
2729 remote_copy_buffer_size: 0,
2730 filter: None,
2731 dry_run: None,
2732 delete: None,
2733 },
2734 &NO_PRESERVE_SETTINGS,
2735 false,
2736 )
2737 .await
2738 {
2739 Ok(_) => panic!("Expected the copy to error!"),
2740 Err(error) => {
2741 tracing::info!("{:#}", &error);
2742 assert_eq!(error.summary.files_copied, 3);
2753 assert_eq!(error.summary.symlinks_created, 0);
2754 assert_eq!(error.summary.directories_created, 2);
2755 }
2756 }
2757 for fpath in &filepaths {
2759 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
2760 if tokio::fs::symlink_metadata(fpath).await?.is_file() {
2761 tokio::fs::remove_file(fpath).await?;
2762 } else {
2763 tokio::fs::remove_dir_all(fpath).await?;
2764 }
2765 }
2766 testutils::check_dirs_identical(
2767 &test_path.join("foo"),
2768 &test_path.join("bar"),
2769 testutils::FileEqualityCheck::Basic,
2770 )
2771 .await?;
2772 Ok(())
2773 }
2774
2775 #[tokio::test]
2776 #[traced_test]
2777 async fn check_default_mode() -> Result<(), anyhow::Error> {
2778 let tmp_dir = testutils::setup_test_dir().await?;
2779 tokio::fs::set_permissions(
2781 tmp_dir.join("foo").join("0.txt"),
2782 std::fs::Permissions::from_mode(0o700),
2783 )
2784 .await?;
2785 let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
2787 tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
2788 .await?;
2789 let test_path = tmp_dir.as_path();
2790 let summary = copy(
2791 &PROGRESS,
2792 &test_path.join("foo"),
2793 &test_path.join("bar"),
2794 &Settings {
2795 dereference: false,
2796 fail_early: false,
2797 overwrite: false,
2798 overwrite_compare: filecmp::MetadataCmpSettings {
2799 size: true,
2800 mtime: true,
2801 ..Default::default()
2802 },
2803 overwrite_filter: None,
2804 ignore_existing: false,
2805 chunk_size: 0,
2806 skip_specials: false,
2807 remote_copy_buffer_size: 0,
2808 filter: None,
2809 dry_run: None,
2810 delete: None,
2811 },
2812 &NO_PRESERVE_SETTINGS,
2813 false,
2814 )
2815 .await?;
2816 assert_eq!(summary.files_copied, 5);
2817 assert_eq!(summary.symlinks_created, 2);
2818 assert_eq!(summary.directories_created, 3);
2819 tokio::fs::set_permissions(
2821 &exec_sticky_file,
2822 std::fs::Permissions::from_mode(
2823 std::fs::symlink_metadata(&exec_sticky_file)?
2824 .permissions()
2825 .mode()
2826 & 0o0777,
2827 ),
2828 )
2829 .await?;
2830 testutils::check_dirs_identical(
2831 &test_path.join("foo"),
2832 &test_path.join("bar"),
2833 testutils::FileEqualityCheck::Basic,
2834 )
2835 .await?;
2836 Ok(())
2837 }
2838
2839 #[tokio::test]
2840 #[traced_test]
2841 async fn no_write_permission() -> Result<(), anyhow::Error> {
2842 let tmp_dir = testutils::setup_test_dir().await?;
2843 let test_path = tmp_dir.as_path();
2844 let non_exec_dir = test_path.join("foo").join("bogey");
2846 tokio::fs::create_dir(&non_exec_dir).await?;
2847 tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
2848 tokio::fs::set_permissions(
2850 &test_path.join("foo").join("baz"),
2851 std::fs::Permissions::from_mode(0o500),
2852 )
2853 .await?;
2854 tokio::fs::set_permissions(
2856 &test_path.join("foo").join("baz").join("4.txt"),
2857 std::fs::Permissions::from_mode(0o440),
2858 )
2859 .await?;
2860 let summary = copy(
2861 &PROGRESS,
2862 &test_path.join("foo"),
2863 &test_path.join("bar"),
2864 &Settings {
2865 dereference: false,
2866 fail_early: false,
2867 overwrite: false,
2868 overwrite_compare: filecmp::MetadataCmpSettings {
2869 size: true,
2870 mtime: true,
2871 ..Default::default()
2872 },
2873 overwrite_filter: None,
2874 ignore_existing: false,
2875 chunk_size: 0,
2876 skip_specials: false,
2877 remote_copy_buffer_size: 0,
2878 filter: None,
2879 dry_run: None,
2880 delete: None,
2881 },
2882 &NO_PRESERVE_SETTINGS,
2883 false,
2884 )
2885 .await?;
2886 assert_eq!(summary.files_copied, 5);
2887 assert_eq!(summary.symlinks_created, 2);
2888 assert_eq!(summary.directories_created, 4);
2889 testutils::check_dirs_identical(
2890 &test_path.join("foo"),
2891 &test_path.join("bar"),
2892 testutils::FileEqualityCheck::Basic,
2893 )
2894 .await?;
2895 Ok(())
2896 }
2897
2898 #[tokio::test]
2899 #[traced_test]
2900 async fn dereference() -> Result<(), anyhow::Error> {
2901 let tmp_dir = testutils::setup_test_dir().await?;
2902 let test_path = tmp_dir.as_path();
2903 let src1 = &test_path.join("foo").join("bar").join("2.txt");
2905 let src2 = &test_path.join("foo").join("bar").join("3.txt");
2906 let test_mode = 0o440;
2907 for f in [src1, src2] {
2908 tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
2909 }
2910 let summary = copy(
2911 &PROGRESS,
2912 &test_path.join("foo"),
2913 &test_path.join("bar"),
2914 &Settings {
2915 dereference: true, fail_early: false,
2917 overwrite: false,
2918 overwrite_compare: filecmp::MetadataCmpSettings {
2919 size: true,
2920 mtime: true,
2921 ..Default::default()
2922 },
2923 overwrite_filter: None,
2924 ignore_existing: false,
2925 chunk_size: 0,
2926 skip_specials: false,
2927 remote_copy_buffer_size: 0,
2928 filter: None,
2929 dry_run: None,
2930 delete: None,
2931 },
2932 &NO_PRESERVE_SETTINGS,
2933 false,
2934 )
2935 .await?;
2936 assert_eq!(summary.files_copied, 7);
2937 assert_eq!(summary.symlinks_created, 0);
2938 assert_eq!(summary.directories_created, 3);
2939 let dst1 = &test_path.join("bar").join("baz").join("5.txt");
2945 let dst2 = &test_path.join("bar").join("baz").join("6.txt");
2946 for f in [dst1, dst2] {
2947 let metadata = tokio::fs::symlink_metadata(f)
2948 .await
2949 .with_context(|| format!("failed reading metadata from {:?}", &f))?;
2950 assert!(metadata.is_file());
2951 assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
2953 }
2954 Ok(())
2955 }
2956
2957 async fn cp_compare(
2958 cp_args: &[&str],
2959 rcp_settings: &Settings,
2960 preserve: bool,
2961 ) -> Result<(), anyhow::Error> {
2962 let tmp_dir = testutils::setup_test_dir().await?;
2963 let test_path = tmp_dir.as_path();
2964 let cp_output = tokio::process::Command::new("cp")
2966 .args(cp_args)
2967 .arg(test_path.join("foo"))
2968 .arg(test_path.join("bar"))
2969 .output()
2970 .await?;
2971 assert!(cp_output.status.success());
2972 let summary = copy(
2974 &PROGRESS,
2975 &test_path.join("foo"),
2976 &test_path.join("baz"),
2977 rcp_settings,
2978 if preserve {
2979 &DO_PRESERVE_SETTINGS
2980 } else {
2981 &NO_PRESERVE_SETTINGS
2982 },
2983 false,
2984 )
2985 .await?;
2986 if rcp_settings.dereference {
2987 assert_eq!(summary.files_copied, 7);
2988 assert_eq!(summary.symlinks_created, 0);
2989 } else {
2990 assert_eq!(summary.files_copied, 5);
2991 assert_eq!(summary.symlinks_created, 2);
2992 }
2993 assert_eq!(summary.directories_created, 3);
2994 testutils::check_dirs_identical(
2995 &test_path.join("bar"),
2996 &test_path.join("baz"),
2997 if preserve {
2998 testutils::FileEqualityCheck::Timestamp
2999 } else {
3000 testutils::FileEqualityCheck::Basic
3001 },
3002 )
3003 .await?;
3004 Ok(())
3005 }
3006
3007 #[tokio::test]
3008 #[traced_test]
3009 async fn test_cp_compat() -> Result<(), anyhow::Error> {
3010 cp_compare(
3011 &["-r"],
3012 &Settings {
3013 dereference: false,
3014 fail_early: false,
3015 overwrite: false,
3016 overwrite_compare: filecmp::MetadataCmpSettings {
3017 size: true,
3018 mtime: true,
3019 ..Default::default()
3020 },
3021 overwrite_filter: None,
3022 ignore_existing: false,
3023 chunk_size: 0,
3024 skip_specials: false,
3025 remote_copy_buffer_size: 0,
3026 filter: None,
3027 dry_run: None,
3028 delete: None,
3029 },
3030 false,
3031 )
3032 .await?;
3033 Ok(())
3034 }
3035
3036 #[tokio::test]
3037 #[traced_test]
3038 async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
3039 cp_compare(
3040 &["-r", "-p"],
3041 &Settings {
3042 dereference: false,
3043 fail_early: false,
3044 overwrite: false,
3045 overwrite_compare: filecmp::MetadataCmpSettings {
3046 size: true,
3047 mtime: true,
3048 ..Default::default()
3049 },
3050 overwrite_filter: None,
3051 ignore_existing: false,
3052 chunk_size: 0,
3053 skip_specials: false,
3054 remote_copy_buffer_size: 0,
3055 filter: None,
3056 dry_run: None,
3057 delete: None,
3058 },
3059 true,
3060 )
3061 .await?;
3062 Ok(())
3063 }
3064
3065 #[tokio::test]
3066 #[traced_test]
3067 async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
3068 cp_compare(
3069 &["-r", "-L"],
3070 &Settings {
3071 dereference: true,
3072 fail_early: false,
3073 overwrite: false,
3074 overwrite_compare: filecmp::MetadataCmpSettings {
3075 size: true,
3076 mtime: true,
3077 ..Default::default()
3078 },
3079 overwrite_filter: None,
3080 ignore_existing: false,
3081 chunk_size: 0,
3082 skip_specials: false,
3083 remote_copy_buffer_size: 0,
3084 filter: None,
3085 dry_run: None,
3086 delete: None,
3087 },
3088 false,
3089 )
3090 .await?;
3091 Ok(())
3092 }
3093
3094 #[tokio::test]
3095 #[traced_test]
3096 async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
3097 cp_compare(
3098 &["-r", "-p", "-L"],
3099 &Settings {
3100 dereference: true,
3101 fail_early: false,
3102 overwrite: false,
3103 overwrite_compare: filecmp::MetadataCmpSettings {
3104 size: true,
3105 mtime: true,
3106 ..Default::default()
3107 },
3108 overwrite_filter: None,
3109 ignore_existing: false,
3110 chunk_size: 0,
3111 skip_specials: false,
3112 remote_copy_buffer_size: 0,
3113 filter: None,
3114 dry_run: None,
3115 delete: None,
3116 },
3117 true,
3118 )
3119 .await?;
3120 Ok(())
3121 }
3122
3123 async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
3124 let tmp_dir = testutils::setup_test_dir().await?;
3125 let test_path = tmp_dir.as_path();
3126 let summary = copy(
3127 &PROGRESS,
3128 &test_path.join("foo"),
3129 &test_path.join("bar"),
3130 &Settings {
3131 dereference: false,
3132 fail_early: false,
3133 overwrite: false,
3134 overwrite_compare: filecmp::MetadataCmpSettings {
3135 size: true,
3136 mtime: true,
3137 ..Default::default()
3138 },
3139 overwrite_filter: None,
3140 ignore_existing: false,
3141 chunk_size: 0,
3142 skip_specials: false,
3143 remote_copy_buffer_size: 0,
3144 filter: None,
3145 dry_run: None,
3146 delete: None,
3147 },
3148 &DO_PRESERVE_SETTINGS,
3149 false,
3150 )
3151 .await?;
3152 assert_eq!(summary.files_copied, 5);
3153 assert_eq!(summary.symlinks_created, 2);
3154 assert_eq!(summary.directories_created, 3);
3155 Ok(tmp_dir)
3156 }
3157
3158 #[tokio::test]
3159 #[traced_test]
3160 async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
3161 let tmp_dir = setup_test_dir_and_copy().await?;
3162 let output_path = &tmp_dir.join("bar");
3163 {
3164 let summary = rm::rm(
3175 &PROGRESS,
3176 &output_path.join("bar"),
3177 &RmSettings {
3178 fail_early: false,
3179 filter: None,
3180 dry_run: None,
3181 time_filter: None,
3182 },
3183 )
3184 .await?
3185 + rm::rm(
3186 &PROGRESS,
3187 &output_path.join("baz").join("5.txt"),
3188 &RmSettings {
3189 fail_early: false,
3190 filter: None,
3191 dry_run: None,
3192 time_filter: None,
3193 },
3194 )
3195 .await?;
3196 assert_eq!(summary.files_removed, 3);
3197 assert_eq!(summary.symlinks_removed, 1);
3198 assert_eq!(summary.directories_removed, 1);
3199 }
3200 let summary = copy(
3201 &PROGRESS,
3202 &tmp_dir.join("foo"),
3203 output_path,
3204 &Settings {
3205 dereference: false,
3206 fail_early: false,
3207 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3209 size: true,
3210 mtime: true,
3211 ..Default::default()
3212 },
3213 overwrite_filter: None,
3214 ignore_existing: false,
3215 chunk_size: 0,
3216 skip_specials: false,
3217 remote_copy_buffer_size: 0,
3218 filter: None,
3219 dry_run: None,
3220 delete: None,
3221 },
3222 &DO_PRESERVE_SETTINGS,
3223 false,
3224 )
3225 .await?;
3226 assert_eq!(summary.files_copied, 3);
3227 assert_eq!(summary.symlinks_created, 1);
3228 assert_eq!(summary.directories_created, 1);
3229 testutils::check_dirs_identical(
3230 &tmp_dir.join("foo"),
3231 output_path,
3232 testutils::FileEqualityCheck::Timestamp,
3233 )
3234 .await?;
3235 Ok(())
3236 }
3237
3238 #[tokio::test]
3239 #[traced_test]
3240 async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
3241 let tmp_dir = setup_test_dir_and_copy().await?;
3242 let output_path = &tmp_dir.join("bar");
3243 {
3244 let summary = rm::rm(
3255 &PROGRESS,
3256 &output_path.join("bar").join("1.txt"),
3257 &RmSettings {
3258 fail_early: false,
3259 filter: None,
3260 dry_run: None,
3261 time_filter: None,
3262 },
3263 )
3264 .await?
3265 + rm::rm(
3266 &PROGRESS,
3267 &output_path.join("baz"),
3268 &RmSettings {
3269 fail_early: false,
3270 filter: None,
3271 dry_run: None,
3272 time_filter: None,
3273 },
3274 )
3275 .await?;
3276 assert_eq!(summary.files_removed, 2);
3277 assert_eq!(summary.symlinks_removed, 2);
3278 assert_eq!(summary.directories_removed, 1);
3279 }
3280 {
3281 tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
3283 tokio::fs::write(&output_path.join("baz"), "baz").await?;
3285 }
3286 let summary = copy(
3287 &PROGRESS,
3288 &tmp_dir.join("foo"),
3289 output_path,
3290 &Settings {
3291 dereference: false,
3292 fail_early: false,
3293 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3295 size: true,
3296 mtime: true,
3297 ..Default::default()
3298 },
3299 overwrite_filter: None,
3300 ignore_existing: false,
3301 chunk_size: 0,
3302 skip_specials: false,
3303 remote_copy_buffer_size: 0,
3304 filter: None,
3305 dry_run: None,
3306 delete: None,
3307 },
3308 &DO_PRESERVE_SETTINGS,
3309 false,
3310 )
3311 .await?;
3312 assert_eq!(summary.rm_summary.files_removed, 1);
3313 assert_eq!(summary.rm_summary.symlinks_removed, 0);
3314 assert_eq!(summary.rm_summary.directories_removed, 1);
3315 assert_eq!(summary.files_copied, 2);
3316 assert_eq!(summary.symlinks_created, 2);
3317 assert_eq!(summary.directories_created, 1);
3318 testutils::check_dirs_identical(
3319 &tmp_dir.join("foo"),
3320 output_path,
3321 testutils::FileEqualityCheck::Timestamp,
3322 )
3323 .await?;
3324 Ok(())
3325 }
3326
3327 #[tokio::test]
3328 #[traced_test]
3329 async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
3330 let tmp_dir = setup_test_dir_and_copy().await?;
3331 let output_path = &tmp_dir.join("bar");
3332 {
3333 let summary = rm::rm(
3340 &PROGRESS,
3341 &output_path.join("baz").join("4.txt"),
3342 &RmSettings {
3343 fail_early: false,
3344 filter: None,
3345 dry_run: None,
3346 time_filter: None,
3347 },
3348 )
3349 .await?
3350 + rm::rm(
3351 &PROGRESS,
3352 &output_path.join("baz").join("5.txt"),
3353 &RmSettings {
3354 fail_early: false,
3355 filter: None,
3356 dry_run: None,
3357 time_filter: None,
3358 },
3359 )
3360 .await?;
3361 assert_eq!(summary.files_removed, 1);
3362 assert_eq!(summary.symlinks_removed, 1);
3363 assert_eq!(summary.directories_removed, 0);
3364 }
3365 {
3366 tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
3368 tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
3370 }
3371 let summary = copy(
3372 &PROGRESS,
3373 &tmp_dir.join("foo"),
3374 output_path,
3375 &Settings {
3376 dereference: false,
3377 fail_early: false,
3378 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3380 size: true,
3381 mtime: true,
3382 ..Default::default()
3383 },
3384 overwrite_filter: None,
3385 ignore_existing: false,
3386 chunk_size: 0,
3387 skip_specials: false,
3388 remote_copy_buffer_size: 0,
3389 filter: None,
3390 dry_run: None,
3391 delete: None,
3392 },
3393 &DO_PRESERVE_SETTINGS,
3394 false,
3395 )
3396 .await?;
3397 assert_eq!(summary.rm_summary.files_removed, 1);
3398 assert_eq!(summary.rm_summary.symlinks_removed, 1);
3399 assert_eq!(summary.rm_summary.directories_removed, 0);
3400 assert_eq!(summary.files_copied, 1);
3401 assert_eq!(summary.symlinks_created, 1);
3402 assert_eq!(summary.directories_created, 0);
3403 testutils::check_dirs_identical(
3404 &tmp_dir.join("foo"),
3405 output_path,
3406 testutils::FileEqualityCheck::Timestamp,
3407 )
3408 .await?;
3409 Ok(())
3410 }
3411
3412 #[tokio::test]
3413 #[traced_test]
3414 async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
3415 let tmp_dir = setup_test_dir_and_copy().await?;
3416 let output_path = &tmp_dir.join("bar");
3417 {
3418 let summary = rm::rm(
3428 &PROGRESS,
3429 &output_path.join("bar"),
3430 &RmSettings {
3431 fail_early: false,
3432 filter: None,
3433 dry_run: None,
3434 time_filter: None,
3435 },
3436 )
3437 .await?
3438 + rm::rm(
3439 &PROGRESS,
3440 &output_path.join("baz").join("5.txt"),
3441 &RmSettings {
3442 fail_early: false,
3443 filter: None,
3444 dry_run: None,
3445 time_filter: None,
3446 },
3447 )
3448 .await?;
3449 assert_eq!(summary.files_removed, 3);
3450 assert_eq!(summary.symlinks_removed, 1);
3451 assert_eq!(summary.directories_removed, 1);
3452 }
3453 {
3454 tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
3456 tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
3458 }
3459 let summary = copy(
3460 &PROGRESS,
3461 &tmp_dir.join("foo"),
3462 output_path,
3463 &Settings {
3464 dereference: false,
3465 fail_early: false,
3466 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3468 size: true,
3469 mtime: true,
3470 ..Default::default()
3471 },
3472 overwrite_filter: None,
3473 ignore_existing: false,
3474 chunk_size: 0,
3475 skip_specials: false,
3476 remote_copy_buffer_size: 0,
3477 filter: None,
3478 dry_run: None,
3479 delete: None,
3480 },
3481 &DO_PRESERVE_SETTINGS,
3482 false,
3483 )
3484 .await?;
3485 assert_eq!(summary.rm_summary.files_removed, 0);
3486 assert_eq!(summary.rm_summary.symlinks_removed, 1);
3487 assert_eq!(summary.rm_summary.directories_removed, 1);
3488 assert_eq!(summary.files_copied, 3);
3489 assert_eq!(summary.symlinks_created, 1);
3490 assert_eq!(summary.directories_created, 1);
3491 assert_eq!(summary.files_unchanged, 2);
3492 assert_eq!(summary.symlinks_unchanged, 1);
3493 assert_eq!(summary.directories_unchanged, 2);
3494 testutils::check_dirs_identical(
3495 &tmp_dir.join("foo"),
3496 output_path,
3497 testutils::FileEqualityCheck::Timestamp,
3498 )
3499 .await?;
3500 Ok(())
3501 }
3502
3503 #[tokio::test]
3504 #[traced_test]
3505 async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
3506 let tmp_dir = testutils::setup_test_dir().await?;
3507 let test_path = tmp_dir.as_path();
3508 let summary = copy(
3509 &PROGRESS,
3510 &test_path.join("foo"),
3511 &test_path.join("bar"),
3512 &Settings {
3513 dereference: false,
3514 fail_early: false,
3515 overwrite: false,
3516 overwrite_compare: filecmp::MetadataCmpSettings {
3517 size: true,
3518 mtime: true,
3519 ..Default::default()
3520 },
3521 overwrite_filter: None,
3522 ignore_existing: false,
3523 chunk_size: 0,
3524 skip_specials: false,
3525 remote_copy_buffer_size: 0,
3526 filter: None,
3527 dry_run: None,
3528 delete: None,
3529 },
3530 &NO_PRESERVE_SETTINGS, false,
3532 )
3533 .await?;
3534 assert_eq!(summary.files_copied, 5);
3535 assert_eq!(summary.symlinks_created, 2);
3536 assert_eq!(summary.directories_created, 3);
3537 let source_path = &test_path.join("foo");
3538 let output_path = &tmp_dir.join("bar");
3539 tokio::fs::set_permissions(
3541 &source_path.join("bar"),
3542 std::fs::Permissions::from_mode(0o000),
3543 )
3544 .await?;
3545 tokio::fs::set_permissions(
3546 &source_path.join("baz").join("4.txt"),
3547 std::fs::Permissions::from_mode(0o000),
3548 )
3549 .await?;
3550 match copy(
3558 &PROGRESS,
3559 &tmp_dir.join("foo"),
3560 output_path,
3561 &Settings {
3562 dereference: false,
3563 fail_early: false,
3564 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3566 size: true,
3567 mtime: true,
3568 ..Default::default()
3569 },
3570 overwrite_filter: None,
3571 ignore_existing: false,
3572 chunk_size: 0,
3573 skip_specials: false,
3574 remote_copy_buffer_size: 0,
3575 filter: None,
3576 dry_run: None,
3577 delete: None,
3578 },
3579 &DO_PRESERVE_SETTINGS,
3580 false,
3581 )
3582 .await
3583 {
3584 Ok(_) => panic!("Expected the copy to error!"),
3585 Err(error) => {
3586 tracing::info!("{:#}", &error);
3587 assert_eq!(error.summary.files_copied, 1);
3588 assert_eq!(error.summary.symlinks_created, 2);
3589 assert_eq!(error.summary.directories_created, 0);
3590 assert_eq!(error.summary.rm_summary.files_removed, 1);
3596 assert!(
3597 output_path.join("baz").join("4.txt").exists(),
3598 "the destination of an unreadable source must not be removed"
3599 );
3600 assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
3601 assert_eq!(error.summary.rm_summary.directories_removed, 0);
3602 }
3603 }
3604 Ok(())
3605 }
3606
3607 #[tokio::test]
3608 #[traced_test]
3609 async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
3610 let tmp_dir = testutils::create_temp_dir().await?;
3612 let test_path = tmp_dir.as_path();
3613 let baz_file = test_path.join("baz_file.txt");
3615 tokio::fs::write(&baz_file, "final content").await?;
3616 let bar_link = test_path.join("bar_link");
3617 let foo_link = test_path.join("foo_link");
3618 tokio::fs::symlink(&baz_file, &bar_link).await?;
3620 tokio::fs::symlink(&bar_link, &foo_link).await?;
3621 let src_dir = test_path.join("src_chain");
3623 tokio::fs::create_dir(&src_dir).await?;
3624 tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
3626 tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
3627 tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
3628 let summary = copy(
3630 &PROGRESS,
3631 &src_dir,
3632 &test_path.join("dst_with_deref"),
3633 &Settings {
3634 dereference: true, fail_early: false,
3636 overwrite: false,
3637 overwrite_compare: filecmp::MetadataCmpSettings {
3638 size: true,
3639 mtime: true,
3640 ..Default::default()
3641 },
3642 overwrite_filter: None,
3643 ignore_existing: false,
3644 chunk_size: 0,
3645 skip_specials: false,
3646 remote_copy_buffer_size: 0,
3647 filter: None,
3648 dry_run: None,
3649 delete: None,
3650 },
3651 &NO_PRESERVE_SETTINGS,
3652 false,
3653 )
3654 .await?;
3655 assert_eq!(summary.files_copied, 3); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1);
3658 let dst_dir = test_path.join("dst_with_deref");
3659 let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
3661 let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
3662 let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
3663 assert_eq!(foo_content, "final content");
3664 assert_eq!(bar_content, "final content");
3665 assert_eq!(baz_content, "final content");
3666 assert!(dst_dir.join("foo").is_file());
3668 assert!(dst_dir.join("bar").is_file());
3669 assert!(dst_dir.join("baz").is_file());
3670 assert!(!dst_dir.join("foo").is_symlink());
3671 assert!(!dst_dir.join("bar").is_symlink());
3672 assert!(!dst_dir.join("baz").is_symlink());
3673 Ok(())
3674 }
3675
3676 #[tokio::test]
3677 #[traced_test]
3678 async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
3679 let tmp_dir = testutils::create_temp_dir().await?;
3680 let test_path = tmp_dir.as_path();
3681 let target_dir = test_path.join("target_dir");
3683 tokio::fs::create_dir(&target_dir).await?;
3684 tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
3685 tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
3687 tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
3688 tokio::fs::set_permissions(
3689 &target_dir.join("file1.txt"),
3690 std::fs::Permissions::from_mode(0o644),
3691 )
3692 .await?;
3693 tokio::fs::set_permissions(
3694 &target_dir.join("file2.txt"),
3695 std::fs::Permissions::from_mode(0o600),
3696 )
3697 .await?;
3698 let dir_symlink = test_path.join("dir_symlink");
3700 tokio::fs::symlink(&target_dir, &dir_symlink).await?;
3701 let summary = copy(
3703 &PROGRESS,
3704 &dir_symlink,
3705 &test_path.join("copied_dir"),
3706 &Settings {
3707 dereference: true, fail_early: false,
3709 overwrite: false,
3710 overwrite_compare: filecmp::MetadataCmpSettings {
3711 size: true,
3712 mtime: true,
3713 ..Default::default()
3714 },
3715 overwrite_filter: None,
3716 ignore_existing: false,
3717 chunk_size: 0,
3718 skip_specials: false,
3719 remote_copy_buffer_size: 0,
3720 filter: None,
3721 dry_run: None,
3722 delete: None,
3723 },
3724 &DO_PRESERVE_SETTINGS,
3725 false,
3726 )
3727 .await?;
3728 assert_eq!(summary.files_copied, 2); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1); let copied_dir = test_path.join("copied_dir");
3732 assert!(copied_dir.is_dir());
3734 assert!(!copied_dir.is_symlink()); let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
3737 let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
3738 assert_eq!(file1_content, "content1");
3739 assert_eq!(file2_content, "content2");
3740 let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
3742 let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
3743 let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
3744 assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
3745 assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
3746 assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
3747 Ok(())
3748 }
3749
3750 #[tokio::test]
3751 #[traced_test]
3752 async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
3753 let tmp_dir = testutils::create_temp_dir().await?;
3754 let test_path = tmp_dir.as_path();
3755 let file1 = test_path.join("file1.txt");
3757 let file2 = test_path.join("file2.txt");
3758 tokio::fs::write(&file1, "content1").await?;
3759 tokio::fs::write(&file2, "content2").await?;
3760 tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
3761 tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
3762 let symlink1 = test_path.join("symlink1");
3764 let symlink2 = test_path.join("symlink2");
3765 tokio::fs::symlink(&file1, &symlink1).await?;
3766 tokio::fs::symlink(&file2, &symlink2).await?;
3767 let summary1 = copy(
3769 &PROGRESS,
3770 &symlink1,
3771 &test_path.join("copied_file1.txt"),
3772 &Settings {
3773 dereference: true, fail_early: false,
3775 overwrite: false,
3776 overwrite_compare: filecmp::MetadataCmpSettings::default(),
3777 overwrite_filter: None,
3778 ignore_existing: false,
3779 chunk_size: 0,
3780 skip_specials: false,
3781 remote_copy_buffer_size: 0,
3782 filter: None,
3783 dry_run: None,
3784 delete: None,
3785 },
3786 &DO_PRESERVE_SETTINGS, false,
3788 )
3789 .await?;
3790 let summary2 = copy(
3791 &PROGRESS,
3792 &symlink2,
3793 &test_path.join("copied_file2.txt"),
3794 &Settings {
3795 dereference: true,
3796 fail_early: false,
3797 overwrite: false,
3798 overwrite_compare: filecmp::MetadataCmpSettings::default(),
3799 overwrite_filter: None,
3800 ignore_existing: false,
3801 chunk_size: 0,
3802 skip_specials: false,
3803 remote_copy_buffer_size: 0,
3804 filter: None,
3805 dry_run: None,
3806 delete: None,
3807 },
3808 &DO_PRESERVE_SETTINGS,
3809 false,
3810 )
3811 .await?;
3812 assert_eq!(summary1.files_copied, 1);
3813 assert_eq!(summary1.symlinks_created, 0);
3814 assert_eq!(summary2.files_copied, 1);
3815 assert_eq!(summary2.symlinks_created, 0);
3816 let copied1 = test_path.join("copied_file1.txt");
3817 let copied2 = test_path.join("copied_file2.txt");
3818 assert!(copied1.is_file());
3820 assert!(!copied1.is_symlink());
3821 assert!(copied2.is_file());
3822 assert!(!copied2.is_symlink());
3823 let content1 = tokio::fs::read_to_string(&copied1).await?;
3825 let content2 = tokio::fs::read_to_string(&copied2).await?;
3826 assert_eq!(content1, "content1");
3827 assert_eq!(content2, "content2");
3828 let copied1_metadata = tokio::fs::metadata(&copied1).await?;
3830 let copied2_metadata = tokio::fs::metadata(&copied2).await?;
3831 assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
3832 assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
3833 Ok(())
3834 }
3835
3836 #[tokio::test]
3837 #[traced_test]
3838 async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
3839 let tmp_dir = testutils::setup_test_dir().await?;
3840 tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
3842 tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
3844 let summary = copy(
3845 &PROGRESS,
3846 &tmp_dir.join("foo"),
3847 &tmp_dir.join("bar"),
3848 &Settings {
3849 dereference: true, fail_early: false,
3851 overwrite: false,
3852 overwrite_compare: filecmp::MetadataCmpSettings {
3853 size: true,
3854 mtime: true,
3855 ..Default::default()
3856 },
3857 overwrite_filter: None,
3858 ignore_existing: false,
3859 chunk_size: 0,
3860 skip_specials: false,
3861 remote_copy_buffer_size: 0,
3862 filter: None,
3863 dry_run: None,
3864 delete: None,
3865 },
3866 &DO_PRESERVE_SETTINGS,
3867 false,
3868 )
3869 .await?;
3870 assert_eq!(summary.files_copied, 13); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 5);
3873 tokio::process::Command::new("cp")
3875 .args(["-r", "-L"])
3876 .arg(tmp_dir.join("foo"))
3877 .arg(tmp_dir.join("bar-cp"))
3878 .output()
3879 .await?;
3880 testutils::check_dirs_identical(
3881 &tmp_dir.join("bar"),
3882 &tmp_dir.join("bar-cp"),
3883 testutils::FileEqualityCheck::Basic,
3884 )
3885 .await?;
3886 Ok(())
3887 }
3888
3889 mod error_message_tests {
3891 use super::*;
3892
3893 fn get_full_error_message(error: &Error) -> String {
3895 format!("{:#}", error.source)
3896 }
3897
3898 #[tokio::test]
3899 #[traced_test]
3900 async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
3901 let tmp_dir = testutils::create_temp_dir().await?;
3902
3903 let result = copy(
3904 &PROGRESS,
3905 &tmp_dir.join("does_not_exist.txt"),
3906 &tmp_dir.join("dest.txt"),
3907 &Settings {
3908 dereference: false,
3909 fail_early: false,
3910 overwrite: false,
3911 overwrite_compare: Default::default(),
3912 overwrite_filter: None,
3913 ignore_existing: false,
3914 chunk_size: 0,
3915 skip_specials: false,
3916 remote_copy_buffer_size: 0,
3917 filter: None,
3918 dry_run: None,
3919 delete: None,
3920 },
3921 &NO_PRESERVE_SETTINGS,
3922 false,
3923 )
3924 .await;
3925
3926 assert!(result.is_err());
3927 let err_msg = get_full_error_message(&result.unwrap_err());
3928
3929 assert!(
3930 err_msg.to_lowercase().contains("no such file")
3931 || err_msg.to_lowercase().contains("not found")
3932 || err_msg.contains("ENOENT"),
3933 "Error message must include file not found text. Got: {}",
3934 err_msg
3935 );
3936 Ok(())
3937 }
3938
3939 #[tokio::test]
3940 #[traced_test]
3941 async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
3942 let tmp_dir = testutils::create_temp_dir().await?;
3943 let unreadable_dir = tmp_dir.join("unreadable_dir");
3944 tokio::fs::create_dir(&unreadable_dir).await?;
3945 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
3946 .await?;
3947
3948 let result = copy(
3949 &PROGRESS,
3950 &unreadable_dir,
3951 &tmp_dir.join("dest"),
3952 &Settings {
3953 dereference: false,
3954 fail_early: true,
3955 overwrite: false,
3956 overwrite_compare: Default::default(),
3957 overwrite_filter: None,
3958 ignore_existing: false,
3959 chunk_size: 0,
3960 skip_specials: false,
3961 remote_copy_buffer_size: 0,
3962 filter: None,
3963 dry_run: None,
3964 delete: None,
3965 },
3966 &NO_PRESERVE_SETTINGS,
3967 false,
3968 )
3969 .await;
3970
3971 assert!(result.is_err());
3972 let err_msg = get_full_error_message(&result.unwrap_err());
3973
3974 assert!(
3975 err_msg.to_lowercase().contains("permission")
3976 || err_msg.contains("EACCES")
3977 || err_msg.contains("denied"),
3978 "Error message must include permission-related text. Got: {}",
3979 err_msg
3980 );
3981
3982 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
3984 .await?;
3985 Ok(())
3986 }
3987
3988 #[tokio::test]
3989 #[traced_test]
3990 async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
3991 {
3992 let tmp_dir = testutils::setup_test_dir().await?;
3993 let test_path = tmp_dir.as_path();
3994 let readonly_parent = test_path.join("readonly_dest");
3995 tokio::fs::create_dir(&readonly_parent).await?;
3996 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3997 .await?;
3998
3999 let result = copy(
4000 &PROGRESS,
4001 &test_path.join("foo"),
4002 &readonly_parent.join("copy"),
4003 &Settings {
4004 dereference: false,
4005 fail_early: true,
4006 overwrite: false,
4007 overwrite_compare: Default::default(),
4008 overwrite_filter: None,
4009 ignore_existing: false,
4010 chunk_size: 0,
4011 skip_specials: false,
4012 remote_copy_buffer_size: 0,
4013 filter: None,
4014 dry_run: None,
4015 delete: None,
4016 },
4017 &NO_PRESERVE_SETTINGS,
4018 false,
4019 )
4020 .await;
4021
4022 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
4024 .await?;
4025
4026 assert!(result.is_err(), "copy into read-only parent should fail");
4027 let err_msg = get_full_error_message(&result.unwrap_err());
4028
4029 assert!(
4030 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
4031 "Error message must include permission denied text. Got: {}",
4032 err_msg
4033 );
4034 Ok(())
4035 }
4036
4037 #[tokio::test]
4043 #[traced_test]
4044 async fn default_reused_readonly_dir_still_fails_eacces() -> Result<(), anyhow::Error> {
4045 let tmp_dir = testutils::setup_test_dir().await?;
4046 let test_path = tmp_dir.as_path();
4047 let dst = test_path.join("dst");
4049 tokio::fs::create_dir(&dst).await?;
4050 tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o500)).await?;
4051 let result = copy(
4052 &PROGRESS,
4053 &test_path.join("foo"),
4054 &dst,
4055 &Settings {
4056 dereference: false,
4057 fail_early: true,
4058 overwrite: true,
4059 overwrite_compare: Default::default(),
4060 overwrite_filter: None,
4061 ignore_existing: false,
4062 chunk_size: 0,
4063 skip_specials: false,
4064 remote_copy_buffer_size: 0,
4065 filter: None,
4066 dry_run: None,
4067 delete: None,
4068 },
4069 &NO_PRESERVE_SETTINGS,
4070 false,
4071 )
4072 .await;
4073 tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o700)).await?;
4075 assert!(
4076 result.is_err(),
4077 "copy into a read-only reused directory must fail without strict mode"
4078 );
4079 let err_msg = get_full_error_message(&result.unwrap_err());
4080 assert!(
4081 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
4082 "error must be a permission error. Got: {}",
4083 err_msg
4084 );
4085 Ok(())
4086 }
4087 }
4088
4089 mod empty_dir_cleanup_tests {
4090 use super::*;
4091 use crate::filter::FilterSettings;
4092 use std::path::Path;
4093 #[test]
4094 fn test_check_empty_dir_cleanup_no_filter() {
4095 assert_eq!(
4097 check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
4098 EmptyDirAction::Keep
4099 );
4100 }
4101 #[test]
4102 fn test_check_empty_dir_cleanup_something_copied() {
4103 let mut filter = FilterSettings::new();
4105 filter.add_include("*.txt").unwrap();
4106 assert_eq!(
4107 check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
4108 EmptyDirAction::Keep
4109 );
4110 }
4111 #[test]
4112 fn test_check_empty_dir_cleanup_not_created() {
4113 let mut filter = FilterSettings::new();
4115 filter.add_include("*.txt").unwrap();
4116 assert_eq!(
4117 check_empty_dir_cleanup(
4118 Some(&filter),
4119 false,
4120 false,
4121 Path::new("any"),
4122 false,
4123 false
4124 ),
4125 EmptyDirAction::Keep
4126 );
4127 }
4128 #[test]
4129 fn test_check_empty_dir_cleanup_directly_matched() {
4130 let mut filter = FilterSettings::new();
4132 filter.add_include("target/").unwrap();
4133 assert_eq!(
4134 check_empty_dir_cleanup(
4135 Some(&filter),
4136 true,
4137 false,
4138 Path::new("target"),
4139 false,
4140 false
4141 ),
4142 EmptyDirAction::Keep
4143 );
4144 }
4145 #[test]
4146 fn test_check_empty_dir_cleanup_traversed_only() {
4147 let mut filter = FilterSettings::new();
4149 filter.add_include("*.txt").unwrap();
4150 assert_eq!(
4151 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
4152 EmptyDirAction::Remove
4153 );
4154 }
4155 #[test]
4156 fn test_check_empty_dir_cleanup_dry_run() {
4157 let mut filter = FilterSettings::new();
4159 filter.add_include("*.txt").unwrap();
4160 assert_eq!(
4161 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
4162 EmptyDirAction::DryRunSkip
4163 );
4164 }
4165 #[test]
4166 fn test_check_empty_dir_cleanup_root_always_kept() {
4167 let mut filter = FilterSettings::new();
4169 filter.add_include("*.txt").unwrap();
4170 assert_eq!(
4171 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
4172 EmptyDirAction::Keep
4173 );
4174 }
4175 #[test]
4176 fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
4177 let mut filter = FilterSettings::new();
4179 filter.add_include("*.txt").unwrap();
4180 assert_eq!(
4181 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
4182 EmptyDirAction::Keep
4183 );
4184 }
4185 }
4186
4187 #[tokio::test]
4191 #[traced_test]
4192 async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
4193 let tmp_dir = testutils::create_temp_dir().await?;
4194 let test_path = tmp_dir.as_path();
4195 let src_dir = test_path.join("src");
4197 tokio::fs::create_dir(&src_dir).await?;
4198 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
4199 let readable_file = src_dir.join("readable.txt");
4201 tokio::fs::write(&readable_file, "content").await?;
4202 let unreadable_file = src_dir.join("unreadable.txt");
4203 tokio::fs::write(&unreadable_file, "secret").await?;
4204 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
4205 .await?;
4206 let dst_dir = test_path.join("dst");
4207 let result = copy(
4209 &PROGRESS,
4210 &src_dir,
4211 &dst_dir,
4212 &Settings {
4213 dereference: false,
4214 fail_early: false,
4215 overwrite: false,
4216 overwrite_compare: Default::default(),
4217 overwrite_filter: None,
4218 ignore_existing: false,
4219 chunk_size: 0,
4220 skip_specials: false,
4221 remote_copy_buffer_size: 0,
4222 filter: None,
4223 dry_run: None,
4224 delete: None,
4225 },
4226 &DO_PRESERVE_SETTINGS,
4227 false,
4228 )
4229 .await;
4230 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
4232 .await?;
4233 assert!(result.is_err(), "copy should fail due to unreadable file");
4235 let error = result.unwrap_err();
4236 assert_eq!(error.summary.files_copied, 1);
4238 assert_eq!(error.summary.directories_created, 1);
4239 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
4241 assert!(dst_metadata.is_dir());
4242 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
4243 assert_eq!(
4244 actual_mode, 0o750,
4245 "directory should have preserved source permissions (0o750), got {:o}",
4246 actual_mode
4247 );
4248 Ok(())
4249 }
4250
4251 #[tokio::test]
4255 #[traced_test]
4256 async fn pruned_empty_traversal_dir_still_surfaces_child_error() -> Result<(), anyhow::Error> {
4257 let tmp_dir = testutils::create_temp_dir().await?;
4258 let test_path = tmp_dir.as_path();
4259 let src_dir = test_path.join("src");
4260 let sub = src_dir.join("sub");
4261 tokio::fs::create_dir_all(&sub).await?;
4262 let unreadable = sub.join("file.txt");
4265 tokio::fs::write(&unreadable, "secret").await?;
4266 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
4267 let dst_dir = test_path.join("dst");
4268 let mut filter = crate::filter::FilterSettings::new();
4269 filter.add_include("*.txt").unwrap();
4270 let result = copy(
4271 &PROGRESS,
4272 &src_dir,
4273 &dst_dir,
4274 &Settings {
4275 dereference: false,
4276 fail_early: false,
4277 overwrite: false,
4278 overwrite_compare: Default::default(),
4279 overwrite_filter: None,
4280 ignore_existing: false,
4281 chunk_size: 0,
4282 skip_specials: false,
4283 remote_copy_buffer_size: 0,
4284 filter: Some(filter),
4285 dry_run: None,
4286 delete: None,
4287 },
4288 &DO_PRESERVE_SETTINGS,
4289 false,
4290 )
4291 .await;
4292 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).await?;
4294 assert!(
4295 result.is_err(),
4296 "the failed child copy must surface even though the empty traversal dir was pruned"
4297 );
4298 assert!(
4300 !dst_dir.join("sub").exists(),
4301 "empty traversal directory should have been removed"
4302 );
4303 Ok(())
4304 }
4305
4306 #[tokio::test]
4308 #[traced_test]
4309 async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error()
4310 -> Result<(), anyhow::Error> {
4311 let tmp_dir = testutils::create_temp_dir().await?;
4312 let test_path = tmp_dir.as_path();
4313 let src_dir = test_path.join("src");
4314 tokio::fs::create_dir(&src_dir).await?;
4315 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
4316 let unreadable_file = src_dir.join("unreadable.txt");
4317 tokio::fs::write(&unreadable_file, "secret").await?;
4318 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
4319 .await?;
4320 let fixed_secs = 946684800;
4321 let fixed_nsec = 123_456_789;
4322 let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
4323 nix::sys::stat::utimensat(
4324 nix::fcntl::AT_FDCWD,
4325 &src_dir,
4326 &fixed_time,
4327 &fixed_time,
4328 nix::sys::stat::UtimensatFlags::NoFollowSymlink,
4329 )?;
4330 let src_metadata = tokio::fs::metadata(&src_dir).await?;
4331 let dst_dir = test_path.join("dst");
4332 let result = copy(
4333 &PROGRESS,
4334 &src_dir,
4335 &dst_dir,
4336 &Settings {
4337 dereference: false,
4338 fail_early: true,
4339 overwrite: false,
4340 overwrite_compare: Default::default(),
4341 overwrite_filter: None,
4342 ignore_existing: false,
4343 chunk_size: 0,
4344 skip_specials: false,
4345 remote_copy_buffer_size: 0,
4346 filter: None,
4347 dry_run: None,
4348 delete: None,
4349 },
4350 &DO_PRESERVE_SETTINGS,
4351 false,
4352 )
4353 .await;
4354 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
4355 .await?;
4356 assert!(result.is_err(), "copy should fail due to unreadable file");
4357 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
4358 assert!(dst_metadata.is_dir());
4359 assert_ne!(
4360 (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
4361 (src_metadata.mtime(), src_metadata.mtime_nsec()),
4362 "fail-early should return before applying preserved directory timestamps"
4363 );
4364 Ok(())
4365 }
4366 mod filter_tests {
4367 use super::*;
4368 use crate::filter::FilterSettings;
4369 #[tokio::test]
4373 #[traced_test]
4374 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
4375 let tmp_dir = testutils::setup_test_dir().await?;
4376 let test_path = tmp_dir.as_path();
4377 let mut filter = FilterSettings::new();
4389 filter.add_include("bar/*.txt").unwrap();
4390 let summary = copy(
4391 &PROGRESS,
4392 &test_path.join("foo"),
4393 &test_path.join("dst"),
4394 &Settings {
4395 dereference: false,
4396 fail_early: false,
4397 overwrite: false,
4398 overwrite_compare: Default::default(),
4399 overwrite_filter: None,
4400 ignore_existing: false,
4401 chunk_size: 0,
4402 skip_specials: false,
4403 remote_copy_buffer_size: 0,
4404 filter: Some(filter),
4405 dry_run: None,
4406 delete: None,
4407 },
4408 &NO_PRESERVE_SETTINGS,
4409 false,
4410 )
4411 .await?;
4412 assert_eq!(
4415 summary.files_copied, 3,
4416 "should copy 3 files matching bar/*.txt"
4417 );
4418 assert!(
4420 test_path.join("dst/bar/1.txt").exists(),
4421 "bar/1.txt should be copied"
4422 );
4423 assert!(
4424 test_path.join("dst/bar/2.txt").exists(),
4425 "bar/2.txt should be copied"
4426 );
4427 assert!(
4428 test_path.join("dst/bar/3.txt").exists(),
4429 "bar/3.txt should be copied"
4430 );
4431 assert!(
4433 !test_path.join("dst/0.txt").exists(),
4434 "0.txt should not be copied"
4435 );
4436 Ok(())
4437 }
4438 #[tokio::test]
4440 #[traced_test]
4441 async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
4442 let tmp_dir = testutils::setup_test_dir().await?;
4443 let test_path = tmp_dir.as_path();
4444 let mut filter = FilterSettings::new();
4446 filter.add_include("/bar/**").unwrap();
4447 let summary = copy(
4448 &PROGRESS,
4449 &test_path.join("foo"),
4450 &test_path.join("dst"),
4451 &Settings {
4452 dereference: false,
4453 fail_early: false,
4454 overwrite: false,
4455 overwrite_compare: Default::default(),
4456 overwrite_filter: None,
4457 ignore_existing: false,
4458 chunk_size: 0,
4459 skip_specials: false,
4460 remote_copy_buffer_size: 0,
4461 filter: Some(filter),
4462 dry_run: None,
4463 delete: None,
4464 },
4465 &NO_PRESERVE_SETTINGS,
4466 false,
4467 )
4468 .await?;
4469 assert!(
4471 test_path.join("dst/bar").exists(),
4472 "bar directory should be copied"
4473 );
4474 assert!(
4475 !test_path.join("dst/baz").exists(),
4476 "baz directory should not be copied"
4477 );
4478 assert!(
4479 !test_path.join("dst/0.txt").exists(),
4480 "0.txt should not be copied"
4481 );
4482 assert_eq!(
4484 summary.files_copied, 3,
4485 "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
4486 );
4487 assert_eq!(
4488 summary.directories_created, 2,
4489 "should create 2 directories (root dst + bar)"
4490 );
4491 assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
4493 assert_eq!(
4494 summary.directories_skipped, 1,
4495 "should skip 1 directory (baz)"
4496 );
4497 Ok(())
4498 }
4499 #[tokio::test]
4501 #[traced_test]
4502 async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
4503 let tmp_dir = testutils::setup_test_dir().await?;
4504 let test_path = tmp_dir.as_path();
4505 let mut filter = FilterSettings::new();
4507 filter.add_include("**/*.txt").unwrap();
4508 let summary = copy(
4509 &PROGRESS,
4510 &test_path.join("foo"),
4511 &test_path.join("dst"),
4512 &Settings {
4513 dereference: false,
4514 fail_early: false,
4515 overwrite: false,
4516 overwrite_compare: Default::default(),
4517 overwrite_filter: None,
4518 ignore_existing: false,
4519 chunk_size: 0,
4520 skip_specials: false,
4521 remote_copy_buffer_size: 0,
4522 filter: Some(filter),
4523 dry_run: None,
4524 delete: None,
4525 },
4526 &NO_PRESERVE_SETTINGS,
4527 false,
4528 )
4529 .await?;
4530 assert_eq!(
4532 summary.files_copied, 5,
4533 "should copy all 5 .txt files with **/*.txt pattern"
4534 );
4535 Ok(())
4536 }
4537 #[tokio::test]
4539 #[traced_test]
4540 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
4541 let tmp_dir = testutils::setup_test_dir().await?;
4542 let test_path = tmp_dir.as_path();
4543 let mut filter = FilterSettings::new();
4545 filter.add_exclude("*.txt").unwrap();
4546 let result = copy(
4547 &PROGRESS,
4548 &test_path.join("foo/0.txt"), &test_path.join("dst.txt"),
4550 &Settings {
4551 dereference: false,
4552 fail_early: false,
4553 overwrite: false,
4554 overwrite_compare: Default::default(),
4555 overwrite_filter: None,
4556 ignore_existing: false,
4557 chunk_size: 0,
4558 skip_specials: false,
4559 remote_copy_buffer_size: 0,
4560 filter: Some(filter),
4561 dry_run: None,
4562 delete: None,
4563 },
4564 &NO_PRESERVE_SETTINGS,
4565 false,
4566 )
4567 .await?;
4568 assert_eq!(
4570 result.files_copied, 0,
4571 "file matching exclude pattern should not be copied"
4572 );
4573 assert!(
4574 !test_path.join("dst.txt").exists(),
4575 "excluded file should not exist at destination"
4576 );
4577 Ok(())
4578 }
4579 #[tokio::test]
4581 #[traced_test]
4582 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
4583 let test_path = testutils::create_temp_dir().await?;
4584 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
4586 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
4587 let mut filter = FilterSettings::new();
4589 filter.add_exclude("*_dir/").unwrap();
4590 let result = copy(
4591 &PROGRESS,
4592 &test_path.join("excluded_dir"),
4593 &test_path.join("dst"),
4594 &Settings {
4595 dereference: false,
4596 fail_early: false,
4597 overwrite: false,
4598 overwrite_compare: Default::default(),
4599 overwrite_filter: None,
4600 ignore_existing: false,
4601 chunk_size: 0,
4602 skip_specials: false,
4603 remote_copy_buffer_size: 0,
4604 filter: Some(filter),
4605 dry_run: None,
4606 delete: None,
4607 },
4608 &NO_PRESERVE_SETTINGS,
4609 false,
4610 )
4611 .await?;
4612 assert_eq!(
4614 result.directories_created, 0,
4615 "root directory matching exclude should not be created"
4616 );
4617 assert!(
4618 !test_path.join("dst").exists(),
4619 "excluded root directory should not exist at destination"
4620 );
4621 Ok(())
4622 }
4623 #[tokio::test]
4625 #[traced_test]
4626 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
4627 let test_path = testutils::create_temp_dir().await?;
4628 tokio::fs::write(test_path.join("target.txt"), "content").await?;
4630 tokio::fs::symlink(
4631 test_path.join("target.txt"),
4632 test_path.join("excluded_link"),
4633 )
4634 .await?;
4635 let mut filter = FilterSettings::new();
4637 filter.add_exclude("*_link").unwrap();
4638 let result = copy(
4639 &PROGRESS,
4640 &test_path.join("excluded_link"),
4641 &test_path.join("dst"),
4642 &Settings {
4643 dereference: false,
4644 fail_early: false,
4645 overwrite: false,
4646 overwrite_compare: Default::default(),
4647 overwrite_filter: None,
4648 ignore_existing: false,
4649 chunk_size: 0,
4650 skip_specials: false,
4651 remote_copy_buffer_size: 0,
4652 filter: Some(filter),
4653 dry_run: None,
4654 delete: None,
4655 },
4656 &NO_PRESERVE_SETTINGS,
4657 false,
4658 )
4659 .await?;
4660 assert_eq!(
4662 result.symlinks_created, 0,
4663 "root symlink matching exclude should not be created"
4664 );
4665 assert!(
4666 !test_path.join("dst").exists(),
4667 "excluded root symlink should not exist at destination"
4668 );
4669 Ok(())
4670 }
4671 #[tokio::test]
4673 #[traced_test]
4674 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
4675 let tmp_dir = testutils::setup_test_dir().await?;
4676 let test_path = tmp_dir.as_path();
4677 let mut filter = FilterSettings::new();
4684 filter.add_include("**/*.txt").unwrap();
4685 filter.add_exclude("bar/2.txt").unwrap();
4686 let summary = copy(
4687 &PROGRESS,
4688 &test_path.join("foo"),
4689 &test_path.join("dst"),
4690 &Settings {
4691 dereference: false,
4692 fail_early: false,
4693 overwrite: false,
4694 overwrite_compare: Default::default(),
4695 overwrite_filter: None,
4696 ignore_existing: false,
4697 chunk_size: 0,
4698 skip_specials: false,
4699 remote_copy_buffer_size: 0,
4700 filter: Some(filter),
4701 dry_run: None,
4702 delete: None,
4703 },
4704 &NO_PRESERVE_SETTINGS,
4705 false,
4706 )
4707 .await?;
4708 assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
4712 assert_eq!(
4713 summary.files_skipped, 1,
4714 "should skip 1 file (bar/2.txt excluded)"
4715 );
4716 assert!(
4718 test_path.join("dst/bar/1.txt").exists(),
4719 "bar/1.txt should be copied"
4720 );
4721 assert!(
4722 !test_path.join("dst/bar/2.txt").exists(),
4723 "bar/2.txt should be excluded"
4724 );
4725 assert!(
4726 test_path.join("dst/bar/3.txt").exists(),
4727 "bar/3.txt should be copied"
4728 );
4729 Ok(())
4730 }
4731 #[tokio::test]
4733 #[traced_test]
4734 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
4735 let tmp_dir = testutils::setup_test_dir().await?;
4736 let test_path = tmp_dir.as_path();
4737 let mut filter = FilterSettings::new();
4744 filter.add_exclude("bar/").unwrap();
4745 let summary = copy(
4746 &PROGRESS,
4747 &test_path.join("foo"),
4748 &test_path.join("dst"),
4749 &Settings {
4750 dereference: false,
4751 fail_early: false,
4752 overwrite: false,
4753 overwrite_compare: Default::default(),
4754 overwrite_filter: None,
4755 ignore_existing: false,
4756 chunk_size: 0,
4757 skip_specials: false,
4758 remote_copy_buffer_size: 0,
4759 filter: Some(filter),
4760 dry_run: None,
4761 delete: None,
4762 },
4763 &NO_PRESERVE_SETTINGS,
4764 false,
4765 )
4766 .await?;
4767 assert_eq!(summary.files_copied, 2, "should copy 2 files");
4771 assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
4772 assert_eq!(
4773 summary.directories_created, 2,
4774 "should create 2 directories"
4775 );
4776 assert_eq!(
4777 summary.directories_skipped, 1,
4778 "should skip 1 directory (bar)"
4779 );
4780 assert_eq!(
4781 summary.files_skipped, 0,
4782 "no files skipped (bar contents not counted)"
4783 );
4784 Ok(())
4785 }
4786 #[tokio::test]
4789 #[traced_test]
4790 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
4791 let test_path = testutils::create_temp_dir().await?;
4792 let src_path = test_path.join("src");
4798 tokio::fs::create_dir(&src_path).await?;
4799 tokio::fs::write(src_path.join("foo"), "content").await?;
4800 tokio::fs::write(src_path.join("bar"), "content").await?;
4801 tokio::fs::create_dir(src_path.join("baz")).await?;
4802 let mut filter = FilterSettings::new();
4804 filter.add_include("foo").unwrap();
4805 let summary = copy(
4806 &PROGRESS,
4807 &src_path,
4808 &test_path.join("dst"),
4809 &Settings {
4810 dereference: false,
4811 fail_early: false,
4812 overwrite: false,
4813 overwrite_compare: Default::default(),
4814 overwrite_filter: None,
4815 ignore_existing: false,
4816 chunk_size: 0,
4817 skip_specials: false,
4818 remote_copy_buffer_size: 0,
4819 filter: Some(filter),
4820 dry_run: None,
4821 delete: None,
4822 },
4823 &NO_PRESERVE_SETTINGS,
4824 false,
4825 )
4826 .await?;
4827 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4829 assert_eq!(
4830 summary.directories_created, 1,
4831 "should create only root directory (not empty 'baz')"
4832 );
4833 assert!(
4835 test_path.join("dst").join("foo").exists(),
4836 "foo should be copied"
4837 );
4838 assert!(
4840 !test_path.join("dst").join("bar").exists(),
4841 "bar should not be copied"
4842 );
4843 assert!(
4845 !test_path.join("dst").join("baz").exists(),
4846 "empty baz directory should NOT be created"
4847 );
4848 Ok(())
4849 }
4850 #[tokio::test]
4853 #[traced_test]
4854 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
4855 let test_path = testutils::create_temp_dir().await?;
4856 let src_path = test_path.join("src");
4863 tokio::fs::create_dir(&src_path).await?;
4864 tokio::fs::write(src_path.join("foo"), "content").await?;
4865 tokio::fs::create_dir(src_path.join("baz")).await?;
4866 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
4867 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
4868 let mut filter = FilterSettings::new();
4870 filter.add_include("foo").unwrap();
4871 let summary = copy(
4872 &PROGRESS,
4873 &src_path,
4874 &test_path.join("dst"),
4875 &Settings {
4876 dereference: false,
4877 fail_early: false,
4878 overwrite: false,
4879 overwrite_compare: Default::default(),
4880 overwrite_filter: None,
4881 ignore_existing: false,
4882 chunk_size: 0,
4883 skip_specials: false,
4884 remote_copy_buffer_size: 0,
4885 filter: Some(filter),
4886 dry_run: None,
4887 delete: None,
4888 },
4889 &NO_PRESERVE_SETTINGS,
4890 false,
4891 )
4892 .await?;
4893 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4895 assert_eq!(
4896 summary.files_skipped, 2,
4897 "should skip 2 files (qux and quux)"
4898 );
4899 assert_eq!(
4900 summary.directories_created, 1,
4901 "should create only root directory (not 'baz' with non-matching content)"
4902 );
4903 assert!(
4905 test_path.join("dst").join("foo").exists(),
4906 "foo should be copied"
4907 );
4908 assert!(
4910 !test_path.join("dst").join("baz").exists(),
4911 "baz directory should NOT be created (no matching content inside)"
4912 );
4913 Ok(())
4914 }
4915 #[tokio::test]
4918 #[traced_test]
4919 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
4920 let test_path = testutils::create_temp_dir().await?;
4921 let src_path = test_path.join("src");
4927 tokio::fs::create_dir(&src_path).await?;
4928 tokio::fs::write(src_path.join("foo"), "content").await?;
4929 tokio::fs::write(src_path.join("bar"), "content").await?;
4930 tokio::fs::create_dir(src_path.join("baz")).await?;
4931 let mut filter = FilterSettings::new();
4933 filter.add_include("foo").unwrap();
4934 let summary = copy(
4935 &PROGRESS,
4936 &src_path,
4937 &test_path.join("dst"),
4938 &Settings {
4939 dereference: false,
4940 fail_early: false,
4941 overwrite: false,
4942 overwrite_compare: Default::default(),
4943 overwrite_filter: None,
4944 ignore_existing: false,
4945 chunk_size: 0,
4946 skip_specials: false,
4947 remote_copy_buffer_size: 0,
4948 filter: Some(filter),
4949 dry_run: Some(crate::config::DryRunMode::Explain),
4950 delete: None,
4951 },
4952 &NO_PRESERVE_SETTINGS,
4953 false,
4954 )
4955 .await?;
4956 assert_eq!(
4958 summary.files_copied, 1,
4959 "should report only 'foo' would be copied"
4960 );
4961 assert_eq!(
4962 summary.directories_created, 1,
4963 "should report only root directory would be created (not empty 'baz')"
4964 );
4965 assert!(
4967 !test_path.join("dst").exists(),
4968 "dst should not exist in dry-run"
4969 );
4970 Ok(())
4971 }
4972 #[tokio::test]
4975 #[traced_test]
4976 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
4977 let test_path = testutils::create_temp_dir().await?;
4978 let src_path = test_path.join("src");
4984 tokio::fs::create_dir(&src_path).await?;
4985 tokio::fs::write(src_path.join("foo"), "content").await?;
4986 tokio::fs::write(src_path.join("bar"), "content").await?;
4987 tokio::fs::create_dir(src_path.join("baz")).await?;
4988 let dst_path = test_path.join("dst");
4990 tokio::fs::create_dir(&dst_path).await?;
4991 tokio::fs::create_dir(dst_path.join("baz")).await?;
4992 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
4994 let mut filter = FilterSettings::new();
4996 filter.add_include("foo").unwrap();
4997 let summary = copy(
4998 &PROGRESS,
4999 &src_path,
5000 &dst_path,
5001 &Settings {
5002 dereference: false,
5003 fail_early: false,
5004 overwrite: true, overwrite_compare: Default::default(),
5006 overwrite_filter: None,
5007 ignore_existing: false,
5008 chunk_size: 0,
5009 skip_specials: false,
5010 remote_copy_buffer_size: 0,
5011 filter: Some(filter),
5012 dry_run: None,
5013 delete: None,
5014 },
5015 &NO_PRESERVE_SETTINGS,
5016 false,
5017 )
5018 .await?;
5019 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
5021 assert_eq!(
5023 summary.directories_unchanged, 2,
5024 "root dst and baz directories should be unchanged"
5025 );
5026 assert_eq!(
5027 summary.directories_created, 0,
5028 "should not create any directories"
5029 );
5030 assert!(dst_path.join("foo").exists(), "foo should be copied");
5032 assert!(!dst_path.join("bar").exists(), "bar should not be copied");
5034 assert!(
5036 dst_path.join("baz").exists(),
5037 "existing baz directory should still exist"
5038 );
5039 assert!(
5040 dst_path.join("baz").join("marker.txt").exists(),
5041 "existing content in baz should still exist"
5042 );
5043 Ok(())
5044 }
5045 }
5046 mod dry_run_tests {
5047 use super::*;
5048 use crate::filter::FilterSettings;
5049 #[tokio::test]
5052 #[traced_test]
5053 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
5054 let tmp_dir = testutils::setup_test_dir().await?;
5055 let test_path = tmp_dir.as_path();
5056 let dst_path = test_path.join("nonexistent_dst");
5057 assert!(
5059 !dst_path.exists(),
5060 "destination should not exist before dry-run"
5061 );
5062 let summary = copy(
5063 &PROGRESS,
5064 &test_path.join("foo"),
5065 &dst_path,
5066 &Settings {
5067 dereference: false,
5068 fail_early: false,
5069 overwrite: false,
5070 overwrite_compare: Default::default(),
5071 overwrite_filter: None,
5072 ignore_existing: false,
5073 chunk_size: 0,
5074 skip_specials: false,
5075 remote_copy_buffer_size: 0,
5076 filter: None,
5077 dry_run: Some(crate::config::DryRunMode::Brief),
5078 delete: None,
5079 },
5080 &NO_PRESERVE_SETTINGS,
5081 false,
5082 )
5083 .await?;
5084 assert!(
5086 !dst_path.exists(),
5087 "dry-run should not create destination directory"
5088 );
5089 assert!(
5091 summary.directories_created > 0,
5092 "dry-run should report directories that would be created"
5093 );
5094 assert!(
5095 summary.files_copied > 0,
5096 "dry-run should report files that would be copied"
5097 );
5098 Ok(())
5099 }
5100 #[tokio::test]
5104 async fn dry_run_ignore_existing_reports_file_as_skipped() -> Result<(), anyhow::Error> {
5105 let tmp = testutils::create_temp_dir().await?;
5106 let src = tmp.join("src.txt");
5107 let dst = tmp.join("dst.txt");
5108 tokio::fs::write(&src, "new").await?;
5109 tokio::fs::write(&dst, "old").await?; let summary = copy(
5111 &PROGRESS,
5112 &src,
5113 &dst,
5114 &Settings {
5115 dereference: false,
5116 fail_early: false,
5117 overwrite: false,
5118 overwrite_compare: Default::default(),
5119 overwrite_filter: None,
5120 ignore_existing: true,
5121 chunk_size: 0,
5122 skip_specials: false,
5123 remote_copy_buffer_size: 0,
5124 filter: None,
5125 dry_run: Some(crate::config::DryRunMode::Brief),
5126 delete: None,
5127 },
5128 &NO_PRESERVE_SETTINGS,
5129 false,
5130 )
5131 .await?;
5132 assert_eq!(
5133 summary.files_unchanged, 1,
5134 "existing file must be reported skipped in dry-run under --ignore-existing"
5135 );
5136 assert_eq!(
5137 summary.files_copied, 0,
5138 "must not report a would-be copy when --ignore-existing skips"
5139 );
5140 assert_eq!(
5141 tokio::fs::read_to_string(&dst).await?,
5142 "old",
5143 "dry-run must not mutate the destination"
5144 );
5145 Ok(())
5146 }
5147 #[tokio::test]
5150 async fn dry_run_ignore_existing_reports_symlink_as_skipped() -> Result<(), anyhow::Error> {
5151 let tmp = testutils::create_temp_dir().await?;
5152 let src = tmp.join("src_link");
5153 let dst = tmp.join("dst_link");
5154 tokio::fs::symlink("some/target", &src).await?;
5155 tokio::fs::write(&dst, "existing").await?; let summary = copy(
5157 &PROGRESS,
5158 &src,
5159 &dst,
5160 &Settings {
5161 dereference: false,
5162 fail_early: false,
5163 overwrite: false,
5164 overwrite_compare: Default::default(),
5165 overwrite_filter: None,
5166 ignore_existing: true,
5167 chunk_size: 0,
5168 skip_specials: false,
5169 remote_copy_buffer_size: 0,
5170 filter: None,
5171 dry_run: Some(crate::config::DryRunMode::Brief),
5172 delete: None,
5173 },
5174 &NO_PRESERVE_SETTINGS,
5175 false,
5176 )
5177 .await?;
5178 assert_eq!(
5179 summary.symlinks_unchanged, 1,
5180 "existing symlink dst must be reported skipped in dry-run under --ignore-existing"
5181 );
5182 assert_eq!(
5183 summary.symlinks_created, 0,
5184 "must not report a would-be symlink creation when --ignore-existing skips"
5185 );
5186 Ok(())
5187 }
5188 #[tokio::test]
5196 #[traced_test]
5197 async fn dry_run_ignore_existing_reports_dir_behind_a_symlink_as_skipped()
5198 -> Result<(), anyhow::Error> {
5199 let tmp = testutils::create_temp_dir().await?;
5200 let src = tmp.join("src_dir");
5201 tokio::fs::create_dir(&src).await?;
5202 tokio::fs::write(src.join("child.txt"), "content").await?;
5203 let link_target = tmp.join("elsewhere");
5205 tokio::fs::create_dir(&link_target).await?;
5206 let dst = tmp.join("dst_dir");
5207 tokio::fs::symlink(&link_target, &dst).await?;
5208 let summary = copy(
5209 &PROGRESS,
5210 &src,
5211 &dst,
5212 &Settings {
5213 dereference: false,
5214 fail_early: false,
5215 overwrite: false,
5216 overwrite_compare: Default::default(),
5217 overwrite_filter: None,
5218 ignore_existing: true,
5219 chunk_size: 0,
5220 skip_specials: false,
5221 remote_copy_buffer_size: 0,
5222 filter: None,
5223 dry_run: Some(crate::config::DryRunMode::Brief),
5224 delete: None,
5225 },
5226 &NO_PRESERVE_SETTINGS,
5227 false,
5228 )
5229 .await?;
5230 assert_eq!(
5231 summary.directories_unchanged, 1,
5232 "a destination symlink is a non-directory, so --ignore-existing skips the subtree; \
5233 dry-run must report that rather than following the link"
5234 );
5235 assert_eq!(
5236 summary.directories_created, 0,
5237 "must not report a would-be directory creation when --ignore-existing skips"
5238 );
5239 assert_eq!(
5240 summary.files_copied, 0,
5241 "the skipped subtree's children must not be reported as would-be-copied"
5242 );
5243 Ok(())
5244 }
5245
5246 #[tokio::test]
5259 #[traced_test]
5260 async fn dry_run_ignore_existing_reports_an_unprobeable_destination_as_an_error()
5261 -> Result<(), anyhow::Error> {
5262 if nix::unistd::geteuid().is_root() {
5263 eprintln!("skipping: root bypasses the directory permissions this test relies on");
5264 return Ok(());
5265 }
5266 let tmp = testutils::create_temp_dir().await?;
5267 let src = tmp.join("src_dir");
5268 tokio::fs::create_dir(&src).await?;
5269 tokio::fs::create_dir(src.join("sub")).await?;
5274 let sealed = tmp.join("dst_dir");
5277 tokio::fs::create_dir(&sealed).await?;
5278 tokio::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o600)).await?;
5279 let result = copy(
5280 &PROGRESS,
5281 &src,
5282 &sealed,
5283 &Settings {
5284 dereference: false,
5285 fail_early: false,
5286 overwrite: false,
5287 overwrite_compare: Default::default(),
5288 overwrite_filter: None,
5289 ignore_existing: true,
5290 chunk_size: 0,
5291 skip_specials: false,
5292 remote_copy_buffer_size: 0,
5293 filter: None,
5294 dry_run: Some(crate::config::DryRunMode::Brief),
5295 delete: None,
5296 },
5297 &NO_PRESERVE_SETTINGS,
5298 false,
5299 )
5300 .await;
5301 tokio::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o700)).await?;
5303 let error = result.expect_err(
5304 "an unprobeable destination must be reported, not treated as vacant and predicted \
5305 as a copy",
5306 );
5307 let rendered = format!("{:#}", error.source);
5308 assert!(
5309 rendered.contains("Permission denied"),
5310 "the error must keep the real cause; got: {rendered}"
5311 );
5312 Ok(())
5313 }
5314
5315 #[tokio::test]
5319 #[traced_test]
5320 async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
5321 let test_path = testutils::create_temp_dir().await?;
5322 let src_path = test_path.join("src");
5327 tokio::fs::create_dir(&src_path).await?;
5328 tokio::fs::write(src_path.join("bar.log"), "content").await?;
5329 tokio::fs::create_dir(src_path.join("baz")).await?;
5330 let mut filter = FilterSettings::new();
5332 filter.add_include("*.txt").unwrap();
5333 let dst_path = test_path.join("dst");
5334 let summary = copy(
5335 &PROGRESS,
5336 &src_path,
5337 &dst_path,
5338 &Settings {
5339 dereference: false,
5340 fail_early: false,
5341 overwrite: false,
5342 overwrite_compare: Default::default(),
5343 overwrite_filter: None,
5344 ignore_existing: false,
5345 chunk_size: 0,
5346 skip_specials: false,
5347 remote_copy_buffer_size: 0,
5348 filter: Some(filter),
5349 dry_run: None,
5350 delete: None,
5351 },
5352 &NO_PRESERVE_SETTINGS,
5353 false,
5354 )
5355 .await?;
5356 assert_eq!(summary.files_copied, 0, "no files match *.txt");
5358 assert_eq!(
5360 summary.directories_created, 1,
5361 "root directory should always be created"
5362 );
5363 assert!(dst_path.exists(), "root destination directory should exist");
5364 assert!(
5366 !dst_path.join("baz").exists(),
5367 "empty baz should not be created"
5368 );
5369 Ok(())
5370 }
5371 #[tokio::test]
5373 #[traced_test]
5374 async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
5375 {
5376 let test_path = testutils::create_temp_dir().await?;
5377 let src_path = test_path.join("src");
5378 tokio::fs::create_dir(&src_path).await?;
5379 tokio::fs::write(src_path.join("bar.log"), "content").await?;
5380 let mut filter = FilterSettings::new();
5382 filter.add_include("*.txt").unwrap();
5383 let dst_path = test_path.join("dst");
5384 let summary = copy(
5385 &PROGRESS,
5386 &src_path,
5387 &dst_path,
5388 &Settings {
5389 dereference: false,
5390 fail_early: false,
5391 overwrite: false,
5392 overwrite_compare: Default::default(),
5393 overwrite_filter: None,
5394 ignore_existing: false,
5395 chunk_size: 0,
5396 skip_specials: false,
5397 remote_copy_buffer_size: 0,
5398 filter: Some(filter),
5399 dry_run: Some(crate::config::DryRunMode::Explain),
5400 delete: None,
5401 },
5402 &NO_PRESERVE_SETTINGS,
5403 false,
5404 )
5405 .await?;
5406 assert_eq!(summary.files_copied, 0, "no files match *.txt");
5407 assert_eq!(
5408 summary.directories_created, 1,
5409 "root directory should be counted in dry-run"
5410 );
5411 assert!(
5412 !dst_path.exists(),
5413 "nothing should be created in dry-run mode"
5414 );
5415 Ok(())
5416 }
5417 }
5418
5419 mod max_open_files_tests {
5421 use super::*;
5422
5423 #[tokio::test]
5426 #[traced_test]
5427 async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
5428 let tmp_dir = testutils::create_temp_dir().await?;
5429 let src = tmp_dir.join("src");
5430 let dst = tmp_dir.join("dst");
5431 tokio::fs::create_dir(&src).await?;
5432 let file_count = 200;
5433 for i in 0..file_count {
5434 tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
5435 }
5436 throttle::set_max_open_files(4);
5438 let summary = copy(
5439 &PROGRESS,
5440 &src,
5441 &dst,
5442 &Settings {
5443 dereference: false,
5444 fail_early: true,
5445 overwrite: false,
5446 overwrite_compare: Default::default(),
5447 overwrite_filter: None,
5448 ignore_existing: false,
5449 chunk_size: 0,
5450 skip_specials: false,
5451 remote_copy_buffer_size: 0,
5452 filter: None,
5453 dry_run: None,
5454 delete: None,
5455 },
5456 &NO_PRESERVE_SETTINGS,
5457 false,
5458 )
5459 .await?;
5460 assert_eq!(summary.files_copied, file_count);
5461 assert_eq!(summary.directories_created, 1);
5462 for i in 0..file_count {
5463 let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
5464 assert_eq!(content, format!("content-{}", i));
5465 }
5466 Ok(())
5467 }
5468
5469 #[tokio::test]
5472 #[traced_test]
5473 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
5474 let tmp_dir = testutils::create_temp_dir().await?;
5475 let src = tmp_dir.join("src");
5476 let dst = tmp_dir.join("dst");
5477 let depth = 20;
5478 let files_per_level = 5;
5479 let limit = 4;
5480 let mut dir = src.clone();
5482 for level in 0..depth {
5483 tokio::fs::create_dir_all(&dir).await?;
5484 for f in 0..files_per_level {
5485 tokio::fs::write(
5486 dir.join(format!("f{}_{}.txt", level, f)),
5487 format!("L{}F{}", level, f),
5488 )
5489 .await?;
5490 }
5491 dir = dir.join(format!("d{}", level));
5492 }
5493 throttle::set_max_open_files(limit);
5494 let summary = tokio::time::timeout(
5495 std::time::Duration::from_secs(30),
5496 copy(
5497 &PROGRESS,
5498 &src,
5499 &dst,
5500 &Settings {
5501 dereference: false,
5502 fail_early: true,
5503 overwrite: false,
5504 overwrite_compare: Default::default(),
5505 overwrite_filter: None,
5506 ignore_existing: false,
5507 chunk_size: 0,
5508 skip_specials: false,
5509 remote_copy_buffer_size: 0,
5510 filter: None,
5511 dry_run: None,
5512 delete: None,
5513 },
5514 &NO_PRESERVE_SETTINGS,
5515 false,
5516 ),
5517 )
5518 .await
5519 .context("copy timed out — possible deadlock")?
5520 .context("copy failed")?;
5521 assert_eq!(summary.files_copied, depth * files_per_level);
5522 assert_eq!(summary.directories_created, depth);
5523 let mut check_dir = dst.clone();
5525 for level in 0..depth {
5526 let content =
5527 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
5528 assert_eq!(content, format!("L{}F0", level));
5529 check_dir = check_dir.join(format!("d{}", level));
5530 }
5531 Ok(())
5532 }
5533
5534 #[tokio::test]
5544 #[traced_test]
5545 async fn parallel_overwrite_dir_with_file_no_deadlock() -> Result<(), anyhow::Error> {
5546 let tmp_dir = testutils::create_temp_dir().await?;
5547 let src = tmp_dir.join("src");
5548 let dst = tmp_dir.join("dst");
5549 tokio::fs::create_dir(&src).await?;
5550 tokio::fs::create_dir(&dst).await?;
5551 let n = 8;
5555 for i in 0..n {
5556 tokio::fs::write(src.join(format!("e{}", i)), format!("file-{}", i)).await?;
5557 let dst_subdir = dst.join(format!("e{}", i));
5558 tokio::fs::create_dir(&dst_subdir).await?;
5559 for j in 0..3 {
5560 tokio::fs::write(
5561 dst_subdir.join(format!("inner_{}.txt", j)),
5562 format!("inner-{}-{}", i, j),
5563 )
5564 .await?;
5565 }
5566 }
5567 throttle::set_max_open_files(2);
5571 let summary = tokio::time::timeout(
5572 std::time::Duration::from_secs(30),
5573 copy(
5574 &PROGRESS,
5575 &src,
5576 &dst,
5577 &Settings {
5578 dereference: false,
5579 fail_early: true,
5580 overwrite: true,
5581 overwrite_compare: Default::default(),
5582 overwrite_filter: None,
5583 ignore_existing: false,
5584 chunk_size: 0,
5585 skip_specials: false,
5586 remote_copy_buffer_size: 0,
5587 filter: None,
5588 dry_run: None,
5589 delete: None,
5590 },
5591 &NO_PRESERVE_SETTINGS,
5592 false,
5593 ),
5594 )
5595 .await
5596 .context(
5597 "copy timed out — deadlock between copy_file's open-files permit and inner rm",
5598 )?
5599 .context("copy failed")?;
5600 assert_eq!(summary.files_copied, n);
5601 assert_eq!(summary.rm_summary.files_removed, n * 3);
5602 assert_eq!(summary.rm_summary.directories_removed, n);
5603 for i in 0..n {
5604 let path = dst.join(format!("e{}", i));
5605 let content = tokio::fs::read_to_string(&path).await?;
5606 assert_eq!(content, format!("file-{}", i));
5607 }
5608 Ok(())
5609 }
5610 }
5611
5612 mod skip_specials_tests {
5613 use super::*;
5614
5615 #[tokio::test]
5616 #[traced_test]
5617 async fn skip_specials_skips_socket_in_directory() -> Result<(), anyhow::Error> {
5618 let tmp_dir = testutils::setup_test_dir().await?;
5619 let test_path = tmp_dir.as_path();
5620 let src = test_path.join("src_dir");
5621 let dst = test_path.join("dst_dir");
5622 tokio::fs::create_dir(&src).await?;
5623 tokio::fs::write(src.join("file.txt"), "hello").await?;
5624 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
5626 let summary = copy(
5627 &PROGRESS,
5628 &src,
5629 &dst,
5630 &Settings {
5631 dereference: false,
5632 fail_early: false,
5633 overwrite: false,
5634 overwrite_compare: Default::default(),
5635 overwrite_filter: None,
5636 ignore_existing: false,
5637 chunk_size: 0,
5638 skip_specials: true,
5639 remote_copy_buffer_size: 0,
5640 filter: None,
5641 dry_run: None,
5642 delete: None,
5643 },
5644 &NO_PRESERVE_SETTINGS,
5645 false,
5646 )
5647 .await?;
5648 assert_eq!(summary.files_copied, 1);
5649 assert_eq!(summary.specials_skipped, 1);
5650 assert!(dst.join("file.txt").exists());
5651 assert!(!dst.join("test.sock").exists());
5652 Ok(())
5653 }
5654
5655 #[tokio::test]
5656 #[traced_test]
5657 async fn skip_specials_skips_fifo_in_directory() -> Result<(), anyhow::Error> {
5658 let tmp_dir = testutils::setup_test_dir().await?;
5659 let test_path = tmp_dir.as_path();
5660 let src = test_path.join("src_dir");
5661 let dst = test_path.join("dst_dir");
5662 tokio::fs::create_dir(&src).await?;
5663 tokio::fs::write(src.join("file.txt"), "hello").await?;
5664 nix::unistd::mkfifo(
5666 &src.join("test.fifo"),
5667 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
5668 )?;
5669 let summary = copy(
5670 &PROGRESS,
5671 &src,
5672 &dst,
5673 &Settings {
5674 dereference: false,
5675 fail_early: false,
5676 overwrite: false,
5677 overwrite_compare: Default::default(),
5678 overwrite_filter: None,
5679 ignore_existing: false,
5680 chunk_size: 0,
5681 skip_specials: true,
5682 remote_copy_buffer_size: 0,
5683 filter: None,
5684 dry_run: None,
5685 delete: None,
5686 },
5687 &NO_PRESERVE_SETTINGS,
5688 false,
5689 )
5690 .await?;
5691 assert_eq!(summary.files_copied, 1);
5692 assert_eq!(summary.specials_skipped, 1);
5693 assert!(dst.join("file.txt").exists());
5694 assert!(!dst.join("test.fifo").exists());
5695 Ok(())
5696 }
5697
5698 #[tokio::test]
5699 #[traced_test]
5700 async fn special_file_errors_without_skip_specials() -> Result<(), anyhow::Error> {
5701 let tmp_dir = testutils::setup_test_dir().await?;
5702 let test_path = tmp_dir.as_path();
5703 let src = test_path.join("src_dir");
5704 let dst = test_path.join("dst_dir");
5705 tokio::fs::create_dir(&src).await?;
5706 tokio::fs::write(src.join("file.txt"), "hello").await?;
5707 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
5708 let result = copy(
5709 &PROGRESS,
5710 &src,
5711 &dst,
5712 &Settings {
5713 dereference: false,
5714 fail_early: false,
5715 overwrite: false,
5716 overwrite_compare: Default::default(),
5717 overwrite_filter: None,
5718 ignore_existing: false,
5719 chunk_size: 0,
5720 skip_specials: false,
5721 remote_copy_buffer_size: 0,
5722 filter: None,
5723 dry_run: None,
5724 delete: None,
5725 },
5726 &NO_PRESERVE_SETTINGS,
5727 false,
5728 )
5729 .await;
5730 assert!(result.is_err());
5731 let err = result.unwrap_err();
5732 assert!(
5733 format!("{:#}", err).contains("unsupported src file type"),
5734 "error should mention unsupported file type, got: {:#}",
5735 err
5736 );
5737 Ok(())
5738 }
5739
5740 #[tokio::test]
5741 #[traced_test]
5742 async fn skip_specials_top_level_socket() -> Result<(), anyhow::Error> {
5743 let tmp_dir = testutils::setup_test_dir().await?;
5744 let test_path = tmp_dir.as_path();
5745 let src_socket = test_path.join("test.sock");
5746 let dst = test_path.join("dst.sock");
5747 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
5748 let summary = copy(
5749 &PROGRESS,
5750 &src_socket,
5751 &dst,
5752 &Settings {
5753 dereference: false,
5754 fail_early: false,
5755 overwrite: false,
5756 overwrite_compare: Default::default(),
5757 overwrite_filter: None,
5758 ignore_existing: false,
5759 chunk_size: 0,
5760 skip_specials: true,
5761 remote_copy_buffer_size: 0,
5762 filter: None,
5763 dry_run: None,
5764 delete: None,
5765 },
5766 &NO_PRESERVE_SETTINGS,
5767 false,
5768 )
5769 .await?;
5770 assert_eq!(summary.specials_skipped, 1);
5771 assert_eq!(summary.files_copied, 0);
5772 assert!(!dst.exists());
5773 Ok(())
5774 }
5775 }
5776
5777 #[tokio::test]
5778 #[traced_test]
5779 async fn delete_protects_excluded_then_removes_with_delete_excluded()
5780 -> Result<(), anyhow::Error> {
5781 let tmp_dir = testutils::setup_test_dir().await?;
5782 let test_path = tmp_dir.as_path();
5783 let src = test_path.join("foo");
5784 let dst = test_path.join("bar");
5785 copy(
5786 &PROGRESS,
5787 &src,
5788 &dst,
5789 &settings_with_delete(None),
5790 &DO_PRESERVE_SETTINGS,
5791 false,
5792 )
5793 .await?;
5794 tokio::fs::write(dst.join("keep.log"), b"protected").await?;
5796
5797 let mut filter = crate::filter::FilterSettings::new();
5798 filter.add_exclude("*.log")?;
5799
5800 let mut settings = settings_with_delete(delete_on());
5802 settings.filter = Some(filter.clone());
5803 copy(
5804 &PROGRESS,
5805 &src,
5806 &dst,
5807 &settings,
5808 &DO_PRESERVE_SETTINGS,
5809 false,
5810 )
5811 .await?;
5812 assert!(
5813 dst.join("keep.log").exists(),
5814 "*.log must be protected by default"
5815 );
5816
5817 let mut settings = settings_with_delete(Some(DeleteSettings {
5819 delete_excluded: true,
5820 }));
5821 settings.filter = Some(filter);
5822 copy(
5823 &PROGRESS,
5824 &src,
5825 &dst,
5826 &settings,
5827 &DO_PRESERVE_SETTINGS,
5828 false,
5829 )
5830 .await?;
5831 assert!(!dst.join("keep.log").exists());
5832 Ok(())
5833 }
5834
5835 #[tokio::test]
5836 #[traced_test]
5837 async fn delete_dry_run_reports_without_removing() -> Result<(), anyhow::Error> {
5838 let tmp_dir = testutils::setup_test_dir().await?;
5839 let test_path = tmp_dir.as_path();
5840 let src = test_path.join("foo");
5841 let dst = test_path.join("bar");
5842 copy(
5843 &PROGRESS,
5844 &src,
5845 &dst,
5846 &settings_with_delete(None),
5847 &DO_PRESERVE_SETTINGS,
5848 false,
5849 )
5850 .await?;
5851 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
5852
5853 let mut settings = settings_with_delete(delete_on());
5854 settings.dry_run = Some(crate::config::DryRunMode::Brief);
5855 let summary = copy(
5856 &PROGRESS,
5857 &src,
5858 &dst,
5859 &settings,
5860 &DO_PRESERVE_SETTINGS,
5861 false,
5862 )
5863 .await?;
5864
5865 assert!(
5867 dst.join("stale.txt").exists(),
5868 "dry-run must not remove anything"
5869 );
5870 assert_eq!(summary.rm_summary.files_removed, 1);
5872 Ok(())
5873 }
5874
5875 #[tokio::test]
5876 #[traced_test]
5877 async fn delete_does_not_prune_when_source_unreadable() -> Result<(), anyhow::Error> {
5878 let tmp_dir = testutils::setup_test_dir().await?;
5879 let test_path = tmp_dir.as_path();
5880 let src = test_path.join("foo");
5881 let dst = test_path.join("bar");
5882 copy(
5883 &PROGRESS,
5884 &src,
5885 &dst,
5886 &settings_with_delete(None),
5887 &DO_PRESERVE_SETTINGS,
5888 false,
5889 )
5890 .await?;
5891 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
5892 let original = tokio::fs::metadata(&src).await?.permissions();
5894 tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
5895
5896 let result = copy(
5897 &PROGRESS,
5898 &src,
5899 &dst,
5900 &settings_with_delete(delete_on()),
5901 &DO_PRESERVE_SETTINGS,
5902 false,
5903 )
5904 .await;
5905
5906 tokio::fs::set_permissions(&src, original).await?;
5908
5909 assert!(result.is_err(), "unreadable source must error");
5910 assert!(
5911 dst.join("stale.txt").exists(),
5912 "destination must not be pruned when source enumeration fails"
5913 );
5914 Ok(())
5915 }
5916
5917 #[tokio::test]
5922 #[traced_test]
5923 async fn overwrite_regular_file_removes_and_recreates_via_fd() -> Result<(), anyhow::Error> {
5924 let tmp_dir = testutils::create_temp_dir().await?;
5925 let test_path = tmp_dir.as_path();
5926 let src_file = test_path.join("src.txt");
5927 let dst_file = test_path.join("dst.txt");
5928 tokio::fs::write(&src_file, "fresh source content").await?;
5930 tokio::fs::write(&dst_file, "old").await?;
5931 let summary = copy(
5932 &PROGRESS,
5933 &src_file,
5934 &dst_file,
5935 &Settings {
5936 dereference: false,
5937 fail_early: false,
5938 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
5940 size: true,
5941 mtime: true,
5942 ..Default::default()
5943 },
5944 overwrite_filter: None,
5945 ignore_existing: false,
5946 chunk_size: 0,
5947 skip_specials: false,
5948 remote_copy_buffer_size: 0,
5949 filter: None,
5950 dry_run: None,
5951 delete: None,
5952 },
5953 &NO_PRESERVE_SETTINGS,
5954 false,
5955 )
5956 .await?;
5957 assert_eq!(summary.files_copied, 1);
5959 assert_eq!(summary.rm_summary.files_removed, 1);
5960 assert_eq!(summary.rm_summary.symlinks_removed, 0);
5961 assert_eq!(summary.rm_summary.directories_removed, 0);
5962 let content = tokio::fs::read_to_string(&dst_file).await?;
5963 assert_eq!(content, "fresh source content");
5964 Ok(())
5965 }
5966
5967 fn toctou_settings() -> Settings {
5972 Settings {
5973 dereference: false,
5974 fail_early: false,
5975 overwrite: true,
5976 overwrite_compare: filecmp::MetadataCmpSettings {
5977 size: true,
5978 mtime: true,
5979 ..Default::default()
5980 },
5981 overwrite_filter: None,
5982 ignore_existing: false,
5983 chunk_size: 0,
5984 skip_specials: false,
5985 remote_copy_buffer_size: 0,
5986 filter: None,
5987 dry_run: None,
5988 delete: None,
5989 }
5990 }
5991
5992 #[tokio::test]
5998 #[traced_test]
5999 async fn non_dereference_copy_never_canonicalizes() -> Result<(), anyhow::Error> {
6000 let tmp_dir = testutils::create_temp_dir().await?;
6001 let test_path = tmp_dir.as_path();
6002 let src = test_path.join("src");
6003 let dst = test_path.join("dst");
6004 tokio::fs::create_dir(&src).await?;
6005 tokio::fs::write(src.join("real.txt"), "REAL_CONTENT").await?;
6007 tokio::fs::symlink("real.txt", src.join("link")).await?;
6008 let summary = copy(
6009 &PROGRESS,
6010 &src,
6011 &dst,
6012 &toctou_settings(),
6013 &NO_PRESERVE_SETTINGS,
6014 false,
6015 )
6016 .await?;
6017 assert_eq!(summary.files_copied, 1);
6018 assert_eq!(
6019 summary.symlinks_created, 1,
6020 "symlink must be copied as a symlink"
6021 );
6022 let link_md = tokio::fs::symlink_metadata(dst.join("link")).await?;
6024 assert!(
6025 link_md.file_type().is_symlink(),
6026 "with dereference == false the source symlink must remain a symlink in the destination"
6027 );
6028 let target = tokio::fs::read_link(dst.join("link")).await?;
6030 assert_eq!(target, std::path::Path::new("real.txt"));
6031 Ok(())
6032 }
6033
6034 fn spawn_file_symlink_swapper(
6040 dir: std::path::PathBuf,
6041 entry_name: &'static str,
6042 sentinel: std::path::PathBuf,
6043 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
6044 ) -> std::thread::JoinHandle<()> {
6045 std::thread::spawn(move || {
6046 let entry = dir.join(entry_name);
6047 let staged_real = dir.join("__staged_real");
6048 let staged_link = dir.join("__staged_link");
6049 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
6050 let _ = std::fs::remove_file(&staged_real);
6052 if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
6053 continue;
6054 }
6055 let _ = std::fs::rename(&staged_real, &entry);
6056 let _ = std::fs::remove_file(&staged_link);
6058 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
6059 let _ = std::fs::rename(&staged_link, &entry);
6060 }
6061 })
6062 }
6063
6064 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6069 async fn file_symlink_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
6070 let tmp_dir = testutils::create_temp_dir().await?;
6071 let test_path = tmp_dir.as_path();
6072 let sentinel = test_path.join("sentinel_secret");
6074 tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
6075 let src = test_path.join("src");
6076 let sub = src.join("sub");
6077 tokio::fs::create_dir(&src).await?;
6078 tokio::fs::create_dir(&sub).await?;
6079 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
6080
6081 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
6082 let swapper =
6083 spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
6084
6085 let settings = toctou_settings();
6086 let mut caught_swaps = 0usize;
6087 let mut copied_real = 0usize;
6088 for i in 0..200 {
6089 let dst = test_path.join(format!("dst_{i}"));
6090 let result = tokio::time::timeout(
6091 std::time::Duration::from_secs(30),
6092 copy(
6093 &PROGRESS,
6094 &src,
6095 &dst,
6096 &settings,
6097 &NO_PRESERVE_SETTINGS,
6098 false,
6099 ),
6100 )
6101 .await
6102 .expect("copy must not hang under concurrent swapping");
6103 match result {
6104 Ok(_) => {}
6105 Err(_) => caught_swaps += 1, }
6107 let entry_dst = dst.join("sub").join("entry");
6110 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
6111 && md.file_type().is_file()
6112 {
6113 let content = tokio::fs::read_to_string(&entry_dst).await?;
6114 assert_ne!(
6115 content, "SENTINEL_SECRET_CONTENT",
6116 "iteration {i}: sentinel content leaked into the destination as a regular file"
6117 );
6118 assert_eq!(
6119 content, "REAL_CONTENT",
6120 "iteration {i}: a regular destination file must hold the real content"
6121 );
6122 copied_real += 1;
6123 }
6124 let _ = tokio::fs::remove_dir_all(&dst).await;
6125 }
6126
6127 stop.store(true, std::sync::atomic::Ordering::Relaxed);
6128 swapper.join().expect("swapper thread panicked");
6129 tracing::info!("file/symlink swap: caught_swaps={caught_swaps}, copied_real={copied_real}");
6133 assert!(
6134 caught_swaps + copied_real > 0,
6135 "expected at least one observable outcome across 200 iterations"
6136 );
6137 Ok(())
6138 }
6139
6140 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6145 async fn intermediate_dir_swap_never_follows_symlink() -> Result<(), anyhow::Error> {
6146 let tmp_dir = testutils::create_temp_dir().await?;
6147 let test_path = tmp_dir.as_path();
6148 let outside = test_path.join("outside_dir");
6150 tokio::fs::create_dir(&outside).await?;
6151 tokio::fs::write(outside.join("sentinel.txt"), "SENTINEL_SECRET_CONTENT").await?;
6152 let src = test_path.join("src");
6153 tokio::fs::create_dir(&src).await?;
6154 let real_sub = src.join("sub");
6156 tokio::fs::create_dir(&real_sub).await?;
6157 tokio::fs::write(real_sub.join("real.txt"), "REAL_CONTENT").await?;
6158
6159 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
6160 let swapper = {
6161 let src = src.clone();
6162 let outside = outside.clone();
6163 let stop = stop.clone();
6164 std::thread::spawn(move || {
6165 let sub = src.join("sub");
6166 let staged_dir = src.join("__staged_sub_dir");
6167 let staged_link = src.join("__staged_sub_link");
6168 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
6169 let _ = std::fs::remove_dir_all(&staged_dir);
6171 if std::fs::create_dir(&staged_dir).is_ok() {
6172 let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL_CONTENT");
6173 let _ = std::fs::remove_dir_all(&sub);
6177 let _ = std::fs::remove_file(&sub);
6178 let _ = std::fs::rename(&staged_dir, &sub);
6179 }
6180 let _ = std::fs::remove_file(&staged_link);
6182 if std::os::unix::fs::symlink(&outside, &staged_link).is_ok() {
6183 let _ = std::fs::remove_dir_all(&sub);
6184 let _ = std::fs::remove_file(&sub);
6185 let _ = std::fs::rename(&staged_link, &sub);
6186 }
6187 }
6188 })
6189 };
6190
6191 let settings = toctou_settings();
6192 for i in 0..200 {
6193 let dst = test_path.join(format!("dst_{i}"));
6194 let _ = tokio::time::timeout(
6195 std::time::Duration::from_secs(30),
6196 copy(
6197 &PROGRESS,
6198 &src,
6199 &dst,
6200 &settings,
6201 &NO_PRESERVE_SETTINGS,
6202 false,
6203 ),
6204 )
6205 .await
6206 .expect("copy must not hang under concurrent dir swapping");
6207 let sub_dst = dst.join("sub");
6218 if let Ok(sub_md) = tokio::fs::symlink_metadata(&sub_dst).await
6219 && sub_md.file_type().is_dir()
6220 {
6221 let leaked = sub_dst.join("sentinel.txt");
6222 let leaked_is_real_file = tokio::fs::symlink_metadata(&leaked)
6223 .await
6224 .map(|m| m.file_type().is_file())
6225 .unwrap_or(false);
6226 assert!(
6227 !leaked_is_real_file,
6228 "iteration {i}: intermediate-dir symlink was followed; the outside \
6229 sentinel was copied into a real destination directory"
6230 );
6231 let real_dst = sub_dst.join("real.txt");
6233 if let Ok(content) = tokio::fs::read_to_string(&real_dst).await {
6234 assert_eq!(
6235 content, "REAL_CONTENT",
6236 "iteration {i}: copied file must hold real content"
6237 );
6238 }
6239 }
6240 let _ = tokio::fs::remove_dir_all(&dst).await;
6241 }
6242
6243 stop.store(true, std::sync::atomic::Ordering::Relaxed);
6244 swapper.join().expect("dir swapper thread panicked");
6245 Ok(())
6246 }
6247
6248 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6266 async fn dest_nonempty_dir_overwrite_removal_contained_under_intermediate_swap()
6267 -> Result<(), anyhow::Error> {
6268 const N: usize = 8;
6269 let tmp_dir = testutils::create_temp_dir().await?;
6270 let test_path = tmp_dir.as_path();
6271 let out_of_tree = test_path.join("out_of_tree");
6276 let oot_deeper = out_of_tree.join("deeper");
6277 for k in 0..N {
6278 let oot_sub = oot_deeper.join(format!("sub{k}"));
6279 tokio::fs::create_dir_all(&oot_sub).await?;
6280 tokio::fs::write(oot_sub.join("sentinel.txt"), "SENTINEL").await?;
6281 }
6282
6283 let src = test_path.join("src");
6285 let src_deeper = src.join("mid").join("deeper");
6286 tokio::fs::create_dir_all(&src_deeper).await?;
6287 for k in 0..N {
6288 tokio::fs::write(src_deeper.join(format!("sub{k}")), "REAL_FILE_CONTENT").await?;
6289 }
6290
6291 let dst = test_path.join("dst");
6293 tokio::fs::create_dir(&dst).await?;
6294
6295 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
6296 let swapper = {
6297 let dst = dst.clone();
6298 let out_of_tree = out_of_tree.clone();
6299 let stop = stop.clone();
6300 std::thread::spawn(move || {
6301 let mid = dst.join("mid");
6302 let staged_dir = dst.join("__staged_mid_dir");
6303 let staged_link = dst.join("__staged_mid_link");
6304 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
6305 let _ = std::fs::remove_dir_all(&staged_dir);
6309 let staged_deeper = staged_dir.join("deeper");
6310 if std::fs::create_dir_all(&staged_deeper).is_ok() {
6311 for k in 0..N {
6312 let s = staged_deeper.join(format!("sub{k}"));
6313 if std::fs::create_dir(&s).is_ok() {
6314 let _ = std::fs::write(s.join("inner.txt"), b"INNER_CONTENT");
6315 }
6316 }
6317 let _ = std::fs::remove_dir_all(&mid);
6318 let _ = std::fs::remove_file(&mid);
6319 let _ = std::fs::rename(&staged_dir, &mid);
6320 }
6321 let _ = std::fs::remove_file(&staged_link);
6323 if std::os::unix::fs::symlink(&out_of_tree, &staged_link).is_ok() {
6324 let _ = std::fs::remove_dir_all(&mid);
6325 let _ = std::fs::remove_file(&mid);
6326 let _ = std::fs::rename(&staged_link, &mid);
6327 }
6328 }
6329 })
6330 };
6331
6332 let settings = toctou_settings();
6333 for i in 0..200 {
6334 let _ = tokio::time::timeout(
6335 std::time::Duration::from_secs(30),
6336 copy(
6337 &PROGRESS,
6338 &src,
6339 &dst,
6340 &settings,
6341 &NO_PRESERVE_SETTINGS,
6342 false,
6343 ),
6344 )
6345 .await
6346 .expect("copy must not hang under concurrent intermediate-component swapping");
6347 for k in 0..N {
6353 assert!(
6354 oot_deeper
6355 .join(format!("sub{k}"))
6356 .join("sentinel.txt")
6357 .exists(),
6358 "iteration {i}: out-of-tree sentinel sub{k} was deleted — the non-empty-\
6359 directory overwrite removal escaped the destination tree through a swapped \
6360 intermediate symlink (path-based rm regression)"
6361 );
6362 }
6363 }
6364
6365 stop.store(true, std::sync::atomic::Ordering::Relaxed);
6366 swapper
6367 .join()
6368 .expect("intermediate-swap swapper thread panicked");
6369 assert!(
6371 oot_deeper.join("sub0").is_dir(),
6372 "out-of-tree sub directory must survive"
6373 );
6374 Ok(())
6375 }
6376
6377 #[tokio::test]
6387 #[traced_test]
6388 async fn nonempty_dir_overwrite_removal_uses_pinned_fd_not_path() -> Result<(), anyhow::Error> {
6389 let tmp_dir = testutils::create_temp_dir().await?;
6390 let test_path = tmp_dir.as_path();
6391
6392 let real_deeper = test_path.join("base").join("realdeeper");
6394 let real_victim = real_deeper.join("victim");
6395 tokio::fs::create_dir_all(&real_victim).await?;
6396 tokio::fs::write(real_victim.join("inner.txt"), "INNER").await?;
6397
6398 let out_of_tree = test_path.join("out_of_tree");
6401 let oot_victim = out_of_tree.join("realdeeper").join("victim");
6402 tokio::fs::create_dir_all(&oot_victim).await?;
6403 tokio::fs::write(oot_victim.join("sentinel.txt"), "SENTINEL").await?;
6404
6405 let mid_link = test_path.join("base").join("mid");
6409 tokio::fs::symlink(&out_of_tree, &mid_link).await?;
6410 let stale_dst_path = mid_link.join("realdeeper").join("victim");
6411
6412 let dst_parent =
6414 Arc::new(Dir::open_root_dir(&real_deeper, false, congestion::Side::Destination).await?);
6415 let victim_name: &OsStr = OsStr::new("victim");
6416 let dst_handle = dst_parent.child(victim_name).await?;
6417 assert_eq!(dst_handle.kind(), EntryKind::Dir);
6418
6419 let settings = toctou_settings();
6420 let summary = remove_existing(
6421 &PROGRESS,
6422 &dst_parent,
6423 victim_name,
6424 &stale_dst_path,
6425 &dst_handle,
6426 &settings,
6427 )
6428 .await?;
6429 assert_eq!(
6430 summary.directories_removed, 1,
6431 "the real non-empty victim directory should have been removed via the pinned fd"
6432 );
6433
6434 assert!(
6436 !real_victim.exists(),
6437 "the real non-empty victim should have been removed through the pinned parent fd"
6438 );
6439 assert!(
6441 oot_victim.join("sentinel.txt").exists(),
6442 "out-of-tree sentinel was deleted — removal re-resolved the display path through the \
6443 intermediate symlink instead of using the pinned fd (path-based rm regression)"
6444 );
6445 Ok(())
6446 }
6447
6448 #[tokio::test]
6456 async fn copies_into_a_fresh_marked_destination_another_writer_populated()
6457 -> Result<(), anyhow::Error> {
6458 let tmp = testutils::create_temp_dir().await?;
6459 let src = tmp.join("src");
6460 tokio::fs::create_dir(&src).await?;
6461 tokio::fs::create_dir(src.join("sub")).await?;
6462 tokio::fs::write(src.join("sub").join("a.txt"), "a").await?;
6463 let dst = tmp.join("dst");
6465 tokio::fs::create_dir(&dst).await?;
6466 tokio::fs::create_dir(dst.join("sub")).await?;
6467 let summary = copy(
6468 &PROGRESS,
6469 &src,
6470 &dst,
6471 &settings_with_delete(None),
6472 &NO_PRESERVE_SETTINGS,
6473 true, )
6475 .await?;
6476 assert_eq!(summary.directories_created, 0);
6477 assert_eq!(summary.directories_unchanged, 2);
6478 assert_eq!(summary.files_copied, 1);
6479 assert_eq!(
6480 tokio::fs::read_to_string(dst.join("sub").join("a.txt")).await?,
6481 "a"
6482 );
6483 Ok(())
6484 }
6485
6486 #[tokio::test]
6493 async fn stale_fresh_hint_overwrites_preexisting_file() -> Result<(), anyhow::Error> {
6494 let tmp = testutils::create_temp_dir().await?;
6495 let src = tmp.join("src");
6496 tokio::fs::create_dir(&src).await?;
6497 tokio::fs::create_dir(src.join("sub")).await?;
6498 tokio::fs::write(src.join("sub").join("a.txt"), "new").await?;
6499 let dst = tmp.join("dst");
6500 tokio::fs::create_dir(&dst).await?;
6501 tokio::fs::create_dir(dst.join("sub")).await?;
6502 tokio::fs::write(dst.join("sub").join("a.txt"), "old-content-differs").await?;
6503 let summary = copy(
6504 &PROGRESS,
6505 &src,
6506 &dst,
6507 &settings_with_delete(delete_on()), &NO_PRESERVE_SETTINGS,
6509 true, )
6511 .await?;
6512 assert_eq!(summary.files_copied, 1);
6513 assert_eq!(
6514 tokio::fs::read_to_string(dst.join("sub").join("a.txt")).await?,
6515 "new"
6516 );
6517 Ok(())
6518 }
6519
6520 #[tokio::test]
6530 async fn overwrites_a_pre_existing_file_in_a_fresh_marked_destination()
6531 -> Result<(), anyhow::Error> {
6532 let tmp = testutils::create_temp_dir().await?;
6533 let src_file = tmp.join("src.txt");
6534 let dst_file = tmp.join("dst.txt");
6535 tokio::fs::write(&src_file, "fresh source content").await?;
6537 tokio::fs::write(&dst_file, "old").await?;
6538 let summary = copy(
6539 &PROGRESS,
6540 &src_file,
6541 &dst_file,
6542 &Settings {
6543 overwrite: true,
6544 ..settings_with_delete(None)
6545 },
6546 &NO_PRESERVE_SETTINGS,
6547 true, )
6549 .await?;
6550 assert_eq!(summary.files_copied, 1);
6551 assert_eq!(summary.rm_summary.files_removed, 1);
6552 assert_eq!(
6553 tokio::fs::read_to_string(&dst_file).await?,
6554 "fresh source content"
6555 );
6556 Ok(())
6557 }
6558
6559 #[tokio::test]
6560 async fn ignores_a_pre_existing_file_in_a_fresh_marked_destination() -> Result<(), anyhow::Error>
6561 {
6562 let tmp = testutils::create_temp_dir().await?;
6563 let src_file = tmp.join("src.txt");
6564 let dst_file = tmp.join("dst.txt");
6565 tokio::fs::write(&src_file, "fresh source content").await?;
6566 tokio::fs::write(&dst_file, "old").await?;
6567 let summary = copy(
6568 &PROGRESS,
6569 &src_file,
6570 &dst_file,
6571 &Settings {
6572 ignore_existing: true,
6573 ..settings_with_delete(None)
6574 },
6575 &NO_PRESERVE_SETTINGS,
6576 true,
6577 )
6578 .await?;
6579 assert_eq!(summary.files_unchanged, 1);
6580 assert_eq!(summary.files_copied, 0);
6581 assert_eq!(summary.rm_summary.files_removed, 0);
6582 assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "old");
6583 Ok(())
6584 }
6585
6586 #[tokio::test]
6589 async fn skips_an_identical_pre_existing_file_in_a_fresh_marked_destination()
6590 -> Result<(), anyhow::Error> {
6591 let tmp = testutils::create_temp_dir().await?;
6592 let src_file = tmp.join("src.txt");
6593 let dst_file = tmp.join("dst.txt");
6594 tokio::fs::write(&src_file, "abc").await?;
6595 tokio::fs::write(&dst_file, "xyz").await?;
6596 let summary = copy(
6597 &PROGRESS,
6598 &src_file,
6599 &dst_file,
6600 &Settings {
6601 overwrite: true,
6602 overwrite_compare: filecmp::MetadataCmpSettings {
6603 size: true,
6604 ..Default::default()
6605 },
6606 ..settings_with_delete(None)
6607 },
6608 &NO_PRESERVE_SETTINGS,
6609 true,
6610 )
6611 .await?;
6612 assert_eq!(summary.files_unchanged, 1);
6613 assert_eq!(summary.files_copied, 0);
6614 assert_eq!(summary.rm_summary.files_removed, 0);
6615 assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "xyz");
6617 Ok(())
6618 }
6619
6620 #[tokio::test]
6623 async fn reports_missing_overwrite_for_a_pre_existing_file_in_a_fresh_marked_destination()
6624 -> Result<(), anyhow::Error> {
6625 let tmp = testutils::create_temp_dir().await?;
6626 let src_file = tmp.join("src.txt");
6627 let dst_file = tmp.join("dst.txt");
6628 tokio::fs::write(&src_file, "fresh source content").await?;
6629 tokio::fs::write(&dst_file, "old").await?;
6630 let error = copy(
6631 &PROGRESS,
6632 &src_file,
6633 &dst_file,
6634 &settings_with_delete(None), &NO_PRESERVE_SETTINGS,
6636 true,
6637 )
6638 .await
6639 .expect_err("a pre-existing destination without --overwrite must fail");
6640 let message = format!("{:#}", &error.source);
6641 assert!(
6642 message.contains("did you intend to specify --overwrite?"),
6643 "expected the actionable overwrite error, got: {message}"
6644 );
6645 assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "old");
6647 Ok(())
6648 }
6649
6650 #[tokio::test]
6654 async fn ignores_a_pre_existing_file_when_the_destination_is_not_fresh()
6655 -> Result<(), anyhow::Error> {
6656 let tmp = testutils::create_temp_dir().await?;
6657 let src_file = tmp.join("src.txt");
6658 let dst_file = tmp.join("dst.txt");
6659 tokio::fs::write(&src_file, "fresh source content").await?;
6660 tokio::fs::write(&dst_file, "old").await?;
6661 let summary = copy(
6662 &PROGRESS,
6663 &src_file,
6664 &dst_file,
6665 &Settings {
6666 ignore_existing: true,
6667 ..settings_with_delete(None)
6668 },
6669 &NO_PRESERVE_SETTINGS,
6670 false,
6671 )
6672 .await?;
6673 assert_eq!(summary.files_unchanged, 1);
6674 assert_eq!(summary.files_copied, 0);
6675 assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "old");
6676 Ok(())
6677 }
6678
6679 #[tokio::test]
6686 async fn overwrite_keeps_the_destination_when_the_source_cannot_be_opened()
6687 -> Result<(), anyhow::Error> {
6688 let tmp = testutils::create_temp_dir().await?;
6689 let src = tmp.join("src");
6690 tokio::fs::create_dir(&src).await?;
6691 tokio::fs::write(src.join("a.txt"), "new content").await?;
6692 tokio::fs::set_permissions(src.join("a.txt"), std::fs::Permissions::from_mode(0o000))
6695 .await?;
6696 let dst = tmp.join("dst");
6697 tokio::fs::create_dir(&dst).await?;
6698 tokio::fs::write(dst.join("a.txt"), "PRECIOUS").await?;
6700 let error = copy(
6701 &PROGRESS,
6702 &src,
6703 &dst,
6704 &Settings {
6705 overwrite: true,
6706 ..settings_with_delete(None)
6707 },
6708 &NO_PRESERVE_SETTINGS,
6709 false,
6710 )
6711 .await
6712 .expect_err("an unreadable source must fail the copy");
6713 let message = format!("{:#}", &error.source);
6714 assert!(
6715 message.contains("failed opening src file"),
6716 "expected the source open to be the failure; got: {message}"
6717 );
6718 assert_eq!(
6719 tokio::fs::read_to_string(dst.join("a.txt")).await?,
6720 "PRECIOUS",
6721 "the destination must survive a copy that never got far enough to replace it"
6722 );
6723 assert_eq!(
6724 error.summary.rm_summary.files_removed, 0,
6725 "nothing was removed, and the summary must say so"
6726 );
6727 Ok(())
6728 }
6729
6730 #[tokio::test]
6734 async fn executing_a_second_replace_keeps_the_first_removal_in_the_totals()
6735 -> Result<(), anyhow::Error> {
6736 let tmp = testutils::create_temp_dir().await?;
6737 tokio::fs::write(tmp.join("victim.txt"), "0123456").await?; let dst_parent =
6739 Arc::new(Dir::open_root_dir(&tmp, false, congestion::Side::Destination).await?);
6740 let victim: &OsStr = OsStr::new("victim.txt");
6741 let plan = FilePlan::Replace(dst_parent.child(victim).await?);
6742 let mut copy_summary = Summary {
6744 rm_summary: RmSummary {
6745 files_removed: 1,
6746 bytes_removed: 10,
6747 ..Default::default()
6748 },
6749 ..Default::default()
6750 };
6751 execute_dst_plan(
6752 &PROGRESS,
6753 &dst_parent,
6754 victim,
6755 &tmp.join("victim.txt"),
6756 plan,
6757 &settings_with_delete(None),
6758 &mut copy_summary,
6759 )
6760 .await?;
6761 assert_eq!(copy_summary.rm_summary.files_removed, 2);
6762 assert_eq!(copy_summary.rm_summary.bytes_removed, 17);
6763 assert!(!tmp.join("victim.txt").exists(), "the entry was unlinked");
6764 execute_dst_plan(
6766 &PROGRESS,
6767 &dst_parent,
6768 victim,
6769 &tmp.join("victim.txt"),
6770 FilePlan::Vacant,
6771 &settings_with_delete(None),
6772 &mut copy_summary,
6773 )
6774 .await?;
6775 assert_eq!(copy_summary.rm_summary.files_removed, 2);
6776 Ok(())
6777 }
6778
6779 #[tokio::test]
6785 async fn executing_a_failed_replace_keeps_an_earlier_removal_in_the_totals()
6786 -> Result<(), anyhow::Error> {
6787 let tmp = testutils::create_temp_dir().await?;
6788 tokio::fs::write(tmp.join("victim.txt"), "original").await?;
6789 let dst_parent =
6790 Arc::new(Dir::open_root_dir(&tmp, false, congestion::Side::Destination).await?);
6791 let victim: &OsStr = OsStr::new("victim.txt");
6792 let plan = FilePlan::Replace(dst_parent.child(victim).await?);
6793 tokio::fs::remove_file(tmp.join("victim.txt")).await?;
6795 tokio::fs::write(tmp.join("victim.txt"), "someone else's file").await?;
6796 let mut copy_summary = Summary {
6797 rm_summary: RmSummary {
6798 files_removed: 1,
6799 bytes_removed: 10,
6800 ..Default::default()
6801 },
6802 ..Default::default()
6803 };
6804 let error = execute_dst_plan(
6805 &PROGRESS,
6806 &dst_parent,
6807 victim,
6808 &tmp.join("victim.txt"),
6809 plan,
6810 &settings_with_delete(None),
6811 &mut copy_summary,
6812 )
6813 .await
6814 .expect_err("a swapped destination must fail closed, not be removed");
6815 assert_eq!(error.summary.rm_summary.files_removed, 1);
6816 assert_eq!(error.summary.rm_summary.bytes_removed, 10);
6817 assert_eq!(
6819 tokio::fs::read_to_string(tmp.join("victim.txt")).await?,
6820 "someone else's file"
6821 );
6822 let message = format!("{:#}", &error.source);
6824 assert!(
6825 message.contains("changed identity before removal"),
6826 "context lost: {message}"
6827 );
6828 assert!(
6829 message.contains("Stale file handle"),
6830 "root cause lost: {message}"
6831 );
6832 Ok(())
6833 }
6834
6835 #[test]
6838 fn a_skip_keeps_an_earlier_removal_in_the_totals() {
6839 let copy_summary = Summary {
6840 rm_summary: RmSummary {
6841 files_removed: 1,
6842 bytes_removed: 10,
6843 ..Default::default()
6844 },
6845 ..Default::default()
6846 };
6847 let skip = Summary {
6848 files_unchanged: 1,
6849 ..Default::default()
6850 };
6851 let summary = skip_summary(skip, ©_summary);
6852 assert_eq!(summary.files_unchanged, 1);
6853 assert_eq!(summary.rm_summary.files_removed, 1);
6854 assert_eq!(summary.rm_summary.bytes_removed, 10);
6855 }
6856
6857 #[tokio::test]
6866 async fn stale_fresh_hint_overwrites_preexisting_symlink() -> Result<(), anyhow::Error> {
6867 let tmp = testutils::create_temp_dir().await?;
6868 let src_link = tmp.join("src.link");
6869 let dst_link = tmp.join("dst.link");
6870 tokio::fs::symlink("target-a", &src_link).await?;
6871 tokio::fs::symlink("target-b", &dst_link).await?;
6872 let summary = copy(
6873 &PROGRESS,
6874 &src_link,
6875 &dst_link,
6876 &Settings {
6877 overwrite: true,
6878 ..settings_with_delete(None)
6879 },
6880 &NO_PRESERVE_SETTINGS,
6881 true, )
6883 .await?;
6884 assert_eq!(summary.symlinks_created, 1);
6885 assert_eq!(summary.rm_summary.symlinks_removed, 1);
6886 assert_eq!(
6887 tokio::fs::read_link(&dst_link).await?.to_str(),
6888 Some("target-a")
6889 );
6890 Ok(())
6891 }
6892
6893 #[tokio::test]
6894 async fn stale_fresh_hint_ignores_preexisting_symlink() -> Result<(), anyhow::Error> {
6895 let tmp = testutils::create_temp_dir().await?;
6896 let src_link = tmp.join("src.link");
6897 let dst_link = tmp.join("dst.link");
6898 tokio::fs::symlink("target-a", &src_link).await?;
6899 tokio::fs::symlink("target-b", &dst_link).await?;
6900 let summary = copy(
6901 &PROGRESS,
6902 &src_link,
6903 &dst_link,
6904 &Settings {
6905 ignore_existing: true,
6906 ..settings_with_delete(None)
6907 },
6908 &NO_PRESERVE_SETTINGS,
6909 true,
6910 )
6911 .await?;
6912 assert_eq!(summary.symlinks_unchanged, 1);
6913 assert_eq!(summary.symlinks_created, 0);
6914 assert_eq!(
6915 tokio::fs::read_link(&dst_link).await?.to_str(),
6916 Some("target-b")
6917 );
6918 Ok(())
6919 }
6920
6921 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6926 async fn fifo_swap_never_hangs_or_copies_pipe() -> Result<(), anyhow::Error> {
6927 let tmp_dir = testutils::create_temp_dir().await?;
6928 let test_path = tmp_dir.as_path();
6929 let src = test_path.join("src");
6930 let sub = src.join("sub");
6931 tokio::fs::create_dir(&src).await?;
6932 tokio::fs::create_dir(&sub).await?;
6933 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
6934
6935 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
6936 let swapper = {
6937 let sub = sub.clone();
6938 let stop = stop.clone();
6939 std::thread::spawn(move || {
6940 let entry = sub.join("entry");
6941 let staged_real = sub.join("__staged_real");
6942 let staged_fifo = sub.join("__staged_fifo");
6943 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
6944 let _ = std::fs::remove_file(&staged_real);
6946 if std::fs::write(&staged_real, b"REAL_CONTENT").is_ok() {
6947 let _ = std::fs::rename(&staged_real, &entry);
6948 }
6949 let _ = std::fs::remove_file(&staged_fifo);
6952 let made = nix::unistd::mkfifo(
6953 &staged_fifo,
6954 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
6955 )
6956 .is_ok();
6957 if made {
6958 let _ = std::fs::rename(&staged_fifo, &entry);
6959 }
6960 }
6961 })
6962 };
6963
6964 let settings = toctou_settings();
6965 for i in 0..200 {
6966 let dst = test_path.join(format!("dst_{i}"));
6967 let _ = tokio::time::timeout(
6970 std::time::Duration::from_secs(30),
6971 copy(
6972 &PROGRESS,
6973 &src,
6974 &dst,
6975 &settings,
6976 &NO_PRESERVE_SETTINGS,
6977 false,
6978 ),
6979 )
6980 .await
6981 .expect("copy must not hang when an entry is swapped to a FIFO");
6982 let entry_dst = dst.join("sub").join("entry");
6986 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await {
6987 use std::os::unix::fs::FileTypeExt as _;
6988 let ft = md.file_type();
6989 assert!(
6990 !ft.is_fifo(),
6991 "iteration {i}: a FIFO was reproduced at the destination"
6992 );
6993 if ft.is_file() {
6994 let content = tokio::fs::read_to_string(&entry_dst).await?;
6995 assert_eq!(
6996 content, "REAL_CONTENT",
6997 "iteration {i}: a regular destination file must hold the real content"
6998 );
6999 }
7000 }
7001 let _ = tokio::fs::remove_dir_all(&dst).await;
7002 }
7003
7004 stop.store(true, std::sync::atomic::Ordering::Relaxed);
7005 swapper.join().expect("fifo swapper thread panicked");
7006 Ok(())
7007 }
7008
7009 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7013 async fn copy_deep_narrow_tree_completes() -> Result<(), anyhow::Error> {
7014 let tmp_dir = testutils::create_temp_dir().await?;
7015 let test_path = tmp_dir.as_path();
7016 let src = test_path.join("src");
7017 const DEPTH: usize = 800;
7018 let mut cur = src.clone();
7020 for _ in 0..DEPTH {
7021 cur = cur.join("a");
7022 }
7023 tokio::fs::create_dir_all(&cur).await?;
7024 tokio::fs::write(cur.join("leaf.txt"), "DEEP_LEAF").await?;
7025
7026 let dst = test_path.join("dst");
7027 let summary = tokio::time::timeout(
7028 std::time::Duration::from_secs(120),
7029 copy(
7030 &PROGRESS,
7031 &src,
7032 &dst,
7033 &toctou_settings(),
7034 &NO_PRESERVE_SETTINGS,
7035 false,
7036 ),
7037 )
7038 .await
7039 .expect("deep-narrow copy must not hang (fd budget / hold-and-wait)")?;
7040 assert_eq!(
7041 summary.files_copied, 1,
7042 "the single leaf file must be copied"
7043 );
7044 assert_eq!(summary.directories_created, DEPTH + 1);
7046 let mut leaf = dst.clone();
7048 for _ in 0..DEPTH {
7049 leaf = leaf.join("a");
7050 }
7051 let content = tokio::fs::read_to_string(leaf.join("leaf.txt")).await?;
7052 assert_eq!(content, "DEEP_LEAF");
7053 Ok(())
7054 }
7055
7056 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7060 async fn copy_wide_tree_completes() -> Result<(), anyhow::Error> {
7061 let tmp_dir = testutils::create_temp_dir().await?;
7062 let test_path = tmp_dir.as_path();
7063 let src = test_path.join("src");
7064 tokio::fs::create_dir(&src).await?;
7065 const WIDTH: usize = 2000;
7066 for i in 0..WIDTH {
7067 tokio::fs::write(src.join(format!("f{i}.txt")), format!("content {i}")).await?;
7068 }
7069 let dst = test_path.join("dst");
7070 let summary = tokio::time::timeout(
7071 std::time::Duration::from_secs(120),
7072 copy(
7073 &PROGRESS,
7074 &src,
7075 &dst,
7076 &toctou_settings(),
7077 &NO_PRESERVE_SETTINGS,
7078 false,
7079 ),
7080 )
7081 .await
7082 .expect("wide copy must not hang (fd exhaustion / permit deadlock)")?;
7083 assert_eq!(summary.files_copied, WIDTH, "every file must be copied");
7084 for i in [0usize, WIDTH / 2, WIDTH - 1] {
7086 let content = tokio::fs::read_to_string(dst.join(format!("f{i}.txt"))).await?;
7087 assert_eq!(content, format!("content {i}"));
7088 }
7089 Ok(())
7090 }
7091
7092 #[derive(Clone, Debug)]
7094 struct CmpMeta {
7095 uid: u32,
7096 gid: u32,
7097 mode: u32,
7098 size: u64,
7099 mtime: i64,
7100 }
7101 impl crate::preserve::Metadata for CmpMeta {
7102 fn uid(&self) -> u32 {
7103 self.uid
7104 }
7105 fn gid(&self) -> u32 {
7106 self.gid
7107 }
7108 fn atime(&self) -> i64 {
7109 0
7110 }
7111 fn atime_nsec(&self) -> i64 {
7112 0
7113 }
7114 fn mtime(&self) -> i64 {
7115 self.mtime
7116 }
7117 fn mtime_nsec(&self) -> i64 {
7118 0
7119 }
7120 fn permissions(&self) -> std::fs::Permissions {
7121 std::os::unix::fs::PermissionsExt::from_mode(self.mode)
7122 }
7123 fn size(&self) -> u64 {
7124 self.size
7125 }
7126 }
7127
7128 fn size_mtime() -> filecmp::MetadataCmpSettings {
7129 filecmp::MetadataCmpSettings {
7130 size: true,
7131 mtime: true,
7132 ..Default::default()
7133 }
7134 }
7135 fn base() -> CmpMeta {
7136 CmpMeta {
7137 uid: 0,
7138 gid: 0,
7139 mode: 0o644,
7140 size: 10,
7141 mtime: 100,
7142 }
7143 }
7144
7145 #[test]
7146 fn skip_send_no_existing_entry_sends() {
7147 let src = base();
7148 let r = skip_unchanged_send(
7149 &size_mtime(),
7150 None,
7151 false,
7152 &src,
7153 None::<ExistingDst<CmpMeta>>,
7154 );
7155 assert!(!r);
7156 }
7157
7158 #[test]
7159 fn skip_send_ignore_existing_skips_any_type() {
7160 let src = base();
7161 let dst = base();
7162 let r = skip_unchanged_send(
7163 &size_mtime(),
7164 None,
7165 true,
7166 &src,
7167 Some(ExistingDst {
7168 meta: &dst,
7169 is_file: false,
7170 }),
7171 );
7172 assert!(r);
7173 }
7174
7175 #[test]
7176 fn skip_send_overwrite_identical_skips() {
7177 let src = base();
7178 let dst = base();
7179 let r = skip_unchanged_send(
7180 &size_mtime(),
7181 None,
7182 false,
7183 &src,
7184 Some(ExistingDst {
7185 meta: &dst,
7186 is_file: true,
7187 }),
7188 );
7189 assert!(r);
7190 }
7191
7192 #[test]
7193 fn skip_send_overwrite_different_size_sends() {
7194 let src = base();
7195 let dst = CmpMeta { size: 11, ..base() };
7196 let r = skip_unchanged_send(
7197 &size_mtime(),
7198 None,
7199 false,
7200 &src,
7201 Some(ExistingDst {
7202 meta: &dst,
7203 is_file: true,
7204 }),
7205 );
7206 assert!(!r);
7207 }
7208
7209 #[test]
7210 fn skip_send_overwrite_different_mtime_sends() {
7211 let src = base();
7212 let dst = CmpMeta {
7213 mtime: 200,
7214 ..base()
7215 };
7216 let r = skip_unchanged_send(
7217 &size_mtime(),
7218 None,
7219 false,
7220 &src,
7221 Some(ExistingDst {
7222 meta: &dst,
7223 is_file: true,
7224 }),
7225 );
7226 assert!(!r);
7227 }
7228
7229 #[test]
7230 fn skip_send_overwrite_non_file_sends() {
7231 let src = base();
7232 let dst = base();
7233 let r = skip_unchanged_send(
7234 &size_mtime(),
7235 None,
7236 false,
7237 &src,
7238 Some(ExistingDst {
7239 meta: &dst,
7240 is_file: false,
7241 }),
7242 );
7243 assert!(!r);
7244 }
7245
7246 #[test]
7247 fn skip_send_filter_newer_skips_when_dest_newer() {
7248 let src = CmpMeta {
7249 mtime: 100,
7250 size: 5,
7251 ..base()
7252 };
7253 let dst = CmpMeta {
7254 mtime: 200,
7255 size: 11,
7256 ..base()
7257 };
7258 let r = skip_unchanged_send(
7259 &size_mtime(),
7260 Some(OverwriteFilter::Newer),
7261 false,
7262 &src,
7263 Some(ExistingDst {
7264 meta: &dst,
7265 is_file: true,
7266 }),
7267 );
7268 assert!(r);
7269 }
7270
7271 #[test]
7272 fn skip_send_filter_newer_sends_when_dest_older() {
7273 let src = CmpMeta {
7274 mtime: 200,
7275 size: 5,
7276 ..base()
7277 };
7278 let dst = CmpMeta {
7279 mtime: 100,
7280 size: 11,
7281 ..base()
7282 };
7283 let r = skip_unchanged_send(
7284 &size_mtime(),
7285 Some(OverwriteFilter::Newer),
7286 false,
7287 &src,
7288 Some(ExistingDst {
7289 meta: &dst,
7290 is_file: true,
7291 }),
7292 );
7293 assert!(!r);
7294 }
7295}