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 if let Some(ref filter) = settings.filter {
346 let src_metadata = crate::walk::run_metadata_probed(
347 congestion::Side::Source,
348 congestion::MetadataOp::Stat,
349 tokio::fs::symlink_metadata(src),
350 )
351 .await
352 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
353 .map_err(|err| Error::new(err, Default::default()))?;
354 let is_dir = src_metadata.is_dir();
355 let result = if filter_base.as_os_str().is_empty() {
359 filter.should_include_root_item(std::path::Path::new(src_name), is_dir)
360 } else {
361 filter.should_include(filter_base, is_dir)
362 };
363 match result {
364 crate::filter::FilterResult::Included => {}
365 result => {
366 let kind = EntryKind::from_metadata(&src_metadata);
367 if let Some(mode) = settings.dry_run {
368 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
369 }
370 kind.inc_skipped(prog_track);
371 return Ok(skipped_summary_for(kind));
372 }
373 }
374 }
375 let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
381 return Err(Error::new(
382 anyhow!(
383 "copy destination {:?} has no parent directory or file name",
384 dst
385 ),
386 Default::default(),
387 ));
388 };
389 let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
391 std::path::Path::new(".")
392 } else {
393 dst_parent_path
394 };
395 let src_parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
399 .await
400 .with_context(|| format!("cannot open source parent directory {:?}", src_parent_path))
401 .map_err(|err| Error::new(err, Default::default()))?;
402 let src_parent = Arc::new(src_parent.into_tree());
404 let dst_parent = if settings.dry_run.is_some() {
408 None
409 } else {
410 let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
414 .await
415 .with_context(|| {
416 format!(
417 "cannot open destination parent directory {:?}",
418 dst_parent_path
419 )
420 })
421 .map_err(|err| Error::new(err, Default::default()))?;
422 Some(Arc::new(dir.into_tree()))
424 };
425 run_copy_root(
426 prog_track,
427 &src_parent,
428 dst_parent,
429 src_name,
430 src,
431 dst,
432 filter_base,
433 settings,
434 preserve,
435 is_fresh,
436 None,
437 )
438 .await
439}
440
441#[instrument(skip(
455 prog_track,
456 src_parent,
457 dst_parent,
458 settings,
459 preserve,
460 open_file_guard
461))]
462#[allow(clippy::too_many_arguments)]
463pub(crate) async fn copy_child(
464 prog_track: &'static progress::Progress,
465 src_parent: &Arc<Dir>,
466 dst_parent: Option<&Arc<Dir>>,
467 name: &OsStr,
468 src_path: &std::path::Path,
469 dst_path: &std::path::Path,
470 filter_base: &std::path::Path,
471 settings: &Settings,
472 preserve: &preserve::Settings,
473 is_fresh: bool,
474 open_file_guard: Option<throttle::OpenFileGuard>,
475) -> Result<Summary, Error> {
476 run_copy_root(
477 prog_track,
478 src_parent,
479 dst_parent.map(Arc::clone),
480 name,
481 src_path,
482 dst_path,
483 filter_base,
484 settings,
485 preserve,
486 is_fresh,
487 open_file_guard.map(LeafPermit::OpenFile),
488 )
489 .await
490}
491
492struct CopyVisitor {
502 prog_track: &'static progress::Progress,
503 dst_root: PathBuf,
508 filter_base: PathBuf,
510 settings: Settings,
511 preserve: preserve::Settings,
512 dst_parent: Option<Arc<Dir>>,
515 root_is_fresh: bool,
517}
518
519#[derive(Clone)]
524struct CopyDirContext {
525 dst_dir: Option<Arc<Dir>>,
526 is_fresh: bool,
527}
528
529struct CopyDirState {
533 dst_dir: Option<Arc<Dir>>,
535 dst_parent: Option<Arc<Dir>>,
538 dst_name: OsString,
539 we_created: bool,
541 src_meta: FileMeta,
543 is_root: bool,
545 base: Summary,
548}
549
550impl CopyVisitor {
551 fn dst_path_for(&self, cx: &EntryCx) -> PathBuf {
555 if cx.rel_path.as_os_str().is_empty() {
556 self.dst_root.clone()
557 } else {
558 self.dst_root.join(&cx.rel_path)
559 }
560 }
561
562 #[allow(clippy::result_large_err)]
570 fn dst_name_for(&self, cx: &EntryCx) -> Result<OsString, Error> {
571 if cx.rel_path.as_os_str().is_empty() {
572 self.dst_root
573 .file_name()
574 .map(OsStr::to_owned)
575 .ok_or_else(|| {
576 Error::new(
577 anyhow!("copy destination {:?} has no file name", &self.dst_root),
578 Default::default(),
579 )
580 })
581 } else {
582 Ok(cx.name.clone())
583 }
584 }
585
586 async fn prune_finished_dir(
597 &self,
598 copy_summary: &mut Summary,
599 child_error: &mut Option<anyhow::Error>,
600 processed: &ProcessedChildren,
601 dst_dir: &Option<Arc<Dir>>,
602 dst_path: &std::path::Path,
603 rel_path: &std::path::Path,
604 ) -> Result<(), Error> {
605 if child_error.is_some() && self.settings.delete.is_some() {
606 tracing::warn!(
607 "skipping --delete pruning of {:?} because the copy reported errors",
608 dst_path
609 );
610 }
611 let Some(delete_settings) = &self.settings.delete else {
612 return Ok(());
613 };
614 if child_error.is_some() {
615 return Ok(());
616 }
617 let keep_set: std::collections::HashSet<OsString> =
621 processed.names().iter().cloned().collect();
622 let relative_dir = self.filter_base.join(rel_path);
623 let prune_dir: Option<Arc<Dir>> = match dst_dir {
630 Some(dir) => Some(Arc::clone(dir)),
631 None => {
632 match Dir::open_root_dir(dst_path, false, congestion::Side::Destination).await {
633 Ok(dir) => Some(Arc::new(dir)),
634 Err(err)
635 if matches!(
636 err.kind(),
637 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
638 ) || err.raw_os_error() == Some(libc::ELOOP)
639 || err.raw_os_error() == Some(libc::ENOTDIR) =>
640 {
641 tracing::debug!(
642 "skipping --delete pruning of {:?}: not a real directory in dry-run",
643 dst_path
644 );
645 None
646 }
647 Err(err) => {
648 let err = anyhow::Error::new(err).context(format!(
649 "cannot open destination {dst_path:?} for delete scan"
650 ));
651 return Err(Error::new(err, *copy_summary));
655 }
656 }
657 }
658 };
659 let Some(prune_dir) = prune_dir else {
660 return Ok(());
661 };
662 match crate::delete::prune_extraneous(
663 self.prog_track,
664 &prune_dir,
665 &relative_dir,
666 &keep_set,
667 self.settings.filter.as_ref(),
668 delete_settings,
669 self.settings.fail_early,
670 self.settings.dry_run,
671 )
672 .await
673 {
674 Ok(rm_summary) => {
675 copy_summary.rm_summary = copy_summary.rm_summary + rm_summary;
676 }
677 Err(err) => {
678 copy_summary.rm_summary = copy_summary.rm_summary + err.summary;
679 if self.settings.fail_early {
680 return Err(Error::new(err.source, *copy_summary));
681 }
682 *child_error = Some(err.source);
686 }
687 }
688 Ok(())
689 }
690
691 async fn finalize_dir(&self, fin: FinalizeDir, cx: &EntryCx) -> Result<Summary, Error> {
697 let FinalizeDir {
698 mut copy_summary,
699 child_error,
700 dst_dir,
701 dst_parent,
702 dst_name,
703 src_meta,
704 we_created,
705 is_root,
706 } = fin;
707 let src_path = &cx.real_path;
708 let dst_path = self.dst_path_for(cx);
709 let rel_path = &cx.rel_path;
710 let this_dir_count = usize::from(we_created);
713 let child_dirs_created = copy_summary
714 .directories_created
715 .saturating_sub(this_dir_count);
716 let anything_copied = copy_summary.files_copied > 0
717 || copy_summary.symlinks_created > 0
718 || child_dirs_created > 0;
719 let relative_path = self.filter_base.join(rel_path);
720 match check_empty_dir_cleanup(
721 self.settings.filter.as_ref(),
722 we_created,
723 anything_copied,
724 &relative_path,
725 is_root,
726 self.settings.dry_run.is_some(),
727 ) {
728 EmptyDirAction::Keep => { }
729 EmptyDirAction::DryRunSkip => {
730 tracing::debug!(
731 "dry-run: directory {:?} would not be created (nothing to copy inside)",
732 dst_path
733 );
734 copy_summary.directories_created = 0;
735 return match child_error {
739 Some(child_error) => Err(Error::new(child_error, copy_summary)),
740 None => Ok(copy_summary),
741 };
742 }
743 EmptyDirAction::Remove => {
744 tracing::debug!(
745 "directory {:?} has nothing to copy inside, removing empty directory",
746 dst_path
747 );
748 let rmdir_result = match &dst_parent {
755 Some(dst_parent) => dst_parent.rmdir_at(&dst_name).await,
756 None => {
757 crate::walk::run_metadata_probed(
758 congestion::Side::Destination,
759 congestion::MetadataOp::RmDir,
760 tokio::fs::remove_dir(&dst_path),
761 )
762 .await
763 }
764 };
765 match rmdir_result {
766 Ok(()) => {
767 copy_summary.directories_created = 0;
768 return match child_error {
773 Some(child_error) => Err(Error::new(child_error, copy_summary)),
774 None => Ok(copy_summary),
775 };
776 }
777 Err(err) => {
778 tracing::debug!(
779 "failed to remove empty directory {:?}: {:#}, keeping",
780 dst_path,
781 &err
782 );
783 }
785 }
786 }
787 }
788 tracing::debug!("set 'dst' directory metadata");
793 let metadata_result = match &dst_dir {
794 Some(dst_dir) => safedir::set_dir_metadata_fd(&self.preserve, &src_meta, dst_dir).await,
795 None => Ok(()),
796 };
797 if let Some(child_error) = child_error {
798 if let Err(metadata_err) = metadata_result {
799 tracing::error!(
800 "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
801 src_path,
802 dst_path,
803 &metadata_err
804 );
805 }
806 return Err(Error::new(child_error, copy_summary));
807 }
808 metadata_result
810 .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
811 .map_err(|err| Error::new(err, copy_summary))?;
812 Ok(copy_summary)
813 }
814}
815
816struct FinalizeDir {
820 copy_summary: Summary,
821 child_error: Option<anyhow::Error>,
822 dst_dir: Option<Arc<Dir>>,
823 dst_parent: Option<Arc<Dir>>,
824 dst_name: OsString,
825 src_meta: FileMeta,
826 we_created: bool,
827 is_root: bool,
828}
829
830#[allow(clippy::too_many_arguments)]
837async fn run_copy_root(
838 prog_track: &'static progress::Progress,
839 src_parent: &Arc<Dir>,
840 dst_parent: Option<Arc<Dir>>,
841 name: &OsStr,
842 src_root: &std::path::Path,
843 dst_root: &std::path::Path,
844 filter_base: &std::path::Path,
845 settings: &Settings,
846 preserve: &preserve::Settings,
847 is_fresh: bool,
848 permit: Option<LeafPermit>,
849) -> Result<Summary, Error> {
850 let visitor = Arc::new(CopyVisitor {
851 prog_track,
852 dst_root: dst_root.to_path_buf(),
853 filter_base: filter_base.to_path_buf(),
854 settings: settings.clone(),
855 preserve: *preserve,
856 dst_parent,
857 root_is_fresh: is_fresh,
858 });
859 let root_cx = EntryCx {
868 parent: Arc::clone(src_parent),
869 name: name.to_owned(),
870 rel_path: PathBuf::new(),
871 filter_path: filter_base.to_path_buf(),
872 real_path: src_root.to_path_buf(),
873 dry_run: settings.dry_run.is_some(),
874 prog_track,
875 };
876 let root_ctx = visitor.root_dir_context();
877 process_entry(visitor, root_cx, root_ctx, permit).await
878}
879
880impl WalkVisitor for CopyVisitor {
881 type Summary = Summary;
882 type DirContext = CopyDirContext;
883 type DirState = CopyDirState;
884
885 fn root_dir_context(&self) -> CopyDirContext {
886 CopyDirContext {
887 dst_dir: self.dst_parent.clone(),
888 is_fresh: self.root_is_fresh,
889 }
890 }
891
892 fn permit_kind(&self) -> PermitKind {
893 PermitKind::OpenFile
895 }
896
897 fn want_permit(&self, hint: Option<EntryKind>) -> bool {
898 hint == Some(EntryKind::File)
902 }
903
904 fn fail_early(&self) -> bool {
905 self.settings.fail_early
906 }
907
908 fn filter(&self) -> Option<&crate::filter::FilterSettings> {
909 self.settings.filter.as_ref()
910 }
911
912 fn on_skip(
913 &self,
914 cx: &EntryCx,
915 kind: EntryKind,
916 skip_result: &crate::filter::FilterResult,
917 ) -> Summary {
918 if let Some(mode) = self.settings.dry_run {
921 crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
922 }
923 skipped_summary_for(kind)
924 }
925
926 async fn visit_leaf(
927 &self,
928 cx: &EntryCx,
929 parent_ctx: &CopyDirContext,
930 handle: Handle,
931 kind: EntryKind,
932 permit: Option<LeafPermit>,
933 ) -> Result<Summary, Error> {
934 let src_parent = &cx.parent;
935 let dst_parent = parent_ctx.dst_dir.as_ref();
936 let name = cx.name.as_os_str();
937 let src_path = &cx.real_path;
938 let dst_path = self.dst_path_for(cx);
939 let dst_name = self.dst_name_for(cx)?;
940 let is_fresh = parent_ctx.is_fresh;
941 if self.settings.dereference && kind == EntryKind::Symlink {
946 drop(permit);
947 debug_assert!(
952 self.settings.dereference,
953 "canonicalize reached with dereference == false; the non-dereference copy path \
954 must never re-resolve a path"
955 );
956 let link = crate::walk::run_metadata_probed(
957 congestion::Side::Source,
958 congestion::MetadataOp::Stat,
959 tokio::fs::canonicalize(src_path),
960 )
961 .await
962 .with_context(|| format!("failed reading src symlink {:?}", src_path))
963 .map_err(|err| Error::new(err, Default::default()))?;
964 return copy(
965 self.prog_track,
966 &link,
967 &dst_path,
968 &self.settings,
969 &self.preserve,
970 is_fresh,
971 )
972 .await;
973 }
974 match kind {
975 EntryKind::File => {
976 let _guard = match permit {
980 Some(p) => p,
981 None => LeafPermit::OpenFile(throttle::open_file_permit().await),
982 };
983 copy_file_fd(
984 self.prog_track,
985 src_parent,
986 dst_parent,
987 name,
988 &dst_name,
989 &dst_path,
990 src_path,
991 &handle,
992 &self.settings,
993 &self.preserve,
994 is_fresh,
995 )
996 .await
997 }
998 EntryKind::Symlink => {
999 drop(permit);
1000 copy_symlink_fd(
1001 self.prog_track,
1002 src_parent,
1003 dst_parent,
1004 &dst_name,
1005 src_path,
1006 &dst_path,
1007 &handle,
1008 &self.settings,
1009 &self.preserve,
1010 is_fresh,
1011 )
1012 .await
1013 }
1014 EntryKind::Special => {
1015 drop(permit);
1016 if self.settings.skip_specials {
1017 tracing::debug!("skipping special file {:?}", src_path);
1018 if let Some(mode) = self.settings.dry_run {
1019 match mode {
1020 DryRunMode::Brief => {}
1021 DryRunMode::All => println!("skip special {:?}", src_path),
1022 DryRunMode::Explain => {
1023 println!("skip special {:?} (unsupported file type)", src_path);
1024 }
1025 }
1026 }
1027 self.prog_track.specials_skipped.inc();
1028 return Ok(Summary {
1029 specials_skipped: 1,
1030 ..Default::default()
1031 });
1032 }
1033 Err(Error::new(
1034 anyhow!(
1035 "copy: {:?} -> {:?} failed, unsupported src file type",
1036 src_path,
1037 dst_path
1038 ),
1039 Default::default(),
1040 ))
1041 }
1042 EntryKind::Dir => unreachable!("directory entries are handled by dir_pre/dir_post"),
1044 }
1045 }
1046
1047 async fn dir_pre(
1048 &self,
1049 cx: &EntryCx,
1050 parent_ctx: &CopyDirContext,
1051 _handle: &Handle,
1052 ) -> DirPreResult<Self> {
1053 let src_parent = &cx.parent;
1054 let name = cx.name.as_os_str();
1055 let src_path = &cx.real_path;
1056 let dst_path = self.dst_path_for(cx);
1057 let dst_name = self.dst_name_for(cx)?;
1058 let is_root = cx.rel_path.as_os_str().is_empty();
1059 let is_fresh = parent_ctx.is_fresh;
1060 let src_dir = src_parent
1062 .open_dir(name)
1063 .await
1064 .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1065 .map_err(|err| Error::new(err, Default::default()))?;
1066 let src_dir = Arc::new(src_dir);
1067 let src_meta = src_dir
1072 .meta()
1073 .await
1074 .with_context(|| format!("cannot read directory metadata from {:?}", src_path))
1075 .map_err(|err| Error::new(err, Default::default()))?;
1076 if self.settings.dry_run.is_some() {
1077 if self.settings.ignore_existing
1081 && !is_fresh
1082 && crate::walk::run_metadata_probed(
1083 congestion::Side::Destination,
1084 congestion::MetadataOp::Stat,
1085 tokio::fs::symlink_metadata(&dst_path),
1086 )
1087 .await
1088 .is_ok()
1089 && !dst_path.is_dir()
1090 {
1091 match self.settings.dry_run {
1092 Some(DryRunMode::Brief) | None => {}
1093 Some(DryRunMode::All) => println!("skip dir {:?}", dst_path),
1094 Some(DryRunMode::Explain) => {
1095 println!(
1096 "skip dir {:?} (destination exists, not a directory)",
1097 dst_path
1098 );
1099 }
1100 }
1101 return Ok(DirAction::Skip(Summary {
1102 directories_unchanged: 1,
1103 ..Default::default()
1104 }));
1105 }
1106 crate::dry_run::report_action("copy", src_path, Some(&dst_path), "dir");
1107 let base = Summary {
1108 directories_created: 1, ..Default::default()
1110 };
1111 return Ok(DirAction::Descend {
1112 dir: src_dir,
1113 child_ctx: CopyDirContext {
1114 dst_dir: None, is_fresh,
1116 },
1117 state: CopyDirState {
1118 dst_dir: None,
1119 dst_parent: None,
1120 dst_name,
1121 we_created: true,
1123 src_meta,
1124 is_root,
1125 base,
1126 },
1127 });
1128 }
1129 let dst_parent = parent_ctx
1131 .dst_dir
1132 .as_ref()
1133 .expect("destination parent must be open for a real copy");
1134 let DirSlot {
1135 dir: dst_dir,
1136 summary: base,
1137 is_fresh: child_is_fresh,
1138 we_created,
1139 } = match resolve_dst_dir(
1140 self.prog_track,
1141 dst_parent,
1142 &dst_name,
1143 &dst_path,
1144 &self.settings,
1145 is_fresh,
1146 )
1147 .await?
1148 {
1149 DirResolution::Skip(summary) => return Ok(DirAction::Skip(summary)),
1150 DirResolution::Proceed(slot) => slot,
1151 };
1152 Ok(DirAction::Descend {
1153 dir: src_dir,
1154 child_ctx: CopyDirContext {
1155 dst_dir: Some(Arc::clone(&dst_dir)),
1156 is_fresh: child_is_fresh,
1157 },
1158 state: CopyDirState {
1159 dst_dir: Some(dst_dir),
1160 dst_parent: Some(Arc::clone(dst_parent)),
1161 dst_name,
1162 we_created,
1163 src_meta,
1164 is_root,
1165 base,
1166 },
1167 })
1168 }
1169
1170 async fn dir_post(
1171 &self,
1172 cx: &EntryCx,
1173 state: CopyDirState,
1174 processed: &ProcessedChildren,
1175 child_result: Result<Summary, Error>,
1176 ) -> Result<Summary, Error> {
1177 let CopyDirState {
1178 dst_dir,
1179 dst_parent,
1180 dst_name,
1181 we_created,
1182 src_meta,
1183 is_root,
1184 base,
1185 } = state;
1186 let (child_summary, mut child_error) = match child_result {
1192 Ok(summary) => (summary, None),
1193 Err(err) => (err.summary, Some(err.source)),
1194 };
1195 let mut copy_summary = base + child_summary;
1196 let dst_path = self.dst_path_for(cx);
1197 self.prune_finished_dir(
1200 &mut copy_summary,
1201 &mut child_error,
1202 processed,
1203 &dst_dir,
1204 &dst_path,
1205 &cx.rel_path,
1206 )
1207 .await?;
1208 self.finalize_dir(
1210 FinalizeDir {
1211 copy_summary,
1212 child_error,
1213 dst_dir,
1214 dst_parent,
1215 dst_name,
1216 src_meta,
1217 we_created,
1218 is_root,
1219 },
1220 cx,
1221 )
1222 .await
1223 }
1224}
1225
1226#[allow(clippy::too_many_arguments)]
1232async fn copy_file_fd(
1233 prog_track: &'static progress::Progress,
1234 src_parent: &Arc<Dir>,
1235 dst_parent: Option<&Arc<Dir>>,
1236 name: &OsStr,
1237 dst_name: &OsStr,
1238 dst_path: &std::path::Path,
1239 src_path: &std::path::Path,
1240 src_handle: &Handle,
1241 settings: &Settings,
1242 preserve: &preserve::Settings,
1243 is_fresh: bool,
1244) -> Result<Summary, Error> {
1245 use crate::preserve::Metadata as _;
1248 let src_meta = src_handle.meta();
1249 let dst_exists = match dst_parent {
1254 Some(dst_parent) => dst_parent.child(dst_name).await.is_ok(),
1255 None => tokio::fs::symlink_metadata(dst_path).await.is_ok(),
1256 };
1257 if !is_fresh && settings.ignore_existing && dst_exists {
1258 if let Some(mode) = settings.dry_run {
1259 match mode {
1260 DryRunMode::Brief => {}
1261 DryRunMode::All => println!("skip file {:?}", dst_path),
1262 DryRunMode::Explain => println!("skip file {:?} (destination exists)", dst_path),
1263 }
1264 }
1265 tracing::debug!("destination exists, skipping (--ignore-existing)");
1266 prog_track.files_unchanged.inc();
1267 return Ok(Summary {
1268 files_unchanged: 1,
1269 ..Default::default()
1270 });
1271 }
1272 if settings.dry_run.is_some() {
1274 crate::dry_run::report_action("copy", src_path, Some(dst_path), "file");
1275 return Ok(Summary {
1276 files_copied: 1,
1277 bytes_copied: src_meta.size(),
1278 ..Default::default()
1279 });
1280 }
1281 let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1283 get_file_iops_tokens(settings.chunk_size, src_meta.size()).await;
1284 let mut rm_summary = RmSummary::default();
1285 if !is_fresh && let Ok(dst_handle) = dst_parent.child(dst_name).await {
1288 if !settings.overwrite {
1289 return Err(Error::new(
1290 anyhow!(
1291 "destination {:?} already exists, did you intend to specify --overwrite?",
1292 dst_path
1293 ),
1294 Default::default(),
1295 ));
1296 }
1297 tracing::debug!("file exists, check if it's identical");
1298 if dst_handle.kind() == EntryKind::File {
1299 if filecmp::metadata_equal(&settings.overwrite_compare, src_meta, dst_handle.meta()) {
1300 tracing::debug!("file is identical, skipping");
1301 prog_track.files_unchanged.inc();
1302 return Ok(Summary {
1303 files_unchanged: 1,
1304 ..Default::default()
1305 });
1306 }
1307 if let Some(OverwriteFilter::Newer) = settings.overwrite_filter
1308 && filecmp::dest_is_newer(src_meta, dst_handle.meta())
1309 {
1310 tracing::debug!("dest is newer than source, skipping");
1311 prog_track.files_unchanged.inc();
1312 return Ok(Summary {
1313 files_unchanged: 1,
1314 ..Default::default()
1315 });
1316 }
1317 }
1318 tracing::info!("destination differs, removing existing entry");
1319 rm_summary = remove_existing(
1320 prog_track,
1321 dst_parent,
1322 dst_name,
1323 dst_path,
1324 &dst_handle,
1325 settings,
1326 )
1327 .await?;
1328 }
1329 let copy_summary = Summary {
1334 rm_summary,
1335 ..Default::default()
1336 };
1337 let (src_file, open_meta) = src_parent
1338 .open_file_read(name)
1339 .await
1340 .with_context(|| format!("failed opening src file {:?} for reading", src_path))
1341 .map_err(|err| Error::new(err, copy_summary))?;
1342 let create_mode = preserve::masked_mode(preserve.file.mode_mask, &open_meta);
1343 let dst_file = dst_parent
1344 .create_file(dst_name, create_mode)
1345 .await
1346 .with_context(|| format!("failed creating {:?}", dst_path))
1347 .map_err(|err| Error::new(err, copy_summary))?;
1348 tracing::debug!("copying data");
1349 let len = open_meta.size();
1350 let (copied, dst_file) = tokio::task::spawn_blocking(move || {
1356 copy_file_range_all(&src_file, &dst_file, len).map(|copied| (copied, dst_file))
1357 })
1358 .await
1359 .map_err(std::io::Error::other)
1360 .and_then(|res| res)
1361 .with_context(|| format!("failed copying data to {:?}", dst_path))
1362 .map_err(|err| Error::new(err, copy_summary))?;
1363 prog_track.files_copied.inc();
1367 prog_track.bytes_copied.add(copied);
1368 tracing::debug!("setting permissions");
1369 safedir::set_file_metadata_fd(
1370 preserve,
1371 &open_meta,
1372 dst_file.as_fd(),
1373 congestion::Side::Destination,
1374 )
1375 .await
1376 .with_context(|| format!("failed setting metadata on {:?}", dst_path))
1377 .map_err(|err| Error::new(err, copy_summary))?;
1378 let mut copy_summary = copy_summary;
1379 copy_summary.bytes_copied += copied;
1381 copy_summary.files_copied += 1;
1382 Ok(copy_summary)
1383}
1384
1385#[allow(clippy::too_many_arguments)]
1388async fn copy_symlink_fd(
1389 prog_track: &'static progress::Progress,
1390 src_parent: &Arc<Dir>,
1391 dst_parent: Option<&Arc<Dir>>,
1392 dst_name: &OsStr,
1393 src_path: &std::path::Path,
1394 dst_path: &std::path::Path,
1395 src_handle: &Handle,
1396 settings: &Settings,
1397 preserve: &preserve::Settings,
1398 is_fresh: bool,
1399) -> Result<Summary, Error> {
1400 let dst_exists = match dst_parent {
1404 Some(dst_parent) => dst_parent.child(dst_name).await.is_ok(),
1405 None => tokio::fs::symlink_metadata(dst_path).await.is_ok(),
1406 };
1407 if !is_fresh && settings.ignore_existing && dst_exists {
1408 if let Some(mode) = settings.dry_run {
1409 match mode {
1410 DryRunMode::Brief => {}
1411 DryRunMode::All => println!("skip symlink {:?}", dst_path),
1412 DryRunMode::Explain => println!("skip symlink {:?} (destination exists)", dst_path),
1413 }
1414 }
1415 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1416 prog_track.symlinks_unchanged.inc();
1417 return Ok(Summary {
1418 symlinks_unchanged: 1,
1419 ..Default::default()
1420 });
1421 }
1422 if settings.dry_run.is_some() {
1423 crate::dry_run::report_action("copy", src_path, Some(dst_path), "symlink");
1424 return Ok(Summary {
1425 symlinks_created: 1,
1426 ..Default::default()
1427 });
1428 }
1429 let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1430 let (target, src_meta) = src_handle
1434 .read_symlink(src_parent.side())
1435 .await
1436 .with_context(|| format!("failed reading symlink {:?}", src_path))
1437 .map_err(|err| Error::new(err, Default::default()))?;
1438 match dst_parent.symlink_at(dst_name, &target).await {
1440 Ok(link_handle) => {
1441 safedir::set_symlink_metadata_fd(
1442 preserve,
1443 &src_meta,
1444 &link_handle,
1445 congestion::Side::Destination,
1446 )
1447 .await
1448 .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1449 .map_err(|err| Error::new(err, Default::default()))?;
1450 prog_track.symlinks_created.inc();
1451 Ok(Summary {
1452 symlinks_created: 1,
1453 ..Default::default()
1454 })
1455 }
1456 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1457 if settings.ignore_existing {
1458 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1459 prog_track.symlinks_unchanged.inc();
1460 return Ok(Summary {
1461 symlinks_unchanged: 1,
1462 ..Default::default()
1463 });
1464 }
1465 if !settings.overwrite {
1466 return Err(Error::new(
1467 anyhow!("failed creating symlink {:?}", dst_path),
1468 Default::default(),
1469 ));
1470 }
1471 let dst_handle = dst_parent
1472 .child(dst_name)
1473 .await
1474 .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
1475 .map_err(|err| Error::new(err, Default::default()))?;
1476 if dst_handle.kind() == EntryKind::Symlink {
1477 let dst_link = dst_parent
1478 .read_link_at(dst_name) .await
1480 .with_context(|| format!("failed reading dst symlink: {:?}", dst_path))
1481 .map_err(|err| Error::new(err, Default::default()))?;
1482 if dst_link == target {
1483 tracing::debug!("'dst' is a symlink and points to the same location as 'src'");
1484 if preserve.symlink.any()
1485 && !filecmp::metadata_equal(
1486 &settings.overwrite_compare,
1487 &src_meta,
1488 dst_handle.meta(),
1489 )
1490 {
1491 tracing::debug!("'dst' metadata is different, updating");
1492 safedir::set_symlink_metadata_fd(
1493 preserve,
1494 &src_meta,
1495 &dst_handle,
1496 congestion::Side::Destination,
1497 )
1498 .await
1499 .with_context(|| {
1500 format!("failed setting symlink metadata on {:?}", dst_path)
1501 })
1502 .map_err(|err| Error::new(err, Default::default()))?;
1503 prog_track.symlinks_removed.inc();
1504 prog_track.symlinks_created.inc();
1505 return Ok(Summary {
1506 rm_summary: RmSummary {
1507 symlinks_removed: 1,
1508 ..Default::default()
1509 },
1510 symlinks_created: 1,
1511 ..Default::default()
1512 });
1513 }
1514 tracing::debug!("symlink already exists, skipping");
1515 prog_track.symlinks_unchanged.inc();
1516 return Ok(Summary {
1517 symlinks_unchanged: 1,
1518 ..Default::default()
1519 });
1520 }
1521 tracing::debug!("'dst' is a symlink but points to a different path, updating");
1522 } else {
1523 tracing::info!("'dst' is not a symlink, updating");
1524 }
1525 let rm_summary = remove_existing(
1527 prog_track,
1528 dst_parent,
1529 dst_name,
1530 dst_path,
1531 &dst_handle,
1532 settings,
1533 )
1534 .await?;
1535 let copy_summary = Summary {
1536 rm_summary,
1537 ..Default::default()
1538 };
1539 let link_handle = dst_parent
1540 .symlink_at(dst_name, &target)
1541 .await
1542 .with_context(|| format!("failed creating symlink {:?}", dst_path))
1543 .map_err(|err| Error::new(err, copy_summary))?;
1544 safedir::set_symlink_metadata_fd(
1545 preserve,
1546 &src_meta,
1547 &link_handle,
1548 congestion::Side::Destination,
1549 )
1550 .await
1551 .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1552 .map_err(|err| Error::new(err, copy_summary))?;
1553 prog_track.symlinks_created.inc();
1554 Ok(Summary {
1555 rm_summary,
1556 symlinks_created: 1,
1557 ..Default::default()
1558 })
1559 }
1560 Err(error) => Err(Error::new(
1561 anyhow::Error::new(error).context(format!("failed creating symlink {:?}", dst_path)),
1562 Default::default(),
1563 )),
1564 }
1565}
1566
1567pub(crate) struct DirSlot {
1569 pub(crate) dir: Arc<Dir>,
1570 pub(crate) summary: Summary,
1571 pub(crate) is_fresh: bool,
1572 pub(crate) we_created: bool,
1573}
1574
1575pub(crate) enum DirResolution {
1577 Proceed(DirSlot),
1579 Skip(Summary),
1581}
1582
1583pub(crate) async fn resolve_dst_dir(
1590 prog_track: &'static progress::Progress,
1591 dst_parent: &Arc<Dir>,
1592 name: &OsStr,
1593 dst_path: &std::path::Path,
1594 settings: &Settings,
1595 is_fresh: bool,
1596) -> Result<DirResolution, Error> {
1597 match dst_parent.make_dir(name, 0o700).await {
1598 Ok(dir) => {
1599 prog_track.directories_created.inc();
1601 Ok(DirResolution::Proceed(DirSlot {
1602 dir: Arc::new(dir),
1603 summary: Summary {
1604 directories_created: 1,
1605 ..Default::default()
1606 },
1607 is_fresh: true,
1608 we_created: true,
1609 }))
1610 }
1611 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1612 assert!(
1613 !is_fresh,
1614 "unexpected pre-existing directory in a fresh destination: {dst_path:?}"
1615 );
1616 let dst_handle = dst_parent
1617 .child(name)
1618 .await
1619 .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
1620 .map_err(|err| Error::new(err, Default::default()))?;
1621 if dst_handle.kind() == EntryKind::Dir {
1622 tracing::debug!("'dst' is a directory, leaving it as is");
1627 let dir = dst_parent
1628 .open_dir(name)
1629 .await
1630 .with_context(|| format!("cannot open existing directory {:?}", dst_path))
1631 .map_err(|err| Error::new(err, Default::default()))?;
1632 prog_track.directories_unchanged.inc();
1633 Ok(DirResolution::Proceed(DirSlot {
1634 dir: Arc::new(dir),
1635 summary: Summary {
1636 directories_unchanged: 1,
1637 ..Default::default()
1638 },
1639 is_fresh,
1640 we_created: false,
1641 }))
1642 } else if settings.ignore_existing {
1643 tracing::debug!(
1645 "destination exists but is not a directory, skipping subtree (--ignore-existing)"
1646 );
1647 prog_track.directories_unchanged.inc();
1648 Ok(DirResolution::Skip(Summary {
1649 directories_unchanged: 1,
1650 ..Default::default()
1651 }))
1652 } else if settings.overwrite {
1653 tracing::info!("'dst' is not a directory, removing and creating a new one");
1655 let rm_summary = remove_existing(
1656 prog_track,
1657 dst_parent,
1658 name,
1659 dst_path,
1660 &dst_handle,
1661 settings,
1662 )
1663 .await?;
1664 let dir = dst_parent
1665 .make_dir(name, 0o700)
1666 .await
1667 .with_context(|| format!("cannot create directory {:?}", dst_path))
1668 .map_err(|err| {
1669 Error::new(
1670 err,
1671 Summary {
1672 rm_summary,
1673 ..Default::default()
1674 },
1675 )
1676 })?;
1677 prog_track.directories_created.inc();
1678 Ok(DirResolution::Proceed(DirSlot {
1679 dir: Arc::new(dir),
1680 summary: Summary {
1681 rm_summary,
1682 directories_created: 1,
1683 ..Default::default()
1684 },
1685 is_fresh: true,
1686 we_created: true,
1687 }))
1688 } else {
1689 Err(Error::new(
1690 anyhow!(
1691 "destination {:?} already exists, did you intend to specify --overwrite?",
1692 dst_path
1693 ),
1694 Default::default(),
1695 ))
1696 }
1697 }
1698 Err(error) => {
1699 let error = anyhow::Error::new(error)
1700 .context(format!("cannot create directory {:?}", dst_path));
1701 tracing::error!("{:#}", &error);
1702 Err(Error::new(error, Default::default()))
1703 }
1704 }
1705}
1706
1707pub(crate) async fn remove_existing(
1732 prog_track: &'static progress::Progress,
1733 dst_parent: &Arc<Dir>,
1734 dst_name: &OsStr,
1735 dst_path: &std::path::Path,
1736 dst_handle: &Handle,
1737 settings: &Settings,
1738) -> Result<RmSummary, Error> {
1739 dst_parent
1741 .recheck(dst_name, dst_handle)
1742 .await
1743 .with_context(|| {
1744 format!(
1745 "destination {:?} changed identity before removal (possible TOCTOU swap)",
1746 dst_path
1747 )
1748 })
1749 .map_err(|err| Error::new(err, Default::default()))?;
1750 match dst_handle.kind() {
1751 EntryKind::File | EntryKind::Symlink | EntryKind::Special => {
1752 let removed_size = {
1755 use crate::preserve::Metadata as _;
1756 dst_handle.meta().size()
1757 };
1758 dst_parent
1760 .unlink_at(dst_name)
1761 .await
1762 .with_context(|| format!("failed removing existing destination {:?}", dst_path))
1763 .map_err(|err| Error::new(err, Default::default()))?;
1764 let is_symlink = dst_handle.kind() == EntryKind::Symlink;
1765 if is_symlink {
1766 prog_track.symlinks_removed.inc();
1767 } else {
1768 prog_track.files_removed.inc();
1769 prog_track.bytes_removed.add(removed_size);
1770 }
1771 Ok(RmSummary {
1772 files_removed: usize::from(!is_symlink),
1773 symlinks_removed: usize::from(is_symlink),
1774 ..Default::default()
1775 })
1776 }
1777 EntryKind::Dir => {
1778 match dst_parent.rmdir_at(dst_name).await {
1780 Ok(()) => {
1781 prog_track.directories_removed.inc();
1782 Ok(RmSummary {
1783 directories_removed: 1,
1784 ..Default::default()
1785 })
1786 }
1787 Err(error)
1789 if matches!(
1790 error.raw_os_error(),
1791 Some(libc::ENOTEMPTY) | Some(libc::EEXIST)
1792 ) =>
1793 {
1794 rm::rm_child(
1798 prog_track,
1799 dst_parent,
1800 dst_name,
1801 dst_path,
1802 &RmSettings {
1803 fail_early: settings.fail_early,
1804 filter: None,
1805 dry_run: None,
1806 time_filter: None,
1807 },
1808 )
1809 .await
1810 .map_err(|err| {
1811 Error::new(
1812 err.source,
1813 Summary {
1814 rm_summary: err.summary,
1815 ..Default::default()
1816 },
1817 )
1818 })
1819 }
1820 Err(error) => Err(Error::new(
1821 anyhow::Error::new(error)
1822 .context(format!("failed removing existing directory {:?}", dst_path)),
1823 Default::default(),
1824 )),
1825 }
1826 }
1827 }
1828}
1829
1830#[cfg(test)]
1831mod copy_tests {
1832 use crate::testutils;
1833 use anyhow::Context;
1834 use std::os::unix::fs::MetadataExt;
1835 use std::os::unix::fs::PermissionsExt;
1836 use tracing_test::traced_test;
1837
1838 use super::*;
1839
1840 static PROGRESS: std::sync::LazyLock<progress::Progress> =
1841 std::sync::LazyLock::new(progress::Progress::new);
1842 static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1843 std::sync::LazyLock::new(preserve::preserve_none);
1844 static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1845 std::sync::LazyLock::new(preserve::preserve_all);
1846
1847 fn settings_with_delete(delete: Option<DeleteSettings>) -> Settings {
1848 Settings {
1849 dereference: false,
1850 fail_early: false,
1851 overwrite: delete.is_some(), overwrite_compare: filecmp::MetadataCmpSettings {
1853 size: true,
1854 mtime: true,
1855 ..Default::default()
1856 },
1857 overwrite_filter: None,
1858 ignore_existing: false,
1859 chunk_size: 0,
1860 skip_specials: false,
1861 remote_copy_buffer_size: 0,
1862 filter: None,
1863 dry_run: None,
1864 delete,
1865 }
1866 }
1867
1868 fn delete_on() -> Option<DeleteSettings> {
1869 Some(DeleteSettings {
1870 delete_excluded: false,
1871 })
1872 }
1873
1874 #[tokio::test]
1878 async fn copies_dot_dot_source_operand() -> Result<(), anyhow::Error> {
1879 let tmp = testutils::create_temp_dir().await?;
1880 let tree = tmp.join("tree");
1881 tokio::fs::create_dir(&tree).await?;
1882 tokio::fs::write(tree.join("a.txt"), "hello").await?;
1883 tokio::fs::create_dir(tree.join("sub")).await?;
1884 let src = tree.join("sub").join(".."); let dst = tmp.join("dst");
1886 let summary = copy(
1887 &PROGRESS,
1888 &src,
1889 &dst,
1890 &settings_with_delete(None),
1891 &NO_PRESERVE_SETTINGS,
1892 false,
1893 )
1894 .await?;
1895 assert_eq!(
1896 summary.files_copied, 1,
1897 "the dot-dot source's file must be copied"
1898 );
1899 assert_eq!(tokio::fs::read_to_string(dst.join("a.txt")).await?, "hello");
1900 assert!(
1901 dst.join("sub").is_dir(),
1902 "the dot-dot source's subdir must be copied"
1903 );
1904 Ok(())
1905 }
1906
1907 #[tokio::test]
1908 #[traced_test]
1909 async fn delete_protects_skipped_special_name() -> Result<(), anyhow::Error> {
1910 let tmp_dir = testutils::setup_test_dir().await?;
1911 let test_path = tmp_dir.as_path();
1912 let src = test_path.join("src_dir");
1913 let dst = test_path.join("dst_dir");
1914 tokio::fs::create_dir(&src).await?;
1915 tokio::fs::write(src.join("file.txt"), "hello").await?;
1916 nix::unistd::mkfifo(
1918 &src.join("pipe"),
1919 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
1920 )?;
1921 tokio::fs::create_dir(&dst).await?;
1923 tokio::fs::write(dst.join("pipe"), "old").await?;
1924 tokio::fs::write(dst.join("stale.txt"), "junk").await?;
1925
1926 let mut settings = settings_with_delete(delete_on());
1927 settings.skip_specials = true;
1928 let summary = copy(
1929 &PROGRESS,
1930 &src,
1931 &dst,
1932 &settings,
1933 &DO_PRESERVE_SETTINGS,
1934 false,
1935 )
1936 .await?;
1937
1938 assert_eq!(summary.specials_skipped, 1);
1939 assert!(dst.join("file.txt").exists());
1940 assert!(
1941 dst.join("pipe").exists(),
1942 "a destination entry matching a skipped special must not be pruned (it has a source counterpart)"
1943 );
1944 assert!(!dst.join("stale.txt").exists()); Ok(())
1946 }
1947
1948 #[tokio::test]
1953 #[traced_test]
1954 async fn copies_into_symlinked_destination_parent() -> Result<(), anyhow::Error> {
1955 let tmp_dir = testutils::setup_test_dir().await?;
1956 let test_path = tmp_dir.as_path();
1957 let src = test_path.join("src.txt");
1958 tokio::fs::write(&src, b"payload").await?;
1959 let real_dir = test_path.join("real_dst_dir");
1961 tokio::fs::create_dir(&real_dir).await?;
1962 let link_dir = test_path.join("link_dst_dir");
1963 tokio::fs::symlink(&real_dir, &link_dir).await?;
1964 let dst = link_dir.join("out.txt");
1966 let summary = copy(
1967 &PROGRESS,
1968 &src,
1969 &dst,
1970 &settings_with_delete(None),
1971 &DO_PRESERVE_SETTINGS,
1972 false,
1973 )
1974 .await?;
1975 assert_eq!(summary.files_copied, 1);
1976 let written = tokio::fs::read(real_dir.join("out.txt")).await?;
1978 assert_eq!(written, b"payload");
1979 Ok(())
1980 }
1981
1982 #[tokio::test]
1985 #[traced_test]
1986 async fn copies_from_symlinked_source_parent() -> Result<(), anyhow::Error> {
1987 let tmp_dir = testutils::setup_test_dir().await?;
1988 let test_path = tmp_dir.as_path();
1989 let real_src_dir = test_path.join("real_src_dir");
1991 tokio::fs::create_dir(&real_src_dir).await?;
1992 tokio::fs::write(real_src_dir.join("src.txt"), b"payload").await?;
1993 let link_src_dir = test_path.join("link_src_dir");
1994 tokio::fs::symlink(&real_src_dir, &link_src_dir).await?;
1995 let src = link_src_dir.join("src.txt");
1996 let dst = test_path.join("out.txt");
1997 let summary = copy(
1998 &PROGRESS,
1999 &src,
2000 &dst,
2001 &settings_with_delete(None),
2002 &DO_PRESERVE_SETTINGS,
2003 false,
2004 )
2005 .await?;
2006 assert_eq!(summary.files_copied, 1);
2007 let written = tokio::fs::read(&dst).await?;
2008 assert_eq!(written, b"payload");
2009 Ok(())
2010 }
2011
2012 #[tokio::test]
2013 #[traced_test]
2014 async fn delete_removes_extraneous_destination_entries() -> Result<(), anyhow::Error> {
2015 let tmp_dir = testutils::setup_test_dir().await?;
2016 let test_path = tmp_dir.as_path();
2017 let src = test_path.join("foo");
2018 let dst = test_path.join("bar");
2019 copy(
2021 &PROGRESS,
2022 &src,
2023 &dst,
2024 &settings_with_delete(None),
2025 &DO_PRESERVE_SETTINGS,
2026 false,
2027 )
2028 .await?;
2029 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2031 tokio::fs::create_dir(dst.join("extra_dir")).await?;
2032 tokio::fs::write(dst.join("extra_dir").join("nested.txt"), b"junk").await?;
2033 let summary = copy(
2035 &PROGRESS,
2036 &src,
2037 &dst,
2038 &settings_with_delete(delete_on()),
2039 &DO_PRESERVE_SETTINGS,
2040 false,
2041 )
2042 .await?;
2043 assert_eq!(summary.rm_summary.files_removed, 2); assert_eq!(summary.rm_summary.directories_removed, 1); assert!(!dst.join("extraneous.txt").exists());
2046 assert!(!dst.join("extra_dir").exists());
2047 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2048 Ok(())
2049 }
2050
2051 #[tokio::test]
2052 #[traced_test]
2053 async fn delete_prunes_extraneous_at_depth() -> Result<(), anyhow::Error> {
2054 let tmp_dir = testutils::setup_test_dir().await?;
2055 let test_path = tmp_dir.as_path();
2056 let src = test_path.join("foo");
2057 let dst = test_path.join("bar");
2058 copy(
2059 &PROGRESS,
2060 &src,
2061 &dst,
2062 &settings_with_delete(None),
2063 &DO_PRESERVE_SETTINGS,
2064 false,
2065 )
2066 .await?;
2067 let nested = dst.join("bar");
2069 assert!(
2070 nested.is_dir(),
2071 "expected common subdirectory bar/ to exist at destination"
2072 );
2073 tokio::fs::write(nested.join("stale_nested.txt"), b"junk").await?;
2074 let summary = copy(
2075 &PROGRESS,
2076 &src,
2077 &dst,
2078 &settings_with_delete(delete_on()),
2079 &DO_PRESERVE_SETTINGS,
2080 false,
2081 )
2082 .await?;
2083 assert!(
2084 !nested.join("stale_nested.txt").exists(),
2085 "stale entry inside a common subdirectory must be pruned"
2086 );
2087 assert!(summary.rm_summary.files_removed >= 1);
2088 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2089 Ok(())
2090 }
2091
2092 #[tokio::test]
2093 #[traced_test]
2094 async fn delete_removes_extraneous_symlink() -> Result<(), anyhow::Error> {
2095 let tmp_dir = testutils::setup_test_dir().await?;
2096 let test_path = tmp_dir.as_path();
2097 let src = test_path.join("foo");
2098 let dst = test_path.join("bar");
2099 copy(
2100 &PROGRESS,
2101 &src,
2102 &dst,
2103 &settings_with_delete(None),
2104 &DO_PRESERVE_SETTINGS,
2105 false,
2106 )
2107 .await?;
2108 tokio::fs::symlink("/nonexistent/target", dst.join("stale_link")).await?;
2110 let summary = copy(
2111 &PROGRESS,
2112 &src,
2113 &dst,
2114 &settings_with_delete(delete_on()),
2115 &DO_PRESERVE_SETTINGS,
2116 false,
2117 )
2118 .await?;
2119 assert!(
2120 tokio::fs::symlink_metadata(dst.join("stale_link"))
2121 .await
2122 .is_err(),
2123 "extraneous symlink must be removed"
2124 );
2125 assert_eq!(summary.rm_summary.symlinks_removed, 1);
2126 Ok(())
2127 }
2128
2129 #[tokio::test]
2130 #[traced_test]
2131 async fn delete_skips_pruning_when_copy_has_errors() -> Result<(), anyhow::Error> {
2132 let tmp_dir = testutils::setup_test_dir().await?;
2133 let test_path = tmp_dir.as_path();
2134 let src = test_path.join("foo");
2135 let dst = test_path.join("bar");
2136 copy(
2138 &PROGRESS,
2139 &src,
2140 &dst,
2141 &settings_with_delete(None),
2142 &DO_PRESERVE_SETTINGS,
2143 false,
2144 )
2145 .await?;
2146 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2148 let unreadable = src.join("baz");
2152 let original = tokio::fs::metadata(&unreadable).await?.permissions();
2153 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2154
2155 let result = copy(
2156 &PROGRESS,
2157 &src,
2158 &dst,
2159 &settings_with_delete(delete_on()),
2160 &DO_PRESERVE_SETTINGS,
2161 false,
2162 )
2163 .await;
2164
2165 tokio::fs::set_permissions(&unreadable, original).await?;
2166
2167 assert!(
2168 result.is_err(),
2169 "copy of the unreadable directory should fail"
2170 );
2171 assert!(
2172 dst.join("extraneous.txt").exists(),
2173 "pruning must be skipped when the copy reported errors"
2174 );
2175 Ok(())
2176 }
2177
2178 #[tokio::test]
2179 #[traced_test]
2180 async fn check_basic_copy() -> Result<(), anyhow::Error> {
2181 let tmp_dir = testutils::setup_test_dir().await?;
2182 let test_path = tmp_dir.as_path();
2183 let summary = copy(
2184 &PROGRESS,
2185 &test_path.join("foo"),
2186 &test_path.join("bar"),
2187 &Settings {
2188 dereference: false,
2189 fail_early: false,
2190 overwrite: false,
2191 overwrite_compare: filecmp::MetadataCmpSettings {
2192 size: true,
2193 mtime: true,
2194 ..Default::default()
2195 },
2196 overwrite_filter: None,
2197 ignore_existing: false,
2198 chunk_size: 0,
2199 skip_specials: false,
2200 remote_copy_buffer_size: 0,
2201 filter: None,
2202 dry_run: None,
2203 delete: None,
2204 },
2205 &NO_PRESERVE_SETTINGS,
2206 false,
2207 )
2208 .await?;
2209 assert_eq!(summary.files_copied, 5);
2210 assert_eq!(summary.symlinks_created, 2);
2211 assert_eq!(summary.directories_created, 3);
2212 testutils::check_dirs_identical(
2213 &test_path.join("foo"),
2214 &test_path.join("bar"),
2215 testutils::FileEqualityCheck::Basic,
2216 )
2217 .await?;
2218 Ok(())
2219 }
2220
2221 #[tokio::test]
2222 #[traced_test]
2223 async fn no_read_permission() -> Result<(), anyhow::Error> {
2224 let tmp_dir = testutils::setup_test_dir().await?;
2225 let test_path = tmp_dir.as_path();
2226 let filepaths = vec![
2227 test_path.join("foo").join("0.txt"),
2228 test_path.join("foo").join("baz"),
2229 ];
2230 for fpath in &filepaths {
2231 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
2233 }
2234 match copy(
2235 &PROGRESS,
2236 &test_path.join("foo"),
2237 &test_path.join("bar"),
2238 &Settings {
2239 dereference: false,
2240 fail_early: false,
2241 overwrite: false,
2242 overwrite_compare: filecmp::MetadataCmpSettings {
2243 size: true,
2244 mtime: true,
2245 ..Default::default()
2246 },
2247 overwrite_filter: None,
2248 ignore_existing: false,
2249 chunk_size: 0,
2250 skip_specials: false,
2251 remote_copy_buffer_size: 0,
2252 filter: None,
2253 dry_run: None,
2254 delete: None,
2255 },
2256 &NO_PRESERVE_SETTINGS,
2257 false,
2258 )
2259 .await
2260 {
2261 Ok(_) => panic!("Expected the copy to error!"),
2262 Err(error) => {
2263 tracing::info!("{}", &error);
2264 assert_eq!(error.summary.files_copied, 3);
2275 assert_eq!(error.summary.symlinks_created, 0);
2276 assert_eq!(error.summary.directories_created, 2);
2277 }
2278 }
2279 for fpath in &filepaths {
2281 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
2282 if tokio::fs::symlink_metadata(fpath).await?.is_file() {
2283 tokio::fs::remove_file(fpath).await?;
2284 } else {
2285 tokio::fs::remove_dir_all(fpath).await?;
2286 }
2287 }
2288 testutils::check_dirs_identical(
2289 &test_path.join("foo"),
2290 &test_path.join("bar"),
2291 testutils::FileEqualityCheck::Basic,
2292 )
2293 .await?;
2294 Ok(())
2295 }
2296
2297 #[tokio::test]
2298 #[traced_test]
2299 async fn check_default_mode() -> Result<(), anyhow::Error> {
2300 let tmp_dir = testutils::setup_test_dir().await?;
2301 tokio::fs::set_permissions(
2303 tmp_dir.join("foo").join("0.txt"),
2304 std::fs::Permissions::from_mode(0o700),
2305 )
2306 .await?;
2307 let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
2309 tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
2310 .await?;
2311 let test_path = tmp_dir.as_path();
2312 let summary = copy(
2313 &PROGRESS,
2314 &test_path.join("foo"),
2315 &test_path.join("bar"),
2316 &Settings {
2317 dereference: false,
2318 fail_early: false,
2319 overwrite: false,
2320 overwrite_compare: filecmp::MetadataCmpSettings {
2321 size: true,
2322 mtime: true,
2323 ..Default::default()
2324 },
2325 overwrite_filter: None,
2326 ignore_existing: false,
2327 chunk_size: 0,
2328 skip_specials: false,
2329 remote_copy_buffer_size: 0,
2330 filter: None,
2331 dry_run: None,
2332 delete: None,
2333 },
2334 &NO_PRESERVE_SETTINGS,
2335 false,
2336 )
2337 .await?;
2338 assert_eq!(summary.files_copied, 5);
2339 assert_eq!(summary.symlinks_created, 2);
2340 assert_eq!(summary.directories_created, 3);
2341 tokio::fs::set_permissions(
2343 &exec_sticky_file,
2344 std::fs::Permissions::from_mode(
2345 std::fs::symlink_metadata(&exec_sticky_file)?
2346 .permissions()
2347 .mode()
2348 & 0o0777,
2349 ),
2350 )
2351 .await?;
2352 testutils::check_dirs_identical(
2353 &test_path.join("foo"),
2354 &test_path.join("bar"),
2355 testutils::FileEqualityCheck::Basic,
2356 )
2357 .await?;
2358 Ok(())
2359 }
2360
2361 #[tokio::test]
2362 #[traced_test]
2363 async fn no_write_permission() -> Result<(), anyhow::Error> {
2364 let tmp_dir = testutils::setup_test_dir().await?;
2365 let test_path = tmp_dir.as_path();
2366 let non_exec_dir = test_path.join("foo").join("bogey");
2368 tokio::fs::create_dir(&non_exec_dir).await?;
2369 tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
2370 tokio::fs::set_permissions(
2372 &test_path.join("foo").join("baz"),
2373 std::fs::Permissions::from_mode(0o500),
2374 )
2375 .await?;
2376 tokio::fs::set_permissions(
2378 &test_path.join("foo").join("baz").join("4.txt"),
2379 std::fs::Permissions::from_mode(0o440),
2380 )
2381 .await?;
2382 let summary = copy(
2383 &PROGRESS,
2384 &test_path.join("foo"),
2385 &test_path.join("bar"),
2386 &Settings {
2387 dereference: false,
2388 fail_early: false,
2389 overwrite: false,
2390 overwrite_compare: filecmp::MetadataCmpSettings {
2391 size: true,
2392 mtime: true,
2393 ..Default::default()
2394 },
2395 overwrite_filter: None,
2396 ignore_existing: false,
2397 chunk_size: 0,
2398 skip_specials: false,
2399 remote_copy_buffer_size: 0,
2400 filter: None,
2401 dry_run: None,
2402 delete: None,
2403 },
2404 &NO_PRESERVE_SETTINGS,
2405 false,
2406 )
2407 .await?;
2408 assert_eq!(summary.files_copied, 5);
2409 assert_eq!(summary.symlinks_created, 2);
2410 assert_eq!(summary.directories_created, 4);
2411 testutils::check_dirs_identical(
2412 &test_path.join("foo"),
2413 &test_path.join("bar"),
2414 testutils::FileEqualityCheck::Basic,
2415 )
2416 .await?;
2417 Ok(())
2418 }
2419
2420 #[tokio::test]
2421 #[traced_test]
2422 async fn dereference() -> Result<(), anyhow::Error> {
2423 let tmp_dir = testutils::setup_test_dir().await?;
2424 let test_path = tmp_dir.as_path();
2425 let src1 = &test_path.join("foo").join("bar").join("2.txt");
2427 let src2 = &test_path.join("foo").join("bar").join("3.txt");
2428 let test_mode = 0o440;
2429 for f in [src1, src2] {
2430 tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
2431 }
2432 let summary = copy(
2433 &PROGRESS,
2434 &test_path.join("foo"),
2435 &test_path.join("bar"),
2436 &Settings {
2437 dereference: true, fail_early: false,
2439 overwrite: false,
2440 overwrite_compare: filecmp::MetadataCmpSettings {
2441 size: true,
2442 mtime: true,
2443 ..Default::default()
2444 },
2445 overwrite_filter: None,
2446 ignore_existing: false,
2447 chunk_size: 0,
2448 skip_specials: false,
2449 remote_copy_buffer_size: 0,
2450 filter: None,
2451 dry_run: None,
2452 delete: None,
2453 },
2454 &NO_PRESERVE_SETTINGS,
2455 false,
2456 )
2457 .await?;
2458 assert_eq!(summary.files_copied, 7);
2459 assert_eq!(summary.symlinks_created, 0);
2460 assert_eq!(summary.directories_created, 3);
2461 let dst1 = &test_path.join("bar").join("baz").join("5.txt");
2467 let dst2 = &test_path.join("bar").join("baz").join("6.txt");
2468 for f in [dst1, dst2] {
2469 let metadata = tokio::fs::symlink_metadata(f)
2470 .await
2471 .with_context(|| format!("failed reading metadata from {:?}", &f))?;
2472 assert!(metadata.is_file());
2473 assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
2475 }
2476 Ok(())
2477 }
2478
2479 async fn cp_compare(
2480 cp_args: &[&str],
2481 rcp_settings: &Settings,
2482 preserve: bool,
2483 ) -> Result<(), anyhow::Error> {
2484 let tmp_dir = testutils::setup_test_dir().await?;
2485 let test_path = tmp_dir.as_path();
2486 let cp_output = tokio::process::Command::new("cp")
2488 .args(cp_args)
2489 .arg(test_path.join("foo"))
2490 .arg(test_path.join("bar"))
2491 .output()
2492 .await?;
2493 assert!(cp_output.status.success());
2494 let summary = copy(
2496 &PROGRESS,
2497 &test_path.join("foo"),
2498 &test_path.join("baz"),
2499 rcp_settings,
2500 if preserve {
2501 &DO_PRESERVE_SETTINGS
2502 } else {
2503 &NO_PRESERVE_SETTINGS
2504 },
2505 false,
2506 )
2507 .await?;
2508 if rcp_settings.dereference {
2509 assert_eq!(summary.files_copied, 7);
2510 assert_eq!(summary.symlinks_created, 0);
2511 } else {
2512 assert_eq!(summary.files_copied, 5);
2513 assert_eq!(summary.symlinks_created, 2);
2514 }
2515 assert_eq!(summary.directories_created, 3);
2516 testutils::check_dirs_identical(
2517 &test_path.join("bar"),
2518 &test_path.join("baz"),
2519 if preserve {
2520 testutils::FileEqualityCheck::Timestamp
2521 } else {
2522 testutils::FileEqualityCheck::Basic
2523 },
2524 )
2525 .await?;
2526 Ok(())
2527 }
2528
2529 #[tokio::test]
2530 #[traced_test]
2531 async fn test_cp_compat() -> Result<(), anyhow::Error> {
2532 cp_compare(
2533 &["-r"],
2534 &Settings {
2535 dereference: false,
2536 fail_early: false,
2537 overwrite: false,
2538 overwrite_compare: filecmp::MetadataCmpSettings {
2539 size: true,
2540 mtime: true,
2541 ..Default::default()
2542 },
2543 overwrite_filter: None,
2544 ignore_existing: false,
2545 chunk_size: 0,
2546 skip_specials: false,
2547 remote_copy_buffer_size: 0,
2548 filter: None,
2549 dry_run: None,
2550 delete: None,
2551 },
2552 false,
2553 )
2554 .await?;
2555 Ok(())
2556 }
2557
2558 #[tokio::test]
2559 #[traced_test]
2560 async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
2561 cp_compare(
2562 &["-r", "-p"],
2563 &Settings {
2564 dereference: false,
2565 fail_early: false,
2566 overwrite: false,
2567 overwrite_compare: filecmp::MetadataCmpSettings {
2568 size: true,
2569 mtime: true,
2570 ..Default::default()
2571 },
2572 overwrite_filter: None,
2573 ignore_existing: false,
2574 chunk_size: 0,
2575 skip_specials: false,
2576 remote_copy_buffer_size: 0,
2577 filter: None,
2578 dry_run: None,
2579 delete: None,
2580 },
2581 true,
2582 )
2583 .await?;
2584 Ok(())
2585 }
2586
2587 #[tokio::test]
2588 #[traced_test]
2589 async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
2590 cp_compare(
2591 &["-r", "-L"],
2592 &Settings {
2593 dereference: true,
2594 fail_early: false,
2595 overwrite: false,
2596 overwrite_compare: filecmp::MetadataCmpSettings {
2597 size: true,
2598 mtime: true,
2599 ..Default::default()
2600 },
2601 overwrite_filter: None,
2602 ignore_existing: false,
2603 chunk_size: 0,
2604 skip_specials: false,
2605 remote_copy_buffer_size: 0,
2606 filter: None,
2607 dry_run: None,
2608 delete: None,
2609 },
2610 false,
2611 )
2612 .await?;
2613 Ok(())
2614 }
2615
2616 #[tokio::test]
2617 #[traced_test]
2618 async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
2619 cp_compare(
2620 &["-r", "-p", "-L"],
2621 &Settings {
2622 dereference: true,
2623 fail_early: false,
2624 overwrite: false,
2625 overwrite_compare: filecmp::MetadataCmpSettings {
2626 size: true,
2627 mtime: true,
2628 ..Default::default()
2629 },
2630 overwrite_filter: None,
2631 ignore_existing: false,
2632 chunk_size: 0,
2633 skip_specials: false,
2634 remote_copy_buffer_size: 0,
2635 filter: None,
2636 dry_run: None,
2637 delete: None,
2638 },
2639 true,
2640 )
2641 .await?;
2642 Ok(())
2643 }
2644
2645 async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
2646 let tmp_dir = testutils::setup_test_dir().await?;
2647 let test_path = tmp_dir.as_path();
2648 let summary = copy(
2649 &PROGRESS,
2650 &test_path.join("foo"),
2651 &test_path.join("bar"),
2652 &Settings {
2653 dereference: false,
2654 fail_early: false,
2655 overwrite: false,
2656 overwrite_compare: filecmp::MetadataCmpSettings {
2657 size: true,
2658 mtime: true,
2659 ..Default::default()
2660 },
2661 overwrite_filter: None,
2662 ignore_existing: false,
2663 chunk_size: 0,
2664 skip_specials: false,
2665 remote_copy_buffer_size: 0,
2666 filter: None,
2667 dry_run: None,
2668 delete: None,
2669 },
2670 &DO_PRESERVE_SETTINGS,
2671 false,
2672 )
2673 .await?;
2674 assert_eq!(summary.files_copied, 5);
2675 assert_eq!(summary.symlinks_created, 2);
2676 assert_eq!(summary.directories_created, 3);
2677 Ok(tmp_dir)
2678 }
2679
2680 #[tokio::test]
2681 #[traced_test]
2682 async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
2683 let tmp_dir = setup_test_dir_and_copy().await?;
2684 let output_path = &tmp_dir.join("bar");
2685 {
2686 let summary = rm::rm(
2697 &PROGRESS,
2698 &output_path.join("bar"),
2699 &RmSettings {
2700 fail_early: false,
2701 filter: None,
2702 dry_run: None,
2703 time_filter: None,
2704 },
2705 )
2706 .await?
2707 + rm::rm(
2708 &PROGRESS,
2709 &output_path.join("baz").join("5.txt"),
2710 &RmSettings {
2711 fail_early: false,
2712 filter: None,
2713 dry_run: None,
2714 time_filter: None,
2715 },
2716 )
2717 .await?;
2718 assert_eq!(summary.files_removed, 3);
2719 assert_eq!(summary.symlinks_removed, 1);
2720 assert_eq!(summary.directories_removed, 1);
2721 }
2722 let summary = copy(
2723 &PROGRESS,
2724 &tmp_dir.join("foo"),
2725 output_path,
2726 &Settings {
2727 dereference: false,
2728 fail_early: false,
2729 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2731 size: true,
2732 mtime: true,
2733 ..Default::default()
2734 },
2735 overwrite_filter: None,
2736 ignore_existing: false,
2737 chunk_size: 0,
2738 skip_specials: false,
2739 remote_copy_buffer_size: 0,
2740 filter: None,
2741 dry_run: None,
2742 delete: None,
2743 },
2744 &DO_PRESERVE_SETTINGS,
2745 false,
2746 )
2747 .await?;
2748 assert_eq!(summary.files_copied, 3);
2749 assert_eq!(summary.symlinks_created, 1);
2750 assert_eq!(summary.directories_created, 1);
2751 testutils::check_dirs_identical(
2752 &tmp_dir.join("foo"),
2753 output_path,
2754 testutils::FileEqualityCheck::Timestamp,
2755 )
2756 .await?;
2757 Ok(())
2758 }
2759
2760 #[tokio::test]
2761 #[traced_test]
2762 async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
2763 let tmp_dir = setup_test_dir_and_copy().await?;
2764 let output_path = &tmp_dir.join("bar");
2765 {
2766 let summary = rm::rm(
2777 &PROGRESS,
2778 &output_path.join("bar").join("1.txt"),
2779 &RmSettings {
2780 fail_early: false,
2781 filter: None,
2782 dry_run: None,
2783 time_filter: None,
2784 },
2785 )
2786 .await?
2787 + rm::rm(
2788 &PROGRESS,
2789 &output_path.join("baz"),
2790 &RmSettings {
2791 fail_early: false,
2792 filter: None,
2793 dry_run: None,
2794 time_filter: None,
2795 },
2796 )
2797 .await?;
2798 assert_eq!(summary.files_removed, 2);
2799 assert_eq!(summary.symlinks_removed, 2);
2800 assert_eq!(summary.directories_removed, 1);
2801 }
2802 {
2803 tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
2805 tokio::fs::write(&output_path.join("baz"), "baz").await?;
2807 }
2808 let summary = copy(
2809 &PROGRESS,
2810 &tmp_dir.join("foo"),
2811 output_path,
2812 &Settings {
2813 dereference: false,
2814 fail_early: false,
2815 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2817 size: true,
2818 mtime: true,
2819 ..Default::default()
2820 },
2821 overwrite_filter: None,
2822 ignore_existing: false,
2823 chunk_size: 0,
2824 skip_specials: false,
2825 remote_copy_buffer_size: 0,
2826 filter: None,
2827 dry_run: None,
2828 delete: None,
2829 },
2830 &DO_PRESERVE_SETTINGS,
2831 false,
2832 )
2833 .await?;
2834 assert_eq!(summary.rm_summary.files_removed, 1);
2835 assert_eq!(summary.rm_summary.symlinks_removed, 0);
2836 assert_eq!(summary.rm_summary.directories_removed, 1);
2837 assert_eq!(summary.files_copied, 2);
2838 assert_eq!(summary.symlinks_created, 2);
2839 assert_eq!(summary.directories_created, 1);
2840 testutils::check_dirs_identical(
2841 &tmp_dir.join("foo"),
2842 output_path,
2843 testutils::FileEqualityCheck::Timestamp,
2844 )
2845 .await?;
2846 Ok(())
2847 }
2848
2849 #[tokio::test]
2850 #[traced_test]
2851 async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
2852 let tmp_dir = setup_test_dir_and_copy().await?;
2853 let output_path = &tmp_dir.join("bar");
2854 {
2855 let summary = rm::rm(
2862 &PROGRESS,
2863 &output_path.join("baz").join("4.txt"),
2864 &RmSettings {
2865 fail_early: false,
2866 filter: None,
2867 dry_run: None,
2868 time_filter: None,
2869 },
2870 )
2871 .await?
2872 + rm::rm(
2873 &PROGRESS,
2874 &output_path.join("baz").join("5.txt"),
2875 &RmSettings {
2876 fail_early: false,
2877 filter: None,
2878 dry_run: None,
2879 time_filter: None,
2880 },
2881 )
2882 .await?;
2883 assert_eq!(summary.files_removed, 1);
2884 assert_eq!(summary.symlinks_removed, 1);
2885 assert_eq!(summary.directories_removed, 0);
2886 }
2887 {
2888 tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
2890 tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
2892 }
2893 let summary = copy(
2894 &PROGRESS,
2895 &tmp_dir.join("foo"),
2896 output_path,
2897 &Settings {
2898 dereference: false,
2899 fail_early: false,
2900 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2902 size: true,
2903 mtime: true,
2904 ..Default::default()
2905 },
2906 overwrite_filter: None,
2907 ignore_existing: false,
2908 chunk_size: 0,
2909 skip_specials: false,
2910 remote_copy_buffer_size: 0,
2911 filter: None,
2912 dry_run: None,
2913 delete: None,
2914 },
2915 &DO_PRESERVE_SETTINGS,
2916 false,
2917 )
2918 .await?;
2919 assert_eq!(summary.rm_summary.files_removed, 1);
2920 assert_eq!(summary.rm_summary.symlinks_removed, 1);
2921 assert_eq!(summary.rm_summary.directories_removed, 0);
2922 assert_eq!(summary.files_copied, 1);
2923 assert_eq!(summary.symlinks_created, 1);
2924 assert_eq!(summary.directories_created, 0);
2925 testutils::check_dirs_identical(
2926 &tmp_dir.join("foo"),
2927 output_path,
2928 testutils::FileEqualityCheck::Timestamp,
2929 )
2930 .await?;
2931 Ok(())
2932 }
2933
2934 #[tokio::test]
2935 #[traced_test]
2936 async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
2937 let tmp_dir = setup_test_dir_and_copy().await?;
2938 let output_path = &tmp_dir.join("bar");
2939 {
2940 let summary = rm::rm(
2950 &PROGRESS,
2951 &output_path.join("bar"),
2952 &RmSettings {
2953 fail_early: false,
2954 filter: None,
2955 dry_run: None,
2956 time_filter: None,
2957 },
2958 )
2959 .await?
2960 + rm::rm(
2961 &PROGRESS,
2962 &output_path.join("baz").join("5.txt"),
2963 &RmSettings {
2964 fail_early: false,
2965 filter: None,
2966 dry_run: None,
2967 time_filter: None,
2968 },
2969 )
2970 .await?;
2971 assert_eq!(summary.files_removed, 3);
2972 assert_eq!(summary.symlinks_removed, 1);
2973 assert_eq!(summary.directories_removed, 1);
2974 }
2975 {
2976 tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
2978 tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
2980 }
2981 let summary = copy(
2982 &PROGRESS,
2983 &tmp_dir.join("foo"),
2984 output_path,
2985 &Settings {
2986 dereference: false,
2987 fail_early: false,
2988 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2990 size: true,
2991 mtime: true,
2992 ..Default::default()
2993 },
2994 overwrite_filter: None,
2995 ignore_existing: false,
2996 chunk_size: 0,
2997 skip_specials: false,
2998 remote_copy_buffer_size: 0,
2999 filter: None,
3000 dry_run: None,
3001 delete: None,
3002 },
3003 &DO_PRESERVE_SETTINGS,
3004 false,
3005 )
3006 .await?;
3007 assert_eq!(summary.rm_summary.files_removed, 0);
3008 assert_eq!(summary.rm_summary.symlinks_removed, 1);
3009 assert_eq!(summary.rm_summary.directories_removed, 1);
3010 assert_eq!(summary.files_copied, 3);
3011 assert_eq!(summary.symlinks_created, 1);
3012 assert_eq!(summary.directories_created, 1);
3013 assert_eq!(summary.files_unchanged, 2);
3014 assert_eq!(summary.symlinks_unchanged, 1);
3015 assert_eq!(summary.directories_unchanged, 2);
3016 testutils::check_dirs_identical(
3017 &tmp_dir.join("foo"),
3018 output_path,
3019 testutils::FileEqualityCheck::Timestamp,
3020 )
3021 .await?;
3022 Ok(())
3023 }
3024
3025 #[tokio::test]
3026 #[traced_test]
3027 async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
3028 let tmp_dir = testutils::setup_test_dir().await?;
3029 let test_path = tmp_dir.as_path();
3030 let summary = copy(
3031 &PROGRESS,
3032 &test_path.join("foo"),
3033 &test_path.join("bar"),
3034 &Settings {
3035 dereference: false,
3036 fail_early: false,
3037 overwrite: false,
3038 overwrite_compare: filecmp::MetadataCmpSettings {
3039 size: true,
3040 mtime: true,
3041 ..Default::default()
3042 },
3043 overwrite_filter: None,
3044 ignore_existing: false,
3045 chunk_size: 0,
3046 skip_specials: false,
3047 remote_copy_buffer_size: 0,
3048 filter: None,
3049 dry_run: None,
3050 delete: None,
3051 },
3052 &NO_PRESERVE_SETTINGS, false,
3054 )
3055 .await?;
3056 assert_eq!(summary.files_copied, 5);
3057 assert_eq!(summary.symlinks_created, 2);
3058 assert_eq!(summary.directories_created, 3);
3059 let source_path = &test_path.join("foo");
3060 let output_path = &tmp_dir.join("bar");
3061 tokio::fs::set_permissions(
3063 &source_path.join("bar"),
3064 std::fs::Permissions::from_mode(0o000),
3065 )
3066 .await?;
3067 tokio::fs::set_permissions(
3068 &source_path.join("baz").join("4.txt"),
3069 std::fs::Permissions::from_mode(0o000),
3070 )
3071 .await?;
3072 match copy(
3080 &PROGRESS,
3081 &tmp_dir.join("foo"),
3082 output_path,
3083 &Settings {
3084 dereference: false,
3085 fail_early: false,
3086 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3088 size: true,
3089 mtime: true,
3090 ..Default::default()
3091 },
3092 overwrite_filter: None,
3093 ignore_existing: false,
3094 chunk_size: 0,
3095 skip_specials: false,
3096 remote_copy_buffer_size: 0,
3097 filter: None,
3098 dry_run: None,
3099 delete: None,
3100 },
3101 &DO_PRESERVE_SETTINGS,
3102 false,
3103 )
3104 .await
3105 {
3106 Ok(_) => panic!("Expected the copy to error!"),
3107 Err(error) => {
3108 tracing::info!("{}", &error);
3109 assert_eq!(error.summary.files_copied, 1);
3110 assert_eq!(error.summary.symlinks_created, 2);
3111 assert_eq!(error.summary.directories_created, 0);
3112 assert_eq!(error.summary.rm_summary.files_removed, 2);
3113 assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
3114 assert_eq!(error.summary.rm_summary.directories_removed, 0);
3115 }
3116 }
3117 Ok(())
3118 }
3119
3120 #[tokio::test]
3121 #[traced_test]
3122 async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
3123 let tmp_dir = testutils::create_temp_dir().await?;
3125 let test_path = tmp_dir.as_path();
3126 let baz_file = test_path.join("baz_file.txt");
3128 tokio::fs::write(&baz_file, "final content").await?;
3129 let bar_link = test_path.join("bar_link");
3130 let foo_link = test_path.join("foo_link");
3131 tokio::fs::symlink(&baz_file, &bar_link).await?;
3133 tokio::fs::symlink(&bar_link, &foo_link).await?;
3134 let src_dir = test_path.join("src_chain");
3136 tokio::fs::create_dir(&src_dir).await?;
3137 tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
3139 tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
3140 tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
3141 let summary = copy(
3143 &PROGRESS,
3144 &src_dir,
3145 &test_path.join("dst_with_deref"),
3146 &Settings {
3147 dereference: true, fail_early: false,
3149 overwrite: false,
3150 overwrite_compare: filecmp::MetadataCmpSettings {
3151 size: true,
3152 mtime: true,
3153 ..Default::default()
3154 },
3155 overwrite_filter: None,
3156 ignore_existing: false,
3157 chunk_size: 0,
3158 skip_specials: false,
3159 remote_copy_buffer_size: 0,
3160 filter: None,
3161 dry_run: None,
3162 delete: None,
3163 },
3164 &NO_PRESERVE_SETTINGS,
3165 false,
3166 )
3167 .await?;
3168 assert_eq!(summary.files_copied, 3); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1);
3171 let dst_dir = test_path.join("dst_with_deref");
3172 let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
3174 let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
3175 let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
3176 assert_eq!(foo_content, "final content");
3177 assert_eq!(bar_content, "final content");
3178 assert_eq!(baz_content, "final content");
3179 assert!(dst_dir.join("foo").is_file());
3181 assert!(dst_dir.join("bar").is_file());
3182 assert!(dst_dir.join("baz").is_file());
3183 assert!(!dst_dir.join("foo").is_symlink());
3184 assert!(!dst_dir.join("bar").is_symlink());
3185 assert!(!dst_dir.join("baz").is_symlink());
3186 Ok(())
3187 }
3188
3189 #[tokio::test]
3190 #[traced_test]
3191 async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
3192 let tmp_dir = testutils::create_temp_dir().await?;
3193 let test_path = tmp_dir.as_path();
3194 let target_dir = test_path.join("target_dir");
3196 tokio::fs::create_dir(&target_dir).await?;
3197 tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
3198 tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
3200 tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
3201 tokio::fs::set_permissions(
3202 &target_dir.join("file1.txt"),
3203 std::fs::Permissions::from_mode(0o644),
3204 )
3205 .await?;
3206 tokio::fs::set_permissions(
3207 &target_dir.join("file2.txt"),
3208 std::fs::Permissions::from_mode(0o600),
3209 )
3210 .await?;
3211 let dir_symlink = test_path.join("dir_symlink");
3213 tokio::fs::symlink(&target_dir, &dir_symlink).await?;
3214 let summary = copy(
3216 &PROGRESS,
3217 &dir_symlink,
3218 &test_path.join("copied_dir"),
3219 &Settings {
3220 dereference: true, fail_early: false,
3222 overwrite: false,
3223 overwrite_compare: filecmp::MetadataCmpSettings {
3224 size: true,
3225 mtime: true,
3226 ..Default::default()
3227 },
3228 overwrite_filter: None,
3229 ignore_existing: false,
3230 chunk_size: 0,
3231 skip_specials: false,
3232 remote_copy_buffer_size: 0,
3233 filter: None,
3234 dry_run: None,
3235 delete: None,
3236 },
3237 &DO_PRESERVE_SETTINGS,
3238 false,
3239 )
3240 .await?;
3241 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");
3245 assert!(copied_dir.is_dir());
3247 assert!(!copied_dir.is_symlink()); let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
3250 let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
3251 assert_eq!(file1_content, "content1");
3252 assert_eq!(file2_content, "content2");
3253 let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
3255 let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
3256 let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
3257 assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
3258 assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
3259 assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
3260 Ok(())
3261 }
3262
3263 #[tokio::test]
3264 #[traced_test]
3265 async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
3266 let tmp_dir = testutils::create_temp_dir().await?;
3267 let test_path = tmp_dir.as_path();
3268 let file1 = test_path.join("file1.txt");
3270 let file2 = test_path.join("file2.txt");
3271 tokio::fs::write(&file1, "content1").await?;
3272 tokio::fs::write(&file2, "content2").await?;
3273 tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
3274 tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
3275 let symlink1 = test_path.join("symlink1");
3277 let symlink2 = test_path.join("symlink2");
3278 tokio::fs::symlink(&file1, &symlink1).await?;
3279 tokio::fs::symlink(&file2, &symlink2).await?;
3280 let summary1 = copy(
3282 &PROGRESS,
3283 &symlink1,
3284 &test_path.join("copied_file1.txt"),
3285 &Settings {
3286 dereference: true, fail_early: false,
3288 overwrite: false,
3289 overwrite_compare: filecmp::MetadataCmpSettings::default(),
3290 overwrite_filter: None,
3291 ignore_existing: false,
3292 chunk_size: 0,
3293 skip_specials: false,
3294 remote_copy_buffer_size: 0,
3295 filter: None,
3296 dry_run: None,
3297 delete: None,
3298 },
3299 &DO_PRESERVE_SETTINGS, false,
3301 )
3302 .await?;
3303 let summary2 = copy(
3304 &PROGRESS,
3305 &symlink2,
3306 &test_path.join("copied_file2.txt"),
3307 &Settings {
3308 dereference: true,
3309 fail_early: false,
3310 overwrite: false,
3311 overwrite_compare: filecmp::MetadataCmpSettings::default(),
3312 overwrite_filter: None,
3313 ignore_existing: false,
3314 chunk_size: 0,
3315 skip_specials: false,
3316 remote_copy_buffer_size: 0,
3317 filter: None,
3318 dry_run: None,
3319 delete: None,
3320 },
3321 &DO_PRESERVE_SETTINGS,
3322 false,
3323 )
3324 .await?;
3325 assert_eq!(summary1.files_copied, 1);
3326 assert_eq!(summary1.symlinks_created, 0);
3327 assert_eq!(summary2.files_copied, 1);
3328 assert_eq!(summary2.symlinks_created, 0);
3329 let copied1 = test_path.join("copied_file1.txt");
3330 let copied2 = test_path.join("copied_file2.txt");
3331 assert!(copied1.is_file());
3333 assert!(!copied1.is_symlink());
3334 assert!(copied2.is_file());
3335 assert!(!copied2.is_symlink());
3336 let content1 = tokio::fs::read_to_string(&copied1).await?;
3338 let content2 = tokio::fs::read_to_string(&copied2).await?;
3339 assert_eq!(content1, "content1");
3340 assert_eq!(content2, "content2");
3341 let copied1_metadata = tokio::fs::metadata(&copied1).await?;
3343 let copied2_metadata = tokio::fs::metadata(&copied2).await?;
3344 assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
3345 assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
3346 Ok(())
3347 }
3348
3349 #[tokio::test]
3350 #[traced_test]
3351 async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
3352 let tmp_dir = testutils::setup_test_dir().await?;
3353 tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
3355 tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
3357 let summary = copy(
3358 &PROGRESS,
3359 &tmp_dir.join("foo"),
3360 &tmp_dir.join("bar"),
3361 &Settings {
3362 dereference: true, fail_early: false,
3364 overwrite: false,
3365 overwrite_compare: filecmp::MetadataCmpSettings {
3366 size: true,
3367 mtime: true,
3368 ..Default::default()
3369 },
3370 overwrite_filter: None,
3371 ignore_existing: false,
3372 chunk_size: 0,
3373 skip_specials: false,
3374 remote_copy_buffer_size: 0,
3375 filter: None,
3376 dry_run: None,
3377 delete: None,
3378 },
3379 &DO_PRESERVE_SETTINGS,
3380 false,
3381 )
3382 .await?;
3383 assert_eq!(summary.files_copied, 13); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 5);
3386 tokio::process::Command::new("cp")
3388 .args(["-r", "-L"])
3389 .arg(tmp_dir.join("foo"))
3390 .arg(tmp_dir.join("bar-cp"))
3391 .output()
3392 .await?;
3393 testutils::check_dirs_identical(
3394 &tmp_dir.join("bar"),
3395 &tmp_dir.join("bar-cp"),
3396 testutils::FileEqualityCheck::Basic,
3397 )
3398 .await?;
3399 Ok(())
3400 }
3401
3402 mod error_message_tests {
3404 use super::*;
3405
3406 fn get_full_error_message(error: &Error) -> String {
3408 format!("{:#}", error.source)
3409 }
3410
3411 #[tokio::test]
3412 #[traced_test]
3413 async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
3414 let tmp_dir = testutils::create_temp_dir().await?;
3415
3416 let result = copy(
3417 &PROGRESS,
3418 &tmp_dir.join("does_not_exist.txt"),
3419 &tmp_dir.join("dest.txt"),
3420 &Settings {
3421 dereference: false,
3422 fail_early: false,
3423 overwrite: false,
3424 overwrite_compare: Default::default(),
3425 overwrite_filter: None,
3426 ignore_existing: false,
3427 chunk_size: 0,
3428 skip_specials: false,
3429 remote_copy_buffer_size: 0,
3430 filter: None,
3431 dry_run: None,
3432 delete: None,
3433 },
3434 &NO_PRESERVE_SETTINGS,
3435 false,
3436 )
3437 .await;
3438
3439 assert!(result.is_err());
3440 let err_msg = get_full_error_message(&result.unwrap_err());
3441
3442 assert!(
3443 err_msg.to_lowercase().contains("no such file")
3444 || err_msg.to_lowercase().contains("not found")
3445 || err_msg.contains("ENOENT"),
3446 "Error message must include file not found text. Got: {}",
3447 err_msg
3448 );
3449 Ok(())
3450 }
3451
3452 #[tokio::test]
3453 #[traced_test]
3454 async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
3455 let tmp_dir = testutils::create_temp_dir().await?;
3456 let unreadable_dir = tmp_dir.join("unreadable_dir");
3457 tokio::fs::create_dir(&unreadable_dir).await?;
3458 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
3459 .await?;
3460
3461 let result = copy(
3462 &PROGRESS,
3463 &unreadable_dir,
3464 &tmp_dir.join("dest"),
3465 &Settings {
3466 dereference: false,
3467 fail_early: true,
3468 overwrite: false,
3469 overwrite_compare: Default::default(),
3470 overwrite_filter: None,
3471 ignore_existing: false,
3472 chunk_size: 0,
3473 skip_specials: false,
3474 remote_copy_buffer_size: 0,
3475 filter: None,
3476 dry_run: None,
3477 delete: None,
3478 },
3479 &NO_PRESERVE_SETTINGS,
3480 false,
3481 )
3482 .await;
3483
3484 assert!(result.is_err());
3485 let err_msg = get_full_error_message(&result.unwrap_err());
3486
3487 assert!(
3488 err_msg.to_lowercase().contains("permission")
3489 || err_msg.contains("EACCES")
3490 || err_msg.contains("denied"),
3491 "Error message must include permission-related text. Got: {}",
3492 err_msg
3493 );
3494
3495 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
3497 .await?;
3498 Ok(())
3499 }
3500
3501 #[tokio::test]
3502 #[traced_test]
3503 async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
3504 {
3505 let tmp_dir = testutils::setup_test_dir().await?;
3506 let test_path = tmp_dir.as_path();
3507 let readonly_parent = test_path.join("readonly_dest");
3508 tokio::fs::create_dir(&readonly_parent).await?;
3509 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3510 .await?;
3511
3512 let result = copy(
3513 &PROGRESS,
3514 &test_path.join("foo"),
3515 &readonly_parent.join("copy"),
3516 &Settings {
3517 dereference: false,
3518 fail_early: true,
3519 overwrite: false,
3520 overwrite_compare: Default::default(),
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,
3531 false,
3532 )
3533 .await;
3534
3535 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
3537 .await?;
3538
3539 assert!(result.is_err(), "copy into read-only parent should fail");
3540 let err_msg = get_full_error_message(&result.unwrap_err());
3541
3542 assert!(
3543 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
3544 "Error message must include permission denied text. Got: {}",
3545 err_msg
3546 );
3547 Ok(())
3548 }
3549 }
3550
3551 mod empty_dir_cleanup_tests {
3552 use super::*;
3553 use crate::filter::FilterSettings;
3554 use std::path::Path;
3555 #[test]
3556 fn test_check_empty_dir_cleanup_no_filter() {
3557 assert_eq!(
3559 check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
3560 EmptyDirAction::Keep
3561 );
3562 }
3563 #[test]
3564 fn test_check_empty_dir_cleanup_something_copied() {
3565 let mut filter = FilterSettings::new();
3567 filter.add_include("*.txt").unwrap();
3568 assert_eq!(
3569 check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
3570 EmptyDirAction::Keep
3571 );
3572 }
3573 #[test]
3574 fn test_check_empty_dir_cleanup_not_created() {
3575 let mut filter = FilterSettings::new();
3577 filter.add_include("*.txt").unwrap();
3578 assert_eq!(
3579 check_empty_dir_cleanup(
3580 Some(&filter),
3581 false,
3582 false,
3583 Path::new("any"),
3584 false,
3585 false
3586 ),
3587 EmptyDirAction::Keep
3588 );
3589 }
3590 #[test]
3591 fn test_check_empty_dir_cleanup_directly_matched() {
3592 let mut filter = FilterSettings::new();
3594 filter.add_include("target/").unwrap();
3595 assert_eq!(
3596 check_empty_dir_cleanup(
3597 Some(&filter),
3598 true,
3599 false,
3600 Path::new("target"),
3601 false,
3602 false
3603 ),
3604 EmptyDirAction::Keep
3605 );
3606 }
3607 #[test]
3608 fn test_check_empty_dir_cleanup_traversed_only() {
3609 let mut filter = FilterSettings::new();
3611 filter.add_include("*.txt").unwrap();
3612 assert_eq!(
3613 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
3614 EmptyDirAction::Remove
3615 );
3616 }
3617 #[test]
3618 fn test_check_empty_dir_cleanup_dry_run() {
3619 let mut filter = FilterSettings::new();
3621 filter.add_include("*.txt").unwrap();
3622 assert_eq!(
3623 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
3624 EmptyDirAction::DryRunSkip
3625 );
3626 }
3627 #[test]
3628 fn test_check_empty_dir_cleanup_root_always_kept() {
3629 let mut filter = FilterSettings::new();
3631 filter.add_include("*.txt").unwrap();
3632 assert_eq!(
3633 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
3634 EmptyDirAction::Keep
3635 );
3636 }
3637 #[test]
3638 fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
3639 let mut filter = FilterSettings::new();
3641 filter.add_include("*.txt").unwrap();
3642 assert_eq!(
3643 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
3644 EmptyDirAction::Keep
3645 );
3646 }
3647 }
3648
3649 #[tokio::test]
3653 #[traced_test]
3654 async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
3655 let tmp_dir = testutils::create_temp_dir().await?;
3656 let test_path = tmp_dir.as_path();
3657 let src_dir = test_path.join("src");
3659 tokio::fs::create_dir(&src_dir).await?;
3660 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
3661 let readable_file = src_dir.join("readable.txt");
3663 tokio::fs::write(&readable_file, "content").await?;
3664 let unreadable_file = src_dir.join("unreadable.txt");
3665 tokio::fs::write(&unreadable_file, "secret").await?;
3666 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3667 .await?;
3668 let dst_dir = test_path.join("dst");
3669 let result = copy(
3671 &PROGRESS,
3672 &src_dir,
3673 &dst_dir,
3674 &Settings {
3675 dereference: false,
3676 fail_early: false,
3677 overwrite: false,
3678 overwrite_compare: Default::default(),
3679 overwrite_filter: None,
3680 ignore_existing: false,
3681 chunk_size: 0,
3682 skip_specials: false,
3683 remote_copy_buffer_size: 0,
3684 filter: None,
3685 dry_run: None,
3686 delete: None,
3687 },
3688 &DO_PRESERVE_SETTINGS,
3689 false,
3690 )
3691 .await;
3692 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
3694 .await?;
3695 assert!(result.is_err(), "copy should fail due to unreadable file");
3697 let error = result.unwrap_err();
3698 assert_eq!(error.summary.files_copied, 1);
3700 assert_eq!(error.summary.directories_created, 1);
3701 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3703 assert!(dst_metadata.is_dir());
3704 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
3705 assert_eq!(
3706 actual_mode, 0o750,
3707 "directory should have preserved source permissions (0o750), got {:o}",
3708 actual_mode
3709 );
3710 Ok(())
3711 }
3712
3713 #[tokio::test]
3717 #[traced_test]
3718 async fn pruned_empty_traversal_dir_still_surfaces_child_error() -> Result<(), anyhow::Error> {
3719 let tmp_dir = testutils::create_temp_dir().await?;
3720 let test_path = tmp_dir.as_path();
3721 let src_dir = test_path.join("src");
3722 let sub = src_dir.join("sub");
3723 tokio::fs::create_dir_all(&sub).await?;
3724 let unreadable = sub.join("file.txt");
3727 tokio::fs::write(&unreadable, "secret").await?;
3728 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3729 let dst_dir = test_path.join("dst");
3730 let mut filter = crate::filter::FilterSettings::new();
3731 filter.add_include("*.txt").unwrap();
3732 let result = copy(
3733 &PROGRESS,
3734 &src_dir,
3735 &dst_dir,
3736 &Settings {
3737 dereference: false,
3738 fail_early: false,
3739 overwrite: false,
3740 overwrite_compare: Default::default(),
3741 overwrite_filter: None,
3742 ignore_existing: false,
3743 chunk_size: 0,
3744 skip_specials: false,
3745 remote_copy_buffer_size: 0,
3746 filter: Some(filter),
3747 dry_run: None,
3748 delete: None,
3749 },
3750 &DO_PRESERVE_SETTINGS,
3751 false,
3752 )
3753 .await;
3754 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).await?;
3756 assert!(
3757 result.is_err(),
3758 "the failed child copy must surface even though the empty traversal dir was pruned"
3759 );
3760 assert!(
3762 !dst_dir.join("sub").exists(),
3763 "empty traversal directory should have been removed"
3764 );
3765 Ok(())
3766 }
3767
3768 #[tokio::test]
3770 #[traced_test]
3771 async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error()
3772 -> Result<(), anyhow::Error> {
3773 let tmp_dir = testutils::create_temp_dir().await?;
3774 let test_path = tmp_dir.as_path();
3775 let src_dir = test_path.join("src");
3776 tokio::fs::create_dir(&src_dir).await?;
3777 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
3778 let unreadable_file = src_dir.join("unreadable.txt");
3779 tokio::fs::write(&unreadable_file, "secret").await?;
3780 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3781 .await?;
3782 let fixed_secs = 946684800;
3783 let fixed_nsec = 123_456_789;
3784 let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
3785 nix::sys::stat::utimensat(
3786 nix::fcntl::AT_FDCWD,
3787 &src_dir,
3788 &fixed_time,
3789 &fixed_time,
3790 nix::sys::stat::UtimensatFlags::NoFollowSymlink,
3791 )?;
3792 let src_metadata = tokio::fs::metadata(&src_dir).await?;
3793 let dst_dir = test_path.join("dst");
3794 let result = copy(
3795 &PROGRESS,
3796 &src_dir,
3797 &dst_dir,
3798 &Settings {
3799 dereference: false,
3800 fail_early: true,
3801 overwrite: false,
3802 overwrite_compare: Default::default(),
3803 overwrite_filter: None,
3804 ignore_existing: false,
3805 chunk_size: 0,
3806 skip_specials: false,
3807 remote_copy_buffer_size: 0,
3808 filter: None,
3809 dry_run: None,
3810 delete: None,
3811 },
3812 &DO_PRESERVE_SETTINGS,
3813 false,
3814 )
3815 .await;
3816 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
3817 .await?;
3818 assert!(result.is_err(), "copy should fail due to unreadable file");
3819 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3820 assert!(dst_metadata.is_dir());
3821 assert_ne!(
3822 (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
3823 (src_metadata.mtime(), src_metadata.mtime_nsec()),
3824 "fail-early should return before applying preserved directory timestamps"
3825 );
3826 Ok(())
3827 }
3828 mod filter_tests {
3829 use super::*;
3830 use crate::filter::FilterSettings;
3831 #[tokio::test]
3835 #[traced_test]
3836 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
3837 let tmp_dir = testutils::setup_test_dir().await?;
3838 let test_path = tmp_dir.as_path();
3839 let mut filter = FilterSettings::new();
3851 filter.add_include("bar/*.txt").unwrap();
3852 let summary = copy(
3853 &PROGRESS,
3854 &test_path.join("foo"),
3855 &test_path.join("dst"),
3856 &Settings {
3857 dereference: false,
3858 fail_early: false,
3859 overwrite: false,
3860 overwrite_compare: Default::default(),
3861 overwrite_filter: None,
3862 ignore_existing: false,
3863 chunk_size: 0,
3864 skip_specials: false,
3865 remote_copy_buffer_size: 0,
3866 filter: Some(filter),
3867 dry_run: None,
3868 delete: None,
3869 },
3870 &NO_PRESERVE_SETTINGS,
3871 false,
3872 )
3873 .await?;
3874 assert_eq!(
3877 summary.files_copied, 3,
3878 "should copy 3 files matching bar/*.txt"
3879 );
3880 assert!(
3882 test_path.join("dst/bar/1.txt").exists(),
3883 "bar/1.txt should be copied"
3884 );
3885 assert!(
3886 test_path.join("dst/bar/2.txt").exists(),
3887 "bar/2.txt should be copied"
3888 );
3889 assert!(
3890 test_path.join("dst/bar/3.txt").exists(),
3891 "bar/3.txt should be copied"
3892 );
3893 assert!(
3895 !test_path.join("dst/0.txt").exists(),
3896 "0.txt should not be copied"
3897 );
3898 Ok(())
3899 }
3900 #[tokio::test]
3902 #[traced_test]
3903 async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
3904 let tmp_dir = testutils::setup_test_dir().await?;
3905 let test_path = tmp_dir.as_path();
3906 let mut filter = FilterSettings::new();
3908 filter.add_include("/bar/**").unwrap();
3909 let summary = copy(
3910 &PROGRESS,
3911 &test_path.join("foo"),
3912 &test_path.join("dst"),
3913 &Settings {
3914 dereference: false,
3915 fail_early: false,
3916 overwrite: false,
3917 overwrite_compare: Default::default(),
3918 overwrite_filter: None,
3919 ignore_existing: false,
3920 chunk_size: 0,
3921 skip_specials: false,
3922 remote_copy_buffer_size: 0,
3923 filter: Some(filter),
3924 dry_run: None,
3925 delete: None,
3926 },
3927 &NO_PRESERVE_SETTINGS,
3928 false,
3929 )
3930 .await?;
3931 assert!(
3933 test_path.join("dst/bar").exists(),
3934 "bar directory should be copied"
3935 );
3936 assert!(
3937 !test_path.join("dst/baz").exists(),
3938 "baz directory should not be copied"
3939 );
3940 assert!(
3941 !test_path.join("dst/0.txt").exists(),
3942 "0.txt should not be copied"
3943 );
3944 assert_eq!(
3946 summary.files_copied, 3,
3947 "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
3948 );
3949 assert_eq!(
3950 summary.directories_created, 2,
3951 "should create 2 directories (root dst + bar)"
3952 );
3953 assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
3955 assert_eq!(
3956 summary.directories_skipped, 1,
3957 "should skip 1 directory (baz)"
3958 );
3959 Ok(())
3960 }
3961 #[tokio::test]
3963 #[traced_test]
3964 async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
3965 let tmp_dir = testutils::setup_test_dir().await?;
3966 let test_path = tmp_dir.as_path();
3967 let mut filter = FilterSettings::new();
3969 filter.add_include("**/*.txt").unwrap();
3970 let summary = copy(
3971 &PROGRESS,
3972 &test_path.join("foo"),
3973 &test_path.join("dst"),
3974 &Settings {
3975 dereference: false,
3976 fail_early: false,
3977 overwrite: false,
3978 overwrite_compare: Default::default(),
3979 overwrite_filter: None,
3980 ignore_existing: false,
3981 chunk_size: 0,
3982 skip_specials: false,
3983 remote_copy_buffer_size: 0,
3984 filter: Some(filter),
3985 dry_run: None,
3986 delete: None,
3987 },
3988 &NO_PRESERVE_SETTINGS,
3989 false,
3990 )
3991 .await?;
3992 assert_eq!(
3994 summary.files_copied, 5,
3995 "should copy all 5 .txt files with **/*.txt pattern"
3996 );
3997 Ok(())
3998 }
3999 #[tokio::test]
4001 #[traced_test]
4002 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
4003 let tmp_dir = testutils::setup_test_dir().await?;
4004 let test_path = tmp_dir.as_path();
4005 let mut filter = FilterSettings::new();
4007 filter.add_exclude("*.txt").unwrap();
4008 let result = copy(
4009 &PROGRESS,
4010 &test_path.join("foo/0.txt"), &test_path.join("dst.txt"),
4012 &Settings {
4013 dereference: false,
4014 fail_early: false,
4015 overwrite: false,
4016 overwrite_compare: Default::default(),
4017 overwrite_filter: None,
4018 ignore_existing: false,
4019 chunk_size: 0,
4020 skip_specials: false,
4021 remote_copy_buffer_size: 0,
4022 filter: Some(filter),
4023 dry_run: None,
4024 delete: None,
4025 },
4026 &NO_PRESERVE_SETTINGS,
4027 false,
4028 )
4029 .await?;
4030 assert_eq!(
4032 result.files_copied, 0,
4033 "file matching exclude pattern should not be copied"
4034 );
4035 assert!(
4036 !test_path.join("dst.txt").exists(),
4037 "excluded file should not exist at destination"
4038 );
4039 Ok(())
4040 }
4041 #[tokio::test]
4043 #[traced_test]
4044 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
4045 let test_path = testutils::create_temp_dir().await?;
4046 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
4048 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
4049 let mut filter = FilterSettings::new();
4051 filter.add_exclude("*_dir/").unwrap();
4052 let result = copy(
4053 &PROGRESS,
4054 &test_path.join("excluded_dir"),
4055 &test_path.join("dst"),
4056 &Settings {
4057 dereference: false,
4058 fail_early: false,
4059 overwrite: false,
4060 overwrite_compare: Default::default(),
4061 overwrite_filter: None,
4062 ignore_existing: false,
4063 chunk_size: 0,
4064 skip_specials: false,
4065 remote_copy_buffer_size: 0,
4066 filter: Some(filter),
4067 dry_run: None,
4068 delete: None,
4069 },
4070 &NO_PRESERVE_SETTINGS,
4071 false,
4072 )
4073 .await?;
4074 assert_eq!(
4076 result.directories_created, 0,
4077 "root directory matching exclude should not be created"
4078 );
4079 assert!(
4080 !test_path.join("dst").exists(),
4081 "excluded root directory should not exist at destination"
4082 );
4083 Ok(())
4084 }
4085 #[tokio::test]
4087 #[traced_test]
4088 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
4089 let test_path = testutils::create_temp_dir().await?;
4090 tokio::fs::write(test_path.join("target.txt"), "content").await?;
4092 tokio::fs::symlink(
4093 test_path.join("target.txt"),
4094 test_path.join("excluded_link"),
4095 )
4096 .await?;
4097 let mut filter = FilterSettings::new();
4099 filter.add_exclude("*_link").unwrap();
4100 let result = copy(
4101 &PROGRESS,
4102 &test_path.join("excluded_link"),
4103 &test_path.join("dst"),
4104 &Settings {
4105 dereference: false,
4106 fail_early: false,
4107 overwrite: false,
4108 overwrite_compare: Default::default(),
4109 overwrite_filter: None,
4110 ignore_existing: false,
4111 chunk_size: 0,
4112 skip_specials: false,
4113 remote_copy_buffer_size: 0,
4114 filter: Some(filter),
4115 dry_run: None,
4116 delete: None,
4117 },
4118 &NO_PRESERVE_SETTINGS,
4119 false,
4120 )
4121 .await?;
4122 assert_eq!(
4124 result.symlinks_created, 0,
4125 "root symlink matching exclude should not be created"
4126 );
4127 assert!(
4128 !test_path.join("dst").exists(),
4129 "excluded root symlink should not exist at destination"
4130 );
4131 Ok(())
4132 }
4133 #[tokio::test]
4135 #[traced_test]
4136 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
4137 let tmp_dir = testutils::setup_test_dir().await?;
4138 let test_path = tmp_dir.as_path();
4139 let mut filter = FilterSettings::new();
4146 filter.add_include("**/*.txt").unwrap();
4147 filter.add_exclude("bar/2.txt").unwrap();
4148 let summary = copy(
4149 &PROGRESS,
4150 &test_path.join("foo"),
4151 &test_path.join("dst"),
4152 &Settings {
4153 dereference: false,
4154 fail_early: false,
4155 overwrite: false,
4156 overwrite_compare: Default::default(),
4157 overwrite_filter: None,
4158 ignore_existing: false,
4159 chunk_size: 0,
4160 skip_specials: false,
4161 remote_copy_buffer_size: 0,
4162 filter: Some(filter),
4163 dry_run: None,
4164 delete: None,
4165 },
4166 &NO_PRESERVE_SETTINGS,
4167 false,
4168 )
4169 .await?;
4170 assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
4174 assert_eq!(
4175 summary.files_skipped, 1,
4176 "should skip 1 file (bar/2.txt excluded)"
4177 );
4178 assert!(
4180 test_path.join("dst/bar/1.txt").exists(),
4181 "bar/1.txt should be copied"
4182 );
4183 assert!(
4184 !test_path.join("dst/bar/2.txt").exists(),
4185 "bar/2.txt should be excluded"
4186 );
4187 assert!(
4188 test_path.join("dst/bar/3.txt").exists(),
4189 "bar/3.txt should be copied"
4190 );
4191 Ok(())
4192 }
4193 #[tokio::test]
4195 #[traced_test]
4196 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
4197 let tmp_dir = testutils::setup_test_dir().await?;
4198 let test_path = tmp_dir.as_path();
4199 let mut filter = FilterSettings::new();
4206 filter.add_exclude("bar/").unwrap();
4207 let summary = copy(
4208 &PROGRESS,
4209 &test_path.join("foo"),
4210 &test_path.join("dst"),
4211 &Settings {
4212 dereference: false,
4213 fail_early: false,
4214 overwrite: false,
4215 overwrite_compare: Default::default(),
4216 overwrite_filter: None,
4217 ignore_existing: false,
4218 chunk_size: 0,
4219 skip_specials: false,
4220 remote_copy_buffer_size: 0,
4221 filter: Some(filter),
4222 dry_run: None,
4223 delete: None,
4224 },
4225 &NO_PRESERVE_SETTINGS,
4226 false,
4227 )
4228 .await?;
4229 assert_eq!(summary.files_copied, 2, "should copy 2 files");
4233 assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
4234 assert_eq!(
4235 summary.directories_created, 2,
4236 "should create 2 directories"
4237 );
4238 assert_eq!(
4239 summary.directories_skipped, 1,
4240 "should skip 1 directory (bar)"
4241 );
4242 assert_eq!(
4243 summary.files_skipped, 0,
4244 "no files skipped (bar contents not counted)"
4245 );
4246 Ok(())
4247 }
4248 #[tokio::test]
4251 #[traced_test]
4252 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
4253 let test_path = testutils::create_temp_dir().await?;
4254 let src_path = test_path.join("src");
4260 tokio::fs::create_dir(&src_path).await?;
4261 tokio::fs::write(src_path.join("foo"), "content").await?;
4262 tokio::fs::write(src_path.join("bar"), "content").await?;
4263 tokio::fs::create_dir(src_path.join("baz")).await?;
4264 let mut filter = FilterSettings::new();
4266 filter.add_include("foo").unwrap();
4267 let summary = copy(
4268 &PROGRESS,
4269 &src_path,
4270 &test_path.join("dst"),
4271 &Settings {
4272 dereference: false,
4273 fail_early: false,
4274 overwrite: false,
4275 overwrite_compare: Default::default(),
4276 overwrite_filter: None,
4277 ignore_existing: false,
4278 chunk_size: 0,
4279 skip_specials: false,
4280 remote_copy_buffer_size: 0,
4281 filter: Some(filter),
4282 dry_run: None,
4283 delete: None,
4284 },
4285 &NO_PRESERVE_SETTINGS,
4286 false,
4287 )
4288 .await?;
4289 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4291 assert_eq!(
4292 summary.directories_created, 1,
4293 "should create only root directory (not empty 'baz')"
4294 );
4295 assert!(
4297 test_path.join("dst").join("foo").exists(),
4298 "foo should be copied"
4299 );
4300 assert!(
4302 !test_path.join("dst").join("bar").exists(),
4303 "bar should not be copied"
4304 );
4305 assert!(
4307 !test_path.join("dst").join("baz").exists(),
4308 "empty baz directory should NOT be created"
4309 );
4310 Ok(())
4311 }
4312 #[tokio::test]
4315 #[traced_test]
4316 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
4317 let test_path = testutils::create_temp_dir().await?;
4318 let src_path = test_path.join("src");
4325 tokio::fs::create_dir(&src_path).await?;
4326 tokio::fs::write(src_path.join("foo"), "content").await?;
4327 tokio::fs::create_dir(src_path.join("baz")).await?;
4328 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
4329 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
4330 let mut filter = FilterSettings::new();
4332 filter.add_include("foo").unwrap();
4333 let summary = copy(
4334 &PROGRESS,
4335 &src_path,
4336 &test_path.join("dst"),
4337 &Settings {
4338 dereference: false,
4339 fail_early: false,
4340 overwrite: false,
4341 overwrite_compare: Default::default(),
4342 overwrite_filter: None,
4343 ignore_existing: false,
4344 chunk_size: 0,
4345 skip_specials: false,
4346 remote_copy_buffer_size: 0,
4347 filter: Some(filter),
4348 dry_run: None,
4349 delete: None,
4350 },
4351 &NO_PRESERVE_SETTINGS,
4352 false,
4353 )
4354 .await?;
4355 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4357 assert_eq!(
4358 summary.files_skipped, 2,
4359 "should skip 2 files (qux and quux)"
4360 );
4361 assert_eq!(
4362 summary.directories_created, 1,
4363 "should create only root directory (not 'baz' with non-matching content)"
4364 );
4365 assert!(
4367 test_path.join("dst").join("foo").exists(),
4368 "foo should be copied"
4369 );
4370 assert!(
4372 !test_path.join("dst").join("baz").exists(),
4373 "baz directory should NOT be created (no matching content inside)"
4374 );
4375 Ok(())
4376 }
4377 #[tokio::test]
4380 #[traced_test]
4381 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
4382 let test_path = testutils::create_temp_dir().await?;
4383 let src_path = test_path.join("src");
4389 tokio::fs::create_dir(&src_path).await?;
4390 tokio::fs::write(src_path.join("foo"), "content").await?;
4391 tokio::fs::write(src_path.join("bar"), "content").await?;
4392 tokio::fs::create_dir(src_path.join("baz")).await?;
4393 let mut filter = FilterSettings::new();
4395 filter.add_include("foo").unwrap();
4396 let summary = copy(
4397 &PROGRESS,
4398 &src_path,
4399 &test_path.join("dst"),
4400 &Settings {
4401 dereference: false,
4402 fail_early: false,
4403 overwrite: false,
4404 overwrite_compare: Default::default(),
4405 overwrite_filter: None,
4406 ignore_existing: false,
4407 chunk_size: 0,
4408 skip_specials: false,
4409 remote_copy_buffer_size: 0,
4410 filter: Some(filter),
4411 dry_run: Some(crate::config::DryRunMode::Explain),
4412 delete: None,
4413 },
4414 &NO_PRESERVE_SETTINGS,
4415 false,
4416 )
4417 .await?;
4418 assert_eq!(
4420 summary.files_copied, 1,
4421 "should report only 'foo' would be copied"
4422 );
4423 assert_eq!(
4424 summary.directories_created, 1,
4425 "should report only root directory would be created (not empty 'baz')"
4426 );
4427 assert!(
4429 !test_path.join("dst").exists(),
4430 "dst should not exist in dry-run"
4431 );
4432 Ok(())
4433 }
4434 #[tokio::test]
4437 #[traced_test]
4438 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
4439 let test_path = testutils::create_temp_dir().await?;
4440 let src_path = test_path.join("src");
4446 tokio::fs::create_dir(&src_path).await?;
4447 tokio::fs::write(src_path.join("foo"), "content").await?;
4448 tokio::fs::write(src_path.join("bar"), "content").await?;
4449 tokio::fs::create_dir(src_path.join("baz")).await?;
4450 let dst_path = test_path.join("dst");
4452 tokio::fs::create_dir(&dst_path).await?;
4453 tokio::fs::create_dir(dst_path.join("baz")).await?;
4454 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
4456 let mut filter = FilterSettings::new();
4458 filter.add_include("foo").unwrap();
4459 let summary = copy(
4460 &PROGRESS,
4461 &src_path,
4462 &dst_path,
4463 &Settings {
4464 dereference: false,
4465 fail_early: false,
4466 overwrite: true, overwrite_compare: Default::default(),
4468 overwrite_filter: None,
4469 ignore_existing: false,
4470 chunk_size: 0,
4471 skip_specials: false,
4472 remote_copy_buffer_size: 0,
4473 filter: Some(filter),
4474 dry_run: None,
4475 delete: None,
4476 },
4477 &NO_PRESERVE_SETTINGS,
4478 false,
4479 )
4480 .await?;
4481 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4483 assert_eq!(
4485 summary.directories_unchanged, 2,
4486 "root dst and baz directories should be unchanged"
4487 );
4488 assert_eq!(
4489 summary.directories_created, 0,
4490 "should not create any directories"
4491 );
4492 assert!(dst_path.join("foo").exists(), "foo should be copied");
4494 assert!(!dst_path.join("bar").exists(), "bar should not be copied");
4496 assert!(
4498 dst_path.join("baz").exists(),
4499 "existing baz directory should still exist"
4500 );
4501 assert!(
4502 dst_path.join("baz").join("marker.txt").exists(),
4503 "existing content in baz should still exist"
4504 );
4505 Ok(())
4506 }
4507 }
4508 mod dry_run_tests {
4509 use super::*;
4510 use crate::filter::FilterSettings;
4511 #[tokio::test]
4514 #[traced_test]
4515 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
4516 let tmp_dir = testutils::setup_test_dir().await?;
4517 let test_path = tmp_dir.as_path();
4518 let dst_path = test_path.join("nonexistent_dst");
4519 assert!(
4521 !dst_path.exists(),
4522 "destination should not exist before dry-run"
4523 );
4524 let summary = copy(
4525 &PROGRESS,
4526 &test_path.join("foo"),
4527 &dst_path,
4528 &Settings {
4529 dereference: false,
4530 fail_early: false,
4531 overwrite: false,
4532 overwrite_compare: Default::default(),
4533 overwrite_filter: None,
4534 ignore_existing: false,
4535 chunk_size: 0,
4536 skip_specials: false,
4537 remote_copy_buffer_size: 0,
4538 filter: None,
4539 dry_run: Some(crate::config::DryRunMode::Brief),
4540 delete: None,
4541 },
4542 &NO_PRESERVE_SETTINGS,
4543 false,
4544 )
4545 .await?;
4546 assert!(
4548 !dst_path.exists(),
4549 "dry-run should not create destination directory"
4550 );
4551 assert!(
4553 summary.directories_created > 0,
4554 "dry-run should report directories that would be created"
4555 );
4556 assert!(
4557 summary.files_copied > 0,
4558 "dry-run should report files that would be copied"
4559 );
4560 Ok(())
4561 }
4562 #[tokio::test]
4566 async fn dry_run_ignore_existing_reports_file_as_skipped() -> Result<(), anyhow::Error> {
4567 let tmp = testutils::create_temp_dir().await?;
4568 let src = tmp.join("src.txt");
4569 let dst = tmp.join("dst.txt");
4570 tokio::fs::write(&src, "new").await?;
4571 tokio::fs::write(&dst, "old").await?; let summary = copy(
4573 &PROGRESS,
4574 &src,
4575 &dst,
4576 &Settings {
4577 dereference: false,
4578 fail_early: false,
4579 overwrite: false,
4580 overwrite_compare: Default::default(),
4581 overwrite_filter: None,
4582 ignore_existing: true,
4583 chunk_size: 0,
4584 skip_specials: false,
4585 remote_copy_buffer_size: 0,
4586 filter: None,
4587 dry_run: Some(crate::config::DryRunMode::Brief),
4588 delete: None,
4589 },
4590 &NO_PRESERVE_SETTINGS,
4591 false,
4592 )
4593 .await?;
4594 assert_eq!(
4595 summary.files_unchanged, 1,
4596 "existing file must be reported skipped in dry-run under --ignore-existing"
4597 );
4598 assert_eq!(
4599 summary.files_copied, 0,
4600 "must not report a would-be copy when --ignore-existing skips"
4601 );
4602 assert_eq!(
4603 tokio::fs::read_to_string(&dst).await?,
4604 "old",
4605 "dry-run must not mutate the destination"
4606 );
4607 Ok(())
4608 }
4609 #[tokio::test]
4612 async fn dry_run_ignore_existing_reports_symlink_as_skipped() -> Result<(), anyhow::Error> {
4613 let tmp = testutils::create_temp_dir().await?;
4614 let src = tmp.join("src_link");
4615 let dst = tmp.join("dst_link");
4616 tokio::fs::symlink("some/target", &src).await?;
4617 tokio::fs::write(&dst, "existing").await?; let summary = copy(
4619 &PROGRESS,
4620 &src,
4621 &dst,
4622 &Settings {
4623 dereference: false,
4624 fail_early: false,
4625 overwrite: false,
4626 overwrite_compare: Default::default(),
4627 overwrite_filter: None,
4628 ignore_existing: true,
4629 chunk_size: 0,
4630 skip_specials: false,
4631 remote_copy_buffer_size: 0,
4632 filter: None,
4633 dry_run: Some(crate::config::DryRunMode::Brief),
4634 delete: None,
4635 },
4636 &NO_PRESERVE_SETTINGS,
4637 false,
4638 )
4639 .await?;
4640 assert_eq!(
4641 summary.symlinks_unchanged, 1,
4642 "existing symlink dst must be reported skipped in dry-run under --ignore-existing"
4643 );
4644 assert_eq!(
4645 summary.symlinks_created, 0,
4646 "must not report a would-be symlink creation when --ignore-existing skips"
4647 );
4648 Ok(())
4649 }
4650 #[tokio::test]
4654 #[traced_test]
4655 async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
4656 let test_path = testutils::create_temp_dir().await?;
4657 let src_path = test_path.join("src");
4662 tokio::fs::create_dir(&src_path).await?;
4663 tokio::fs::write(src_path.join("bar.log"), "content").await?;
4664 tokio::fs::create_dir(src_path.join("baz")).await?;
4665 let mut filter = FilterSettings::new();
4667 filter.add_include("*.txt").unwrap();
4668 let dst_path = test_path.join("dst");
4669 let summary = copy(
4670 &PROGRESS,
4671 &src_path,
4672 &dst_path,
4673 &Settings {
4674 dereference: false,
4675 fail_early: false,
4676 overwrite: false,
4677 overwrite_compare: Default::default(),
4678 overwrite_filter: None,
4679 ignore_existing: false,
4680 chunk_size: 0,
4681 skip_specials: false,
4682 remote_copy_buffer_size: 0,
4683 filter: Some(filter),
4684 dry_run: None,
4685 delete: None,
4686 },
4687 &NO_PRESERVE_SETTINGS,
4688 false,
4689 )
4690 .await?;
4691 assert_eq!(summary.files_copied, 0, "no files match *.txt");
4693 assert_eq!(
4695 summary.directories_created, 1,
4696 "root directory should always be created"
4697 );
4698 assert!(dst_path.exists(), "root destination directory should exist");
4699 assert!(
4701 !dst_path.join("baz").exists(),
4702 "empty baz should not be created"
4703 );
4704 Ok(())
4705 }
4706 #[tokio::test]
4708 #[traced_test]
4709 async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
4710 {
4711 let test_path = testutils::create_temp_dir().await?;
4712 let src_path = test_path.join("src");
4713 tokio::fs::create_dir(&src_path).await?;
4714 tokio::fs::write(src_path.join("bar.log"), "content").await?;
4715 let mut filter = FilterSettings::new();
4717 filter.add_include("*.txt").unwrap();
4718 let dst_path = test_path.join("dst");
4719 let summary = copy(
4720 &PROGRESS,
4721 &src_path,
4722 &dst_path,
4723 &Settings {
4724 dereference: false,
4725 fail_early: false,
4726 overwrite: false,
4727 overwrite_compare: Default::default(),
4728 overwrite_filter: None,
4729 ignore_existing: false,
4730 chunk_size: 0,
4731 skip_specials: false,
4732 remote_copy_buffer_size: 0,
4733 filter: Some(filter),
4734 dry_run: Some(crate::config::DryRunMode::Explain),
4735 delete: None,
4736 },
4737 &NO_PRESERVE_SETTINGS,
4738 false,
4739 )
4740 .await?;
4741 assert_eq!(summary.files_copied, 0, "no files match *.txt");
4742 assert_eq!(
4743 summary.directories_created, 1,
4744 "root directory should be counted in dry-run"
4745 );
4746 assert!(
4747 !dst_path.exists(),
4748 "nothing should be created in dry-run mode"
4749 );
4750 Ok(())
4751 }
4752 }
4753
4754 mod max_open_files_tests {
4756 use super::*;
4757
4758 #[tokio::test]
4761 #[traced_test]
4762 async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
4763 let tmp_dir = testutils::create_temp_dir().await?;
4764 let src = tmp_dir.join("src");
4765 let dst = tmp_dir.join("dst");
4766 tokio::fs::create_dir(&src).await?;
4767 let file_count = 200;
4768 for i in 0..file_count {
4769 tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
4770 }
4771 throttle::set_max_open_files(4);
4773 let summary = copy(
4774 &PROGRESS,
4775 &src,
4776 &dst,
4777 &Settings {
4778 dereference: false,
4779 fail_early: true,
4780 overwrite: false,
4781 overwrite_compare: Default::default(),
4782 overwrite_filter: None,
4783 ignore_existing: false,
4784 chunk_size: 0,
4785 skip_specials: false,
4786 remote_copy_buffer_size: 0,
4787 filter: None,
4788 dry_run: None,
4789 delete: None,
4790 },
4791 &NO_PRESERVE_SETTINGS,
4792 false,
4793 )
4794 .await?;
4795 assert_eq!(summary.files_copied, file_count);
4796 assert_eq!(summary.directories_created, 1);
4797 for i in 0..file_count {
4798 let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
4799 assert_eq!(content, format!("content-{}", i));
4800 }
4801 Ok(())
4802 }
4803
4804 #[tokio::test]
4807 #[traced_test]
4808 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
4809 let tmp_dir = testutils::create_temp_dir().await?;
4810 let src = tmp_dir.join("src");
4811 let dst = tmp_dir.join("dst");
4812 let depth = 20;
4813 let files_per_level = 5;
4814 let limit = 4;
4815 let mut dir = src.clone();
4817 for level in 0..depth {
4818 tokio::fs::create_dir_all(&dir).await?;
4819 for f in 0..files_per_level {
4820 tokio::fs::write(
4821 dir.join(format!("f{}_{}.txt", level, f)),
4822 format!("L{}F{}", level, f),
4823 )
4824 .await?;
4825 }
4826 dir = dir.join(format!("d{}", level));
4827 }
4828 throttle::set_max_open_files(limit);
4829 let summary = tokio::time::timeout(
4830 std::time::Duration::from_secs(30),
4831 copy(
4832 &PROGRESS,
4833 &src,
4834 &dst,
4835 &Settings {
4836 dereference: false,
4837 fail_early: true,
4838 overwrite: false,
4839 overwrite_compare: Default::default(),
4840 overwrite_filter: None,
4841 ignore_existing: false,
4842 chunk_size: 0,
4843 skip_specials: false,
4844 remote_copy_buffer_size: 0,
4845 filter: None,
4846 dry_run: None,
4847 delete: None,
4848 },
4849 &NO_PRESERVE_SETTINGS,
4850 false,
4851 ),
4852 )
4853 .await
4854 .context("copy timed out — possible deadlock")?
4855 .context("copy failed")?;
4856 assert_eq!(summary.files_copied, depth * files_per_level);
4857 assert_eq!(summary.directories_created, depth);
4858 let mut check_dir = dst.clone();
4860 for level in 0..depth {
4861 let content =
4862 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
4863 assert_eq!(content, format!("L{}F0", level));
4864 check_dir = check_dir.join(format!("d{}", level));
4865 }
4866 Ok(())
4867 }
4868
4869 #[tokio::test]
4879 #[traced_test]
4880 async fn parallel_overwrite_dir_with_file_no_deadlock() -> Result<(), anyhow::Error> {
4881 let tmp_dir = testutils::create_temp_dir().await?;
4882 let src = tmp_dir.join("src");
4883 let dst = tmp_dir.join("dst");
4884 tokio::fs::create_dir(&src).await?;
4885 tokio::fs::create_dir(&dst).await?;
4886 let n = 8;
4890 for i in 0..n {
4891 tokio::fs::write(src.join(format!("e{}", i)), format!("file-{}", i)).await?;
4892 let dst_subdir = dst.join(format!("e{}", i));
4893 tokio::fs::create_dir(&dst_subdir).await?;
4894 for j in 0..3 {
4895 tokio::fs::write(
4896 dst_subdir.join(format!("inner_{}.txt", j)),
4897 format!("inner-{}-{}", i, j),
4898 )
4899 .await?;
4900 }
4901 }
4902 throttle::set_max_open_files(2);
4906 let summary = tokio::time::timeout(
4907 std::time::Duration::from_secs(30),
4908 copy(
4909 &PROGRESS,
4910 &src,
4911 &dst,
4912 &Settings {
4913 dereference: false,
4914 fail_early: true,
4915 overwrite: true,
4916 overwrite_compare: Default::default(),
4917 overwrite_filter: None,
4918 ignore_existing: false,
4919 chunk_size: 0,
4920 skip_specials: false,
4921 remote_copy_buffer_size: 0,
4922 filter: None,
4923 dry_run: None,
4924 delete: None,
4925 },
4926 &NO_PRESERVE_SETTINGS,
4927 false,
4928 ),
4929 )
4930 .await
4931 .context(
4932 "copy timed out — deadlock between copy_file's open-files permit and inner rm",
4933 )?
4934 .context("copy failed")?;
4935 assert_eq!(summary.files_copied, n);
4936 assert_eq!(summary.rm_summary.files_removed, n * 3);
4937 assert_eq!(summary.rm_summary.directories_removed, n);
4938 for i in 0..n {
4939 let path = dst.join(format!("e{}", i));
4940 let content = tokio::fs::read_to_string(&path).await?;
4941 assert_eq!(content, format!("file-{}", i));
4942 }
4943 Ok(())
4944 }
4945 }
4946
4947 mod skip_specials_tests {
4948 use super::*;
4949
4950 #[tokio::test]
4951 #[traced_test]
4952 async fn skip_specials_skips_socket_in_directory() -> Result<(), anyhow::Error> {
4953 let tmp_dir = testutils::setup_test_dir().await?;
4954 let test_path = tmp_dir.as_path();
4955 let src = test_path.join("src_dir");
4956 let dst = test_path.join("dst_dir");
4957 tokio::fs::create_dir(&src).await?;
4958 tokio::fs::write(src.join("file.txt"), "hello").await?;
4959 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
4961 let summary = copy(
4962 &PROGRESS,
4963 &src,
4964 &dst,
4965 &Settings {
4966 dereference: false,
4967 fail_early: false,
4968 overwrite: false,
4969 overwrite_compare: Default::default(),
4970 overwrite_filter: None,
4971 ignore_existing: false,
4972 chunk_size: 0,
4973 skip_specials: true,
4974 remote_copy_buffer_size: 0,
4975 filter: None,
4976 dry_run: None,
4977 delete: None,
4978 },
4979 &NO_PRESERVE_SETTINGS,
4980 false,
4981 )
4982 .await?;
4983 assert_eq!(summary.files_copied, 1);
4984 assert_eq!(summary.specials_skipped, 1);
4985 assert!(dst.join("file.txt").exists());
4986 assert!(!dst.join("test.sock").exists());
4987 Ok(())
4988 }
4989
4990 #[tokio::test]
4991 #[traced_test]
4992 async fn skip_specials_skips_fifo_in_directory() -> Result<(), anyhow::Error> {
4993 let tmp_dir = testutils::setup_test_dir().await?;
4994 let test_path = tmp_dir.as_path();
4995 let src = test_path.join("src_dir");
4996 let dst = test_path.join("dst_dir");
4997 tokio::fs::create_dir(&src).await?;
4998 tokio::fs::write(src.join("file.txt"), "hello").await?;
4999 nix::unistd::mkfifo(
5001 &src.join("test.fifo"),
5002 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
5003 )?;
5004 let summary = copy(
5005 &PROGRESS,
5006 &src,
5007 &dst,
5008 &Settings {
5009 dereference: false,
5010 fail_early: false,
5011 overwrite: false,
5012 overwrite_compare: Default::default(),
5013 overwrite_filter: None,
5014 ignore_existing: false,
5015 chunk_size: 0,
5016 skip_specials: true,
5017 remote_copy_buffer_size: 0,
5018 filter: None,
5019 dry_run: None,
5020 delete: None,
5021 },
5022 &NO_PRESERVE_SETTINGS,
5023 false,
5024 )
5025 .await?;
5026 assert_eq!(summary.files_copied, 1);
5027 assert_eq!(summary.specials_skipped, 1);
5028 assert!(dst.join("file.txt").exists());
5029 assert!(!dst.join("test.fifo").exists());
5030 Ok(())
5031 }
5032
5033 #[tokio::test]
5034 #[traced_test]
5035 async fn special_file_errors_without_skip_specials() -> Result<(), anyhow::Error> {
5036 let tmp_dir = testutils::setup_test_dir().await?;
5037 let test_path = tmp_dir.as_path();
5038 let src = test_path.join("src_dir");
5039 let dst = test_path.join("dst_dir");
5040 tokio::fs::create_dir(&src).await?;
5041 tokio::fs::write(src.join("file.txt"), "hello").await?;
5042 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
5043 let result = copy(
5044 &PROGRESS,
5045 &src,
5046 &dst,
5047 &Settings {
5048 dereference: false,
5049 fail_early: false,
5050 overwrite: false,
5051 overwrite_compare: Default::default(),
5052 overwrite_filter: None,
5053 ignore_existing: false,
5054 chunk_size: 0,
5055 skip_specials: false,
5056 remote_copy_buffer_size: 0,
5057 filter: None,
5058 dry_run: None,
5059 delete: None,
5060 },
5061 &NO_PRESERVE_SETTINGS,
5062 false,
5063 )
5064 .await;
5065 assert!(result.is_err());
5066 let err = result.unwrap_err();
5067 assert!(
5068 format!("{:#}", err).contains("unsupported src file type"),
5069 "error should mention unsupported file type, got: {:#}",
5070 err
5071 );
5072 Ok(())
5073 }
5074
5075 #[tokio::test]
5076 #[traced_test]
5077 async fn skip_specials_top_level_socket() -> Result<(), anyhow::Error> {
5078 let tmp_dir = testutils::setup_test_dir().await?;
5079 let test_path = tmp_dir.as_path();
5080 let src_socket = test_path.join("test.sock");
5081 let dst = test_path.join("dst.sock");
5082 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
5083 let summary = copy(
5084 &PROGRESS,
5085 &src_socket,
5086 &dst,
5087 &Settings {
5088 dereference: false,
5089 fail_early: false,
5090 overwrite: false,
5091 overwrite_compare: Default::default(),
5092 overwrite_filter: None,
5093 ignore_existing: false,
5094 chunk_size: 0,
5095 skip_specials: true,
5096 remote_copy_buffer_size: 0,
5097 filter: None,
5098 dry_run: None,
5099 delete: None,
5100 },
5101 &NO_PRESERVE_SETTINGS,
5102 false,
5103 )
5104 .await?;
5105 assert_eq!(summary.specials_skipped, 1);
5106 assert_eq!(summary.files_copied, 0);
5107 assert!(!dst.exists());
5108 Ok(())
5109 }
5110 }
5111
5112 #[tokio::test]
5113 #[traced_test]
5114 async fn delete_protects_excluded_then_removes_with_delete_excluded()
5115 -> Result<(), anyhow::Error> {
5116 let tmp_dir = testutils::setup_test_dir().await?;
5117 let test_path = tmp_dir.as_path();
5118 let src = test_path.join("foo");
5119 let dst = test_path.join("bar");
5120 copy(
5121 &PROGRESS,
5122 &src,
5123 &dst,
5124 &settings_with_delete(None),
5125 &DO_PRESERVE_SETTINGS,
5126 false,
5127 )
5128 .await?;
5129 tokio::fs::write(dst.join("keep.log"), b"protected").await?;
5131
5132 let mut filter = crate::filter::FilterSettings::new();
5133 filter.add_exclude("*.log")?;
5134
5135 let mut settings = settings_with_delete(delete_on());
5137 settings.filter = Some(filter.clone());
5138 copy(
5139 &PROGRESS,
5140 &src,
5141 &dst,
5142 &settings,
5143 &DO_PRESERVE_SETTINGS,
5144 false,
5145 )
5146 .await?;
5147 assert!(
5148 dst.join("keep.log").exists(),
5149 "*.log must be protected by default"
5150 );
5151
5152 let mut settings = settings_with_delete(Some(DeleteSettings {
5154 delete_excluded: true,
5155 }));
5156 settings.filter = Some(filter);
5157 copy(
5158 &PROGRESS,
5159 &src,
5160 &dst,
5161 &settings,
5162 &DO_PRESERVE_SETTINGS,
5163 false,
5164 )
5165 .await?;
5166 assert!(!dst.join("keep.log").exists());
5167 Ok(())
5168 }
5169
5170 #[tokio::test]
5171 #[traced_test]
5172 async fn delete_dry_run_reports_without_removing() -> Result<(), anyhow::Error> {
5173 let tmp_dir = testutils::setup_test_dir().await?;
5174 let test_path = tmp_dir.as_path();
5175 let src = test_path.join("foo");
5176 let dst = test_path.join("bar");
5177 copy(
5178 &PROGRESS,
5179 &src,
5180 &dst,
5181 &settings_with_delete(None),
5182 &DO_PRESERVE_SETTINGS,
5183 false,
5184 )
5185 .await?;
5186 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
5187
5188 let mut settings = settings_with_delete(delete_on());
5189 settings.dry_run = Some(crate::config::DryRunMode::Brief);
5190 let summary = copy(
5191 &PROGRESS,
5192 &src,
5193 &dst,
5194 &settings,
5195 &DO_PRESERVE_SETTINGS,
5196 false,
5197 )
5198 .await?;
5199
5200 assert!(
5202 dst.join("stale.txt").exists(),
5203 "dry-run must not remove anything"
5204 );
5205 assert_eq!(summary.rm_summary.files_removed, 1);
5207 Ok(())
5208 }
5209
5210 #[tokio::test]
5211 #[traced_test]
5212 async fn delete_does_not_prune_when_source_unreadable() -> Result<(), anyhow::Error> {
5213 let tmp_dir = testutils::setup_test_dir().await?;
5214 let test_path = tmp_dir.as_path();
5215 let src = test_path.join("foo");
5216 let dst = test_path.join("bar");
5217 copy(
5218 &PROGRESS,
5219 &src,
5220 &dst,
5221 &settings_with_delete(None),
5222 &DO_PRESERVE_SETTINGS,
5223 false,
5224 )
5225 .await?;
5226 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
5227 let original = tokio::fs::metadata(&src).await?.permissions();
5229 tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
5230
5231 let result = copy(
5232 &PROGRESS,
5233 &src,
5234 &dst,
5235 &settings_with_delete(delete_on()),
5236 &DO_PRESERVE_SETTINGS,
5237 false,
5238 )
5239 .await;
5240
5241 tokio::fs::set_permissions(&src, original).await?;
5243
5244 assert!(result.is_err(), "unreadable source must error");
5245 assert!(
5246 dst.join("stale.txt").exists(),
5247 "destination must not be pruned when source enumeration fails"
5248 );
5249 Ok(())
5250 }
5251
5252 #[tokio::test]
5257 #[traced_test]
5258 async fn overwrite_regular_file_removes_and_recreates_via_fd() -> Result<(), anyhow::Error> {
5259 let tmp_dir = testutils::create_temp_dir().await?;
5260 let test_path = tmp_dir.as_path();
5261 let src_file = test_path.join("src.txt");
5262 let dst_file = test_path.join("dst.txt");
5263 tokio::fs::write(&src_file, "fresh source content").await?;
5265 tokio::fs::write(&dst_file, "old").await?;
5266 let summary = copy(
5267 &PROGRESS,
5268 &src_file,
5269 &dst_file,
5270 &Settings {
5271 dereference: false,
5272 fail_early: false,
5273 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
5275 size: true,
5276 mtime: true,
5277 ..Default::default()
5278 },
5279 overwrite_filter: None,
5280 ignore_existing: false,
5281 chunk_size: 0,
5282 skip_specials: false,
5283 remote_copy_buffer_size: 0,
5284 filter: None,
5285 dry_run: None,
5286 delete: None,
5287 },
5288 &NO_PRESERVE_SETTINGS,
5289 false,
5290 )
5291 .await?;
5292 assert_eq!(summary.files_copied, 1);
5294 assert_eq!(summary.rm_summary.files_removed, 1);
5295 assert_eq!(summary.rm_summary.symlinks_removed, 0);
5296 assert_eq!(summary.rm_summary.directories_removed, 0);
5297 let content = tokio::fs::read_to_string(&dst_file).await?;
5298 assert_eq!(content, "fresh source content");
5299 Ok(())
5300 }
5301
5302 fn toctou_settings() -> Settings {
5307 Settings {
5308 dereference: false,
5309 fail_early: false,
5310 overwrite: true,
5311 overwrite_compare: filecmp::MetadataCmpSettings {
5312 size: true,
5313 mtime: true,
5314 ..Default::default()
5315 },
5316 overwrite_filter: None,
5317 ignore_existing: false,
5318 chunk_size: 0,
5319 skip_specials: false,
5320 remote_copy_buffer_size: 0,
5321 filter: None,
5322 dry_run: None,
5323 delete: None,
5324 }
5325 }
5326
5327 #[tokio::test]
5333 #[traced_test]
5334 async fn non_dereference_copy_never_canonicalizes() -> Result<(), anyhow::Error> {
5335 let tmp_dir = testutils::create_temp_dir().await?;
5336 let test_path = tmp_dir.as_path();
5337 let src = test_path.join("src");
5338 let dst = test_path.join("dst");
5339 tokio::fs::create_dir(&src).await?;
5340 tokio::fs::write(src.join("real.txt"), "REAL_CONTENT").await?;
5342 tokio::fs::symlink("real.txt", src.join("link")).await?;
5343 let summary = copy(
5344 &PROGRESS,
5345 &src,
5346 &dst,
5347 &toctou_settings(),
5348 &NO_PRESERVE_SETTINGS,
5349 false,
5350 )
5351 .await?;
5352 assert_eq!(summary.files_copied, 1);
5353 assert_eq!(
5354 summary.symlinks_created, 1,
5355 "symlink must be copied as a symlink"
5356 );
5357 let link_md = tokio::fs::symlink_metadata(dst.join("link")).await?;
5359 assert!(
5360 link_md.file_type().is_symlink(),
5361 "with dereference == false the source symlink must remain a symlink in the destination"
5362 );
5363 let target = tokio::fs::read_link(dst.join("link")).await?;
5365 assert_eq!(target, std::path::Path::new("real.txt"));
5366 Ok(())
5367 }
5368
5369 fn spawn_file_symlink_swapper(
5375 dir: std::path::PathBuf,
5376 entry_name: &'static str,
5377 sentinel: std::path::PathBuf,
5378 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
5379 ) -> std::thread::JoinHandle<()> {
5380 std::thread::spawn(move || {
5381 let entry = dir.join(entry_name);
5382 let staged_real = dir.join("__staged_real");
5383 let staged_link = dir.join("__staged_link");
5384 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5385 let _ = std::fs::remove_file(&staged_real);
5387 if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
5388 continue;
5389 }
5390 let _ = std::fs::rename(&staged_real, &entry);
5391 let _ = std::fs::remove_file(&staged_link);
5393 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
5394 let _ = std::fs::rename(&staged_link, &entry);
5395 }
5396 })
5397 }
5398
5399 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5404 async fn file_symlink_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
5405 let tmp_dir = testutils::create_temp_dir().await?;
5406 let test_path = tmp_dir.as_path();
5407 let sentinel = test_path.join("sentinel_secret");
5409 tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
5410 let src = test_path.join("src");
5411 let sub = src.join("sub");
5412 tokio::fs::create_dir(&src).await?;
5413 tokio::fs::create_dir(&sub).await?;
5414 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
5415
5416 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5417 let swapper =
5418 spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
5419
5420 let settings = toctou_settings();
5421 let mut caught_swaps = 0usize;
5422 let mut copied_real = 0usize;
5423 for i in 0..200 {
5424 let dst = test_path.join(format!("dst_{i}"));
5425 let result = tokio::time::timeout(
5426 std::time::Duration::from_secs(30),
5427 copy(
5428 &PROGRESS,
5429 &src,
5430 &dst,
5431 &settings,
5432 &NO_PRESERVE_SETTINGS,
5433 false,
5434 ),
5435 )
5436 .await
5437 .expect("copy must not hang under concurrent swapping");
5438 match result {
5439 Ok(_) => {}
5440 Err(_) => caught_swaps += 1, }
5442 let entry_dst = dst.join("sub").join("entry");
5445 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
5446 && md.file_type().is_file()
5447 {
5448 let content = tokio::fs::read_to_string(&entry_dst).await?;
5449 assert_ne!(
5450 content, "SENTINEL_SECRET_CONTENT",
5451 "iteration {i}: sentinel content leaked into the destination as a regular file"
5452 );
5453 assert_eq!(
5454 content, "REAL_CONTENT",
5455 "iteration {i}: a regular destination file must hold the real content"
5456 );
5457 copied_real += 1;
5458 }
5459 let _ = tokio::fs::remove_dir_all(&dst).await;
5460 }
5461
5462 stop.store(true, std::sync::atomic::Ordering::Relaxed);
5463 swapper.join().expect("swapper thread panicked");
5464 tracing::info!("file/symlink swap: caught_swaps={caught_swaps}, copied_real={copied_real}");
5468 assert!(
5469 caught_swaps + copied_real > 0,
5470 "expected at least one observable outcome across 200 iterations"
5471 );
5472 Ok(())
5473 }
5474
5475 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5480 async fn intermediate_dir_swap_never_follows_symlink() -> Result<(), anyhow::Error> {
5481 let tmp_dir = testutils::create_temp_dir().await?;
5482 let test_path = tmp_dir.as_path();
5483 let outside = test_path.join("outside_dir");
5485 tokio::fs::create_dir(&outside).await?;
5486 tokio::fs::write(outside.join("sentinel.txt"), "SENTINEL_SECRET_CONTENT").await?;
5487 let src = test_path.join("src");
5488 tokio::fs::create_dir(&src).await?;
5489 let real_sub = src.join("sub");
5491 tokio::fs::create_dir(&real_sub).await?;
5492 tokio::fs::write(real_sub.join("real.txt"), "REAL_CONTENT").await?;
5493
5494 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5495 let swapper = {
5496 let src = src.clone();
5497 let outside = outside.clone();
5498 let stop = stop.clone();
5499 std::thread::spawn(move || {
5500 let sub = src.join("sub");
5501 let staged_dir = src.join("__staged_sub_dir");
5502 let staged_link = src.join("__staged_sub_link");
5503 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5504 let _ = std::fs::remove_dir_all(&staged_dir);
5506 if std::fs::create_dir(&staged_dir).is_ok() {
5507 let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL_CONTENT");
5508 let _ = std::fs::remove_dir_all(&sub);
5512 let _ = std::fs::remove_file(&sub);
5513 let _ = std::fs::rename(&staged_dir, &sub);
5514 }
5515 let _ = std::fs::remove_file(&staged_link);
5517 if std::os::unix::fs::symlink(&outside, &staged_link).is_ok() {
5518 let _ = std::fs::remove_dir_all(&sub);
5519 let _ = std::fs::remove_file(&sub);
5520 let _ = std::fs::rename(&staged_link, &sub);
5521 }
5522 }
5523 })
5524 };
5525
5526 let settings = toctou_settings();
5527 for i in 0..200 {
5528 let dst = test_path.join(format!("dst_{i}"));
5529 let _ = tokio::time::timeout(
5530 std::time::Duration::from_secs(30),
5531 copy(
5532 &PROGRESS,
5533 &src,
5534 &dst,
5535 &settings,
5536 &NO_PRESERVE_SETTINGS,
5537 false,
5538 ),
5539 )
5540 .await
5541 .expect("copy must not hang under concurrent dir swapping");
5542 let sub_dst = dst.join("sub");
5553 if let Ok(sub_md) = tokio::fs::symlink_metadata(&sub_dst).await
5554 && sub_md.file_type().is_dir()
5555 {
5556 let leaked = sub_dst.join("sentinel.txt");
5557 let leaked_is_real_file = tokio::fs::symlink_metadata(&leaked)
5558 .await
5559 .map(|m| m.file_type().is_file())
5560 .unwrap_or(false);
5561 assert!(
5562 !leaked_is_real_file,
5563 "iteration {i}: intermediate-dir symlink was followed; the outside \
5564 sentinel was copied into a real destination directory"
5565 );
5566 let real_dst = sub_dst.join("real.txt");
5568 if let Ok(content) = tokio::fs::read_to_string(&real_dst).await {
5569 assert_eq!(
5570 content, "REAL_CONTENT",
5571 "iteration {i}: copied file must hold real content"
5572 );
5573 }
5574 }
5575 let _ = tokio::fs::remove_dir_all(&dst).await;
5576 }
5577
5578 stop.store(true, std::sync::atomic::Ordering::Relaxed);
5579 swapper.join().expect("dir swapper thread panicked");
5580 Ok(())
5581 }
5582
5583 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5601 async fn dest_nonempty_dir_overwrite_removal_contained_under_intermediate_swap()
5602 -> Result<(), anyhow::Error> {
5603 const N: usize = 8;
5604 let tmp_dir = testutils::create_temp_dir().await?;
5605 let test_path = tmp_dir.as_path();
5606 let out_of_tree = test_path.join("out_of_tree");
5611 let oot_deeper = out_of_tree.join("deeper");
5612 for k in 0..N {
5613 let oot_sub = oot_deeper.join(format!("sub{k}"));
5614 tokio::fs::create_dir_all(&oot_sub).await?;
5615 tokio::fs::write(oot_sub.join("sentinel.txt"), "SENTINEL").await?;
5616 }
5617
5618 let src = test_path.join("src");
5620 let src_deeper = src.join("mid").join("deeper");
5621 tokio::fs::create_dir_all(&src_deeper).await?;
5622 for k in 0..N {
5623 tokio::fs::write(src_deeper.join(format!("sub{k}")), "REAL_FILE_CONTENT").await?;
5624 }
5625
5626 let dst = test_path.join("dst");
5628 tokio::fs::create_dir(&dst).await?;
5629
5630 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5631 let swapper = {
5632 let dst = dst.clone();
5633 let out_of_tree = out_of_tree.clone();
5634 let stop = stop.clone();
5635 std::thread::spawn(move || {
5636 let mid = dst.join("mid");
5637 let staged_dir = dst.join("__staged_mid_dir");
5638 let staged_link = dst.join("__staged_mid_link");
5639 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5640 let _ = std::fs::remove_dir_all(&staged_dir);
5644 let staged_deeper = staged_dir.join("deeper");
5645 if std::fs::create_dir_all(&staged_deeper).is_ok() {
5646 for k in 0..N {
5647 let s = staged_deeper.join(format!("sub{k}"));
5648 if std::fs::create_dir(&s).is_ok() {
5649 let _ = std::fs::write(s.join("inner.txt"), b"INNER_CONTENT");
5650 }
5651 }
5652 let _ = std::fs::remove_dir_all(&mid);
5653 let _ = std::fs::remove_file(&mid);
5654 let _ = std::fs::rename(&staged_dir, &mid);
5655 }
5656 let _ = std::fs::remove_file(&staged_link);
5658 if std::os::unix::fs::symlink(&out_of_tree, &staged_link).is_ok() {
5659 let _ = std::fs::remove_dir_all(&mid);
5660 let _ = std::fs::remove_file(&mid);
5661 let _ = std::fs::rename(&staged_link, &mid);
5662 }
5663 }
5664 })
5665 };
5666
5667 let settings = toctou_settings();
5668 for i in 0..200 {
5669 let _ = tokio::time::timeout(
5670 std::time::Duration::from_secs(30),
5671 copy(
5672 &PROGRESS,
5673 &src,
5674 &dst,
5675 &settings,
5676 &NO_PRESERVE_SETTINGS,
5677 false,
5678 ),
5679 )
5680 .await
5681 .expect("copy must not hang under concurrent intermediate-component swapping");
5682 for k in 0..N {
5688 assert!(
5689 oot_deeper
5690 .join(format!("sub{k}"))
5691 .join("sentinel.txt")
5692 .exists(),
5693 "iteration {i}: out-of-tree sentinel sub{k} was deleted — the non-empty-\
5694 directory overwrite removal escaped the destination tree through a swapped \
5695 intermediate symlink (path-based rm regression)"
5696 );
5697 }
5698 }
5699
5700 stop.store(true, std::sync::atomic::Ordering::Relaxed);
5701 swapper
5702 .join()
5703 .expect("intermediate-swap swapper thread panicked");
5704 assert!(
5706 oot_deeper.join("sub0").is_dir(),
5707 "out-of-tree sub directory must survive"
5708 );
5709 Ok(())
5710 }
5711
5712 #[tokio::test]
5722 #[traced_test]
5723 async fn nonempty_dir_overwrite_removal_uses_pinned_fd_not_path() -> Result<(), anyhow::Error> {
5724 let tmp_dir = testutils::create_temp_dir().await?;
5725 let test_path = tmp_dir.as_path();
5726
5727 let real_deeper = test_path.join("base").join("realdeeper");
5729 let real_victim = real_deeper.join("victim");
5730 tokio::fs::create_dir_all(&real_victim).await?;
5731 tokio::fs::write(real_victim.join("inner.txt"), "INNER").await?;
5732
5733 let out_of_tree = test_path.join("out_of_tree");
5736 let oot_victim = out_of_tree.join("realdeeper").join("victim");
5737 tokio::fs::create_dir_all(&oot_victim).await?;
5738 tokio::fs::write(oot_victim.join("sentinel.txt"), "SENTINEL").await?;
5739
5740 let mid_link = test_path.join("base").join("mid");
5744 tokio::fs::symlink(&out_of_tree, &mid_link).await?;
5745 let stale_dst_path = mid_link.join("realdeeper").join("victim");
5746
5747 let dst_parent =
5749 Arc::new(Dir::open_root_dir(&real_deeper, false, congestion::Side::Destination).await?);
5750 let victim_name: &OsStr = OsStr::new("victim");
5751 let dst_handle = dst_parent.child(victim_name).await?;
5752 assert_eq!(dst_handle.kind(), EntryKind::Dir);
5753
5754 let settings = toctou_settings();
5755 let summary = remove_existing(
5756 &PROGRESS,
5757 &dst_parent,
5758 victim_name,
5759 &stale_dst_path,
5760 &dst_handle,
5761 &settings,
5762 )
5763 .await?;
5764 assert_eq!(
5765 summary.directories_removed, 1,
5766 "the real non-empty victim directory should have been removed via the pinned fd"
5767 );
5768
5769 assert!(
5771 !real_victim.exists(),
5772 "the real non-empty victim should have been removed through the pinned parent fd"
5773 );
5774 assert!(
5776 oot_victim.join("sentinel.txt").exists(),
5777 "out-of-tree sentinel was deleted — removal re-resolved the display path through the \
5778 intermediate symlink instead of using the pinned fd (path-based rm regression)"
5779 );
5780 Ok(())
5781 }
5782
5783 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5788 async fn fifo_swap_never_hangs_or_copies_pipe() -> Result<(), anyhow::Error> {
5789 let tmp_dir = testutils::create_temp_dir().await?;
5790 let test_path = tmp_dir.as_path();
5791 let src = test_path.join("src");
5792 let sub = src.join("sub");
5793 tokio::fs::create_dir(&src).await?;
5794 tokio::fs::create_dir(&sub).await?;
5795 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
5796
5797 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5798 let swapper = {
5799 let sub = sub.clone();
5800 let stop = stop.clone();
5801 std::thread::spawn(move || {
5802 let entry = sub.join("entry");
5803 let staged_real = sub.join("__staged_real");
5804 let staged_fifo = sub.join("__staged_fifo");
5805 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5806 let _ = std::fs::remove_file(&staged_real);
5808 if std::fs::write(&staged_real, b"REAL_CONTENT").is_ok() {
5809 let _ = std::fs::rename(&staged_real, &entry);
5810 }
5811 let _ = std::fs::remove_file(&staged_fifo);
5814 let made = nix::unistd::mkfifo(
5815 &staged_fifo,
5816 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
5817 )
5818 .is_ok();
5819 if made {
5820 let _ = std::fs::rename(&staged_fifo, &entry);
5821 }
5822 }
5823 })
5824 };
5825
5826 let settings = toctou_settings();
5827 for i in 0..200 {
5828 let dst = test_path.join(format!("dst_{i}"));
5829 let _ = tokio::time::timeout(
5832 std::time::Duration::from_secs(30),
5833 copy(
5834 &PROGRESS,
5835 &src,
5836 &dst,
5837 &settings,
5838 &NO_PRESERVE_SETTINGS,
5839 false,
5840 ),
5841 )
5842 .await
5843 .expect("copy must not hang when an entry is swapped to a FIFO");
5844 let entry_dst = dst.join("sub").join("entry");
5848 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await {
5849 use std::os::unix::fs::FileTypeExt as _;
5850 let ft = md.file_type();
5851 assert!(
5852 !ft.is_fifo(),
5853 "iteration {i}: a FIFO was reproduced at the destination"
5854 );
5855 if ft.is_file() {
5856 let content = tokio::fs::read_to_string(&entry_dst).await?;
5857 assert_eq!(
5858 content, "REAL_CONTENT",
5859 "iteration {i}: a regular destination file must hold the real content"
5860 );
5861 }
5862 }
5863 let _ = tokio::fs::remove_dir_all(&dst).await;
5864 }
5865
5866 stop.store(true, std::sync::atomic::Ordering::Relaxed);
5867 swapper.join().expect("fifo swapper thread panicked");
5868 Ok(())
5869 }
5870
5871 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5875 async fn copy_deep_narrow_tree_completes() -> Result<(), anyhow::Error> {
5876 let tmp_dir = testutils::create_temp_dir().await?;
5877 let test_path = tmp_dir.as_path();
5878 let src = test_path.join("src");
5879 const DEPTH: usize = 800;
5880 let mut cur = src.clone();
5882 for _ in 0..DEPTH {
5883 cur = cur.join("a");
5884 }
5885 tokio::fs::create_dir_all(&cur).await?;
5886 tokio::fs::write(cur.join("leaf.txt"), "DEEP_LEAF").await?;
5887
5888 let dst = test_path.join("dst");
5889 let summary = tokio::time::timeout(
5890 std::time::Duration::from_secs(120),
5891 copy(
5892 &PROGRESS,
5893 &src,
5894 &dst,
5895 &toctou_settings(),
5896 &NO_PRESERVE_SETTINGS,
5897 false,
5898 ),
5899 )
5900 .await
5901 .expect("deep-narrow copy must not hang (fd budget / hold-and-wait)")?;
5902 assert_eq!(
5903 summary.files_copied, 1,
5904 "the single leaf file must be copied"
5905 );
5906 assert_eq!(summary.directories_created, DEPTH + 1);
5908 let mut leaf = dst.clone();
5910 for _ in 0..DEPTH {
5911 leaf = leaf.join("a");
5912 }
5913 let content = tokio::fs::read_to_string(leaf.join("leaf.txt")).await?;
5914 assert_eq!(content, "DEEP_LEAF");
5915 Ok(())
5916 }
5917
5918 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5922 async fn copy_wide_tree_completes() -> Result<(), anyhow::Error> {
5923 let tmp_dir = testutils::create_temp_dir().await?;
5924 let test_path = tmp_dir.as_path();
5925 let src = test_path.join("src");
5926 tokio::fs::create_dir(&src).await?;
5927 const WIDTH: usize = 2000;
5928 for i in 0..WIDTH {
5929 tokio::fs::write(src.join(format!("f{i}.txt")), format!("content {i}")).await?;
5930 }
5931 let dst = test_path.join("dst");
5932 let summary = tokio::time::timeout(
5933 std::time::Duration::from_secs(120),
5934 copy(
5935 &PROGRESS,
5936 &src,
5937 &dst,
5938 &toctou_settings(),
5939 &NO_PRESERVE_SETTINGS,
5940 false,
5941 ),
5942 )
5943 .await
5944 .expect("wide copy must not hang (fd exhaustion / permit deadlock)")?;
5945 assert_eq!(summary.files_copied, WIDTH, "every file must be copied");
5946 for i in [0usize, WIDTH / 2, WIDTH - 1] {
5948 let content = tokio::fs::read_to_string(dst.join(format!("f{i}.txt"))).await?;
5949 assert_eq!(content, format!("content {i}"));
5950 }
5951 Ok(())
5952 }
5953
5954 #[derive(Clone, Debug)]
5956 struct CmpMeta {
5957 uid: u32,
5958 gid: u32,
5959 mode: u32,
5960 size: u64,
5961 mtime: i64,
5962 }
5963 impl crate::preserve::Metadata for CmpMeta {
5964 fn uid(&self) -> u32 {
5965 self.uid
5966 }
5967 fn gid(&self) -> u32 {
5968 self.gid
5969 }
5970 fn atime(&self) -> i64 {
5971 0
5972 }
5973 fn atime_nsec(&self) -> i64 {
5974 0
5975 }
5976 fn mtime(&self) -> i64 {
5977 self.mtime
5978 }
5979 fn mtime_nsec(&self) -> i64 {
5980 0
5981 }
5982 fn permissions(&self) -> std::fs::Permissions {
5983 std::os::unix::fs::PermissionsExt::from_mode(self.mode)
5984 }
5985 fn size(&self) -> u64 {
5986 self.size
5987 }
5988 }
5989
5990 fn size_mtime() -> filecmp::MetadataCmpSettings {
5991 filecmp::MetadataCmpSettings {
5992 size: true,
5993 mtime: true,
5994 ..Default::default()
5995 }
5996 }
5997 fn base() -> CmpMeta {
5998 CmpMeta {
5999 uid: 0,
6000 gid: 0,
6001 mode: 0o644,
6002 size: 10,
6003 mtime: 100,
6004 }
6005 }
6006
6007 #[test]
6008 fn skip_send_no_existing_entry_sends() {
6009 let src = base();
6010 let r = skip_unchanged_send(
6011 &size_mtime(),
6012 None,
6013 false,
6014 &src,
6015 None::<ExistingDst<CmpMeta>>,
6016 );
6017 assert!(!r);
6018 }
6019
6020 #[test]
6021 fn skip_send_ignore_existing_skips_any_type() {
6022 let src = base();
6023 let dst = base();
6024 let r = skip_unchanged_send(
6025 &size_mtime(),
6026 None,
6027 true,
6028 &src,
6029 Some(ExistingDst {
6030 meta: &dst,
6031 is_file: false,
6032 }),
6033 );
6034 assert!(r);
6035 }
6036
6037 #[test]
6038 fn skip_send_overwrite_identical_skips() {
6039 let src = base();
6040 let dst = base();
6041 let r = skip_unchanged_send(
6042 &size_mtime(),
6043 None,
6044 false,
6045 &src,
6046 Some(ExistingDst {
6047 meta: &dst,
6048 is_file: true,
6049 }),
6050 );
6051 assert!(r);
6052 }
6053
6054 #[test]
6055 fn skip_send_overwrite_different_size_sends() {
6056 let src = base();
6057 let dst = CmpMeta { size: 11, ..base() };
6058 let r = skip_unchanged_send(
6059 &size_mtime(),
6060 None,
6061 false,
6062 &src,
6063 Some(ExistingDst {
6064 meta: &dst,
6065 is_file: true,
6066 }),
6067 );
6068 assert!(!r);
6069 }
6070
6071 #[test]
6072 fn skip_send_overwrite_different_mtime_sends() {
6073 let src = base();
6074 let dst = CmpMeta {
6075 mtime: 200,
6076 ..base()
6077 };
6078 let r = skip_unchanged_send(
6079 &size_mtime(),
6080 None,
6081 false,
6082 &src,
6083 Some(ExistingDst {
6084 meta: &dst,
6085 is_file: true,
6086 }),
6087 );
6088 assert!(!r);
6089 }
6090
6091 #[test]
6092 fn skip_send_overwrite_non_file_sends() {
6093 let src = base();
6094 let dst = base();
6095 let r = skip_unchanged_send(
6096 &size_mtime(),
6097 None,
6098 false,
6099 &src,
6100 Some(ExistingDst {
6101 meta: &dst,
6102 is_file: false,
6103 }),
6104 );
6105 assert!(!r);
6106 }
6107
6108 #[test]
6109 fn skip_send_filter_newer_skips_when_dest_newer() {
6110 let src = CmpMeta {
6111 mtime: 100,
6112 size: 5,
6113 ..base()
6114 };
6115 let dst = CmpMeta {
6116 mtime: 200,
6117 size: 11,
6118 ..base()
6119 };
6120 let r = skip_unchanged_send(
6121 &size_mtime(),
6122 Some(OverwriteFilter::Newer),
6123 false,
6124 &src,
6125 Some(ExistingDst {
6126 meta: &dst,
6127 is_file: true,
6128 }),
6129 );
6130 assert!(r);
6131 }
6132
6133 #[test]
6134 fn skip_send_filter_newer_sends_when_dest_older() {
6135 let src = CmpMeta {
6136 mtime: 200,
6137 size: 5,
6138 ..base()
6139 };
6140 let dst = CmpMeta {
6141 mtime: 100,
6142 size: 11,
6143 ..base()
6144 };
6145 let r = skip_unchanged_send(
6146 &size_mtime(),
6147 Some(OverwriteFilter::Newer),
6148 false,
6149 &src,
6150 Some(ExistingDst {
6151 meta: &dst,
6152 is_file: true,
6153 }),
6154 );
6155 assert!(!r);
6156 }
6157}