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, RemovalSnapshot};
18use crate::walk::{AdmittedEntry, AdmittedLeaf, EntryAdmission, EntryKind, LeafPermit, PermitKind};
19use crate::walk_driver::{
20 DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_admitted_entry,
21 process_entry,
22};
23
24pub type Error = crate::error::OperationError<Summary>;
27
28#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
33pub enum OverwriteFilter {
34 #[value(name = "newer")]
36 Newer,
37}
38
39impl std::fmt::Display for OverwriteFilter {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 OverwriteFilter::Newer => write!(f, "newer"),
43 }
44 }
45}
46
47pub struct ExistingDst<'a, M: crate::preserve::Metadata> {
49 pub meta: &'a M,
51 pub is_file: bool,
55}
56
57pub fn skip_unchanged_send<S, D>(
65 overwrite_compare: &filecmp::MetadataCmpSettings,
66 overwrite_filter: Option<OverwriteFilter>,
67 ignore_existing: bool,
68 src: &S,
69 dst: Option<ExistingDst<'_, D>>,
70) -> bool
71where
72 S: crate::preserve::Metadata + std::fmt::Debug,
73 D: crate::preserve::Metadata + std::fmt::Debug,
74{
75 let Some(dst) = dst else {
76 return false; };
78 if ignore_existing {
79 return true; }
81 if !dst.is_file {
82 return false; }
84 if filecmp::metadata_equal(overwrite_compare, src, dst.meta) {
85 return true; }
87 if overwrite_filter == Some(OverwriteFilter::Newer) && filecmp::dest_is_newer(src, dst.meta) {
88 return true; }
90 false
91}
92
93#[derive(Debug, Clone)]
99pub struct DeleteSettings {
100 pub delete_excluded: bool,
103}
104
105#[derive(Debug, Clone)]
106pub struct Settings {
107 pub dereference: bool,
108 pub fail_early: bool,
109 pub overwrite: bool,
110 pub overwrite_compare: filecmp::MetadataCmpSettings,
111 pub overwrite_filter: Option<OverwriteFilter>,
112 pub ignore_existing: bool,
113 pub chunk_size: u64,
114 pub skip_specials: bool,
116 pub remote_copy_buffer_size: usize,
122 pub filter: Option<crate::filter::FilterSettings>,
124 pub dry_run: Option<crate::config::DryRunMode>,
126 pub delete: Option<DeleteSettings>,
128}
129
130fn skipped_summary_for(kind: EntryKind) -> Summary {
134 match kind {
135 EntryKind::Dir => Summary {
136 directories_skipped: 1,
137 ..Default::default()
138 },
139 EntryKind::Symlink => Summary {
140 symlinks_skipped: 1,
141 ..Default::default()
142 },
143 EntryKind::File | EntryKind::Special => Summary {
144 files_skipped: 1,
145 ..Default::default()
146 },
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum EmptyDirAction {
154 Keep,
156 Remove,
158 DryRunSkip,
160}
161
162pub fn check_empty_dir_cleanup(
177 filter: Option<&crate::filter::FilterSettings>,
178 we_created_dir: bool,
179 anything_copied: bool,
180 relative_path: &std::path::Path,
181 is_root: bool,
182 is_dry_run: bool,
183) -> EmptyDirAction {
184 if filter.is_none() || anything_copied {
186 return EmptyDirAction::Keep;
187 }
188 if !we_created_dir {
190 return EmptyDirAction::Keep;
191 }
192 if is_root {
194 return EmptyDirAction::Keep;
195 }
196 let f = filter.unwrap();
198 if f.directly_matches_include(relative_path, true) {
200 return EmptyDirAction::Keep;
201 }
202 if is_dry_run {
204 EmptyDirAction::DryRunSkip
205 } else {
206 EmptyDirAction::Remove
207 }
208}
209
210#[instrument]
211pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
212 let ft1 = md1.file_type();
213 let ft2 = md2.file_type();
214 ft1.is_dir() == ft2.is_dir()
215 && ft1.is_file() == ft2.is_file()
216 && ft1.is_symlink() == ft2.is_symlink()
217}
218
219#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
220pub struct Summary {
221 pub bytes_copied: u64,
222 pub files_copied: usize,
223 pub symlinks_created: usize,
224 pub directories_created: usize,
225 pub files_unchanged: usize,
226 pub symlinks_unchanged: usize,
227 pub directories_unchanged: usize,
228 pub files_skipped: usize,
229 pub symlinks_skipped: usize,
230 pub directories_skipped: usize,
231 pub specials_skipped: usize,
232 pub rm_summary: RmSummary,
233}
234
235impl std::ops::Add for Summary {
236 type Output = Self;
237 fn add(self, other: Self) -> Self {
238 Self {
239 bytes_copied: self.bytes_copied + other.bytes_copied,
240 files_copied: self.files_copied + other.files_copied,
241 symlinks_created: self.symlinks_created + other.symlinks_created,
242 directories_created: self.directories_created + other.directories_created,
243 files_unchanged: self.files_unchanged + other.files_unchanged,
244 symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
245 directories_unchanged: self.directories_unchanged + other.directories_unchanged,
246 files_skipped: self.files_skipped + other.files_skipped,
247 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
248 directories_skipped: self.directories_skipped + other.directories_skipped,
249 specials_skipped: self.specials_skipped + other.specials_skipped,
250 rm_summary: self.rm_summary + other.rm_summary,
251 }
252 }
253}
254
255impl std::fmt::Display for Summary {
256 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
257 write!(
258 f,
259 "copy:\n\
260 -----\n\
261 bytes copied: {}\n\
262 files copied: {}\n\
263 symlinks created: {}\n\
264 directories created: {}\n\
265 files unchanged: {}\n\
266 symlinks unchanged: {}\n\
267 directories unchanged: {}\n\
268 files skipped: {}\n\
269 symlinks skipped: {}\n\
270 directories skipped: {}\n\
271 specials skipped: {}\n\
272 \n\
273 delete:\n\
274 -------\n\
275 {}",
276 bytesize::ByteSize(self.bytes_copied),
277 self.files_copied,
278 self.symlinks_created,
279 self.directories_created,
280 self.files_unchanged,
281 self.symlinks_unchanged,
282 self.directories_unchanged,
283 self.files_skipped,
284 self.symlinks_skipped,
285 self.directories_skipped,
286 self.specials_skipped,
287 &self.rm_summary,
288 )
289 }
290}
291
292#[instrument(skip(prog_track, settings, preserve))]
295pub async fn copy(
296 prog_track: &'static progress::Progress,
297 src: &std::path::Path,
298 dst: &std::path::Path,
299 settings: &Settings,
300 preserve: &preserve::Settings,
301 is_fresh: bool,
302) -> Result<Summary, Error> {
303 copy_with_filter_base(
304 prog_track,
305 src,
306 dst,
307 settings,
308 preserve,
309 is_fresh,
310 std::path::Path::new(""),
311 )
312 .await
313}
314
315#[instrument(skip(prog_track, settings, preserve))]
326#[allow(clippy::too_many_arguments)]
327pub async fn copy_with_filter_base(
328 prog_track: &'static progress::Progress,
329 src: &std::path::Path,
330 dst: &std::path::Path,
331 settings: &Settings,
332 preserve: &preserve::Settings,
333 is_fresh: bool,
334 filter_base: &std::path::Path,
335) -> Result<Summary, Error> {
336 copy_with_filter_base_admitted(
337 prog_track,
338 src,
339 dst,
340 settings,
341 preserve,
342 is_fresh,
343 filter_base,
344 DeleteScanAnchor::new(dst, std::path::Path::new("")),
345 None,
346 )
347 .await
348}
349
350#[instrument(skip(prog_track, settings, preserve, open_file_guard))]
357#[allow(clippy::too_many_arguments)]
358async fn copy_with_filter_base_admitted(
359 prog_track: &'static progress::Progress,
360 src: &std::path::Path,
361 dst: &std::path::Path,
362 settings: &Settings,
363 preserve: &preserve::Settings,
364 is_fresh: bool,
365 filter_base: &std::path::Path,
366 delete_scan_anchor: DeleteScanAnchor,
367 open_file_guard: Option<throttle::OpenFileGuard>,
368) -> Result<Summary, Error> {
369 let open_file_guard = match open_file_guard {
370 Some(guard) => guard,
371 None => throttle::open_file_permit().await,
372 };
373 enum CopyRootSetup {
374 Complete(Summary),
375 Ready {
376 src_operand: crate::walk::RootOperand,
377 src_parent: Arc<Dir>,
378 dst_parent: Option<Arc<Dir>>,
379 },
380 }
381 let setup_admission = open_file_guard.admission();
382 let setup = safedir::with_fd_admission(setup_admission, async {
383 let src_operand = crate::walk::split_root_operand(src)
387 .await
388 .map_err(|err| Error::new(err, Default::default()))?;
389 let src_parent_path = src_operand.parent.as_path();
390 let src_name = src_operand.name.as_os_str();
391 let src = src_operand.display.as_path();
392 let dst_parent_path_opt: Option<&std::path::Path> = match (dst.parent(), dst.file_name()) {
397 (Some(parent), Some(_)) if parent.as_os_str().is_empty() => {
398 Some(std::path::Path::new("."))
399 }
400 (Some(parent), Some(_)) => Some(parent),
401 _ => None,
402 };
403 let strict = crate::safedir::strict_operand_resolution();
413 let strict_src_parent: Option<Arc<Dir>> = if strict {
414 let parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
415 .await
416 .with_context(|| {
417 format!("cannot open source parent directory {:?}", src_parent_path)
418 })
419 .map_err(|err| Error::new(err, Default::default()))?;
420 Some(Arc::new(parent.into_tree()))
422 } else {
423 None
424 };
425 let strict_dst_parent: Option<Arc<Dir>> = match (strict, dst_parent_path_opt) {
426 (true, Some(dst_parent_path)) => {
427 match Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination).await {
428 Ok(parent) => Some(Arc::new(parent.into_tree())),
429 Err(err)
433 if err.kind() == std::io::ErrorKind::NotFound
434 || err.raw_os_error() == Some(libc::ENOTDIR) =>
435 {
436 None
437 }
438 Err(err) => {
439 return Err(Error::new(
440 anyhow::Error::new(err).context(format!(
441 "cannot open destination parent directory {dst_parent_path:?}"
442 )),
443 Default::default(),
444 ));
445 }
446 }
447 }
448 _ => None,
450 };
451 if let Some(ref filter) = settings.filter {
453 let (kind, is_dir) = match &strict_src_parent {
454 Some(parent) => {
456 let root_handle = parent
457 .child(src_name)
458 .await
459 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
460 .map_err(|err| Error::new(err, Default::default()))?;
461 (root_handle.kind(), root_handle.kind() == EntryKind::Dir)
462 }
463 None => {
464 let src_metadata = crate::walk::run_metadata_probed(
465 congestion::Side::Source,
466 congestion::MetadataOp::Stat,
467 tokio::fs::symlink_metadata(src),
468 )
469 .await
470 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
471 .map_err(|err| Error::new(err, Default::default()))?;
472 (
473 EntryKind::from_metadata(&src_metadata),
474 src_metadata.is_dir(),
475 )
476 }
477 };
478 let result = if filter_base.as_os_str().is_empty() {
482 filter.should_include_root_item(std::path::Path::new(src_name), is_dir)
483 } else {
484 filter.should_include(filter_base, is_dir)
485 };
486 match result {
487 crate::filter::FilterResult::Included => {}
488 result => {
489 if let Some(mode) = settings.dry_run {
490 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
491 }
492 kind.inc_skipped(prog_track);
493 return Ok(CopyRootSetup::Complete(skipped_summary_for(kind)));
494 }
495 }
496 }
497 let src_parent = match strict_src_parent {
500 Some(parent) => parent,
501 None => {
502 let parent = Dir::open_parent_dir(src_parent_path, congestion::Side::Source)
503 .await
504 .with_context(|| {
505 format!("cannot open source parent directory {:?}", src_parent_path)
506 })
507 .map_err(|err| Error::new(err, Default::default()))?;
508 Arc::new(parent.into_tree())
509 }
510 };
511 crate::safedir::warn_if_root_acl_unpreserved_at(
516 &src_parent,
517 src_name,
518 src,
519 crate::safedir::RootAclNotice::for_preserve(preserve),
520 )
521 .await;
522 let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
526 return Err(Error::new(
527 anyhow!(
528 "copy destination {:?} has no parent directory or file name",
529 dst
530 ),
531 Default::default(),
532 ));
533 };
534 let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
535 std::path::Path::new(".")
536 } else {
537 dst_parent_path
538 };
539 let dst_parent = if settings.dry_run.is_some() {
544 None
545 } else {
546 match strict_dst_parent {
547 Some(parent) => Some(parent),
548 None => {
549 let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
550 .await
551 .with_context(|| {
552 format!(
553 "cannot open destination parent directory {:?}",
554 dst_parent_path
555 )
556 })
557 .map_err(|err| Error::new(err, Default::default()))?;
558 Some(Arc::new(dir.into_tree()))
559 }
560 }
561 };
562 Ok(CopyRootSetup::Ready {
563 src_operand,
564 src_parent,
565 dst_parent,
566 })
567 })
568 .await?;
569 let (src_operand, src_parent, dst_parent) = match setup {
570 CopyRootSetup::Complete(summary) => return Ok(summary),
571 CopyRootSetup::Ready {
572 src_operand,
573 src_parent,
574 dst_parent,
575 } => (src_operand, src_parent, dst_parent),
576 };
577 run_copy_root(
578 prog_track,
579 &src_parent,
580 dst_parent,
581 &src_operand.name,
582 &src_operand.display,
583 dst,
584 filter_base,
585 settings,
586 preserve,
587 is_fresh,
588 delete_scan_anchor,
589 EntryAdmission::Held(LeafPermit::OpenFile(open_file_guard)).into(),
590 )
591 .await
592}
593
594pub(crate) enum CopyEntryAdmission {
604 Unclassified(EntryAdmission),
605 Admitted(AdmittedEntry),
606 Filtered(AdmittedEntry),
607}
608
609impl From<EntryAdmission> for CopyEntryAdmission {
610 fn from(admission: EntryAdmission) -> Self {
611 Self::Unclassified(admission)
612 }
613}
614
615impl From<AdmittedEntry> for CopyEntryAdmission {
616 fn from(entry: AdmittedEntry) -> Self {
617 Self::Admitted(entry)
618 }
619}
620
621#[instrument(skip(prog_track, src_parent, dst_parent, settings, preserve, admission))]
635#[allow(clippy::too_many_arguments)]
636pub(crate) async fn copy_child(
637 prog_track: &'static progress::Progress,
638 src_parent: &Arc<Dir>,
639 dst_parent: Option<&Arc<Dir>>,
640 name: &OsStr,
641 src_path: &std::path::Path,
642 dst_path: &std::path::Path,
643 filter_base: &std::path::Path,
644 settings: &Settings,
645 preserve: &preserve::Settings,
646 is_fresh: bool,
647 delete_scan_anchor: DeleteScanAnchor,
648 admission: CopyEntryAdmission,
649) -> Result<Summary, Error> {
650 run_copy_root(
651 prog_track,
652 src_parent,
653 dst_parent.map(Arc::clone),
654 name,
655 src_path,
656 dst_path,
657 filter_base,
658 settings,
659 preserve,
660 is_fresh,
661 delete_scan_anchor,
662 admission,
663 )
664 .await
665}
666
667#[derive(Clone, Debug)]
669pub(crate) struct DeleteScanAnchor {
670 named_root: PathBuf,
671 base: PathBuf,
672}
673
674impl DeleteScanAnchor {
675 #[must_use]
677 pub(crate) fn new(named_root: &std::path::Path, base: &std::path::Path) -> Self {
678 Self {
679 named_root: named_root.to_path_buf(),
680 base: base.to_path_buf(),
681 }
682 }
683
684 fn relative_from_root(&self, relative: &std::path::Path) -> PathBuf {
685 if self.base.as_os_str().is_empty() {
686 relative.to_path_buf()
687 } else if relative.as_os_str().is_empty() {
688 self.base.clone()
689 } else {
690 self.base.join(relative)
691 }
692 }
693
694 fn descend(&self, relative: &std::path::Path) -> Self {
695 Self {
696 named_root: self.named_root.clone(),
697 base: self.relative_from_root(relative),
698 }
699 }
700
701 async fn open(&self, relative: &std::path::Path) -> std::io::Result<Option<Dir>> {
702 let relative = self.relative_from_root(relative);
703 safedir::open_existing_dir_beneath_operand(
704 &self.named_root,
705 &relative,
706 congestion::Side::Destination,
707 )
708 .await
709 }
710}
711
712struct CopyVisitor {
722 prog_track: &'static progress::Progress,
723 dst_root: PathBuf,
728 filter_base: PathBuf,
730 delete_scan_anchor: DeleteScanAnchor,
733 settings: Settings,
734 preserve: preserve::Settings,
735 dst_parent: Option<Arc<Dir>>,
738 root_is_fresh: bool,
740}
741
742#[derive(Clone)]
747struct CopyDirContext {
748 dst_dir: Option<Arc<Dir>>,
749 is_fresh: bool,
750}
751
752struct CopyDirState {
756 dst_dir: Option<Arc<Dir>>,
758 dst_parent: Option<Arc<Dir>>,
761 dst_name: OsString,
762 we_created: bool,
764 reused_lock: Option<safedir::ReusedDirLock>,
767 src_meta: FileMeta,
769 src_acls: Option<safedir::Acls>,
773 is_root: bool,
775 base: Summary,
778}
779
780impl CopyVisitor {
781 fn dst_path_for(&self, cx: &EntryCx) -> PathBuf {
785 if cx.rel_path.as_os_str().is_empty() {
786 self.dst_root.clone()
787 } else {
788 self.dst_root.join(&cx.rel_path)
789 }
790 }
791
792 #[allow(clippy::result_large_err)]
800 fn dst_name_for(&self, cx: &EntryCx) -> Result<OsString, Error> {
801 if cx.rel_path.as_os_str().is_empty() {
802 self.dst_root
803 .file_name()
804 .map(OsStr::to_owned)
805 .ok_or_else(|| {
806 Error::new(
807 anyhow!("copy destination {:?} has no file name", &self.dst_root),
808 Default::default(),
809 )
810 })
811 } else {
812 Ok(cx.name.clone())
813 }
814 }
815
816 async fn prune_finished_dir(
827 &self,
828 copy_summary: &mut Summary,
829 child_error: &mut Option<anyhow::Error>,
830 processed: &ProcessedChildren,
831 dst_dir: &Option<Arc<Dir>>,
832 dst_path: &std::path::Path,
833 rel_path: &std::path::Path,
834 ) -> Result<(), Error> {
835 if child_error.is_some() && self.settings.delete.is_some() {
836 tracing::warn!(
837 "skipping --delete pruning of {:?} because the copy reported errors",
838 dst_path
839 );
840 }
841 let Some(delete_settings) = &self.settings.delete else {
842 return Ok(());
843 };
844 if child_error.is_some() {
845 return Ok(());
846 }
847 let keep_set: std::collections::HashSet<OsString> =
851 processed.names().iter().cloned().collect();
852 let relative_dir = self.filter_base.join(rel_path);
853 let prune_dir: Option<Arc<Dir>> = match dst_dir {
857 Some(dir) => Some(Arc::clone(dir)),
858 None => match self.delete_scan_anchor.open(rel_path).await {
859 Ok(Some(dir)) => Some(Arc::new(dir)),
860 Ok(None) => {
861 tracing::debug!(
862 "skipping --delete pruning of {:?}: not a real directory in dry-run",
863 dst_path
864 );
865 None
866 }
867 Err(err) => {
868 let err = anyhow::Error::new(err).context(format!(
869 "cannot open destination {dst_path:?} for delete scan"
870 ));
871 return Err(Error::new(err, *copy_summary));
875 }
876 },
877 };
878 let Some(prune_dir) = prune_dir else {
879 return Ok(());
880 };
881 match crate::delete::prune_extraneous(
882 self.prog_track,
883 &prune_dir,
884 &relative_dir,
885 &keep_set,
886 self.settings.filter.as_ref(),
887 delete_settings,
888 self.settings.fail_early,
889 self.settings.dry_run,
890 )
891 .await
892 {
893 Ok(rm_summary) => {
894 copy_summary.rm_summary = copy_summary.rm_summary + rm_summary;
895 }
896 Err(err) => {
897 copy_summary.rm_summary = copy_summary.rm_summary + err.summary;
898 if self.settings.fail_early {
899 return Err(Error::new(err.source, *copy_summary));
900 }
901 *child_error = Some(err.source);
905 }
906 }
907 Ok(())
908 }
909
910 async fn finalize_dir(&self, fin: FinalizeDir, cx: &EntryCx) -> Result<Summary, Error> {
916 let FinalizeDir {
917 mut copy_summary,
918 child_error,
919 dst_dir,
920 dst_parent,
921 dst_name,
922 src_meta,
923 src_acls,
924 we_created,
925 reused_lock,
926 is_root,
927 } = fin;
928 let src_path = &cx.real_path;
929 let dst_path = self.dst_path_for(cx);
930 let rel_path = &cx.rel_path;
931 let this_dir_count = usize::from(we_created);
934 let child_dirs_created = copy_summary
935 .directories_created
936 .saturating_sub(this_dir_count);
937 let anything_copied = copy_summary.files_copied > 0
938 || copy_summary.symlinks_created > 0
939 || child_dirs_created > 0;
940 let relative_path = self.filter_base.join(rel_path);
941 match check_empty_dir_cleanup(
942 self.settings.filter.as_ref(),
943 we_created,
944 anything_copied,
945 &relative_path,
946 is_root,
947 self.settings.dry_run.is_some(),
948 ) {
949 EmptyDirAction::Keep => { }
950 EmptyDirAction::DryRunSkip => {
951 tracing::debug!(
952 "dry-run: directory {:?} would not be created (nothing to copy inside)",
953 dst_path
954 );
955 copy_summary.directories_created = 0;
956 return match child_error {
960 Some(child_error) => Err(Error::new(child_error, copy_summary)),
961 None => Ok(copy_summary),
962 };
963 }
964 EmptyDirAction::Remove => {
965 tracing::debug!(
966 "directory {:?} has nothing to copy inside, removing empty directory",
967 dst_path
968 );
969 let rmdir_result = match &dst_parent {
976 Some(dst_parent) => dst_parent.rmdir_at(&dst_name).await,
977 None => {
978 crate::walk::run_metadata_probed(
979 congestion::Side::Destination,
980 congestion::MetadataOp::RmDir,
981 tokio::fs::remove_dir(&dst_path),
982 )
983 .await
984 }
985 };
986 match rmdir_result {
987 Ok(()) => {
988 copy_summary.directories_created = 0;
989 return match child_error {
994 Some(child_error) => Err(Error::new(child_error, copy_summary)),
995 None => Ok(copy_summary),
996 };
997 }
998 Err(err) => {
999 tracing::debug!(
1000 "failed to remove empty directory {:?}: {:#}, keeping",
1001 dst_path,
1002 &err
1003 );
1004 }
1006 }
1007 }
1008 }
1009 tracing::debug!("set 'dst' directory metadata");
1014 let metadata_result = match &dst_dir {
1015 Some(dst_dir) => {
1021 safedir::set_reused_dir_metadata_fd(
1022 &self.preserve,
1023 &src_meta,
1024 src_acls.as_ref(),
1025 reused_lock,
1026 dst_dir,
1027 )
1028 .await
1029 }
1030 None => Ok(()),
1031 };
1032 if let Some(child_error) = child_error {
1033 if let Err(metadata_err) = metadata_result {
1034 tracing::error!(
1035 "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
1036 src_path,
1037 dst_path,
1038 &metadata_err
1039 );
1040 }
1041 return Err(Error::new(child_error, copy_summary));
1042 }
1043 metadata_result
1045 .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
1046 .map_err(|err| Error::new(err, copy_summary))?;
1047 Ok(copy_summary)
1048 }
1049}
1050
1051struct FinalizeDir {
1055 copy_summary: Summary,
1056 child_error: Option<anyhow::Error>,
1057 dst_dir: Option<Arc<Dir>>,
1058 dst_parent: Option<Arc<Dir>>,
1059 dst_name: OsString,
1060 src_meta: FileMeta,
1061 src_acls: Option<safedir::Acls>,
1062 we_created: bool,
1063 reused_lock: Option<safedir::ReusedDirLock>,
1064 is_root: bool,
1065}
1066
1067async fn filter_and_process_admitted_root(
1068 visitor: Arc<CopyVisitor>,
1069 root_cx: EntryCx,
1070 root_ctx: CopyDirContext,
1071 entry: AdmittedEntry,
1072 filter: &crate::filter::FilterSettings,
1073) -> Result<Summary, Error> {
1074 let kind = entry.kind();
1075 let filter_result = if root_cx.filter_path.as_os_str().is_empty() {
1076 filter.should_include_root_item(
1077 std::path::Path::new(root_cx.name.as_os_str()),
1078 kind == EntryKind::Dir,
1079 )
1080 } else {
1081 filter.should_include(&root_cx.filter_path, kind == EntryKind::Dir)
1082 };
1083 if !matches!(filter_result, crate::filter::FilterResult::Included) {
1084 if let Some(mode) = visitor.settings.dry_run {
1085 crate::dry_run::report_skip(
1086 &root_cx.real_path,
1087 &filter_result,
1088 mode,
1089 kind.label_long(),
1090 );
1091 }
1092 kind.inc_skipped(visitor.prog_track);
1093 return Ok(skipped_summary_for(kind));
1094 }
1095 process_admitted_entry(visitor, root_cx, root_ctx, entry).await
1096}
1097
1098#[allow(clippy::too_many_arguments)]
1105async fn run_copy_root(
1106 prog_track: &'static progress::Progress,
1107 src_parent: &Arc<Dir>,
1108 dst_parent: Option<Arc<Dir>>,
1109 name: &OsStr,
1110 src_root: &std::path::Path,
1111 dst_root: &std::path::Path,
1112 filter_base: &std::path::Path,
1113 settings: &Settings,
1114 preserve: &preserve::Settings,
1115 is_fresh: bool,
1116 delete_scan_anchor: DeleteScanAnchor,
1117 admission: CopyEntryAdmission,
1118) -> Result<Summary, Error> {
1119 let visitor = Arc::new(CopyVisitor {
1120 prog_track,
1121 dst_root: dst_root.to_path_buf(),
1122 filter_base: filter_base.to_path_buf(),
1123 delete_scan_anchor,
1124 settings: settings.clone(),
1125 preserve: *preserve,
1126 dst_parent,
1127 root_is_fresh: is_fresh,
1128 });
1129 let root_cx = EntryCx {
1138 parent: Arc::clone(src_parent),
1139 name: name.to_owned(),
1140 rel_path: PathBuf::new(),
1141 filter_path: filter_base.to_path_buf(),
1142 real_path: src_root.to_path_buf(),
1143 dry_run: settings.dry_run.is_some(),
1144 prog_track,
1145 };
1146 let root_ctx = visitor.root_dir_context();
1147 match admission {
1148 CopyEntryAdmission::Unclassified(admission) => {
1149 if let Some(filter) = visitor.filter().cloned() {
1150 let admission = crate::walk::ensure_entry_admission(
1151 visitor.permit_kind(),
1152 admission.require_admission(),
1153 )
1154 .await;
1155 let entry = crate::walk::classify_admitted_entry(
1156 &root_cx.parent,
1157 &root_cx.name,
1158 admission.into_permit(),
1159 )
1160 .await
1161 .with_context(|| format!("failed reading metadata from {:?}", &root_cx.real_path))
1162 .map_err(|err| Error::new(err, Default::default()))?;
1163 return filter_and_process_admitted_root(
1164 visitor, root_cx, root_ctx, entry, &filter,
1165 )
1166 .await;
1167 }
1168 process_entry(visitor, root_cx, root_ctx, admission).await
1169 }
1170 CopyEntryAdmission::Admitted(entry) => {
1171 if let Some(filter) = visitor.filter().cloned() {
1172 return filter_and_process_admitted_root(
1173 visitor, root_cx, root_ctx, entry, &filter,
1174 )
1175 .await;
1176 }
1177 process_admitted_entry(visitor, root_cx, root_ctx, entry).await
1178 }
1179 CopyEntryAdmission::Filtered(entry) => {
1180 process_admitted_entry(visitor, root_cx, root_ctx, entry).await
1181 }
1182 }
1183}
1184
1185impl WalkVisitor for CopyVisitor {
1186 type Summary = Summary;
1187 type DirContext = CopyDirContext;
1188 type DirState = CopyDirState;
1189
1190 fn root_dir_context(&self) -> CopyDirContext {
1191 CopyDirContext {
1192 dst_dir: self.dst_parent.clone(),
1193 is_fresh: self.root_is_fresh,
1194 }
1195 }
1196
1197 fn permit_kind(&self) -> PermitKind {
1198 PermitKind::OpenFile
1201 }
1202
1203 fn fail_early(&self) -> bool {
1204 self.settings.fail_early
1205 }
1206
1207 fn filter(&self) -> Option<&crate::filter::FilterSettings> {
1208 self.settings.filter.as_ref()
1209 }
1210
1211 fn filter_requires_admitted_entry(&self) -> bool {
1212 true
1213 }
1214
1215 fn filter_allows_hint_only_skip(&self, _dir_ctx: &Self::DirContext) -> bool {
1216 self.settings.delete.is_none() && self.settings.dry_run.is_none()
1220 }
1221
1222 fn on_skip(
1223 &self,
1224 cx: &EntryCx,
1225 kind: EntryKind,
1226 skip_result: &crate::filter::FilterResult,
1227 ) -> Summary {
1228 if let Some(mode) = self.settings.dry_run {
1231 crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
1232 }
1233 skipped_summary_for(kind)
1234 }
1235
1236 async fn visit_leaf(
1237 &self,
1238 cx: &EntryCx,
1239 parent_ctx: &CopyDirContext,
1240 leaf: AdmittedLeaf,
1241 ) -> Result<Summary, Error> {
1242 let src_parent = &cx.parent;
1243 let dst_parent = parent_ctx.dst_dir.as_ref();
1244 let name = cx.name.as_os_str();
1245 let src_path = &cx.real_path;
1246 let dst_path = self.dst_path_for(cx);
1247 let dst_name = self.dst_name_for(cx)?;
1248 let is_fresh = parent_ctx.is_fresh;
1249 if self.settings.dereference && leaf.kind() == EntryKind::Symlink {
1254 let open_file_guard = match leaf.into_permit() {
1255 Some(permit) => permit.into_open_file(),
1256 None => throttle::open_file_permit().await,
1257 };
1258 debug_assert!(
1263 self.settings.dereference,
1264 "canonicalize reached with dereference == false; the non-dereference copy path \
1265 must never re-resolve a path"
1266 );
1267 let link = crate::walk::run_metadata_probed(
1268 congestion::Side::Source,
1269 congestion::MetadataOp::Stat,
1270 tokio::fs::canonicalize(src_path),
1271 )
1272 .await
1273 .with_context(|| format!("failed reading src symlink {:?}", src_path))
1274 .map_err(|err| Error::new(err, Default::default()))?;
1275 return copy_with_filter_base_admitted(
1276 self.prog_track,
1277 &link,
1278 &dst_path,
1279 &self.settings,
1280 &self.preserve,
1281 is_fresh,
1282 std::path::Path::new(""),
1283 self.delete_scan_anchor.descend(&cx.rel_path),
1284 Some(open_file_guard),
1285 )
1286 .await;
1287 }
1288 match leaf.kind() {
1289 EntryKind::File => {
1290 copy_file_fd(
1291 self.prog_track,
1292 src_parent,
1293 dst_parent,
1294 name,
1295 &dst_name,
1296 &dst_path,
1297 src_path,
1298 leaf.handle(),
1299 &self.settings,
1300 &self.preserve,
1301 is_fresh,
1302 )
1303 .await
1304 }
1305 EntryKind::Symlink => {
1306 copy_symlink_fd(
1307 self.prog_track,
1308 src_parent,
1309 dst_parent,
1310 &dst_name,
1311 src_path,
1312 &dst_path,
1313 leaf.handle(),
1314 &self.settings,
1315 &self.preserve,
1316 is_fresh,
1317 )
1318 .await
1319 }
1320 EntryKind::Special => {
1321 if self.settings.skip_specials {
1322 tracing::debug!("skipping special file {:?}", src_path);
1323 if let Some(mode) = self.settings.dry_run {
1324 match mode {
1325 DryRunMode::Brief => {}
1326 DryRunMode::All => println!("skip special {:?}", src_path),
1327 DryRunMode::Explain => {
1328 println!("skip special {:?} (unsupported file type)", src_path);
1329 }
1330 }
1331 }
1332 self.prog_track.specials_skipped.inc();
1333 return Ok(Summary {
1334 specials_skipped: 1,
1335 ..Default::default()
1336 });
1337 }
1338 Err(Error::new(
1339 anyhow!(
1340 "copy: {:?} -> {:?} failed, unsupported src file type",
1341 src_path,
1342 dst_path
1343 ),
1344 Default::default(),
1345 ))
1346 }
1347 EntryKind::Dir => unreachable!("directory entries are handled by dir_pre/dir_post"),
1349 }
1350 }
1351
1352 async fn dir_pre(
1353 &self,
1354 cx: &EntryCx,
1355 parent_ctx: &CopyDirContext,
1356 _handle: &Handle,
1357 ) -> DirPreResult<Self> {
1358 let src_parent = &cx.parent;
1359 let name = cx.name.as_os_str();
1360 let src_path = &cx.real_path;
1361 let dst_path = self.dst_path_for(cx);
1362 let dst_name = self.dst_name_for(cx)?;
1363 let is_root = cx.rel_path.as_os_str().is_empty();
1364 let is_fresh = parent_ctx.is_fresh;
1365 let src_dir = src_parent
1367 .open_dir(name)
1368 .await
1369 .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1370 .map_err(|err| Error::new(err, Default::default()))?;
1371 let src_dir = Arc::new(src_dir);
1372 let src_meta = src_dir
1377 .meta()
1378 .await
1379 .with_context(|| format!("cannot read directory metadata from {:?}", src_path))
1380 .map_err(|err| Error::new(err, Default::default()))?;
1381 if self.settings.dry_run.is_some() {
1382 let dst_nondir_exists = if !self.settings.ignore_existing || is_fresh {
1386 false
1387 } else if crate::safedir::strict_operand_resolution() {
1388 matches!(strict_dry_run_dst_kind(&dst_path).await?, Some(kind) if kind != EntryKind::Dir)
1390 } else {
1391 match crate::walk::run_metadata_probed(
1392 congestion::Side::Destination,
1393 congestion::MetadataOp::Stat,
1394 tokio::fs::symlink_metadata(&dst_path),
1395 )
1396 .await
1397 {
1398 Ok(meta) => !meta.is_dir(),
1406 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1412 Err(error) => {
1413 return Err(Error::new(
1414 anyhow::Error::new(error)
1415 .context(format!("cannot probe destination {dst_path:?}")),
1416 Default::default(),
1417 ));
1418 }
1419 }
1420 };
1421 if dst_nondir_exists {
1422 match self.settings.dry_run {
1423 Some(DryRunMode::Brief) | None => {}
1424 Some(DryRunMode::All) => println!("skip dir {:?}", dst_path),
1425 Some(DryRunMode::Explain) => {
1426 println!(
1427 "skip dir {:?} (destination exists, not a directory)",
1428 dst_path
1429 );
1430 }
1431 }
1432 return Ok(DirAction::Skip(Summary {
1433 directories_unchanged: 1,
1434 ..Default::default()
1435 }));
1436 }
1437 crate::dry_run::report_action("copy", src_path, Some(&dst_path), "dir");
1438 let base = Summary {
1439 directories_created: 1, ..Default::default()
1441 };
1442 return Ok(DirAction::Descend {
1443 dir: src_dir,
1444 child_ctx: CopyDirContext {
1445 dst_dir: None, is_fresh,
1447 },
1448 state: CopyDirState {
1449 dst_dir: None,
1450 dst_parent: None,
1451 dst_name,
1452 we_created: true,
1454 reused_lock: None,
1456 src_meta,
1457 src_acls: None,
1459 is_root,
1460 base,
1461 },
1462 });
1463 }
1464 let src_acls = if self.preserve.dir.acl {
1469 Some(
1470 src_dir
1471 .read_acls()
1472 .await
1473 .with_context(|| format!("cannot read ACLs from directory {:?}", src_path))
1474 .map_err(|err| Error::new(err, Default::default()))?,
1475 )
1476 } else {
1477 None
1478 };
1479 let dst_parent = parent_ctx
1481 .dst_dir
1482 .as_ref()
1483 .expect("destination parent must be open for a real copy");
1484 let DirSlot {
1485 dir: dst_dir,
1486 summary: base,
1487 is_fresh: child_is_fresh,
1488 we_created,
1489 reused_lock,
1490 } = match resolve_dst_dir(
1491 self.prog_track,
1492 dst_parent,
1493 &dst_name,
1494 &dst_path,
1495 &self.settings,
1496 )
1497 .await?
1498 {
1499 DirResolution::Skip(summary) => return Ok(DirAction::Skip(summary)),
1500 DirResolution::Proceed(slot) => slot,
1501 };
1502 Ok(DirAction::Descend {
1503 dir: src_dir,
1504 child_ctx: CopyDirContext {
1505 dst_dir: Some(Arc::clone(&dst_dir)),
1506 is_fresh: child_is_fresh,
1507 },
1508 state: CopyDirState {
1509 dst_dir: Some(dst_dir),
1510 dst_parent: Some(Arc::clone(dst_parent)),
1511 dst_name,
1512 we_created,
1513 reused_lock,
1514 src_meta,
1515 src_acls,
1516 is_root,
1517 base,
1518 },
1519 })
1520 }
1521
1522 async fn dir_post(
1523 &self,
1524 cx: &EntryCx,
1525 state: CopyDirState,
1526 processed: &ProcessedChildren,
1527 child_result: Result<Summary, Error>,
1528 ) -> Result<Summary, Error> {
1529 let CopyDirState {
1530 dst_dir,
1531 dst_parent,
1532 dst_name,
1533 we_created,
1534 reused_lock,
1535 src_meta,
1536 src_acls,
1537 is_root,
1538 base,
1539 } = state;
1540 let (child_summary, mut child_error) = match child_result {
1546 Ok(summary) => (summary, None),
1547 Err(err) => (err.summary, Some(err.source)),
1548 };
1549 let mut copy_summary = base + child_summary;
1550 let dst_path = self.dst_path_for(cx);
1551 self.prune_finished_dir(
1554 &mut copy_summary,
1555 &mut child_error,
1556 processed,
1557 &dst_dir,
1558 &dst_path,
1559 &cx.rel_path,
1560 )
1561 .await?;
1562 self.finalize_dir(
1564 FinalizeDir {
1565 copy_summary,
1566 child_error,
1567 dst_dir,
1568 dst_parent,
1569 dst_name,
1570 src_meta,
1571 src_acls,
1572 we_created,
1573 reused_lock,
1574 is_root,
1575 },
1576 cx,
1577 )
1578 .await
1579 }
1580}
1581
1582async fn strict_dry_run_dst_kind(dst_path: &std::path::Path) -> Result<Option<EntryKind>, Error> {
1589 crate::safedir::strict_probe_dst_kind(dst_path, congestion::Side::Destination)
1590 .await
1591 .map_err(|err| {
1592 Error::new(
1593 anyhow::Error::new(err).context(format!("cannot probe destination {dst_path:?}")),
1594 Default::default(),
1595 )
1596 })
1597}
1598
1599async fn dry_run_dst_exists(dst_path: &std::path::Path) -> Result<bool, Error> {
1609 if crate::safedir::strict_operand_resolution() {
1610 return Ok(strict_dry_run_dst_kind(dst_path).await?.is_some());
1611 }
1612 match tokio::fs::symlink_metadata(dst_path).await {
1613 Ok(_) => Ok(true),
1614 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1622 Err(error) => Err(Error::new(
1623 anyhow::Error::new(error).context(format!("cannot probe destination {dst_path:?}")),
1624 Default::default(),
1625 )),
1626 }
1627}
1628
1629#[allow(clippy::too_many_arguments)]
1635async fn copy_file_fd(
1636 prog_track: &'static progress::Progress,
1637 src_parent: &Arc<Dir>,
1638 dst_parent: Option<&Arc<Dir>>,
1639 name: &OsStr,
1640 dst_name: &OsStr,
1641 dst_path: &std::path::Path,
1642 src_path: &std::path::Path,
1643 src_handle: &Handle,
1644 settings: &Settings,
1645 preserve: &preserve::Settings,
1646 is_fresh: bool,
1647) -> Result<Summary, Error> {
1648 use crate::preserve::Metadata as _;
1651 debug_assert_eq!(src_handle.kind(), EntryKind::File);
1652 if dst_parent.is_none()
1658 && !is_fresh
1659 && settings.ignore_existing
1660 && dry_run_dst_exists(dst_path).await?
1661 {
1662 if let Some(mode) = settings.dry_run {
1663 match mode {
1664 DryRunMode::Brief => {}
1665 DryRunMode::All => println!("skip file {:?}", dst_path),
1666 DryRunMode::Explain => {
1667 println!("skip file {:?} (destination exists)", dst_path)
1668 }
1669 }
1670 }
1671 tracing::debug!("destination exists, skipping (--ignore-existing)");
1672 prog_track.files_unchanged.inc();
1673 return Ok(Summary {
1674 files_unchanged: 1,
1675 ..Default::default()
1676 });
1677 }
1678 if settings.dry_run.is_none() {
1679 let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1681 let preflight = if is_fresh {
1682 DestinationPreflight::Vacant
1683 } else {
1684 preflight_dst_file(prog_track, dst_parent, dst_name, dst_path, settings).await?
1685 };
1686 let (current, plan) = match preflight {
1689 DestinationPreflight::Skip(summary) => return Ok(summary),
1690 DestinationPreflight::Vacant => (
1691 CurrentRegular::open(src_parent, name, src_path).await?,
1692 FilePlan::Vacant,
1693 ),
1694 DestinationPreflight::OverwriteCandidate(dst_handle) => {
1695 match plan_overwrite_candidate(prog_track, &dst_handle, src_handle.meta(), settings)
1696 {
1697 OverwriteCandidateDecision::Skip(summary) => return Ok(summary),
1698 OverwriteCandidateDecision::Replace => {}
1699 }
1700 let current = CurrentRegular::open(src_parent, name, src_path).await?;
1703 if !current.matches(src_handle)
1704 && let OverwriteCandidateDecision::Skip(summary) =
1705 plan_overwrite_candidate(prog_track, &dst_handle, ¤t.meta, settings)
1706 {
1707 return Ok(summary);
1708 }
1709 (
1710 current,
1711 FilePlan::Replace(dst_handle.into_removal_snapshot()),
1712 )
1713 }
1714 };
1715 return copy_current_regular(
1716 prog_track, src_parent, dst_parent, dst_name, dst_path, src_path, current, plan,
1717 settings, preserve,
1718 )
1719 .await;
1720 }
1721 crate::dry_run::report_action("copy", src_path, Some(dst_path), "file");
1723 Ok(Summary {
1724 files_copied: 1,
1725 bytes_copied: src_handle.meta().size(),
1726 ..Default::default()
1727 })
1728}
1729
1730#[allow(clippy::too_many_arguments)]
1732async fn copy_current_regular(
1733 prog_track: &'static progress::Progress,
1734 src_parent: &Arc<Dir>,
1735 dst_parent: &Arc<Dir>,
1736 dst_name: &OsStr,
1737 dst_path: &std::path::Path,
1738 src_path: &std::path::Path,
1739 current: CurrentRegular,
1740 plan: FilePlan,
1741 settings: &Settings,
1742 preserve: &preserve::Settings,
1743) -> Result<Summary, Error> {
1744 use crate::preserve::Metadata as _;
1745 let CurrentRegular {
1746 file: src_file,
1747 meta: src_meta,
1748 } = current;
1749 let mut copy_summary = Summary::default();
1752 get_file_iops_tokens(settings.chunk_size, src_meta.size()).await;
1768 let src_acls = if preserve.file.acl {
1773 Some(
1774 safedir::read_acls_fd(src_file.as_fd(), src_parent.side(), false)
1775 .await
1776 .with_context(|| format!("failed reading ACLs from {:?}", src_path))
1777 .map_err(|err| Error::new(err, copy_summary))?,
1778 )
1779 } else {
1780 None
1781 };
1782 execute_dst_plan(
1790 prog_track,
1791 dst_parent,
1792 dst_name,
1793 dst_path,
1794 plan,
1795 settings,
1796 &mut copy_summary,
1797 )
1798 .await?;
1799 let dst_file = match dst_parent.create_file(dst_name).await {
1800 Ok(dst_file) => dst_file,
1801 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1811 let preflight =
1812 preflight_dst_file(prog_track, dst_parent, dst_name, dst_path, settings)
1813 .await
1814 .map_err(|err| Error::new(err.source, err.summary + copy_summary))?;
1816 let plan = match preflight {
1817 DestinationPreflight::Vacant => FilePlan::Vacant,
1818 DestinationPreflight::Skip(summary) => {
1819 return Ok(skip_summary(summary, ©_summary));
1820 }
1821 DestinationPreflight::OverwriteCandidate(dst_handle) => {
1822 match plan_overwrite_candidate(prog_track, &dst_handle, &src_meta, settings) {
1823 OverwriteCandidateDecision::Skip(summary) => {
1824 return Ok(skip_summary(summary, ©_summary));
1825 }
1826 OverwriteCandidateDecision::Replace => {
1827 FilePlan::Replace(dst_handle.into_removal_snapshot())
1828 }
1829 }
1830 }
1831 };
1832 execute_dst_plan(
1833 prog_track,
1834 dst_parent,
1835 dst_name,
1836 dst_path,
1837 plan,
1838 settings,
1839 &mut copy_summary,
1840 )
1841 .await?;
1842 dst_parent
1846 .create_file(dst_name)
1847 .await
1848 .with_context(|| format!("failed creating {:?}", dst_path))
1849 .map_err(|err| Error::new(err, copy_summary))?
1850 }
1851 Err(error) => {
1852 return Err(Error::new(
1853 anyhow::Error::new(error).context(format!("failed creating {:?}", dst_path)),
1854 copy_summary,
1855 ));
1856 }
1857 };
1858 tracing::debug!("copying data");
1859 let len = src_meta.size();
1860 #[cfg(test)]
1867 let data_gate_path = dst_path.to_path_buf();
1868 let data_output = safedir::run_fd_admitted_blocking(move || {
1869 #[cfg(test)]
1870 let gate_visit = {
1871 use std::os::fd::AsRawFd as _;
1872 crate::testutils::wait_on_blocking_path_gate(
1873 &data_gate_path,
1874 dst_file.as_fd().as_raw_fd(),
1875 )
1876 };
1877 let copy_result = copy_file_range_all(&src_file, &dst_file, len);
1878 #[cfg(test)]
1879 {
1880 Ok((copy_result, dst_file, gate_visit))
1881 }
1882 #[cfg(not(test))]
1883 {
1884 Ok((copy_result, dst_file))
1885 }
1886 })
1887 .await
1888 .with_context(|| format!("failed copying data to {:?}", dst_path))
1889 .map_err(|err| Error::new(err, copy_summary))?;
1890 #[cfg(test)]
1891 let (copy_result, dst_file, _gate_visit) = data_output;
1892 #[cfg(not(test))]
1893 let (copy_result, dst_file) = data_output;
1894 let copied = copy_result
1895 .with_context(|| format!("failed copying data to {:?}", dst_path))
1896 .map_err(|err| Error::new(err, copy_summary))?;
1897 prog_track.files_copied.inc();
1901 prog_track.bytes_copied.add(copied);
1902 tracing::debug!("setting permissions");
1903 safedir::set_file_metadata_fd(
1904 preserve,
1905 &src_meta,
1906 src_acls.as_ref(),
1907 dst_file.as_fd(),
1908 congestion::Side::Destination,
1909 )
1910 .await
1911 .with_context(|| format!("failed setting metadata on {:?}", dst_path))
1912 .map_err(|err| Error::new(err, copy_summary))?;
1913 copy_summary.bytes_copied += copied;
1915 copy_summary.files_copied += 1;
1916 Ok(copy_summary)
1917}
1918
1919#[allow(clippy::too_many_arguments)]
1922async fn copy_symlink_fd(
1923 prog_track: &'static progress::Progress,
1924 src_parent: &Arc<Dir>,
1925 dst_parent: Option<&Arc<Dir>>,
1926 dst_name: &OsStr,
1927 src_path: &std::path::Path,
1928 dst_path: &std::path::Path,
1929 src_handle: &Handle,
1930 settings: &Settings,
1931 preserve: &preserve::Settings,
1932 is_fresh: bool,
1933) -> Result<Summary, Error> {
1934 if dst_parent.is_none()
1940 && !is_fresh
1941 && settings.ignore_existing
1942 && dry_run_dst_exists(dst_path).await?
1943 {
1944 if let Some(mode) = settings.dry_run {
1945 match mode {
1946 DryRunMode::Brief => {}
1947 DryRunMode::All => println!("skip symlink {:?}", dst_path),
1948 DryRunMode::Explain => {
1949 println!("skip symlink {:?} (destination exists)", dst_path)
1950 }
1951 }
1952 }
1953 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1954 prog_track.symlinks_unchanged.inc();
1955 return Ok(Summary {
1956 symlinks_unchanged: 1,
1957 ..Default::default()
1958 });
1959 }
1960 if settings.dry_run.is_some() {
1961 crate::dry_run::report_action("copy", src_path, Some(dst_path), "symlink");
1962 return Ok(Summary {
1963 symlinks_created: 1,
1964 ..Default::default()
1965 });
1966 }
1967 let dst_parent = dst_parent.expect("destination parent must be open for a real copy");
1968 let (target, src_meta) = src_handle
1972 .read_symlink(src_parent.side())
1973 .await
1974 .with_context(|| format!("failed reading symlink {:?}", src_path))
1975 .map_err(|err| Error::new(err, Default::default()))?;
1976 match dst_parent.symlink_at(dst_name, &target).await {
1978 Ok(link_handle) => {
1979 safedir::set_symlink_metadata_fd(
1980 preserve,
1981 &src_meta,
1982 &link_handle,
1983 congestion::Side::Destination,
1984 )
1985 .await
1986 .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
1987 .map_err(|err| Error::new(err, Default::default()))?;
1988 prog_track.symlinks_created.inc();
1989 Ok(Summary {
1990 symlinks_created: 1,
1991 ..Default::default()
1992 })
1993 }
1994 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1995 if settings.ignore_existing {
1996 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
1997 prog_track.symlinks_unchanged.inc();
1998 return Ok(Summary {
1999 symlinks_unchanged: 1,
2000 ..Default::default()
2001 });
2002 }
2003 if !settings.overwrite {
2004 return Err(Error::new(
2005 anyhow!("failed creating symlink {:?}", dst_path),
2006 Default::default(),
2007 ));
2008 }
2009 let dst_handle = dst_parent
2010 .child(dst_name)
2011 .await
2012 .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
2013 .map_err(|err| Error::new(err, Default::default()))?;
2014 if dst_handle.kind() == EntryKind::Symlink {
2015 let dst_link = dst_parent
2016 .read_link_at(dst_name) .await
2018 .with_context(|| format!("failed reading dst symlink: {:?}", dst_path))
2019 .map_err(|err| Error::new(err, Default::default()))?;
2020 if dst_link == target {
2021 tracing::debug!("'dst' is a symlink and points to the same location as 'src'");
2022 if preserve.symlink.any()
2023 && !filecmp::metadata_equal(
2024 &settings.overwrite_compare,
2025 &src_meta,
2026 dst_handle.meta(),
2027 )
2028 {
2029 tracing::debug!("'dst' metadata is different, updating");
2030 safedir::set_symlink_metadata_fd(
2031 preserve,
2032 &src_meta,
2033 &dst_handle,
2034 congestion::Side::Destination,
2035 )
2036 .await
2037 .with_context(|| {
2038 format!("failed setting symlink metadata on {:?}", dst_path)
2039 })
2040 .map_err(|err| Error::new(err, Default::default()))?;
2041 prog_track.symlinks_removed.inc();
2042 prog_track.symlinks_created.inc();
2043 return Ok(Summary {
2044 rm_summary: RmSummary {
2045 symlinks_removed: 1,
2046 ..Default::default()
2047 },
2048 symlinks_created: 1,
2049 ..Default::default()
2050 });
2051 }
2052 tracing::debug!("symlink already exists, skipping");
2053 prog_track.symlinks_unchanged.inc();
2054 return Ok(Summary {
2055 symlinks_unchanged: 1,
2056 ..Default::default()
2057 });
2058 }
2059 tracing::debug!("'dst' is a symlink but points to a different path, updating");
2060 } else {
2061 tracing::info!("'dst' is not a symlink, updating");
2062 }
2063 let rm_summary = remove_existing(
2065 prog_track,
2066 dst_parent,
2067 dst_name,
2068 dst_path,
2069 dst_handle.into_removal_snapshot(),
2070 settings,
2071 )
2072 .await?;
2073 let copy_summary = Summary {
2074 rm_summary,
2075 ..Default::default()
2076 };
2077 let link_handle = dst_parent
2078 .symlink_at(dst_name, &target)
2079 .await
2080 .with_context(|| format!("failed creating symlink {:?}", dst_path))
2081 .map_err(|err| Error::new(err, copy_summary))?;
2082 safedir::set_symlink_metadata_fd(
2083 preserve,
2084 &src_meta,
2085 &link_handle,
2086 congestion::Side::Destination,
2087 )
2088 .await
2089 .with_context(|| format!("failed setting symlink metadata on {:?}", dst_path))
2090 .map_err(|err| Error::new(err, copy_summary))?;
2091 prog_track.symlinks_created.inc();
2092 Ok(Summary {
2093 rm_summary,
2094 symlinks_created: 1,
2095 ..Default::default()
2096 })
2097 }
2098 Err(error) => Err(Error::new(
2099 anyhow::Error::new(error).context(format!("failed creating symlink {:?}", dst_path)),
2100 Default::default(),
2101 )),
2102 }
2103}
2104
2105struct CurrentRegular {
2107 file: std::fs::File,
2108 meta: FileMeta,
2109}
2110
2111impl CurrentRegular {
2112 async fn open(
2114 src_parent: &Dir,
2115 name: &OsStr,
2116 src_path: &std::path::Path,
2117 ) -> Result<Self, Error> {
2118 let (file, meta) = src_parent
2119 .open_file_read(name)
2120 .await
2121 .with_context(|| format!("failed opening src file {:?} for reading", src_path))
2122 .map_err(|err| Error::new(err, Default::default()))?;
2123 Ok(Self { file, meta })
2124 }
2125
2126 fn matches(&self, handle: &Handle) -> bool {
2127 self.meta.dev() == handle.dev() && self.meta.ino() == handle.ino()
2128 }
2129}
2130
2131#[derive(Debug)]
2133enum DestinationPreflight {
2134 Vacant,
2136 Skip(Summary),
2138 OverwriteCandidate(Handle),
2140}
2141
2142#[derive(Debug)]
2144enum FilePlan {
2145 Vacant,
2147 Replace(RemovalSnapshot),
2153}
2154
2155#[derive(Clone, Copy, Debug)]
2157enum OverwriteCandidateDecision {
2158 Skip(Summary),
2159 Replace,
2160}
2161
2162fn skip_summary(skip: Summary, copy_summary: &Summary) -> Summary {
2170 Summary {
2171 rm_summary: copy_summary.rm_summary + skip.rm_summary,
2172 ..skip
2173 }
2174}
2175
2176async fn execute_dst_plan(
2185 prog_track: &'static progress::Progress,
2186 dst_parent: &Arc<Dir>,
2187 dst_name: &OsStr,
2188 dst_path: &std::path::Path,
2189 plan: FilePlan,
2190 settings: &Settings,
2191 copy_summary: &mut Summary,
2192) -> Result<(), Error> {
2193 let dst_snapshot = match plan {
2194 FilePlan::Vacant => return Ok(()),
2195 FilePlan::Replace(dst_snapshot) => dst_snapshot,
2196 };
2197 match remove_existing(
2198 prog_track,
2199 dst_parent,
2200 dst_name,
2201 dst_path,
2202 dst_snapshot,
2203 settings,
2204 )
2205 .await
2206 {
2207 Ok(rm_summary) => {
2208 copy_summary.rm_summary = copy_summary.rm_summary + rm_summary;
2209 Ok(())
2210 }
2211 Err(err) => Err(Error::new(err.source, err.summary + *copy_summary)),
2213 }
2214}
2215
2216async fn preflight_dst_file(
2227 prog_track: &'static progress::Progress,
2228 dst_parent: &Arc<Dir>,
2229 dst_name: &OsStr,
2230 dst_path: &std::path::Path,
2231 settings: &Settings,
2232) -> Result<DestinationPreflight, Error> {
2233 let dst_handle = match dst_parent.child(dst_name).await {
2234 Ok(dst_handle) => dst_handle,
2235 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2243 return Ok(DestinationPreflight::Vacant);
2244 }
2245 Err(error) => {
2246 return Err(Error::new(
2247 anyhow::Error::new(error)
2248 .context(format!("failed looking up destination {:?}", dst_path)),
2249 Default::default(),
2250 ));
2251 }
2252 };
2253 if settings.ignore_existing {
2254 tracing::debug!("destination exists, skipping (--ignore-existing)");
2255 prog_track.files_unchanged.inc();
2256 return Ok(DestinationPreflight::Skip(Summary {
2257 files_unchanged: 1,
2258 ..Default::default()
2259 }));
2260 }
2261 if !settings.overwrite {
2262 return Err(Error::new(
2263 anyhow!(
2264 "destination {:?} already exists, did you intend to specify --overwrite?",
2265 dst_path
2266 ),
2267 Default::default(),
2268 ));
2269 }
2270 Ok(DestinationPreflight::OverwriteCandidate(dst_handle))
2271}
2272
2273fn plan_overwrite_candidate(
2276 prog_track: &'static progress::Progress,
2277 dst_handle: &Handle,
2278 src_meta: &FileMeta,
2279 settings: &Settings,
2280) -> OverwriteCandidateDecision {
2281 tracing::debug!("file exists, check if it's identical");
2282 if dst_handle.kind() == EntryKind::File {
2283 if filecmp::metadata_equal(&settings.overwrite_compare, src_meta, dst_handle.meta()) {
2284 tracing::debug!("file is identical, skipping");
2285 prog_track.files_unchanged.inc();
2286 return OverwriteCandidateDecision::Skip(Summary {
2287 files_unchanged: 1,
2288 ..Default::default()
2289 });
2290 }
2291 if let Some(OverwriteFilter::Newer) = settings.overwrite_filter
2292 && filecmp::dest_is_newer(src_meta, dst_handle.meta())
2293 {
2294 tracing::debug!("dest is newer than source, skipping");
2295 prog_track.files_unchanged.inc();
2296 return OverwriteCandidateDecision::Skip(Summary {
2297 files_unchanged: 1,
2298 ..Default::default()
2299 });
2300 }
2301 }
2302 tracing::info!("destination differs, will replace existing entry");
2303 OverwriteCandidateDecision::Replace
2304}
2305
2306pub(crate) struct DirSlot {
2308 pub(crate) dir: Arc<Dir>,
2309 pub(crate) summary: Summary,
2310 pub(crate) is_fresh: bool,
2311 pub(crate) we_created: bool,
2312 pub(crate) reused_lock: Option<safedir::ReusedDirLock>,
2318}
2319
2320pub(crate) enum DirResolution {
2322 Proceed(DirSlot),
2324 Skip(Summary),
2326}
2327
2328pub(crate) async fn resolve_dst_dir(
2336 prog_track: &'static progress::Progress,
2337 dst_parent: &Arc<Dir>,
2338 name: &OsStr,
2339 dst_path: &std::path::Path,
2340 settings: &Settings,
2341) -> Result<DirResolution, Error> {
2342 match dst_parent
2343 .make_dir(name, safedir::DST_DIR_CREATE_MODE)
2344 .await
2345 {
2346 Ok(dir) => {
2347 prog_track.directories_created.inc();
2349 Ok(DirResolution::Proceed(DirSlot {
2350 dir: Arc::new(dir),
2351 summary: Summary {
2352 directories_created: 1,
2353 ..Default::default()
2354 },
2355 is_fresh: true,
2356 we_created: true,
2357 reused_lock: None,
2360 }))
2361 }
2362 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2363 let dst_handle = dst_parent
2370 .child(name)
2371 .await
2372 .with_context(|| format!("failed reading metadata from dst: {:?}", dst_path))
2373 .map_err(|err| Error::new(err, Default::default()))?;
2374 if dst_handle.kind() == EntryKind::Dir {
2375 tracing::debug!("'dst' is a directory, reusing it");
2383 let dir = dst_parent
2384 .open_dir(name)
2385 .await
2386 .with_context(|| format!("cannot open existing directory {:?}", dst_path))
2387 .map_err(|err| Error::new(err, Default::default()))?;
2388 let reused_lock = safedir::lockdown_reused_dir(&dir, &dst_handle)
2393 .await
2394 .with_context(|| {
2395 format!("cannot secure reused destination directory {:?}", dst_path)
2396 })
2397 .map_err(|err| Error::new(err, Default::default()))?;
2398 prog_track.directories_unchanged.inc();
2399 Ok(DirResolution::Proceed(DirSlot {
2400 dir: Arc::new(dir),
2401 summary: Summary {
2402 directories_unchanged: 1,
2403 ..Default::default()
2404 },
2405 is_fresh: false,
2408 we_created: false,
2409 reused_lock,
2410 }))
2411 } else if settings.ignore_existing {
2412 tracing::debug!(
2414 "destination exists but is not a directory, skipping subtree (--ignore-existing)"
2415 );
2416 prog_track.directories_unchanged.inc();
2417 Ok(DirResolution::Skip(Summary {
2418 directories_unchanged: 1,
2419 ..Default::default()
2420 }))
2421 } else if settings.overwrite {
2422 tracing::info!("'dst' is not a directory, removing and creating a new one");
2424 let rm_summary = remove_existing(
2425 prog_track,
2426 dst_parent,
2427 name,
2428 dst_path,
2429 dst_handle.into_removal_snapshot(),
2430 settings,
2431 )
2432 .await?;
2433 let dir = dst_parent
2434 .make_dir(name, safedir::DST_DIR_CREATE_MODE)
2435 .await
2436 .with_context(|| format!("cannot create directory {:?}", dst_path))
2437 .map_err(|err| {
2438 Error::new(
2439 err,
2440 Summary {
2441 rm_summary,
2442 ..Default::default()
2443 },
2444 )
2445 })?;
2446 prog_track.directories_created.inc();
2447 Ok(DirResolution::Proceed(DirSlot {
2448 dir: Arc::new(dir),
2449 summary: Summary {
2450 rm_summary,
2451 directories_created: 1,
2452 ..Default::default()
2453 },
2454 is_fresh: true,
2455 we_created: true,
2456 reused_lock: None,
2459 }))
2460 } else {
2461 Err(Error::new(
2462 anyhow!(
2463 "destination {:?} already exists, did you intend to specify --overwrite?",
2464 dst_path
2465 ),
2466 Default::default(),
2467 ))
2468 }
2469 }
2470 Err(error) => {
2471 let error = anyhow::Error::new(error)
2472 .context(format!("cannot create directory {:?}", dst_path));
2473 tracing::error!("{:#}", &error);
2474 Err(Error::new(error, Default::default()))
2475 }
2476 }
2477}
2478
2479pub async fn remove_existing(
2500 prog_track: &'static progress::Progress,
2501 dst_parent: &Arc<Dir>,
2502 dst_name: &OsStr,
2503 dst_path: &std::path::Path,
2504 dst_snapshot: RemovalSnapshot,
2505 settings: &Settings,
2506) -> Result<RmSummary, Error> {
2507 match dst_snapshot.kind() {
2508 EntryKind::File | EntryKind::Symlink | EntryKind::Special => {
2509 let removed_size = dst_snapshot.size();
2510 dst_parent
2512 .unlink_at(dst_name)
2513 .await
2514 .with_context(|| format!("failed removing existing destination {:?}", dst_path))
2515 .map_err(|err| Error::new(err, Default::default()))?;
2516 let is_symlink = dst_snapshot.kind() == EntryKind::Symlink;
2517 if is_symlink {
2518 prog_track.symlinks_removed.inc();
2519 } else {
2520 prog_track.files_removed.inc();
2521 prog_track.bytes_removed.add(removed_size);
2522 }
2523 Ok(RmSummary {
2524 bytes_removed: if is_symlink { 0 } else { removed_size },
2528 files_removed: usize::from(!is_symlink),
2529 symlinks_removed: usize::from(is_symlink),
2530 ..Default::default()
2531 })
2532 }
2533 EntryKind::Dir => {
2534 match dst_parent.rmdir_at(dst_name).await {
2536 Ok(()) => {
2537 prog_track.directories_removed.inc();
2538 Ok(RmSummary {
2539 directories_removed: 1,
2540 ..Default::default()
2541 })
2542 }
2543 Err(error)
2545 if matches!(
2546 error.raw_os_error(),
2547 Some(libc::ENOTEMPTY) | Some(libc::EEXIST)
2548 ) =>
2549 {
2550 rm::rm_child(
2554 prog_track,
2555 dst_parent,
2556 dst_name,
2557 dst_path,
2558 &RmSettings {
2559 fail_early: settings.fail_early,
2560 filter: None,
2561 dry_run: None,
2562 time_filter: None,
2563 },
2564 )
2565 .await
2566 .map_err(|err| {
2567 Error::new(
2568 err.source,
2569 Summary {
2570 rm_summary: err.summary,
2571 ..Default::default()
2572 },
2573 )
2574 })
2575 }
2576 Err(error) => Err(Error::new(
2577 anyhow::Error::new(error)
2578 .context(format!("failed removing existing directory {:?}", dst_path)),
2579 Default::default(),
2580 )),
2581 }
2582 }
2583 }
2584}
2585
2586#[cfg(test)]
2587mod copy_tests {
2588 use crate::testutils;
2589 use anyhow::Context;
2590 use std::os::unix::fs::MetadataExt;
2591 use std::os::unix::fs::PermissionsExt;
2592 use tracing_test::traced_test;
2593
2594 use super::*;
2595
2596 static PROGRESS: std::sync::LazyLock<progress::Progress> =
2597 std::sync::LazyLock::new(progress::Progress::new);
2598 static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
2599 std::sync::LazyLock::new(preserve::preserve_none);
2600 static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
2601 std::sync::LazyLock::new(preserve::preserve_all);
2602
2603 fn settings_with_delete(delete: Option<DeleteSettings>) -> Settings {
2604 Settings {
2605 dereference: false,
2606 fail_early: false,
2607 overwrite: delete.is_some(), overwrite_compare: filecmp::MetadataCmpSettings {
2609 size: true,
2610 mtime: true,
2611 ..Default::default()
2612 },
2613 overwrite_filter: None,
2614 ignore_existing: false,
2615 chunk_size: 0,
2616 skip_specials: false,
2617 remote_copy_buffer_size: 0,
2618 filter: None,
2619 dry_run: None,
2620 delete,
2621 }
2622 }
2623
2624 fn delete_on() -> Option<DeleteSettings> {
2625 Some(DeleteSettings {
2626 delete_excluded: false,
2627 })
2628 }
2629
2630 async fn run_with_open_file_cleanup<F>(
2632 admission: &testutils::AdmissionLimit,
2633 operation: F,
2634 ) -> Result<F::Output, anyhow::Error>
2635 where
2636 F: std::future::Future,
2637 {
2638 let output = admission
2639 .run_with_timeout(std::time::Duration::from_secs(20), operation)
2640 .await
2641 .context("copy operation did not finish at an open-file limit of one")?;
2642 let returned = admission
2643 .run_with_timeout(
2644 std::time::Duration::from_secs(20),
2645 throttle::open_file_permit(),
2646 )
2647 .await
2648 .context("copy operation did not return open-file capacity")?;
2649 drop(returned);
2650 Ok(output)
2651 }
2652
2653 #[tokio::test]
2657 async fn copies_dot_dot_source_operand() -> Result<(), anyhow::Error> {
2658 let tmp = testutils::create_temp_dir().await?;
2659 let tree = tmp.join("tree");
2660 tokio::fs::create_dir(&tree).await?;
2661 tokio::fs::write(tree.join("a.txt"), "hello").await?;
2662 tokio::fs::create_dir(tree.join("sub")).await?;
2663 let src = tree.join("sub").join(".."); let dst = tmp.join("dst");
2665 let summary = copy(
2666 &PROGRESS,
2667 &src,
2668 &dst,
2669 &settings_with_delete(None),
2670 &NO_PRESERVE_SETTINGS,
2671 false,
2672 )
2673 .await?;
2674 assert_eq!(
2675 summary.files_copied, 1,
2676 "the dot-dot source's file must be copied"
2677 );
2678 assert_eq!(tokio::fs::read_to_string(dst.join("a.txt")).await?, "hello");
2679 assert!(
2680 dst.join("sub").is_dir(),
2681 "the dot-dot source's subdir must be copied"
2682 );
2683 Ok(())
2684 }
2685
2686 #[tokio::test]
2687 #[traced_test]
2688 async fn delete_protects_skipped_special_name() -> Result<(), anyhow::Error> {
2689 let tmp_dir = testutils::setup_test_dir().await?;
2690 let test_path = tmp_dir.as_path();
2691 let src = test_path.join("src_dir");
2692 let dst = test_path.join("dst_dir");
2693 tokio::fs::create_dir(&src).await?;
2694 tokio::fs::write(src.join("file.txt"), "hello").await?;
2695 nix::unistd::mkfifo(
2697 &src.join("pipe"),
2698 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
2699 )?;
2700 tokio::fs::create_dir(&dst).await?;
2702 tokio::fs::write(dst.join("pipe"), "old").await?;
2703 tokio::fs::write(dst.join("stale.txt"), "junk").await?;
2704
2705 let mut settings = settings_with_delete(delete_on());
2706 settings.skip_specials = true;
2707 let summary = copy(
2708 &PROGRESS,
2709 &src,
2710 &dst,
2711 &settings,
2712 &DO_PRESERVE_SETTINGS,
2713 false,
2714 )
2715 .await?;
2716
2717 assert_eq!(summary.specials_skipped, 1);
2718 assert!(dst.join("file.txt").exists());
2719 assert!(
2720 dst.join("pipe").exists(),
2721 "a destination entry matching a skipped special must not be pruned (it has a source counterpart)"
2722 );
2723 assert!(!dst.join("stale.txt").exists()); Ok(())
2725 }
2726
2727 #[tokio::test]
2732 #[traced_test]
2733 async fn copies_into_symlinked_destination_parent() -> Result<(), anyhow::Error> {
2734 let tmp_dir = testutils::setup_test_dir().await?;
2735 let test_path = tmp_dir.as_path();
2736 let src = test_path.join("src.txt");
2737 tokio::fs::write(&src, b"payload").await?;
2738 let real_dir = test_path.join("real_dst_dir");
2740 tokio::fs::create_dir(&real_dir).await?;
2741 let link_dir = test_path.join("link_dst_dir");
2742 tokio::fs::symlink(&real_dir, &link_dir).await?;
2743 let dst = link_dir.join("out.txt");
2745 let summary = copy(
2746 &PROGRESS,
2747 &src,
2748 &dst,
2749 &settings_with_delete(None),
2750 &DO_PRESERVE_SETTINGS,
2751 false,
2752 )
2753 .await?;
2754 assert_eq!(summary.files_copied, 1);
2755 let written = tokio::fs::read(real_dir.join("out.txt")).await?;
2757 assert_eq!(written, b"payload");
2758 Ok(())
2759 }
2760
2761 #[tokio::test]
2764 #[traced_test]
2765 async fn copies_from_symlinked_source_parent() -> Result<(), anyhow::Error> {
2766 let tmp_dir = testutils::setup_test_dir().await?;
2767 let test_path = tmp_dir.as_path();
2768 let real_src_dir = test_path.join("real_src_dir");
2770 tokio::fs::create_dir(&real_src_dir).await?;
2771 tokio::fs::write(real_src_dir.join("src.txt"), b"payload").await?;
2772 let link_src_dir = test_path.join("link_src_dir");
2773 tokio::fs::symlink(&real_src_dir, &link_src_dir).await?;
2774 let src = link_src_dir.join("src.txt");
2775 let dst = test_path.join("out.txt");
2776 let summary = copy(
2777 &PROGRESS,
2778 &src,
2779 &dst,
2780 &settings_with_delete(None),
2781 &DO_PRESERVE_SETTINGS,
2782 false,
2783 )
2784 .await?;
2785 assert_eq!(summary.files_copied, 1);
2786 let written = tokio::fs::read(&dst).await?;
2787 assert_eq!(written, b"payload");
2788 Ok(())
2789 }
2790
2791 #[tokio::test]
2792 #[traced_test]
2793 async fn delete_removes_extraneous_destination_entries() -> Result<(), anyhow::Error> {
2794 let tmp_dir = testutils::setup_test_dir().await?;
2795 let test_path = tmp_dir.as_path();
2796 let src = test_path.join("foo");
2797 let dst = test_path.join("bar");
2798 copy(
2800 &PROGRESS,
2801 &src,
2802 &dst,
2803 &settings_with_delete(None),
2804 &DO_PRESERVE_SETTINGS,
2805 false,
2806 )
2807 .await?;
2808 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2810 tokio::fs::create_dir(dst.join("extra_dir")).await?;
2811 tokio::fs::write(dst.join("extra_dir").join("nested.txt"), b"junk").await?;
2812 let summary = copy(
2814 &PROGRESS,
2815 &src,
2816 &dst,
2817 &settings_with_delete(delete_on()),
2818 &DO_PRESERVE_SETTINGS,
2819 false,
2820 )
2821 .await?;
2822 assert_eq!(summary.rm_summary.files_removed, 2); assert_eq!(summary.rm_summary.directories_removed, 1); assert!(!dst.join("extraneous.txt").exists());
2825 assert!(!dst.join("extra_dir").exists());
2826 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2827 Ok(())
2828 }
2829
2830 #[tokio::test]
2831 #[traced_test]
2832 async fn delete_prunes_extraneous_at_depth() -> Result<(), anyhow::Error> {
2833 let tmp_dir = testutils::setup_test_dir().await?;
2834 let test_path = tmp_dir.as_path();
2835 let src = test_path.join("foo");
2836 let dst = test_path.join("bar");
2837 copy(
2838 &PROGRESS,
2839 &src,
2840 &dst,
2841 &settings_with_delete(None),
2842 &DO_PRESERVE_SETTINGS,
2843 false,
2844 )
2845 .await?;
2846 let nested = dst.join("bar");
2848 assert!(
2849 nested.is_dir(),
2850 "expected common subdirectory bar/ to exist at destination"
2851 );
2852 tokio::fs::write(nested.join("stale_nested.txt"), b"junk").await?;
2853 let summary = copy(
2854 &PROGRESS,
2855 &src,
2856 &dst,
2857 &settings_with_delete(delete_on()),
2858 &DO_PRESERVE_SETTINGS,
2859 false,
2860 )
2861 .await?;
2862 assert!(
2863 !nested.join("stale_nested.txt").exists(),
2864 "stale entry inside a common subdirectory must be pruned"
2865 );
2866 assert!(summary.rm_summary.files_removed >= 1);
2867 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
2868 Ok(())
2869 }
2870
2871 #[tokio::test]
2872 #[traced_test]
2873 async fn delete_removes_extraneous_symlink() -> Result<(), anyhow::Error> {
2874 let tmp_dir = testutils::setup_test_dir().await?;
2875 let test_path = tmp_dir.as_path();
2876 let src = test_path.join("foo");
2877 let dst = test_path.join("bar");
2878 copy(
2879 &PROGRESS,
2880 &src,
2881 &dst,
2882 &settings_with_delete(None),
2883 &DO_PRESERVE_SETTINGS,
2884 false,
2885 )
2886 .await?;
2887 tokio::fs::symlink("/nonexistent/target", dst.join("stale_link")).await?;
2889 let summary = copy(
2890 &PROGRESS,
2891 &src,
2892 &dst,
2893 &settings_with_delete(delete_on()),
2894 &DO_PRESERVE_SETTINGS,
2895 false,
2896 )
2897 .await?;
2898 assert!(
2899 tokio::fs::symlink_metadata(dst.join("stale_link"))
2900 .await
2901 .is_err(),
2902 "extraneous symlink must be removed"
2903 );
2904 assert_eq!(summary.rm_summary.symlinks_removed, 1);
2905 Ok(())
2906 }
2907
2908 #[tokio::test]
2909 #[traced_test]
2910 async fn delete_skips_pruning_when_copy_has_errors() -> Result<(), anyhow::Error> {
2911 let tmp_dir = testutils::setup_test_dir().await?;
2912 let test_path = tmp_dir.as_path();
2913 let src = test_path.join("foo");
2914 let dst = test_path.join("bar");
2915 copy(
2917 &PROGRESS,
2918 &src,
2919 &dst,
2920 &settings_with_delete(None),
2921 &DO_PRESERVE_SETTINGS,
2922 false,
2923 )
2924 .await?;
2925 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2927 let unreadable = src.join("baz");
2931 let original = tokio::fs::metadata(&unreadable).await?.permissions();
2932 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2933
2934 let result = copy(
2935 &PROGRESS,
2936 &src,
2937 &dst,
2938 &settings_with_delete(delete_on()),
2939 &DO_PRESERVE_SETTINGS,
2940 false,
2941 )
2942 .await;
2943
2944 tokio::fs::set_permissions(&unreadable, original).await?;
2945
2946 assert!(
2947 result.is_err(),
2948 "copy of the unreadable directory should fail"
2949 );
2950 assert!(
2951 dst.join("extraneous.txt").exists(),
2952 "pruning must be skipped when the copy reported errors"
2953 );
2954 Ok(())
2955 }
2956
2957 #[tokio::test]
2958 #[traced_test]
2959 async fn check_basic_copy() -> Result<(), anyhow::Error> {
2960 let tmp_dir = testutils::setup_test_dir().await?;
2961 let test_path = tmp_dir.as_path();
2962 let summary = copy(
2963 &PROGRESS,
2964 &test_path.join("foo"),
2965 &test_path.join("bar"),
2966 &Settings {
2967 dereference: false,
2968 fail_early: false,
2969 overwrite: false,
2970 overwrite_compare: filecmp::MetadataCmpSettings {
2971 size: true,
2972 mtime: true,
2973 ..Default::default()
2974 },
2975 overwrite_filter: None,
2976 ignore_existing: false,
2977 chunk_size: 0,
2978 skip_specials: false,
2979 remote_copy_buffer_size: 0,
2980 filter: None,
2981 dry_run: None,
2982 delete: None,
2983 },
2984 &NO_PRESERVE_SETTINGS,
2985 false,
2986 )
2987 .await?;
2988 assert_eq!(summary.files_copied, 5);
2989 assert_eq!(summary.symlinks_created, 2);
2990 assert_eq!(summary.directories_created, 3);
2991 testutils::check_dirs_identical(
2992 &test_path.join("foo"),
2993 &test_path.join("bar"),
2994 testutils::FileEqualityCheck::Basic,
2995 )
2996 .await?;
2997 Ok(())
2998 }
2999
3000 #[tokio::test]
3001 #[traced_test]
3002 async fn no_read_permission() -> Result<(), anyhow::Error> {
3003 let tmp_dir = testutils::setup_test_dir().await?;
3004 let test_path = tmp_dir.as_path();
3005 let filepaths = vec![
3006 test_path.join("foo").join("0.txt"),
3007 test_path.join("foo").join("baz"),
3008 ];
3009 for fpath in &filepaths {
3010 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
3012 }
3013 match copy(
3014 &PROGRESS,
3015 &test_path.join("foo"),
3016 &test_path.join("bar"),
3017 &Settings {
3018 dereference: false,
3019 fail_early: false,
3020 overwrite: false,
3021 overwrite_compare: filecmp::MetadataCmpSettings {
3022 size: true,
3023 mtime: true,
3024 ..Default::default()
3025 },
3026 overwrite_filter: None,
3027 ignore_existing: false,
3028 chunk_size: 0,
3029 skip_specials: false,
3030 remote_copy_buffer_size: 0,
3031 filter: None,
3032 dry_run: None,
3033 delete: None,
3034 },
3035 &NO_PRESERVE_SETTINGS,
3036 false,
3037 )
3038 .await
3039 {
3040 Ok(_) => panic!("Expected the copy to error!"),
3041 Err(error) => {
3042 tracing::info!("{:#}", &error);
3043 assert_eq!(error.summary.files_copied, 3);
3054 assert_eq!(error.summary.symlinks_created, 0);
3055 assert_eq!(error.summary.directories_created, 2);
3056 }
3057 }
3058 for fpath in &filepaths {
3060 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
3061 if tokio::fs::symlink_metadata(fpath).await?.is_file() {
3062 tokio::fs::remove_file(fpath).await?;
3063 } else {
3064 tokio::fs::remove_dir_all(fpath).await?;
3065 }
3066 }
3067 testutils::check_dirs_identical(
3068 &test_path.join("foo"),
3069 &test_path.join("bar"),
3070 testutils::FileEqualityCheck::Basic,
3071 )
3072 .await?;
3073 Ok(())
3074 }
3075
3076 #[cfg_attr(
3077 rcp_nix_sandbox,
3078 ignore = "Nix sandbox cannot set special permission bits"
3079 )]
3080 #[tokio::test]
3081 #[traced_test]
3082 async fn check_default_mode() -> Result<(), anyhow::Error> {
3083 let tmp_dir = testutils::setup_test_dir().await?;
3084 tokio::fs::set_permissions(
3086 tmp_dir.join("foo").join("0.txt"),
3087 std::fs::Permissions::from_mode(0o700),
3088 )
3089 .await?;
3090 let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
3092 tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
3093 .await?;
3094 let test_path = tmp_dir.as_path();
3095 let summary = copy(
3096 &PROGRESS,
3097 &test_path.join("foo"),
3098 &test_path.join("bar"),
3099 &Settings {
3100 dereference: false,
3101 fail_early: false,
3102 overwrite: false,
3103 overwrite_compare: filecmp::MetadataCmpSettings {
3104 size: true,
3105 mtime: true,
3106 ..Default::default()
3107 },
3108 overwrite_filter: None,
3109 ignore_existing: false,
3110 chunk_size: 0,
3111 skip_specials: false,
3112 remote_copy_buffer_size: 0,
3113 filter: None,
3114 dry_run: None,
3115 delete: None,
3116 },
3117 &NO_PRESERVE_SETTINGS,
3118 false,
3119 )
3120 .await?;
3121 assert_eq!(summary.files_copied, 5);
3122 assert_eq!(summary.symlinks_created, 2);
3123 assert_eq!(summary.directories_created, 3);
3124 tokio::fs::set_permissions(
3126 &exec_sticky_file,
3127 std::fs::Permissions::from_mode(
3128 std::fs::symlink_metadata(&exec_sticky_file)?
3129 .permissions()
3130 .mode()
3131 & 0o0777,
3132 ),
3133 )
3134 .await?;
3135 testutils::check_dirs_identical(
3136 &test_path.join("foo"),
3137 &test_path.join("bar"),
3138 testutils::FileEqualityCheck::Basic,
3139 )
3140 .await?;
3141 Ok(())
3142 }
3143
3144 #[tokio::test]
3145 #[traced_test]
3146 async fn no_write_permission() -> Result<(), anyhow::Error> {
3147 let tmp_dir = testutils::setup_test_dir().await?;
3148 let test_path = tmp_dir.as_path();
3149 let non_exec_dir = test_path.join("foo").join("bogey");
3151 tokio::fs::create_dir(&non_exec_dir).await?;
3152 tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
3153 tokio::fs::set_permissions(
3155 &test_path.join("foo").join("baz"),
3156 std::fs::Permissions::from_mode(0o500),
3157 )
3158 .await?;
3159 tokio::fs::set_permissions(
3161 &test_path.join("foo").join("baz").join("4.txt"),
3162 std::fs::Permissions::from_mode(0o440),
3163 )
3164 .await?;
3165 let summary = copy(
3166 &PROGRESS,
3167 &test_path.join("foo"),
3168 &test_path.join("bar"),
3169 &Settings {
3170 dereference: false,
3171 fail_early: false,
3172 overwrite: false,
3173 overwrite_compare: filecmp::MetadataCmpSettings {
3174 size: true,
3175 mtime: true,
3176 ..Default::default()
3177 },
3178 overwrite_filter: None,
3179 ignore_existing: false,
3180 chunk_size: 0,
3181 skip_specials: false,
3182 remote_copy_buffer_size: 0,
3183 filter: None,
3184 dry_run: None,
3185 delete: None,
3186 },
3187 &NO_PRESERVE_SETTINGS,
3188 false,
3189 )
3190 .await?;
3191 assert_eq!(summary.files_copied, 5);
3192 assert_eq!(summary.symlinks_created, 2);
3193 assert_eq!(summary.directories_created, 4);
3194 testutils::check_dirs_identical(
3195 &test_path.join("foo"),
3196 &test_path.join("bar"),
3197 testutils::FileEqualityCheck::Basic,
3198 )
3199 .await?;
3200 Ok(())
3201 }
3202
3203 #[tokio::test]
3204 #[traced_test]
3205 async fn dereference() -> Result<(), anyhow::Error> {
3206 let tmp_dir = testutils::setup_test_dir().await?;
3207 let test_path = tmp_dir.as_path();
3208 let src1 = &test_path.join("foo").join("bar").join("2.txt");
3210 let src2 = &test_path.join("foo").join("bar").join("3.txt");
3211 let test_mode = 0o440;
3212 for f in [src1, src2] {
3213 tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
3214 }
3215 let summary = copy(
3216 &PROGRESS,
3217 &test_path.join("foo"),
3218 &test_path.join("bar"),
3219 &Settings {
3220 dereference: true, fail_early: false,
3222 overwrite: false,
3223 overwrite_compare: filecmp::MetadataCmpSettings {
3224 size: true,
3225 mtime: true,
3226 ..Default::default()
3227 },
3228 overwrite_filter: None,
3229 ignore_existing: false,
3230 chunk_size: 0,
3231 skip_specials: false,
3232 remote_copy_buffer_size: 0,
3233 filter: None,
3234 dry_run: None,
3235 delete: None,
3236 },
3237 &NO_PRESERVE_SETTINGS,
3238 false,
3239 )
3240 .await?;
3241 assert_eq!(summary.files_copied, 7);
3242 assert_eq!(summary.symlinks_created, 0);
3243 assert_eq!(summary.directories_created, 3);
3244 let dst1 = &test_path.join("bar").join("baz").join("5.txt");
3250 let dst2 = &test_path.join("bar").join("baz").join("6.txt");
3251 for f in [dst1, dst2] {
3252 let metadata = tokio::fs::symlink_metadata(f)
3253 .await
3254 .with_context(|| format!("failed reading metadata from {:?}", &f))?;
3255 assert!(metadata.is_file());
3256 assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
3258 }
3259 Ok(())
3260 }
3261
3262 async fn cp_compare(
3263 cp_args: &[&str],
3264 rcp_settings: &Settings,
3265 preserve: bool,
3266 ) -> Result<(), anyhow::Error> {
3267 let tmp_dir = testutils::setup_test_dir().await?;
3268 let test_path = tmp_dir.as_path();
3269 let cp_output = tokio::process::Command::new("cp")
3271 .args(cp_args)
3272 .arg(test_path.join("foo"))
3273 .arg(test_path.join("bar"))
3274 .output()
3275 .await?;
3276 assert!(cp_output.status.success());
3277 let summary = copy(
3279 &PROGRESS,
3280 &test_path.join("foo"),
3281 &test_path.join("baz"),
3282 rcp_settings,
3283 if preserve {
3284 &DO_PRESERVE_SETTINGS
3285 } else {
3286 &NO_PRESERVE_SETTINGS
3287 },
3288 false,
3289 )
3290 .await?;
3291 if rcp_settings.dereference {
3292 assert_eq!(summary.files_copied, 7);
3293 assert_eq!(summary.symlinks_created, 0);
3294 } else {
3295 assert_eq!(summary.files_copied, 5);
3296 assert_eq!(summary.symlinks_created, 2);
3297 }
3298 assert_eq!(summary.directories_created, 3);
3299 testutils::check_dirs_identical(
3300 &test_path.join("bar"),
3301 &test_path.join("baz"),
3302 if preserve {
3303 testutils::FileEqualityCheck::Timestamp
3304 } else {
3305 testutils::FileEqualityCheck::Basic
3306 },
3307 )
3308 .await?;
3309 Ok(())
3310 }
3311
3312 #[tokio::test]
3313 #[traced_test]
3314 async fn test_cp_compat() -> Result<(), anyhow::Error> {
3315 cp_compare(
3316 &["-r"],
3317 &Settings {
3318 dereference: false,
3319 fail_early: false,
3320 overwrite: false,
3321 overwrite_compare: filecmp::MetadataCmpSettings {
3322 size: true,
3323 mtime: true,
3324 ..Default::default()
3325 },
3326 overwrite_filter: None,
3327 ignore_existing: false,
3328 chunk_size: 0,
3329 skip_specials: false,
3330 remote_copy_buffer_size: 0,
3331 filter: None,
3332 dry_run: None,
3333 delete: None,
3334 },
3335 false,
3336 )
3337 .await?;
3338 Ok(())
3339 }
3340
3341 #[tokio::test]
3342 #[traced_test]
3343 async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
3344 cp_compare(
3345 &["-r", "-p"],
3346 &Settings {
3347 dereference: false,
3348 fail_early: false,
3349 overwrite: false,
3350 overwrite_compare: filecmp::MetadataCmpSettings {
3351 size: true,
3352 mtime: true,
3353 ..Default::default()
3354 },
3355 overwrite_filter: None,
3356 ignore_existing: false,
3357 chunk_size: 0,
3358 skip_specials: false,
3359 remote_copy_buffer_size: 0,
3360 filter: None,
3361 dry_run: None,
3362 delete: None,
3363 },
3364 true,
3365 )
3366 .await?;
3367 Ok(())
3368 }
3369
3370 #[tokio::test]
3371 #[traced_test]
3372 async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
3373 cp_compare(
3374 &["-r", "-L"],
3375 &Settings {
3376 dereference: true,
3377 fail_early: false,
3378 overwrite: false,
3379 overwrite_compare: filecmp::MetadataCmpSettings {
3380 size: true,
3381 mtime: true,
3382 ..Default::default()
3383 },
3384 overwrite_filter: None,
3385 ignore_existing: false,
3386 chunk_size: 0,
3387 skip_specials: false,
3388 remote_copy_buffer_size: 0,
3389 filter: None,
3390 dry_run: None,
3391 delete: None,
3392 },
3393 false,
3394 )
3395 .await?;
3396 Ok(())
3397 }
3398
3399 #[tokio::test]
3400 #[traced_test]
3401 async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
3402 cp_compare(
3403 &["-r", "-p", "-L"],
3404 &Settings {
3405 dereference: true,
3406 fail_early: false,
3407 overwrite: false,
3408 overwrite_compare: filecmp::MetadataCmpSettings {
3409 size: true,
3410 mtime: true,
3411 ..Default::default()
3412 },
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 true,
3423 )
3424 .await?;
3425 Ok(())
3426 }
3427
3428 async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
3429 let tmp_dir = testutils::setup_test_dir().await?;
3430 let test_path = tmp_dir.as_path();
3431 let summary = copy(
3432 &PROGRESS,
3433 &test_path.join("foo"),
3434 &test_path.join("bar"),
3435 &Settings {
3436 dereference: false,
3437 fail_early: false,
3438 overwrite: false,
3439 overwrite_compare: filecmp::MetadataCmpSettings {
3440 size: true,
3441 mtime: true,
3442 ..Default::default()
3443 },
3444 overwrite_filter: None,
3445 ignore_existing: false,
3446 chunk_size: 0,
3447 skip_specials: false,
3448 remote_copy_buffer_size: 0,
3449 filter: None,
3450 dry_run: None,
3451 delete: None,
3452 },
3453 &DO_PRESERVE_SETTINGS,
3454 false,
3455 )
3456 .await?;
3457 assert_eq!(summary.files_copied, 5);
3458 assert_eq!(summary.symlinks_created, 2);
3459 assert_eq!(summary.directories_created, 3);
3460 Ok(tmp_dir)
3461 }
3462
3463 #[tokio::test]
3464 #[traced_test]
3465 async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
3466 let tmp_dir = setup_test_dir_and_copy().await?;
3467 let output_path = &tmp_dir.join("bar");
3468 {
3469 let summary = rm::rm(
3480 &PROGRESS,
3481 &output_path.join("bar"),
3482 &RmSettings {
3483 fail_early: false,
3484 filter: None,
3485 dry_run: None,
3486 time_filter: None,
3487 },
3488 )
3489 .await?
3490 + rm::rm(
3491 &PROGRESS,
3492 &output_path.join("baz").join("5.txt"),
3493 &RmSettings {
3494 fail_early: false,
3495 filter: None,
3496 dry_run: None,
3497 time_filter: None,
3498 },
3499 )
3500 .await?;
3501 assert_eq!(summary.files_removed, 3);
3502 assert_eq!(summary.symlinks_removed, 1);
3503 assert_eq!(summary.directories_removed, 1);
3504 }
3505 let summary = copy(
3506 &PROGRESS,
3507 &tmp_dir.join("foo"),
3508 output_path,
3509 &Settings {
3510 dereference: false,
3511 fail_early: false,
3512 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3514 size: true,
3515 mtime: true,
3516 ..Default::default()
3517 },
3518 overwrite_filter: None,
3519 ignore_existing: false,
3520 chunk_size: 0,
3521 skip_specials: false,
3522 remote_copy_buffer_size: 0,
3523 filter: None,
3524 dry_run: None,
3525 delete: None,
3526 },
3527 &DO_PRESERVE_SETTINGS,
3528 false,
3529 )
3530 .await?;
3531 assert_eq!(summary.files_copied, 3);
3532 assert_eq!(summary.symlinks_created, 1);
3533 assert_eq!(summary.directories_created, 1);
3534 testutils::check_dirs_identical(
3535 &tmp_dir.join("foo"),
3536 output_path,
3537 testutils::FileEqualityCheck::Timestamp,
3538 )
3539 .await?;
3540 Ok(())
3541 }
3542
3543 #[tokio::test]
3544 #[traced_test]
3545 async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
3546 let tmp_dir = setup_test_dir_and_copy().await?;
3547 let output_path = &tmp_dir.join("bar");
3548 {
3549 let summary = rm::rm(
3560 &PROGRESS,
3561 &output_path.join("bar").join("1.txt"),
3562 &RmSettings {
3563 fail_early: false,
3564 filter: None,
3565 dry_run: None,
3566 time_filter: None,
3567 },
3568 )
3569 .await?
3570 + rm::rm(
3571 &PROGRESS,
3572 &output_path.join("baz"),
3573 &RmSettings {
3574 fail_early: false,
3575 filter: None,
3576 dry_run: None,
3577 time_filter: None,
3578 },
3579 )
3580 .await?;
3581 assert_eq!(summary.files_removed, 2);
3582 assert_eq!(summary.symlinks_removed, 2);
3583 assert_eq!(summary.directories_removed, 1);
3584 }
3585 {
3586 tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
3588 tokio::fs::write(&output_path.join("baz"), "baz").await?;
3590 }
3591 let summary = copy(
3592 &PROGRESS,
3593 &tmp_dir.join("foo"),
3594 output_path,
3595 &Settings {
3596 dereference: false,
3597 fail_early: false,
3598 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3600 size: true,
3601 mtime: true,
3602 ..Default::default()
3603 },
3604 overwrite_filter: None,
3605 ignore_existing: false,
3606 chunk_size: 0,
3607 skip_specials: false,
3608 remote_copy_buffer_size: 0,
3609 filter: None,
3610 dry_run: None,
3611 delete: None,
3612 },
3613 &DO_PRESERVE_SETTINGS,
3614 false,
3615 )
3616 .await?;
3617 assert_eq!(summary.rm_summary.files_removed, 1);
3618 assert_eq!(summary.rm_summary.symlinks_removed, 0);
3619 assert_eq!(summary.rm_summary.directories_removed, 1);
3620 assert_eq!(summary.files_copied, 2);
3621 assert_eq!(summary.symlinks_created, 2);
3622 assert_eq!(summary.directories_created, 1);
3623 testutils::check_dirs_identical(
3624 &tmp_dir.join("foo"),
3625 output_path,
3626 testutils::FileEqualityCheck::Timestamp,
3627 )
3628 .await?;
3629 Ok(())
3630 }
3631
3632 #[tokio::test]
3633 #[traced_test]
3634 async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
3635 let tmp_dir = setup_test_dir_and_copy().await?;
3636 let output_path = &tmp_dir.join("bar");
3637 {
3638 let summary = rm::rm(
3645 &PROGRESS,
3646 &output_path.join("baz").join("4.txt"),
3647 &RmSettings {
3648 fail_early: false,
3649 filter: None,
3650 dry_run: None,
3651 time_filter: None,
3652 },
3653 )
3654 .await?
3655 + rm::rm(
3656 &PROGRESS,
3657 &output_path.join("baz").join("5.txt"),
3658 &RmSettings {
3659 fail_early: false,
3660 filter: None,
3661 dry_run: None,
3662 time_filter: None,
3663 },
3664 )
3665 .await?;
3666 assert_eq!(summary.files_removed, 1);
3667 assert_eq!(summary.symlinks_removed, 1);
3668 assert_eq!(summary.directories_removed, 0);
3669 }
3670 {
3671 tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
3673 tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
3675 }
3676 let summary = copy(
3677 &PROGRESS,
3678 &tmp_dir.join("foo"),
3679 output_path,
3680 &Settings {
3681 dereference: false,
3682 fail_early: false,
3683 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3685 size: true,
3686 mtime: true,
3687 ..Default::default()
3688 },
3689 overwrite_filter: None,
3690 ignore_existing: false,
3691 chunk_size: 0,
3692 skip_specials: false,
3693 remote_copy_buffer_size: 0,
3694 filter: None,
3695 dry_run: None,
3696 delete: None,
3697 },
3698 &DO_PRESERVE_SETTINGS,
3699 false,
3700 )
3701 .await?;
3702 assert_eq!(summary.rm_summary.files_removed, 1);
3703 assert_eq!(summary.rm_summary.symlinks_removed, 1);
3704 assert_eq!(summary.rm_summary.directories_removed, 0);
3705 assert_eq!(summary.files_copied, 1);
3706 assert_eq!(summary.symlinks_created, 1);
3707 assert_eq!(summary.directories_created, 0);
3708 testutils::check_dirs_identical(
3709 &tmp_dir.join("foo"),
3710 output_path,
3711 testutils::FileEqualityCheck::Timestamp,
3712 )
3713 .await?;
3714 Ok(())
3715 }
3716
3717 #[tokio::test]
3718 #[traced_test]
3719 async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
3720 let tmp_dir = setup_test_dir_and_copy().await?;
3721 let output_path = &tmp_dir.join("bar");
3722 {
3723 let summary = rm::rm(
3733 &PROGRESS,
3734 &output_path.join("bar"),
3735 &RmSettings {
3736 fail_early: false,
3737 filter: None,
3738 dry_run: None,
3739 time_filter: None,
3740 },
3741 )
3742 .await?
3743 + rm::rm(
3744 &PROGRESS,
3745 &output_path.join("baz").join("5.txt"),
3746 &RmSettings {
3747 fail_early: false,
3748 filter: None,
3749 dry_run: None,
3750 time_filter: None,
3751 },
3752 )
3753 .await?;
3754 assert_eq!(summary.files_removed, 3);
3755 assert_eq!(summary.symlinks_removed, 1);
3756 assert_eq!(summary.directories_removed, 1);
3757 }
3758 {
3759 tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
3761 tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
3763 }
3764 let summary = copy(
3765 &PROGRESS,
3766 &tmp_dir.join("foo"),
3767 output_path,
3768 &Settings {
3769 dereference: false,
3770 fail_early: false,
3771 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3773 size: true,
3774 mtime: true,
3775 ..Default::default()
3776 },
3777 overwrite_filter: None,
3778 ignore_existing: false,
3779 chunk_size: 0,
3780 skip_specials: false,
3781 remote_copy_buffer_size: 0,
3782 filter: None,
3783 dry_run: None,
3784 delete: None,
3785 },
3786 &DO_PRESERVE_SETTINGS,
3787 false,
3788 )
3789 .await?;
3790 assert_eq!(summary.rm_summary.files_removed, 0);
3791 assert_eq!(summary.rm_summary.symlinks_removed, 1);
3792 assert_eq!(summary.rm_summary.directories_removed, 1);
3793 assert_eq!(summary.files_copied, 3);
3794 assert_eq!(summary.symlinks_created, 1);
3795 assert_eq!(summary.directories_created, 1);
3796 assert_eq!(summary.files_unchanged, 2);
3797 assert_eq!(summary.symlinks_unchanged, 1);
3798 assert_eq!(summary.directories_unchanged, 2);
3799 testutils::check_dirs_identical(
3800 &tmp_dir.join("foo"),
3801 output_path,
3802 testutils::FileEqualityCheck::Timestamp,
3803 )
3804 .await?;
3805 Ok(())
3806 }
3807
3808 #[tokio::test]
3809 #[traced_test]
3810 async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
3811 let tmp_dir = testutils::setup_test_dir().await?;
3812 let test_path = tmp_dir.as_path();
3813 let summary = copy(
3814 &PROGRESS,
3815 &test_path.join("foo"),
3816 &test_path.join("bar"),
3817 &Settings {
3818 dereference: false,
3819 fail_early: false,
3820 overwrite: false,
3821 overwrite_compare: filecmp::MetadataCmpSettings {
3822 size: true,
3823 mtime: true,
3824 ..Default::default()
3825 },
3826 overwrite_filter: None,
3827 ignore_existing: false,
3828 chunk_size: 0,
3829 skip_specials: false,
3830 remote_copy_buffer_size: 0,
3831 filter: None,
3832 dry_run: None,
3833 delete: None,
3834 },
3835 &NO_PRESERVE_SETTINGS, false,
3837 )
3838 .await?;
3839 assert_eq!(summary.files_copied, 5);
3840 assert_eq!(summary.symlinks_created, 2);
3841 assert_eq!(summary.directories_created, 3);
3842 let source_path = &test_path.join("foo");
3843 let output_path = &tmp_dir.join("bar");
3844 tokio::fs::set_permissions(
3846 &source_path.join("bar"),
3847 std::fs::Permissions::from_mode(0o000),
3848 )
3849 .await?;
3850 tokio::fs::set_permissions(
3851 &source_path.join("baz").join("4.txt"),
3852 std::fs::Permissions::from_mode(0o000),
3853 )
3854 .await?;
3855 match copy(
3863 &PROGRESS,
3864 &tmp_dir.join("foo"),
3865 output_path,
3866 &Settings {
3867 dereference: false,
3868 fail_early: false,
3869 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
3871 size: true,
3872 mtime: true,
3873 ..Default::default()
3874 },
3875 overwrite_filter: None,
3876 ignore_existing: false,
3877 chunk_size: 0,
3878 skip_specials: false,
3879 remote_copy_buffer_size: 0,
3880 filter: None,
3881 dry_run: None,
3882 delete: None,
3883 },
3884 &DO_PRESERVE_SETTINGS,
3885 false,
3886 )
3887 .await
3888 {
3889 Ok(_) => panic!("Expected the copy to error!"),
3890 Err(error) => {
3891 tracing::info!("{:#}", &error);
3892 assert_eq!(error.summary.files_copied, 1);
3893 assert_eq!(error.summary.symlinks_created, 2);
3894 assert_eq!(error.summary.directories_created, 0);
3895 assert_eq!(error.summary.rm_summary.files_removed, 1);
3900 assert!(
3901 output_path.join("baz").join("4.txt").exists(),
3902 "the destination of an unreadable source must not be removed"
3903 );
3904 assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
3905 assert_eq!(error.summary.rm_summary.directories_removed, 0);
3906 }
3907 }
3908 Ok(())
3909 }
3910
3911 #[tokio::test]
3912 #[traced_test]
3913 async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
3914 let tmp_dir = testutils::create_temp_dir().await?;
3916 let test_path = tmp_dir.as_path();
3917 let baz_file = test_path.join("baz_file.txt");
3919 tokio::fs::write(&baz_file, "final content").await?;
3920 let bar_link = test_path.join("bar_link");
3921 let foo_link = test_path.join("foo_link");
3922 tokio::fs::symlink(&baz_file, &bar_link).await?;
3924 tokio::fs::symlink(&bar_link, &foo_link).await?;
3925 let src_dir = test_path.join("src_chain");
3927 tokio::fs::create_dir(&src_dir).await?;
3928 tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
3930 tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
3931 tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
3932 let summary = copy(
3934 &PROGRESS,
3935 &src_dir,
3936 &test_path.join("dst_with_deref"),
3937 &Settings {
3938 dereference: true, fail_early: false,
3940 overwrite: false,
3941 overwrite_compare: filecmp::MetadataCmpSettings {
3942 size: true,
3943 mtime: true,
3944 ..Default::default()
3945 },
3946 overwrite_filter: None,
3947 ignore_existing: false,
3948 chunk_size: 0,
3949 skip_specials: false,
3950 remote_copy_buffer_size: 0,
3951 filter: None,
3952 dry_run: None,
3953 delete: None,
3954 },
3955 &NO_PRESERVE_SETTINGS,
3956 false,
3957 )
3958 .await?;
3959 assert_eq!(summary.files_copied, 3); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1);
3962 let dst_dir = test_path.join("dst_with_deref");
3963 let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
3965 let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
3966 let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
3967 assert_eq!(foo_content, "final content");
3968 assert_eq!(bar_content, "final content");
3969 assert_eq!(baz_content, "final content");
3970 assert!(dst_dir.join("foo").is_file());
3972 assert!(dst_dir.join("bar").is_file());
3973 assert!(dst_dir.join("baz").is_file());
3974 assert!(!dst_dir.join("foo").is_symlink());
3975 assert!(!dst_dir.join("bar").is_symlink());
3976 assert!(!dst_dir.join("baz").is_symlink());
3977 Ok(())
3978 }
3979
3980 #[tokio::test]
3981 #[traced_test]
3982 async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
3983 let tmp_dir = testutils::create_temp_dir().await?;
3984 let test_path = tmp_dir.as_path();
3985 let target_dir = test_path.join("target_dir");
3987 tokio::fs::create_dir(&target_dir).await?;
3988 tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
3989 tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
3991 tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
3992 tokio::fs::set_permissions(
3993 &target_dir.join("file1.txt"),
3994 std::fs::Permissions::from_mode(0o644),
3995 )
3996 .await?;
3997 tokio::fs::set_permissions(
3998 &target_dir.join("file2.txt"),
3999 std::fs::Permissions::from_mode(0o600),
4000 )
4001 .await?;
4002 let dir_symlink = test_path.join("dir_symlink");
4004 tokio::fs::symlink(&target_dir, &dir_symlink).await?;
4005 let summary = copy(
4007 &PROGRESS,
4008 &dir_symlink,
4009 &test_path.join("copied_dir"),
4010 &Settings {
4011 dereference: true, fail_early: false,
4013 overwrite: false,
4014 overwrite_compare: filecmp::MetadataCmpSettings {
4015 size: true,
4016 mtime: true,
4017 ..Default::default()
4018 },
4019 overwrite_filter: None,
4020 ignore_existing: false,
4021 chunk_size: 0,
4022 skip_specials: false,
4023 remote_copy_buffer_size: 0,
4024 filter: None,
4025 dry_run: None,
4026 delete: None,
4027 },
4028 &DO_PRESERVE_SETTINGS,
4029 false,
4030 )
4031 .await?;
4032 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");
4036 assert!(copied_dir.is_dir());
4038 assert!(!copied_dir.is_symlink()); let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
4041 let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
4042 assert_eq!(file1_content, "content1");
4043 assert_eq!(file2_content, "content2");
4044 let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
4046 let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
4047 let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
4048 assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
4049 assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
4050 assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
4051 Ok(())
4052 }
4053
4054 #[tokio::test]
4055 #[traced_test]
4056 async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
4057 let tmp_dir = testutils::create_temp_dir().await?;
4058 let test_path = tmp_dir.as_path();
4059 let file1 = test_path.join("file1.txt");
4061 let file2 = test_path.join("file2.txt");
4062 tokio::fs::write(&file1, "content1").await?;
4063 tokio::fs::write(&file2, "content2").await?;
4064 tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
4065 tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
4066 let symlink1 = test_path.join("symlink1");
4068 let symlink2 = test_path.join("symlink2");
4069 tokio::fs::symlink(&file1, &symlink1).await?;
4070 tokio::fs::symlink(&file2, &symlink2).await?;
4071 let summary1 = copy(
4073 &PROGRESS,
4074 &symlink1,
4075 &test_path.join("copied_file1.txt"),
4076 &Settings {
4077 dereference: true, fail_early: false,
4079 overwrite: false,
4080 overwrite_compare: filecmp::MetadataCmpSettings::default(),
4081 overwrite_filter: None,
4082 ignore_existing: false,
4083 chunk_size: 0,
4084 skip_specials: false,
4085 remote_copy_buffer_size: 0,
4086 filter: None,
4087 dry_run: None,
4088 delete: None,
4089 },
4090 &DO_PRESERVE_SETTINGS, false,
4092 )
4093 .await?;
4094 let summary2 = copy(
4095 &PROGRESS,
4096 &symlink2,
4097 &test_path.join("copied_file2.txt"),
4098 &Settings {
4099 dereference: true,
4100 fail_early: false,
4101 overwrite: false,
4102 overwrite_compare: filecmp::MetadataCmpSettings::default(),
4103 overwrite_filter: None,
4104 ignore_existing: false,
4105 chunk_size: 0,
4106 skip_specials: false,
4107 remote_copy_buffer_size: 0,
4108 filter: None,
4109 dry_run: None,
4110 delete: None,
4111 },
4112 &DO_PRESERVE_SETTINGS,
4113 false,
4114 )
4115 .await?;
4116 assert_eq!(summary1.files_copied, 1);
4117 assert_eq!(summary1.symlinks_created, 0);
4118 assert_eq!(summary2.files_copied, 1);
4119 assert_eq!(summary2.symlinks_created, 0);
4120 let copied1 = test_path.join("copied_file1.txt");
4121 let copied2 = test_path.join("copied_file2.txt");
4122 assert!(copied1.is_file());
4124 assert!(!copied1.is_symlink());
4125 assert!(copied2.is_file());
4126 assert!(!copied2.is_symlink());
4127 let content1 = tokio::fs::read_to_string(&copied1).await?;
4129 let content2 = tokio::fs::read_to_string(&copied2).await?;
4130 assert_eq!(content1, "content1");
4131 assert_eq!(content2, "content2");
4132 let copied1_metadata = tokio::fs::metadata(&copied1).await?;
4134 let copied2_metadata = tokio::fs::metadata(&copied2).await?;
4135 assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
4136 assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
4137 Ok(())
4138 }
4139
4140 #[tokio::test]
4141 #[traced_test]
4142 async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
4143 let tmp_dir = testutils::setup_test_dir().await?;
4144 tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
4146 tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
4148 let summary = copy(
4149 &PROGRESS,
4150 &tmp_dir.join("foo"),
4151 &tmp_dir.join("bar"),
4152 &Settings {
4153 dereference: true, fail_early: false,
4155 overwrite: false,
4156 overwrite_compare: filecmp::MetadataCmpSettings {
4157 size: true,
4158 mtime: true,
4159 ..Default::default()
4160 },
4161 overwrite_filter: None,
4162 ignore_existing: false,
4163 chunk_size: 0,
4164 skip_specials: false,
4165 remote_copy_buffer_size: 0,
4166 filter: None,
4167 dry_run: None,
4168 delete: None,
4169 },
4170 &DO_PRESERVE_SETTINGS,
4171 false,
4172 )
4173 .await?;
4174 assert_eq!(summary.files_copied, 13); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 5);
4177 tokio::process::Command::new("cp")
4179 .args(["-r", "-L"])
4180 .arg(tmp_dir.join("foo"))
4181 .arg(tmp_dir.join("bar-cp"))
4182 .output()
4183 .await?;
4184 testutils::check_dirs_identical(
4185 &tmp_dir.join("bar"),
4186 &tmp_dir.join("bar-cp"),
4187 testutils::FileEqualityCheck::Basic,
4188 )
4189 .await?;
4190 Ok(())
4191 }
4192
4193 mod error_message_tests {
4195 use super::*;
4196
4197 fn get_full_error_message(error: &Error) -> String {
4199 format!("{:#}", error.source)
4200 }
4201
4202 #[tokio::test]
4203 #[traced_test]
4204 async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
4205 let tmp_dir = testutils::create_temp_dir().await?;
4206
4207 let result = copy(
4208 &PROGRESS,
4209 &tmp_dir.join("does_not_exist.txt"),
4210 &tmp_dir.join("dest.txt"),
4211 &Settings {
4212 dereference: false,
4213 fail_early: false,
4214 overwrite: false,
4215 overwrite_compare: Default::default(),
4216 overwrite_filter: None,
4217 ignore_existing: false,
4218 chunk_size: 0,
4219 skip_specials: false,
4220 remote_copy_buffer_size: 0,
4221 filter: None,
4222 dry_run: None,
4223 delete: None,
4224 },
4225 &NO_PRESERVE_SETTINGS,
4226 false,
4227 )
4228 .await;
4229
4230 assert!(result.is_err());
4231 let err_msg = get_full_error_message(&result.unwrap_err());
4232
4233 assert!(
4234 err_msg.to_lowercase().contains("no such file")
4235 || err_msg.to_lowercase().contains("not found")
4236 || err_msg.contains("ENOENT"),
4237 "Error message must include file not found text. Got: {}",
4238 err_msg
4239 );
4240 Ok(())
4241 }
4242
4243 #[tokio::test]
4244 #[traced_test]
4245 async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
4246 let tmp_dir = testutils::create_temp_dir().await?;
4247 let unreadable_dir = tmp_dir.join("unreadable_dir");
4248 tokio::fs::create_dir(&unreadable_dir).await?;
4249 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
4250 .await?;
4251
4252 let result = copy(
4253 &PROGRESS,
4254 &unreadable_dir,
4255 &tmp_dir.join("dest"),
4256 &Settings {
4257 dereference: false,
4258 fail_early: true,
4259 overwrite: false,
4260 overwrite_compare: Default::default(),
4261 overwrite_filter: None,
4262 ignore_existing: false,
4263 chunk_size: 0,
4264 skip_specials: false,
4265 remote_copy_buffer_size: 0,
4266 filter: None,
4267 dry_run: None,
4268 delete: None,
4269 },
4270 &NO_PRESERVE_SETTINGS,
4271 false,
4272 )
4273 .await;
4274
4275 assert!(result.is_err());
4276 let err_msg = get_full_error_message(&result.unwrap_err());
4277
4278 assert!(
4279 err_msg.to_lowercase().contains("permission")
4280 || err_msg.contains("EACCES")
4281 || err_msg.contains("denied"),
4282 "Error message must include permission-related text. Got: {}",
4283 err_msg
4284 );
4285
4286 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
4288 .await?;
4289 Ok(())
4290 }
4291
4292 #[tokio::test]
4293 #[traced_test]
4294 async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
4295 {
4296 let tmp_dir = testutils::setup_test_dir().await?;
4297 let test_path = tmp_dir.as_path();
4298 let readonly_parent = test_path.join("readonly_dest");
4299 tokio::fs::create_dir(&readonly_parent).await?;
4300 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
4301 .await?;
4302
4303 let result = copy(
4304 &PROGRESS,
4305 &test_path.join("foo"),
4306 &readonly_parent.join("copy"),
4307 &Settings {
4308 dereference: false,
4309 fail_early: true,
4310 overwrite: false,
4311 overwrite_compare: Default::default(),
4312 overwrite_filter: None,
4313 ignore_existing: false,
4314 chunk_size: 0,
4315 skip_specials: false,
4316 remote_copy_buffer_size: 0,
4317 filter: None,
4318 dry_run: None,
4319 delete: None,
4320 },
4321 &NO_PRESERVE_SETTINGS,
4322 false,
4323 )
4324 .await;
4325
4326 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
4328 .await?;
4329
4330 assert!(result.is_err(), "copy into read-only parent should fail");
4331 let err_msg = get_full_error_message(&result.unwrap_err());
4332
4333 assert!(
4334 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
4335 "Error message must include permission denied text. Got: {}",
4336 err_msg
4337 );
4338 Ok(())
4339 }
4340
4341 #[tokio::test]
4347 #[traced_test]
4348 async fn default_reused_readonly_dir_still_fails_eacces() -> Result<(), anyhow::Error> {
4349 let tmp_dir = testutils::setup_test_dir().await?;
4350 let test_path = tmp_dir.as_path();
4351 let dst = test_path.join("dst");
4353 tokio::fs::create_dir(&dst).await?;
4354 tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o500)).await?;
4355 let result = copy(
4356 &PROGRESS,
4357 &test_path.join("foo"),
4358 &dst,
4359 &Settings {
4360 dereference: false,
4361 fail_early: true,
4362 overwrite: true,
4363 overwrite_compare: Default::default(),
4364 overwrite_filter: None,
4365 ignore_existing: false,
4366 chunk_size: 0,
4367 skip_specials: false,
4368 remote_copy_buffer_size: 0,
4369 filter: None,
4370 dry_run: None,
4371 delete: None,
4372 },
4373 &NO_PRESERVE_SETTINGS,
4374 false,
4375 )
4376 .await;
4377 tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o700)).await?;
4379 assert!(
4380 result.is_err(),
4381 "copy into a read-only reused directory must fail without strict mode"
4382 );
4383 let err_msg = get_full_error_message(&result.unwrap_err());
4384 assert!(
4385 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
4386 "error must be a permission error. Got: {}",
4387 err_msg
4388 );
4389 Ok(())
4390 }
4391 }
4392
4393 mod empty_dir_cleanup_tests {
4394 use super::*;
4395 use crate::filter::FilterSettings;
4396 use std::path::Path;
4397 #[test]
4398 fn test_check_empty_dir_cleanup_no_filter() {
4399 assert_eq!(
4401 check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
4402 EmptyDirAction::Keep
4403 );
4404 }
4405 #[test]
4406 fn test_check_empty_dir_cleanup_something_copied() {
4407 let mut filter = crate::filter::FilterSettings::new();
4409 filter.add_include("*.txt").unwrap();
4410 assert_eq!(
4411 check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
4412 EmptyDirAction::Keep
4413 );
4414 }
4415 #[test]
4416 fn test_check_empty_dir_cleanup_not_created() {
4417 let mut filter = FilterSettings::new();
4419 filter.add_include("*.txt").unwrap();
4420 assert_eq!(
4421 check_empty_dir_cleanup(
4422 Some(&filter),
4423 false,
4424 false,
4425 Path::new("any"),
4426 false,
4427 false
4428 ),
4429 EmptyDirAction::Keep
4430 );
4431 }
4432 #[test]
4433 fn test_check_empty_dir_cleanup_directly_matched() {
4434 let mut filter = FilterSettings::new();
4436 filter.add_include("target/").unwrap();
4437 assert_eq!(
4438 check_empty_dir_cleanup(
4439 Some(&filter),
4440 true,
4441 false,
4442 Path::new("target"),
4443 false,
4444 false
4445 ),
4446 EmptyDirAction::Keep
4447 );
4448 }
4449 #[test]
4450 fn test_check_empty_dir_cleanup_traversed_only() {
4451 let mut filter = FilterSettings::new();
4453 filter.add_include("*.txt").unwrap();
4454 assert_eq!(
4455 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
4456 EmptyDirAction::Remove
4457 );
4458 }
4459 #[test]
4460 fn test_check_empty_dir_cleanup_dry_run() {
4461 let mut filter = FilterSettings::new();
4463 filter.add_include("*.txt").unwrap();
4464 assert_eq!(
4465 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
4466 EmptyDirAction::DryRunSkip
4467 );
4468 }
4469 #[test]
4470 fn test_check_empty_dir_cleanup_root_always_kept() {
4471 let mut filter = FilterSettings::new();
4473 filter.add_include("*.txt").unwrap();
4474 assert_eq!(
4475 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
4476 EmptyDirAction::Keep
4477 );
4478 }
4479 #[test]
4480 fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
4481 let mut filter = FilterSettings::new();
4483 filter.add_include("*.txt").unwrap();
4484 assert_eq!(
4485 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
4486 EmptyDirAction::Keep
4487 );
4488 }
4489 }
4490
4491 #[tokio::test]
4495 #[traced_test]
4496 async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
4497 let tmp_dir = testutils::create_temp_dir().await?;
4498 let test_path = tmp_dir.as_path();
4499 let src_dir = test_path.join("src");
4501 tokio::fs::create_dir(&src_dir).await?;
4502 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
4503 let readable_file = src_dir.join("readable.txt");
4505 tokio::fs::write(&readable_file, "content").await?;
4506 let unreadable_file = src_dir.join("unreadable.txt");
4507 tokio::fs::write(&unreadable_file, "secret").await?;
4508 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
4509 .await?;
4510 let dst_dir = test_path.join("dst");
4511 let result = copy(
4513 &PROGRESS,
4514 &src_dir,
4515 &dst_dir,
4516 &Settings {
4517 dereference: false,
4518 fail_early: false,
4519 overwrite: false,
4520 overwrite_compare: Default::default(),
4521 overwrite_filter: None,
4522 ignore_existing: false,
4523 chunk_size: 0,
4524 skip_specials: false,
4525 remote_copy_buffer_size: 0,
4526 filter: None,
4527 dry_run: None,
4528 delete: None,
4529 },
4530 &DO_PRESERVE_SETTINGS,
4531 false,
4532 )
4533 .await;
4534 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
4536 .await?;
4537 assert!(result.is_err(), "copy should fail due to unreadable file");
4539 let error = result.unwrap_err();
4540 assert_eq!(error.summary.files_copied, 1);
4542 assert_eq!(error.summary.directories_created, 1);
4543 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
4545 assert!(dst_metadata.is_dir());
4546 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
4547 assert_eq!(
4548 actual_mode, 0o750,
4549 "directory should have preserved source permissions (0o750), got {:o}",
4550 actual_mode
4551 );
4552 Ok(())
4553 }
4554
4555 #[tokio::test]
4559 #[traced_test]
4560 async fn pruned_empty_traversal_dir_still_surfaces_child_error() -> Result<(), anyhow::Error> {
4561 let tmp_dir = testutils::create_temp_dir().await?;
4562 let test_path = tmp_dir.as_path();
4563 let src_dir = test_path.join("src");
4564 let sub = src_dir.join("sub");
4565 tokio::fs::create_dir_all(&sub).await?;
4566 let unreadable = sub.join("file.txt");
4569 tokio::fs::write(&unreadable, "secret").await?;
4570 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
4571 let dst_dir = test_path.join("dst");
4572 let mut filter = crate::filter::FilterSettings::new();
4573 filter.add_include("*.txt").unwrap();
4574 let result = copy(
4575 &PROGRESS,
4576 &src_dir,
4577 &dst_dir,
4578 &Settings {
4579 dereference: false,
4580 fail_early: false,
4581 overwrite: false,
4582 overwrite_compare: Default::default(),
4583 overwrite_filter: None,
4584 ignore_existing: false,
4585 chunk_size: 0,
4586 skip_specials: false,
4587 remote_copy_buffer_size: 0,
4588 filter: Some(filter),
4589 dry_run: None,
4590 delete: None,
4591 },
4592 &DO_PRESERVE_SETTINGS,
4593 false,
4594 )
4595 .await;
4596 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).await?;
4598 assert!(
4599 result.is_err(),
4600 "the failed child copy must surface even though the empty traversal dir was pruned"
4601 );
4602 assert!(
4604 !dst_dir.join("sub").exists(),
4605 "empty traversal directory should have been removed"
4606 );
4607 Ok(())
4608 }
4609
4610 #[tokio::test]
4612 #[traced_test]
4613 async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error()
4614 -> Result<(), anyhow::Error> {
4615 let tmp_dir = testutils::create_temp_dir().await?;
4616 let test_path = tmp_dir.as_path();
4617 let src_dir = test_path.join("src");
4618 tokio::fs::create_dir(&src_dir).await?;
4619 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
4620 let unreadable_file = src_dir.join("unreadable.txt");
4621 tokio::fs::write(&unreadable_file, "secret").await?;
4622 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
4623 .await?;
4624 let fixed_secs = 946684800;
4625 let fixed_nsec = 123_456_789;
4626 let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
4627 nix::sys::stat::utimensat(
4628 nix::fcntl::AT_FDCWD,
4629 &src_dir,
4630 &fixed_time,
4631 &fixed_time,
4632 nix::sys::stat::UtimensatFlags::NoFollowSymlink,
4633 )?;
4634 let src_metadata = tokio::fs::metadata(&src_dir).await?;
4635 let dst_dir = test_path.join("dst");
4636 let result = copy(
4637 &PROGRESS,
4638 &src_dir,
4639 &dst_dir,
4640 &Settings {
4641 dereference: false,
4642 fail_early: true,
4643 overwrite: false,
4644 overwrite_compare: Default::default(),
4645 overwrite_filter: None,
4646 ignore_existing: false,
4647 chunk_size: 0,
4648 skip_specials: false,
4649 remote_copy_buffer_size: 0,
4650 filter: None,
4651 dry_run: None,
4652 delete: None,
4653 },
4654 &DO_PRESERVE_SETTINGS,
4655 false,
4656 )
4657 .await;
4658 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
4659 .await?;
4660 assert!(result.is_err(), "copy should fail due to unreadable file");
4661 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
4662 assert!(dst_metadata.is_dir());
4663 assert_ne!(
4664 (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
4665 (src_metadata.mtime(), src_metadata.mtime_nsec()),
4666 "fail-early should return before applying preserved directory timestamps"
4667 );
4668 Ok(())
4669 }
4670 mod filter_tests {
4671 use super::*;
4672 use crate::filter::FilterSettings;
4673 #[tokio::test]
4677 #[traced_test]
4678 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
4679 let tmp_dir = testutils::setup_test_dir().await?;
4680 let test_path = tmp_dir.as_path();
4681 let mut filter = FilterSettings::new();
4693 filter.add_include("bar/*.txt").unwrap();
4694 let summary = copy(
4695 &PROGRESS,
4696 &test_path.join("foo"),
4697 &test_path.join("dst"),
4698 &Settings {
4699 dereference: false,
4700 fail_early: false,
4701 overwrite: false,
4702 overwrite_compare: Default::default(),
4703 overwrite_filter: None,
4704 ignore_existing: false,
4705 chunk_size: 0,
4706 skip_specials: false,
4707 remote_copy_buffer_size: 0,
4708 filter: Some(filter),
4709 dry_run: None,
4710 delete: None,
4711 },
4712 &NO_PRESERVE_SETTINGS,
4713 false,
4714 )
4715 .await?;
4716 assert_eq!(
4719 summary.files_copied, 3,
4720 "should copy 3 files matching bar/*.txt"
4721 );
4722 assert!(
4724 test_path.join("dst/bar/1.txt").exists(),
4725 "bar/1.txt should be copied"
4726 );
4727 assert!(
4728 test_path.join("dst/bar/2.txt").exists(),
4729 "bar/2.txt should be copied"
4730 );
4731 assert!(
4732 test_path.join("dst/bar/3.txt").exists(),
4733 "bar/3.txt should be copied"
4734 );
4735 assert!(
4737 !test_path.join("dst/0.txt").exists(),
4738 "0.txt should not be copied"
4739 );
4740 Ok(())
4741 }
4742 #[tokio::test]
4744 #[traced_test]
4745 async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
4746 let tmp_dir = testutils::setup_test_dir().await?;
4747 let test_path = tmp_dir.as_path();
4748 let mut filter = FilterSettings::new();
4750 filter.add_include("/bar/**").unwrap();
4751 let summary = copy(
4752 &PROGRESS,
4753 &test_path.join("foo"),
4754 &test_path.join("dst"),
4755 &Settings {
4756 dereference: false,
4757 fail_early: false,
4758 overwrite: false,
4759 overwrite_compare: Default::default(),
4760 overwrite_filter: None,
4761 ignore_existing: false,
4762 chunk_size: 0,
4763 skip_specials: false,
4764 remote_copy_buffer_size: 0,
4765 filter: Some(filter),
4766 dry_run: None,
4767 delete: None,
4768 },
4769 &NO_PRESERVE_SETTINGS,
4770 false,
4771 )
4772 .await?;
4773 assert!(
4775 test_path.join("dst/bar").exists(),
4776 "bar directory should be copied"
4777 );
4778 assert!(
4779 !test_path.join("dst/baz").exists(),
4780 "baz directory should not be copied"
4781 );
4782 assert!(
4783 !test_path.join("dst/0.txt").exists(),
4784 "0.txt should not be copied"
4785 );
4786 assert_eq!(
4788 summary.files_copied, 3,
4789 "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
4790 );
4791 assert_eq!(
4792 summary.directories_created, 2,
4793 "should create 2 directories (root dst + bar)"
4794 );
4795 assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
4797 assert_eq!(
4798 summary.directories_skipped, 1,
4799 "should skip 1 directory (baz)"
4800 );
4801 Ok(())
4802 }
4803 #[tokio::test]
4805 #[traced_test]
4806 async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
4807 let tmp_dir = testutils::setup_test_dir().await?;
4808 let test_path = tmp_dir.as_path();
4809 let mut filter = FilterSettings::new();
4811 filter.add_include("**/*.txt").unwrap();
4812 let summary = copy(
4813 &PROGRESS,
4814 &test_path.join("foo"),
4815 &test_path.join("dst"),
4816 &Settings {
4817 dereference: false,
4818 fail_early: false,
4819 overwrite: false,
4820 overwrite_compare: Default::default(),
4821 overwrite_filter: None,
4822 ignore_existing: false,
4823 chunk_size: 0,
4824 skip_specials: false,
4825 remote_copy_buffer_size: 0,
4826 filter: Some(filter),
4827 dry_run: None,
4828 delete: None,
4829 },
4830 &NO_PRESERVE_SETTINGS,
4831 false,
4832 )
4833 .await?;
4834 assert_eq!(
4836 summary.files_copied, 5,
4837 "should copy all 5 .txt files with **/*.txt pattern"
4838 );
4839 Ok(())
4840 }
4841 #[tokio::test]
4843 #[traced_test]
4844 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
4845 let tmp_dir = testutils::setup_test_dir().await?;
4846 let test_path = tmp_dir.as_path();
4847 let mut filter = FilterSettings::new();
4849 filter.add_exclude("*.txt").unwrap();
4850 let result = copy(
4851 &PROGRESS,
4852 &test_path.join("foo/0.txt"), &test_path.join("dst.txt"),
4854 &Settings {
4855 dereference: false,
4856 fail_early: false,
4857 overwrite: false,
4858 overwrite_compare: Default::default(),
4859 overwrite_filter: None,
4860 ignore_existing: false,
4861 chunk_size: 0,
4862 skip_specials: false,
4863 remote_copy_buffer_size: 0,
4864 filter: Some(filter),
4865 dry_run: None,
4866 delete: None,
4867 },
4868 &NO_PRESERVE_SETTINGS,
4869 false,
4870 )
4871 .await?;
4872 assert_eq!(
4874 result.files_copied, 0,
4875 "file matching exclude pattern should not be copied"
4876 );
4877 assert!(
4878 !test_path.join("dst.txt").exists(),
4879 "excluded file should not exist at destination"
4880 );
4881 Ok(())
4882 }
4883 #[tokio::test]
4885 #[traced_test]
4886 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
4887 let test_path = testutils::create_temp_dir().await?;
4888 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
4890 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
4891 let mut filter = FilterSettings::new();
4893 filter.add_exclude("*_dir/").unwrap();
4894 let result = copy(
4895 &PROGRESS,
4896 &test_path.join("excluded_dir"),
4897 &test_path.join("dst"),
4898 &Settings {
4899 dereference: false,
4900 fail_early: false,
4901 overwrite: false,
4902 overwrite_compare: Default::default(),
4903 overwrite_filter: None,
4904 ignore_existing: false,
4905 chunk_size: 0,
4906 skip_specials: false,
4907 remote_copy_buffer_size: 0,
4908 filter: Some(filter),
4909 dry_run: None,
4910 delete: None,
4911 },
4912 &NO_PRESERVE_SETTINGS,
4913 false,
4914 )
4915 .await?;
4916 assert_eq!(
4918 result.directories_created, 0,
4919 "root directory matching exclude should not be created"
4920 );
4921 assert!(
4922 !test_path.join("dst").exists(),
4923 "excluded root directory should not exist at destination"
4924 );
4925 Ok(())
4926 }
4927
4928 #[tokio::test]
4931 #[traced_test]
4932 async fn root_file_swap_to_excluded_directory_is_rechecked_authoritatively()
4933 -> Result<(), anyhow::Error> {
4934 let root = testutils::create_temp_dir().await?;
4935 let src = root.join("source");
4936 let dst = root.join("destination");
4937 tokio::fs::write(&src, "INITIAL-FILE").await?;
4938
4939 let mut filter = FilterSettings::new();
4940 filter.add_exclude("source/")?;
4941 assert!(matches!(
4942 filter.should_include_root_item(std::path::Path::new("source"), false),
4943 crate::filter::FilterResult::Included
4944 ));
4945 assert!(!matches!(
4946 filter.should_include_root_item(std::path::Path::new("source"), true),
4947 crate::filter::FilterResult::Included
4948 ));
4949
4950 let admission = testutils::AdmissionLimit::new().await;
4951 admission.set_files_in_flight(1);
4952 let src_parent = Arc::new(
4953 Dir::open_parent_dir(&root, congestion::Side::Source)
4954 .await?
4955 .into_tree(),
4956 );
4957 let dst_parent = Arc::new(
4958 Dir::open_parent_dir(&root, congestion::Side::Destination)
4959 .await?
4960 .into_tree(),
4961 );
4962
4963 tokio::fs::remove_file(&src).await?;
4965 tokio::fs::create_dir(&src).await?;
4966 tokio::fs::write(src.join("replacement.txt"), "EXCLUDED").await?;
4967 let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
4968 let entry = crate::walk::classify_admitted_entry(
4969 &src_parent,
4970 std::ffi::OsStr::new("source"),
4971 permit,
4972 )
4973 .await?;
4974 assert_eq!(entry.kind(), EntryKind::Dir);
4975
4976 let mut settings = settings_with_delete(None);
4977 settings.filter = Some(filter);
4978 let summary = run_with_open_file_cleanup(
4979 &admission,
4980 run_copy_root(
4981 &PROGRESS,
4982 &src_parent,
4983 Some(dst_parent),
4984 std::ffi::OsStr::new("source"),
4985 &src,
4986 &dst,
4987 std::path::Path::new(""),
4988 &settings,
4989 &NO_PRESERVE_SETTINGS,
4990 false,
4991 DeleteScanAnchor::new(&dst, std::path::Path::new("")),
4992 CopyEntryAdmission::Admitted(entry),
4993 ),
4994 )
4995 .await??;
4996
4997 assert_eq!(summary.directories_skipped, 1);
4998 assert_eq!(summary.directories_created, 0);
4999 assert!(
5000 !dst.exists(),
5001 "an excluded replacement directory must not be materialized"
5002 );
5003 Ok(())
5004 }
5005
5006 #[tokio::test(flavor = "current_thread")]
5009 #[traced_test]
5010 async fn nested_file_swap_to_excluded_directory_is_rechecked_authoritatively()
5011 -> Result<(), anyhow::Error> {
5012 let root = testutils::create_temp_dir().await?;
5013 let src = root.join("source");
5014 let dst = root.join("destination");
5015 let node = src.join("node");
5016 tokio::fs::create_dir(&src).await?;
5017 tokio::fs::write(&node, "INITIAL-FILE").await?;
5018
5019 let mut filter = FilterSettings::new();
5020 filter.add_exclude("node/")?;
5021 assert!(matches!(
5022 filter.should_include(std::path::Path::new("node"), false),
5023 crate::filter::FilterResult::Included
5024 ));
5025 assert!(!matches!(
5026 filter.should_include(std::path::Path::new("node"), true),
5027 crate::filter::FilterResult::Included
5028 ));
5029 let mut settings = settings_with_delete(None);
5030 settings.filter = Some(filter);
5031
5032 tokio::fs::remove_file(&node).await?;
5035 tokio::fs::create_dir(&node).await?;
5036 tokio::fs::write(node.join("replacement.txt"), "EXCLUDED").await?;
5037 tokio::fs::create_dir(&dst).await?;
5038 let admission = testutils::AdmissionLimit::new().await;
5039 admission.set_files_in_flight(1);
5040 let src_dir =
5041 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
5042 let dst_dir =
5043 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
5044 let visitor = Arc::new(CopyVisitor {
5045 prog_track: &PROGRESS,
5046 dst_root: dst.clone(),
5047 filter_base: PathBuf::new(),
5048 delete_scan_anchor: DeleteScanAnchor::new(&dst, std::path::Path::new("")),
5049 settings,
5050 preserve: *NO_PRESERVE_SETTINGS,
5051 dst_parent: None,
5052 root_is_fresh: false,
5053 });
5054 let root_cx = EntryCx {
5055 parent: Arc::clone(&src_dir),
5056 name: std::ffi::OsString::from("source"),
5057 rel_path: PathBuf::new(),
5058 filter_path: PathBuf::new(),
5059 real_path: src.clone(),
5060 dry_run: false,
5061 prog_track: &PROGRESS,
5062 };
5063 let child_ctx = CopyDirContext {
5064 dst_dir: Some(dst_dir),
5065 is_fresh: true,
5066 };
5067 let (summary, processed) = run_with_open_file_cleanup(
5068 &admission,
5069 crate::walk_driver::scope_tasks(crate::walk_driver::walk_dir_entries(
5070 Arc::clone(&visitor),
5071 src_dir,
5072 &root_cx,
5073 &child_ctx,
5074 vec![(std::ffi::OsString::from("node"), Some(EntryKind::File))],
5075 )),
5076 )
5077 .await?
5078 .map_err(|error| error.source)?;
5079
5080 assert_eq!(summary.directories_skipped, 1);
5081 assert!(processed.names().is_empty());
5082 assert!(
5083 !dst.join("node").exists(),
5084 "an excluded nested replacement directory must not be materialized"
5085 );
5086 Ok(())
5087 }
5088
5089 #[tokio::test(flavor = "current_thread")]
5091 #[traced_test]
5092 async fn delete_rechecks_a_directory_hint_before_pruning_an_included_file()
5093 -> Result<(), anyhow::Error> {
5094 let root = testutils::create_temp_dir().await?;
5095 let src = root.join("source");
5096 let dst = root.join("destination");
5097 tokio::fs::create_dir(&src).await?;
5098 tokio::fs::create_dir(&dst).await?;
5099 tokio::fs::write(src.join("node"), "CURRENT-SOURCE-CONTENT").await?;
5100 tokio::fs::write(dst.join("node"), "OLD").await?;
5101
5102 let mut filter = FilterSettings::new();
5103 filter.add_exclude("node/")?;
5104 assert!(!matches!(
5105 filter.should_include(std::path::Path::new("node"), true),
5106 crate::filter::FilterResult::Included
5107 ));
5108 assert!(matches!(
5109 filter.should_include(std::path::Path::new("node"), false),
5110 crate::filter::FilterResult::Included
5111 ));
5112 let mut settings = settings_with_delete(delete_on());
5113 settings.filter = Some(filter);
5114
5115 let admission = testutils::AdmissionLimit::new().await;
5116 admission.set_files_in_flight(1);
5117 let src_dir =
5118 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
5119 let dst_dir =
5120 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
5121 let visitor = Arc::new(CopyVisitor {
5122 prog_track: &PROGRESS,
5123 dst_root: dst.clone(),
5124 filter_base: PathBuf::new(),
5125 delete_scan_anchor: DeleteScanAnchor::new(&dst, std::path::Path::new("")),
5126 settings,
5127 preserve: *NO_PRESERVE_SETTINGS,
5128 dst_parent: None,
5129 root_is_fresh: false,
5130 });
5131 let root_cx = EntryCx {
5132 parent: Arc::clone(&src_dir),
5133 name: std::ffi::OsString::from("source"),
5134 rel_path: PathBuf::new(),
5135 filter_path: PathBuf::new(),
5136 real_path: src.clone(),
5137 dry_run: false,
5138 prog_track: &PROGRESS,
5139 };
5140 let child_ctx = CopyDirContext {
5141 dst_dir: Some(Arc::clone(&dst_dir)),
5142 is_fresh: false,
5143 };
5144
5145 let (summary, processed) = run_with_open_file_cleanup(&admission, async {
5148 let (mut summary, processed) =
5149 crate::walk_driver::scope_tasks(crate::walk_driver::walk_dir_entries(
5150 Arc::clone(&visitor),
5151 src_dir,
5152 &root_cx,
5153 &child_ctx,
5154 vec![(std::ffi::OsString::from("node"), Some(EntryKind::Dir))],
5155 ))
5156 .await
5157 .map_err(|error| error.source)?;
5158 let mut child_error = None;
5159 visitor
5160 .prune_finished_dir(
5161 &mut summary,
5162 &mut child_error,
5163 &processed,
5164 &Some(dst_dir),
5165 &dst,
5166 std::path::Path::new(""),
5167 )
5168 .await
5169 .map_err(|error| error.source)?;
5170 assert!(child_error.is_none());
5171 Ok::<_, anyhow::Error>((summary, processed))
5172 })
5173 .await??;
5174
5175 assert_eq!(processed.names(), &[std::ffi::OsString::from("node")]);
5176 assert_eq!(summary.files_copied, 1);
5177 assert_eq!(summary.rm_summary.files_removed, 1);
5178 assert_eq!(
5179 tokio::fs::read_to_string(dst.join("node")).await?,
5180 "CURRENT-SOURCE-CONTENT"
5181 );
5182 Ok(())
5183 }
5184
5185 #[tokio::test(flavor = "current_thread")]
5188 #[traced_test]
5189 async fn dry_run_rechecks_a_directory_hint_before_previewing_an_included_file()
5190 -> Result<(), anyhow::Error> {
5191 let root = testutils::create_temp_dir().await?;
5192 let src = root.join("source");
5193 let dst = root.join("destination");
5194 tokio::fs::create_dir(&src).await?;
5195 tokio::fs::write(src.join("node"), "CURRENT-SOURCE-CONTENT").await?;
5196 let mut filter = FilterSettings::new();
5197 filter.add_exclude("node/")?;
5198 assert!(!matches!(
5199 filter.should_include(std::path::Path::new("node"), true),
5200 crate::filter::FilterResult::Included
5201 ));
5202 assert!(matches!(
5203 filter.should_include(std::path::Path::new("node"), false),
5204 crate::filter::FilterResult::Included
5205 ));
5206 let mut settings = settings_with_delete(None);
5207 settings.filter = Some(filter);
5208 settings.dry_run = Some(crate::config::DryRunMode::Brief);
5209 let admission = testutils::AdmissionLimit::new().await;
5210 admission.set_files_in_flight(1);
5211 let src_dir =
5212 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
5213 let visitor = Arc::new(CopyVisitor {
5214 prog_track: &PROGRESS,
5215 dst_root: dst.clone(),
5216 filter_base: PathBuf::new(),
5217 delete_scan_anchor: DeleteScanAnchor::new(&dst, std::path::Path::new("")),
5218 settings,
5219 preserve: *NO_PRESERVE_SETTINGS,
5220 dst_parent: None,
5221 root_is_fresh: false,
5222 });
5223 let root_cx = EntryCx {
5224 parent: Arc::clone(&src_dir),
5225 name: std::ffi::OsString::from("source"),
5226 rel_path: PathBuf::new(),
5227 filter_path: PathBuf::new(),
5228 real_path: src.clone(),
5229 dry_run: true,
5230 prog_track: &PROGRESS,
5231 };
5232 let (summary, processed) = run_with_open_file_cleanup(
5233 &admission,
5234 crate::walk_driver::scope_tasks(crate::walk_driver::walk_dir_entries(
5235 visitor,
5236 src_dir,
5237 &root_cx,
5238 &CopyDirContext {
5239 dst_dir: None,
5240 is_fresh: false,
5241 },
5242 vec![(std::ffi::OsString::from("node"), Some(EntryKind::Dir))],
5243 )),
5244 )
5245 .await??;
5246 assert_eq!(processed.names(), &[std::ffi::OsString::from("node")]);
5247 assert_eq!(summary.files_copied, 1);
5248 assert_eq!(summary.directories_skipped, 0);
5249 Ok(())
5250 }
5251
5252 #[tokio::test]
5254 #[traced_test]
5255 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
5256 let test_path = testutils::create_temp_dir().await?;
5257 tokio::fs::write(test_path.join("target.txt"), "content").await?;
5259 tokio::fs::symlink(
5260 test_path.join("target.txt"),
5261 test_path.join("excluded_link"),
5262 )
5263 .await?;
5264 let mut filter = FilterSettings::new();
5266 filter.add_exclude("*_link").unwrap();
5267 let result = copy(
5268 &PROGRESS,
5269 &test_path.join("excluded_link"),
5270 &test_path.join("dst"),
5271 &Settings {
5272 dereference: false,
5273 fail_early: false,
5274 overwrite: false,
5275 overwrite_compare: Default::default(),
5276 overwrite_filter: None,
5277 ignore_existing: false,
5278 chunk_size: 0,
5279 skip_specials: false,
5280 remote_copy_buffer_size: 0,
5281 filter: Some(filter),
5282 dry_run: None,
5283 delete: None,
5284 },
5285 &NO_PRESERVE_SETTINGS,
5286 false,
5287 )
5288 .await?;
5289 assert_eq!(
5291 result.symlinks_created, 0,
5292 "root symlink matching exclude should not be created"
5293 );
5294 assert!(
5295 !test_path.join("dst").exists(),
5296 "excluded root symlink should not exist at destination"
5297 );
5298 Ok(())
5299 }
5300 #[tokio::test]
5302 #[traced_test]
5303 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
5304 let tmp_dir = testutils::setup_test_dir().await?;
5305 let test_path = tmp_dir.as_path();
5306 let mut filter = FilterSettings::new();
5313 filter.add_include("**/*.txt").unwrap();
5314 filter.add_exclude("bar/2.txt").unwrap();
5315 let summary = copy(
5316 &PROGRESS,
5317 &test_path.join("foo"),
5318 &test_path.join("dst"),
5319 &Settings {
5320 dereference: false,
5321 fail_early: false,
5322 overwrite: false,
5323 overwrite_compare: Default::default(),
5324 overwrite_filter: None,
5325 ignore_existing: false,
5326 chunk_size: 0,
5327 skip_specials: false,
5328 remote_copy_buffer_size: 0,
5329 filter: Some(filter),
5330 dry_run: None,
5331 delete: None,
5332 },
5333 &NO_PRESERVE_SETTINGS,
5334 false,
5335 )
5336 .await?;
5337 assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
5341 assert_eq!(
5342 summary.files_skipped, 1,
5343 "should skip 1 file (bar/2.txt excluded)"
5344 );
5345 assert!(
5347 test_path.join("dst/bar/1.txt").exists(),
5348 "bar/1.txt should be copied"
5349 );
5350 assert!(
5351 !test_path.join("dst/bar/2.txt").exists(),
5352 "bar/2.txt should be excluded"
5353 );
5354 assert!(
5355 test_path.join("dst/bar/3.txt").exists(),
5356 "bar/3.txt should be copied"
5357 );
5358 Ok(())
5359 }
5360 #[tokio::test]
5362 #[traced_test]
5363 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
5364 let tmp_dir = testutils::setup_test_dir().await?;
5365 let test_path = tmp_dir.as_path();
5366 let mut filter = FilterSettings::new();
5373 filter.add_exclude("bar/").unwrap();
5374 let summary = copy(
5375 &PROGRESS,
5376 &test_path.join("foo"),
5377 &test_path.join("dst"),
5378 &Settings {
5379 dereference: false,
5380 fail_early: false,
5381 overwrite: false,
5382 overwrite_compare: Default::default(),
5383 overwrite_filter: None,
5384 ignore_existing: false,
5385 chunk_size: 0,
5386 skip_specials: false,
5387 remote_copy_buffer_size: 0,
5388 filter: Some(filter),
5389 dry_run: None,
5390 delete: None,
5391 },
5392 &NO_PRESERVE_SETTINGS,
5393 false,
5394 )
5395 .await?;
5396 assert_eq!(summary.files_copied, 2, "should copy 2 files");
5400 assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
5401 assert_eq!(
5402 summary.directories_created, 2,
5403 "should create 2 directories"
5404 );
5405 assert_eq!(
5406 summary.directories_skipped, 1,
5407 "should skip 1 directory (bar)"
5408 );
5409 assert_eq!(
5410 summary.files_skipped, 0,
5411 "no files skipped (bar contents not counted)"
5412 );
5413 Ok(())
5414 }
5415 #[tokio::test]
5418 #[traced_test]
5419 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
5420 let test_path = testutils::create_temp_dir().await?;
5421 let src_path = test_path.join("src");
5427 tokio::fs::create_dir(&src_path).await?;
5428 tokio::fs::write(src_path.join("foo"), "content").await?;
5429 tokio::fs::write(src_path.join("bar"), "content").await?;
5430 tokio::fs::create_dir(src_path.join("baz")).await?;
5431 let mut filter = FilterSettings::new();
5433 filter.add_include("foo").unwrap();
5434 let summary = copy(
5435 &PROGRESS,
5436 &src_path,
5437 &test_path.join("dst"),
5438 &Settings {
5439 dereference: false,
5440 fail_early: false,
5441 overwrite: false,
5442 overwrite_compare: Default::default(),
5443 overwrite_filter: None,
5444 ignore_existing: false,
5445 chunk_size: 0,
5446 skip_specials: false,
5447 remote_copy_buffer_size: 0,
5448 filter: Some(filter),
5449 dry_run: None,
5450 delete: None,
5451 },
5452 &NO_PRESERVE_SETTINGS,
5453 false,
5454 )
5455 .await?;
5456 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
5458 assert_eq!(
5459 summary.directories_created, 1,
5460 "should create only root directory (not empty 'baz')"
5461 );
5462 assert!(
5464 test_path.join("dst").join("foo").exists(),
5465 "foo should be copied"
5466 );
5467 assert!(
5469 !test_path.join("dst").join("bar").exists(),
5470 "bar should not be copied"
5471 );
5472 assert!(
5474 !test_path.join("dst").join("baz").exists(),
5475 "empty baz directory should NOT be created"
5476 );
5477 Ok(())
5478 }
5479 #[tokio::test]
5482 #[traced_test]
5483 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
5484 let test_path = testutils::create_temp_dir().await?;
5485 let src_path = test_path.join("src");
5492 tokio::fs::create_dir(&src_path).await?;
5493 tokio::fs::write(src_path.join("foo"), "content").await?;
5494 tokio::fs::create_dir(src_path.join("baz")).await?;
5495 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
5496 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
5497 let mut filter = FilterSettings::new();
5499 filter.add_include("foo").unwrap();
5500 let summary = copy(
5501 &PROGRESS,
5502 &src_path,
5503 &test_path.join("dst"),
5504 &Settings {
5505 dereference: false,
5506 fail_early: false,
5507 overwrite: false,
5508 overwrite_compare: Default::default(),
5509 overwrite_filter: None,
5510 ignore_existing: false,
5511 chunk_size: 0,
5512 skip_specials: false,
5513 remote_copy_buffer_size: 0,
5514 filter: Some(filter),
5515 dry_run: None,
5516 delete: None,
5517 },
5518 &NO_PRESERVE_SETTINGS,
5519 false,
5520 )
5521 .await?;
5522 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
5524 assert_eq!(
5525 summary.files_skipped, 2,
5526 "should skip 2 files (qux and quux)"
5527 );
5528 assert_eq!(
5529 summary.directories_created, 1,
5530 "should create only root directory (not 'baz' with non-matching content)"
5531 );
5532 assert!(
5534 test_path.join("dst").join("foo").exists(),
5535 "foo should be copied"
5536 );
5537 assert!(
5539 !test_path.join("dst").join("baz").exists(),
5540 "baz directory should NOT be created (no matching content inside)"
5541 );
5542 Ok(())
5543 }
5544 #[tokio::test]
5547 #[traced_test]
5548 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
5549 let test_path = testutils::create_temp_dir().await?;
5550 let src_path = test_path.join("src");
5556 tokio::fs::create_dir(&src_path).await?;
5557 tokio::fs::write(src_path.join("foo"), "content").await?;
5558 tokio::fs::write(src_path.join("bar"), "content").await?;
5559 tokio::fs::create_dir(src_path.join("baz")).await?;
5560 let mut filter = FilterSettings::new();
5562 filter.add_include("foo").unwrap();
5563 let summary = copy(
5564 &PROGRESS,
5565 &src_path,
5566 &test_path.join("dst"),
5567 &Settings {
5568 dereference: false,
5569 fail_early: false,
5570 overwrite: false,
5571 overwrite_compare: Default::default(),
5572 overwrite_filter: None,
5573 ignore_existing: false,
5574 chunk_size: 0,
5575 skip_specials: false,
5576 remote_copy_buffer_size: 0,
5577 filter: Some(filter),
5578 dry_run: Some(crate::config::DryRunMode::Explain),
5579 delete: None,
5580 },
5581 &NO_PRESERVE_SETTINGS,
5582 false,
5583 )
5584 .await?;
5585 assert_eq!(
5587 summary.files_copied, 1,
5588 "should report only 'foo' would be copied"
5589 );
5590 assert_eq!(
5591 summary.directories_created, 1,
5592 "should report only root directory would be created (not empty 'baz')"
5593 );
5594 assert!(
5596 !test_path.join("dst").exists(),
5597 "dst should not exist in dry-run"
5598 );
5599 Ok(())
5600 }
5601 #[tokio::test]
5604 #[traced_test]
5605 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
5606 let test_path = testutils::create_temp_dir().await?;
5607 let src_path = test_path.join("src");
5613 tokio::fs::create_dir(&src_path).await?;
5614 tokio::fs::write(src_path.join("foo"), "content").await?;
5615 tokio::fs::write(src_path.join("bar"), "content").await?;
5616 tokio::fs::create_dir(src_path.join("baz")).await?;
5617 let dst_path = test_path.join("dst");
5619 tokio::fs::create_dir(&dst_path).await?;
5620 tokio::fs::create_dir(dst_path.join("baz")).await?;
5621 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
5623 let mut filter = FilterSettings::new();
5625 filter.add_include("foo").unwrap();
5626 let summary = copy(
5627 &PROGRESS,
5628 &src_path,
5629 &dst_path,
5630 &Settings {
5631 dereference: false,
5632 fail_early: false,
5633 overwrite: true, overwrite_compare: Default::default(),
5635 overwrite_filter: None,
5636 ignore_existing: false,
5637 chunk_size: 0,
5638 skip_specials: false,
5639 remote_copy_buffer_size: 0,
5640 filter: Some(filter),
5641 dry_run: None,
5642 delete: None,
5643 },
5644 &NO_PRESERVE_SETTINGS,
5645 false,
5646 )
5647 .await?;
5648 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
5650 assert_eq!(
5652 summary.directories_unchanged, 2,
5653 "root dst and baz directories should be unchanged"
5654 );
5655 assert_eq!(
5656 summary.directories_created, 0,
5657 "should not create any directories"
5658 );
5659 assert!(dst_path.join("foo").exists(), "foo should be copied");
5661 assert!(!dst_path.join("bar").exists(), "bar should not be copied");
5663 assert!(
5665 dst_path.join("baz").exists(),
5666 "existing baz directory should still exist"
5667 );
5668 assert!(
5669 dst_path.join("baz").join("marker.txt").exists(),
5670 "existing content in baz should still exist"
5671 );
5672 Ok(())
5673 }
5674 }
5675 mod dry_run_tests {
5676 use super::*;
5677 use crate::filter::FilterSettings;
5678 #[tokio::test]
5681 #[traced_test]
5682 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
5683 let tmp_dir = testutils::setup_test_dir().await?;
5684 let test_path = tmp_dir.as_path();
5685 let dst_path = test_path.join("nonexistent_dst");
5686 assert!(
5688 !dst_path.exists(),
5689 "destination should not exist before dry-run"
5690 );
5691 let summary = copy(
5692 &PROGRESS,
5693 &test_path.join("foo"),
5694 &dst_path,
5695 &Settings {
5696 dereference: false,
5697 fail_early: false,
5698 overwrite: false,
5699 overwrite_compare: Default::default(),
5700 overwrite_filter: None,
5701 ignore_existing: false,
5702 chunk_size: 0,
5703 skip_specials: false,
5704 remote_copy_buffer_size: 0,
5705 filter: None,
5706 dry_run: Some(crate::config::DryRunMode::Brief),
5707 delete: None,
5708 },
5709 &NO_PRESERVE_SETTINGS,
5710 false,
5711 )
5712 .await?;
5713 assert!(
5715 !dst_path.exists(),
5716 "dry-run should not create destination directory"
5717 );
5718 assert!(
5720 summary.directories_created > 0,
5721 "dry-run should report directories that would be created"
5722 );
5723 assert!(
5724 summary.files_copied > 0,
5725 "dry-run should report files that would be copied"
5726 );
5727 Ok(())
5728 }
5729 #[tokio::test]
5733 async fn dry_run_ignore_existing_reports_file_as_skipped() -> Result<(), anyhow::Error> {
5734 let tmp = testutils::create_temp_dir().await?;
5735 let src = tmp.join("src.txt");
5736 let dst = tmp.join("dst.txt");
5737 tokio::fs::write(&src, "new").await?;
5738 tokio::fs::write(&dst, "old").await?; let summary = copy(
5740 &PROGRESS,
5741 &src,
5742 &dst,
5743 &Settings {
5744 dereference: false,
5745 fail_early: false,
5746 overwrite: false,
5747 overwrite_compare: Default::default(),
5748 overwrite_filter: None,
5749 ignore_existing: true,
5750 chunk_size: 0,
5751 skip_specials: false,
5752 remote_copy_buffer_size: 0,
5753 filter: None,
5754 dry_run: Some(crate::config::DryRunMode::Brief),
5755 delete: None,
5756 },
5757 &NO_PRESERVE_SETTINGS,
5758 false,
5759 )
5760 .await?;
5761 assert_eq!(
5762 summary.files_unchanged, 1,
5763 "existing file must be reported skipped in dry-run under --ignore-existing"
5764 );
5765 assert_eq!(
5766 summary.files_copied, 0,
5767 "must not report a would-be copy when --ignore-existing skips"
5768 );
5769 assert_eq!(
5770 tokio::fs::read_to_string(&dst).await?,
5771 "old",
5772 "dry-run must not mutate the destination"
5773 );
5774 Ok(())
5775 }
5776 #[tokio::test]
5779 async fn dry_run_ignore_existing_reports_symlink_as_skipped() -> Result<(), anyhow::Error> {
5780 let tmp = testutils::create_temp_dir().await?;
5781 let src = tmp.join("src_link");
5782 let dst = tmp.join("dst_link");
5783 tokio::fs::symlink("some/target", &src).await?;
5784 tokio::fs::write(&dst, "existing").await?; let summary = copy(
5786 &PROGRESS,
5787 &src,
5788 &dst,
5789 &Settings {
5790 dereference: false,
5791 fail_early: false,
5792 overwrite: false,
5793 overwrite_compare: Default::default(),
5794 overwrite_filter: None,
5795 ignore_existing: true,
5796 chunk_size: 0,
5797 skip_specials: false,
5798 remote_copy_buffer_size: 0,
5799 filter: None,
5800 dry_run: Some(crate::config::DryRunMode::Brief),
5801 delete: None,
5802 },
5803 &NO_PRESERVE_SETTINGS,
5804 false,
5805 )
5806 .await?;
5807 assert_eq!(
5808 summary.symlinks_unchanged, 1,
5809 "existing symlink dst must be reported skipped in dry-run under --ignore-existing"
5810 );
5811 assert_eq!(
5812 summary.symlinks_created, 0,
5813 "must not report a would-be symlink creation when --ignore-existing skips"
5814 );
5815 Ok(())
5816 }
5817 #[tokio::test]
5824 #[traced_test]
5825 async fn dry_run_ignore_existing_reports_dir_behind_a_symlink_as_skipped()
5826 -> Result<(), anyhow::Error> {
5827 let tmp = testutils::create_temp_dir().await?;
5828 let src = tmp.join("src_dir");
5829 tokio::fs::create_dir(&src).await?;
5830 tokio::fs::write(src.join("child.txt"), "content").await?;
5831 let link_target = tmp.join("elsewhere");
5833 tokio::fs::create_dir(&link_target).await?;
5834 let dst = tmp.join("dst_dir");
5835 tokio::fs::symlink(&link_target, &dst).await?;
5836 let summary = copy(
5837 &PROGRESS,
5838 &src,
5839 &dst,
5840 &Settings {
5841 dereference: false,
5842 fail_early: false,
5843 overwrite: false,
5844 overwrite_compare: Default::default(),
5845 overwrite_filter: None,
5846 ignore_existing: true,
5847 chunk_size: 0,
5848 skip_specials: false,
5849 remote_copy_buffer_size: 0,
5850 filter: None,
5851 dry_run: Some(crate::config::DryRunMode::Brief),
5852 delete: None,
5853 },
5854 &NO_PRESERVE_SETTINGS,
5855 false,
5856 )
5857 .await?;
5858 assert_eq!(
5859 summary.directories_unchanged, 1,
5860 "a destination symlink is a non-directory, so --ignore-existing skips the subtree; \
5861 dry-run must report that rather than following the link"
5862 );
5863 assert_eq!(
5864 summary.directories_created, 0,
5865 "must not report a would-be directory creation when --ignore-existing skips"
5866 );
5867 assert_eq!(
5868 summary.files_copied, 0,
5869 "the skipped subtree's children must not be reported as would-be-copied"
5870 );
5871 Ok(())
5872 }
5873
5874 #[tokio::test]
5886 #[traced_test]
5887 async fn dry_run_ignore_existing_reports_an_unprobeable_destination_as_an_error()
5888 -> Result<(), anyhow::Error> {
5889 if nix::unistd::geteuid().is_root() {
5890 eprintln!("skipping: root bypasses the directory permissions this test relies on");
5891 return Ok(());
5892 }
5893 let tmp = testutils::create_temp_dir().await?;
5894 let src = tmp.join("src_dir");
5895 tokio::fs::create_dir(&src).await?;
5896 tokio::fs::create_dir(src.join("sub")).await?;
5901 let sealed = tmp.join("dst_dir");
5904 tokio::fs::create_dir(&sealed).await?;
5905 tokio::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o600)).await?;
5906 let result = copy(
5907 &PROGRESS,
5908 &src,
5909 &sealed,
5910 &Settings {
5911 dereference: false,
5912 fail_early: false,
5913 overwrite: false,
5914 overwrite_compare: Default::default(),
5915 overwrite_filter: None,
5916 ignore_existing: true,
5917 chunk_size: 0,
5918 skip_specials: false,
5919 remote_copy_buffer_size: 0,
5920 filter: None,
5921 dry_run: Some(crate::config::DryRunMode::Brief),
5922 delete: None,
5923 },
5924 &NO_PRESERVE_SETTINGS,
5925 false,
5926 )
5927 .await;
5928 tokio::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o700)).await?;
5930 let error = result.expect_err(
5931 "an unprobeable destination must be reported, not treated as vacant and predicted \
5932 as a copy",
5933 );
5934 let rendered = format!("{:#}", error.source);
5935 assert!(
5936 rendered.contains("Permission denied"),
5937 "the error must keep the real cause; got: {rendered}"
5938 );
5939 Ok(())
5940 }
5941
5942 #[tokio::test]
5946 #[traced_test]
5947 async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
5948 let test_path = testutils::create_temp_dir().await?;
5949 let src_path = test_path.join("src");
5954 tokio::fs::create_dir(&src_path).await?;
5955 tokio::fs::write(src_path.join("bar.log"), "content").await?;
5956 tokio::fs::create_dir(src_path.join("baz")).await?;
5957 let mut filter = FilterSettings::new();
5959 filter.add_include("*.txt").unwrap();
5960 let dst_path = test_path.join("dst");
5961 let summary = copy(
5962 &PROGRESS,
5963 &src_path,
5964 &dst_path,
5965 &Settings {
5966 dereference: false,
5967 fail_early: false,
5968 overwrite: false,
5969 overwrite_compare: Default::default(),
5970 overwrite_filter: None,
5971 ignore_existing: false,
5972 chunk_size: 0,
5973 skip_specials: false,
5974 remote_copy_buffer_size: 0,
5975 filter: Some(filter),
5976 dry_run: None,
5977 delete: None,
5978 },
5979 &NO_PRESERVE_SETTINGS,
5980 false,
5981 )
5982 .await?;
5983 assert_eq!(summary.files_copied, 0, "no files match *.txt");
5985 assert_eq!(
5987 summary.directories_created, 1,
5988 "root directory should always be created"
5989 );
5990 assert!(dst_path.exists(), "root destination directory should exist");
5991 assert!(
5993 !dst_path.join("baz").exists(),
5994 "empty baz should not be created"
5995 );
5996 Ok(())
5997 }
5998 #[tokio::test]
6000 #[traced_test]
6001 async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
6002 {
6003 let test_path = testutils::create_temp_dir().await?;
6004 let src_path = test_path.join("src");
6005 tokio::fs::create_dir(&src_path).await?;
6006 tokio::fs::write(src_path.join("bar.log"), "content").await?;
6007 let mut filter = FilterSettings::new();
6009 filter.add_include("*.txt").unwrap();
6010 let dst_path = test_path.join("dst");
6011 let summary = copy(
6012 &PROGRESS,
6013 &src_path,
6014 &dst_path,
6015 &Settings {
6016 dereference: false,
6017 fail_early: false,
6018 overwrite: false,
6019 overwrite_compare: Default::default(),
6020 overwrite_filter: None,
6021 ignore_existing: false,
6022 chunk_size: 0,
6023 skip_specials: false,
6024 remote_copy_buffer_size: 0,
6025 filter: Some(filter),
6026 dry_run: Some(crate::config::DryRunMode::Explain),
6027 delete: None,
6028 },
6029 &NO_PRESERVE_SETTINGS,
6030 false,
6031 )
6032 .await?;
6033 assert_eq!(summary.files_copied, 0, "no files match *.txt");
6034 assert_eq!(
6035 summary.directories_created, 1,
6036 "root directory should be counted in dry-run"
6037 );
6038 assert!(
6039 !dst_path.exists(),
6040 "nothing should be created in dry-run mode"
6041 );
6042 Ok(())
6043 }
6044 }
6045
6046 mod max_files_in_flight_tests {
6048 use super::*;
6049
6050 #[tokio::test]
6053 async fn filtered_public_root_waits_for_admission_before_setup() -> anyhow::Result<()> {
6054 let root = testutils::create_temp_dir().await?;
6055 let src = root.join("src");
6056 let dst = root.join("dst");
6057 tokio::fs::write(&src, b"x").await?;
6058 let mut filter = crate::filter::FilterSettings::new();
6059 filter.add_exclude("src")?;
6060 let mut settings = settings_with_delete(None);
6061 settings.filter = Some(filter);
6062 let admission = testutils::AdmissionLimit::new().await;
6063 admission.set_files_in_flight(1);
6064 let stat_resource =
6065 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6066 admission.set_max_ops_in_flight(stat_resource, 1);
6067 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6068 let operation = copy(
6069 &PROGRESS,
6070 &src,
6071 &dst,
6072 &settings,
6073 &NO_PRESERVE_SETTINGS,
6074 false,
6075 );
6076 tokio::pin!(operation);
6077 let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
6078 let mut second_permit = Box::pin(throttle::open_file_permit());
6079 let setup_bypassed_admission = futures::poll!(second_permit.as_mut()).is_ready();
6080 drop(second_permit);
6081 drop(held_stat);
6082 let result = admission
6083 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6084 .await;
6085 assert!(
6086 stopped_at_stat_gate,
6087 "copy root did not reach the held stat gate"
6088 );
6089 let summary =
6090 result.context("copy root did not resume after stat capacity was released")??;
6091 assert!(
6092 !setup_bypassed_admission,
6093 "copy performed root setup before open-file admission"
6094 );
6095 assert_eq!(summary.files_skipped, 1);
6096 Ok(())
6097 }
6098
6099 #[tokio::test]
6102 async fn unknown_type_reserves_max_files_in_flight_capacity() {
6103 let admission = testutils::AdmissionLimit::new().await;
6104 admission.set_files_in_flight(1);
6105 let held_permit = throttle::open_file_permit().await;
6106 let visitor = CopyVisitor {
6107 prog_track: &PROGRESS,
6108 dst_root: PathBuf::new(),
6109 filter_base: PathBuf::new(),
6110 delete_scan_anchor: DeleteScanAnchor::new(
6111 std::path::Path::new("dst"),
6112 std::path::Path::new(""),
6113 ),
6114 settings: settings_with_delete(None),
6115 preserve: *NO_PRESERVE_SETTINGS,
6116 dst_parent: None,
6117 root_is_fresh: false,
6118 };
6119 let acquire = crate::walk::preacquire_leaf_permit(visitor.permit_kind(), None);
6120 tokio::pin!(acquire);
6121 assert!(
6122 futures::poll!(acquire.as_mut()).is_pending(),
6123 "DT_UNKNOWN must not bypass pre-spawn open-file backpressure"
6124 );
6125 drop(held_permit);
6126 let permit = acquire.await;
6127 assert!(
6128 permit.is_some(),
6129 "DT_UNKNOWN must reserve capacity for authoritative classification"
6130 );
6131 }
6132
6133 #[tokio::test]
6136 async fn symlink_holds_open_file_capacity_until_copy_finishes() -> anyhow::Result<()> {
6137 let root = testutils::create_temp_dir().await?;
6138 let src_name = std::ffi::OsStr::new("src-link");
6139 let dst_name = std::ffi::OsStr::new("dst-link");
6140 tokio::fs::symlink("target", root.join(src_name)).await?;
6141 let admission = testutils::AdmissionLimit::new().await;
6142 let src_parent = Arc::new(
6143 Dir::open_parent_dir(&root, congestion::Side::Source)
6144 .await?
6145 .into_tree(),
6146 );
6147 let dst_parent = Arc::new(
6148 Dir::open_parent_dir(&root, congestion::Side::Destination)
6149 .await?
6150 .into_tree(),
6151 );
6152 let handle = src_parent.child(src_name).await?;
6153 assert_eq!(handle.kind(), EntryKind::Symlink);
6154 let dst_root = root.join(dst_name);
6155 let visitor = CopyVisitor {
6156 prog_track: &PROGRESS,
6157 dst_root: dst_root.clone(),
6158 filter_base: PathBuf::new(),
6159 delete_scan_anchor: DeleteScanAnchor::new(&dst_root, std::path::Path::new("")),
6160 settings: settings_with_delete(None),
6161 preserve: *NO_PRESERVE_SETTINGS,
6162 dst_parent: Some(Arc::clone(&dst_parent)),
6163 root_is_fresh: true,
6164 };
6165 let cx = EntryCx {
6166 parent: src_parent,
6167 name: src_name.to_owned(),
6168 rel_path: PathBuf::new(),
6169 filter_path: PathBuf::new(),
6170 real_path: root.join(src_name),
6171 dry_run: false,
6172 prog_track: &PROGRESS,
6173 };
6174 let parent_ctx = CopyDirContext {
6175 dst_dir: Some(dst_parent),
6176 is_fresh: true,
6177 };
6178 admission.set_files_in_flight(1);
6179 let permit = Some(LeafPermit::OpenFile(throttle::open_file_permit().await));
6180 let readlink_resource =
6181 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::ReadLink);
6182 admission.set_max_ops_in_flight(readlink_resource, 1);
6183 let held_readlink = throttle::ops_in_flight_permit(readlink_resource).await;
6184 let leaf = AdmittedLeaf::try_new(handle, permit)
6185 .map_err(|_| anyhow::anyhow!("symlink classification returned a directory"))?;
6186 let visit = visitor.visit_leaf(&cx, &parent_ctx, leaf);
6187 tokio::pin!(visit);
6188 let stopped_at_readlink_gate = futures::poll!(visit.as_mut()).is_pending();
6189 let mut second_permit = Box::pin(throttle::open_file_permit());
6190 let released_before_copy_finished = futures::poll!(second_permit.as_mut()).is_ready();
6191 drop(second_permit);
6192 drop(held_readlink);
6193 let result = admission
6194 .run_with_timeout(std::time::Duration::from_secs(20), visit.as_mut())
6195 .await;
6196 assert!(
6197 stopped_at_readlink_gate,
6198 "symlink copy did not reach the held readlink gate"
6199 );
6200 assert!(
6201 !released_before_copy_finished,
6202 "symlink released its open-file permit while fd-bearing work was still pending"
6203 );
6204 let summary = result
6205 .context("symlink copy did not resume after readlink capacity was released")?
6206 .map_err(|error| error.source)?;
6207 assert_eq!(summary.symlinks_created, 1);
6208 assert_eq!(
6209 tokio::fs::read_link(root.join(dst_name)).await?,
6210 std::path::Path::new("target")
6211 );
6212 Ok(())
6213 }
6214
6215 #[tokio::test]
6219 async fn dereferenced_symlink_transfers_open_file_capacity_to_target_copy()
6220 -> anyhow::Result<()> {
6221 let root = testutils::create_temp_dir().await?;
6222 let src_name = std::ffi::OsStr::new("src-link");
6223 tokio::fs::write(root.join("target"), b"payload").await?;
6224 tokio::fs::symlink("target", root.join(src_name)).await?;
6225 let admission = testutils::AdmissionLimit::new().await;
6226 let src_parent = Arc::new(
6227 Dir::open_parent_dir(&root, congestion::Side::Source)
6228 .await?
6229 .into_tree(),
6230 );
6231 let handle = src_parent.child(src_name).await?;
6232 let mut settings = settings_with_delete(None);
6233 settings.dereference = true;
6234 settings.dry_run = Some(crate::config::DryRunMode::Brief);
6235 let dst_root = root.join("dst");
6236 let visitor = CopyVisitor {
6237 prog_track: &PROGRESS,
6238 dst_root: dst_root.clone(),
6239 filter_base: PathBuf::new(),
6240 delete_scan_anchor: DeleteScanAnchor::new(&dst_root, std::path::Path::new("")),
6241 settings,
6242 preserve: *NO_PRESERVE_SETTINGS,
6243 dst_parent: None,
6244 root_is_fresh: false,
6245 };
6246 let cx = EntryCx {
6247 parent: src_parent,
6248 name: src_name.to_owned(),
6249 rel_path: PathBuf::new(),
6250 filter_path: PathBuf::new(),
6251 real_path: root.join(src_name),
6252 dry_run: true,
6253 prog_track: &PROGRESS,
6254 };
6255 let parent_ctx = CopyDirContext {
6256 dst_dir: None,
6257 is_fresh: false,
6258 };
6259 admission.set_files_in_flight(1);
6260 let permit = Some(LeafPermit::OpenFile(throttle::open_file_permit().await));
6261 let stat_resource =
6262 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6263 admission.set_max_ops_in_flight(stat_resource, 1);
6264 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6265 let leaf = AdmittedLeaf::try_new(handle, permit)
6266 .map_err(|_| anyhow::anyhow!("symlink classification returned a directory"))?;
6267 let visit = visitor.visit_leaf(&cx, &parent_ctx, leaf);
6268 tokio::pin!(visit);
6269 let stopped_at_canonicalize_gate = futures::poll!(visit.as_mut()).is_pending();
6270 let mut target_setup_gate = Box::pin(throttle::ops_in_flight_permit(stat_resource));
6275 let target_setup_waiter_queued =
6276 futures::poll!(target_setup_gate.as_mut()).is_pending();
6277 drop(held_stat);
6278 let gate_race = tokio::time::timeout(std::time::Duration::from_secs(20), async {
6279 tokio::select! {
6280 permit = target_setup_gate.as_mut() => Ok(permit),
6281 result = visit.as_mut() => Err(result),
6282 }
6283 })
6284 .await;
6285 let mut setup_guard = None;
6286 let mut early_visit_result = None;
6287 let gate_timed_out = match gate_race {
6288 Ok(Ok(guard)) => {
6289 setup_guard = Some(guard);
6290 false
6291 }
6292 Ok(Err(result)) => {
6293 early_visit_result = Some(result);
6294 false
6295 }
6296 Err(_) => true,
6297 };
6298 drop(target_setup_gate);
6299 let finished_before_target_setup = early_visit_result.is_some();
6300 let mut second_permit = Box::pin(throttle::open_file_permit());
6301 let released_inside_target_setup = futures::poll!(second_permit.as_mut()).is_ready();
6302 drop(second_permit);
6303 drop(setup_guard);
6304 let result = match early_visit_result {
6305 Some(result) => Ok(result),
6306 None => {
6307 admission
6308 .run_with_timeout(std::time::Duration::from_secs(20), visit.as_mut())
6309 .await
6310 }
6311 };
6312 assert!(
6313 stopped_at_canonicalize_gate,
6314 "dereference did not reach the held canonicalize Stat gate"
6315 );
6316 assert!(
6317 target_setup_waiter_queued,
6318 "target-setup test waiter did not queue behind canonicalize"
6319 );
6320 assert!(
6321 !gate_timed_out,
6322 "canonicalize did not release its Stat slot"
6323 );
6324 assert!(
6325 !finished_before_target_setup,
6326 "dereferenced copy finished before reaching target setup"
6327 );
6328 assert!(
6329 !released_inside_target_setup,
6330 "dereference released admission instead of transferring it into target setup"
6331 );
6332 let summary = result
6333 .context("dereferenced copy did not resume after target setup was released")?
6334 .map_err(|error| error.source)?;
6335 assert_eq!(summary.files_copied, 1);
6336 Ok(())
6337 }
6338
6339 #[tokio::test]
6343 async fn dereferenced_symlink_directory_releases_capacity_before_descent()
6344 -> anyhow::Result<()> {
6345 let root = testutils::create_temp_dir().await?;
6346 let target = root.join("target");
6347 let src = root.join("src-link");
6348 let dst = root.join("dst");
6349 tokio::fs::create_dir(&target).await?;
6350 tokio::fs::write(target.join("child"), b"payload").await?;
6351 tokio::fs::symlink("target", &src).await?;
6352 let mut settings = settings_with_delete(None);
6353 settings.dereference = true;
6354 let admission = testutils::AdmissionLimit::new().await;
6355 admission.set_files_in_flight(1);
6356 let result = admission
6357 .run_with_timeout(
6358 std::time::Duration::from_secs(20),
6359 copy(
6360 &PROGRESS,
6361 &src,
6362 &dst,
6363 &settings,
6364 &NO_PRESERVE_SETTINGS,
6365 false,
6366 ),
6367 )
6368 .await;
6369 let summary = result.context(
6370 "dereferenced directory copy retained admission across recursive descent",
6371 )??;
6372 assert_eq!(summary.files_copied, 1);
6373 assert_eq!(tokio::fs::read(dst.join("child")).await?, b"payload");
6374 Ok(())
6375 }
6376
6377 #[tokio::test]
6380 async fn cancelled_copy_file_fd_retains_capacity_until_its_fds_close() -> anyhow::Result<()>
6381 {
6382 let root = testutils::create_temp_dir().await?;
6383 let src = root.join("source");
6384 let dst = root.join("destination");
6385 if let Err(error) = tokio::fs::write(&src, vec![b'x'; 4096]).await {
6386 let _ = tokio::fs::remove_dir_all(&root).await;
6387 return Err(error.into());
6388 }
6389 let gate = testutils::BlockingPathGate::install(dst.clone());
6390 let admission = testutils::AdmissionLimit::new().await;
6391 admission.set_files_in_flight(1);
6392 let timeout = std::time::Duration::from_secs(20);
6393 let settings = settings_with_delete(None);
6394 let task = tokio::spawn(async move {
6395 copy(
6396 &PROGRESS,
6397 &src,
6398 &dst,
6399 &settings,
6400 &NO_PRESERVE_SETTINGS,
6401 false,
6402 )
6403 .await
6404 });
6405 let observations =
6406 testutils::cancel_at_blocking_path(admission, gate, task, timeout, |_| ()).await;
6407 let cleanup_result = tokio::fs::remove_dir_all(root).await;
6408 let (observations, ()) = observations?;
6409 cleanup_result?;
6410
6411 assert!(observations.waiter_was_cancelled);
6412 assert!(
6413 observations.admission_was_retained_while_work_gated,
6414 "cancelling copy_file_fd released capacity while its fd-owning job was live"
6415 );
6416 assert!(
6417 observations.fd_was_open_while_work_gated,
6418 "copy_file_fd closed its fd while the job was gated"
6419 );
6420 assert_eq!(observations.hit_count_before_release, 1);
6421 assert!(
6422 observations.fd_was_closed_at_output_drop_start,
6423 "copy_file_fd reached its output-drop boundary before its destination fd closed"
6424 );
6425 assert!(
6426 observations.admission_was_retained_at_output_drop_start,
6427 "copy_file_fd released capacity before its abandoned output dropped"
6428 );
6429 assert_eq!(observations.final_hit_count, 1);
6430 Ok(())
6431 }
6432
6433 #[tokio::test]
6436 #[traced_test]
6437 async fn wide_copy_under_files_in_flight_saturation() -> Result<(), anyhow::Error> {
6438 let tmp_dir = testutils::create_temp_dir().await?;
6439 let src = tmp_dir.join("src");
6440 let dst = tmp_dir.join("dst");
6441 tokio::fs::create_dir(&src).await?;
6442 let file_count = 200;
6443 for i in 0..file_count {
6444 tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
6445 }
6446 let admission = testutils::AdmissionLimit::new().await;
6448 admission.set_files_in_flight(4);
6449 let summary = admission
6450 .run_with_timeout(
6451 std::time::Duration::from_secs(30),
6452 copy(
6453 &PROGRESS,
6454 &src,
6455 &dst,
6456 &Settings {
6457 dereference: false,
6458 fail_early: true,
6459 overwrite: false,
6460 overwrite_compare: Default::default(),
6461 overwrite_filter: None,
6462 ignore_existing: false,
6463 chunk_size: 0,
6464 skip_specials: false,
6465 remote_copy_buffer_size: 0,
6466 filter: None,
6467 dry_run: None,
6468 delete: None,
6469 },
6470 &NO_PRESERVE_SETTINGS,
6471 false,
6472 ),
6473 )
6474 .await
6475 .context("copy timed out — possible deadlock")??;
6476 assert_eq!(summary.files_copied, file_count);
6477 assert_eq!(summary.directories_created, 1);
6478 for i in 0..file_count {
6479 let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
6480 assert_eq!(content, format!("content-{}", i));
6481 }
6482 Ok(())
6483 }
6484
6485 #[tokio::test]
6488 #[traced_test]
6489 async fn deep_tree_no_deadlock_under_files_in_flight_saturation()
6490 -> Result<(), anyhow::Error> {
6491 let tmp_dir = testutils::create_temp_dir().await?;
6492 let src = tmp_dir.join("src");
6493 let dst = tmp_dir.join("dst");
6494 let depth = 20;
6495 let files_per_level = 5;
6496 let limit = 1;
6497 let mut dir = src.clone();
6499 for level in 0..depth {
6500 tokio::fs::create_dir_all(&dir).await?;
6501 for f in 0..files_per_level {
6502 tokio::fs::write(
6503 dir.join(format!("f{}_{}.txt", level, f)),
6504 format!("L{}F{}", level, f),
6505 )
6506 .await?;
6507 }
6508 dir = dir.join(format!("d{}", level));
6509 }
6510 let admission = testutils::AdmissionLimit::new().await;
6511 admission.set_files_in_flight(limit);
6512 let summary = admission
6513 .run_with_timeout(
6514 std::time::Duration::from_secs(30),
6515 copy(
6516 &PROGRESS,
6517 &src,
6518 &dst,
6519 &Settings {
6520 dereference: false,
6521 fail_early: true,
6522 overwrite: false,
6523 overwrite_compare: Default::default(),
6524 overwrite_filter: None,
6525 ignore_existing: false,
6526 chunk_size: 0,
6527 skip_specials: false,
6528 remote_copy_buffer_size: 0,
6529 filter: None,
6530 dry_run: None,
6531 delete: None,
6532 },
6533 &NO_PRESERVE_SETTINGS,
6534 false,
6535 ),
6536 )
6537 .await
6538 .context("copy timed out — possible deadlock")?
6539 .context("copy failed")?;
6540 assert_eq!(summary.files_copied, depth * files_per_level);
6541 assert_eq!(summary.directories_created, depth);
6542 let mut check_dir = dst.clone();
6544 for level in 0..depth {
6545 let content =
6546 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
6547 assert_eq!(content, format!("L{}F0", level));
6548 check_dir = check_dir.join(format!("d{}", level));
6549 }
6550 Ok(())
6551 }
6552
6553 #[tokio::test]
6561 #[traced_test]
6562 async fn parallel_overwrite_dir_with_file_no_deadlock() -> Result<(), anyhow::Error> {
6563 let tmp_dir = testutils::create_temp_dir().await?;
6564 let src = tmp_dir.join("src");
6565 let dst = tmp_dir.join("dst");
6566 tokio::fs::create_dir(&src).await?;
6567 tokio::fs::create_dir(&dst).await?;
6568 let n = 8;
6572 for i in 0..n {
6573 tokio::fs::write(src.join(format!("e{}", i)), format!("file-{}", i)).await?;
6574 let dst_subdir = dst.join(format!("e{}", i));
6575 tokio::fs::create_dir(&dst_subdir).await?;
6576 for j in 0..3 {
6577 tokio::fs::write(
6578 dst_subdir.join(format!("inner_{}.txt", j)),
6579 format!("inner-{}-{}", i, j),
6580 )
6581 .await?;
6582 }
6583 }
6584 let admission = testutils::AdmissionLimit::new().await;
6587 admission.set_files_in_flight(2);
6588 let summary = admission
6589 .run_with_timeout(
6590 std::time::Duration::from_secs(30),
6591 copy(
6592 &PROGRESS,
6593 &src,
6594 &dst,
6595 &Settings {
6596 dereference: false,
6597 fail_early: true,
6598 overwrite: true,
6599 overwrite_compare: Default::default(),
6600 overwrite_filter: None,
6601 ignore_existing: false,
6602 chunk_size: 0,
6603 skip_specials: false,
6604 remote_copy_buffer_size: 0,
6605 filter: None,
6606 dry_run: None,
6607 delete: None,
6608 },
6609 &NO_PRESERVE_SETTINGS,
6610 false,
6611 ),
6612 )
6613 .await
6614 .context(
6615 "copy timed out — deadlock between copy_file_fd's OpenFile permit and inner rm",
6616 )?
6617 .context("copy failed")?;
6618 assert_eq!(summary.files_copied, n);
6619 assert_eq!(summary.rm_summary.files_removed, n * 3);
6620 assert_eq!(summary.rm_summary.directories_removed, n);
6621 for i in 0..n {
6622 let path = dst.join(format!("e{}", i));
6623 let content = tokio::fs::read_to_string(&path).await?;
6624 assert_eq!(content, format!("file-{}", i));
6625 }
6626 Ok(())
6627 }
6628 }
6629
6630 mod skip_specials_tests {
6631 use super::*;
6632
6633 #[tokio::test]
6634 #[traced_test]
6635 async fn skip_specials_skips_socket_in_directory() -> Result<(), anyhow::Error> {
6636 let tmp_dir = testutils::setup_test_dir().await?;
6637 let test_path = tmp_dir.as_path();
6638 let src = test_path.join("src_dir");
6639 let dst = test_path.join("dst_dir");
6640 tokio::fs::create_dir(&src).await?;
6641 tokio::fs::write(src.join("file.txt"), "hello").await?;
6642 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
6644 let summary = copy(
6645 &PROGRESS,
6646 &src,
6647 &dst,
6648 &Settings {
6649 dereference: false,
6650 fail_early: false,
6651 overwrite: false,
6652 overwrite_compare: Default::default(),
6653 overwrite_filter: None,
6654 ignore_existing: false,
6655 chunk_size: 0,
6656 skip_specials: true,
6657 remote_copy_buffer_size: 0,
6658 filter: None,
6659 dry_run: None,
6660 delete: None,
6661 },
6662 &NO_PRESERVE_SETTINGS,
6663 false,
6664 )
6665 .await?;
6666 assert_eq!(summary.files_copied, 1);
6667 assert_eq!(summary.specials_skipped, 1);
6668 assert!(dst.join("file.txt").exists());
6669 assert!(!dst.join("test.sock").exists());
6670 Ok(())
6671 }
6672
6673 #[tokio::test]
6674 #[traced_test]
6675 async fn skip_specials_skips_fifo_in_directory() -> Result<(), anyhow::Error> {
6676 let tmp_dir = testutils::setup_test_dir().await?;
6677 let test_path = tmp_dir.as_path();
6678 let src = test_path.join("src_dir");
6679 let dst = test_path.join("dst_dir");
6680 tokio::fs::create_dir(&src).await?;
6681 tokio::fs::write(src.join("file.txt"), "hello").await?;
6682 nix::unistd::mkfifo(
6684 &src.join("test.fifo"),
6685 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
6686 )?;
6687 let summary = copy(
6688 &PROGRESS,
6689 &src,
6690 &dst,
6691 &Settings {
6692 dereference: false,
6693 fail_early: false,
6694 overwrite: false,
6695 overwrite_compare: Default::default(),
6696 overwrite_filter: None,
6697 ignore_existing: false,
6698 chunk_size: 0,
6699 skip_specials: true,
6700 remote_copy_buffer_size: 0,
6701 filter: None,
6702 dry_run: None,
6703 delete: None,
6704 },
6705 &NO_PRESERVE_SETTINGS,
6706 false,
6707 )
6708 .await?;
6709 assert_eq!(summary.files_copied, 1);
6710 assert_eq!(summary.specials_skipped, 1);
6711 assert!(dst.join("file.txt").exists());
6712 assert!(!dst.join("test.fifo").exists());
6713 Ok(())
6714 }
6715
6716 #[tokio::test]
6717 #[traced_test]
6718 async fn special_file_errors_without_skip_specials() -> Result<(), anyhow::Error> {
6719 let tmp_dir = testutils::setup_test_dir().await?;
6720 let test_path = tmp_dir.as_path();
6721 let src = test_path.join("src_dir");
6722 let dst = test_path.join("dst_dir");
6723 tokio::fs::create_dir(&src).await?;
6724 tokio::fs::write(src.join("file.txt"), "hello").await?;
6725 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
6726 let result = copy(
6727 &PROGRESS,
6728 &src,
6729 &dst,
6730 &Settings {
6731 dereference: false,
6732 fail_early: false,
6733 overwrite: false,
6734 overwrite_compare: Default::default(),
6735 overwrite_filter: None,
6736 ignore_existing: false,
6737 chunk_size: 0,
6738 skip_specials: false,
6739 remote_copy_buffer_size: 0,
6740 filter: None,
6741 dry_run: None,
6742 delete: None,
6743 },
6744 &NO_PRESERVE_SETTINGS,
6745 false,
6746 )
6747 .await;
6748 assert!(result.is_err());
6749 let err = result.unwrap_err();
6750 assert!(
6751 format!("{:#}", err).contains("unsupported src file type"),
6752 "error should mention unsupported file type, got: {:#}",
6753 err
6754 );
6755 Ok(())
6756 }
6757
6758 #[tokio::test]
6759 #[traced_test]
6760 async fn skip_specials_top_level_socket() -> Result<(), anyhow::Error> {
6761 let tmp_dir = testutils::setup_test_dir().await?;
6762 let test_path = tmp_dir.as_path();
6763 let src_socket = test_path.join("test.sock");
6764 let dst = test_path.join("dst.sock");
6765 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
6766 let summary = copy(
6767 &PROGRESS,
6768 &src_socket,
6769 &dst,
6770 &Settings {
6771 dereference: false,
6772 fail_early: false,
6773 overwrite: false,
6774 overwrite_compare: Default::default(),
6775 overwrite_filter: None,
6776 ignore_existing: false,
6777 chunk_size: 0,
6778 skip_specials: true,
6779 remote_copy_buffer_size: 0,
6780 filter: None,
6781 dry_run: None,
6782 delete: None,
6783 },
6784 &NO_PRESERVE_SETTINGS,
6785 false,
6786 )
6787 .await?;
6788 assert_eq!(summary.specials_skipped, 1);
6789 assert_eq!(summary.files_copied, 0);
6790 assert!(!dst.exists());
6791 Ok(())
6792 }
6793 }
6794
6795 #[tokio::test]
6796 #[traced_test]
6797 async fn delete_protects_excluded_then_removes_with_delete_excluded()
6798 -> Result<(), anyhow::Error> {
6799 let tmp_dir = testutils::setup_test_dir().await?;
6800 let test_path = tmp_dir.as_path();
6801 let src = test_path.join("foo");
6802 let dst = test_path.join("bar");
6803 copy(
6804 &PROGRESS,
6805 &src,
6806 &dst,
6807 &settings_with_delete(None),
6808 &DO_PRESERVE_SETTINGS,
6809 false,
6810 )
6811 .await?;
6812 tokio::fs::write(dst.join("keep.log"), b"protected").await?;
6814
6815 let mut filter = crate::filter::FilterSettings::new();
6816 filter.add_exclude("*.log")?;
6817
6818 let mut settings = settings_with_delete(delete_on());
6820 settings.filter = Some(filter.clone());
6821 copy(
6822 &PROGRESS,
6823 &src,
6824 &dst,
6825 &settings,
6826 &DO_PRESERVE_SETTINGS,
6827 false,
6828 )
6829 .await?;
6830 assert!(
6831 dst.join("keep.log").exists(),
6832 "*.log must be protected by default"
6833 );
6834
6835 let mut settings = settings_with_delete(Some(DeleteSettings {
6837 delete_excluded: true,
6838 }));
6839 settings.filter = Some(filter);
6840 copy(
6841 &PROGRESS,
6842 &src,
6843 &dst,
6844 &settings,
6845 &DO_PRESERVE_SETTINGS,
6846 false,
6847 )
6848 .await?;
6849 assert!(!dst.join("keep.log").exists());
6850 Ok(())
6851 }
6852
6853 #[tokio::test]
6854 #[traced_test]
6855 async fn delete_dry_run_reports_without_removing() -> Result<(), anyhow::Error> {
6856 let tmp_dir = testutils::setup_test_dir().await?;
6857 let test_path = tmp_dir.as_path();
6858 let src = test_path.join("foo");
6859 let dst = test_path.join("bar");
6860 copy(
6861 &PROGRESS,
6862 &src,
6863 &dst,
6864 &settings_with_delete(None),
6865 &DO_PRESERVE_SETTINGS,
6866 false,
6867 )
6868 .await?;
6869 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
6870
6871 let mut settings = settings_with_delete(delete_on());
6872 settings.dry_run = Some(crate::config::DryRunMode::Brief);
6873 let summary = copy(
6874 &PROGRESS,
6875 &src,
6876 &dst,
6877 &settings,
6878 &DO_PRESERVE_SETTINGS,
6879 false,
6880 )
6881 .await?;
6882
6883 assert!(
6885 dst.join("stale.txt").exists(),
6886 "dry-run must not remove anything"
6887 );
6888 assert_eq!(summary.rm_summary.files_removed, 1);
6890 Ok(())
6891 }
6892
6893 #[tokio::test]
6894 #[traced_test]
6895 async fn delete_dry_run_does_not_scan_through_named_destination_symlink()
6896 -> Result<(), anyhow::Error> {
6897 let root = testutils::create_temp_dir().await?;
6898 let src = root.join("src");
6899 let outside = root.join("outside");
6900 let dst = root.join("dst");
6901 tokio::fs::create_dir_all(src.join("sub")).await?;
6902 tokio::fs::create_dir_all(outside.join("sub")).await?;
6903 tokio::fs::write(outside.join("sub/stale"), "OUTSIDE").await?;
6904 tokio::fs::symlink(&outside, &dst).await?;
6905 let mut settings = settings_with_delete(delete_on());
6906 settings.dry_run = Some(crate::config::DryRunMode::Brief);
6907 let summary = copy(
6908 &PROGRESS,
6909 &src,
6910 &dst,
6911 &settings,
6912 &NO_PRESERVE_SETTINGS,
6913 false,
6914 )
6915 .await?;
6916 assert_eq!(summary.rm_summary.files_removed, 0);
6917 assert_eq!(
6918 tokio::fs::read_to_string(outside.join("sub/stale")).await?,
6919 "OUTSIDE"
6920 );
6921 Ok(())
6922 }
6923
6924 #[tokio::test]
6925 #[traced_test]
6926 async fn dereferenced_delete_dry_run_scans_its_destination_subtree() -> Result<(), anyhow::Error>
6927 {
6928 let root = testutils::create_temp_dir().await?;
6929 let src = root.join("src");
6930 let target = root.join("target");
6931 let dst = root.join("dst");
6932 tokio::fs::create_dir_all(&src).await?;
6933 tokio::fs::create_dir_all(&target).await?;
6934 tokio::fs::write(target.join("kept"), "SOURCE").await?;
6935 tokio::fs::symlink(&target, src.join("node")).await?;
6936 tokio::fs::create_dir_all(dst.join("node")).await?;
6937 tokio::fs::write(dst.join("node/stale"), "STALE").await?;
6938 let mut settings = settings_with_delete(delete_on());
6939 settings.dry_run = Some(crate::config::DryRunMode::Brief);
6940 settings.dereference = true;
6941
6942 let summary = copy(
6943 &PROGRESS,
6944 &src,
6945 &dst,
6946 &settings,
6947 &NO_PRESERVE_SETTINGS,
6948 false,
6949 )
6950 .await?;
6951
6952 assert_eq!(summary.rm_summary.files_removed, 1);
6953 assert_eq!(summary.rm_summary.directories_removed, 0);
6954 assert!(dst.join("node/stale").exists());
6955 Ok(())
6956 }
6957
6958 #[tokio::test]
6959 #[traced_test]
6960 async fn delete_does_not_prune_when_source_unreadable() -> Result<(), anyhow::Error> {
6961 let tmp_dir = testutils::setup_test_dir().await?;
6962 let test_path = tmp_dir.as_path();
6963 let src = test_path.join("foo");
6964 let dst = test_path.join("bar");
6965 copy(
6966 &PROGRESS,
6967 &src,
6968 &dst,
6969 &settings_with_delete(None),
6970 &DO_PRESERVE_SETTINGS,
6971 false,
6972 )
6973 .await?;
6974 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
6975 let original = tokio::fs::metadata(&src).await?.permissions();
6977 tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
6978
6979 let result = copy(
6980 &PROGRESS,
6981 &src,
6982 &dst,
6983 &settings_with_delete(delete_on()),
6984 &DO_PRESERVE_SETTINGS,
6985 false,
6986 )
6987 .await;
6988
6989 tokio::fs::set_permissions(&src, original).await?;
6991
6992 assert!(result.is_err(), "unreadable source must error");
6993 assert!(
6994 dst.join("stale.txt").exists(),
6995 "destination must not be pruned when source enumeration fails"
6996 );
6997 Ok(())
6998 }
6999
7000 #[tokio::test]
7004 #[traced_test]
7005 async fn overwrite_regular_file_removes_and_recreates_via_fd() -> Result<(), anyhow::Error> {
7006 let tmp_dir = testutils::create_temp_dir().await?;
7007 let test_path = tmp_dir.as_path();
7008 let src_file = test_path.join("src.txt");
7009 let dst_file = test_path.join("dst.txt");
7010 tokio::fs::write(&src_file, "fresh source content").await?;
7012 tokio::fs::write(&dst_file, "old").await?;
7013 let summary = copy(
7014 &PROGRESS,
7015 &src_file,
7016 &dst_file,
7017 &Settings {
7018 dereference: false,
7019 fail_early: false,
7020 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
7022 size: true,
7023 mtime: true,
7024 ..Default::default()
7025 },
7026 overwrite_filter: None,
7027 ignore_existing: false,
7028 chunk_size: 0,
7029 skip_specials: false,
7030 remote_copy_buffer_size: 0,
7031 filter: None,
7032 dry_run: None,
7033 delete: None,
7034 },
7035 &NO_PRESERVE_SETTINGS,
7036 false,
7037 )
7038 .await?;
7039 assert_eq!(summary.files_copied, 1);
7041 assert_eq!(summary.rm_summary.files_removed, 1);
7042 assert_eq!(summary.rm_summary.symlinks_removed, 0);
7043 assert_eq!(summary.rm_summary.directories_removed, 0);
7044 let content = tokio::fs::read_to_string(&dst_file).await?;
7045 assert_eq!(content, "fresh source content");
7046 Ok(())
7047 }
7048
7049 #[tokio::test]
7052 #[traced_test]
7053 async fn admitted_file_replacement_matching_destination_does_not_force_work()
7054 -> Result<(), anyhow::Error> {
7055 let root = testutils::create_temp_dir().await?;
7056 let src = root.join("source");
7057 let dst = root.join("destination");
7058 tokio::fs::write(&src, "SELECTED-A").await?;
7059 tokio::fs::hard_link(&src, &dst).await?;
7060
7061 let admission = testutils::AdmissionLimit::new().await;
7062 admission.set_files_in_flight(1);
7063 let src_parent = Arc::new(
7064 Dir::open_parent_dir(&root, congestion::Side::Source)
7065 .await?
7066 .into_tree(),
7067 );
7068 let dst_parent = Arc::new(
7069 Dir::open_parent_dir(&root, congestion::Side::Destination)
7070 .await?
7071 .into_tree(),
7072 );
7073 let selected = src_parent.child(std::ffi::OsStr::new("source")).await?;
7074 assert_eq!(selected.kind(), EntryKind::File);
7075 let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7076 let entry = AdmittedEntry::new(selected, permit);
7077
7078 tokio::fs::remove_file(&src).await?;
7079 tokio::fs::write(&src, "CURRENT-BY-NAME-REPLACEMENT").await?;
7080 let mut settings = settings_with_delete(None);
7081 settings.overwrite = true;
7082 settings.overwrite_compare = filecmp::MetadataCmpSettings {
7083 size: true,
7084 ..Default::default()
7085 };
7086
7087 let summary = admission
7088 .run_with_timeout(
7089 std::time::Duration::from_secs(20),
7090 copy_child(
7091 &PROGRESS,
7092 &src_parent,
7093 Some(&dst_parent),
7094 std::ffi::OsStr::new("source"),
7095 &src,
7096 &dst,
7097 std::path::Path::new(""),
7098 &settings,
7099 &NO_PRESERVE_SETTINGS,
7100 false,
7101 DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7102 CopyEntryAdmission::Admitted(entry),
7103 ),
7104 )
7105 .await
7106 .context("admitted replacement copy did not finish at a limit of one")??;
7107 let returned = admission
7108 .run_with_timeout(
7109 std::time::Duration::from_secs(20),
7110 throttle::open_file_permit(),
7111 )
7112 .await
7113 .context("admitted replacement copy did not return open-file capacity")?;
7114 drop(returned);
7115
7116 assert_eq!(summary.files_unchanged, 1);
7117 assert_eq!(summary.files_copied, 0);
7118 assert_eq!(summary.rm_summary.files_removed, 0);
7119 assert_eq!(tokio::fs::read_to_string(&dst).await?, "SELECTED-A");
7120 Ok(())
7121 }
7122
7123 #[tokio::test]
7126 #[traced_test]
7127 async fn actionable_admitted_file_replacement_replans_against_opened_payload()
7128 -> Result<(), anyhow::Error> {
7129 let root = testutils::create_temp_dir().await?;
7130 let src = root.join("source");
7131 let dst = root.join("destination");
7132 tokio::fs::write(&src, "SELECTED-A").await?;
7133 tokio::fs::write(&dst, "KEEP-ME").await?;
7134
7135 let admission = testutils::AdmissionLimit::new().await;
7136 admission.set_files_in_flight(1);
7137 let src_parent = Arc::new(
7138 Dir::open_parent_dir(&root, congestion::Side::Source)
7139 .await?
7140 .into_tree(),
7141 );
7142 let dst_parent = Arc::new(
7143 Dir::open_parent_dir(&root, congestion::Side::Destination)
7144 .await?
7145 .into_tree(),
7146 );
7147 let selected = src_parent.child(std::ffi::OsStr::new("source")).await?;
7148 assert_eq!(selected.kind(), EntryKind::File);
7149 let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7150 let entry = AdmittedEntry::new(selected, permit);
7151
7152 tokio::fs::remove_file(&src).await?;
7153 tokio::fs::write(&src, "PAYLOAD").await?;
7154 let mut settings = settings_with_delete(None);
7155 settings.overwrite = true;
7156 settings.overwrite_compare = filecmp::MetadataCmpSettings {
7157 size: true,
7158 ..Default::default()
7159 };
7160
7161 let summary = admission
7162 .run_with_timeout(
7163 std::time::Duration::from_secs(20),
7164 copy_child(
7165 &PROGRESS,
7166 &src_parent,
7167 Some(&dst_parent),
7168 std::ffi::OsStr::new("source"),
7169 &src,
7170 &dst,
7171 std::path::Path::new(""),
7172 &settings,
7173 &NO_PRESERVE_SETTINGS,
7174 false,
7175 DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7176 CopyEntryAdmission::Admitted(entry),
7177 ),
7178 )
7179 .await
7180 .context("actionable admitted replacement did not finish at a limit of one")??;
7181 let returned = admission
7182 .run_with_timeout(
7183 std::time::Duration::from_secs(20),
7184 throttle::open_file_permit(),
7185 )
7186 .await
7187 .context("actionable admitted replacement did not return open-file capacity")?;
7188 drop(returned);
7189
7190 assert_eq!(summary.files_unchanged, 1);
7191 assert_eq!(summary.files_copied, 0);
7192 assert_eq!(summary.rm_summary.files_removed, 0);
7193 assert_eq!(tokio::fs::read_to_string(&dst).await?, "KEEP-ME");
7194 Ok(())
7195 }
7196
7197 #[tokio::test]
7202 #[traced_test]
7203 async fn dry_run_previews_an_included_unreadable_regular_file() -> Result<(), anyhow::Error> {
7204 let root = testutils::create_temp_dir().await?;
7205 let src = root.join("source");
7206 let dst = root.join("destination");
7207 let contents = b"metadata is enough";
7208 tokio::fs::write(&src, contents).await?;
7209 tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
7210 let mut filter = crate::filter::FilterSettings::new();
7211 filter.add_include("source")?;
7212 let result = copy(
7213 &PROGRESS,
7214 &src,
7215 &dst,
7216 &Settings {
7217 filter: Some(filter),
7218 dry_run: Some(DryRunMode::Brief),
7219 ..settings_with_delete(None)
7220 },
7221 &NO_PRESERVE_SETTINGS,
7222 false,
7223 )
7224 .await;
7225 tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o600)).await?;
7226 let summary = result?;
7227 assert_eq!(summary.files_copied, 1);
7228 assert_eq!(summary.bytes_copied, contents.len() as u64);
7229 assert!(!dst.exists(), "dry-run must not create the destination");
7230 Ok(())
7231 }
7232
7233 #[tokio::test]
7236 #[traced_test]
7237 async fn identical_overwrite_skips_an_unreadable_regular_file() -> Result<(), anyhow::Error> {
7238 let root = testutils::create_temp_dir().await?;
7239 let src = root.join("source");
7240 let dst = root.join("destination");
7241 tokio::fs::write(&src, "SRC-NEW").await?;
7242 tokio::fs::write(&dst, "KEEP-ME").await?;
7243 tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
7244 let result = copy(
7245 &PROGRESS,
7246 &src,
7247 &dst,
7248 &Settings {
7249 overwrite: true,
7250 overwrite_compare: filecmp::MetadataCmpSettings {
7251 size: true,
7252 ..Default::default()
7253 },
7254 ..settings_with_delete(None)
7255 },
7256 &NO_PRESERVE_SETTINGS,
7257 false,
7258 )
7259 .await;
7260 tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o600)).await?;
7261 let summary = result?;
7262 assert_eq!(summary.files_unchanged, 1);
7263 assert_eq!(summary.files_copied, 0);
7264 assert_eq!(summary.rm_summary.files_removed, 0);
7265 assert_eq!(tokio::fs::read_to_string(&dst).await?, "KEEP-ME");
7266 Ok(())
7267 }
7268
7269 #[tokio::test]
7272 #[traced_test]
7273 async fn ignore_existing_skips_before_reopening_an_admitted_missing_source()
7274 -> Result<(), anyhow::Error> {
7275 let root = testutils::create_temp_dir().await?;
7276 let src = root.join("source");
7277 let dst = root.join("destination");
7278 tokio::fs::write(&src, "SOURCE").await?;
7279 tokio::fs::write(&dst, "PRECIOUS").await?;
7280
7281 let admission = testutils::AdmissionLimit::new().await;
7282 admission.set_files_in_flight(1);
7283 let src_parent = Arc::new(
7284 Dir::open_parent_dir(&root, congestion::Side::Source)
7285 .await?
7286 .into_tree(),
7287 );
7288 let dst_parent = Arc::new(
7289 Dir::open_parent_dir(&root, congestion::Side::Destination)
7290 .await?
7291 .into_tree(),
7292 );
7293 let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7294 let entry = crate::walk::classify_admitted_entry(
7295 &src_parent,
7296 std::ffi::OsStr::new("source"),
7297 permit,
7298 )
7299 .await?;
7300 tokio::fs::remove_file(&src).await?;
7301
7302 let summary = run_with_open_file_cleanup(
7303 &admission,
7304 copy_child(
7305 &PROGRESS,
7306 &src_parent,
7307 Some(&dst_parent),
7308 std::ffi::OsStr::new("source"),
7309 &src,
7310 &dst,
7311 std::path::Path::new(""),
7312 &Settings {
7313 ignore_existing: true,
7314 ..settings_with_delete(None)
7315 },
7316 &NO_PRESERVE_SETTINGS,
7317 false,
7318 DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7319 CopyEntryAdmission::Admitted(entry),
7320 ),
7321 )
7322 .await??;
7323
7324 assert_eq!(summary.files_unchanged, 1);
7325 assert_eq!(summary.files_copied, 0);
7326 assert_eq!(tokio::fs::read_to_string(&dst).await?, "PRECIOUS");
7327 Ok(())
7328 }
7329
7330 #[tokio::test]
7332 #[traced_test]
7333 async fn no_overwrite_reports_destination_conflict_before_an_admitted_missing_source()
7334 -> Result<(), anyhow::Error> {
7335 let root = testutils::create_temp_dir().await?;
7336 let src = root.join("source");
7337 let dst = root.join("destination");
7338 tokio::fs::write(&src, "SOURCE").await?;
7339 tokio::fs::write(&dst, "PRECIOUS").await?;
7340
7341 let admission = testutils::AdmissionLimit::new().await;
7342 admission.set_files_in_flight(1);
7343 let src_parent = Arc::new(
7344 Dir::open_parent_dir(&root, congestion::Side::Source)
7345 .await?
7346 .into_tree(),
7347 );
7348 let dst_parent = Arc::new(
7349 Dir::open_parent_dir(&root, congestion::Side::Destination)
7350 .await?
7351 .into_tree(),
7352 );
7353 let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7354 let entry = crate::walk::classify_admitted_entry(
7355 &src_parent,
7356 std::ffi::OsStr::new("source"),
7357 permit,
7358 )
7359 .await?;
7360 tokio::fs::remove_file(&src).await?;
7361
7362 let error = run_with_open_file_cleanup(
7363 &admission,
7364 copy_child(
7365 &PROGRESS,
7366 &src_parent,
7367 Some(&dst_parent),
7368 std::ffi::OsStr::new("source"),
7369 &src,
7370 &dst,
7371 std::path::Path::new(""),
7372 &settings_with_delete(None),
7373 &NO_PRESERVE_SETTINGS,
7374 false,
7375 DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7376 CopyEntryAdmission::Admitted(entry),
7377 ),
7378 )
7379 .await?
7380 .expect_err("an existing destination without --overwrite must fail");
7381
7382 let message = format!("{:#}", &error.source);
7383 assert!(
7384 message.contains("did you intend to specify --overwrite?"),
7385 "destination conflict lost precedence: {message}"
7386 );
7387 assert!(
7388 !message.contains("failed opening src file"),
7389 "source acquisition masked the destination conflict: {message}"
7390 );
7391 assert_eq!(tokio::fs::read_to_string(&dst).await?, "PRECIOUS");
7392 Ok(())
7393 }
7394
7395 #[tokio::test]
7397 #[traced_test]
7398 async fn dry_run_ignore_existing_skips_before_reopening_an_admitted_missing_source()
7399 -> Result<(), anyhow::Error> {
7400 let root = testutils::create_temp_dir().await?;
7401 let src = root.join("source");
7402 let dst = root.join("destination");
7403 tokio::fs::write(&src, "SOURCE").await?;
7404 tokio::fs::write(&dst, "PRECIOUS").await?;
7405
7406 let admission = testutils::AdmissionLimit::new().await;
7407 admission.set_files_in_flight(1);
7408 let src_parent = Arc::new(
7409 Dir::open_parent_dir(&root, congestion::Side::Source)
7410 .await?
7411 .into_tree(),
7412 );
7413 let permit = crate::walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
7414 let entry = crate::walk::classify_admitted_entry(
7415 &src_parent,
7416 std::ffi::OsStr::new("source"),
7417 permit,
7418 )
7419 .await?;
7420 tokio::fs::remove_file(&src).await?;
7421
7422 let summary = run_with_open_file_cleanup(
7423 &admission,
7424 copy_child(
7425 &PROGRESS,
7426 &src_parent,
7427 None,
7428 std::ffi::OsStr::new("source"),
7429 &src,
7430 &dst,
7431 std::path::Path::new(""),
7432 &Settings {
7433 ignore_existing: true,
7434 dry_run: Some(DryRunMode::Brief),
7435 ..settings_with_delete(None)
7436 },
7437 &NO_PRESERVE_SETTINGS,
7438 false,
7439 DeleteScanAnchor::new(&dst, std::path::Path::new("")),
7440 CopyEntryAdmission::Admitted(entry),
7441 ),
7442 )
7443 .await??;
7444
7445 assert_eq!(summary.files_unchanged, 1);
7446 assert_eq!(summary.files_copied, 0);
7447 assert_eq!(tokio::fs::read_to_string(&dst).await?, "PRECIOUS");
7448 Ok(())
7449 }
7450
7451 fn toctou_settings() -> Settings {
7454 Settings {
7455 dereference: false,
7456 fail_early: false,
7457 overwrite: true,
7458 overwrite_compare: filecmp::MetadataCmpSettings {
7459 size: true,
7460 mtime: true,
7461 ..Default::default()
7462 },
7463 overwrite_filter: None,
7464 ignore_existing: false,
7465 chunk_size: 0,
7466 skip_specials: false,
7467 remote_copy_buffer_size: 0,
7468 filter: None,
7469 dry_run: None,
7470 delete: None,
7471 }
7472 }
7473
7474 #[tokio::test]
7480 #[traced_test]
7481 async fn non_dereference_copy_never_canonicalizes() -> Result<(), anyhow::Error> {
7482 let tmp_dir = testutils::create_temp_dir().await?;
7483 let test_path = tmp_dir.as_path();
7484 let src = test_path.join("src");
7485 let dst = test_path.join("dst");
7486 tokio::fs::create_dir(&src).await?;
7487 tokio::fs::write(src.join("real.txt"), "REAL_CONTENT").await?;
7489 tokio::fs::symlink("real.txt", src.join("link")).await?;
7490 let summary = copy(
7491 &PROGRESS,
7492 &src,
7493 &dst,
7494 &toctou_settings(),
7495 &NO_PRESERVE_SETTINGS,
7496 false,
7497 )
7498 .await?;
7499 assert_eq!(summary.files_copied, 1);
7500 assert_eq!(
7501 summary.symlinks_created, 1,
7502 "symlink must be copied as a symlink"
7503 );
7504 let link_md = tokio::fs::symlink_metadata(dst.join("link")).await?;
7506 assert!(
7507 link_md.file_type().is_symlink(),
7508 "with dereference == false the source symlink must remain a symlink in the destination"
7509 );
7510 let target = tokio::fs::read_link(dst.join("link")).await?;
7512 assert_eq!(target, std::path::Path::new("real.txt"));
7513 Ok(())
7514 }
7515
7516 fn spawn_file_symlink_swapper(
7522 dir: std::path::PathBuf,
7523 entry_name: &'static str,
7524 sentinel: std::path::PathBuf,
7525 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
7526 ) -> std::thread::JoinHandle<()> {
7527 std::thread::spawn(move || {
7528 let entry = dir.join(entry_name);
7529 let staged_real = dir.join("__staged_real");
7530 let staged_link = dir.join("__staged_link");
7531 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
7532 let _ = std::fs::remove_file(&staged_real);
7534 if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
7535 continue;
7536 }
7537 let _ = std::fs::rename(&staged_real, &entry);
7538 let _ = std::fs::remove_file(&staged_link);
7540 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
7541 let _ = std::fs::rename(&staged_link, &entry);
7542 }
7543 })
7544 }
7545
7546 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7551 async fn file_symlink_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
7552 let tmp_dir = testutils::create_temp_dir().await?;
7553 let test_path = tmp_dir.as_path();
7554 let sentinel = test_path.join("sentinel_secret");
7556 tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
7557 let src = test_path.join("src");
7558 let sub = src.join("sub");
7559 tokio::fs::create_dir(&src).await?;
7560 tokio::fs::create_dir(&sub).await?;
7561 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
7562
7563 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7564 let swapper =
7565 spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
7566
7567 let settings = toctou_settings();
7568 let mut caught_swaps = 0usize;
7569 let mut copied_real = 0usize;
7570 for i in 0..200 {
7571 let dst = test_path.join(format!("dst_{i}"));
7572 let result = tokio::time::timeout(
7573 std::time::Duration::from_secs(30),
7574 copy(
7575 &PROGRESS,
7576 &src,
7577 &dst,
7578 &settings,
7579 &NO_PRESERVE_SETTINGS,
7580 false,
7581 ),
7582 )
7583 .await
7584 .expect("copy must not hang under concurrent swapping");
7585 match result {
7586 Ok(_) => {}
7587 Err(_) => caught_swaps += 1, }
7589 let entry_dst = dst.join("sub").join("entry");
7592 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
7593 && md.file_type().is_file()
7594 {
7595 let content = tokio::fs::read_to_string(&entry_dst).await?;
7596 assert_ne!(
7597 content, "SENTINEL_SECRET_CONTENT",
7598 "iteration {i}: sentinel content leaked into the destination as a regular file"
7599 );
7600 assert_eq!(
7601 content, "REAL_CONTENT",
7602 "iteration {i}: a regular destination file must hold the real content"
7603 );
7604 copied_real += 1;
7605 }
7606 let _ = tokio::fs::remove_dir_all(&dst).await;
7607 }
7608
7609 stop.store(true, std::sync::atomic::Ordering::Relaxed);
7610 swapper.join().expect("swapper thread panicked");
7611 tracing::info!("file/symlink swap: caught_swaps={caught_swaps}, copied_real={copied_real}");
7615 assert!(
7616 caught_swaps + copied_real > 0,
7617 "expected at least one observable outcome across 200 iterations"
7618 );
7619 Ok(())
7620 }
7621
7622 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7627 async fn intermediate_dir_swap_never_follows_symlink() -> Result<(), anyhow::Error> {
7628 let tmp_dir = testutils::create_temp_dir().await?;
7629 let test_path = tmp_dir.as_path();
7630 let outside = test_path.join("outside_dir");
7632 tokio::fs::create_dir(&outside).await?;
7633 tokio::fs::write(outside.join("sentinel.txt"), "SENTINEL_SECRET_CONTENT").await?;
7634 let src = test_path.join("src");
7635 tokio::fs::create_dir(&src).await?;
7636 let real_sub = src.join("sub");
7638 tokio::fs::create_dir(&real_sub).await?;
7639 tokio::fs::write(real_sub.join("real.txt"), "REAL_CONTENT").await?;
7640
7641 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7642 let swapper = {
7643 let src = src.clone();
7644 let outside = outside.clone();
7645 let stop = stop.clone();
7646 std::thread::spawn(move || {
7647 let sub = src.join("sub");
7648 let staged_dir = src.join("__staged_sub_dir");
7649 let staged_link = src.join("__staged_sub_link");
7650 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
7651 let _ = std::fs::remove_dir_all(&staged_dir);
7653 if std::fs::create_dir(&staged_dir).is_ok() {
7654 let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL_CONTENT");
7655 let _ = std::fs::remove_dir_all(&sub);
7659 let _ = std::fs::remove_file(&sub);
7660 let _ = std::fs::rename(&staged_dir, &sub);
7661 }
7662 let _ = std::fs::remove_file(&staged_link);
7664 if std::os::unix::fs::symlink(&outside, &staged_link).is_ok() {
7665 let _ = std::fs::remove_dir_all(&sub);
7666 let _ = std::fs::remove_file(&sub);
7667 let _ = std::fs::rename(&staged_link, &sub);
7668 }
7669 }
7670 })
7671 };
7672
7673 let settings = toctou_settings();
7674 for i in 0..200 {
7675 let dst = test_path.join(format!("dst_{i}"));
7676 let _ = tokio::time::timeout(
7677 std::time::Duration::from_secs(30),
7678 copy(
7679 &PROGRESS,
7680 &src,
7681 &dst,
7682 &settings,
7683 &NO_PRESERVE_SETTINGS,
7684 false,
7685 ),
7686 )
7687 .await
7688 .expect("copy must not hang under concurrent dir swapping");
7689 let sub_dst = dst.join("sub");
7700 if let Ok(sub_md) = tokio::fs::symlink_metadata(&sub_dst).await
7701 && sub_md.file_type().is_dir()
7702 {
7703 let leaked = sub_dst.join("sentinel.txt");
7704 let leaked_is_real_file = tokio::fs::symlink_metadata(&leaked)
7705 .await
7706 .map(|m| m.file_type().is_file())
7707 .unwrap_or(false);
7708 assert!(
7709 !leaked_is_real_file,
7710 "iteration {i}: intermediate-dir symlink was followed; the outside \
7711 sentinel was copied into a real destination directory"
7712 );
7713 let real_dst = sub_dst.join("real.txt");
7715 if let Ok(content) = tokio::fs::read_to_string(&real_dst).await {
7716 assert_eq!(
7717 content, "REAL_CONTENT",
7718 "iteration {i}: copied file must hold real content"
7719 );
7720 }
7721 }
7722 let _ = tokio::fs::remove_dir_all(&dst).await;
7723 }
7724
7725 stop.store(true, std::sync::atomic::Ordering::Relaxed);
7726 swapper.join().expect("dir swapper thread panicked");
7727 Ok(())
7728 }
7729
7730 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7748 async fn dest_nonempty_dir_overwrite_removal_contained_under_intermediate_swap()
7749 -> Result<(), anyhow::Error> {
7750 const N: usize = 8;
7751 let tmp_dir = testutils::create_temp_dir().await?;
7752 let test_path = tmp_dir.as_path();
7753 let out_of_tree = test_path.join("out_of_tree");
7758 let oot_deeper = out_of_tree.join("deeper");
7759 for k in 0..N {
7760 let oot_sub = oot_deeper.join(format!("sub{k}"));
7761 tokio::fs::create_dir_all(&oot_sub).await?;
7762 tokio::fs::write(oot_sub.join("sentinel.txt"), "SENTINEL").await?;
7763 }
7764
7765 let src = test_path.join("src");
7767 let src_deeper = src.join("mid").join("deeper");
7768 tokio::fs::create_dir_all(&src_deeper).await?;
7769 for k in 0..N {
7770 tokio::fs::write(src_deeper.join(format!("sub{k}")), "REAL_FILE_CONTENT").await?;
7771 }
7772
7773 let dst = test_path.join("dst");
7775 tokio::fs::create_dir(&dst).await?;
7776
7777 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7778 let swapper = {
7779 let dst = dst.clone();
7780 let out_of_tree = out_of_tree.clone();
7781 let stop = stop.clone();
7782 std::thread::spawn(move || {
7783 let mid = dst.join("mid");
7784 let staged_dir = dst.join("__staged_mid_dir");
7785 let staged_link = dst.join("__staged_mid_link");
7786 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
7787 let _ = std::fs::remove_dir_all(&staged_dir);
7791 let staged_deeper = staged_dir.join("deeper");
7792 if std::fs::create_dir_all(&staged_deeper).is_ok() {
7793 for k in 0..N {
7794 let s = staged_deeper.join(format!("sub{k}"));
7795 if std::fs::create_dir(&s).is_ok() {
7796 let _ = std::fs::write(s.join("inner.txt"), b"INNER_CONTENT");
7797 }
7798 }
7799 let _ = std::fs::remove_dir_all(&mid);
7800 let _ = std::fs::remove_file(&mid);
7801 let _ = std::fs::rename(&staged_dir, &mid);
7802 }
7803 let _ = std::fs::remove_file(&staged_link);
7805 if std::os::unix::fs::symlink(&out_of_tree, &staged_link).is_ok() {
7806 let _ = std::fs::remove_dir_all(&mid);
7807 let _ = std::fs::remove_file(&mid);
7808 let _ = std::fs::rename(&staged_link, &mid);
7809 }
7810 }
7811 })
7812 };
7813
7814 let settings = toctou_settings();
7815 for i in 0..200 {
7816 let _ = tokio::time::timeout(
7817 std::time::Duration::from_secs(30),
7818 copy(
7819 &PROGRESS,
7820 &src,
7821 &dst,
7822 &settings,
7823 &NO_PRESERVE_SETTINGS,
7824 false,
7825 ),
7826 )
7827 .await
7828 .expect("copy must not hang under concurrent intermediate-component swapping");
7829 for k in 0..N {
7835 assert!(
7836 oot_deeper
7837 .join(format!("sub{k}"))
7838 .join("sentinel.txt")
7839 .exists(),
7840 "iteration {i}: out-of-tree sentinel sub{k} was deleted — the non-empty-\
7841 directory overwrite removal escaped the destination tree through a swapped \
7842 intermediate symlink (path-based rm regression)"
7843 );
7844 }
7845 }
7846
7847 stop.store(true, std::sync::atomic::Ordering::Relaxed);
7848 swapper
7849 .join()
7850 .expect("intermediate-swap swapper thread panicked");
7851 assert!(
7853 oot_deeper.join("sub0").is_dir(),
7854 "out-of-tree sub directory must survive"
7855 );
7856 Ok(())
7857 }
7858
7859 #[tokio::test]
7866 #[traced_test]
7867 async fn nonempty_dir_overwrite_removal_uses_pinned_fd_not_path() -> Result<(), anyhow::Error> {
7868 let tmp_dir = testutils::create_temp_dir().await?;
7869 let test_path = tmp_dir.as_path();
7870
7871 let real_deeper = test_path.join("base").join("realdeeper");
7873 let real_victim = real_deeper.join("victim");
7874 tokio::fs::create_dir_all(&real_victim).await?;
7875 tokio::fs::write(real_victim.join("inner.txt"), "INNER").await?;
7876
7877 let out_of_tree = test_path.join("out_of_tree");
7880 let oot_victim = out_of_tree.join("realdeeper").join("victim");
7881 tokio::fs::create_dir_all(&oot_victim).await?;
7882 tokio::fs::write(oot_victim.join("sentinel.txt"), "SENTINEL").await?;
7883
7884 let mid_link = test_path.join("base").join("mid");
7888 tokio::fs::symlink(&out_of_tree, &mid_link).await?;
7889 let stale_dst_path = mid_link.join("realdeeper").join("victim");
7890
7891 let dst_parent =
7893 Arc::new(Dir::open_root_dir(&real_deeper, false, congestion::Side::Destination).await?);
7894 let victim_name: &OsStr = OsStr::new("victim");
7895 let dst_handle = dst_parent.child(victim_name).await?;
7896 assert_eq!(dst_handle.kind(), EntryKind::Dir);
7897
7898 let settings = toctou_settings();
7899 let summary = remove_existing(
7900 &PROGRESS,
7901 &dst_parent,
7902 victim_name,
7903 &stale_dst_path,
7904 dst_handle.into_removal_snapshot(),
7905 &settings,
7906 )
7907 .await?;
7908 assert_eq!(
7909 summary.directories_removed, 1,
7910 "the real non-empty victim directory should have been removed via the pinned fd"
7911 );
7912
7913 assert!(
7915 !real_victim.exists(),
7916 "the real non-empty victim should have been removed through the pinned parent fd"
7917 );
7918 assert!(
7920 oot_victim.join("sentinel.txt").exists(),
7921 "out-of-tree sentinel was deleted — removal re-resolved the display path through the \
7922 intermediate symlink instead of using the pinned fd (path-based rm regression)"
7923 );
7924 Ok(())
7925 }
7926
7927 #[tokio::test]
7935 async fn copies_into_a_fresh_marked_destination_another_writer_populated()
7936 -> Result<(), anyhow::Error> {
7937 let tmp = testutils::create_temp_dir().await?;
7938 let src = tmp.join("src");
7939 tokio::fs::create_dir(&src).await?;
7940 tokio::fs::create_dir(src.join("sub")).await?;
7941 tokio::fs::write(src.join("sub").join("a.txt"), "a").await?;
7942 let dst = tmp.join("dst");
7944 tokio::fs::create_dir(&dst).await?;
7945 tokio::fs::create_dir(dst.join("sub")).await?;
7946 let summary = copy(
7947 &PROGRESS,
7948 &src,
7949 &dst,
7950 &settings_with_delete(None),
7951 &NO_PRESERVE_SETTINGS,
7952 true, )
7954 .await?;
7955 assert_eq!(summary.directories_created, 0);
7956 assert_eq!(summary.directories_unchanged, 2);
7957 assert_eq!(summary.files_copied, 1);
7958 assert_eq!(
7959 tokio::fs::read_to_string(dst.join("sub").join("a.txt")).await?,
7960 "a"
7961 );
7962 Ok(())
7963 }
7964
7965 #[tokio::test]
7971 async fn stale_fresh_hint_overwrites_preexisting_file() -> Result<(), anyhow::Error> {
7972 let tmp = testutils::create_temp_dir().await?;
7973 let src = tmp.join("src");
7974 tokio::fs::create_dir(&src).await?;
7975 tokio::fs::create_dir(src.join("sub")).await?;
7976 tokio::fs::write(src.join("sub").join("a.txt"), "new").await?;
7977 let dst = tmp.join("dst");
7978 tokio::fs::create_dir(&dst).await?;
7979 tokio::fs::create_dir(dst.join("sub")).await?;
7980 tokio::fs::write(dst.join("sub").join("a.txt"), "old-content-differs").await?;
7981 let summary = copy(
7982 &PROGRESS,
7983 &src,
7984 &dst,
7985 &settings_with_delete(delete_on()), &NO_PRESERVE_SETTINGS,
7987 true, )
7989 .await?;
7990 assert_eq!(summary.files_copied, 1);
7991 assert_eq!(
7992 tokio::fs::read_to_string(dst.join("sub").join("a.txt")).await?,
7993 "new"
7994 );
7995 Ok(())
7996 }
7997
7998 #[tokio::test]
8008 async fn overwrites_a_pre_existing_file_in_a_fresh_marked_destination()
8009 -> Result<(), anyhow::Error> {
8010 let tmp = testutils::create_temp_dir().await?;
8011 let src_file = tmp.join("src.txt");
8012 let dst_file = tmp.join("dst.txt");
8013 tokio::fs::write(&src_file, "fresh source content").await?;
8015 tokio::fs::write(&dst_file, "old").await?;
8016 let summary = copy(
8017 &PROGRESS,
8018 &src_file,
8019 &dst_file,
8020 &Settings {
8021 overwrite: true,
8022 ..settings_with_delete(None)
8023 },
8024 &NO_PRESERVE_SETTINGS,
8025 true, )
8027 .await?;
8028 assert_eq!(summary.files_copied, 1);
8029 assert_eq!(summary.rm_summary.files_removed, 1);
8030 assert_eq!(
8031 tokio::fs::read_to_string(&dst_file).await?,
8032 "fresh source content"
8033 );
8034 Ok(())
8035 }
8036
8037 #[tokio::test]
8038 async fn ignores_a_pre_existing_file_in_a_fresh_marked_destination() -> Result<(), anyhow::Error>
8039 {
8040 let tmp = testutils::create_temp_dir().await?;
8041 let src_file = tmp.join("src.txt");
8042 let dst_file = tmp.join("dst.txt");
8043 tokio::fs::write(&src_file, "fresh source content").await?;
8044 tokio::fs::write(&dst_file, "old").await?;
8045 let summary = copy(
8046 &PROGRESS,
8047 &src_file,
8048 &dst_file,
8049 &Settings {
8050 ignore_existing: true,
8051 ..settings_with_delete(None)
8052 },
8053 &NO_PRESERVE_SETTINGS,
8054 true,
8055 )
8056 .await?;
8057 assert_eq!(summary.files_unchanged, 1);
8058 assert_eq!(summary.files_copied, 0);
8059 assert_eq!(summary.rm_summary.files_removed, 0);
8060 assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "old");
8061 Ok(())
8062 }
8063
8064 #[tokio::test]
8067 async fn skips_an_identical_pre_existing_file_in_a_fresh_marked_destination()
8068 -> Result<(), anyhow::Error> {
8069 let tmp = testutils::create_temp_dir().await?;
8070 let src_file = tmp.join("src.txt");
8071 let dst_file = tmp.join("dst.txt");
8072 tokio::fs::write(&src_file, "abc").await?;
8073 tokio::fs::write(&dst_file, "xyz").await?;
8074 let summary = copy(
8075 &PROGRESS,
8076 &src_file,
8077 &dst_file,
8078 &Settings {
8079 overwrite: true,
8080 overwrite_compare: filecmp::MetadataCmpSettings {
8081 size: true,
8082 ..Default::default()
8083 },
8084 ..settings_with_delete(None)
8085 },
8086 &NO_PRESERVE_SETTINGS,
8087 true,
8088 )
8089 .await?;
8090 assert_eq!(summary.files_unchanged, 1);
8091 assert_eq!(summary.files_copied, 0);
8092 assert_eq!(summary.rm_summary.files_removed, 0);
8093 assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "xyz");
8095 Ok(())
8096 }
8097
8098 #[tokio::test]
8101 async fn reports_missing_overwrite_for_a_pre_existing_file_in_a_fresh_marked_destination()
8102 -> Result<(), anyhow::Error> {
8103 let tmp = testutils::create_temp_dir().await?;
8104 let src_file = tmp.join("src.txt");
8105 let dst_file = tmp.join("dst.txt");
8106 tokio::fs::write(&src_file, "fresh source content").await?;
8107 tokio::fs::write(&dst_file, "old").await?;
8108 let error = copy(
8109 &PROGRESS,
8110 &src_file,
8111 &dst_file,
8112 &settings_with_delete(None), &NO_PRESERVE_SETTINGS,
8114 true,
8115 )
8116 .await
8117 .expect_err("a pre-existing destination without --overwrite must fail");
8118 let message = format!("{:#}", &error.source);
8119 assert!(
8120 message.contains("did you intend to specify --overwrite?"),
8121 "expected the actionable overwrite error, got: {message}"
8122 );
8123 assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "old");
8125 Ok(())
8126 }
8127
8128 #[tokio::test]
8132 async fn ignores_a_pre_existing_file_when_the_destination_is_not_fresh()
8133 -> Result<(), anyhow::Error> {
8134 let tmp = testutils::create_temp_dir().await?;
8135 let src_file = tmp.join("src.txt");
8136 let dst_file = tmp.join("dst.txt");
8137 tokio::fs::write(&src_file, "fresh source content").await?;
8138 tokio::fs::write(&dst_file, "old").await?;
8139 let summary = copy(
8140 &PROGRESS,
8141 &src_file,
8142 &dst_file,
8143 &Settings {
8144 ignore_existing: true,
8145 ..settings_with_delete(None)
8146 },
8147 &NO_PRESERVE_SETTINGS,
8148 false,
8149 )
8150 .await?;
8151 assert_eq!(summary.files_unchanged, 1);
8152 assert_eq!(summary.files_copied, 0);
8153 assert_eq!(tokio::fs::read_to_string(&dst_file).await?, "old");
8154 Ok(())
8155 }
8156
8157 #[tokio::test]
8162 async fn overwrite_keeps_the_destination_when_the_source_cannot_be_opened()
8163 -> Result<(), anyhow::Error> {
8164 let tmp = testutils::create_temp_dir().await?;
8165 let src = tmp.join("src");
8166 tokio::fs::create_dir(&src).await?;
8167 tokio::fs::write(src.join("a.txt"), "new content").await?;
8168 tokio::fs::set_permissions(src.join("a.txt"), std::fs::Permissions::from_mode(0o000))
8171 .await?;
8172 let dst = tmp.join("dst");
8173 tokio::fs::create_dir(&dst).await?;
8174 tokio::fs::write(dst.join("a.txt"), "PRECIOUS").await?;
8176 let error = copy(
8177 &PROGRESS,
8178 &src,
8179 &dst,
8180 &Settings {
8181 overwrite: true,
8182 ..settings_with_delete(None)
8183 },
8184 &NO_PRESERVE_SETTINGS,
8185 false,
8186 )
8187 .await
8188 .expect_err("an unreadable source must fail the copy");
8189 let message = format!("{:#}", &error.source);
8190 assert!(
8191 message.contains("failed opening src file"),
8192 "expected the source open to be the failure; got: {message}"
8193 );
8194 assert_eq!(
8195 tokio::fs::read_to_string(dst.join("a.txt")).await?,
8196 "PRECIOUS",
8197 "the destination must survive a copy that never got far enough to replace it"
8198 );
8199 assert_eq!(
8200 error.summary.rm_summary.files_removed, 0,
8201 "nothing was removed, and the summary must say so"
8202 );
8203 Ok(())
8204 }
8205
8206 #[tokio::test]
8210 async fn executing_a_second_replace_keeps_the_first_removal_in_the_totals()
8211 -> Result<(), anyhow::Error> {
8212 let tmp = testutils::create_temp_dir().await?;
8213 tokio::fs::write(tmp.join("victim.txt"), "0123456").await?; let dst_parent =
8215 Arc::new(Dir::open_root_dir(&tmp, false, congestion::Side::Destination).await?);
8216 let victim: &OsStr = OsStr::new("victim.txt");
8217 let plan = FilePlan::Replace(dst_parent.child(victim).await?.into_removal_snapshot());
8218 let mut copy_summary = Summary {
8220 rm_summary: RmSummary {
8221 files_removed: 1,
8222 bytes_removed: 10,
8223 ..Default::default()
8224 },
8225 ..Default::default()
8226 };
8227 execute_dst_plan(
8228 &PROGRESS,
8229 &dst_parent,
8230 victim,
8231 &tmp.join("victim.txt"),
8232 plan,
8233 &settings_with_delete(None),
8234 &mut copy_summary,
8235 )
8236 .await?;
8237 assert_eq!(copy_summary.rm_summary.files_removed, 2);
8238 assert_eq!(copy_summary.rm_summary.bytes_removed, 17);
8239 assert!(!tmp.join("victim.txt").exists(), "the entry was unlinked");
8240 execute_dst_plan(
8242 &PROGRESS,
8243 &dst_parent,
8244 victim,
8245 &tmp.join("victim.txt"),
8246 FilePlan::Vacant,
8247 &settings_with_delete(None),
8248 &mut copy_summary,
8249 )
8250 .await?;
8251 assert_eq!(copy_summary.rm_summary.files_removed, 2);
8252 Ok(())
8253 }
8254
8255 #[tokio::test]
8258 async fn executing_a_swapped_replace_removes_the_current_entry_and_keeps_earlier_totals()
8259 -> Result<(), anyhow::Error> {
8260 let tmp = testutils::create_temp_dir().await?;
8261 tokio::fs::write(tmp.join("victim.txt"), "original").await?;
8262 let dst_parent =
8263 Arc::new(Dir::open_root_dir(&tmp, false, congestion::Side::Destination).await?);
8264 let victim: &OsStr = OsStr::new("victim.txt");
8265 let plan = FilePlan::Replace(dst_parent.child(victim).await?.into_removal_snapshot());
8266 tokio::fs::remove_file(tmp.join("victim.txt")).await?;
8268 tokio::fs::write(tmp.join("victim.txt"), "someone else's file").await?;
8269 let mut copy_summary = Summary {
8270 rm_summary: RmSummary {
8271 files_removed: 1,
8272 bytes_removed: 10,
8273 ..Default::default()
8274 },
8275 ..Default::default()
8276 };
8277 execute_dst_plan(
8278 &PROGRESS,
8279 &dst_parent,
8280 victim,
8281 &tmp.join("victim.txt"),
8282 plan,
8283 &settings_with_delete(None),
8284 &mut copy_summary,
8285 )
8286 .await?;
8287 assert_eq!(copy_summary.rm_summary.files_removed, 2);
8288 assert_eq!(copy_summary.rm_summary.bytes_removed, 18);
8289 assert!(
8290 !tmp.join("victim.txt").exists(),
8291 "overwrite removes the entry currently occupying the contained slot"
8292 );
8293 Ok(())
8294 }
8295
8296 #[tokio::test]
8297 async fn file_plan_swapped_to_symlink_unlinks_the_slot_and_keeps_file_snapshot_accounting()
8298 -> Result<(), anyhow::Error> {
8299 let tmp = testutils::create_temp_dir().await?;
8300 let dst = tmp.join("dst");
8301 tokio::fs::create_dir(&dst).await?;
8302 let victim_path = dst.join("victim.txt");
8303 let sentinel_path = tmp.join("sentinel.txt");
8304 tokio::fs::write(&victim_path, "original").await?;
8305 tokio::fs::write(&sentinel_path, "keep me").await?;
8306 let dst_parent =
8307 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
8308 let victim: &OsStr = OsStr::new("victim.txt");
8309 let plan = FilePlan::Replace(dst_parent.child(victim).await?.into_removal_snapshot());
8310
8311 tokio::fs::remove_file(&victim_path).await?;
8313 tokio::fs::symlink(&sentinel_path, &victim_path).await?;
8314
8315 let mut copy_summary = Summary::default();
8316 execute_dst_plan(
8317 &PROGRESS,
8318 &dst_parent,
8319 victim,
8320 &victim_path,
8321 plan,
8322 &settings_with_delete(None),
8323 &mut copy_summary,
8324 )
8325 .await?;
8326
8327 assert!(
8328 tokio::fs::symlink_metadata(&victim_path).await.is_err(),
8329 "overwrite must unlink the replacement symlink"
8330 );
8331 assert_eq!(tokio::fs::read_to_string(&sentinel_path).await?, "keep me");
8332 assert_eq!(copy_summary.rm_summary.files_removed, 1);
8333 assert_eq!(copy_summary.rm_summary.symlinks_removed, 0);
8334 assert_eq!(copy_summary.rm_summary.bytes_removed, 8);
8335 Ok(())
8336 }
8337
8338 #[test]
8341 fn a_skip_keeps_an_earlier_removal_in_the_totals() {
8342 let copy_summary = Summary {
8343 rm_summary: RmSummary {
8344 files_removed: 1,
8345 bytes_removed: 10,
8346 ..Default::default()
8347 },
8348 ..Default::default()
8349 };
8350 let skip = Summary {
8351 files_unchanged: 1,
8352 ..Default::default()
8353 };
8354 let summary = skip_summary(skip, ©_summary);
8355 assert_eq!(summary.files_unchanged, 1);
8356 assert_eq!(summary.rm_summary.files_removed, 1);
8357 assert_eq!(summary.rm_summary.bytes_removed, 10);
8358 }
8359
8360 #[tokio::test]
8369 async fn stale_fresh_hint_overwrites_preexisting_symlink() -> Result<(), anyhow::Error> {
8370 let tmp = testutils::create_temp_dir().await?;
8371 let src_link = tmp.join("src.link");
8372 let dst_link = tmp.join("dst.link");
8373 tokio::fs::symlink("target-a", &src_link).await?;
8374 tokio::fs::symlink("target-b", &dst_link).await?;
8375 let summary = copy(
8376 &PROGRESS,
8377 &src_link,
8378 &dst_link,
8379 &Settings {
8380 overwrite: true,
8381 ..settings_with_delete(None)
8382 },
8383 &NO_PRESERVE_SETTINGS,
8384 true, )
8386 .await?;
8387 assert_eq!(summary.symlinks_created, 1);
8388 assert_eq!(summary.rm_summary.symlinks_removed, 1);
8389 assert_eq!(
8390 tokio::fs::read_link(&dst_link).await?.to_str(),
8391 Some("target-a")
8392 );
8393 Ok(())
8394 }
8395
8396 #[tokio::test]
8397 async fn stale_fresh_hint_ignores_preexisting_symlink() -> Result<(), anyhow::Error> {
8398 let tmp = testutils::create_temp_dir().await?;
8399 let src_link = tmp.join("src.link");
8400 let dst_link = tmp.join("dst.link");
8401 tokio::fs::symlink("target-a", &src_link).await?;
8402 tokio::fs::symlink("target-b", &dst_link).await?;
8403 let summary = copy(
8404 &PROGRESS,
8405 &src_link,
8406 &dst_link,
8407 &Settings {
8408 ignore_existing: true,
8409 ..settings_with_delete(None)
8410 },
8411 &NO_PRESERVE_SETTINGS,
8412 true,
8413 )
8414 .await?;
8415 assert_eq!(summary.symlinks_unchanged, 1);
8416 assert_eq!(summary.symlinks_created, 0);
8417 assert_eq!(
8418 tokio::fs::read_link(&dst_link).await?.to_str(),
8419 Some("target-b")
8420 );
8421 Ok(())
8422 }
8423
8424 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8429 async fn fifo_swap_never_hangs_or_copies_pipe() -> Result<(), anyhow::Error> {
8430 let tmp_dir = testutils::create_temp_dir().await?;
8431 let test_path = tmp_dir.as_path();
8432 let src = test_path.join("src");
8433 let sub = src.join("sub");
8434 tokio::fs::create_dir(&src).await?;
8435 tokio::fs::create_dir(&sub).await?;
8436 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
8437
8438 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
8439 let swapper = {
8440 let sub = sub.clone();
8441 let stop = stop.clone();
8442 std::thread::spawn(move || {
8443 let entry = sub.join("entry");
8444 let staged_real = sub.join("__staged_real");
8445 let staged_fifo = sub.join("__staged_fifo");
8446 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
8447 let _ = std::fs::remove_file(&staged_real);
8449 if std::fs::write(&staged_real, b"REAL_CONTENT").is_ok() {
8450 let _ = std::fs::rename(&staged_real, &entry);
8451 }
8452 let _ = std::fs::remove_file(&staged_fifo);
8455 let made = nix::unistd::mkfifo(
8456 &staged_fifo,
8457 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
8458 )
8459 .is_ok();
8460 if made {
8461 let _ = std::fs::rename(&staged_fifo, &entry);
8462 }
8463 }
8464 })
8465 };
8466
8467 let settings = toctou_settings();
8468 for i in 0..200 {
8469 let dst = test_path.join(format!("dst_{i}"));
8470 let _ = tokio::time::timeout(
8473 std::time::Duration::from_secs(30),
8474 copy(
8475 &PROGRESS,
8476 &src,
8477 &dst,
8478 &settings,
8479 &NO_PRESERVE_SETTINGS,
8480 false,
8481 ),
8482 )
8483 .await
8484 .expect("copy must not hang when an entry is swapped to a FIFO");
8485 let entry_dst = dst.join("sub").join("entry");
8489 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await {
8490 use std::os::unix::fs::FileTypeExt as _;
8491 let ft = md.file_type();
8492 assert!(
8493 !ft.is_fifo(),
8494 "iteration {i}: a FIFO was reproduced at the destination"
8495 );
8496 if ft.is_file() {
8497 let content = tokio::fs::read_to_string(&entry_dst).await?;
8498 assert_eq!(
8499 content, "REAL_CONTENT",
8500 "iteration {i}: a regular destination file must hold the real content"
8501 );
8502 }
8503 }
8504 let _ = tokio::fs::remove_dir_all(&dst).await;
8505 }
8506
8507 stop.store(true, std::sync::atomic::Ordering::Relaxed);
8508 swapper.join().expect("fifo swapper thread panicked");
8509 Ok(())
8510 }
8511
8512 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8516 async fn copy_deep_narrow_tree_completes() -> Result<(), anyhow::Error> {
8517 let tmp_dir = testutils::create_temp_dir().await?;
8518 let test_path = tmp_dir.as_path();
8519 let src = test_path.join("src");
8520 const DEPTH: usize = 800;
8521 let mut cur = src.clone();
8523 for _ in 0..DEPTH {
8524 cur = cur.join("a");
8525 }
8526 tokio::fs::create_dir_all(&cur).await?;
8527 tokio::fs::write(cur.join("leaf.txt"), "DEEP_LEAF").await?;
8528
8529 let dst = test_path.join("dst");
8530 let summary = tokio::time::timeout(
8531 std::time::Duration::from_secs(120),
8532 copy(
8533 &PROGRESS,
8534 &src,
8535 &dst,
8536 &toctou_settings(),
8537 &NO_PRESERVE_SETTINGS,
8538 false,
8539 ),
8540 )
8541 .await
8542 .expect("deep-narrow copy must not hang (fd budget / hold-and-wait)")?;
8543 assert_eq!(
8544 summary.files_copied, 1,
8545 "the single leaf file must be copied"
8546 );
8547 assert_eq!(summary.directories_created, DEPTH + 1);
8549 let mut leaf = dst.clone();
8551 for _ in 0..DEPTH {
8552 leaf = leaf.join("a");
8553 }
8554 let content = tokio::fs::read_to_string(leaf.join("leaf.txt")).await?;
8555 assert_eq!(content, "DEEP_LEAF");
8556 Ok(())
8557 }
8558
8559 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8563 async fn copy_wide_tree_completes() -> Result<(), anyhow::Error> {
8564 let tmp_dir = testutils::create_temp_dir().await?;
8565 let test_path = tmp_dir.as_path();
8566 let src = test_path.join("src");
8567 tokio::fs::create_dir(&src).await?;
8568 const WIDTH: usize = 2000;
8569 for i in 0..WIDTH {
8570 tokio::fs::write(src.join(format!("f{i}.txt")), format!("content {i}")).await?;
8571 }
8572 let dst = test_path.join("dst");
8573 let summary = tokio::time::timeout(
8574 std::time::Duration::from_secs(120),
8575 copy(
8576 &PROGRESS,
8577 &src,
8578 &dst,
8579 &toctou_settings(),
8580 &NO_PRESERVE_SETTINGS,
8581 false,
8582 ),
8583 )
8584 .await
8585 .expect("wide copy must not hang (fd exhaustion / permit deadlock)")?;
8586 assert_eq!(summary.files_copied, WIDTH, "every file must be copied");
8587 for i in [0usize, WIDTH / 2, WIDTH - 1] {
8589 let content = tokio::fs::read_to_string(dst.join(format!("f{i}.txt"))).await?;
8590 assert_eq!(content, format!("content {i}"));
8591 }
8592 Ok(())
8593 }
8594
8595 #[derive(Clone, Debug)]
8597 struct CmpMeta {
8598 uid: u32,
8599 gid: u32,
8600 mode: u32,
8601 size: u64,
8602 mtime: i64,
8603 }
8604 impl crate::preserve::Metadata for CmpMeta {
8605 fn uid(&self) -> u32 {
8606 self.uid
8607 }
8608 fn gid(&self) -> u32 {
8609 self.gid
8610 }
8611 fn atime(&self) -> i64 {
8612 0
8613 }
8614 fn atime_nsec(&self) -> i64 {
8615 0
8616 }
8617 fn mtime(&self) -> i64 {
8618 self.mtime
8619 }
8620 fn mtime_nsec(&self) -> i64 {
8621 0
8622 }
8623 fn permissions(&self) -> std::fs::Permissions {
8624 std::os::unix::fs::PermissionsExt::from_mode(self.mode)
8625 }
8626 fn size(&self) -> u64 {
8627 self.size
8628 }
8629 }
8630
8631 fn size_mtime() -> filecmp::MetadataCmpSettings {
8632 filecmp::MetadataCmpSettings {
8633 size: true,
8634 mtime: true,
8635 ..Default::default()
8636 }
8637 }
8638 fn base() -> CmpMeta {
8639 CmpMeta {
8640 uid: 0,
8641 gid: 0,
8642 mode: 0o644,
8643 size: 10,
8644 mtime: 100,
8645 }
8646 }
8647
8648 #[test]
8649 fn skip_send_no_existing_entry_sends() {
8650 let src = base();
8651 let r = skip_unchanged_send(
8652 &size_mtime(),
8653 None,
8654 false,
8655 &src,
8656 None::<ExistingDst<CmpMeta>>,
8657 );
8658 assert!(!r);
8659 }
8660
8661 #[test]
8662 fn skip_send_ignore_existing_skips_any_type() {
8663 let src = base();
8664 let dst = base();
8665 let r = skip_unchanged_send(
8666 &size_mtime(),
8667 None,
8668 true,
8669 &src,
8670 Some(ExistingDst {
8671 meta: &dst,
8672 is_file: false,
8673 }),
8674 );
8675 assert!(r);
8676 }
8677
8678 #[test]
8679 fn skip_send_overwrite_identical_skips() {
8680 let src = base();
8681 let dst = base();
8682 let r = skip_unchanged_send(
8683 &size_mtime(),
8684 None,
8685 false,
8686 &src,
8687 Some(ExistingDst {
8688 meta: &dst,
8689 is_file: true,
8690 }),
8691 );
8692 assert!(r);
8693 }
8694
8695 #[test]
8696 fn skip_send_overwrite_different_size_sends() {
8697 let src = base();
8698 let dst = CmpMeta { size: 11, ..base() };
8699 let r = skip_unchanged_send(
8700 &size_mtime(),
8701 None,
8702 false,
8703 &src,
8704 Some(ExistingDst {
8705 meta: &dst,
8706 is_file: true,
8707 }),
8708 );
8709 assert!(!r);
8710 }
8711
8712 #[test]
8713 fn skip_send_overwrite_different_mtime_sends() {
8714 let src = base();
8715 let dst = CmpMeta {
8716 mtime: 200,
8717 ..base()
8718 };
8719 let r = skip_unchanged_send(
8720 &size_mtime(),
8721 None,
8722 false,
8723 &src,
8724 Some(ExistingDst {
8725 meta: &dst,
8726 is_file: true,
8727 }),
8728 );
8729 assert!(!r);
8730 }
8731
8732 #[test]
8733 fn skip_send_overwrite_non_file_sends() {
8734 let src = base();
8735 let dst = base();
8736 let r = skip_unchanged_send(
8737 &size_mtime(),
8738 None,
8739 false,
8740 &src,
8741 Some(ExistingDst {
8742 meta: &dst,
8743 is_file: false,
8744 }),
8745 );
8746 assert!(!r);
8747 }
8748
8749 #[test]
8750 fn skip_send_filter_newer_skips_when_dest_newer() {
8751 let src = CmpMeta {
8752 mtime: 100,
8753 size: 5,
8754 ..base()
8755 };
8756 let dst = CmpMeta {
8757 mtime: 200,
8758 size: 11,
8759 ..base()
8760 };
8761 let r = skip_unchanged_send(
8762 &size_mtime(),
8763 Some(OverwriteFilter::Newer),
8764 false,
8765 &src,
8766 Some(ExistingDst {
8767 meta: &dst,
8768 is_file: true,
8769 }),
8770 );
8771 assert!(r);
8772 }
8773
8774 #[test]
8775 fn skip_send_filter_newer_sends_when_dest_older() {
8776 let src = CmpMeta {
8777 mtime: 200,
8778 size: 5,
8779 ..base()
8780 };
8781 let dst = CmpMeta {
8782 mtime: 100,
8783 size: 11,
8784 ..base()
8785 };
8786 let r = skip_unchanged_send(
8787 &size_mtime(),
8788 Some(OverwriteFilter::Newer),
8789 false,
8790 &src,
8791 Some(ExistingDst {
8792 meta: &dst,
8793 is_file: true,
8794 }),
8795 );
8796 assert!(!r);
8797 }
8798}