1use std::ffi::{OsStr, OsString};
2use std::os::fd::AsFd;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use anyhow::{Context, anyhow};
7use throttle::get_file_iops_tokens;
8use tracing::instrument;
9
10use crate::config::DryRunMode;
11use crate::copy_data::copy_file_range_all;
12use crate::filecmp;
13use crate::preserve;
14use crate::progress;
15use crate::rm;
16use crate::rm::{Settings as RmSettings, Summary as RmSummary};
17use crate::safedir::{self, Dir, FileMeta, Handle};
18use crate::walk::{EntryKind, LeafPermit, PermitKind};
19use crate::walk_driver::{
20 DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_entry,
21};
22
23pub type Error = crate::error::OperationError<Summary>;
26
27#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
32pub enum OverwriteFilter {
33 #[value(name = "newer")]
35 Newer,
36}
37
38impl std::fmt::Display for OverwriteFilter {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 OverwriteFilter::Newer => write!(f, "newer"),
42 }
43 }
44}
45
46pub struct ExistingDst<'a, M: crate::preserve::Metadata> {
48 pub meta: &'a M,
50 pub is_file: bool,
54}
55
56pub fn skip_unchanged_send<S, D>(
64 overwrite_compare: &filecmp::MetadataCmpSettings,
65 overwrite_filter: Option<OverwriteFilter>,
66 ignore_existing: bool,
67 src: &S,
68 dst: Option<ExistingDst<'_, D>>,
69) -> bool
70where
71 S: crate::preserve::Metadata + std::fmt::Debug,
72 D: crate::preserve::Metadata + std::fmt::Debug,
73{
74 let Some(dst) = dst else {
75 return false; };
77 if ignore_existing {
78 return true; }
80 if !dst.is_file {
81 return false; }
83 if filecmp::metadata_equal(overwrite_compare, src, dst.meta) {
84 return true; }
86 if overwrite_filter == Some(OverwriteFilter::Newer) && filecmp::dest_is_newer(src, dst.meta) {
87 return true; }
89 false
90}
91
92#[derive(Debug, Clone)]
98pub struct DeleteSettings {
99 pub delete_excluded: bool,
102}
103
104#[derive(Debug, Clone)]
105pub struct Settings {
106 pub dereference: bool,
107 pub fail_early: bool,
108 pub overwrite: bool,
109 pub overwrite_compare: filecmp::MetadataCmpSettings,
110 pub overwrite_filter: Option<OverwriteFilter>,
111 pub ignore_existing: bool,
112 pub chunk_size: u64,
113 pub skip_specials: bool,
115 pub remote_copy_buffer_size: usize,
121 pub filter: Option<crate::filter::FilterSettings>,
123 pub dry_run: Option<crate::config::DryRunMode>,
125 pub delete: Option<DeleteSettings>,
127}
128
129fn skipped_summary_for(kind: EntryKind) -> Summary {
133 match kind {
134 EntryKind::Dir => Summary {
135 directories_skipped: 1,
136 ..Default::default()
137 },
138 EntryKind::Symlink => Summary {
139 symlinks_skipped: 1,
140 ..Default::default()
141 },
142 EntryKind::File | EntryKind::Special => Summary {
143 files_skipped: 1,
144 ..Default::default()
145 },
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum EmptyDirAction {
153 Keep,
155 Remove,
157 DryRunSkip,
159}
160
161pub fn check_empty_dir_cleanup(
176 filter: Option<&crate::filter::FilterSettings>,
177 we_created_dir: bool,
178 anything_copied: bool,
179 relative_path: &std::path::Path,
180 is_root: bool,
181 is_dry_run: bool,
182) -> EmptyDirAction {
183 if filter.is_none() || anything_copied {
185 return EmptyDirAction::Keep;
186 }
187 if !we_created_dir {
189 return EmptyDirAction::Keep;
190 }
191 if is_root {
193 return EmptyDirAction::Keep;
194 }
195 let f = filter.unwrap();
197 if f.directly_matches_include(relative_path, true) {
199 return EmptyDirAction::Keep;
200 }
201 if is_dry_run {
203 EmptyDirAction::DryRunSkip
204 } else {
205 EmptyDirAction::Remove
206 }
207}
208
209#[instrument]
210pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
211 let ft1 = md1.file_type();
212 let ft2 = md2.file_type();
213 ft1.is_dir() == ft2.is_dir()
214 && ft1.is_file() == ft2.is_file()
215 && ft1.is_symlink() == ft2.is_symlink()
216}
217
218#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
219pub struct Summary {
220 pub bytes_copied: u64,
221 pub files_copied: usize,
222 pub symlinks_created: usize,
223 pub directories_created: usize,
224 pub files_unchanged: usize,
225 pub symlinks_unchanged: usize,
226 pub directories_unchanged: usize,
227 pub files_skipped: usize,
228 pub symlinks_skipped: usize,
229 pub directories_skipped: usize,
230 pub specials_skipped: usize,
231 pub rm_summary: RmSummary,
232}
233
234impl std::ops::Add for Summary {
235 type Output = Self;
236 fn add(self, other: Self) -> Self {
237 Self {
238 bytes_copied: self.bytes_copied + other.bytes_copied,
239 files_copied: self.files_copied + other.files_copied,
240 symlinks_created: self.symlinks_created + other.symlinks_created,
241 directories_created: self.directories_created + other.directories_created,
242 files_unchanged: self.files_unchanged + other.files_unchanged,
243 symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
244 directories_unchanged: self.directories_unchanged + other.directories_unchanged,
245 files_skipped: self.files_skipped + other.files_skipped,
246 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
247 directories_skipped: self.directories_skipped + other.directories_skipped,
248 specials_skipped: self.specials_skipped + other.specials_skipped,
249 rm_summary: self.rm_summary + other.rm_summary,
250 }
251 }
252}
253
254impl std::fmt::Display for Summary {
255 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
256 write!(
257 f,
258 "copy:\n\
259 -----\n\
260 bytes copied: {}\n\
261 files copied: {}\n\
262 symlinks created: {}\n\
263 directories created: {}\n\
264 files unchanged: {}\n\
265 symlinks unchanged: {}\n\
266 directories unchanged: {}\n\
267 files skipped: {}\n\
268 symlinks skipped: {}\n\
269 directories skipped: {}\n\
270 specials skipped: {}\n\
271 \n\
272 delete:\n\
273 -------\n\
274 {}",
275 bytesize::ByteSize(self.bytes_copied),
276 self.files_copied,
277 self.symlinks_created,
278 self.directories_created,
279 self.files_unchanged,
280 self.symlinks_unchanged,
281 self.directories_unchanged,
282 self.files_skipped,
283 self.symlinks_skipped,
284 self.directories_skipped,
285 self.specials_skipped,
286 &self.rm_summary,
287 )
288 }
289}
290
291#[instrument(skip(prog_track, settings, preserve))]
294pub async fn copy(
295 prog_track: &'static progress::Progress,
296 src: &std::path::Path,
297 dst: &std::path::Path,
298 settings: &Settings,
299 preserve: &preserve::Settings,
300 is_fresh: bool,
301) -> Result<Summary, Error> {
302 copy_with_filter_base(
303 prog_track,
304 src,
305 dst,
306 settings,
307 preserve,
308 is_fresh,
309 std::path::Path::new(""),
310 )
311 .await
312}
313
314#[instrument(skip(prog_track, settings, preserve))]
325#[allow(clippy::too_many_arguments)]
326pub async fn copy_with_filter_base(
327 prog_track: &'static progress::Progress,
328 src: &std::path::Path,
329 dst: &std::path::Path,
330 settings: &Settings,
331 preserve: &preserve::Settings,
332 is_fresh: bool,
333 filter_base: &std::path::Path,
334) -> Result<Summary, Error> {
335 let src_operand = crate::walk::split_root_operand(src)
339 .await
340 .map_err(|err| Error::new(err, Default::default()))?;
341 let src_parent_path = src_operand.parent.as_path();
342 let src_name = src_operand.name.as_os_str();
343 let src = src_operand.display.as_path();
344 let dst_parent_path_opt: Option<&std::path::Path> = match (dst.parent(), dst.file_name()) {
349 (Some(parent), Some(_)) if parent.as_os_str().is_empty() => Some(std::path::Path::new(".")),
350 (Some(parent), Some(_)) => Some(parent),
351 _ => None,
352 };
353 let strict = crate::safedir::strict_operand_resolution();
363 let strict_src_parent: Option<Arc<Dir>> = if strict {
364 let parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
365 .await
366 .with_context(|| format!("cannot open source parent directory {:?}", src_parent_path))
367 .map_err(|err| Error::new(err, Default::default()))?;
368 Some(Arc::new(parent.into_tree()))
370 } else {
371 None
372 };
373 let strict_dst_parent: Option<Arc<Dir>> = match (strict, dst_parent_path_opt) {
374 (true, Some(dst_parent_path)) => {
375 match Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination).await {
376 Ok(parent) => Some(Arc::new(parent.into_tree())),
377 Err(err)
381 if err.kind() == std::io::ErrorKind::NotFound
382 || err.raw_os_error() == Some(libc::ENOTDIR) =>
383 {
384 None
385 }
386 Err(err) => {
387 return Err(Error::new(
388 anyhow::Error::new(err).context(format!(
389 "cannot open destination parent directory {dst_parent_path:?}"
390 )),
391 Default::default(),
392 ));
393 }
394 }
395 }
396 _ => None,
398 };
399 if let Some(ref filter) = settings.filter {
401 let (kind, is_dir) = match &strict_src_parent {
402 Some(parent) => {
404 let root_handle = parent
405 .child(src_name)
406 .await
407 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
408 .map_err(|err| Error::new(err, Default::default()))?;
409 (root_handle.kind(), root_handle.kind() == EntryKind::Dir)
410 }
411 None => {
412 let src_metadata = crate::walk::run_metadata_probed(
413 congestion::Side::Source,
414 congestion::MetadataOp::Stat,
415 tokio::fs::symlink_metadata(src),
416 )
417 .await
418 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
419 .map_err(|err| Error::new(err, Default::default()))?;
420 (
421 EntryKind::from_metadata(&src_metadata),
422 src_metadata.is_dir(),
423 )
424 }
425 };
426 let result = if filter_base.as_os_str().is_empty() {
430 filter.should_include_root_item(std::path::Path::new(src_name), is_dir)
431 } else {
432 filter.should_include(filter_base, is_dir)
433 };
434 match result {
435 crate::filter::FilterResult::Included => {}
436 result => {
437 if let Some(mode) = settings.dry_run {
438 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
439 }
440 kind.inc_skipped(prog_track);
441 return Ok(skipped_summary_for(kind));
442 }
443 }
444 }
445 let src_parent = match strict_src_parent {
448 Some(parent) => parent,
449 None => {
450 let parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
451 .await
452 .with_context(|| {
453 format!("cannot open source parent directory {:?}", src_parent_path)
454 })
455 .map_err(|err| Error::new(err, Default::default()))?;
456 Arc::new(parent.into_tree())
457 }
458 };
459 let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
463 return Err(Error::new(
464 anyhow!(
465 "copy destination {:?} has no parent directory or file name",
466 dst
467 ),
468 Default::default(),
469 ));
470 };
471 let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
472 std::path::Path::new(".")
473 } else {
474 dst_parent_path
475 };
476 let dst_parent = if settings.dry_run.is_some() {
481 None
482 } else {
483 match strict_dst_parent {
484 Some(parent) => Some(parent),
485 None => {
486 let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
487 .await
488 .with_context(|| {
489 format!(
490 "cannot open destination parent directory {:?}",
491 dst_parent_path
492 )
493 })
494 .map_err(|err| Error::new(err, Default::default()))?;
495 Some(Arc::new(dir.into_tree()))
496 }
497 }
498 };
499 run_copy_root(
500 prog_track,
501 &src_parent,
502 dst_parent,
503 src_name,
504 src,
505 dst,
506 filter_base,
507 settings,
508 preserve,
509 is_fresh,
510 None,
511 )
512 .await
513}
514
515#[instrument(skip(
529 prog_track,
530 src_parent,
531 dst_parent,
532 settings,
533 preserve,
534 open_file_guard
535))]
536#[allow(clippy::too_many_arguments)]
537pub(crate) async fn copy_child(
538 prog_track: &'static progress::Progress,
539 src_parent: &Arc<Dir>,
540 dst_parent: Option<&Arc<Dir>>,
541 name: &OsStr,
542 src_path: &std::path::Path,
543 dst_path: &std::path::Path,
544 filter_base: &std::path::Path,
545 settings: &Settings,
546 preserve: &preserve::Settings,
547 is_fresh: bool,
548 open_file_guard: Option<throttle::OpenFileGuard>,
549) -> Result<Summary, Error> {
550 run_copy_root(
551 prog_track,
552 src_parent,
553 dst_parent.map(Arc::clone),
554 name,
555 src_path,
556 dst_path,
557 filter_base,
558 settings,
559 preserve,
560 is_fresh,
561 open_file_guard.map(LeafPermit::OpenFile),
562 )
563 .await
564}
565
566struct CopyVisitor {
576 prog_track: &'static progress::Progress,
577 dst_root: PathBuf,
582 filter_base: PathBuf,
584 settings: Settings,
585 preserve: preserve::Settings,
586 dst_parent: Option<Arc<Dir>>,
589 root_is_fresh: bool,
591}
592
593#[derive(Clone)]
598struct CopyDirContext {
599 dst_dir: Option<Arc<Dir>>,
600 is_fresh: bool,
601}
602
603struct CopyDirState {
607 dst_dir: Option<Arc<Dir>>,
609 dst_parent: Option<Arc<Dir>>,
612 dst_name: OsString,
613 we_created: bool,
615 src_meta: FileMeta,
617 is_root: bool,
619 base: Summary,
622}
623
624impl CopyVisitor {
625 fn dst_path_for(&self, cx: &EntryCx) -> PathBuf {
629 if cx.rel_path.as_os_str().is_empty() {
630 self.dst_root.clone()
631 } else {
632 self.dst_root.join(&cx.rel_path)
633 }
634 }
635
636 #[allow(clippy::result_large_err)]
644 fn dst_name_for(&self, cx: &EntryCx) -> Result<OsString, Error> {
645 if cx.rel_path.as_os_str().is_empty() {
646 self.dst_root
647 .file_name()
648 .map(OsStr::to_owned)
649 .ok_or_else(|| {
650 Error::new(
651 anyhow!("copy destination {:?} has no file name", &self.dst_root),
652 Default::default(),
653 )
654 })
655 } else {
656 Ok(cx.name.clone())
657 }
658 }
659
660 async fn prune_finished_dir(
671 &self,
672 copy_summary: &mut Summary,
673 child_error: &mut Option<anyhow::Error>,
674 processed: &ProcessedChildren,
675 dst_dir: &Option<Arc<Dir>>,
676 dst_path: &std::path::Path,
677 rel_path: &std::path::Path,
678 ) -> Result<(), Error> {
679 if child_error.is_some() && self.settings.delete.is_some() {
680 tracing::warn!(
681 "skipping --delete pruning of {:?} because the copy reported errors",
682 dst_path
683 );
684 }
685 let Some(delete_settings) = &self.settings.delete else {
686 return Ok(());
687 };
688 if child_error.is_some() {
689 return Ok(());
690 }
691 let keep_set: std::collections::HashSet<OsString> =
695 processed.names().iter().cloned().collect();
696 let relative_dir = self.filter_base.join(rel_path);
697 let prune_dir: Option<Arc<Dir>> = match dst_dir {
713 Some(dir) => Some(Arc::clone(dir)),
714 None => {
715 match Dir::open_root_dir(dst_path, false, congestion::Side::Destination).await {
716 Ok(dir) => Some(Arc::new(dir)),
717 Err(err)
718 if matches!(
719 err.kind(),
720 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
721 ) || err.raw_os_error() == Some(libc::ELOOP)
722 || err.raw_os_error() == Some(libc::ENOTDIR) =>
723 {
724 tracing::debug!(
725 "skipping --delete pruning of {:?}: not a real directory in dry-run",
726 dst_path
727 );
728 None
729 }
730 Err(err) => {
731 let err = anyhow::Error::new(err).context(format!(
732 "cannot open destination {dst_path:?} for delete scan"
733 ));
734 return Err(Error::new(err, *copy_summary));
738 }
739 }
740 }
741 };
742 let Some(prune_dir) = prune_dir else {
743 return Ok(());
744 };
745 match crate::delete::prune_extraneous(
746 self.prog_track,
747 &prune_dir,
748 &relative_dir,
749 &keep_set,
750 self.settings.filter.as_ref(),
751 delete_settings,
752 self.settings.fail_early,
753 self.settings.dry_run,
754 )
755 .await
756 {
757 Ok(rm_summary) => {
758 copy_summary.rm_summary = copy_summary.rm_summary + rm_summary;
759 }
760 Err(err) => {
761 copy_summary.rm_summary = copy_summary.rm_summary + err.summary;
762 if self.settings.fail_early {
763 return Err(Error::new(err.source, *copy_summary));
764 }
765 *child_error = Some(err.source);
769 }
770 }
771 Ok(())
772 }
773
774 async fn finalize_dir(&self, fin: FinalizeDir, cx: &EntryCx) -> Result<Summary, Error> {
780 let FinalizeDir {
781 mut copy_summary,
782 child_error,
783 dst_dir,
784 dst_parent,
785 dst_name,
786 src_meta,
787 we_created,
788 is_root,
789 } = fin;
790 let src_path = &cx.real_path;
791 let dst_path = self.dst_path_for(cx);
792 let rel_path = &cx.rel_path;
793 let this_dir_count = usize::from(we_created);
796 let child_dirs_created = copy_summary
797 .directories_created
798 .saturating_sub(this_dir_count);
799 let anything_copied = copy_summary.files_copied > 0
800 || copy_summary.symlinks_created > 0
801 || child_dirs_created > 0;
802 let relative_path = self.filter_base.join(rel_path);
803 match check_empty_dir_cleanup(
804 self.settings.filter.as_ref(),
805 we_created,
806 anything_copied,
807 &relative_path,
808 is_root,
809 self.settings.dry_run.is_some(),
810 ) {
811 EmptyDirAction::Keep => { }
812 EmptyDirAction::DryRunSkip => {
813 tracing::debug!(
814 "dry-run: directory {:?} would not be created (nothing to copy inside)",
815 dst_path
816 );
817 copy_summary.directories_created = 0;
818 return match child_error {
822 Some(child_error) => Err(Error::new(child_error, copy_summary)),
823 None => Ok(copy_summary),
824 };
825 }
826 EmptyDirAction::Remove => {
827 tracing::debug!(
828 "directory {:?} has nothing to copy inside, removing empty directory",
829 dst_path
830 );
831 let rmdir_result = match &dst_parent {
838 Some(dst_parent) => dst_parent.rmdir_at(&dst_name).await,
839 None => {
840 crate::walk::run_metadata_probed(
841 congestion::Side::Destination,
842 congestion::MetadataOp::RmDir,
843 tokio::fs::remove_dir(&dst_path),
844 )
845 .await
846 }
847 };
848 match rmdir_result {
849 Ok(()) => {
850 copy_summary.directories_created = 0;
851 return match child_error {
856 Some(child_error) => Err(Error::new(child_error, copy_summary)),
857 None => Ok(copy_summary),
858 };
859 }
860 Err(err) => {
861 tracing::debug!(
862 "failed to remove empty directory {:?}: {:#}, keeping",
863 dst_path,
864 &err
865 );
866 }
868 }
869 }
870 }
871 tracing::debug!("set 'dst' directory metadata");
876 let metadata_result = match &dst_dir {
877 Some(dst_dir) => safedir::set_dir_metadata_fd(&self.preserve, &src_meta, dst_dir).await,
878 None => Ok(()),
879 };
880 if let Some(child_error) = child_error {
881 if let Err(metadata_err) = metadata_result {
882 tracing::error!(
883 "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
884 src_path,
885 dst_path,
886 &metadata_err
887 );
888 }
889 return Err(Error::new(child_error, copy_summary));
890 }
891 metadata_result
893 .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
894 .map_err(|err| Error::new(err, copy_summary))?;
895 Ok(copy_summary)
896 }
897}
898
899struct FinalizeDir {
903 copy_summary: Summary,
904 child_error: Option<anyhow::Error>,
905 dst_dir: Option<Arc<Dir>>,
906 dst_parent: Option<Arc<Dir>>,
907 dst_name: OsString,
908 src_meta: FileMeta,
909 we_created: bool,
910 is_root: bool,
911}
912
913#[allow(clippy::too_many_arguments)]
920async fn run_copy_root(
921 prog_track: &'static progress::Progress,
922 src_parent: &Arc<Dir>,
923 dst_parent: Option<Arc<Dir>>,
924 name: &OsStr,
925 src_root: &std::path::Path,
926 dst_root: &std::path::Path,
927 filter_base: &std::path::Path,
928 settings: &Settings,
929 preserve: &preserve::Settings,
930 is_fresh: bool,
931 permit: Option<LeafPermit>,
932) -> Result<Summary, Error> {
933 let visitor = Arc::new(CopyVisitor {
934 prog_track,
935 dst_root: dst_root.to_path_buf(),
936 filter_base: filter_base.to_path_buf(),
937 settings: settings.clone(),
938 preserve: *preserve,
939 dst_parent,
940 root_is_fresh: is_fresh,
941 });
942 let root_cx = EntryCx {
951 parent: Arc::clone(src_parent),
952 name: name.to_owned(),
953 rel_path: PathBuf::new(),
954 filter_path: filter_base.to_path_buf(),
955 real_path: src_root.to_path_buf(),
956 dry_run: settings.dry_run.is_some(),
957 prog_track,
958 };
959 let root_ctx = visitor.root_dir_context();
960 process_entry(visitor, root_cx, root_ctx, permit).await
961}
962
963impl WalkVisitor for CopyVisitor {
964 type Summary = Summary;
965 type DirContext = CopyDirContext;
966 type DirState = CopyDirState;
967
968 fn root_dir_context(&self) -> CopyDirContext {
969 CopyDirContext {
970 dst_dir: self.dst_parent.clone(),
971 is_fresh: self.root_is_fresh,
972 }
973 }
974
975 fn permit_kind(&self) -> PermitKind {
976 PermitKind::OpenFile
978 }
979
980 fn want_permit(&self, hint: Option<EntryKind>) -> bool {
981 hint == Some(EntryKind::File)
985 }
986
987 fn fail_early(&self) -> bool {
988 self.settings.fail_early
989 }
990
991 fn filter(&self) -> Option<&crate::filter::FilterSettings> {
992 self.settings.filter.as_ref()
993 }
994
995 fn on_skip(
996 &self,
997 cx: &EntryCx,
998 kind: EntryKind,
999 skip_result: &crate::filter::FilterResult,
1000 ) -> Summary {
1001 if let Some(mode) = self.settings.dry_run {
1004 crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
1005 }
1006 skipped_summary_for(kind)
1007 }
1008
1009 async fn visit_leaf(
1010 &self,
1011 cx: &EntryCx,
1012 parent_ctx: &CopyDirContext,
1013 handle: Handle,
1014 kind: EntryKind,
1015 permit: Option<LeafPermit>,
1016 ) -> Result<Summary, Error> {
1017 let src_parent = &cx.parent;
1018 let dst_parent = parent_ctx.dst_dir.as_ref();
1019 let name = cx.name.as_os_str();
1020 let src_path = &cx.real_path;
1021 let dst_path = self.dst_path_for(cx);
1022 let dst_name = self.dst_name_for(cx)?;
1023 let is_fresh = parent_ctx.is_fresh;
1024 if self.settings.dereference && kind == EntryKind::Symlink {
1029 drop(permit);
1030 debug_assert!(
1035 self.settings.dereference,
1036 "canonicalize reached with dereference == false; the non-dereference copy path \
1037 must never re-resolve a path"
1038 );
1039 let link = crate::walk::run_metadata_probed(
1040 congestion::Side::Source,
1041 congestion::MetadataOp::Stat,
1042 tokio::fs::canonicalize(src_path),
1043 )
1044 .await
1045 .with_context(|| format!("failed reading src symlink {:?}", src_path))
1046 .map_err(|err| Error::new(err, Default::default()))?;
1047 return copy(
1048 self.prog_track,
1049 &link,
1050 &dst_path,
1051 &self.settings,
1052 &self.preserve,
1053 is_fresh,
1054 )
1055 .await;
1056 }
1057 match kind {
1058 EntryKind::File => {
1059 let _guard = match permit {
1063 Some(p) => p,
1064 None => LeafPermit::OpenFile(throttle::open_file_permit().await),
1065 };
1066 copy_file_fd(
1067 self.prog_track,
1068 src_parent,
1069 dst_parent,
1070 name,
1071 &dst_name,
1072 &dst_path,
1073 src_path,
1074 &handle,
1075 &self.settings,
1076 &self.preserve,
1077 is_fresh,
1078 )
1079 .await
1080 }
1081 EntryKind::Symlink => {
1082 drop(permit);
1083 copy_symlink_fd(
1084 self.prog_track,
1085 src_parent,
1086 dst_parent,
1087 &dst_name,
1088 src_path,
1089 &dst_path,
1090 &handle,
1091 &self.settings,
1092 &self.preserve,
1093 is_fresh,
1094 )
1095 .await
1096 }
1097 EntryKind::Special => {
1098 drop(permit);
1099 if self.settings.skip_specials {
1100 tracing::debug!("skipping special file {:?}", src_path);
1101 if let Some(mode) = self.settings.dry_run {
1102 match mode {
1103 DryRunMode::Brief => {}
1104 DryRunMode::All => println!("skip special {:?}", src_path),
1105 DryRunMode::Explain => {
1106 println!("skip special {:?} (unsupported file type)", src_path);
1107 }
1108 }
1109 }
1110 self.prog_track.specials_skipped.inc();
1111 return Ok(Summary {
1112 specials_skipped: 1,
1113 ..Default::default()
1114 });
1115 }
1116 Err(Error::new(
1117 anyhow!(
1118 "copy: {:?} -> {:?} failed, unsupported src file type",
1119 src_path,
1120 dst_path
1121 ),
1122 Default::default(),
1123 ))
1124 }
1125 EntryKind::Dir => unreachable!("directory entries are handled by dir_pre/dir_post"),
1127 }
1128 }
1129
1130 async fn dir_pre(
1131 &self,
1132 cx: &EntryCx,
1133 parent_ctx: &CopyDirContext,
1134 _handle: &Handle,
1135 ) -> DirPreResult<Self> {
1136 let src_parent = &cx.parent;
1137 let name = cx.name.as_os_str();
1138 let src_path = &cx.real_path;
1139 let dst_path = self.dst_path_for(cx);
1140 let dst_name = self.dst_name_for(cx)?;
1141 let is_root = cx.rel_path.as_os_str().is_empty();
1142 let is_fresh = parent_ctx.is_fresh;
1143 let src_dir = src_parent
1145 .open_dir(name)
1146 .await
1147 .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1148 .map_err(|err| Error::new(err, Default::default()))?;
1149 let src_dir = Arc::new(src_dir);
1150 let src_meta = src_dir
1155 .meta()
1156 .await
1157 .with_context(|| format!("cannot read directory metadata from {:?}", src_path))
1158 .map_err(|err| Error::new(err, Default::default()))?;
1159 if self.settings.dry_run.is_some() {
1160 let dst_nondir_exists = if !self.settings.ignore_existing || is_fresh {
1164 false
1165 } else if crate::safedir::strict_operand_resolution() {
1166 matches!(strict_dry_run_dst_kind(&dst_path).await?, Some(kind) if kind != EntryKind::Dir)
1168 } else {
1169 crate::walk::run_metadata_probed(
1170 congestion::Side::Destination,
1171 congestion::MetadataOp::Stat,
1172 tokio::fs::symlink_metadata(&dst_path),
1173 )
1174 .await
1175 .is_ok()
1176 && !dst_path.is_dir()
1177 };
1178 if dst_nondir_exists {
1179 match self.settings.dry_run {
1180 Some(DryRunMode::Brief) | None => {}
1181 Some(DryRunMode::All) => println!("skip dir {:?}", dst_path),
1182 Some(DryRunMode::Explain) => {
1183 println!(
1184 "skip dir {:?} (destination exists, not a directory)",
1185 dst_path
1186 );
1187 }
1188 }
1189 return Ok(DirAction::Skip(Summary {
1190 directories_unchanged: 1,
1191 ..Default::default()
1192 }));
1193 }
1194 crate::dry_run::report_action("copy", src_path, Some(&dst_path), "dir");
1195 let base = Summary {
1196 directories_created: 1, ..Default::default()
1198 };
1199 return Ok(DirAction::Descend {
1200 dir: src_dir,
1201 child_ctx: CopyDirContext {
1202 dst_dir: None, is_fresh,
1204 },
1205 state: CopyDirState {
1206 dst_dir: None,
1207 dst_parent: None,
1208 dst_name,
1209 we_created: true,
1211 src_meta,
1212 is_root,
1213 base,
1214 },
1215 });
1216 }
1217 let dst_parent = parent_ctx
1219 .dst_dir
1220 .as_ref()
1221 .expect("destination parent must be open for a real copy");
1222 let DirSlot {
1223 dir: dst_dir,
1224 summary: base,
1225 is_fresh: child_is_fresh,
1226 we_created,
1227 } = match resolve_dst_dir(
1228 self.prog_track,
1229 dst_parent,
1230 &dst_name,
1231 &dst_path,
1232 &self.settings,
1233 is_fresh,
1234 )
1235 .await?
1236 {
1237 DirResolution::Skip(summary) => return Ok(DirAction::Skip(summary)),
1238 DirResolution::Proceed(slot) => slot,
1239 };
1240 Ok(DirAction::Descend {
1241 dir: src_dir,
1242 child_ctx: CopyDirContext {
1243 dst_dir: Some(Arc::clone(&dst_dir)),
1244 is_fresh: child_is_fresh,
1245 },
1246 state: CopyDirState {
1247 dst_dir: Some(dst_dir),
1248 dst_parent: Some(Arc::clone(dst_parent)),
1249 dst_name,
1250 we_created,
1251 src_meta,
1252 is_root,
1253 base,
1254 },
1255 })
1256 }
1257
1258 async fn dir_post(
1259 &self,
1260 cx: &EntryCx,
1261 state: CopyDirState,
1262 processed: &ProcessedChildren,
1263 child_result: Result<Summary, Error>,
1264 ) -> Result<Summary, Error> {
1265 let CopyDirState {
1266 dst_dir,
1267 dst_parent,
1268 dst_name,
1269 we_created,
1270 src_meta,
1271 is_root,
1272 base,
1273 } = state;
1274 let (child_summary, mut child_error) = match child_result {
1280 Ok(summary) => (summary, None),
1281 Err(err) => (err.summary, Some(err.source)),
1282 };
1283 let mut copy_summary = base + child_summary;
1284 let dst_path = self.dst_path_for(cx);
1285 self.prune_finished_dir(
1288 &mut copy_summary,
1289 &mut child_error,
1290 processed,
1291 &dst_dir,
1292 &dst_path,
1293 &cx.rel_path,
1294 )
1295 .await?;
1296 self.finalize_dir(
1298 FinalizeDir {
1299 copy_summary,
1300 child_error,
1301 dst_dir,
1302 dst_parent,
1303 dst_name,
1304 src_meta,
1305 we_created,
1306 is_root,
1307 },
1308 cx,
1309 )
1310 .await
1311 }
1312}
1313
1314async fn strict_dry_run_dst_kind(dst_path: &std::path::Path) -> Result<Option<EntryKind>, Error> {
1321 crate::safedir::strict_probe_dst_kind(dst_path, congestion::Side::Destination)
1322 .await
1323 .map_err(|err| {
1324 Error::new(
1325 anyhow::Error::new(err).context(format!("cannot probe destination {dst_path:?}")),
1326 Default::default(),
1327 )
1328 })
1329}
1330
1331#[allow(clippy::too_many_arguments)]
1337async fn copy_file_fd(
1338 prog_track: &'static progress::Progress,
1339 src_parent: &Arc<Dir>,
1340 dst_parent: Option<&Arc<Dir>>,
1341 name: &OsStr,
1342 dst_name: &OsStr,
1343 dst_path: &std::path::Path,
1344 src_path: &std::path::Path,
1345 src_handle: &Handle,
1346 settings: &Settings,
1347 preserve: &preserve::Settings,
1348 is_fresh: bool,
1349) -> Result<Summary, Error> {
1350 use crate::preserve::Metadata as _;
1353 let src_meta = src_handle.meta();
1354 let dst_exists = match dst_parent {
1361 Some(dst_parent) => dst_parent.child(dst_name).await.is_ok(),
1362 None if !is_fresh && settings.ignore_existing => {
1363 if crate::safedir::strict_operand_resolution() {
1364 strict_dry_run_dst_kind(dst_path).await?.is_some()
1365 } else {
1366 tokio::fs::symlink_metadata(dst_path).await.is_ok()
1367 }
1368 }
1369 None => false,
1370 };
1371 if !is_fresh && settings.ignore_existing && dst_exists {
1372 if let Some(mode) = settings.dry_run {
1373 match mode {
1374 DryRunMode::Brief => {}
1375 DryRunMode::All => println!("skip file {:?}", dst_path),
1376 DryRunMode::Explain => println!("skip file {:?} (destination exists)", dst_path),
1377 }
1378 }
1379 tracing::debug!("destination exists, skipping (--ignore-existing)");
1380 prog_track.files_unchanged.inc();
1381 return Ok(Summary {
1382 files_unchanged: 1,
1383 ..Default::default()
1384 });
1385 }
1386 if settings.dry_run.is_some() {
1388 crate::dry_run::report_action("copy", src_path, Some(dst_path), "file");
1389 return Ok(Summary {
1390 files_copied: 1,
1391 bytes_copied: src_meta.size(),
1392 ..Default::default()
1393 });
1394 }
1395 let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1397 get_file_iops_tokens(settings.chunk_size, src_meta.size()).await;
1398 let mut rm_summary = RmSummary::default();
1399 if !is_fresh && let Ok(dst_handle) = dst_parent.child(dst_name).await {
1402 if !settings.overwrite {
1403 return Err(Error::new(
1404 anyhow!(
1405 "destination {:?} already exists, did you intend to specify --overwrite?",
1406 dst_path
1407 ),
1408 Default::default(),
1409 ));
1410 }
1411 tracing::debug!("file exists, check if it's identical");
1412 if dst_handle.kind() == EntryKind::File {
1413 if filecmp::metadata_equal(&settings.overwrite_compare, src_meta, dst_handle.meta()) {
1414 tracing::debug!("file is identical, skipping");
1415 prog_track.files_unchanged.inc();
1416 return Ok(Summary {
1417 files_unchanged: 1,
1418 ..Default::default()
1419 });
1420 }
1421 if let Some(OverwriteFilter::Newer) = settings.overwrite_filter
1422 && filecmp::dest_is_newer(src_meta, dst_handle.meta())
1423 {
1424 tracing::debug!("dest is newer than source, skipping");
1425 prog_track.files_unchanged.inc();
1426 return Ok(Summary {
1427 files_unchanged: 1,
1428 ..Default::default()
1429 });
1430 }
1431 }
1432 tracing::info!("destination differs, removing existing entry");
1433 rm_summary = remove_existing(
1434 prog_track,
1435 dst_parent,
1436 dst_name,
1437 dst_path,
1438 &dst_handle,
1439 settings,
1440 )
1441 .await?;
1442 }
1443 let copy_summary = Summary {
1448 rm_summary,
1449 ..Default::default()
1450 };
1451 let (src_file, open_meta) = src_parent
1452 .open_file_read(name)
1453 .await
1454 .with_context(|| format!("failed opening src file {:?} for reading", src_path))
1455 .map_err(|err| Error::new(err, copy_summary))?;
1456 let create_mode = preserve::masked_mode(preserve.file.mode_mask, &open_meta);
1457 let dst_file = dst_parent
1458 .create_file(dst_name, create_mode)
1459 .await
1460 .with_context(|| format!("failed creating {:?}", dst_path))
1461 .map_err(|err| Error::new(err, copy_summary))?;
1462 tracing::debug!("copying data");
1463 let len = open_meta.size();
1464 let (copied, dst_file) = tokio::task::spawn_blocking(move || {
1470 copy_file_range_all(&src_file, &dst_file, len).map(|copied| (copied, dst_file))
1471 })
1472 .await
1473 .map_err(std::io::Error::other)
1474 .and_then(|res| res)
1475 .with_context(|| format!("failed copying data to {:?}", dst_path))
1476 .map_err(|err| Error::new(err, copy_summary))?;
1477 prog_track.files_copied.inc();
1481 prog_track.bytes_copied.add(copied);
1482 tracing::debug!("setting permissions");
1483 safedir::set_file_metadata_fd(
1484 preserve,
1485 &open_meta,
1486 dst_file.as_fd(),
1487 congestion::Side::Destination,
1488 )
1489 .await
1490 .with_context(|| format!("failed setting metadata on {:?}", dst_path))
1491 .map_err(|err| Error::new(err, copy_summary))?;
1492 let mut copy_summary = copy_summary;
1493 copy_summary.bytes_copied += copied;
1495 copy_summary.files_copied += 1;
1496 Ok(copy_summary)
1497}
1498
1499#[allow(clippy::too_many_arguments)]
1502async fn copy_symlink_fd(
1503 prog_track: &'static progress::Progress,
1504 src_parent: &Arc<Dir>,
1505 dst_parent: Option<&Arc<Dir>>,
1506 dst_name: &OsStr,
1507 src_path: &std::path::Path,
1508 dst_path: &std::path::Path,
1509 src_handle: &Handle,
1510 settings: &Settings,
1511 preserve: &preserve::Settings,
1512 is_fresh: bool,
1513) -> Result<Summary, Error> {
1514 let dst_exists = match dst_parent {
1520 Some(dst_parent) => dst_parent.child(dst_name).await.is_ok(),
1521 None if !is_fresh && settings.ignore_existing => {
1522 if crate::safedir::strict_operand_resolution() {
1523 strict_dry_run_dst_kind(dst_path).await?.is_some()
1524 } else {
1525 tokio::fs::symlink_metadata(dst_path).await.is_ok()
1526 }
1527 }
1528 None => false,
1529 };
1530 if !is_fresh && settings.ignore_existing && dst_exists {
1531 if let Some(mode) = settings.dry_run {
1532 match mode {
1533 DryRunMode::Brief => {}
1534 DryRunMode::All => println!("skip symlink {:?}", dst_path),
1535 DryRunMode::Explain => println!("skip symlink {:?} (destination exists)", dst_path),
1536 }
1537 }
1538 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1539 prog_track.symlinks_unchanged.inc();
1540 return Ok(Summary {
1541 symlinks_unchanged: 1,
1542 ..Default::default()
1543 });
1544 }
1545 if settings.dry_run.is_some() {
1546 crate::dry_run::report_action("copy", src_path, Some(dst_path), "symlink");
1547 return Ok(Summary {
1548 symlinks_created: 1,
1549 ..Default::default()
1550 });
1551 }
1552 let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1553 let (target, src_meta) = src_handle
1557 .read_symlink(src_parent.side())
1558 .await
1559 .with_context(|| format!("failed reading symlink {:?}", src_path))
1560 .map_err(|err| Error::new(err, Default::default()))?;
1561 match dst_parent.symlink_at(dst_name, &target).await {
1563 Ok(link_handle) => {
1564 safedir::set_symlink_metadata_fd(
1565 preserve,
1566 &src_meta,
1567 &link_handle,
1568 congestion::Side::Destination,
1569 )
1570 .await
1571 .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1572 .map_err(|err| Error::new(err, Default::default()))?;
1573 prog_track.symlinks_created.inc();
1574 Ok(Summary {
1575 symlinks_created: 1,
1576 ..Default::default()
1577 })
1578 }
1579 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1580 if settings.ignore_existing {
1581 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1582 prog_track.symlinks_unchanged.inc();
1583 return Ok(Summary {
1584 symlinks_unchanged: 1,
1585 ..Default::default()
1586 });
1587 }
1588 if !settings.overwrite {
1589 return Err(Error::new(
1590 anyhow!("failed creating symlink {:?}", dst_path),
1591 Default::default(),
1592 ));
1593 }
1594 let dst_handle = dst_parent
1595 .child(dst_name)
1596 .await
1597 .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
1598 .map_err(|err| Error::new(err, Default::default()))?;
1599 if dst_handle.kind() == EntryKind::Symlink {
1600 let dst_link = dst_parent
1601 .read_link_at(dst_name) .await
1603 .with_context(|| format!("failed reading dst symlink: {:?}", dst_path))
1604 .map_err(|err| Error::new(err, Default::default()))?;
1605 if dst_link == target {
1606 tracing::debug!("'dst' is a symlink and points to the same location as 'src'");
1607 if preserve.symlink.any()
1608 && !filecmp::metadata_equal(
1609 &settings.overwrite_compare,
1610 &src_meta,
1611 dst_handle.meta(),
1612 )
1613 {
1614 tracing::debug!("'dst' metadata is different, updating");
1615 safedir::set_symlink_metadata_fd(
1616 preserve,
1617 &src_meta,
1618 &dst_handle,
1619 congestion::Side::Destination,
1620 )
1621 .await
1622 .with_context(|| {
1623 format!("failed setting symlink metadata on {:?}", dst_path)
1624 })
1625 .map_err(|err| Error::new(err, Default::default()))?;
1626 prog_track.symlinks_removed.inc();
1627 prog_track.symlinks_created.inc();
1628 return Ok(Summary {
1629 rm_summary: RmSummary {
1630 symlinks_removed: 1,
1631 ..Default::default()
1632 },
1633 symlinks_created: 1,
1634 ..Default::default()
1635 });
1636 }
1637 tracing::debug!("symlink already exists, skipping");
1638 prog_track.symlinks_unchanged.inc();
1639 return Ok(Summary {
1640 symlinks_unchanged: 1,
1641 ..Default::default()
1642 });
1643 }
1644 tracing::debug!("'dst' is a symlink but points to a different path, updating");
1645 } else {
1646 tracing::info!("'dst' is not a symlink, updating");
1647 }
1648 let rm_summary = remove_existing(
1650 prog_track,
1651 dst_parent,
1652 dst_name,
1653 dst_path,
1654 &dst_handle,
1655 settings,
1656 )
1657 .await?;
1658 let copy_summary = Summary {
1659 rm_summary,
1660 ..Default::default()
1661 };
1662 let link_handle = dst_parent
1663 .symlink_at(dst_name, &target)
1664 .await
1665 .with_context(|| format!("failed creating symlink {:?}", dst_path))
1666 .map_err(|err| Error::new(err, copy_summary))?;
1667 safedir::set_symlink_metadata_fd(
1668 preserve,
1669 &src_meta,
1670 &link_handle,
1671 congestion::Side::Destination,
1672 )
1673 .await
1674 .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1675 .map_err(|err| Error::new(err, copy_summary))?;
1676 prog_track.symlinks_created.inc();
1677 Ok(Summary {
1678 rm_summary,
1679 symlinks_created: 1,
1680 ..Default::default()
1681 })
1682 }
1683 Err(error) => Err(Error::new(
1684 anyhow::Error::new(error).context(format!("failed creating symlink {:?}", dst_path)),
1685 Default::default(),
1686 )),
1687 }
1688}
1689
1690pub(crate) struct DirSlot {
1692 pub(crate) dir: Arc<Dir>,
1693 pub(crate) summary: Summary,
1694 pub(crate) is_fresh: bool,
1695 pub(crate) we_created: bool,
1696}
1697
1698pub(crate) enum DirResolution {
1700 Proceed(DirSlot),
1702 Skip(Summary),
1704}
1705
1706pub(crate) async fn resolve_dst_dir(
1713 prog_track: &'static progress::Progress,
1714 dst_parent: &Arc<Dir>,
1715 name: &OsStr,
1716 dst_path: &std::path::Path,
1717 settings: &Settings,
1718 is_fresh: bool,
1719) -> Result<DirResolution, Error> {
1720 match dst_parent.make_dir(name, 0o700).await {
1721 Ok(dir) => {
1722 prog_track.directories_created.inc();
1724 Ok(DirResolution::Proceed(DirSlot {
1725 dir: Arc::new(dir),
1726 summary: Summary {
1727 directories_created: 1,
1728 ..Default::default()
1729 },
1730 is_fresh: true,
1731 we_created: true,
1732 }))
1733 }
1734 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1735 assert!(
1736 !is_fresh,
1737 "unexpected pre-existing directory in a fresh destination: {dst_path:?}"
1738 );
1739 let dst_handle = dst_parent
1740 .child(name)
1741 .await
1742 .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
1743 .map_err(|err| Error::new(err, Default::default()))?;
1744 if dst_handle.kind() == EntryKind::Dir {
1745 tracing::debug!("'dst' is a directory, leaving it as is");
1750 let dir = dst_parent
1751 .open_dir(name)
1752 .await
1753 .with_context(|| format!("cannot open existing directory {:?}", dst_path))
1754 .map_err(|err| Error::new(err, Default::default()))?;
1755 prog_track.directories_unchanged.inc();
1756 Ok(DirResolution::Proceed(DirSlot {
1757 dir: Arc::new(dir),
1758 summary: Summary {
1759 directories_unchanged: 1,
1760 ..Default::default()
1761 },
1762 is_fresh,
1763 we_created: false,
1764 }))
1765 } else if settings.ignore_existing {
1766 tracing::debug!(
1768 "destination exists but is not a directory, skipping subtree (--ignore-existing)"
1769 );
1770 prog_track.directories_unchanged.inc();
1771 Ok(DirResolution::Skip(Summary {
1772 directories_unchanged: 1,
1773 ..Default::default()
1774 }))
1775 } else if settings.overwrite {
1776 tracing::info!("'dst' is not a directory, removing and creating a new one");
1778 let rm_summary = remove_existing(
1779 prog_track,
1780 dst_parent,
1781 name,
1782 dst_path,
1783 &dst_handle,
1784 settings,
1785 )
1786 .await?;
1787 let dir = dst_parent
1788 .make_dir(name, 0o700)
1789 .await
1790 .with_context(|| format!("cannot create directory {:?}", dst_path))
1791 .map_err(|err| {
1792 Error::new(
1793 err,
1794 Summary {
1795 rm_summary,
1796 ..Default::default()
1797 },
1798 )
1799 })?;
1800 prog_track.directories_created.inc();
1801 Ok(DirResolution::Proceed(DirSlot {
1802 dir: Arc::new(dir),
1803 summary: Summary {
1804 rm_summary,
1805 directories_created: 1,
1806 ..Default::default()
1807 },
1808 is_fresh: true,
1809 we_created: true,
1810 }))
1811 } else {
1812 Err(Error::new(
1813 anyhow!(
1814 "destination {:?} already exists, did you intend to specify --overwrite?",
1815 dst_path
1816 ),
1817 Default::default(),
1818 ))
1819 }
1820 }
1821 Err(error) => {
1822 let error = anyhow::Error::new(error)
1823 .context(format!("cannot create directory {:?}", dst_path));
1824 tracing::error!("{:#}", &error);
1825 Err(Error::new(error, Default::default()))
1826 }
1827 }
1828}
1829
1830pub(crate) async fn remove_existing(
1855 prog_track: &'static progress::Progress,
1856 dst_parent: &Arc<Dir>,
1857 dst_name: &OsStr,
1858 dst_path: &std::path::Path,
1859 dst_handle: &Handle,
1860 settings: &Settings,
1861) -> Result<RmSummary, Error> {
1862 dst_parent
1864 .recheck(dst_name, dst_handle)
1865 .await
1866 .with_context(|| {
1867 format!(
1868 "destination {:?} changed identity before removal (possible TOCTOU swap)",
1869 dst_path
1870 )
1871 })
1872 .map_err(|err| Error::new(err, Default::default()))?;
1873 match dst_handle.kind() {
1874 EntryKind::File | EntryKind::Symlink | EntryKind::Special => {
1875 let removed_size = {
1878 use crate::preserve::Metadata as _;
1879 dst_handle.meta().size()
1880 };
1881 dst_parent
1883 .unlink_at(dst_name)
1884 .await
1885 .with_context(|| format!("failed removing existing destination {:?}", dst_path))
1886 .map_err(|err| Error::new(err, Default::default()))?;
1887 let is_symlink = dst_handle.kind() == EntryKind::Symlink;
1888 if is_symlink {
1889 prog_track.symlinks_removed.inc();
1890 } else {
1891 prog_track.files_removed.inc();
1892 prog_track.bytes_removed.add(removed_size);
1893 }
1894 Ok(RmSummary {
1895 files_removed: usize::from(!is_symlink),
1896 symlinks_removed: usize::from(is_symlink),
1897 ..Default::default()
1898 })
1899 }
1900 EntryKind::Dir => {
1901 match dst_parent.rmdir_at(dst_name).await {
1903 Ok(()) => {
1904 prog_track.directories_removed.inc();
1905 Ok(RmSummary {
1906 directories_removed: 1,
1907 ..Default::default()
1908 })
1909 }
1910 Err(error)
1912 if matches!(
1913 error.raw_os_error(),
1914 Some(libc::ENOTEMPTY) | Some(libc::EEXIST)
1915 ) =>
1916 {
1917 rm::rm_child(
1921 prog_track,
1922 dst_parent,
1923 dst_name,
1924 dst_path,
1925 &RmSettings {
1926 fail_early: settings.fail_early,
1927 filter: None,
1928 dry_run: None,
1929 time_filter: None,
1930 },
1931 )
1932 .await
1933 .map_err(|err| {
1934 Error::new(
1935 err.source,
1936 Summary {
1937 rm_summary: err.summary,
1938 ..Default::default()
1939 },
1940 )
1941 })
1942 }
1943 Err(error) => Err(Error::new(
1944 anyhow::Error::new(error)
1945 .context(format!("failed removing existing directory {:?}", dst_path)),
1946 Default::default(),
1947 )),
1948 }
1949 }
1950 }
1951}
1952
1953#[cfg(test)]
1954mod copy_tests {
1955 use crate::testutils;
1956 use anyhow::Context;
1957 use std::os::unix::fs::MetadataExt;
1958 use std::os::unix::fs::PermissionsExt;
1959 use tracing_test::traced_test;
1960
1961 use super::*;
1962
1963 static PROGRESS: std::sync::LazyLock<progress::Progress> =
1964 std::sync::LazyLock::new(progress::Progress::new);
1965 static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1966 std::sync::LazyLock::new(preserve::preserve_none);
1967 static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1968 std::sync::LazyLock::new(preserve::preserve_all);
1969
1970 fn settings_with_delete(delete: Option<DeleteSettings>) -> Settings {
1971 Settings {
1972 dereference: false,
1973 fail_early: false,
1974 overwrite: delete.is_some(), overwrite_compare: filecmp::MetadataCmpSettings {
1976 size: true,
1977 mtime: true,
1978 ..Default::default()
1979 },
1980 overwrite_filter: None,
1981 ignore_existing: false,
1982 chunk_size: 0,
1983 skip_specials: false,
1984 remote_copy_buffer_size: 0,
1985 filter: None,
1986 dry_run: None,
1987 delete,
1988 }
1989 }
1990
1991 fn delete_on() -> Option<DeleteSettings> {
1992 Some(DeleteSettings {
1993 delete_excluded: false,
1994 })
1995 }
1996
1997 #[tokio::test]
2001 async fn copies_dot_dot_source_operand() -> Result<(), anyhow::Error> {
2002 let tmp = testutils::create_temp_dir().await?;
2003 let tree = tmp.join("tree");
2004 tokio::fs::create_dir(&tree).await?;
2005 tokio::fs::write(tree.join("a.txt"), "hello").await?;
2006 tokio::fs::create_dir(tree.join("sub")).await?;
2007 let src = tree.join("sub").join(".."); let dst = tmp.join("dst");
2009 let summary = copy(
2010 &PROGRESS,
2011 &src,
2012 &dst,
2013 &settings_with_delete(None),
2014 &NO_PRESERVE_SETTINGS,
2015 false,
2016 )
2017 .await?;
2018 assert_eq!(
2019 summary.files_copied, 1,
2020 "the dot-dot source's file must be copied"
2021 );
2022 assert_eq!(tokio::fs::read_to_string(dst.join("a.txt")).await?, "hello");
2023 assert!(
2024 dst.join("sub").is_dir(),
2025 "the dot-dot source's subdir must be copied"
2026 );
2027 Ok(())
2028 }
2029
2030 #[tokio::test]
2031 #[traced_test]
2032 async fn delete_protects_skipped_special_name() -> Result<(), anyhow::Error> {
2033 let tmp_dir = testutils::setup_test_dir().await?;
2034 let test_path = tmp_dir.as_path();
2035 let src = test_path.join("src_dir");
2036 let dst = test_path.join("dst_dir");
2037 tokio::fs::create_dir(&src).await?;
2038 tokio::fs::write(src.join("file.txt"), "hello").await?;
2039 nix::unistd::mkfifo(
2041 &src.join("pipe"),
2042 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
2043 )?;
2044 tokio::fs::create_dir(&dst).await?;
2046 tokio::fs::write(dst.join("pipe"), "old").await?;
2047 tokio::fs::write(dst.join("stale.txt"), "junk").await?;
2048
2049 let mut settings = settings_with_delete(delete_on());
2050 settings.skip_specials = true;
2051 let summary = copy(
2052 &PROGRESS,
2053 &src,
2054 &dst,
2055 &settings,
2056 &DO_PRESERVE_SETTINGS,
2057 false,
2058 )
2059 .await?;
2060
2061 assert_eq!(summary.specials_skipped, 1);
2062 assert!(dst.join("file.txt").exists());
2063 assert!(
2064 dst.join("pipe").exists(),
2065 "a destination entry matching a skipped special must not be pruned (it has a source counterpart)"
2066 );
2067 assert!(!dst.join("stale.txt").exists()); Ok(())
2069 }
2070
2071 #[tokio::test]
2076 #[traced_test]
2077 async fn copies_into_symlinked_destination_parent() -> Result<(), anyhow::Error> {
2078 let tmp_dir = testutils::setup_test_dir().await?;
2079 let test_path = tmp_dir.as_path();
2080 let src = test_path.join("src.txt");
2081 tokio::fs::write(&src, b"payload").await?;
2082 let real_dir = test_path.join("real_dst_dir");
2084 tokio::fs::create_dir(&real_dir).await?;
2085 let link_dir = test_path.join("link_dst_dir");
2086 tokio::fs::symlink(&real_dir, &link_dir).await?;
2087 let dst = link_dir.join("out.txt");
2089 let summary = copy(
2090 &PROGRESS,
2091 &src,
2092 &dst,
2093 &settings_with_delete(None),
2094 &DO_PRESERVE_SETTINGS,
2095 false,
2096 )
2097 .await?;
2098 assert_eq!(summary.files_copied, 1);
2099 let written = tokio::fs::read(real_dir.join("out.txt")).await?;
2101 assert_eq!(written, b"payload");
2102 Ok(())
2103 }
2104
2105 #[tokio::test]
2108 #[traced_test]
2109 async fn copies_from_symlinked_source_parent() -> Result<(), anyhow::Error> {
2110 let tmp_dir = testutils::setup_test_dir().await?;
2111 let test_path = tmp_dir.as_path();
2112 let real_src_dir = test_path.join("real_src_dir");
2114 tokio::fs::create_dir(&real_src_dir).await?;
2115 tokio::fs::write(real_src_dir.join("src.txt"), b"payload").await?;
2116 let link_src_dir = test_path.join("link_src_dir");
2117 tokio::fs::symlink(&real_src_dir, &link_src_dir).await?;
2118 let src = link_src_dir.join("src.txt");
2119 let dst = test_path.join("out.txt");
2120 let summary = copy(
2121 &PROGRESS,
2122 &src,
2123 &dst,
2124 &settings_with_delete(None),
2125 &DO_PRESERVE_SETTINGS,
2126 false,
2127 )
2128 .await?;
2129 assert_eq!(summary.files_copied, 1);
2130 let written = tokio::fs::read(&dst).await?;
2131 assert_eq!(written, b"payload");
2132 Ok(())
2133 }
2134
2135 #[tokio::test]
2136 #[traced_test]
2137 async fn delete_removes_extraneous_destination_entries() -> Result<(), anyhow::Error> {
2138 let tmp_dir = testutils::setup_test_dir().await?;
2139 let test_path = tmp_dir.as_path();
2140 let src = test_path.join("foo");
2141 let dst = test_path.join("bar");
2142 copy(
2144 &PROGRESS,
2145 &src,
2146 &dst,
2147 &settings_with_delete(None),
2148 &DO_PRESERVE_SETTINGS,
2149 false,
2150 )
2151 .await?;
2152 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2154 tokio::fs::create_dir(dst.join("extra_dir")).await?;
2155 tokio::fs::write(dst.join("extra_dir").join("nested.txt"), b"junk").await?;
2156 let summary = copy(
2158 &PROGRESS,
2159 &src,
2160 &dst,
2161 &settings_with_delete(delete_on()),
2162 &DO_PRESERVE_SETTINGS,
2163 false,
2164 )
2165 .await?;
2166 assert_eq!(summary.rm_summary.files_removed, 2); assert_eq!(summary.rm_summary.directories_removed, 1); assert!(!dst.join("extraneous.txt").exists());
2169 assert!(!dst.join("extra_dir").exists());
2170 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2171 Ok(())
2172 }
2173
2174 #[tokio::test]
2175 #[traced_test]
2176 async fn delete_prunes_extraneous_at_depth() -> Result<(), anyhow::Error> {
2177 let tmp_dir = testutils::setup_test_dir().await?;
2178 let test_path = tmp_dir.as_path();
2179 let src = test_path.join("foo");
2180 let dst = test_path.join("bar");
2181 copy(
2182 &PROGRESS,
2183 &src,
2184 &dst,
2185 &settings_with_delete(None),
2186 &DO_PRESERVE_SETTINGS,
2187 false,
2188 )
2189 .await?;
2190 let nested = dst.join("bar");
2192 assert!(
2193 nested.is_dir(),
2194 "expected common subdirectory bar/ to exist at destination"
2195 );
2196 tokio::fs::write(nested.join("stale_nested.txt"), b"junk").await?;
2197 let summary = copy(
2198 &PROGRESS,
2199 &src,
2200 &dst,
2201 &settings_with_delete(delete_on()),
2202 &DO_PRESERVE_SETTINGS,
2203 false,
2204 )
2205 .await?;
2206 assert!(
2207 !nested.join("stale_nested.txt").exists(),
2208 "stale entry inside a common subdirectory must be pruned"
2209 );
2210 assert!(summary.rm_summary.files_removed >= 1);
2211 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2212 Ok(())
2213 }
2214
2215 #[tokio::test]
2216 #[traced_test]
2217 async fn delete_removes_extraneous_symlink() -> Result<(), anyhow::Error> {
2218 let tmp_dir = testutils::setup_test_dir().await?;
2219 let test_path = tmp_dir.as_path();
2220 let src = test_path.join("foo");
2221 let dst = test_path.join("bar");
2222 copy(
2223 &PROGRESS,
2224 &src,
2225 &dst,
2226 &settings_with_delete(None),
2227 &DO_PRESERVE_SETTINGS,
2228 false,
2229 )
2230 .await?;
2231 tokio::fs::symlink("/nonexistent/target", dst.join("stale_link")).await?;
2233 let summary = copy(
2234 &PROGRESS,
2235 &src,
2236 &dst,
2237 &settings_with_delete(delete_on()),
2238 &DO_PRESERVE_SETTINGS,
2239 false,
2240 )
2241 .await?;
2242 assert!(
2243 tokio::fs::symlink_metadata(dst.join("stale_link"))
2244 .await
2245 .is_err(),
2246 "extraneous symlink must be removed"
2247 );
2248 assert_eq!(summary.rm_summary.symlinks_removed, 1);
2249 Ok(())
2250 }
2251
2252 #[tokio::test]
2253 #[traced_test]
2254 async fn delete_skips_pruning_when_copy_has_errors() -> Result<(), anyhow::Error> {
2255 let tmp_dir = testutils::setup_test_dir().await?;
2256 let test_path = tmp_dir.as_path();
2257 let src = test_path.join("foo");
2258 let dst = test_path.join("bar");
2259 copy(
2261 &PROGRESS,
2262 &src,
2263 &dst,
2264 &settings_with_delete(None),
2265 &DO_PRESERVE_SETTINGS,
2266 false,
2267 )
2268 .await?;
2269 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2271 let unreadable = src.join("baz");
2275 let original = tokio::fs::metadata(&unreadable).await?.permissions();
2276 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2277
2278 let result = copy(
2279 &PROGRESS,
2280 &src,
2281 &dst,
2282 &settings_with_delete(delete_on()),
2283 &DO_PRESERVE_SETTINGS,
2284 false,
2285 )
2286 .await;
2287
2288 tokio::fs::set_permissions(&unreadable, original).await?;
2289
2290 assert!(
2291 result.is_err(),
2292 "copy of the unreadable directory should fail"
2293 );
2294 assert!(
2295 dst.join("extraneous.txt").exists(),
2296 "pruning must be skipped when the copy reported errors"
2297 );
2298 Ok(())
2299 }
2300
2301 #[tokio::test]
2302 #[traced_test]
2303 async fn check_basic_copy() -> Result<(), anyhow::Error> {
2304 let tmp_dir = testutils::setup_test_dir().await?;
2305 let test_path = tmp_dir.as_path();
2306 let summary = copy(
2307 &PROGRESS,
2308 &test_path.join("foo"),
2309 &test_path.join("bar"),
2310 &Settings {
2311 dereference: false,
2312 fail_early: false,
2313 overwrite: false,
2314 overwrite_compare: filecmp::MetadataCmpSettings {
2315 size: true,
2316 mtime: true,
2317 ..Default::default()
2318 },
2319 overwrite_filter: None,
2320 ignore_existing: false,
2321 chunk_size: 0,
2322 skip_specials: false,
2323 remote_copy_buffer_size: 0,
2324 filter: None,
2325 dry_run: None,
2326 delete: None,
2327 },
2328 &NO_PRESERVE_SETTINGS,
2329 false,
2330 )
2331 .await?;
2332 assert_eq!(summary.files_copied, 5);
2333 assert_eq!(summary.symlinks_created, 2);
2334 assert_eq!(summary.directories_created, 3);
2335 testutils::check_dirs_identical(
2336 &test_path.join("foo"),
2337 &test_path.join("bar"),
2338 testutils::FileEqualityCheck::Basic,
2339 )
2340 .await?;
2341 Ok(())
2342 }
2343
2344 #[tokio::test]
2345 #[traced_test]
2346 async fn no_read_permission() -> Result<(), anyhow::Error> {
2347 let tmp_dir = testutils::setup_test_dir().await?;
2348 let test_path = tmp_dir.as_path();
2349 let filepaths = vec![
2350 test_path.join("foo").join("0.txt"),
2351 test_path.join("foo").join("baz"),
2352 ];
2353 for fpath in &filepaths {
2354 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
2356 }
2357 match copy(
2358 &PROGRESS,
2359 &test_path.join("foo"),
2360 &test_path.join("bar"),
2361 &Settings {
2362 dereference: false,
2363 fail_early: false,
2364 overwrite: false,
2365 overwrite_compare: filecmp::MetadataCmpSettings {
2366 size: true,
2367 mtime: true,
2368 ..Default::default()
2369 },
2370 overwrite_filter: None,
2371 ignore_existing: false,
2372 chunk_size: 0,
2373 skip_specials: false,
2374 remote_copy_buffer_size: 0,
2375 filter: None,
2376 dry_run: None,
2377 delete: None,
2378 },
2379 &NO_PRESERVE_SETTINGS,
2380 false,
2381 )
2382 .await
2383 {
2384 Ok(_) => panic!("Expected the copy to error!"),
2385 Err(error) => {
2386 tracing::info!("{}", &error);
2387 assert_eq!(error.summary.files_copied, 3);
2398 assert_eq!(error.summary.symlinks_created, 0);
2399 assert_eq!(error.summary.directories_created, 2);
2400 }
2401 }
2402 for fpath in &filepaths {
2404 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
2405 if tokio::fs::symlink_metadata(fpath).await?.is_file() {
2406 tokio::fs::remove_file(fpath).await?;
2407 } else {
2408 tokio::fs::remove_dir_all(fpath).await?;
2409 }
2410 }
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 check_default_mode() -> Result<(), anyhow::Error> {
2423 let tmp_dir = testutils::setup_test_dir().await?;
2424 tokio::fs::set_permissions(
2426 tmp_dir.join("foo").join("0.txt"),
2427 std::fs::Permissions::from_mode(0o700),
2428 )
2429 .await?;
2430 let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
2432 tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
2433 .await?;
2434 let test_path = tmp_dir.as_path();
2435 let summary = copy(
2436 &PROGRESS,
2437 &test_path.join("foo"),
2438 &test_path.join("bar"),
2439 &Settings {
2440 dereference: false,
2441 fail_early: false,
2442 overwrite: false,
2443 overwrite_compare: filecmp::MetadataCmpSettings {
2444 size: true,
2445 mtime: true,
2446 ..Default::default()
2447 },
2448 overwrite_filter: None,
2449 ignore_existing: false,
2450 chunk_size: 0,
2451 skip_specials: false,
2452 remote_copy_buffer_size: 0,
2453 filter: None,
2454 dry_run: None,
2455 delete: None,
2456 },
2457 &NO_PRESERVE_SETTINGS,
2458 false,
2459 )
2460 .await?;
2461 assert_eq!(summary.files_copied, 5);
2462 assert_eq!(summary.symlinks_created, 2);
2463 assert_eq!(summary.directories_created, 3);
2464 tokio::fs::set_permissions(
2466 &exec_sticky_file,
2467 std::fs::Permissions::from_mode(
2468 std::fs::symlink_metadata(&exec_sticky_file)?
2469 .permissions()
2470 .mode()
2471 & 0o0777,
2472 ),
2473 )
2474 .await?;
2475 testutils::check_dirs_identical(
2476 &test_path.join("foo"),
2477 &test_path.join("bar"),
2478 testutils::FileEqualityCheck::Basic,
2479 )
2480 .await?;
2481 Ok(())
2482 }
2483
2484 #[tokio::test]
2485 #[traced_test]
2486 async fn no_write_permission() -> Result<(), anyhow::Error> {
2487 let tmp_dir = testutils::setup_test_dir().await?;
2488 let test_path = tmp_dir.as_path();
2489 let non_exec_dir = test_path.join("foo").join("bogey");
2491 tokio::fs::create_dir(&non_exec_dir).await?;
2492 tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
2493 tokio::fs::set_permissions(
2495 &test_path.join("foo").join("baz"),
2496 std::fs::Permissions::from_mode(0o500),
2497 )
2498 .await?;
2499 tokio::fs::set_permissions(
2501 &test_path.join("foo").join("baz").join("4.txt"),
2502 std::fs::Permissions::from_mode(0o440),
2503 )
2504 .await?;
2505 let summary = copy(
2506 &PROGRESS,
2507 &test_path.join("foo"),
2508 &test_path.join("bar"),
2509 &Settings {
2510 dereference: false,
2511 fail_early: false,
2512 overwrite: false,
2513 overwrite_compare: filecmp::MetadataCmpSettings {
2514 size: true,
2515 mtime: true,
2516 ..Default::default()
2517 },
2518 overwrite_filter: None,
2519 ignore_existing: false,
2520 chunk_size: 0,
2521 skip_specials: false,
2522 remote_copy_buffer_size: 0,
2523 filter: None,
2524 dry_run: None,
2525 delete: None,
2526 },
2527 &NO_PRESERVE_SETTINGS,
2528 false,
2529 )
2530 .await?;
2531 assert_eq!(summary.files_copied, 5);
2532 assert_eq!(summary.symlinks_created, 2);
2533 assert_eq!(summary.directories_created, 4);
2534 testutils::check_dirs_identical(
2535 &test_path.join("foo"),
2536 &test_path.join("bar"),
2537 testutils::FileEqualityCheck::Basic,
2538 )
2539 .await?;
2540 Ok(())
2541 }
2542
2543 #[tokio::test]
2544 #[traced_test]
2545 async fn dereference() -> Result<(), anyhow::Error> {
2546 let tmp_dir = testutils::setup_test_dir().await?;
2547 let test_path = tmp_dir.as_path();
2548 let src1 = &test_path.join("foo").join("bar").join("2.txt");
2550 let src2 = &test_path.join("foo").join("bar").join("3.txt");
2551 let test_mode = 0o440;
2552 for f in [src1, src2] {
2553 tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
2554 }
2555 let summary = copy(
2556 &PROGRESS,
2557 &test_path.join("foo"),
2558 &test_path.join("bar"),
2559 &Settings {
2560 dereference: true, fail_early: false,
2562 overwrite: false,
2563 overwrite_compare: filecmp::MetadataCmpSettings {
2564 size: true,
2565 mtime: true,
2566 ..Default::default()
2567 },
2568 overwrite_filter: None,
2569 ignore_existing: false,
2570 chunk_size: 0,
2571 skip_specials: false,
2572 remote_copy_buffer_size: 0,
2573 filter: None,
2574 dry_run: None,
2575 delete: None,
2576 },
2577 &NO_PRESERVE_SETTINGS,
2578 false,
2579 )
2580 .await?;
2581 assert_eq!(summary.files_copied, 7);
2582 assert_eq!(summary.symlinks_created, 0);
2583 assert_eq!(summary.directories_created, 3);
2584 let dst1 = &test_path.join("bar").join("baz").join("5.txt");
2590 let dst2 = &test_path.join("bar").join("baz").join("6.txt");
2591 for f in [dst1, dst2] {
2592 let metadata = tokio::fs::symlink_metadata(f)
2593 .await
2594 .with_context(|| format!("failed reading metadata from {:?}", &f))?;
2595 assert!(metadata.is_file());
2596 assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
2598 }
2599 Ok(())
2600 }
2601
2602 async fn cp_compare(
2603 cp_args: &[&str],
2604 rcp_settings: &Settings,
2605 preserve: bool,
2606 ) -> Result<(), anyhow::Error> {
2607 let tmp_dir = testutils::setup_test_dir().await?;
2608 let test_path = tmp_dir.as_path();
2609 let cp_output = tokio::process::Command::new("cp")
2611 .args(cp_args)
2612 .arg(test_path.join("foo"))
2613 .arg(test_path.join("bar"))
2614 .output()
2615 .await?;
2616 assert!(cp_output.status.success());
2617 let summary = copy(
2619 &PROGRESS,
2620 &test_path.join("foo"),
2621 &test_path.join("baz"),
2622 rcp_settings,
2623 if preserve {
2624 &DO_PRESERVE_SETTINGS
2625 } else {
2626 &NO_PRESERVE_SETTINGS
2627 },
2628 false,
2629 )
2630 .await?;
2631 if rcp_settings.dereference {
2632 assert_eq!(summary.files_copied, 7);
2633 assert_eq!(summary.symlinks_created, 0);
2634 } else {
2635 assert_eq!(summary.files_copied, 5);
2636 assert_eq!(summary.symlinks_created, 2);
2637 }
2638 assert_eq!(summary.directories_created, 3);
2639 testutils::check_dirs_identical(
2640 &test_path.join("bar"),
2641 &test_path.join("baz"),
2642 if preserve {
2643 testutils::FileEqualityCheck::Timestamp
2644 } else {
2645 testutils::FileEqualityCheck::Basic
2646 },
2647 )
2648 .await?;
2649 Ok(())
2650 }
2651
2652 #[tokio::test]
2653 #[traced_test]
2654 async fn test_cp_compat() -> Result<(), anyhow::Error> {
2655 cp_compare(
2656 &["-r"],
2657 &Settings {
2658 dereference: false,
2659 fail_early: false,
2660 overwrite: false,
2661 overwrite_compare: filecmp::MetadataCmpSettings {
2662 size: true,
2663 mtime: true,
2664 ..Default::default()
2665 },
2666 overwrite_filter: None,
2667 ignore_existing: false,
2668 chunk_size: 0,
2669 skip_specials: false,
2670 remote_copy_buffer_size: 0,
2671 filter: None,
2672 dry_run: None,
2673 delete: None,
2674 },
2675 false,
2676 )
2677 .await?;
2678 Ok(())
2679 }
2680
2681 #[tokio::test]
2682 #[traced_test]
2683 async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
2684 cp_compare(
2685 &["-r", "-p"],
2686 &Settings {
2687 dereference: false,
2688 fail_early: false,
2689 overwrite: false,
2690 overwrite_compare: filecmp::MetadataCmpSettings {
2691 size: true,
2692 mtime: true,
2693 ..Default::default()
2694 },
2695 overwrite_filter: None,
2696 ignore_existing: false,
2697 chunk_size: 0,
2698 skip_specials: false,
2699 remote_copy_buffer_size: 0,
2700 filter: None,
2701 dry_run: None,
2702 delete: None,
2703 },
2704 true,
2705 )
2706 .await?;
2707 Ok(())
2708 }
2709
2710 #[tokio::test]
2711 #[traced_test]
2712 async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
2713 cp_compare(
2714 &["-r", "-L"],
2715 &Settings {
2716 dereference: true,
2717 fail_early: false,
2718 overwrite: false,
2719 overwrite_compare: filecmp::MetadataCmpSettings {
2720 size: true,
2721 mtime: true,
2722 ..Default::default()
2723 },
2724 overwrite_filter: None,
2725 ignore_existing: false,
2726 chunk_size: 0,
2727 skip_specials: false,
2728 remote_copy_buffer_size: 0,
2729 filter: None,
2730 dry_run: None,
2731 delete: None,
2732 },
2733 false,
2734 )
2735 .await?;
2736 Ok(())
2737 }
2738
2739 #[tokio::test]
2740 #[traced_test]
2741 async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
2742 cp_compare(
2743 &["-r", "-p", "-L"],
2744 &Settings {
2745 dereference: true,
2746 fail_early: false,
2747 overwrite: false,
2748 overwrite_compare: filecmp::MetadataCmpSettings {
2749 size: true,
2750 mtime: true,
2751 ..Default::default()
2752 },
2753 overwrite_filter: None,
2754 ignore_existing: false,
2755 chunk_size: 0,
2756 skip_specials: false,
2757 remote_copy_buffer_size: 0,
2758 filter: None,
2759 dry_run: None,
2760 delete: None,
2761 },
2762 true,
2763 )
2764 .await?;
2765 Ok(())
2766 }
2767
2768 async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
2769 let tmp_dir = testutils::setup_test_dir().await?;
2770 let test_path = tmp_dir.as_path();
2771 let summary = copy(
2772 &PROGRESS,
2773 &test_path.join("foo"),
2774 &test_path.join("bar"),
2775 &Settings {
2776 dereference: false,
2777 fail_early: false,
2778 overwrite: false,
2779 overwrite_compare: filecmp::MetadataCmpSettings {
2780 size: true,
2781 mtime: true,
2782 ..Default::default()
2783 },
2784 overwrite_filter: None,
2785 ignore_existing: false,
2786 chunk_size: 0,
2787 skip_specials: false,
2788 remote_copy_buffer_size: 0,
2789 filter: None,
2790 dry_run: None,
2791 delete: None,
2792 },
2793 &DO_PRESERVE_SETTINGS,
2794 false,
2795 )
2796 .await?;
2797 assert_eq!(summary.files_copied, 5);
2798 assert_eq!(summary.symlinks_created, 2);
2799 assert_eq!(summary.directories_created, 3);
2800 Ok(tmp_dir)
2801 }
2802
2803 #[tokio::test]
2804 #[traced_test]
2805 async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
2806 let tmp_dir = setup_test_dir_and_copy().await?;
2807 let output_path = &tmp_dir.join("bar");
2808 {
2809 let summary = rm::rm(
2820 &PROGRESS,
2821 &output_path.join("bar"),
2822 &RmSettings {
2823 fail_early: false,
2824 filter: None,
2825 dry_run: None,
2826 time_filter: None,
2827 },
2828 )
2829 .await?
2830 + rm::rm(
2831 &PROGRESS,
2832 &output_path.join("baz").join("5.txt"),
2833 &RmSettings {
2834 fail_early: false,
2835 filter: None,
2836 dry_run: None,
2837 time_filter: None,
2838 },
2839 )
2840 .await?;
2841 assert_eq!(summary.files_removed, 3);
2842 assert_eq!(summary.symlinks_removed, 1);
2843 assert_eq!(summary.directories_removed, 1);
2844 }
2845 let summary = copy(
2846 &PROGRESS,
2847 &tmp_dir.join("foo"),
2848 output_path,
2849 &Settings {
2850 dereference: false,
2851 fail_early: false,
2852 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2854 size: true,
2855 mtime: true,
2856 ..Default::default()
2857 },
2858 overwrite_filter: None,
2859 ignore_existing: false,
2860 chunk_size: 0,
2861 skip_specials: false,
2862 remote_copy_buffer_size: 0,
2863 filter: None,
2864 dry_run: None,
2865 delete: None,
2866 },
2867 &DO_PRESERVE_SETTINGS,
2868 false,
2869 )
2870 .await?;
2871 assert_eq!(summary.files_copied, 3);
2872 assert_eq!(summary.symlinks_created, 1);
2873 assert_eq!(summary.directories_created, 1);
2874 testutils::check_dirs_identical(
2875 &tmp_dir.join("foo"),
2876 output_path,
2877 testutils::FileEqualityCheck::Timestamp,
2878 )
2879 .await?;
2880 Ok(())
2881 }
2882
2883 #[tokio::test]
2884 #[traced_test]
2885 async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
2886 let tmp_dir = setup_test_dir_and_copy().await?;
2887 let output_path = &tmp_dir.join("bar");
2888 {
2889 let summary = rm::rm(
2900 &PROGRESS,
2901 &output_path.join("bar").join("1.txt"),
2902 &RmSettings {
2903 fail_early: false,
2904 filter: None,
2905 dry_run: None,
2906 time_filter: None,
2907 },
2908 )
2909 .await?
2910 + rm::rm(
2911 &PROGRESS,
2912 &output_path.join("baz"),
2913 &RmSettings {
2914 fail_early: false,
2915 filter: None,
2916 dry_run: None,
2917 time_filter: None,
2918 },
2919 )
2920 .await?;
2921 assert_eq!(summary.files_removed, 2);
2922 assert_eq!(summary.symlinks_removed, 2);
2923 assert_eq!(summary.directories_removed, 1);
2924 }
2925 {
2926 tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
2928 tokio::fs::write(&output_path.join("baz"), "baz").await?;
2930 }
2931 let summary = copy(
2932 &PROGRESS,
2933 &tmp_dir.join("foo"),
2934 output_path,
2935 &Settings {
2936 dereference: false,
2937 fail_early: false,
2938 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2940 size: true,
2941 mtime: true,
2942 ..Default::default()
2943 },
2944 overwrite_filter: None,
2945 ignore_existing: false,
2946 chunk_size: 0,
2947 skip_specials: false,
2948 remote_copy_buffer_size: 0,
2949 filter: None,
2950 dry_run: None,
2951 delete: None,
2952 },
2953 &DO_PRESERVE_SETTINGS,
2954 false,
2955 )
2956 .await?;
2957 assert_eq!(summary.rm_summary.files_removed, 1);
2958 assert_eq!(summary.rm_summary.symlinks_removed, 0);
2959 assert_eq!(summary.rm_summary.directories_removed, 1);
2960 assert_eq!(summary.files_copied, 2);
2961 assert_eq!(summary.symlinks_created, 2);
2962 assert_eq!(summary.directories_created, 1);
2963 testutils::check_dirs_identical(
2964 &tmp_dir.join("foo"),
2965 output_path,
2966 testutils::FileEqualityCheck::Timestamp,
2967 )
2968 .await?;
2969 Ok(())
2970 }
2971
2972 #[tokio::test]
2973 #[traced_test]
2974 async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
2975 let tmp_dir = setup_test_dir_and_copy().await?;
2976 let output_path = &tmp_dir.join("bar");
2977 {
2978 let summary = rm::rm(
2985 &PROGRESS,
2986 &output_path.join("baz").join("4.txt"),
2987 &RmSettings {
2988 fail_early: false,
2989 filter: None,
2990 dry_run: None,
2991 time_filter: None,
2992 },
2993 )
2994 .await?
2995 + rm::rm(
2996 &PROGRESS,
2997 &output_path.join("baz").join("5.txt"),
2998 &RmSettings {
2999 fail_early: false,
3000 filter: None,
3001 dry_run: None,
3002 time_filter: None,
3003 },
3004 )
3005 .await?;
3006 assert_eq!(summary.files_removed, 1);
3007 assert_eq!(summary.symlinks_removed, 1);
3008 assert_eq!(summary.directories_removed, 0);
3009 }
3010 {
3011 tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
3013 tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
3015 }
3016 let summary = copy(
3017 &PROGRESS,
3018 &tmp_dir.join("foo"),
3019 output_path,
3020 &Settings {
3021 dereference: false,
3022 fail_early: false,
3023 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3025 size: true,
3026 mtime: true,
3027 ..Default::default()
3028 },
3029 overwrite_filter: None,
3030 ignore_existing: false,
3031 chunk_size: 0,
3032 skip_specials: false,
3033 remote_copy_buffer_size: 0,
3034 filter: None,
3035 dry_run: None,
3036 delete: None,
3037 },
3038 &DO_PRESERVE_SETTINGS,
3039 false,
3040 )
3041 .await?;
3042 assert_eq!(summary.rm_summary.files_removed, 1);
3043 assert_eq!(summary.rm_summary.symlinks_removed, 1);
3044 assert_eq!(summary.rm_summary.directories_removed, 0);
3045 assert_eq!(summary.files_copied, 1);
3046 assert_eq!(summary.symlinks_created, 1);
3047 assert_eq!(summary.directories_created, 0);
3048 testutils::check_dirs_identical(
3049 &tmp_dir.join("foo"),
3050 output_path,
3051 testutils::FileEqualityCheck::Timestamp,
3052 )
3053 .await?;
3054 Ok(())
3055 }
3056
3057 #[tokio::test]
3058 #[traced_test]
3059 async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
3060 let tmp_dir = setup_test_dir_and_copy().await?;
3061 let output_path = &tmp_dir.join("bar");
3062 {
3063 let summary = rm::rm(
3073 &PROGRESS,
3074 &output_path.join("bar"),
3075 &RmSettings {
3076 fail_early: false,
3077 filter: None,
3078 dry_run: None,
3079 time_filter: None,
3080 },
3081 )
3082 .await?
3083 + rm::rm(
3084 &PROGRESS,
3085 &output_path.join("baz").join("5.txt"),
3086 &RmSettings {
3087 fail_early: false,
3088 filter: None,
3089 dry_run: None,
3090 time_filter: None,
3091 },
3092 )
3093 .await?;
3094 assert_eq!(summary.files_removed, 3);
3095 assert_eq!(summary.symlinks_removed, 1);
3096 assert_eq!(summary.directories_removed, 1);
3097 }
3098 {
3099 tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
3101 tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
3103 }
3104 let summary = copy(
3105 &PROGRESS,
3106 &tmp_dir.join("foo"),
3107 output_path,
3108 &Settings {
3109 dereference: false,
3110 fail_early: false,
3111 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3113 size: true,
3114 mtime: true,
3115 ..Default::default()
3116 },
3117 overwrite_filter: None,
3118 ignore_existing: false,
3119 chunk_size: 0,
3120 skip_specials: false,
3121 remote_copy_buffer_size: 0,
3122 filter: None,
3123 dry_run: None,
3124 delete: None,
3125 },
3126 &DO_PRESERVE_SETTINGS,
3127 false,
3128 )
3129 .await?;
3130 assert_eq!(summary.rm_summary.files_removed, 0);
3131 assert_eq!(summary.rm_summary.symlinks_removed, 1);
3132 assert_eq!(summary.rm_summary.directories_removed, 1);
3133 assert_eq!(summary.files_copied, 3);
3134 assert_eq!(summary.symlinks_created, 1);
3135 assert_eq!(summary.directories_created, 1);
3136 assert_eq!(summary.files_unchanged, 2);
3137 assert_eq!(summary.symlinks_unchanged, 1);
3138 assert_eq!(summary.directories_unchanged, 2);
3139 testutils::check_dirs_identical(
3140 &tmp_dir.join("foo"),
3141 output_path,
3142 testutils::FileEqualityCheck::Timestamp,
3143 )
3144 .await?;
3145 Ok(())
3146 }
3147
3148 #[tokio::test]
3149 #[traced_test]
3150 async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
3151 let tmp_dir = testutils::setup_test_dir().await?;
3152 let test_path = tmp_dir.as_path();
3153 let summary = copy(
3154 &PROGRESS,
3155 &test_path.join("foo"),
3156 &test_path.join("bar"),
3157 &Settings {
3158 dereference: false,
3159 fail_early: false,
3160 overwrite: false,
3161 overwrite_compare: filecmp::MetadataCmpSettings {
3162 size: true,
3163 mtime: true,
3164 ..Default::default()
3165 },
3166 overwrite_filter: None,
3167 ignore_existing: false,
3168 chunk_size: 0,
3169 skip_specials: false,
3170 remote_copy_buffer_size: 0,
3171 filter: None,
3172 dry_run: None,
3173 delete: None,
3174 },
3175 &NO_PRESERVE_SETTINGS, false,
3177 )
3178 .await?;
3179 assert_eq!(summary.files_copied, 5);
3180 assert_eq!(summary.symlinks_created, 2);
3181 assert_eq!(summary.directories_created, 3);
3182 let source_path = &test_path.join("foo");
3183 let output_path = &tmp_dir.join("bar");
3184 tokio::fs::set_permissions(
3186 &source_path.join("bar"),
3187 std::fs::Permissions::from_mode(0o000),
3188 )
3189 .await?;
3190 tokio::fs::set_permissions(
3191 &source_path.join("baz").join("4.txt"),
3192 std::fs::Permissions::from_mode(0o000),
3193 )
3194 .await?;
3195 match copy(
3203 &PROGRESS,
3204 &tmp_dir.join("foo"),
3205 output_path,
3206 &Settings {
3207 dereference: false,
3208 fail_early: false,
3209 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3211 size: true,
3212 mtime: true,
3213 ..Default::default()
3214 },
3215 overwrite_filter: None,
3216 ignore_existing: false,
3217 chunk_size: 0,
3218 skip_specials: false,
3219 remote_copy_buffer_size: 0,
3220 filter: None,
3221 dry_run: None,
3222 delete: None,
3223 },
3224 &DO_PRESERVE_SETTINGS,
3225 false,
3226 )
3227 .await
3228 {
3229 Ok(_) => panic!("Expected the copy to error!"),
3230 Err(error) => {
3231 tracing::info!("{}", &error);
3232 assert_eq!(error.summary.files_copied, 1);
3233 assert_eq!(error.summary.symlinks_created, 2);
3234 assert_eq!(error.summary.directories_created, 0);
3235 assert_eq!(error.summary.rm_summary.files_removed, 2);
3236 assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
3237 assert_eq!(error.summary.rm_summary.directories_removed, 0);
3238 }
3239 }
3240 Ok(())
3241 }
3242
3243 #[tokio::test]
3244 #[traced_test]
3245 async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
3246 let tmp_dir = testutils::create_temp_dir().await?;
3248 let test_path = tmp_dir.as_path();
3249 let baz_file = test_path.join("baz_file.txt");
3251 tokio::fs::write(&baz_file, "final content").await?;
3252 let bar_link = test_path.join("bar_link");
3253 let foo_link = test_path.join("foo_link");
3254 tokio::fs::symlink(&baz_file, &bar_link).await?;
3256 tokio::fs::symlink(&bar_link, &foo_link).await?;
3257 let src_dir = test_path.join("src_chain");
3259 tokio::fs::create_dir(&src_dir).await?;
3260 tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
3262 tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
3263 tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
3264 let summary = copy(
3266 &PROGRESS,
3267 &src_dir,
3268 &test_path.join("dst_with_deref"),
3269 &Settings {
3270 dereference: true, fail_early: false,
3272 overwrite: false,
3273 overwrite_compare: filecmp::MetadataCmpSettings {
3274 size: true,
3275 mtime: true,
3276 ..Default::default()
3277 },
3278 overwrite_filter: None,
3279 ignore_existing: false,
3280 chunk_size: 0,
3281 skip_specials: false,
3282 remote_copy_buffer_size: 0,
3283 filter: None,
3284 dry_run: None,
3285 delete: None,
3286 },
3287 &NO_PRESERVE_SETTINGS,
3288 false,
3289 )
3290 .await?;
3291 assert_eq!(summary.files_copied, 3); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1);
3294 let dst_dir = test_path.join("dst_with_deref");
3295 let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
3297 let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
3298 let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
3299 assert_eq!(foo_content, "final content");
3300 assert_eq!(bar_content, "final content");
3301 assert_eq!(baz_content, "final content");
3302 assert!(dst_dir.join("foo").is_file());
3304 assert!(dst_dir.join("bar").is_file());
3305 assert!(dst_dir.join("baz").is_file());
3306 assert!(!dst_dir.join("foo").is_symlink());
3307 assert!(!dst_dir.join("bar").is_symlink());
3308 assert!(!dst_dir.join("baz").is_symlink());
3309 Ok(())
3310 }
3311
3312 #[tokio::test]
3313 #[traced_test]
3314 async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
3315 let tmp_dir = testutils::create_temp_dir().await?;
3316 let test_path = tmp_dir.as_path();
3317 let target_dir = test_path.join("target_dir");
3319 tokio::fs::create_dir(&target_dir).await?;
3320 tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
3321 tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
3323 tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
3324 tokio::fs::set_permissions(
3325 &target_dir.join("file1.txt"),
3326 std::fs::Permissions::from_mode(0o644),
3327 )
3328 .await?;
3329 tokio::fs::set_permissions(
3330 &target_dir.join("file2.txt"),
3331 std::fs::Permissions::from_mode(0o600),
3332 )
3333 .await?;
3334 let dir_symlink = test_path.join("dir_symlink");
3336 tokio::fs::symlink(&target_dir, &dir_symlink).await?;
3337 let summary = copy(
3339 &PROGRESS,
3340 &dir_symlink,
3341 &test_path.join("copied_dir"),
3342 &Settings {
3343 dereference: true, fail_early: false,
3345 overwrite: false,
3346 overwrite_compare: filecmp::MetadataCmpSettings {
3347 size: true,
3348 mtime: true,
3349 ..Default::default()
3350 },
3351 overwrite_filter: None,
3352 ignore_existing: false,
3353 chunk_size: 0,
3354 skip_specials: false,
3355 remote_copy_buffer_size: 0,
3356 filter: None,
3357 dry_run: None,
3358 delete: None,
3359 },
3360 &DO_PRESERVE_SETTINGS,
3361 false,
3362 )
3363 .await?;
3364 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");
3368 assert!(copied_dir.is_dir());
3370 assert!(!copied_dir.is_symlink()); let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
3373 let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
3374 assert_eq!(file1_content, "content1");
3375 assert_eq!(file2_content, "content2");
3376 let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
3378 let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
3379 let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
3380 assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
3381 assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
3382 assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
3383 Ok(())
3384 }
3385
3386 #[tokio::test]
3387 #[traced_test]
3388 async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
3389 let tmp_dir = testutils::create_temp_dir().await?;
3390 let test_path = tmp_dir.as_path();
3391 let file1 = test_path.join("file1.txt");
3393 let file2 = test_path.join("file2.txt");
3394 tokio::fs::write(&file1, "content1").await?;
3395 tokio::fs::write(&file2, "content2").await?;
3396 tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
3397 tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
3398 let symlink1 = test_path.join("symlink1");
3400 let symlink2 = test_path.join("symlink2");
3401 tokio::fs::symlink(&file1, &symlink1).await?;
3402 tokio::fs::symlink(&file2, &symlink2).await?;
3403 let summary1 = copy(
3405 &PROGRESS,
3406 &symlink1,
3407 &test_path.join("copied_file1.txt"),
3408 &Settings {
3409 dereference: true, fail_early: false,
3411 overwrite: false,
3412 overwrite_compare: filecmp::MetadataCmpSettings::default(),
3413 overwrite_filter: None,
3414 ignore_existing: false,
3415 chunk_size: 0,
3416 skip_specials: false,
3417 remote_copy_buffer_size: 0,
3418 filter: None,
3419 dry_run: None,
3420 delete: None,
3421 },
3422 &DO_PRESERVE_SETTINGS, false,
3424 )
3425 .await?;
3426 let summary2 = copy(
3427 &PROGRESS,
3428 &symlink2,
3429 &test_path.join("copied_file2.txt"),
3430 &Settings {
3431 dereference: true,
3432 fail_early: false,
3433 overwrite: false,
3434 overwrite_compare: filecmp::MetadataCmpSettings::default(),
3435 overwrite_filter: None,
3436 ignore_existing: false,
3437 chunk_size: 0,
3438 skip_specials: false,
3439 remote_copy_buffer_size: 0,
3440 filter: None,
3441 dry_run: None,
3442 delete: None,
3443 },
3444 &DO_PRESERVE_SETTINGS,
3445 false,
3446 )
3447 .await?;
3448 assert_eq!(summary1.files_copied, 1);
3449 assert_eq!(summary1.symlinks_created, 0);
3450 assert_eq!(summary2.files_copied, 1);
3451 assert_eq!(summary2.symlinks_created, 0);
3452 let copied1 = test_path.join("copied_file1.txt");
3453 let copied2 = test_path.join("copied_file2.txt");
3454 assert!(copied1.is_file());
3456 assert!(!copied1.is_symlink());
3457 assert!(copied2.is_file());
3458 assert!(!copied2.is_symlink());
3459 let content1 = tokio::fs::read_to_string(&copied1).await?;
3461 let content2 = tokio::fs::read_to_string(&copied2).await?;
3462 assert_eq!(content1, "content1");
3463 assert_eq!(content2, "content2");
3464 let copied1_metadata = tokio::fs::metadata(&copied1).await?;
3466 let copied2_metadata = tokio::fs::metadata(&copied2).await?;
3467 assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
3468 assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
3469 Ok(())
3470 }
3471
3472 #[tokio::test]
3473 #[traced_test]
3474 async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
3475 let tmp_dir = testutils::setup_test_dir().await?;
3476 tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
3478 tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
3480 let summary = copy(
3481 &PROGRESS,
3482 &tmp_dir.join("foo"),
3483 &tmp_dir.join("bar"),
3484 &Settings {
3485 dereference: true, fail_early: false,
3487 overwrite: false,
3488 overwrite_compare: filecmp::MetadataCmpSettings {
3489 size: true,
3490 mtime: true,
3491 ..Default::default()
3492 },
3493 overwrite_filter: None,
3494 ignore_existing: false,
3495 chunk_size: 0,
3496 skip_specials: false,
3497 remote_copy_buffer_size: 0,
3498 filter: None,
3499 dry_run: None,
3500 delete: None,
3501 },
3502 &DO_PRESERVE_SETTINGS,
3503 false,
3504 )
3505 .await?;
3506 assert_eq!(summary.files_copied, 13); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 5);
3509 tokio::process::Command::new("cp")
3511 .args(["-r", "-L"])
3512 .arg(tmp_dir.join("foo"))
3513 .arg(tmp_dir.join("bar-cp"))
3514 .output()
3515 .await?;
3516 testutils::check_dirs_identical(
3517 &tmp_dir.join("bar"),
3518 &tmp_dir.join("bar-cp"),
3519 testutils::FileEqualityCheck::Basic,
3520 )
3521 .await?;
3522 Ok(())
3523 }
3524
3525 mod error_message_tests {
3527 use super::*;
3528
3529 fn get_full_error_message(error: &Error) -> String {
3531 format!("{:#}", error.source)
3532 }
3533
3534 #[tokio::test]
3535 #[traced_test]
3536 async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
3537 let tmp_dir = testutils::create_temp_dir().await?;
3538
3539 let result = copy(
3540 &PROGRESS,
3541 &tmp_dir.join("does_not_exist.txt"),
3542 &tmp_dir.join("dest.txt"),
3543 &Settings {
3544 dereference: false,
3545 fail_early: false,
3546 overwrite: false,
3547 overwrite_compare: Default::default(),
3548 overwrite_filter: None,
3549 ignore_existing: false,
3550 chunk_size: 0,
3551 skip_specials: false,
3552 remote_copy_buffer_size: 0,
3553 filter: None,
3554 dry_run: None,
3555 delete: None,
3556 },
3557 &NO_PRESERVE_SETTINGS,
3558 false,
3559 )
3560 .await;
3561
3562 assert!(result.is_err());
3563 let err_msg = get_full_error_message(&result.unwrap_err());
3564
3565 assert!(
3566 err_msg.to_lowercase().contains("no such file")
3567 || err_msg.to_lowercase().contains("not found")
3568 || err_msg.contains("ENOENT"),
3569 "Error message must include file not found text. Got: {}",
3570 err_msg
3571 );
3572 Ok(())
3573 }
3574
3575 #[tokio::test]
3576 #[traced_test]
3577 async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
3578 let tmp_dir = testutils::create_temp_dir().await?;
3579 let unreadable_dir = tmp_dir.join("unreadable_dir");
3580 tokio::fs::create_dir(&unreadable_dir).await?;
3581 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
3582 .await?;
3583
3584 let result = copy(
3585 &PROGRESS,
3586 &unreadable_dir,
3587 &tmp_dir.join("dest"),
3588 &Settings {
3589 dereference: false,
3590 fail_early: true,
3591 overwrite: false,
3592 overwrite_compare: Default::default(),
3593 overwrite_filter: None,
3594 ignore_existing: false,
3595 chunk_size: 0,
3596 skip_specials: false,
3597 remote_copy_buffer_size: 0,
3598 filter: None,
3599 dry_run: None,
3600 delete: None,
3601 },
3602 &NO_PRESERVE_SETTINGS,
3603 false,
3604 )
3605 .await;
3606
3607 assert!(result.is_err());
3608 let err_msg = get_full_error_message(&result.unwrap_err());
3609
3610 assert!(
3611 err_msg.to_lowercase().contains("permission")
3612 || err_msg.contains("EACCES")
3613 || err_msg.contains("denied"),
3614 "Error message must include permission-related text. Got: {}",
3615 err_msg
3616 );
3617
3618 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
3620 .await?;
3621 Ok(())
3622 }
3623
3624 #[tokio::test]
3625 #[traced_test]
3626 async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
3627 {
3628 let tmp_dir = testutils::setup_test_dir().await?;
3629 let test_path = tmp_dir.as_path();
3630 let readonly_parent = test_path.join("readonly_dest");
3631 tokio::fs::create_dir(&readonly_parent).await?;
3632 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3633 .await?;
3634
3635 let result = copy(
3636 &PROGRESS,
3637 &test_path.join("foo"),
3638 &readonly_parent.join("copy"),
3639 &Settings {
3640 dereference: false,
3641 fail_early: true,
3642 overwrite: false,
3643 overwrite_compare: Default::default(),
3644 overwrite_filter: None,
3645 ignore_existing: false,
3646 chunk_size: 0,
3647 skip_specials: false,
3648 remote_copy_buffer_size: 0,
3649 filter: None,
3650 dry_run: None,
3651 delete: None,
3652 },
3653 &NO_PRESERVE_SETTINGS,
3654 false,
3655 )
3656 .await;
3657
3658 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
3660 .await?;
3661
3662 assert!(result.is_err(), "copy into read-only parent should fail");
3663 let err_msg = get_full_error_message(&result.unwrap_err());
3664
3665 assert!(
3666 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
3667 "Error message must include permission denied text. Got: {}",
3668 err_msg
3669 );
3670 Ok(())
3671 }
3672 }
3673
3674 mod empty_dir_cleanup_tests {
3675 use super::*;
3676 use crate::filter::FilterSettings;
3677 use std::path::Path;
3678 #[test]
3679 fn test_check_empty_dir_cleanup_no_filter() {
3680 assert_eq!(
3682 check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
3683 EmptyDirAction::Keep
3684 );
3685 }
3686 #[test]
3687 fn test_check_empty_dir_cleanup_something_copied() {
3688 let mut filter = FilterSettings::new();
3690 filter.add_include("*.txt").unwrap();
3691 assert_eq!(
3692 check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
3693 EmptyDirAction::Keep
3694 );
3695 }
3696 #[test]
3697 fn test_check_empty_dir_cleanup_not_created() {
3698 let mut filter = FilterSettings::new();
3700 filter.add_include("*.txt").unwrap();
3701 assert_eq!(
3702 check_empty_dir_cleanup(
3703 Some(&filter),
3704 false,
3705 false,
3706 Path::new("any"),
3707 false,
3708 false
3709 ),
3710 EmptyDirAction::Keep
3711 );
3712 }
3713 #[test]
3714 fn test_check_empty_dir_cleanup_directly_matched() {
3715 let mut filter = FilterSettings::new();
3717 filter.add_include("target/").unwrap();
3718 assert_eq!(
3719 check_empty_dir_cleanup(
3720 Some(&filter),
3721 true,
3722 false,
3723 Path::new("target"),
3724 false,
3725 false
3726 ),
3727 EmptyDirAction::Keep
3728 );
3729 }
3730 #[test]
3731 fn test_check_empty_dir_cleanup_traversed_only() {
3732 let mut filter = FilterSettings::new();
3734 filter.add_include("*.txt").unwrap();
3735 assert_eq!(
3736 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
3737 EmptyDirAction::Remove
3738 );
3739 }
3740 #[test]
3741 fn test_check_empty_dir_cleanup_dry_run() {
3742 let mut filter = FilterSettings::new();
3744 filter.add_include("*.txt").unwrap();
3745 assert_eq!(
3746 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
3747 EmptyDirAction::DryRunSkip
3748 );
3749 }
3750 #[test]
3751 fn test_check_empty_dir_cleanup_root_always_kept() {
3752 let mut filter = FilterSettings::new();
3754 filter.add_include("*.txt").unwrap();
3755 assert_eq!(
3756 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
3757 EmptyDirAction::Keep
3758 );
3759 }
3760 #[test]
3761 fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
3762 let mut filter = FilterSettings::new();
3764 filter.add_include("*.txt").unwrap();
3765 assert_eq!(
3766 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
3767 EmptyDirAction::Keep
3768 );
3769 }
3770 }
3771
3772 #[tokio::test]
3776 #[traced_test]
3777 async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
3778 let tmp_dir = testutils::create_temp_dir().await?;
3779 let test_path = tmp_dir.as_path();
3780 let src_dir = test_path.join("src");
3782 tokio::fs::create_dir(&src_dir).await?;
3783 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
3784 let readable_file = src_dir.join("readable.txt");
3786 tokio::fs::write(&readable_file, "content").await?;
3787 let unreadable_file = src_dir.join("unreadable.txt");
3788 tokio::fs::write(&unreadable_file, "secret").await?;
3789 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3790 .await?;
3791 let dst_dir = test_path.join("dst");
3792 let result = copy(
3794 &PROGRESS,
3795 &src_dir,
3796 &dst_dir,
3797 &Settings {
3798 dereference: false,
3799 fail_early: false,
3800 overwrite: false,
3801 overwrite_compare: Default::default(),
3802 overwrite_filter: None,
3803 ignore_existing: false,
3804 chunk_size: 0,
3805 skip_specials: false,
3806 remote_copy_buffer_size: 0,
3807 filter: None,
3808 dry_run: None,
3809 delete: None,
3810 },
3811 &DO_PRESERVE_SETTINGS,
3812 false,
3813 )
3814 .await;
3815 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");
3820 let error = result.unwrap_err();
3821 assert_eq!(error.summary.files_copied, 1);
3823 assert_eq!(error.summary.directories_created, 1);
3824 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3826 assert!(dst_metadata.is_dir());
3827 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
3828 assert_eq!(
3829 actual_mode, 0o750,
3830 "directory should have preserved source permissions (0o750), got {:o}",
3831 actual_mode
3832 );
3833 Ok(())
3834 }
3835
3836 #[tokio::test]
3840 #[traced_test]
3841 async fn pruned_empty_traversal_dir_still_surfaces_child_error() -> Result<(), anyhow::Error> {
3842 let tmp_dir = testutils::create_temp_dir().await?;
3843 let test_path = tmp_dir.as_path();
3844 let src_dir = test_path.join("src");
3845 let sub = src_dir.join("sub");
3846 tokio::fs::create_dir_all(&sub).await?;
3847 let unreadable = sub.join("file.txt");
3850 tokio::fs::write(&unreadable, "secret").await?;
3851 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3852 let dst_dir = test_path.join("dst");
3853 let mut filter = crate::filter::FilterSettings::new();
3854 filter.add_include("*.txt").unwrap();
3855 let result = copy(
3856 &PROGRESS,
3857 &src_dir,
3858 &dst_dir,
3859 &Settings {
3860 dereference: false,
3861 fail_early: false,
3862 overwrite: false,
3863 overwrite_compare: Default::default(),
3864 overwrite_filter: None,
3865 ignore_existing: false,
3866 chunk_size: 0,
3867 skip_specials: false,
3868 remote_copy_buffer_size: 0,
3869 filter: Some(filter),
3870 dry_run: None,
3871 delete: None,
3872 },
3873 &DO_PRESERVE_SETTINGS,
3874 false,
3875 )
3876 .await;
3877 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).await?;
3879 assert!(
3880 result.is_err(),
3881 "the failed child copy must surface even though the empty traversal dir was pruned"
3882 );
3883 assert!(
3885 !dst_dir.join("sub").exists(),
3886 "empty traversal directory should have been removed"
3887 );
3888 Ok(())
3889 }
3890
3891 #[tokio::test]
3893 #[traced_test]
3894 async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error()
3895 -> Result<(), anyhow::Error> {
3896 let tmp_dir = testutils::create_temp_dir().await?;
3897 let test_path = tmp_dir.as_path();
3898 let src_dir = test_path.join("src");
3899 tokio::fs::create_dir(&src_dir).await?;
3900 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
3901 let unreadable_file = src_dir.join("unreadable.txt");
3902 tokio::fs::write(&unreadable_file, "secret").await?;
3903 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3904 .await?;
3905 let fixed_secs = 946684800;
3906 let fixed_nsec = 123_456_789;
3907 let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
3908 nix::sys::stat::utimensat(
3909 nix::fcntl::AT_FDCWD,
3910 &src_dir,
3911 &fixed_time,
3912 &fixed_time,
3913 nix::sys::stat::UtimensatFlags::NoFollowSymlink,
3914 )?;
3915 let src_metadata = tokio::fs::metadata(&src_dir).await?;
3916 let dst_dir = test_path.join("dst");
3917 let result = copy(
3918 &PROGRESS,
3919 &src_dir,
3920 &dst_dir,
3921 &Settings {
3922 dereference: false,
3923 fail_early: true,
3924 overwrite: false,
3925 overwrite_compare: Default::default(),
3926 overwrite_filter: None,
3927 ignore_existing: false,
3928 chunk_size: 0,
3929 skip_specials: false,
3930 remote_copy_buffer_size: 0,
3931 filter: None,
3932 dry_run: None,
3933 delete: None,
3934 },
3935 &DO_PRESERVE_SETTINGS,
3936 false,
3937 )
3938 .await;
3939 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
3940 .await?;
3941 assert!(result.is_err(), "copy should fail due to unreadable file");
3942 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3943 assert!(dst_metadata.is_dir());
3944 assert_ne!(
3945 (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
3946 (src_metadata.mtime(), src_metadata.mtime_nsec()),
3947 "fail-early should return before applying preserved directory timestamps"
3948 );
3949 Ok(())
3950 }
3951 mod filter_tests {
3952 use super::*;
3953 use crate::filter::FilterSettings;
3954 #[tokio::test]
3958 #[traced_test]
3959 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
3960 let tmp_dir = testutils::setup_test_dir().await?;
3961 let test_path = tmp_dir.as_path();
3962 let mut filter = FilterSettings::new();
3974 filter.add_include("bar/*.txt").unwrap();
3975 let summary = copy(
3976 &PROGRESS,
3977 &test_path.join("foo"),
3978 &test_path.join("dst"),
3979 &Settings {
3980 dereference: false,
3981 fail_early: false,
3982 overwrite: false,
3983 overwrite_compare: Default::default(),
3984 overwrite_filter: None,
3985 ignore_existing: false,
3986 chunk_size: 0,
3987 skip_specials: false,
3988 remote_copy_buffer_size: 0,
3989 filter: Some(filter),
3990 dry_run: None,
3991 delete: None,
3992 },
3993 &NO_PRESERVE_SETTINGS,
3994 false,
3995 )
3996 .await?;
3997 assert_eq!(
4000 summary.files_copied, 3,
4001 "should copy 3 files matching bar/*.txt"
4002 );
4003 assert!(
4005 test_path.join("dst/bar/1.txt").exists(),
4006 "bar/1.txt should be copied"
4007 );
4008 assert!(
4009 test_path.join("dst/bar/2.txt").exists(),
4010 "bar/2.txt should be copied"
4011 );
4012 assert!(
4013 test_path.join("dst/bar/3.txt").exists(),
4014 "bar/3.txt should be copied"
4015 );
4016 assert!(
4018 !test_path.join("dst/0.txt").exists(),
4019 "0.txt should not be copied"
4020 );
4021 Ok(())
4022 }
4023 #[tokio::test]
4025 #[traced_test]
4026 async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
4027 let tmp_dir = testutils::setup_test_dir().await?;
4028 let test_path = tmp_dir.as_path();
4029 let mut filter = FilterSettings::new();
4031 filter.add_include("/bar/**").unwrap();
4032 let summary = copy(
4033 &PROGRESS,
4034 &test_path.join("foo"),
4035 &test_path.join("dst"),
4036 &Settings {
4037 dereference: false,
4038 fail_early: false,
4039 overwrite: false,
4040 overwrite_compare: Default::default(),
4041 overwrite_filter: None,
4042 ignore_existing: false,
4043 chunk_size: 0,
4044 skip_specials: false,
4045 remote_copy_buffer_size: 0,
4046 filter: Some(filter),
4047 dry_run: None,
4048 delete: None,
4049 },
4050 &NO_PRESERVE_SETTINGS,
4051 false,
4052 )
4053 .await?;
4054 assert!(
4056 test_path.join("dst/bar").exists(),
4057 "bar directory should be copied"
4058 );
4059 assert!(
4060 !test_path.join("dst/baz").exists(),
4061 "baz directory should not be copied"
4062 );
4063 assert!(
4064 !test_path.join("dst/0.txt").exists(),
4065 "0.txt should not be copied"
4066 );
4067 assert_eq!(
4069 summary.files_copied, 3,
4070 "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
4071 );
4072 assert_eq!(
4073 summary.directories_created, 2,
4074 "should create 2 directories (root dst + bar)"
4075 );
4076 assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
4078 assert_eq!(
4079 summary.directories_skipped, 1,
4080 "should skip 1 directory (baz)"
4081 );
4082 Ok(())
4083 }
4084 #[tokio::test]
4086 #[traced_test]
4087 async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
4088 let tmp_dir = testutils::setup_test_dir().await?;
4089 let test_path = tmp_dir.as_path();
4090 let mut filter = FilterSettings::new();
4092 filter.add_include("**/*.txt").unwrap();
4093 let summary = copy(
4094 &PROGRESS,
4095 &test_path.join("foo"),
4096 &test_path.join("dst"),
4097 &Settings {
4098 dereference: false,
4099 fail_early: false,
4100 overwrite: false,
4101 overwrite_compare: Default::default(),
4102 overwrite_filter: None,
4103 ignore_existing: false,
4104 chunk_size: 0,
4105 skip_specials: false,
4106 remote_copy_buffer_size: 0,
4107 filter: Some(filter),
4108 dry_run: None,
4109 delete: None,
4110 },
4111 &NO_PRESERVE_SETTINGS,
4112 false,
4113 )
4114 .await?;
4115 assert_eq!(
4117 summary.files_copied, 5,
4118 "should copy all 5 .txt files with **/*.txt pattern"
4119 );
4120 Ok(())
4121 }
4122 #[tokio::test]
4124 #[traced_test]
4125 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
4126 let tmp_dir = testutils::setup_test_dir().await?;
4127 let test_path = tmp_dir.as_path();
4128 let mut filter = FilterSettings::new();
4130 filter.add_exclude("*.txt").unwrap();
4131 let result = copy(
4132 &PROGRESS,
4133 &test_path.join("foo/0.txt"), &test_path.join("dst.txt"),
4135 &Settings {
4136 dereference: false,
4137 fail_early: false,
4138 overwrite: false,
4139 overwrite_compare: Default::default(),
4140 overwrite_filter: None,
4141 ignore_existing: false,
4142 chunk_size: 0,
4143 skip_specials: false,
4144 remote_copy_buffer_size: 0,
4145 filter: Some(filter),
4146 dry_run: None,
4147 delete: None,
4148 },
4149 &NO_PRESERVE_SETTINGS,
4150 false,
4151 )
4152 .await?;
4153 assert_eq!(
4155 result.files_copied, 0,
4156 "file matching exclude pattern should not be copied"
4157 );
4158 assert!(
4159 !test_path.join("dst.txt").exists(),
4160 "excluded file should not exist at destination"
4161 );
4162 Ok(())
4163 }
4164 #[tokio::test]
4166 #[traced_test]
4167 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
4168 let test_path = testutils::create_temp_dir().await?;
4169 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
4171 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
4172 let mut filter = FilterSettings::new();
4174 filter.add_exclude("*_dir/").unwrap();
4175 let result = copy(
4176 &PROGRESS,
4177 &test_path.join("excluded_dir"),
4178 &test_path.join("dst"),
4179 &Settings {
4180 dereference: false,
4181 fail_early: false,
4182 overwrite: false,
4183 overwrite_compare: Default::default(),
4184 overwrite_filter: None,
4185 ignore_existing: false,
4186 chunk_size: 0,
4187 skip_specials: false,
4188 remote_copy_buffer_size: 0,
4189 filter: Some(filter),
4190 dry_run: None,
4191 delete: None,
4192 },
4193 &NO_PRESERVE_SETTINGS,
4194 false,
4195 )
4196 .await?;
4197 assert_eq!(
4199 result.directories_created, 0,
4200 "root directory matching exclude should not be created"
4201 );
4202 assert!(
4203 !test_path.join("dst").exists(),
4204 "excluded root directory should not exist at destination"
4205 );
4206 Ok(())
4207 }
4208 #[tokio::test]
4210 #[traced_test]
4211 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
4212 let test_path = testutils::create_temp_dir().await?;
4213 tokio::fs::write(test_path.join("target.txt"), "content").await?;
4215 tokio::fs::symlink(
4216 test_path.join("target.txt"),
4217 test_path.join("excluded_link"),
4218 )
4219 .await?;
4220 let mut filter = FilterSettings::new();
4222 filter.add_exclude("*_link").unwrap();
4223 let result = copy(
4224 &PROGRESS,
4225 &test_path.join("excluded_link"),
4226 &test_path.join("dst"),
4227 &Settings {
4228 dereference: false,
4229 fail_early: false,
4230 overwrite: false,
4231 overwrite_compare: Default::default(),
4232 overwrite_filter: None,
4233 ignore_existing: false,
4234 chunk_size: 0,
4235 skip_specials: false,
4236 remote_copy_buffer_size: 0,
4237 filter: Some(filter),
4238 dry_run: None,
4239 delete: None,
4240 },
4241 &NO_PRESERVE_SETTINGS,
4242 false,
4243 )
4244 .await?;
4245 assert_eq!(
4247 result.symlinks_created, 0,
4248 "root symlink matching exclude should not be created"
4249 );
4250 assert!(
4251 !test_path.join("dst").exists(),
4252 "excluded root symlink should not exist at destination"
4253 );
4254 Ok(())
4255 }
4256 #[tokio::test]
4258 #[traced_test]
4259 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
4260 let tmp_dir = testutils::setup_test_dir().await?;
4261 let test_path = tmp_dir.as_path();
4262 let mut filter = FilterSettings::new();
4269 filter.add_include("**/*.txt").unwrap();
4270 filter.add_exclude("bar/2.txt").unwrap();
4271 let summary = copy(
4272 &PROGRESS,
4273 &test_path.join("foo"),
4274 &test_path.join("dst"),
4275 &Settings {
4276 dereference: false,
4277 fail_early: false,
4278 overwrite: false,
4279 overwrite_compare: Default::default(),
4280 overwrite_filter: None,
4281 ignore_existing: false,
4282 chunk_size: 0,
4283 skip_specials: false,
4284 remote_copy_buffer_size: 0,
4285 filter: Some(filter),
4286 dry_run: None,
4287 delete: None,
4288 },
4289 &NO_PRESERVE_SETTINGS,
4290 false,
4291 )
4292 .await?;
4293 assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
4297 assert_eq!(
4298 summary.files_skipped, 1,
4299 "should skip 1 file (bar/2.txt excluded)"
4300 );
4301 assert!(
4303 test_path.join("dst/bar/1.txt").exists(),
4304 "bar/1.txt should be copied"
4305 );
4306 assert!(
4307 !test_path.join("dst/bar/2.txt").exists(),
4308 "bar/2.txt should be excluded"
4309 );
4310 assert!(
4311 test_path.join("dst/bar/3.txt").exists(),
4312 "bar/3.txt should be copied"
4313 );
4314 Ok(())
4315 }
4316 #[tokio::test]
4318 #[traced_test]
4319 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
4320 let tmp_dir = testutils::setup_test_dir().await?;
4321 let test_path = tmp_dir.as_path();
4322 let mut filter = FilterSettings::new();
4329 filter.add_exclude("bar/").unwrap();
4330 let summary = copy(
4331 &PROGRESS,
4332 &test_path.join("foo"),
4333 &test_path.join("dst"),
4334 &Settings {
4335 dereference: false,
4336 fail_early: false,
4337 overwrite: false,
4338 overwrite_compare: Default::default(),
4339 overwrite_filter: None,
4340 ignore_existing: false,
4341 chunk_size: 0,
4342 skip_specials: false,
4343 remote_copy_buffer_size: 0,
4344 filter: Some(filter),
4345 dry_run: None,
4346 delete: None,
4347 },
4348 &NO_PRESERVE_SETTINGS,
4349 false,
4350 )
4351 .await?;
4352 assert_eq!(summary.files_copied, 2, "should copy 2 files");
4356 assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
4357 assert_eq!(
4358 summary.directories_created, 2,
4359 "should create 2 directories"
4360 );
4361 assert_eq!(
4362 summary.directories_skipped, 1,
4363 "should skip 1 directory (bar)"
4364 );
4365 assert_eq!(
4366 summary.files_skipped, 0,
4367 "no files skipped (bar contents not counted)"
4368 );
4369 Ok(())
4370 }
4371 #[tokio::test]
4374 #[traced_test]
4375 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
4376 let test_path = testutils::create_temp_dir().await?;
4377 let src_path = test_path.join("src");
4383 tokio::fs::create_dir(&src_path).await?;
4384 tokio::fs::write(src_path.join("foo"), "content").await?;
4385 tokio::fs::write(src_path.join("bar"), "content").await?;
4386 tokio::fs::create_dir(src_path.join("baz")).await?;
4387 let mut filter = FilterSettings::new();
4389 filter.add_include("foo").unwrap();
4390 let summary = copy(
4391 &PROGRESS,
4392 &src_path,
4393 &test_path.join("dst"),
4394 &Settings {
4395 dereference: false,
4396 fail_early: false,
4397 overwrite: false,
4398 overwrite_compare: Default::default(),
4399 overwrite_filter: None,
4400 ignore_existing: false,
4401 chunk_size: 0,
4402 skip_specials: false,
4403 remote_copy_buffer_size: 0,
4404 filter: Some(filter),
4405 dry_run: None,
4406 delete: None,
4407 },
4408 &NO_PRESERVE_SETTINGS,
4409 false,
4410 )
4411 .await?;
4412 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4414 assert_eq!(
4415 summary.directories_created, 1,
4416 "should create only root directory (not empty 'baz')"
4417 );
4418 assert!(
4420 test_path.join("dst").join("foo").exists(),
4421 "foo should be copied"
4422 );
4423 assert!(
4425 !test_path.join("dst").join("bar").exists(),
4426 "bar should not be copied"
4427 );
4428 assert!(
4430 !test_path.join("dst").join("baz").exists(),
4431 "empty baz directory should NOT be created"
4432 );
4433 Ok(())
4434 }
4435 #[tokio::test]
4438 #[traced_test]
4439 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
4440 let test_path = testutils::create_temp_dir().await?;
4441 let src_path = test_path.join("src");
4448 tokio::fs::create_dir(&src_path).await?;
4449 tokio::fs::write(src_path.join("foo"), "content").await?;
4450 tokio::fs::create_dir(src_path.join("baz")).await?;
4451 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
4452 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
4453 let mut filter = FilterSettings::new();
4455 filter.add_include("foo").unwrap();
4456 let summary = copy(
4457 &PROGRESS,
4458 &src_path,
4459 &test_path.join("dst"),
4460 &Settings {
4461 dereference: false,
4462 fail_early: false,
4463 overwrite: false,
4464 overwrite_compare: Default::default(),
4465 overwrite_filter: None,
4466 ignore_existing: false,
4467 chunk_size: 0,
4468 skip_specials: false,
4469 remote_copy_buffer_size: 0,
4470 filter: Some(filter),
4471 dry_run: None,
4472 delete: None,
4473 },
4474 &NO_PRESERVE_SETTINGS,
4475 false,
4476 )
4477 .await?;
4478 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4480 assert_eq!(
4481 summary.files_skipped, 2,
4482 "should skip 2 files (qux and quux)"
4483 );
4484 assert_eq!(
4485 summary.directories_created, 1,
4486 "should create only root directory (not 'baz' with non-matching content)"
4487 );
4488 assert!(
4490 test_path.join("dst").join("foo").exists(),
4491 "foo should be copied"
4492 );
4493 assert!(
4495 !test_path.join("dst").join("baz").exists(),
4496 "baz directory should NOT be created (no matching content inside)"
4497 );
4498 Ok(())
4499 }
4500 #[tokio::test]
4503 #[traced_test]
4504 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
4505 let test_path = testutils::create_temp_dir().await?;
4506 let src_path = test_path.join("src");
4512 tokio::fs::create_dir(&src_path).await?;
4513 tokio::fs::write(src_path.join("foo"), "content").await?;
4514 tokio::fs::write(src_path.join("bar"), "content").await?;
4515 tokio::fs::create_dir(src_path.join("baz")).await?;
4516 let mut filter = FilterSettings::new();
4518 filter.add_include("foo").unwrap();
4519 let summary = copy(
4520 &PROGRESS,
4521 &src_path,
4522 &test_path.join("dst"),
4523 &Settings {
4524 dereference: false,
4525 fail_early: false,
4526 overwrite: false,
4527 overwrite_compare: Default::default(),
4528 overwrite_filter: None,
4529 ignore_existing: false,
4530 chunk_size: 0,
4531 skip_specials: false,
4532 remote_copy_buffer_size: 0,
4533 filter: Some(filter),
4534 dry_run: Some(crate::config::DryRunMode::Explain),
4535 delete: None,
4536 },
4537 &NO_PRESERVE_SETTINGS,
4538 false,
4539 )
4540 .await?;
4541 assert_eq!(
4543 summary.files_copied, 1,
4544 "should report only 'foo' would be copied"
4545 );
4546 assert_eq!(
4547 summary.directories_created, 1,
4548 "should report only root directory would be created (not empty 'baz')"
4549 );
4550 assert!(
4552 !test_path.join("dst").exists(),
4553 "dst should not exist in dry-run"
4554 );
4555 Ok(())
4556 }
4557 #[tokio::test]
4560 #[traced_test]
4561 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
4562 let test_path = testutils::create_temp_dir().await?;
4563 let src_path = test_path.join("src");
4569 tokio::fs::create_dir(&src_path).await?;
4570 tokio::fs::write(src_path.join("foo"), "content").await?;
4571 tokio::fs::write(src_path.join("bar"), "content").await?;
4572 tokio::fs::create_dir(src_path.join("baz")).await?;
4573 let dst_path = test_path.join("dst");
4575 tokio::fs::create_dir(&dst_path).await?;
4576 tokio::fs::create_dir(dst_path.join("baz")).await?;
4577 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
4579 let mut filter = FilterSettings::new();
4581 filter.add_include("foo").unwrap();
4582 let summary = copy(
4583 &PROGRESS,
4584 &src_path,
4585 &dst_path,
4586 &Settings {
4587 dereference: false,
4588 fail_early: false,
4589 overwrite: true, overwrite_compare: Default::default(),
4591 overwrite_filter: None,
4592 ignore_existing: false,
4593 chunk_size: 0,
4594 skip_specials: false,
4595 remote_copy_buffer_size: 0,
4596 filter: Some(filter),
4597 dry_run: None,
4598 delete: None,
4599 },
4600 &NO_PRESERVE_SETTINGS,
4601 false,
4602 )
4603 .await?;
4604 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
4606 assert_eq!(
4608 summary.directories_unchanged, 2,
4609 "root dst and baz directories should be unchanged"
4610 );
4611 assert_eq!(
4612 summary.directories_created, 0,
4613 "should not create any directories"
4614 );
4615 assert!(dst_path.join("foo").exists(), "foo should be copied");
4617 assert!(!dst_path.join("bar").exists(), "bar should not be copied");
4619 assert!(
4621 dst_path.join("baz").exists(),
4622 "existing baz directory should still exist"
4623 );
4624 assert!(
4625 dst_path.join("baz").join("marker.txt").exists(),
4626 "existing content in baz should still exist"
4627 );
4628 Ok(())
4629 }
4630 }
4631 mod dry_run_tests {
4632 use super::*;
4633 use crate::filter::FilterSettings;
4634 #[tokio::test]
4637 #[traced_test]
4638 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
4639 let tmp_dir = testutils::setup_test_dir().await?;
4640 let test_path = tmp_dir.as_path();
4641 let dst_path = test_path.join("nonexistent_dst");
4642 assert!(
4644 !dst_path.exists(),
4645 "destination should not exist before dry-run"
4646 );
4647 let summary = copy(
4648 &PROGRESS,
4649 &test_path.join("foo"),
4650 &dst_path,
4651 &Settings {
4652 dereference: false,
4653 fail_early: false,
4654 overwrite: false,
4655 overwrite_compare: Default::default(),
4656 overwrite_filter: None,
4657 ignore_existing: false,
4658 chunk_size: 0,
4659 skip_specials: false,
4660 remote_copy_buffer_size: 0,
4661 filter: None,
4662 dry_run: Some(crate::config::DryRunMode::Brief),
4663 delete: None,
4664 },
4665 &NO_PRESERVE_SETTINGS,
4666 false,
4667 )
4668 .await?;
4669 assert!(
4671 !dst_path.exists(),
4672 "dry-run should not create destination directory"
4673 );
4674 assert!(
4676 summary.directories_created > 0,
4677 "dry-run should report directories that would be created"
4678 );
4679 assert!(
4680 summary.files_copied > 0,
4681 "dry-run should report files that would be copied"
4682 );
4683 Ok(())
4684 }
4685 #[tokio::test]
4689 async fn dry_run_ignore_existing_reports_file_as_skipped() -> Result<(), anyhow::Error> {
4690 let tmp = testutils::create_temp_dir().await?;
4691 let src = tmp.join("src.txt");
4692 let dst = tmp.join("dst.txt");
4693 tokio::fs::write(&src, "new").await?;
4694 tokio::fs::write(&dst, "old").await?; let summary = copy(
4696 &PROGRESS,
4697 &src,
4698 &dst,
4699 &Settings {
4700 dereference: false,
4701 fail_early: false,
4702 overwrite: false,
4703 overwrite_compare: Default::default(),
4704 overwrite_filter: None,
4705 ignore_existing: true,
4706 chunk_size: 0,
4707 skip_specials: false,
4708 remote_copy_buffer_size: 0,
4709 filter: None,
4710 dry_run: Some(crate::config::DryRunMode::Brief),
4711 delete: None,
4712 },
4713 &NO_PRESERVE_SETTINGS,
4714 false,
4715 )
4716 .await?;
4717 assert_eq!(
4718 summary.files_unchanged, 1,
4719 "existing file must be reported skipped in dry-run under --ignore-existing"
4720 );
4721 assert_eq!(
4722 summary.files_copied, 0,
4723 "must not report a would-be copy when --ignore-existing skips"
4724 );
4725 assert_eq!(
4726 tokio::fs::read_to_string(&dst).await?,
4727 "old",
4728 "dry-run must not mutate the destination"
4729 );
4730 Ok(())
4731 }
4732 #[tokio::test]
4735 async fn dry_run_ignore_existing_reports_symlink_as_skipped() -> Result<(), anyhow::Error> {
4736 let tmp = testutils::create_temp_dir().await?;
4737 let src = tmp.join("src_link");
4738 let dst = tmp.join("dst_link");
4739 tokio::fs::symlink("some/target", &src).await?;
4740 tokio::fs::write(&dst, "existing").await?; let summary = copy(
4742 &PROGRESS,
4743 &src,
4744 &dst,
4745 &Settings {
4746 dereference: false,
4747 fail_early: false,
4748 overwrite: false,
4749 overwrite_compare: Default::default(),
4750 overwrite_filter: None,
4751 ignore_existing: true,
4752 chunk_size: 0,
4753 skip_specials: false,
4754 remote_copy_buffer_size: 0,
4755 filter: None,
4756 dry_run: Some(crate::config::DryRunMode::Brief),
4757 delete: None,
4758 },
4759 &NO_PRESERVE_SETTINGS,
4760 false,
4761 )
4762 .await?;
4763 assert_eq!(
4764 summary.symlinks_unchanged, 1,
4765 "existing symlink dst must be reported skipped in dry-run under --ignore-existing"
4766 );
4767 assert_eq!(
4768 summary.symlinks_created, 0,
4769 "must not report a would-be symlink creation when --ignore-existing skips"
4770 );
4771 Ok(())
4772 }
4773 #[tokio::test]
4777 #[traced_test]
4778 async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
4779 let test_path = testutils::create_temp_dir().await?;
4780 let src_path = test_path.join("src");
4785 tokio::fs::create_dir(&src_path).await?;
4786 tokio::fs::write(src_path.join("bar.log"), "content").await?;
4787 tokio::fs::create_dir(src_path.join("baz")).await?;
4788 let mut filter = FilterSettings::new();
4790 filter.add_include("*.txt").unwrap();
4791 let dst_path = test_path.join("dst");
4792 let summary = copy(
4793 &PROGRESS,
4794 &src_path,
4795 &dst_path,
4796 &Settings {
4797 dereference: false,
4798 fail_early: false,
4799 overwrite: false,
4800 overwrite_compare: Default::default(),
4801 overwrite_filter: None,
4802 ignore_existing: false,
4803 chunk_size: 0,
4804 skip_specials: false,
4805 remote_copy_buffer_size: 0,
4806 filter: Some(filter),
4807 dry_run: None,
4808 delete: None,
4809 },
4810 &NO_PRESERVE_SETTINGS,
4811 false,
4812 )
4813 .await?;
4814 assert_eq!(summary.files_copied, 0, "no files match *.txt");
4816 assert_eq!(
4818 summary.directories_created, 1,
4819 "root directory should always be created"
4820 );
4821 assert!(dst_path.exists(), "root destination directory should exist");
4822 assert!(
4824 !dst_path.join("baz").exists(),
4825 "empty baz should not be created"
4826 );
4827 Ok(())
4828 }
4829 #[tokio::test]
4831 #[traced_test]
4832 async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
4833 {
4834 let test_path = testutils::create_temp_dir().await?;
4835 let src_path = test_path.join("src");
4836 tokio::fs::create_dir(&src_path).await?;
4837 tokio::fs::write(src_path.join("bar.log"), "content").await?;
4838 let mut filter = FilterSettings::new();
4840 filter.add_include("*.txt").unwrap();
4841 let dst_path = test_path.join("dst");
4842 let summary = copy(
4843 &PROGRESS,
4844 &src_path,
4845 &dst_path,
4846 &Settings {
4847 dereference: false,
4848 fail_early: false,
4849 overwrite: false,
4850 overwrite_compare: Default::default(),
4851 overwrite_filter: None,
4852 ignore_existing: false,
4853 chunk_size: 0,
4854 skip_specials: false,
4855 remote_copy_buffer_size: 0,
4856 filter: Some(filter),
4857 dry_run: Some(crate::config::DryRunMode::Explain),
4858 delete: None,
4859 },
4860 &NO_PRESERVE_SETTINGS,
4861 false,
4862 )
4863 .await?;
4864 assert_eq!(summary.files_copied, 0, "no files match *.txt");
4865 assert_eq!(
4866 summary.directories_created, 1,
4867 "root directory should be counted in dry-run"
4868 );
4869 assert!(
4870 !dst_path.exists(),
4871 "nothing should be created in dry-run mode"
4872 );
4873 Ok(())
4874 }
4875 }
4876
4877 mod max_open_files_tests {
4879 use super::*;
4880
4881 #[tokio::test]
4884 #[traced_test]
4885 async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
4886 let tmp_dir = testutils::create_temp_dir().await?;
4887 let src = tmp_dir.join("src");
4888 let dst = tmp_dir.join("dst");
4889 tokio::fs::create_dir(&src).await?;
4890 let file_count = 200;
4891 for i in 0..file_count {
4892 tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
4893 }
4894 throttle::set_max_open_files(4);
4896 let summary = copy(
4897 &PROGRESS,
4898 &src,
4899 &dst,
4900 &Settings {
4901 dereference: false,
4902 fail_early: true,
4903 overwrite: false,
4904 overwrite_compare: Default::default(),
4905 overwrite_filter: None,
4906 ignore_existing: false,
4907 chunk_size: 0,
4908 skip_specials: false,
4909 remote_copy_buffer_size: 0,
4910 filter: None,
4911 dry_run: None,
4912 delete: None,
4913 },
4914 &NO_PRESERVE_SETTINGS,
4915 false,
4916 )
4917 .await?;
4918 assert_eq!(summary.files_copied, file_count);
4919 assert_eq!(summary.directories_created, 1);
4920 for i in 0..file_count {
4921 let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
4922 assert_eq!(content, format!("content-{}", i));
4923 }
4924 Ok(())
4925 }
4926
4927 #[tokio::test]
4930 #[traced_test]
4931 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
4932 let tmp_dir = testutils::create_temp_dir().await?;
4933 let src = tmp_dir.join("src");
4934 let dst = tmp_dir.join("dst");
4935 let depth = 20;
4936 let files_per_level = 5;
4937 let limit = 4;
4938 let mut dir = src.clone();
4940 for level in 0..depth {
4941 tokio::fs::create_dir_all(&dir).await?;
4942 for f in 0..files_per_level {
4943 tokio::fs::write(
4944 dir.join(format!("f{}_{}.txt", level, f)),
4945 format!("L{}F{}", level, f),
4946 )
4947 .await?;
4948 }
4949 dir = dir.join(format!("d{}", level));
4950 }
4951 throttle::set_max_open_files(limit);
4952 let summary = tokio::time::timeout(
4953 std::time::Duration::from_secs(30),
4954 copy(
4955 &PROGRESS,
4956 &src,
4957 &dst,
4958 &Settings {
4959 dereference: false,
4960 fail_early: true,
4961 overwrite: false,
4962 overwrite_compare: Default::default(),
4963 overwrite_filter: None,
4964 ignore_existing: false,
4965 chunk_size: 0,
4966 skip_specials: false,
4967 remote_copy_buffer_size: 0,
4968 filter: None,
4969 dry_run: None,
4970 delete: None,
4971 },
4972 &NO_PRESERVE_SETTINGS,
4973 false,
4974 ),
4975 )
4976 .await
4977 .context("copy timed out — possible deadlock")?
4978 .context("copy failed")?;
4979 assert_eq!(summary.files_copied, depth * files_per_level);
4980 assert_eq!(summary.directories_created, depth);
4981 let mut check_dir = dst.clone();
4983 for level in 0..depth {
4984 let content =
4985 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
4986 assert_eq!(content, format!("L{}F0", level));
4987 check_dir = check_dir.join(format!("d{}", level));
4988 }
4989 Ok(())
4990 }
4991
4992 #[tokio::test]
5002 #[traced_test]
5003 async fn parallel_overwrite_dir_with_file_no_deadlock() -> Result<(), anyhow::Error> {
5004 let tmp_dir = testutils::create_temp_dir().await?;
5005 let src = tmp_dir.join("src");
5006 let dst = tmp_dir.join("dst");
5007 tokio::fs::create_dir(&src).await?;
5008 tokio::fs::create_dir(&dst).await?;
5009 let n = 8;
5013 for i in 0..n {
5014 tokio::fs::write(src.join(format!("e{}", i)), format!("file-{}", i)).await?;
5015 let dst_subdir = dst.join(format!("e{}", i));
5016 tokio::fs::create_dir(&dst_subdir).await?;
5017 for j in 0..3 {
5018 tokio::fs::write(
5019 dst_subdir.join(format!("inner_{}.txt", j)),
5020 format!("inner-{}-{}", i, j),
5021 )
5022 .await?;
5023 }
5024 }
5025 throttle::set_max_open_files(2);
5029 let summary = tokio::time::timeout(
5030 std::time::Duration::from_secs(30),
5031 copy(
5032 &PROGRESS,
5033 &src,
5034 &dst,
5035 &Settings {
5036 dereference: false,
5037 fail_early: true,
5038 overwrite: true,
5039 overwrite_compare: Default::default(),
5040 overwrite_filter: None,
5041 ignore_existing: false,
5042 chunk_size: 0,
5043 skip_specials: false,
5044 remote_copy_buffer_size: 0,
5045 filter: None,
5046 dry_run: None,
5047 delete: None,
5048 },
5049 &NO_PRESERVE_SETTINGS,
5050 false,
5051 ),
5052 )
5053 .await
5054 .context(
5055 "copy timed out — deadlock between copy_file's open-files permit and inner rm",
5056 )?
5057 .context("copy failed")?;
5058 assert_eq!(summary.files_copied, n);
5059 assert_eq!(summary.rm_summary.files_removed, n * 3);
5060 assert_eq!(summary.rm_summary.directories_removed, n);
5061 for i in 0..n {
5062 let path = dst.join(format!("e{}", i));
5063 let content = tokio::fs::read_to_string(&path).await?;
5064 assert_eq!(content, format!("file-{}", i));
5065 }
5066 Ok(())
5067 }
5068 }
5069
5070 mod skip_specials_tests {
5071 use super::*;
5072
5073 #[tokio::test]
5074 #[traced_test]
5075 async fn skip_specials_skips_socket_in_directory() -> Result<(), anyhow::Error> {
5076 let tmp_dir = testutils::setup_test_dir().await?;
5077 let test_path = tmp_dir.as_path();
5078 let src = test_path.join("src_dir");
5079 let dst = test_path.join("dst_dir");
5080 tokio::fs::create_dir(&src).await?;
5081 tokio::fs::write(src.join("file.txt"), "hello").await?;
5082 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
5084 let summary = copy(
5085 &PROGRESS,
5086 &src,
5087 &dst,
5088 &Settings {
5089 dereference: false,
5090 fail_early: false,
5091 overwrite: false,
5092 overwrite_compare: Default::default(),
5093 overwrite_filter: None,
5094 ignore_existing: false,
5095 chunk_size: 0,
5096 skip_specials: true,
5097 remote_copy_buffer_size: 0,
5098 filter: None,
5099 dry_run: None,
5100 delete: None,
5101 },
5102 &NO_PRESERVE_SETTINGS,
5103 false,
5104 )
5105 .await?;
5106 assert_eq!(summary.files_copied, 1);
5107 assert_eq!(summary.specials_skipped, 1);
5108 assert!(dst.join("file.txt").exists());
5109 assert!(!dst.join("test.sock").exists());
5110 Ok(())
5111 }
5112
5113 #[tokio::test]
5114 #[traced_test]
5115 async fn skip_specials_skips_fifo_in_directory() -> 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("src_dir");
5119 let dst = test_path.join("dst_dir");
5120 tokio::fs::create_dir(&src).await?;
5121 tokio::fs::write(src.join("file.txt"), "hello").await?;
5122 nix::unistd::mkfifo(
5124 &src.join("test.fifo"),
5125 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
5126 )?;
5127 let summary = copy(
5128 &PROGRESS,
5129 &src,
5130 &dst,
5131 &Settings {
5132 dereference: false,
5133 fail_early: false,
5134 overwrite: false,
5135 overwrite_compare: Default::default(),
5136 overwrite_filter: None,
5137 ignore_existing: false,
5138 chunk_size: 0,
5139 skip_specials: true,
5140 remote_copy_buffer_size: 0,
5141 filter: None,
5142 dry_run: None,
5143 delete: None,
5144 },
5145 &NO_PRESERVE_SETTINGS,
5146 false,
5147 )
5148 .await?;
5149 assert_eq!(summary.files_copied, 1);
5150 assert_eq!(summary.specials_skipped, 1);
5151 assert!(dst.join("file.txt").exists());
5152 assert!(!dst.join("test.fifo").exists());
5153 Ok(())
5154 }
5155
5156 #[tokio::test]
5157 #[traced_test]
5158 async fn special_file_errors_without_skip_specials() -> Result<(), anyhow::Error> {
5159 let tmp_dir = testutils::setup_test_dir().await?;
5160 let test_path = tmp_dir.as_path();
5161 let src = test_path.join("src_dir");
5162 let dst = test_path.join("dst_dir");
5163 tokio::fs::create_dir(&src).await?;
5164 tokio::fs::write(src.join("file.txt"), "hello").await?;
5165 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
5166 let result = copy(
5167 &PROGRESS,
5168 &src,
5169 &dst,
5170 &Settings {
5171 dereference: false,
5172 fail_early: false,
5173 overwrite: false,
5174 overwrite_compare: Default::default(),
5175 overwrite_filter: None,
5176 ignore_existing: false,
5177 chunk_size: 0,
5178 skip_specials: false,
5179 remote_copy_buffer_size: 0,
5180 filter: None,
5181 dry_run: None,
5182 delete: None,
5183 },
5184 &NO_PRESERVE_SETTINGS,
5185 false,
5186 )
5187 .await;
5188 assert!(result.is_err());
5189 let err = result.unwrap_err();
5190 assert!(
5191 format!("{:#}", err).contains("unsupported src file type"),
5192 "error should mention unsupported file type, got: {:#}",
5193 err
5194 );
5195 Ok(())
5196 }
5197
5198 #[tokio::test]
5199 #[traced_test]
5200 async fn skip_specials_top_level_socket() -> Result<(), anyhow::Error> {
5201 let tmp_dir = testutils::setup_test_dir().await?;
5202 let test_path = tmp_dir.as_path();
5203 let src_socket = test_path.join("test.sock");
5204 let dst = test_path.join("dst.sock");
5205 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
5206 let summary = copy(
5207 &PROGRESS,
5208 &src_socket,
5209 &dst,
5210 &Settings {
5211 dereference: false,
5212 fail_early: false,
5213 overwrite: false,
5214 overwrite_compare: Default::default(),
5215 overwrite_filter: None,
5216 ignore_existing: false,
5217 chunk_size: 0,
5218 skip_specials: true,
5219 remote_copy_buffer_size: 0,
5220 filter: None,
5221 dry_run: None,
5222 delete: None,
5223 },
5224 &NO_PRESERVE_SETTINGS,
5225 false,
5226 )
5227 .await?;
5228 assert_eq!(summary.specials_skipped, 1);
5229 assert_eq!(summary.files_copied, 0);
5230 assert!(!dst.exists());
5231 Ok(())
5232 }
5233 }
5234
5235 #[tokio::test]
5236 #[traced_test]
5237 async fn delete_protects_excluded_then_removes_with_delete_excluded()
5238 -> Result<(), anyhow::Error> {
5239 let tmp_dir = testutils::setup_test_dir().await?;
5240 let test_path = tmp_dir.as_path();
5241 let src = test_path.join("foo");
5242 let dst = test_path.join("bar");
5243 copy(
5244 &PROGRESS,
5245 &src,
5246 &dst,
5247 &settings_with_delete(None),
5248 &DO_PRESERVE_SETTINGS,
5249 false,
5250 )
5251 .await?;
5252 tokio::fs::write(dst.join("keep.log"), b"protected").await?;
5254
5255 let mut filter = crate::filter::FilterSettings::new();
5256 filter.add_exclude("*.log")?;
5257
5258 let mut settings = settings_with_delete(delete_on());
5260 settings.filter = Some(filter.clone());
5261 copy(
5262 &PROGRESS,
5263 &src,
5264 &dst,
5265 &settings,
5266 &DO_PRESERVE_SETTINGS,
5267 false,
5268 )
5269 .await?;
5270 assert!(
5271 dst.join("keep.log").exists(),
5272 "*.log must be protected by default"
5273 );
5274
5275 let mut settings = settings_with_delete(Some(DeleteSettings {
5277 delete_excluded: true,
5278 }));
5279 settings.filter = Some(filter);
5280 copy(
5281 &PROGRESS,
5282 &src,
5283 &dst,
5284 &settings,
5285 &DO_PRESERVE_SETTINGS,
5286 false,
5287 )
5288 .await?;
5289 assert!(!dst.join("keep.log").exists());
5290 Ok(())
5291 }
5292
5293 #[tokio::test]
5294 #[traced_test]
5295 async fn delete_dry_run_reports_without_removing() -> Result<(), anyhow::Error> {
5296 let tmp_dir = testutils::setup_test_dir().await?;
5297 let test_path = tmp_dir.as_path();
5298 let src = test_path.join("foo");
5299 let dst = test_path.join("bar");
5300 copy(
5301 &PROGRESS,
5302 &src,
5303 &dst,
5304 &settings_with_delete(None),
5305 &DO_PRESERVE_SETTINGS,
5306 false,
5307 )
5308 .await?;
5309 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
5310
5311 let mut settings = settings_with_delete(delete_on());
5312 settings.dry_run = Some(crate::config::DryRunMode::Brief);
5313 let summary = copy(
5314 &PROGRESS,
5315 &src,
5316 &dst,
5317 &settings,
5318 &DO_PRESERVE_SETTINGS,
5319 false,
5320 )
5321 .await?;
5322
5323 assert!(
5325 dst.join("stale.txt").exists(),
5326 "dry-run must not remove anything"
5327 );
5328 assert_eq!(summary.rm_summary.files_removed, 1);
5330 Ok(())
5331 }
5332
5333 #[tokio::test]
5334 #[traced_test]
5335 async fn delete_does_not_prune_when_source_unreadable() -> Result<(), anyhow::Error> {
5336 let tmp_dir = testutils::setup_test_dir().await?;
5337 let test_path = tmp_dir.as_path();
5338 let src = test_path.join("foo");
5339 let dst = test_path.join("bar");
5340 copy(
5341 &PROGRESS,
5342 &src,
5343 &dst,
5344 &settings_with_delete(None),
5345 &DO_PRESERVE_SETTINGS,
5346 false,
5347 )
5348 .await?;
5349 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
5350 let original = tokio::fs::metadata(&src).await?.permissions();
5352 tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
5353
5354 let result = copy(
5355 &PROGRESS,
5356 &src,
5357 &dst,
5358 &settings_with_delete(delete_on()),
5359 &DO_PRESERVE_SETTINGS,
5360 false,
5361 )
5362 .await;
5363
5364 tokio::fs::set_permissions(&src, original).await?;
5366
5367 assert!(result.is_err(), "unreadable source must error");
5368 assert!(
5369 dst.join("stale.txt").exists(),
5370 "destination must not be pruned when source enumeration fails"
5371 );
5372 Ok(())
5373 }
5374
5375 #[tokio::test]
5380 #[traced_test]
5381 async fn overwrite_regular_file_removes_and_recreates_via_fd() -> Result<(), anyhow::Error> {
5382 let tmp_dir = testutils::create_temp_dir().await?;
5383 let test_path = tmp_dir.as_path();
5384 let src_file = test_path.join("src.txt");
5385 let dst_file = test_path.join("dst.txt");
5386 tokio::fs::write(&src_file, "fresh source content").await?;
5388 tokio::fs::write(&dst_file, "old").await?;
5389 let summary = copy(
5390 &PROGRESS,
5391 &src_file,
5392 &dst_file,
5393 &Settings {
5394 dereference: false,
5395 fail_early: false,
5396 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
5398 size: true,
5399 mtime: true,
5400 ..Default::default()
5401 },
5402 overwrite_filter: None,
5403 ignore_existing: false,
5404 chunk_size: 0,
5405 skip_specials: false,
5406 remote_copy_buffer_size: 0,
5407 filter: None,
5408 dry_run: None,
5409 delete: None,
5410 },
5411 &NO_PRESERVE_SETTINGS,
5412 false,
5413 )
5414 .await?;
5415 assert_eq!(summary.files_copied, 1);
5417 assert_eq!(summary.rm_summary.files_removed, 1);
5418 assert_eq!(summary.rm_summary.symlinks_removed, 0);
5419 assert_eq!(summary.rm_summary.directories_removed, 0);
5420 let content = tokio::fs::read_to_string(&dst_file).await?;
5421 assert_eq!(content, "fresh source content");
5422 Ok(())
5423 }
5424
5425 fn toctou_settings() -> Settings {
5430 Settings {
5431 dereference: false,
5432 fail_early: false,
5433 overwrite: true,
5434 overwrite_compare: filecmp::MetadataCmpSettings {
5435 size: true,
5436 mtime: true,
5437 ..Default::default()
5438 },
5439 overwrite_filter: None,
5440 ignore_existing: false,
5441 chunk_size: 0,
5442 skip_specials: false,
5443 remote_copy_buffer_size: 0,
5444 filter: None,
5445 dry_run: None,
5446 delete: None,
5447 }
5448 }
5449
5450 #[tokio::test]
5456 #[traced_test]
5457 async fn non_dereference_copy_never_canonicalizes() -> Result<(), anyhow::Error> {
5458 let tmp_dir = testutils::create_temp_dir().await?;
5459 let test_path = tmp_dir.as_path();
5460 let src = test_path.join("src");
5461 let dst = test_path.join("dst");
5462 tokio::fs::create_dir(&src).await?;
5463 tokio::fs::write(src.join("real.txt"), "REAL_CONTENT").await?;
5465 tokio::fs::symlink("real.txt", src.join("link")).await?;
5466 let summary = copy(
5467 &PROGRESS,
5468 &src,
5469 &dst,
5470 &toctou_settings(),
5471 &NO_PRESERVE_SETTINGS,
5472 false,
5473 )
5474 .await?;
5475 assert_eq!(summary.files_copied, 1);
5476 assert_eq!(
5477 summary.symlinks_created, 1,
5478 "symlink must be copied as a symlink"
5479 );
5480 let link_md = tokio::fs::symlink_metadata(dst.join("link")).await?;
5482 assert!(
5483 link_md.file_type().is_symlink(),
5484 "with dereference == false the source symlink must remain a symlink in the destination"
5485 );
5486 let target = tokio::fs::read_link(dst.join("link")).await?;
5488 assert_eq!(target, std::path::Path::new("real.txt"));
5489 Ok(())
5490 }
5491
5492 fn spawn_file_symlink_swapper(
5498 dir: std::path::PathBuf,
5499 entry_name: &'static str,
5500 sentinel: std::path::PathBuf,
5501 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
5502 ) -> std::thread::JoinHandle<()> {
5503 std::thread::spawn(move || {
5504 let entry = dir.join(entry_name);
5505 let staged_real = dir.join("__staged_real");
5506 let staged_link = dir.join("__staged_link");
5507 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5508 let _ = std::fs::remove_file(&staged_real);
5510 if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
5511 continue;
5512 }
5513 let _ = std::fs::rename(&staged_real, &entry);
5514 let _ = std::fs::remove_file(&staged_link);
5516 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
5517 let _ = std::fs::rename(&staged_link, &entry);
5518 }
5519 })
5520 }
5521
5522 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5527 async fn file_symlink_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
5528 let tmp_dir = testutils::create_temp_dir().await?;
5529 let test_path = tmp_dir.as_path();
5530 let sentinel = test_path.join("sentinel_secret");
5532 tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
5533 let src = test_path.join("src");
5534 let sub = src.join("sub");
5535 tokio::fs::create_dir(&src).await?;
5536 tokio::fs::create_dir(&sub).await?;
5537 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
5538
5539 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5540 let swapper =
5541 spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
5542
5543 let settings = toctou_settings();
5544 let mut caught_swaps = 0usize;
5545 let mut copied_real = 0usize;
5546 for i in 0..200 {
5547 let dst = test_path.join(format!("dst_{i}"));
5548 let result = tokio::time::timeout(
5549 std::time::Duration::from_secs(30),
5550 copy(
5551 &PROGRESS,
5552 &src,
5553 &dst,
5554 &settings,
5555 &NO_PRESERVE_SETTINGS,
5556 false,
5557 ),
5558 )
5559 .await
5560 .expect("copy must not hang under concurrent swapping");
5561 match result {
5562 Ok(_) => {}
5563 Err(_) => caught_swaps += 1, }
5565 let entry_dst = dst.join("sub").join("entry");
5568 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
5569 && md.file_type().is_file()
5570 {
5571 let content = tokio::fs::read_to_string(&entry_dst).await?;
5572 assert_ne!(
5573 content, "SENTINEL_SECRET_CONTENT",
5574 "iteration {i}: sentinel content leaked into the destination as a regular file"
5575 );
5576 assert_eq!(
5577 content, "REAL_CONTENT",
5578 "iteration {i}: a regular destination file must hold the real content"
5579 );
5580 copied_real += 1;
5581 }
5582 let _ = tokio::fs::remove_dir_all(&dst).await;
5583 }
5584
5585 stop.store(true, std::sync::atomic::Ordering::Relaxed);
5586 swapper.join().expect("swapper thread panicked");
5587 tracing::info!("file/symlink swap: caught_swaps={caught_swaps}, copied_real={copied_real}");
5591 assert!(
5592 caught_swaps + copied_real > 0,
5593 "expected at least one observable outcome across 200 iterations"
5594 );
5595 Ok(())
5596 }
5597
5598 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5603 async fn intermediate_dir_swap_never_follows_symlink() -> Result<(), anyhow::Error> {
5604 let tmp_dir = testutils::create_temp_dir().await?;
5605 let test_path = tmp_dir.as_path();
5606 let outside = test_path.join("outside_dir");
5608 tokio::fs::create_dir(&outside).await?;
5609 tokio::fs::write(outside.join("sentinel.txt"), "SENTINEL_SECRET_CONTENT").await?;
5610 let src = test_path.join("src");
5611 tokio::fs::create_dir(&src).await?;
5612 let real_sub = src.join("sub");
5614 tokio::fs::create_dir(&real_sub).await?;
5615 tokio::fs::write(real_sub.join("real.txt"), "REAL_CONTENT").await?;
5616
5617 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5618 let swapper = {
5619 let src = src.clone();
5620 let outside = outside.clone();
5621 let stop = stop.clone();
5622 std::thread::spawn(move || {
5623 let sub = src.join("sub");
5624 let staged_dir = src.join("__staged_sub_dir");
5625 let staged_link = src.join("__staged_sub_link");
5626 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5627 let _ = std::fs::remove_dir_all(&staged_dir);
5629 if std::fs::create_dir(&staged_dir).is_ok() {
5630 let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL_CONTENT");
5631 let _ = std::fs::remove_dir_all(&sub);
5635 let _ = std::fs::remove_file(&sub);
5636 let _ = std::fs::rename(&staged_dir, &sub);
5637 }
5638 let _ = std::fs::remove_file(&staged_link);
5640 if std::os::unix::fs::symlink(&outside, &staged_link).is_ok() {
5641 let _ = std::fs::remove_dir_all(&sub);
5642 let _ = std::fs::remove_file(&sub);
5643 let _ = std::fs::rename(&staged_link, &sub);
5644 }
5645 }
5646 })
5647 };
5648
5649 let settings = toctou_settings();
5650 for i in 0..200 {
5651 let dst = test_path.join(format!("dst_{i}"));
5652 let _ = tokio::time::timeout(
5653 std::time::Duration::from_secs(30),
5654 copy(
5655 &PROGRESS,
5656 &src,
5657 &dst,
5658 &settings,
5659 &NO_PRESERVE_SETTINGS,
5660 false,
5661 ),
5662 )
5663 .await
5664 .expect("copy must not hang under concurrent dir swapping");
5665 let sub_dst = dst.join("sub");
5676 if let Ok(sub_md) = tokio::fs::symlink_metadata(&sub_dst).await
5677 && sub_md.file_type().is_dir()
5678 {
5679 let leaked = sub_dst.join("sentinel.txt");
5680 let leaked_is_real_file = tokio::fs::symlink_metadata(&leaked)
5681 .await
5682 .map(|m| m.file_type().is_file())
5683 .unwrap_or(false);
5684 assert!(
5685 !leaked_is_real_file,
5686 "iteration {i}: intermediate-dir symlink was followed; the outside \
5687 sentinel was copied into a real destination directory"
5688 );
5689 let real_dst = sub_dst.join("real.txt");
5691 if let Ok(content) = tokio::fs::read_to_string(&real_dst).await {
5692 assert_eq!(
5693 content, "REAL_CONTENT",
5694 "iteration {i}: copied file must hold real content"
5695 );
5696 }
5697 }
5698 let _ = tokio::fs::remove_dir_all(&dst).await;
5699 }
5700
5701 stop.store(true, std::sync::atomic::Ordering::Relaxed);
5702 swapper.join().expect("dir swapper thread panicked");
5703 Ok(())
5704 }
5705
5706 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5724 async fn dest_nonempty_dir_overwrite_removal_contained_under_intermediate_swap()
5725 -> Result<(), anyhow::Error> {
5726 const N: usize = 8;
5727 let tmp_dir = testutils::create_temp_dir().await?;
5728 let test_path = tmp_dir.as_path();
5729 let out_of_tree = test_path.join("out_of_tree");
5734 let oot_deeper = out_of_tree.join("deeper");
5735 for k in 0..N {
5736 let oot_sub = oot_deeper.join(format!("sub{k}"));
5737 tokio::fs::create_dir_all(&oot_sub).await?;
5738 tokio::fs::write(oot_sub.join("sentinel.txt"), "SENTINEL").await?;
5739 }
5740
5741 let src = test_path.join("src");
5743 let src_deeper = src.join("mid").join("deeper");
5744 tokio::fs::create_dir_all(&src_deeper).await?;
5745 for k in 0..N {
5746 tokio::fs::write(src_deeper.join(format!("sub{k}")), "REAL_FILE_CONTENT").await?;
5747 }
5748
5749 let dst = test_path.join("dst");
5751 tokio::fs::create_dir(&dst).await?;
5752
5753 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5754 let swapper = {
5755 let dst = dst.clone();
5756 let out_of_tree = out_of_tree.clone();
5757 let stop = stop.clone();
5758 std::thread::spawn(move || {
5759 let mid = dst.join("mid");
5760 let staged_dir = dst.join("__staged_mid_dir");
5761 let staged_link = dst.join("__staged_mid_link");
5762 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5763 let _ = std::fs::remove_dir_all(&staged_dir);
5767 let staged_deeper = staged_dir.join("deeper");
5768 if std::fs::create_dir_all(&staged_deeper).is_ok() {
5769 for k in 0..N {
5770 let s = staged_deeper.join(format!("sub{k}"));
5771 if std::fs::create_dir(&s).is_ok() {
5772 let _ = std::fs::write(s.join("inner.txt"), b"INNER_CONTENT");
5773 }
5774 }
5775 let _ = std::fs::remove_dir_all(&mid);
5776 let _ = std::fs::remove_file(&mid);
5777 let _ = std::fs::rename(&staged_dir, &mid);
5778 }
5779 let _ = std::fs::remove_file(&staged_link);
5781 if std::os::unix::fs::symlink(&out_of_tree, &staged_link).is_ok() {
5782 let _ = std::fs::remove_dir_all(&mid);
5783 let _ = std::fs::remove_file(&mid);
5784 let _ = std::fs::rename(&staged_link, &mid);
5785 }
5786 }
5787 })
5788 };
5789
5790 let settings = toctou_settings();
5791 for i in 0..200 {
5792 let _ = tokio::time::timeout(
5793 std::time::Duration::from_secs(30),
5794 copy(
5795 &PROGRESS,
5796 &src,
5797 &dst,
5798 &settings,
5799 &NO_PRESERVE_SETTINGS,
5800 false,
5801 ),
5802 )
5803 .await
5804 .expect("copy must not hang under concurrent intermediate-component swapping");
5805 for k in 0..N {
5811 assert!(
5812 oot_deeper
5813 .join(format!("sub{k}"))
5814 .join("sentinel.txt")
5815 .exists(),
5816 "iteration {i}: out-of-tree sentinel sub{k} was deleted — the non-empty-\
5817 directory overwrite removal escaped the destination tree through a swapped \
5818 intermediate symlink (path-based rm regression)"
5819 );
5820 }
5821 }
5822
5823 stop.store(true, std::sync::atomic::Ordering::Relaxed);
5824 swapper
5825 .join()
5826 .expect("intermediate-swap swapper thread panicked");
5827 assert!(
5829 oot_deeper.join("sub0").is_dir(),
5830 "out-of-tree sub directory must survive"
5831 );
5832 Ok(())
5833 }
5834
5835 #[tokio::test]
5845 #[traced_test]
5846 async fn nonempty_dir_overwrite_removal_uses_pinned_fd_not_path() -> Result<(), anyhow::Error> {
5847 let tmp_dir = testutils::create_temp_dir().await?;
5848 let test_path = tmp_dir.as_path();
5849
5850 let real_deeper = test_path.join("base").join("realdeeper");
5852 let real_victim = real_deeper.join("victim");
5853 tokio::fs::create_dir_all(&real_victim).await?;
5854 tokio::fs::write(real_victim.join("inner.txt"), "INNER").await?;
5855
5856 let out_of_tree = test_path.join("out_of_tree");
5859 let oot_victim = out_of_tree.join("realdeeper").join("victim");
5860 tokio::fs::create_dir_all(&oot_victim).await?;
5861 tokio::fs::write(oot_victim.join("sentinel.txt"), "SENTINEL").await?;
5862
5863 let mid_link = test_path.join("base").join("mid");
5867 tokio::fs::symlink(&out_of_tree, &mid_link).await?;
5868 let stale_dst_path = mid_link.join("realdeeper").join("victim");
5869
5870 let dst_parent =
5872 Arc::new(Dir::open_root_dir(&real_deeper, false, congestion::Side::Destination).await?);
5873 let victim_name: &OsStr = OsStr::new("victim");
5874 let dst_handle = dst_parent.child(victim_name).await?;
5875 assert_eq!(dst_handle.kind(), EntryKind::Dir);
5876
5877 let settings = toctou_settings();
5878 let summary = remove_existing(
5879 &PROGRESS,
5880 &dst_parent,
5881 victim_name,
5882 &stale_dst_path,
5883 &dst_handle,
5884 &settings,
5885 )
5886 .await?;
5887 assert_eq!(
5888 summary.directories_removed, 1,
5889 "the real non-empty victim directory should have been removed via the pinned fd"
5890 );
5891
5892 assert!(
5894 !real_victim.exists(),
5895 "the real non-empty victim should have been removed through the pinned parent fd"
5896 );
5897 assert!(
5899 oot_victim.join("sentinel.txt").exists(),
5900 "out-of-tree sentinel was deleted — removal re-resolved the display path through the \
5901 intermediate symlink instead of using the pinned fd (path-based rm regression)"
5902 );
5903 Ok(())
5904 }
5905
5906 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5911 async fn fifo_swap_never_hangs_or_copies_pipe() -> Result<(), anyhow::Error> {
5912 let tmp_dir = testutils::create_temp_dir().await?;
5913 let test_path = tmp_dir.as_path();
5914 let src = test_path.join("src");
5915 let sub = src.join("sub");
5916 tokio::fs::create_dir(&src).await?;
5917 tokio::fs::create_dir(&sub).await?;
5918 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
5919
5920 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5921 let swapper = {
5922 let sub = sub.clone();
5923 let stop = stop.clone();
5924 std::thread::spawn(move || {
5925 let entry = sub.join("entry");
5926 let staged_real = sub.join("__staged_real");
5927 let staged_fifo = sub.join("__staged_fifo");
5928 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5929 let _ = std::fs::remove_file(&staged_real);
5931 if std::fs::write(&staged_real, b"REAL_CONTENT").is_ok() {
5932 let _ = std::fs::rename(&staged_real, &entry);
5933 }
5934 let _ = std::fs::remove_file(&staged_fifo);
5937 let made = nix::unistd::mkfifo(
5938 &staged_fifo,
5939 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
5940 )
5941 .is_ok();
5942 if made {
5943 let _ = std::fs::rename(&staged_fifo, &entry);
5944 }
5945 }
5946 })
5947 };
5948
5949 let settings = toctou_settings();
5950 for i in 0..200 {
5951 let dst = test_path.join(format!("dst_{i}"));
5952 let _ = tokio::time::timeout(
5955 std::time::Duration::from_secs(30),
5956 copy(
5957 &PROGRESS,
5958 &src,
5959 &dst,
5960 &settings,
5961 &NO_PRESERVE_SETTINGS,
5962 false,
5963 ),
5964 )
5965 .await
5966 .expect("copy must not hang when an entry is swapped to a FIFO");
5967 let entry_dst = dst.join("sub").join("entry");
5971 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await {
5972 use std::os::unix::fs::FileTypeExt as _;
5973 let ft = md.file_type();
5974 assert!(
5975 !ft.is_fifo(),
5976 "iteration {i}: a FIFO was reproduced at the destination"
5977 );
5978 if ft.is_file() {
5979 let content = tokio::fs::read_to_string(&entry_dst).await?;
5980 assert_eq!(
5981 content, "REAL_CONTENT",
5982 "iteration {i}: a regular destination file must hold the real content"
5983 );
5984 }
5985 }
5986 let _ = tokio::fs::remove_dir_all(&dst).await;
5987 }
5988
5989 stop.store(true, std::sync::atomic::Ordering::Relaxed);
5990 swapper.join().expect("fifo swapper thread panicked");
5991 Ok(())
5992 }
5993
5994 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5998 async fn copy_deep_narrow_tree_completes() -> Result<(), anyhow::Error> {
5999 let tmp_dir = testutils::create_temp_dir().await?;
6000 let test_path = tmp_dir.as_path();
6001 let src = test_path.join("src");
6002 const DEPTH: usize = 800;
6003 let mut cur = src.clone();
6005 for _ in 0..DEPTH {
6006 cur = cur.join("a");
6007 }
6008 tokio::fs::create_dir_all(&cur).await?;
6009 tokio::fs::write(cur.join("leaf.txt"), "DEEP_LEAF").await?;
6010
6011 let dst = test_path.join("dst");
6012 let summary = tokio::time::timeout(
6013 std::time::Duration::from_secs(120),
6014 copy(
6015 &PROGRESS,
6016 &src,
6017 &dst,
6018 &toctou_settings(),
6019 &NO_PRESERVE_SETTINGS,
6020 false,
6021 ),
6022 )
6023 .await
6024 .expect("deep-narrow copy must not hang (fd budget / hold-and-wait)")?;
6025 assert_eq!(
6026 summary.files_copied, 1,
6027 "the single leaf file must be copied"
6028 );
6029 assert_eq!(summary.directories_created, DEPTH + 1);
6031 let mut leaf = dst.clone();
6033 for _ in 0..DEPTH {
6034 leaf = leaf.join("a");
6035 }
6036 let content = tokio::fs::read_to_string(leaf.join("leaf.txt")).await?;
6037 assert_eq!(content, "DEEP_LEAF");
6038 Ok(())
6039 }
6040
6041 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6045 async fn copy_wide_tree_completes() -> Result<(), anyhow::Error> {
6046 let tmp_dir = testutils::create_temp_dir().await?;
6047 let test_path = tmp_dir.as_path();
6048 let src = test_path.join("src");
6049 tokio::fs::create_dir(&src).await?;
6050 const WIDTH: usize = 2000;
6051 for i in 0..WIDTH {
6052 tokio::fs::write(src.join(format!("f{i}.txt")), format!("content {i}")).await?;
6053 }
6054 let dst = test_path.join("dst");
6055 let summary = tokio::time::timeout(
6056 std::time::Duration::from_secs(120),
6057 copy(
6058 &PROGRESS,
6059 &src,
6060 &dst,
6061 &toctou_settings(),
6062 &NO_PRESERVE_SETTINGS,
6063 false,
6064 ),
6065 )
6066 .await
6067 .expect("wide copy must not hang (fd exhaustion / permit deadlock)")?;
6068 assert_eq!(summary.files_copied, WIDTH, "every file must be copied");
6069 for i in [0usize, WIDTH / 2, WIDTH - 1] {
6071 let content = tokio::fs::read_to_string(dst.join(format!("f{i}.txt"))).await?;
6072 assert_eq!(content, format!("content {i}"));
6073 }
6074 Ok(())
6075 }
6076
6077 #[derive(Clone, Debug)]
6079 struct CmpMeta {
6080 uid: u32,
6081 gid: u32,
6082 mode: u32,
6083 size: u64,
6084 mtime: i64,
6085 }
6086 impl crate::preserve::Metadata for CmpMeta {
6087 fn uid(&self) -> u32 {
6088 self.uid
6089 }
6090 fn gid(&self) -> u32 {
6091 self.gid
6092 }
6093 fn atime(&self) -> i64 {
6094 0
6095 }
6096 fn atime_nsec(&self) -> i64 {
6097 0
6098 }
6099 fn mtime(&self) -> i64 {
6100 self.mtime
6101 }
6102 fn mtime_nsec(&self) -> i64 {
6103 0
6104 }
6105 fn permissions(&self) -> std::fs::Permissions {
6106 std::os::unix::fs::PermissionsExt::from_mode(self.mode)
6107 }
6108 fn size(&self) -> u64 {
6109 self.size
6110 }
6111 }
6112
6113 fn size_mtime() -> filecmp::MetadataCmpSettings {
6114 filecmp::MetadataCmpSettings {
6115 size: true,
6116 mtime: true,
6117 ..Default::default()
6118 }
6119 }
6120 fn base() -> CmpMeta {
6121 CmpMeta {
6122 uid: 0,
6123 gid: 0,
6124 mode: 0o644,
6125 size: 10,
6126 mtime: 100,
6127 }
6128 }
6129
6130 #[test]
6131 fn skip_send_no_existing_entry_sends() {
6132 let src = base();
6133 let r = skip_unchanged_send(
6134 &size_mtime(),
6135 None,
6136 false,
6137 &src,
6138 None::<ExistingDst<CmpMeta>>,
6139 );
6140 assert!(!r);
6141 }
6142
6143 #[test]
6144 fn skip_send_ignore_existing_skips_any_type() {
6145 let src = base();
6146 let dst = base();
6147 let r = skip_unchanged_send(
6148 &size_mtime(),
6149 None,
6150 true,
6151 &src,
6152 Some(ExistingDst {
6153 meta: &dst,
6154 is_file: false,
6155 }),
6156 );
6157 assert!(r);
6158 }
6159
6160 #[test]
6161 fn skip_send_overwrite_identical_skips() {
6162 let src = base();
6163 let dst = base();
6164 let r = skip_unchanged_send(
6165 &size_mtime(),
6166 None,
6167 false,
6168 &src,
6169 Some(ExistingDst {
6170 meta: &dst,
6171 is_file: true,
6172 }),
6173 );
6174 assert!(r);
6175 }
6176
6177 #[test]
6178 fn skip_send_overwrite_different_size_sends() {
6179 let src = base();
6180 let dst = CmpMeta { size: 11, ..base() };
6181 let r = skip_unchanged_send(
6182 &size_mtime(),
6183 None,
6184 false,
6185 &src,
6186 Some(ExistingDst {
6187 meta: &dst,
6188 is_file: true,
6189 }),
6190 );
6191 assert!(!r);
6192 }
6193
6194 #[test]
6195 fn skip_send_overwrite_different_mtime_sends() {
6196 let src = base();
6197 let dst = CmpMeta {
6198 mtime: 200,
6199 ..base()
6200 };
6201 let r = skip_unchanged_send(
6202 &size_mtime(),
6203 None,
6204 false,
6205 &src,
6206 Some(ExistingDst {
6207 meta: &dst,
6208 is_file: true,
6209 }),
6210 );
6211 assert!(!r);
6212 }
6213
6214 #[test]
6215 fn skip_send_overwrite_non_file_sends() {
6216 let src = base();
6217 let dst = base();
6218 let r = skip_unchanged_send(
6219 &size_mtime(),
6220 None,
6221 false,
6222 &src,
6223 Some(ExistingDst {
6224 meta: &dst,
6225 is_file: false,
6226 }),
6227 );
6228 assert!(!r);
6229 }
6230
6231 #[test]
6232 fn skip_send_filter_newer_skips_when_dest_newer() {
6233 let src = CmpMeta {
6234 mtime: 100,
6235 size: 5,
6236 ..base()
6237 };
6238 let dst = CmpMeta {
6239 mtime: 200,
6240 size: 11,
6241 ..base()
6242 };
6243 let r = skip_unchanged_send(
6244 &size_mtime(),
6245 Some(OverwriteFilter::Newer),
6246 false,
6247 &src,
6248 Some(ExistingDst {
6249 meta: &dst,
6250 is_file: true,
6251 }),
6252 );
6253 assert!(r);
6254 }
6255
6256 #[test]
6257 fn skip_send_filter_newer_sends_when_dest_older() {
6258 let src = CmpMeta {
6259 mtime: 200,
6260 size: 5,
6261 ..base()
6262 };
6263 let dst = CmpMeta {
6264 mtime: 100,
6265 size: 11,
6266 ..base()
6267 };
6268 let r = skip_unchanged_send(
6269 &size_mtime(),
6270 Some(OverwriteFilter::Newer),
6271 false,
6272 &src,
6273 Some(ExistingDst {
6274 meta: &dst,
6275 is_file: true,
6276 }),
6277 );
6278 assert!(!r);
6279 }
6280}