1use std::os::unix::fs::MetadataExt;
2
3use anyhow::{Context, anyhow};
4use async_recursion::async_recursion;
5use throttle::get_file_iops_tokens;
6use tracing::instrument;
7
8use crate::config::DryRunMode;
9use crate::filecmp;
10use crate::preserve;
11use crate::progress;
12use crate::rm;
13use crate::rm::{Settings as RmSettings, Summary as RmSummary};
14use crate::walk::{self, EntryKind};
15
16pub type Error = crate::error::OperationError<Summary>;
19
20#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
25pub enum OverwriteFilter {
26 #[value(name = "newer")]
28 Newer,
29}
30
31impl std::fmt::Display for OverwriteFilter {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 OverwriteFilter::Newer => write!(f, "newer"),
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
45pub struct DeleteSettings {
46 pub delete_excluded: bool,
49}
50
51#[derive(Debug, Clone)]
52pub struct Settings {
53 pub dereference: bool,
54 pub fail_early: bool,
55 pub overwrite: bool,
56 pub overwrite_compare: filecmp::MetadataCmpSettings,
57 pub overwrite_filter: Option<OverwriteFilter>,
58 pub ignore_existing: bool,
59 pub chunk_size: u64,
60 pub skip_specials: bool,
62 pub remote_copy_buffer_size: usize,
68 pub filter: Option<crate::filter::FilterSettings>,
70 pub dry_run: Option<crate::config::DryRunMode>,
72 pub delete: Option<DeleteSettings>,
74}
75
76fn skipped_summary_for(kind: EntryKind) -> Summary {
80 match kind {
81 EntryKind::Dir => Summary {
82 directories_skipped: 1,
83 ..Default::default()
84 },
85 EntryKind::Symlink => Summary {
86 symlinks_skipped: 1,
87 ..Default::default()
88 },
89 EntryKind::File | EntryKind::Special => Summary {
90 files_skipped: 1,
91 ..Default::default()
92 },
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum EmptyDirAction {
100 Keep,
102 Remove,
104 DryRunSkip,
106}
107
108pub fn check_empty_dir_cleanup(
123 filter: Option<&crate::filter::FilterSettings>,
124 we_created_dir: bool,
125 anything_copied: bool,
126 relative_path: &std::path::Path,
127 is_root: bool,
128 is_dry_run: bool,
129) -> EmptyDirAction {
130 if filter.is_none() || anything_copied {
132 return EmptyDirAction::Keep;
133 }
134 if !we_created_dir {
136 return EmptyDirAction::Keep;
137 }
138 if is_root {
140 return EmptyDirAction::Keep;
141 }
142 let f = filter.unwrap();
144 if f.directly_matches_include(relative_path, true) {
146 return EmptyDirAction::Keep;
147 }
148 if is_dry_run {
150 EmptyDirAction::DryRunSkip
151 } else {
152 EmptyDirAction::Remove
153 }
154}
155
156#[instrument]
157pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
158 let ft1 = md1.file_type();
159 let ft2 = md2.file_type();
160 ft1.is_dir() == ft2.is_dir()
161 && ft1.is_file() == ft2.is_file()
162 && ft1.is_symlink() == ft2.is_symlink()
163}
164
165#[instrument(skip(prog_track, src_metadata, settings, preserve))]
166pub async fn copy_file(
167 prog_track: &'static progress::Progress,
168 src: &std::path::Path,
169 dst: &std::path::Path,
170 src_metadata: &std::fs::Metadata,
171 settings: &Settings,
172 preserve: &preserve::Settings,
173 is_fresh: bool,
174) -> Result<Summary, Error> {
175 if !is_fresh
178 && settings.ignore_existing
179 && crate::walk::run_metadata_probed(
180 congestion::Side::Destination,
181 congestion::MetadataOp::Stat,
182 tokio::fs::symlink_metadata(dst),
183 )
184 .await
185 .is_ok()
186 {
187 if let Some(mode) = settings.dry_run {
188 match mode {
189 DryRunMode::Brief => {}
190 DryRunMode::All => println!("skip file {:?}", dst),
191 DryRunMode::Explain => println!("skip file {:?} (destination exists)", dst),
192 }
193 }
194 tracing::debug!("destination exists, skipping (--ignore-existing)");
195 prog_track.files_unchanged.inc();
196 return Ok(Summary {
197 files_unchanged: 1,
198 ..Default::default()
199 });
200 }
201 if settings.dry_run.is_some() {
203 crate::dry_run::report_action("copy", src, Some(dst), "file");
204 return Ok(Summary {
205 files_copied: 1,
206 bytes_copied: src_metadata.len(),
207 ..Default::default()
208 });
209 }
210 tracing::debug!("opening 'src' for reading and 'dst' for writing");
211 get_file_iops_tokens(settings.chunk_size, src_metadata.size()).await;
212 let mut rm_summary = RmSummary::default();
213 if !is_fresh && dst.exists() {
214 if settings.overwrite {
215 tracing::debug!("file exists, check if it's identical");
216 let dst_metadata = crate::walk::run_metadata_probed(
217 congestion::Side::Destination,
218 congestion::MetadataOp::Stat,
219 tokio::fs::symlink_metadata(dst),
220 )
221 .await
222 .with_context(|| format!("failed reading metadata from {:?}", &dst))
223 .map_err(|err| Error::new(err, Default::default()))?;
224 if is_file_type_same(src_metadata, &dst_metadata) {
225 if filecmp::metadata_equal(&settings.overwrite_compare, src_metadata, &dst_metadata)
226 {
227 tracing::debug!("file is identical, skipping");
228 prog_track.files_unchanged.inc();
229 return Ok(Summary {
230 files_unchanged: 1,
231 ..Default::default()
232 });
233 }
234 if let Some(OverwriteFilter::Newer) = settings.overwrite_filter
235 && filecmp::dest_is_newer(src_metadata, &dst_metadata)
236 {
237 tracing::debug!("dest is newer than source, skipping");
238 prog_track.files_unchanged.inc();
239 return Ok(Summary {
240 files_unchanged: 1,
241 ..Default::default()
242 });
243 }
244 }
245 tracing::info!("file is different, removing existing file");
246 rm_summary = rm::rm(
248 prog_track,
249 dst,
250 &RmSettings {
251 fail_early: settings.fail_early,
252 filter: None,
253 dry_run: None,
254 time_filter: None,
255 },
256 )
257 .await
258 .map_err(|err| {
259 let rm_summary = err.summary;
260 let copy_summary = Summary {
261 rm_summary,
262 ..Default::default()
263 };
264 Error::new(err.source, copy_summary)
265 })?;
266 } else {
267 return Err(Error::new(
268 anyhow!(
269 "destination {:?} already exists, did you intend to specify --overwrite?",
270 dst
271 ),
272 Default::default(),
273 ));
274 }
275 }
276 tracing::debug!("copying data");
277 let mut copy_summary = Summary {
278 rm_summary,
279 ..Default::default()
280 };
281 tokio::fs::copy(src, dst)
282 .await
283 .with_context(|| format!("failed copying {:?} to {:?}", &src, &dst))
284 .map_err(|err| Error::new(err, copy_summary))?;
285 prog_track.files_copied.inc();
286 prog_track.bytes_copied.add(src_metadata.len());
287 tracing::debug!("setting permissions");
288 preserve::set_file_metadata(preserve, src_metadata, dst)
289 .await
290 .map_err(|err| Error::new(err, copy_summary))?;
291 copy_summary.bytes_copied += src_metadata.len();
293 copy_summary.files_copied += 1;
294 Ok(copy_summary)
295}
296
297#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
298pub struct Summary {
299 pub bytes_copied: u64,
300 pub files_copied: usize,
301 pub symlinks_created: usize,
302 pub directories_created: usize,
303 pub files_unchanged: usize,
304 pub symlinks_unchanged: usize,
305 pub directories_unchanged: usize,
306 pub files_skipped: usize,
307 pub symlinks_skipped: usize,
308 pub directories_skipped: usize,
309 pub specials_skipped: usize,
310 pub rm_summary: RmSummary,
311}
312
313impl std::ops::Add for Summary {
314 type Output = Self;
315 fn add(self, other: Self) -> Self {
316 Self {
317 bytes_copied: self.bytes_copied + other.bytes_copied,
318 files_copied: self.files_copied + other.files_copied,
319 symlinks_created: self.symlinks_created + other.symlinks_created,
320 directories_created: self.directories_created + other.directories_created,
321 files_unchanged: self.files_unchanged + other.files_unchanged,
322 symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
323 directories_unchanged: self.directories_unchanged + other.directories_unchanged,
324 files_skipped: self.files_skipped + other.files_skipped,
325 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
326 directories_skipped: self.directories_skipped + other.directories_skipped,
327 specials_skipped: self.specials_skipped + other.specials_skipped,
328 rm_summary: self.rm_summary + other.rm_summary,
329 }
330 }
331}
332
333impl std::fmt::Display for Summary {
334 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
335 write!(
336 f,
337 "copy:\n\
338 -----\n\
339 bytes copied: {}\n\
340 files copied: {}\n\
341 symlinks created: {}\n\
342 directories created: {}\n\
343 files unchanged: {}\n\
344 symlinks unchanged: {}\n\
345 directories unchanged: {}\n\
346 files skipped: {}\n\
347 symlinks skipped: {}\n\
348 directories skipped: {}\n\
349 specials skipped: {}\n\
350 \n\
351 delete:\n\
352 -------\n\
353 {}",
354 bytesize::ByteSize(self.bytes_copied),
355 self.files_copied,
356 self.symlinks_created,
357 self.directories_created,
358 self.files_unchanged,
359 self.symlinks_unchanged,
360 self.directories_unchanged,
361 self.files_skipped,
362 self.symlinks_skipped,
363 self.directories_skipped,
364 self.specials_skipped,
365 &self.rm_summary,
366 )
367 }
368}
369
370#[instrument(skip(prog_track, settings, preserve))]
373pub async fn copy(
374 prog_track: &'static progress::Progress,
375 src: &std::path::Path,
376 dst: &std::path::Path,
377 settings: &Settings,
378 preserve: &preserve::Settings,
379 is_fresh: bool,
380) -> Result<Summary, Error> {
381 copy_with_filter_base(
382 prog_track,
383 src,
384 dst,
385 settings,
386 preserve,
387 is_fresh,
388 std::path::Path::new(""),
389 )
390 .await
391}
392
393#[instrument(skip(prog_track, settings, preserve))]
398#[allow(clippy::too_many_arguments)]
399pub async fn copy_with_filter_base(
400 prog_track: &'static progress::Progress,
401 src: &std::path::Path,
402 dst: &std::path::Path,
403 settings: &Settings,
404 preserve: &preserve::Settings,
405 is_fresh: bool,
406 filter_base: &std::path::Path,
407) -> Result<Summary, Error> {
408 if let Some(ref filter) = settings.filter {
410 let src_name = src.file_name().map(std::path::Path::new);
411 if let Some(name) = src_name {
412 let src_metadata = crate::walk::run_metadata_probed(
413 congestion::Side::Source,
414 congestion::MetadataOp::Stat,
415 tokio::fs::symlink_metadata(src),
416 )
417 .await
418 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
419 .map_err(|err| Error::new(err, Default::default()))?;
420 let is_dir = src_metadata.is_dir();
421 let result = if filter_base.as_os_str().is_empty() {
425 filter.should_include_root_item(name, is_dir)
426 } else {
427 filter.should_include(filter_base, is_dir)
428 };
429 match result {
430 crate::filter::FilterResult::Included => {}
431 result => {
432 let kind = EntryKind::from_metadata(&src_metadata);
433 if let Some(mode) = settings.dry_run {
434 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
435 }
436 kind.inc_skipped(prog_track);
437 return Ok(skipped_summary_for(kind));
438 }
439 }
440 }
441 }
442 copy_internal(
443 prog_track,
444 src,
445 dst,
446 src,
447 settings,
448 preserve,
449 is_fresh,
450 None,
451 filter_base,
452 )
453 .await
454}
455
456#[instrument(skip(prog_track, settings, preserve, open_file_guard))]
457#[async_recursion]
458#[allow(clippy::too_many_arguments)]
459async fn copy_internal(
460 prog_track: &'static progress::Progress,
461 src: &std::path::Path,
462 dst: &std::path::Path,
463 source_root: &std::path::Path,
464 settings: &Settings,
465 preserve: &preserve::Settings,
466 mut is_fresh: bool,
467 open_file_guard: Option<throttle::OpenFileGuard>,
468 filter_base: &std::path::Path,
469) -> Result<Summary, Error> {
470 let _ops_guard = prog_track.ops.guard();
471 tracing::debug!("reading source metadata");
472 let src_metadata = crate::walk::run_metadata_probed(
473 congestion::Side::Source,
474 congestion::MetadataOp::Stat,
475 tokio::fs::symlink_metadata(src),
476 )
477 .await
478 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
479 .map_err(|err| Error::new(err, Default::default()))?;
480 if settings.dereference && src_metadata.is_symlink() {
481 debug_assert!(
482 open_file_guard.is_none(),
483 "open file guard should not be pre-acquired for symlinks"
484 );
485 let link = crate::walk::run_metadata_probed(
486 congestion::Side::Source,
487 congestion::MetadataOp::Stat,
488 tokio::fs::canonicalize(&src),
489 )
490 .await
491 .with_context(|| format!("failed reading src symlink {:?}", &src))
492 .map_err(|err| Error::new(err, Default::default()))?;
493 return copy(prog_track, &link, dst, settings, preserve, is_fresh).await;
494 }
495 if src_metadata.is_file() {
496 let _guard = match open_file_guard {
498 Some(g) => g,
499 None => throttle::open_file_permit().await,
500 };
501 return copy_file(
502 prog_track,
503 src,
504 dst,
505 &src_metadata,
506 settings,
507 preserve,
508 is_fresh,
509 )
510 .await;
511 }
512 debug_assert!(
513 open_file_guard.is_none(),
514 "open file guard should not be pre-acquired for directories or symlinks"
515 );
516 if src_metadata.is_symlink() {
517 if !is_fresh
520 && settings.ignore_existing
521 && crate::walk::run_metadata_probed(
522 congestion::Side::Destination,
523 congestion::MetadataOp::Stat,
524 tokio::fs::symlink_metadata(dst),
525 )
526 .await
527 .is_ok()
528 {
529 if let Some(mode) = settings.dry_run {
530 match mode {
531 DryRunMode::Brief => {}
532 DryRunMode::All => println!("skip symlink {:?}", dst),
533 DryRunMode::Explain => println!("skip symlink {:?} (destination exists)", dst),
534 }
535 }
536 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
537 prog_track.symlinks_unchanged.inc();
538 return Ok(Summary {
539 symlinks_unchanged: 1,
540 ..Default::default()
541 });
542 }
543 if settings.dry_run.is_some() {
545 crate::dry_run::report_action("copy", src, Some(dst), "symlink");
546 return Ok(Summary {
547 symlinks_created: 1,
548 ..Default::default()
549 });
550 }
551 let mut rm_summary = RmSummary::default();
552 let link = crate::walk::run_metadata_probed(
553 congestion::Side::Source,
554 congestion::MetadataOp::ReadLink,
555 tokio::fs::read_link(src),
556 )
557 .await
558 .with_context(|| format!("failed reading symlink {:?}", &src))
559 .map_err(|err| Error::new(err, Default::default()))?;
560 if let Err(error) = crate::walk::run_metadata_probed(
562 congestion::Side::Destination,
563 congestion::MetadataOp::Symlink,
564 tokio::fs::symlink(&link, dst),
565 )
566 .await
567 {
568 if settings.ignore_existing && error.kind() == std::io::ErrorKind::AlreadyExists {
569 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
570 prog_track.symlinks_unchanged.inc();
571 return Ok(Summary {
572 symlinks_unchanged: 1,
573 ..Default::default()
574 });
575 }
576 if settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
577 let dst_metadata = crate::walk::run_metadata_probed(
578 congestion::Side::Destination,
579 congestion::MetadataOp::Stat,
580 tokio::fs::symlink_metadata(dst),
581 )
582 .await
583 .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
584 .map_err(|err| Error::new(err, Default::default()))?;
585 if is_file_type_same(&src_metadata, &dst_metadata) {
586 let dst_link = crate::walk::run_metadata_probed(
587 congestion::Side::Destination,
588 congestion::MetadataOp::ReadLink,
589 tokio::fs::read_link(dst),
590 )
591 .await
592 .with_context(|| format!("failed reading dst symlink: {:?}", &dst))
593 .map_err(|err| Error::new(err, Default::default()))?;
594 if link == dst_link {
595 tracing::debug!(
596 "'dst' is a symlink and points to the same location as 'src'"
597 );
598 if preserve.symlink.any() {
599 let dst_metadata = crate::walk::run_metadata_probed(
601 congestion::Side::Destination,
602 congestion::MetadataOp::Stat,
603 tokio::fs::symlink_metadata(dst),
604 )
605 .await
606 .with_context(|| {
607 format!("failed reading metadata from dst: {:?}", &dst)
608 })
609 .map_err(|err| Error::new(err, Default::default()))?;
610 if !filecmp::metadata_equal(
611 &settings.overwrite_compare,
612 &src_metadata,
613 &dst_metadata,
614 ) {
615 tracing::debug!("'dst' metadata is different, updating");
616 preserve::set_symlink_metadata(preserve, &src_metadata, dst)
617 .await
618 .map_err(|err| Error::new(err, Default::default()))?;
619 prog_track.symlinks_removed.inc();
620 prog_track.symlinks_created.inc();
621 return Ok(Summary {
622 rm_summary: RmSummary {
623 symlinks_removed: 1,
624 ..Default::default()
625 },
626 symlinks_created: 1,
627 ..Default::default()
628 });
629 }
630 }
631 tracing::debug!("symlink already exists, skipping");
632 prog_track.symlinks_unchanged.inc();
633 return Ok(Summary {
634 symlinks_unchanged: 1,
635 ..Default::default()
636 });
637 }
638 tracing::debug!("'dst' is a symlink but points to a different path, updating");
639 } else {
640 tracing::info!("'dst' is not a symlink, updating");
641 }
642 rm_summary = rm::rm(
643 prog_track,
644 dst,
645 &RmSettings {
646 fail_early: settings.fail_early,
647 filter: None,
648 dry_run: None,
649 time_filter: None,
650 },
651 )
652 .await
653 .map_err(|err| {
654 let rm_summary = err.summary;
655 let copy_summary = Summary {
656 rm_summary,
657 ..Default::default()
658 };
659 Error::new(err.source, copy_summary)
660 })?;
661 crate::walk::run_metadata_probed(
662 congestion::Side::Destination,
663 congestion::MetadataOp::Symlink,
664 tokio::fs::symlink(&link, dst),
665 )
666 .await
667 .with_context(|| format!("failed creating symlink {:?}", &dst))
668 .map_err(|err| {
669 let copy_summary = Summary {
670 rm_summary,
671 ..Default::default()
672 };
673 Error::new(err, copy_summary)
674 })?;
675 } else {
676 return Err(Error::new(
677 anyhow!("failed creating symlink {:?}", &dst),
678 Default::default(),
679 ));
680 }
681 }
682 preserve::set_symlink_metadata(preserve, &src_metadata, dst)
683 .await
684 .map_err(|err| {
685 let copy_summary = Summary {
686 rm_summary,
687 ..Default::default()
688 };
689 Error::new(err, copy_summary)
690 })?;
691 prog_track.symlinks_created.inc();
692 return Ok(Summary {
693 rm_summary,
694 symlinks_created: 1,
695 ..Default::default()
696 });
697 }
698 if !src_metadata.is_dir() {
699 if settings.skip_specials {
700 tracing::debug!(
701 "skipping special file {:?} (type: {:?})",
702 src,
703 src_metadata.file_type()
704 );
705 if let Some(mode) = settings.dry_run {
706 match mode {
707 DryRunMode::Brief => {}
708 DryRunMode::All => println!("skip special {:?}", src),
709 DryRunMode::Explain => {
710 println!(
711 "skip special {:?} (unsupported file type: {:?})",
712 src,
713 src_metadata.file_type()
714 );
715 }
716 }
717 }
718 prog_track.specials_skipped.inc();
719 return Ok(Summary {
720 specials_skipped: 1,
721 ..Default::default()
722 });
723 }
724 return Err(Error::new(
725 anyhow!(
726 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
727 src,
728 dst,
729 src_metadata.file_type()
730 ),
731 Default::default(),
732 ));
733 }
734 if settings.dry_run.is_some() {
736 if settings.ignore_existing
737 && !is_fresh
738 && crate::walk::run_metadata_probed(
739 congestion::Side::Destination,
740 congestion::MetadataOp::Stat,
741 tokio::fs::symlink_metadata(dst),
742 )
743 .await
744 .is_ok()
745 && !dst.is_dir()
746 {
747 if let Some(mode) = settings.dry_run {
749 match mode {
750 DryRunMode::Brief => {}
751 DryRunMode::All => println!("skip dir {:?}", dst),
752 DryRunMode::Explain => {
753 println!("skip dir {:?} (destination exists, not a directory)", dst);
754 }
755 }
756 }
757 return Ok(Summary {
758 directories_unchanged: 1,
759 ..Default::default()
760 });
761 }
762 crate::dry_run::report_action("copy", src, Some(dst), "dir");
763 }
765 tracing::debug!("process contents of 'src' directory");
766 let mut entries = tokio::fs::read_dir(src)
767 .await
768 .with_context(|| format!("cannot open directory {src:?} for reading"))
769 .map_err(|err| Error::new(err, Default::default()))?;
770 let mut copy_summary = if settings.dry_run.is_some() {
772 Summary {
773 directories_created: 1, ..Default::default()
775 }
776 } else if let Err(error) = crate::walk::run_metadata_probed(
777 congestion::Side::Destination,
778 congestion::MetadataOp::MkDir,
779 tokio::fs::create_dir(dst),
780 )
781 .await
782 {
783 assert!(
784 !is_fresh,
785 "unexpected error creating directory: {dst:?}: {error}"
786 );
787 if (settings.overwrite || settings.ignore_existing)
788 && error.kind() == std::io::ErrorKind::AlreadyExists
789 {
790 let dst_metadata = crate::walk::run_metadata_probed(
795 congestion::Side::Destination,
796 congestion::MetadataOp::Stat,
797 tokio::fs::symlink_metadata(dst),
798 )
799 .await
800 .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
801 .map_err(|err| Error::new(err, Default::default()))?;
802 if dst_metadata.is_dir() {
803 tracing::debug!("'dst' is a directory, leaving it as is");
804 prog_track.directories_unchanged.inc();
805 Summary {
806 directories_unchanged: 1,
807 ..Default::default()
808 }
809 } else if settings.ignore_existing {
810 tracing::debug!(
813 "destination exists but is not a directory, skipping subtree (--ignore-existing)"
814 );
815 prog_track.directories_unchanged.inc();
816 return Ok(Summary {
817 directories_unchanged: 1,
818 ..Default::default()
819 });
820 } else {
821 tracing::info!("'dst' is not a directory, removing and creating a new one");
822 let rm_summary = rm::rm(
823 prog_track,
824 dst,
825 &RmSettings {
826 fail_early: settings.fail_early,
827 filter: None,
828 dry_run: None,
829 time_filter: None,
830 },
831 )
832 .await
833 .map_err(|err| {
834 let rm_summary = err.summary;
835 let copy_summary = Summary {
836 rm_summary,
837 ..Default::default()
838 };
839 Error::new(err.source, copy_summary)
840 })?;
841 crate::walk::run_metadata_probed(
842 congestion::Side::Destination,
843 congestion::MetadataOp::MkDir,
844 tokio::fs::create_dir(dst),
845 )
846 .await
847 .with_context(|| format!("cannot create directory {dst:?}"))
848 .map_err(|err| {
849 let copy_summary = Summary {
850 rm_summary,
851 ..Default::default()
852 };
853 Error::new(err, copy_summary)
854 })?;
855 is_fresh = true;
857 prog_track.directories_created.inc();
858 Summary {
859 rm_summary,
860 directories_created: 1,
861 ..Default::default()
862 }
863 }
864 } else {
865 let error = Err::<(), std::io::Error>(error)
866 .with_context(|| format!("cannot create directory {:?}", dst))
867 .unwrap_err();
868 tracing::error!("{:#}", &error);
869 return Err(Error::new(error, Default::default()));
870 }
871 } else {
872 is_fresh = true;
874 prog_track.directories_created.inc();
875 Summary {
876 directories_created: 1,
877 ..Default::default()
878 }
879 };
880 let we_created_this_dir = copy_summary.directories_created == 1;
883 let mut join_set = tokio::task::JoinSet::new();
884 let mut keep_set: std::collections::HashSet<std::ffi::OsString> =
889 std::collections::HashSet::new();
890 let errors = crate::error_collector::ErrorCollector::default();
891 loop {
892 let Some((entry, entry_file_type)) =
893 crate::walk::next_entry_probed(&mut entries, congestion::Side::Source, || {
894 format!("failed traversing src directory {:?}", &src)
895 })
896 .await
897 .map_err(|err| Error::new(err, copy_summary))?
898 else {
899 break;
900 };
901 let entry_path = entry.path();
902 let entry_name = entry_path.file_name().unwrap();
903 let dst_path = dst.join(entry_name);
904 let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
905 let entry_is_dir = entry_kind == EntryKind::Dir;
906 let relative_path = filter_base.join(walk::relative_to_root(&entry_path, source_root));
910 if let Some(skip_result) =
912 walk::should_skip_entry(&settings.filter, &relative_path, entry_is_dir)
913 {
914 if let Some(mode) = settings.dry_run {
915 crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
916 }
917 tracing::debug!("skipping {:?} due to filter", &entry_path);
918 copy_summary = copy_summary + skipped_summary_for(entry_kind);
919 entry_kind.inc_skipped(prog_track);
920 continue;
921 }
922 if settings.delete.is_some() {
926 keep_set.insert(entry_name.to_owned());
927 }
928 if settings.skip_specials && entry_kind == EntryKind::Special {
930 tracing::debug!("skipping special file {:?}", &entry_path);
931 if let Some(mode) = settings.dry_run {
932 match mode {
933 DryRunMode::Brief => {}
934 DryRunMode::All => println!("skip special {:?}", &entry_path),
935 DryRunMode::Explain => {
936 println!(
937 "skip special {:?} (unsupported file type: {:?})",
938 &entry_path,
939 entry_file_type.unwrap()
940 );
941 }
942 }
943 }
944 copy_summary.specials_skipped += 1;
945 prog_track.specials_skipped.inc();
946 continue;
947 }
948 let entry_is_regular_file = entry_file_type.as_ref().is_some_and(|ft| ft.is_file());
956 let open_file_guard = if entry_is_regular_file {
957 Some(throttle::open_file_permit().await)
958 } else {
959 None
960 };
961 let settings = settings.clone();
964 let preserve = *preserve;
965 let source_root = source_root.to_owned();
966 let filter_base = filter_base.to_owned();
967 let do_copy = || async move {
968 copy_internal(
969 prog_track,
970 &entry_path,
971 &dst_path,
972 &source_root,
973 &settings,
974 &preserve,
975 is_fresh,
976 open_file_guard,
977 &filter_base,
978 )
979 .await
980 };
981 join_set.spawn(do_copy());
982 }
983 drop(entries);
986 while let Some(res) = join_set.join_next().await {
987 match res {
988 Ok(result) => match result {
989 Ok(summary) => copy_summary = copy_summary + summary,
990 Err(error) => {
991 tracing::error!("copy: {:?} -> {:?} failed with: {:#}", src, dst, &error);
992 copy_summary = copy_summary + error.summary;
993 if settings.fail_early {
994 return Err(Error::new(error.source, copy_summary));
995 }
996 errors.push(error.source);
997 }
998 },
999 Err(error) => {
1000 if settings.fail_early {
1001 return Err(Error::new(error.into(), copy_summary));
1002 }
1003 errors.push(error.into());
1004 }
1005 }
1006 }
1007 if let Some(delete_settings) = &settings.delete {
1011 if errors.has_errors() {
1012 tracing::warn!(
1016 "skipping --delete pruning of {:?} because the copy reported errors",
1017 dst
1018 );
1019 } else {
1020 let relative_dir = filter_base.join(walk::relative_to_root(src, source_root));
1021 match crate::delete::prune_extraneous(
1022 prog_track,
1023 dst,
1024 &relative_dir,
1025 &keep_set,
1026 settings.filter.as_ref(),
1027 delete_settings,
1028 settings.fail_early,
1029 settings.dry_run,
1030 )
1031 .await
1032 {
1033 Ok(rm_summary) => copy_summary.rm_summary = copy_summary.rm_summary + rm_summary,
1034 Err(err) => {
1035 copy_summary.rm_summary = copy_summary.rm_summary + err.summary;
1036 if settings.fail_early {
1037 return Err(Error::new(err.source, copy_summary));
1038 }
1039 errors.push(err.source);
1040 }
1041 }
1042 }
1043 }
1044 let this_dir_count = usize::from(we_created_this_dir);
1047 let child_dirs_created = copy_summary
1048 .directories_created
1049 .saturating_sub(this_dir_count);
1050 let anything_copied = copy_summary.files_copied > 0
1051 || copy_summary.symlinks_created > 0
1052 || child_dirs_created > 0;
1053 let relative_path = filter_base.join(walk::relative_to_root(src, source_root));
1054 let is_root = src == source_root;
1055 match check_empty_dir_cleanup(
1056 settings.filter.as_ref(),
1057 we_created_this_dir,
1058 anything_copied,
1059 &relative_path,
1060 is_root,
1061 settings.dry_run.is_some(),
1062 ) {
1063 EmptyDirAction::Keep => { }
1064 EmptyDirAction::DryRunSkip => {
1065 tracing::debug!(
1066 "dry-run: directory {:?} would not be created (nothing to copy inside)",
1067 &dst
1068 );
1069 copy_summary.directories_created = 0;
1070 return Ok(copy_summary);
1071 }
1072 EmptyDirAction::Remove => {
1073 tracing::debug!(
1074 "directory {:?} has nothing to copy inside, removing empty directory",
1075 &dst
1076 );
1077 match crate::walk::run_metadata_probed(
1078 congestion::Side::Destination,
1079 congestion::MetadataOp::RmDir,
1080 tokio::fs::remove_dir(dst),
1081 )
1082 .await
1083 {
1084 Ok(()) => {
1085 copy_summary.directories_created = 0;
1086 return Ok(copy_summary);
1087 }
1088 Err(err) => {
1089 tracing::debug!(
1091 "failed to remove empty directory {:?}: {:#}, keeping",
1092 &dst,
1093 &err
1094 );
1095 }
1097 }
1098 }
1099 }
1100 tracing::debug!("set 'dst' directory metadata");
1105 let metadata_result = if settings.dry_run.is_some() {
1106 Ok(()) } else {
1108 preserve::set_dir_metadata(preserve, &src_metadata, dst).await
1109 };
1110 if errors.has_errors() {
1111 if let Err(metadata_err) = metadata_result {
1113 tracing::error!(
1114 "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
1115 src,
1116 dst,
1117 &metadata_err
1118 );
1119 }
1120 return Err(Error::new(errors.into_error().unwrap(), copy_summary));
1122 }
1123 metadata_result.map_err(|err| Error::new(err, copy_summary))?;
1125 Ok(copy_summary)
1126}
1127
1128#[cfg(test)]
1129mod copy_tests {
1130 use crate::testutils;
1131 use anyhow::Context;
1132 use std::os::unix::fs::MetadataExt;
1133 use std::os::unix::fs::PermissionsExt;
1134 use tracing_test::traced_test;
1135
1136 use super::*;
1137
1138 static PROGRESS: std::sync::LazyLock<progress::Progress> =
1139 std::sync::LazyLock::new(progress::Progress::new);
1140 static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1141 std::sync::LazyLock::new(preserve::preserve_none);
1142 static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
1143 std::sync::LazyLock::new(preserve::preserve_all);
1144
1145 fn settings_with_delete(delete: Option<DeleteSettings>) -> Settings {
1146 Settings {
1147 dereference: false,
1148 fail_early: false,
1149 overwrite: delete.is_some(), overwrite_compare: filecmp::MetadataCmpSettings {
1151 size: true,
1152 mtime: true,
1153 ..Default::default()
1154 },
1155 overwrite_filter: None,
1156 ignore_existing: false,
1157 chunk_size: 0,
1158 skip_specials: false,
1159 remote_copy_buffer_size: 0,
1160 filter: None,
1161 dry_run: None,
1162 delete,
1163 }
1164 }
1165
1166 fn delete_on() -> Option<DeleteSettings> {
1167 Some(DeleteSettings {
1168 delete_excluded: false,
1169 })
1170 }
1171
1172 #[tokio::test]
1173 #[traced_test]
1174 async fn delete_protects_skipped_special_name() -> Result<(), anyhow::Error> {
1175 let tmp_dir = testutils::setup_test_dir().await?;
1176 let test_path = tmp_dir.as_path();
1177 let src = test_path.join("src_dir");
1178 let dst = test_path.join("dst_dir");
1179 tokio::fs::create_dir(&src).await?;
1180 tokio::fs::write(src.join("file.txt"), "hello").await?;
1181 nix::unistd::mkfifo(
1183 &src.join("pipe"),
1184 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
1185 )?;
1186 tokio::fs::create_dir(&dst).await?;
1188 tokio::fs::write(dst.join("pipe"), "old").await?;
1189 tokio::fs::write(dst.join("stale.txt"), "junk").await?;
1190
1191 let mut settings = settings_with_delete(delete_on());
1192 settings.skip_specials = true;
1193 let summary = copy(
1194 &PROGRESS,
1195 &src,
1196 &dst,
1197 &settings,
1198 &DO_PRESERVE_SETTINGS,
1199 false,
1200 )
1201 .await?;
1202
1203 assert_eq!(summary.specials_skipped, 1);
1204 assert!(dst.join("file.txt").exists());
1205 assert!(
1206 dst.join("pipe").exists(),
1207 "a destination entry matching a skipped special must not be pruned (it has a source counterpart)"
1208 );
1209 assert!(!dst.join("stale.txt").exists()); Ok(())
1211 }
1212
1213 #[tokio::test]
1214 #[traced_test]
1215 async fn delete_removes_extraneous_destination_entries() -> Result<(), anyhow::Error> {
1216 let tmp_dir = testutils::setup_test_dir().await?;
1217 let test_path = tmp_dir.as_path();
1218 let src = test_path.join("foo");
1219 let dst = test_path.join("bar");
1220 copy(
1222 &PROGRESS,
1223 &src,
1224 &dst,
1225 &settings_with_delete(None),
1226 &DO_PRESERVE_SETTINGS,
1227 false,
1228 )
1229 .await?;
1230 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
1232 tokio::fs::create_dir(dst.join("extra_dir")).await?;
1233 tokio::fs::write(dst.join("extra_dir").join("nested.txt"), b"junk").await?;
1234 let summary = copy(
1236 &PROGRESS,
1237 &src,
1238 &dst,
1239 &settings_with_delete(delete_on()),
1240 &DO_PRESERVE_SETTINGS,
1241 false,
1242 )
1243 .await?;
1244 assert_eq!(summary.rm_summary.files_removed, 2); assert_eq!(summary.rm_summary.directories_removed, 1); assert!(!dst.join("extraneous.txt").exists());
1247 assert!(!dst.join("extra_dir").exists());
1248 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
1249 Ok(())
1250 }
1251
1252 #[tokio::test]
1253 #[traced_test]
1254 async fn delete_prunes_extraneous_at_depth() -> Result<(), anyhow::Error> {
1255 let tmp_dir = testutils::setup_test_dir().await?;
1256 let test_path = tmp_dir.as_path();
1257 let src = test_path.join("foo");
1258 let dst = test_path.join("bar");
1259 copy(
1260 &PROGRESS,
1261 &src,
1262 &dst,
1263 &settings_with_delete(None),
1264 &DO_PRESERVE_SETTINGS,
1265 false,
1266 )
1267 .await?;
1268 let nested = dst.join("bar");
1270 assert!(
1271 nested.is_dir(),
1272 "expected common subdirectory bar/ to exist at destination"
1273 );
1274 tokio::fs::write(nested.join("stale_nested.txt"), b"junk").await?;
1275 let summary = copy(
1276 &PROGRESS,
1277 &src,
1278 &dst,
1279 &settings_with_delete(delete_on()),
1280 &DO_PRESERVE_SETTINGS,
1281 false,
1282 )
1283 .await?;
1284 assert!(
1285 !nested.join("stale_nested.txt").exists(),
1286 "stale entry inside a common subdirectory must be pruned"
1287 );
1288 assert!(summary.rm_summary.files_removed >= 1);
1289 testutils::check_dirs_identical(&src, &dst, testutils::FileEqualityCheck::Basic).await?;
1290 Ok(())
1291 }
1292
1293 #[tokio::test]
1294 #[traced_test]
1295 async fn delete_removes_extraneous_symlink() -> Result<(), anyhow::Error> {
1296 let tmp_dir = testutils::setup_test_dir().await?;
1297 let test_path = tmp_dir.as_path();
1298 let src = test_path.join("foo");
1299 let dst = test_path.join("bar");
1300 copy(
1301 &PROGRESS,
1302 &src,
1303 &dst,
1304 &settings_with_delete(None),
1305 &DO_PRESERVE_SETTINGS,
1306 false,
1307 )
1308 .await?;
1309 tokio::fs::symlink("/nonexistent/target", dst.join("stale_link")).await?;
1311 let summary = copy(
1312 &PROGRESS,
1313 &src,
1314 &dst,
1315 &settings_with_delete(delete_on()),
1316 &DO_PRESERVE_SETTINGS,
1317 false,
1318 )
1319 .await?;
1320 assert!(
1321 tokio::fs::symlink_metadata(dst.join("stale_link"))
1322 .await
1323 .is_err(),
1324 "extraneous symlink must be removed"
1325 );
1326 assert_eq!(summary.rm_summary.symlinks_removed, 1);
1327 Ok(())
1328 }
1329
1330 #[tokio::test]
1331 #[traced_test]
1332 async fn delete_skips_pruning_when_copy_has_errors() -> Result<(), anyhow::Error> {
1333 let tmp_dir = testutils::setup_test_dir().await?;
1334 let test_path = tmp_dir.as_path();
1335 let src = test_path.join("foo");
1336 let dst = test_path.join("bar");
1337 copy(
1339 &PROGRESS,
1340 &src,
1341 &dst,
1342 &settings_with_delete(None),
1343 &DO_PRESERVE_SETTINGS,
1344 false,
1345 )
1346 .await?;
1347 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
1349 let unreadable = src.join("baz");
1353 let original = tokio::fs::metadata(&unreadable).await?.permissions();
1354 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
1355
1356 let result = copy(
1357 &PROGRESS,
1358 &src,
1359 &dst,
1360 &settings_with_delete(delete_on()),
1361 &DO_PRESERVE_SETTINGS,
1362 false,
1363 )
1364 .await;
1365
1366 tokio::fs::set_permissions(&unreadable, original).await?;
1367
1368 assert!(
1369 result.is_err(),
1370 "copy of the unreadable directory should fail"
1371 );
1372 assert!(
1373 dst.join("extraneous.txt").exists(),
1374 "pruning must be skipped when the copy reported errors"
1375 );
1376 Ok(())
1377 }
1378
1379 #[tokio::test]
1380 #[traced_test]
1381 async fn check_basic_copy() -> Result<(), anyhow::Error> {
1382 let tmp_dir = testutils::setup_test_dir().await?;
1383 let test_path = tmp_dir.as_path();
1384 let summary = copy(
1385 &PROGRESS,
1386 &test_path.join("foo"),
1387 &test_path.join("bar"),
1388 &Settings {
1389 dereference: false,
1390 fail_early: false,
1391 overwrite: false,
1392 overwrite_compare: filecmp::MetadataCmpSettings {
1393 size: true,
1394 mtime: true,
1395 ..Default::default()
1396 },
1397 overwrite_filter: None,
1398 ignore_existing: false,
1399 chunk_size: 0,
1400 skip_specials: false,
1401 remote_copy_buffer_size: 0,
1402 filter: None,
1403 dry_run: None,
1404 delete: None,
1405 },
1406 &NO_PRESERVE_SETTINGS,
1407 false,
1408 )
1409 .await?;
1410 assert_eq!(summary.files_copied, 5);
1411 assert_eq!(summary.symlinks_created, 2);
1412 assert_eq!(summary.directories_created, 3);
1413 testutils::check_dirs_identical(
1414 &test_path.join("foo"),
1415 &test_path.join("bar"),
1416 testutils::FileEqualityCheck::Basic,
1417 )
1418 .await?;
1419 Ok(())
1420 }
1421
1422 #[tokio::test]
1423 #[traced_test]
1424 async fn no_read_permission() -> Result<(), anyhow::Error> {
1425 let tmp_dir = testutils::setup_test_dir().await?;
1426 let test_path = tmp_dir.as_path();
1427 let filepaths = vec![
1428 test_path.join("foo").join("0.txt"),
1429 test_path.join("foo").join("baz"),
1430 ];
1431 for fpath in &filepaths {
1432 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
1434 }
1435 match copy(
1436 &PROGRESS,
1437 &test_path.join("foo"),
1438 &test_path.join("bar"),
1439 &Settings {
1440 dereference: false,
1441 fail_early: false,
1442 overwrite: false,
1443 overwrite_compare: filecmp::MetadataCmpSettings {
1444 size: true,
1445 mtime: true,
1446 ..Default::default()
1447 },
1448 overwrite_filter: None,
1449 ignore_existing: false,
1450 chunk_size: 0,
1451 skip_specials: false,
1452 remote_copy_buffer_size: 0,
1453 filter: None,
1454 dry_run: None,
1455 delete: None,
1456 },
1457 &NO_PRESERVE_SETTINGS,
1458 false,
1459 )
1460 .await
1461 {
1462 Ok(_) => panic!("Expected the copy to error!"),
1463 Err(error) => {
1464 tracing::info!("{}", &error);
1465 assert_eq!(error.summary.files_copied, 3);
1476 assert_eq!(error.summary.symlinks_created, 0);
1477 assert_eq!(error.summary.directories_created, 2);
1478 }
1479 }
1480 for fpath in &filepaths {
1482 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
1483 if tokio::fs::symlink_metadata(fpath).await?.is_file() {
1484 tokio::fs::remove_file(fpath).await?;
1485 } else {
1486 tokio::fs::remove_dir_all(fpath).await?;
1487 }
1488 }
1489 testutils::check_dirs_identical(
1490 &test_path.join("foo"),
1491 &test_path.join("bar"),
1492 testutils::FileEqualityCheck::Basic,
1493 )
1494 .await?;
1495 Ok(())
1496 }
1497
1498 #[tokio::test]
1499 #[traced_test]
1500 async fn check_default_mode() -> Result<(), anyhow::Error> {
1501 let tmp_dir = testutils::setup_test_dir().await?;
1502 tokio::fs::set_permissions(
1504 tmp_dir.join("foo").join("0.txt"),
1505 std::fs::Permissions::from_mode(0o700),
1506 )
1507 .await?;
1508 let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
1510 tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
1511 .await?;
1512 let test_path = tmp_dir.as_path();
1513 let summary = copy(
1514 &PROGRESS,
1515 &test_path.join("foo"),
1516 &test_path.join("bar"),
1517 &Settings {
1518 dereference: false,
1519 fail_early: false,
1520 overwrite: false,
1521 overwrite_compare: filecmp::MetadataCmpSettings {
1522 size: true,
1523 mtime: true,
1524 ..Default::default()
1525 },
1526 overwrite_filter: None,
1527 ignore_existing: false,
1528 chunk_size: 0,
1529 skip_specials: false,
1530 remote_copy_buffer_size: 0,
1531 filter: None,
1532 dry_run: None,
1533 delete: None,
1534 },
1535 &NO_PRESERVE_SETTINGS,
1536 false,
1537 )
1538 .await?;
1539 assert_eq!(summary.files_copied, 5);
1540 assert_eq!(summary.symlinks_created, 2);
1541 assert_eq!(summary.directories_created, 3);
1542 tokio::fs::set_permissions(
1544 &exec_sticky_file,
1545 std::fs::Permissions::from_mode(
1546 std::fs::symlink_metadata(&exec_sticky_file)?
1547 .permissions()
1548 .mode()
1549 & 0o0777,
1550 ),
1551 )
1552 .await?;
1553 testutils::check_dirs_identical(
1554 &test_path.join("foo"),
1555 &test_path.join("bar"),
1556 testutils::FileEqualityCheck::Basic,
1557 )
1558 .await?;
1559 Ok(())
1560 }
1561
1562 #[tokio::test]
1563 #[traced_test]
1564 async fn no_write_permission() -> Result<(), anyhow::Error> {
1565 let tmp_dir = testutils::setup_test_dir().await?;
1566 let test_path = tmp_dir.as_path();
1567 let non_exec_dir = test_path.join("foo").join("bogey");
1569 tokio::fs::create_dir(&non_exec_dir).await?;
1570 tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
1571 tokio::fs::set_permissions(
1573 &test_path.join("foo").join("baz"),
1574 std::fs::Permissions::from_mode(0o500),
1575 )
1576 .await?;
1577 tokio::fs::set_permissions(
1579 &test_path.join("foo").join("baz").join("4.txt"),
1580 std::fs::Permissions::from_mode(0o440),
1581 )
1582 .await?;
1583 let summary = copy(
1584 &PROGRESS,
1585 &test_path.join("foo"),
1586 &test_path.join("bar"),
1587 &Settings {
1588 dereference: false,
1589 fail_early: false,
1590 overwrite: false,
1591 overwrite_compare: filecmp::MetadataCmpSettings {
1592 size: true,
1593 mtime: true,
1594 ..Default::default()
1595 },
1596 overwrite_filter: None,
1597 ignore_existing: false,
1598 chunk_size: 0,
1599 skip_specials: false,
1600 remote_copy_buffer_size: 0,
1601 filter: None,
1602 dry_run: None,
1603 delete: None,
1604 },
1605 &NO_PRESERVE_SETTINGS,
1606 false,
1607 )
1608 .await?;
1609 assert_eq!(summary.files_copied, 5);
1610 assert_eq!(summary.symlinks_created, 2);
1611 assert_eq!(summary.directories_created, 4);
1612 testutils::check_dirs_identical(
1613 &test_path.join("foo"),
1614 &test_path.join("bar"),
1615 testutils::FileEqualityCheck::Basic,
1616 )
1617 .await?;
1618 Ok(())
1619 }
1620
1621 #[tokio::test]
1622 #[traced_test]
1623 async fn dereference() -> Result<(), anyhow::Error> {
1624 let tmp_dir = testutils::setup_test_dir().await?;
1625 let test_path = tmp_dir.as_path();
1626 let src1 = &test_path.join("foo").join("bar").join("2.txt");
1628 let src2 = &test_path.join("foo").join("bar").join("3.txt");
1629 let test_mode = 0o440;
1630 for f in [src1, src2] {
1631 tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
1632 }
1633 let summary = copy(
1634 &PROGRESS,
1635 &test_path.join("foo"),
1636 &test_path.join("bar"),
1637 &Settings {
1638 dereference: true, fail_early: false,
1640 overwrite: false,
1641 overwrite_compare: filecmp::MetadataCmpSettings {
1642 size: true,
1643 mtime: true,
1644 ..Default::default()
1645 },
1646 overwrite_filter: None,
1647 ignore_existing: false,
1648 chunk_size: 0,
1649 skip_specials: false,
1650 remote_copy_buffer_size: 0,
1651 filter: None,
1652 dry_run: None,
1653 delete: None,
1654 },
1655 &NO_PRESERVE_SETTINGS,
1656 false,
1657 )
1658 .await?;
1659 assert_eq!(summary.files_copied, 7);
1660 assert_eq!(summary.symlinks_created, 0);
1661 assert_eq!(summary.directories_created, 3);
1662 let dst1 = &test_path.join("bar").join("baz").join("5.txt");
1668 let dst2 = &test_path.join("bar").join("baz").join("6.txt");
1669 for f in [dst1, dst2] {
1670 let metadata = tokio::fs::symlink_metadata(f)
1671 .await
1672 .with_context(|| format!("failed reading metadata from {:?}", &f))?;
1673 assert!(metadata.is_file());
1674 assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
1676 }
1677 Ok(())
1678 }
1679
1680 async fn cp_compare(
1681 cp_args: &[&str],
1682 rcp_settings: &Settings,
1683 preserve: bool,
1684 ) -> Result<(), anyhow::Error> {
1685 let tmp_dir = testutils::setup_test_dir().await?;
1686 let test_path = tmp_dir.as_path();
1687 let cp_output = tokio::process::Command::new("cp")
1689 .args(cp_args)
1690 .arg(test_path.join("foo"))
1691 .arg(test_path.join("bar"))
1692 .output()
1693 .await?;
1694 assert!(cp_output.status.success());
1695 let summary = copy(
1697 &PROGRESS,
1698 &test_path.join("foo"),
1699 &test_path.join("baz"),
1700 rcp_settings,
1701 if preserve {
1702 &DO_PRESERVE_SETTINGS
1703 } else {
1704 &NO_PRESERVE_SETTINGS
1705 },
1706 false,
1707 )
1708 .await?;
1709 if rcp_settings.dereference {
1710 assert_eq!(summary.files_copied, 7);
1711 assert_eq!(summary.symlinks_created, 0);
1712 } else {
1713 assert_eq!(summary.files_copied, 5);
1714 assert_eq!(summary.symlinks_created, 2);
1715 }
1716 assert_eq!(summary.directories_created, 3);
1717 testutils::check_dirs_identical(
1718 &test_path.join("bar"),
1719 &test_path.join("baz"),
1720 if preserve {
1721 testutils::FileEqualityCheck::Timestamp
1722 } else {
1723 testutils::FileEqualityCheck::Basic
1724 },
1725 )
1726 .await?;
1727 Ok(())
1728 }
1729
1730 #[tokio::test]
1731 #[traced_test]
1732 async fn test_cp_compat() -> Result<(), anyhow::Error> {
1733 cp_compare(
1734 &["-r"],
1735 &Settings {
1736 dereference: false,
1737 fail_early: false,
1738 overwrite: false,
1739 overwrite_compare: filecmp::MetadataCmpSettings {
1740 size: true,
1741 mtime: true,
1742 ..Default::default()
1743 },
1744 overwrite_filter: None,
1745 ignore_existing: false,
1746 chunk_size: 0,
1747 skip_specials: false,
1748 remote_copy_buffer_size: 0,
1749 filter: None,
1750 dry_run: None,
1751 delete: None,
1752 },
1753 false,
1754 )
1755 .await?;
1756 Ok(())
1757 }
1758
1759 #[tokio::test]
1760 #[traced_test]
1761 async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
1762 cp_compare(
1763 &["-r", "-p"],
1764 &Settings {
1765 dereference: false,
1766 fail_early: false,
1767 overwrite: false,
1768 overwrite_compare: filecmp::MetadataCmpSettings {
1769 size: true,
1770 mtime: true,
1771 ..Default::default()
1772 },
1773 overwrite_filter: None,
1774 ignore_existing: false,
1775 chunk_size: 0,
1776 skip_specials: false,
1777 remote_copy_buffer_size: 0,
1778 filter: None,
1779 dry_run: None,
1780 delete: None,
1781 },
1782 true,
1783 )
1784 .await?;
1785 Ok(())
1786 }
1787
1788 #[tokio::test]
1789 #[traced_test]
1790 async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
1791 cp_compare(
1792 &["-r", "-L"],
1793 &Settings {
1794 dereference: true,
1795 fail_early: false,
1796 overwrite: false,
1797 overwrite_compare: filecmp::MetadataCmpSettings {
1798 size: true,
1799 mtime: true,
1800 ..Default::default()
1801 },
1802 overwrite_filter: None,
1803 ignore_existing: false,
1804 chunk_size: 0,
1805 skip_specials: false,
1806 remote_copy_buffer_size: 0,
1807 filter: None,
1808 dry_run: None,
1809 delete: None,
1810 },
1811 false,
1812 )
1813 .await?;
1814 Ok(())
1815 }
1816
1817 #[tokio::test]
1818 #[traced_test]
1819 async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
1820 cp_compare(
1821 &["-r", "-p", "-L"],
1822 &Settings {
1823 dereference: true,
1824 fail_early: false,
1825 overwrite: false,
1826 overwrite_compare: filecmp::MetadataCmpSettings {
1827 size: true,
1828 mtime: true,
1829 ..Default::default()
1830 },
1831 overwrite_filter: None,
1832 ignore_existing: false,
1833 chunk_size: 0,
1834 skip_specials: false,
1835 remote_copy_buffer_size: 0,
1836 filter: None,
1837 dry_run: None,
1838 delete: None,
1839 },
1840 true,
1841 )
1842 .await?;
1843 Ok(())
1844 }
1845
1846 async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
1847 let tmp_dir = testutils::setup_test_dir().await?;
1848 let test_path = tmp_dir.as_path();
1849 let summary = copy(
1850 &PROGRESS,
1851 &test_path.join("foo"),
1852 &test_path.join("bar"),
1853 &Settings {
1854 dereference: false,
1855 fail_early: false,
1856 overwrite: false,
1857 overwrite_compare: filecmp::MetadataCmpSettings {
1858 size: true,
1859 mtime: true,
1860 ..Default::default()
1861 },
1862 overwrite_filter: None,
1863 ignore_existing: false,
1864 chunk_size: 0,
1865 skip_specials: false,
1866 remote_copy_buffer_size: 0,
1867 filter: None,
1868 dry_run: None,
1869 delete: None,
1870 },
1871 &DO_PRESERVE_SETTINGS,
1872 false,
1873 )
1874 .await?;
1875 assert_eq!(summary.files_copied, 5);
1876 assert_eq!(summary.symlinks_created, 2);
1877 assert_eq!(summary.directories_created, 3);
1878 Ok(tmp_dir)
1879 }
1880
1881 #[tokio::test]
1882 #[traced_test]
1883 async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
1884 let tmp_dir = setup_test_dir_and_copy().await?;
1885 let output_path = &tmp_dir.join("bar");
1886 {
1887 let summary = rm::rm(
1898 &PROGRESS,
1899 &output_path.join("bar"),
1900 &RmSettings {
1901 fail_early: false,
1902 filter: None,
1903 dry_run: None,
1904 time_filter: None,
1905 },
1906 )
1907 .await?
1908 + rm::rm(
1909 &PROGRESS,
1910 &output_path.join("baz").join("5.txt"),
1911 &RmSettings {
1912 fail_early: false,
1913 filter: None,
1914 dry_run: None,
1915 time_filter: None,
1916 },
1917 )
1918 .await?;
1919 assert_eq!(summary.files_removed, 3);
1920 assert_eq!(summary.symlinks_removed, 1);
1921 assert_eq!(summary.directories_removed, 1);
1922 }
1923 let summary = copy(
1924 &PROGRESS,
1925 &tmp_dir.join("foo"),
1926 output_path,
1927 &Settings {
1928 dereference: false,
1929 fail_early: false,
1930 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1932 size: true,
1933 mtime: true,
1934 ..Default::default()
1935 },
1936 overwrite_filter: None,
1937 ignore_existing: false,
1938 chunk_size: 0,
1939 skip_specials: false,
1940 remote_copy_buffer_size: 0,
1941 filter: None,
1942 dry_run: None,
1943 delete: None,
1944 },
1945 &DO_PRESERVE_SETTINGS,
1946 false,
1947 )
1948 .await?;
1949 assert_eq!(summary.files_copied, 3);
1950 assert_eq!(summary.symlinks_created, 1);
1951 assert_eq!(summary.directories_created, 1);
1952 testutils::check_dirs_identical(
1953 &tmp_dir.join("foo"),
1954 output_path,
1955 testutils::FileEqualityCheck::Timestamp,
1956 )
1957 .await?;
1958 Ok(())
1959 }
1960
1961 #[tokio::test]
1962 #[traced_test]
1963 async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
1964 let tmp_dir = setup_test_dir_and_copy().await?;
1965 let output_path = &tmp_dir.join("bar");
1966 {
1967 let summary = rm::rm(
1978 &PROGRESS,
1979 &output_path.join("bar").join("1.txt"),
1980 &RmSettings {
1981 fail_early: false,
1982 filter: None,
1983 dry_run: None,
1984 time_filter: None,
1985 },
1986 )
1987 .await?
1988 + rm::rm(
1989 &PROGRESS,
1990 &output_path.join("baz"),
1991 &RmSettings {
1992 fail_early: false,
1993 filter: None,
1994 dry_run: None,
1995 time_filter: None,
1996 },
1997 )
1998 .await?;
1999 assert_eq!(summary.files_removed, 2);
2000 assert_eq!(summary.symlinks_removed, 2);
2001 assert_eq!(summary.directories_removed, 1);
2002 }
2003 {
2004 tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
2006 tokio::fs::write(&output_path.join("baz"), "baz").await?;
2008 }
2009 let summary = copy(
2010 &PROGRESS,
2011 &tmp_dir.join("foo"),
2012 output_path,
2013 &Settings {
2014 dereference: false,
2015 fail_early: false,
2016 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2018 size: true,
2019 mtime: true,
2020 ..Default::default()
2021 },
2022 overwrite_filter: None,
2023 ignore_existing: false,
2024 chunk_size: 0,
2025 skip_specials: false,
2026 remote_copy_buffer_size: 0,
2027 filter: None,
2028 dry_run: None,
2029 delete: None,
2030 },
2031 &DO_PRESERVE_SETTINGS,
2032 false,
2033 )
2034 .await?;
2035 assert_eq!(summary.rm_summary.files_removed, 1);
2036 assert_eq!(summary.rm_summary.symlinks_removed, 0);
2037 assert_eq!(summary.rm_summary.directories_removed, 1);
2038 assert_eq!(summary.files_copied, 2);
2039 assert_eq!(summary.symlinks_created, 2);
2040 assert_eq!(summary.directories_created, 1);
2041 testutils::check_dirs_identical(
2042 &tmp_dir.join("foo"),
2043 output_path,
2044 testutils::FileEqualityCheck::Timestamp,
2045 )
2046 .await?;
2047 Ok(())
2048 }
2049
2050 #[tokio::test]
2051 #[traced_test]
2052 async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
2053 let tmp_dir = setup_test_dir_and_copy().await?;
2054 let output_path = &tmp_dir.join("bar");
2055 {
2056 let summary = rm::rm(
2063 &PROGRESS,
2064 &output_path.join("baz").join("4.txt"),
2065 &RmSettings {
2066 fail_early: false,
2067 filter: None,
2068 dry_run: None,
2069 time_filter: None,
2070 },
2071 )
2072 .await?
2073 + rm::rm(
2074 &PROGRESS,
2075 &output_path.join("baz").join("5.txt"),
2076 &RmSettings {
2077 fail_early: false,
2078 filter: None,
2079 dry_run: None,
2080 time_filter: None,
2081 },
2082 )
2083 .await?;
2084 assert_eq!(summary.files_removed, 1);
2085 assert_eq!(summary.symlinks_removed, 1);
2086 assert_eq!(summary.directories_removed, 0);
2087 }
2088 {
2089 tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
2091 tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
2093 }
2094 let summary = copy(
2095 &PROGRESS,
2096 &tmp_dir.join("foo"),
2097 output_path,
2098 &Settings {
2099 dereference: false,
2100 fail_early: false,
2101 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2103 size: true,
2104 mtime: true,
2105 ..Default::default()
2106 },
2107 overwrite_filter: None,
2108 ignore_existing: false,
2109 chunk_size: 0,
2110 skip_specials: false,
2111 remote_copy_buffer_size: 0,
2112 filter: None,
2113 dry_run: None,
2114 delete: None,
2115 },
2116 &DO_PRESERVE_SETTINGS,
2117 false,
2118 )
2119 .await?;
2120 assert_eq!(summary.rm_summary.files_removed, 1);
2121 assert_eq!(summary.rm_summary.symlinks_removed, 1);
2122 assert_eq!(summary.rm_summary.directories_removed, 0);
2123 assert_eq!(summary.files_copied, 1);
2124 assert_eq!(summary.symlinks_created, 1);
2125 assert_eq!(summary.directories_created, 0);
2126 testutils::check_dirs_identical(
2127 &tmp_dir.join("foo"),
2128 output_path,
2129 testutils::FileEqualityCheck::Timestamp,
2130 )
2131 .await?;
2132 Ok(())
2133 }
2134
2135 #[tokio::test]
2136 #[traced_test]
2137 async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
2138 let tmp_dir = setup_test_dir_and_copy().await?;
2139 let output_path = &tmp_dir.join("bar");
2140 {
2141 let summary = rm::rm(
2151 &PROGRESS,
2152 &output_path.join("bar"),
2153 &RmSettings {
2154 fail_early: false,
2155 filter: None,
2156 dry_run: None,
2157 time_filter: None,
2158 },
2159 )
2160 .await?
2161 + rm::rm(
2162 &PROGRESS,
2163 &output_path.join("baz").join("5.txt"),
2164 &RmSettings {
2165 fail_early: false,
2166 filter: None,
2167 dry_run: None,
2168 time_filter: None,
2169 },
2170 )
2171 .await?;
2172 assert_eq!(summary.files_removed, 3);
2173 assert_eq!(summary.symlinks_removed, 1);
2174 assert_eq!(summary.directories_removed, 1);
2175 }
2176 {
2177 tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
2179 tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
2181 }
2182 let summary = copy(
2183 &PROGRESS,
2184 &tmp_dir.join("foo"),
2185 output_path,
2186 &Settings {
2187 dereference: false,
2188 fail_early: false,
2189 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2191 size: true,
2192 mtime: true,
2193 ..Default::default()
2194 },
2195 overwrite_filter: None,
2196 ignore_existing: false,
2197 chunk_size: 0,
2198 skip_specials: false,
2199 remote_copy_buffer_size: 0,
2200 filter: None,
2201 dry_run: None,
2202 delete: None,
2203 },
2204 &DO_PRESERVE_SETTINGS,
2205 false,
2206 )
2207 .await?;
2208 assert_eq!(summary.rm_summary.files_removed, 0);
2209 assert_eq!(summary.rm_summary.symlinks_removed, 1);
2210 assert_eq!(summary.rm_summary.directories_removed, 1);
2211 assert_eq!(summary.files_copied, 3);
2212 assert_eq!(summary.symlinks_created, 1);
2213 assert_eq!(summary.directories_created, 1);
2214 assert_eq!(summary.files_unchanged, 2);
2215 assert_eq!(summary.symlinks_unchanged, 1);
2216 assert_eq!(summary.directories_unchanged, 2);
2217 testutils::check_dirs_identical(
2218 &tmp_dir.join("foo"),
2219 output_path,
2220 testutils::FileEqualityCheck::Timestamp,
2221 )
2222 .await?;
2223 Ok(())
2224 }
2225
2226 #[tokio::test]
2227 #[traced_test]
2228 async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
2229 let tmp_dir = testutils::setup_test_dir().await?;
2230 let test_path = tmp_dir.as_path();
2231 let summary = copy(
2232 &PROGRESS,
2233 &test_path.join("foo"),
2234 &test_path.join("bar"),
2235 &Settings {
2236 dereference: false,
2237 fail_early: false,
2238 overwrite: false,
2239 overwrite_compare: filecmp::MetadataCmpSettings {
2240 size: true,
2241 mtime: true,
2242 ..Default::default()
2243 },
2244 overwrite_filter: None,
2245 ignore_existing: false,
2246 chunk_size: 0,
2247 skip_specials: false,
2248 remote_copy_buffer_size: 0,
2249 filter: None,
2250 dry_run: None,
2251 delete: None,
2252 },
2253 &NO_PRESERVE_SETTINGS, false,
2255 )
2256 .await?;
2257 assert_eq!(summary.files_copied, 5);
2258 assert_eq!(summary.symlinks_created, 2);
2259 assert_eq!(summary.directories_created, 3);
2260 let source_path = &test_path.join("foo");
2261 let output_path = &tmp_dir.join("bar");
2262 tokio::fs::set_permissions(
2264 &source_path.join("bar"),
2265 std::fs::Permissions::from_mode(0o000),
2266 )
2267 .await?;
2268 tokio::fs::set_permissions(
2269 &source_path.join("baz").join("4.txt"),
2270 std::fs::Permissions::from_mode(0o000),
2271 )
2272 .await?;
2273 match copy(
2281 &PROGRESS,
2282 &tmp_dir.join("foo"),
2283 output_path,
2284 &Settings {
2285 dereference: false,
2286 fail_early: false,
2287 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
2289 size: true,
2290 mtime: true,
2291 ..Default::default()
2292 },
2293 overwrite_filter: None,
2294 ignore_existing: false,
2295 chunk_size: 0,
2296 skip_specials: false,
2297 remote_copy_buffer_size: 0,
2298 filter: None,
2299 dry_run: None,
2300 delete: None,
2301 },
2302 &DO_PRESERVE_SETTINGS,
2303 false,
2304 )
2305 .await
2306 {
2307 Ok(_) => panic!("Expected the copy to error!"),
2308 Err(error) => {
2309 tracing::info!("{}", &error);
2310 assert_eq!(error.summary.files_copied, 1);
2311 assert_eq!(error.summary.symlinks_created, 2);
2312 assert_eq!(error.summary.directories_created, 0);
2313 assert_eq!(error.summary.rm_summary.files_removed, 2);
2314 assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
2315 assert_eq!(error.summary.rm_summary.directories_removed, 0);
2316 }
2317 }
2318 Ok(())
2319 }
2320
2321 #[tokio::test]
2322 #[traced_test]
2323 async fn overwrite_filter_newer_skips_when_dest_is_newer() -> Result<(), anyhow::Error> {
2324 let tmp_dir = testutils::create_temp_dir().await?;
2325 let test_path = tmp_dir.as_path();
2326 let src_file = test_path.join("src.txt");
2327 let dst_file = test_path.join("dst.txt");
2328 tokio::fs::write(&dst_file, "newer content").await?;
2330 let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
2332 filetime::set_file_mtime(&dst_file, future_time)?;
2333 tokio::fs::write(&src_file, "older content").await?;
2334 let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
2335 filetime::set_file_mtime(&src_file, past_time)?;
2336 let summary = copy_file(
2337 &PROGRESS,
2338 &src_file,
2339 &dst_file,
2340 &tokio::fs::metadata(&src_file).await?,
2341 &Settings {
2342 dereference: false,
2343 fail_early: false,
2344 overwrite: true,
2345 overwrite_compare: filecmp::MetadataCmpSettings {
2346 size: true,
2347 mtime: true,
2348 ..Default::default()
2349 },
2350 overwrite_filter: Some(OverwriteFilter::Newer),
2351 ignore_existing: false,
2352 chunk_size: 0,
2353 skip_specials: false,
2354 remote_copy_buffer_size: 0,
2355 filter: None,
2356 dry_run: None,
2357 delete: None,
2358 },
2359 &NO_PRESERVE_SETTINGS,
2360 false,
2361 )
2362 .await?;
2363 assert_eq!(summary.files_unchanged, 1);
2364 assert_eq!(summary.files_copied, 0);
2365 let content = tokio::fs::read_to_string(&dst_file).await?;
2367 assert_eq!(content, "newer content");
2368 Ok(())
2369 }
2370
2371 #[tokio::test]
2372 #[traced_test]
2373 async fn overwrite_filter_newer_copies_when_dest_is_older() -> Result<(), anyhow::Error> {
2374 let tmp_dir = testutils::create_temp_dir().await?;
2375 let test_path = tmp_dir.as_path();
2376 let src_file = test_path.join("src.txt");
2377 let dst_file = test_path.join("dst.txt");
2378 tokio::fs::write(&dst_file, "old content").await?;
2380 let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
2381 filetime::set_file_mtime(&dst_file, past_time)?;
2382 tokio::fs::write(&src_file, "new content").await?;
2383 let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
2384 filetime::set_file_mtime(&src_file, future_time)?;
2385 let summary = copy_file(
2386 &PROGRESS,
2387 &src_file,
2388 &dst_file,
2389 &tokio::fs::metadata(&src_file).await?,
2390 &Settings {
2391 dereference: false,
2392 fail_early: false,
2393 overwrite: true,
2394 overwrite_compare: filecmp::MetadataCmpSettings {
2395 size: true,
2396 mtime: true,
2397 ..Default::default()
2398 },
2399 overwrite_filter: Some(OverwriteFilter::Newer),
2400 ignore_existing: false,
2401 chunk_size: 0,
2402 skip_specials: false,
2403 remote_copy_buffer_size: 0,
2404 filter: None,
2405 dry_run: None,
2406 delete: None,
2407 },
2408 &NO_PRESERVE_SETTINGS,
2409 false,
2410 )
2411 .await?;
2412 assert_eq!(summary.files_copied, 1);
2413 assert_eq!(summary.files_unchanged, 0);
2414 let content = tokio::fs::read_to_string(&dst_file).await?;
2416 assert_eq!(content, "new content");
2417 Ok(())
2418 }
2419
2420 #[tokio::test]
2421 #[traced_test]
2422 async fn overwrite_filter_newer_copies_when_same_mtime() -> Result<(), anyhow::Error> {
2423 let tmp_dir = testutils::create_temp_dir().await?;
2424 let test_path = tmp_dir.as_path();
2425 let src_file = test_path.join("src.txt");
2426 let dst_file = test_path.join("dst.txt");
2427 tokio::fs::write(&dst_file, "old").await?;
2429 tokio::fs::write(&src_file, "new content").await?;
2430 let same_time = filetime::FileTime::from_unix_time(1_500_000_000, 0);
2431 filetime::set_file_mtime(&dst_file, same_time)?;
2432 filetime::set_file_mtime(&src_file, same_time)?;
2433 let summary = copy_file(
2434 &PROGRESS,
2435 &src_file,
2436 &dst_file,
2437 &tokio::fs::metadata(&src_file).await?,
2438 &Settings {
2439 dereference: false,
2440 fail_early: false,
2441 overwrite: true,
2442 overwrite_compare: filecmp::MetadataCmpSettings {
2443 size: true,
2444 mtime: true,
2445 ..Default::default()
2446 },
2447 overwrite_filter: Some(OverwriteFilter::Newer),
2448 ignore_existing: false,
2449 chunk_size: 0,
2450 skip_specials: false,
2451 remote_copy_buffer_size: 0,
2452 filter: None,
2453 dry_run: None,
2454 delete: None,
2455 },
2456 &NO_PRESERVE_SETTINGS,
2457 false,
2458 )
2459 .await?;
2460 assert_eq!(summary.files_copied, 1);
2462 assert_eq!(summary.files_unchanged, 0);
2463 let content = tokio::fs::read_to_string(&dst_file).await?;
2464 assert_eq!(content, "new content");
2465 Ok(())
2466 }
2467
2468 #[tokio::test]
2469 #[traced_test]
2470 async fn overwrite_without_filter_copies_when_dest_is_newer() -> Result<(), anyhow::Error> {
2471 let tmp_dir = testutils::create_temp_dir().await?;
2472 let test_path = tmp_dir.as_path();
2473 let src_file = test_path.join("src.txt");
2474 let dst_file = test_path.join("dst.txt");
2475 tokio::fs::write(&dst_file, "newer content").await?;
2477 let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
2478 filetime::set_file_mtime(&dst_file, future_time)?;
2479 tokio::fs::write(&src_file, "older content").await?;
2480 let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
2481 filetime::set_file_mtime(&src_file, past_time)?;
2482 let summary = copy_file(
2483 &PROGRESS,
2484 &src_file,
2485 &dst_file,
2486 &tokio::fs::metadata(&src_file).await?,
2487 &Settings {
2488 dereference: false,
2489 fail_early: false,
2490 overwrite: true,
2491 overwrite_compare: filecmp::MetadataCmpSettings {
2492 size: true,
2493 mtime: true,
2494 ..Default::default()
2495 },
2496 overwrite_filter: None,
2497 ignore_existing: false,
2498 chunk_size: 0,
2499 skip_specials: false,
2500 remote_copy_buffer_size: 0,
2501 filter: None,
2502 dry_run: None,
2503 delete: None,
2504 },
2505 &NO_PRESERVE_SETTINGS,
2506 false,
2507 )
2508 .await?;
2509 assert_eq!(summary.files_copied, 1);
2511 let content = tokio::fs::read_to_string(&dst_file).await?;
2512 assert_eq!(content, "older content");
2513 Ok(())
2514 }
2515
2516 #[tokio::test]
2517 #[traced_test]
2518 async fn ignore_existing_skips_when_dest_exists() -> Result<(), anyhow::Error> {
2519 let tmp_dir = testutils::create_temp_dir().await?;
2520 let test_path = tmp_dir.as_path();
2521 let src_file = test_path.join("src.txt");
2522 let dst_file = test_path.join("dst.txt");
2523 tokio::fs::write(&src_file, "source content").await?;
2524 tokio::fs::write(&dst_file, "dest content").await?;
2525 let summary = copy_file(
2526 &PROGRESS,
2527 &src_file,
2528 &dst_file,
2529 &tokio::fs::metadata(&src_file).await?,
2530 &Settings {
2531 dereference: false,
2532 fail_early: false,
2533 overwrite: false,
2534 overwrite_compare: Default::default(),
2535 overwrite_filter: None,
2536 ignore_existing: true,
2537 chunk_size: 0,
2538 skip_specials: false,
2539 remote_copy_buffer_size: 0,
2540 filter: None,
2541 dry_run: None,
2542 delete: None,
2543 },
2544 &NO_PRESERVE_SETTINGS,
2545 false,
2546 )
2547 .await?;
2548 assert_eq!(summary.files_unchanged, 1);
2549 assert_eq!(summary.files_copied, 0);
2550 let content = tokio::fs::read_to_string(&dst_file).await?;
2552 assert_eq!(content, "dest content");
2553 Ok(())
2554 }
2555
2556 #[tokio::test]
2557 #[traced_test]
2558 async fn ignore_existing_skips_when_dest_is_different_type() -> Result<(), anyhow::Error> {
2559 let tmp_dir = testutils::create_temp_dir().await?;
2560 let test_path = tmp_dir.as_path();
2561 let src_file = test_path.join("src.txt");
2562 let dst_dir = test_path.join("dst.txt");
2563 tokio::fs::write(&src_file, "source content").await?;
2564 tokio::fs::create_dir(&dst_dir).await?;
2566 let summary = copy_file(
2567 &PROGRESS,
2568 &src_file,
2569 &dst_dir,
2570 &tokio::fs::metadata(&src_file).await?,
2571 &Settings {
2572 dereference: false,
2573 fail_early: false,
2574 overwrite: false,
2575 overwrite_compare: Default::default(),
2576 overwrite_filter: None,
2577 ignore_existing: true,
2578 chunk_size: 0,
2579 skip_specials: false,
2580 remote_copy_buffer_size: 0,
2581 filter: None,
2582 dry_run: None,
2583 delete: None,
2584 },
2585 &NO_PRESERVE_SETTINGS,
2586 false,
2587 )
2588 .await?;
2589 assert_eq!(summary.files_unchanged, 1);
2590 assert_eq!(summary.files_copied, 0);
2591 assert!(dst_dir.is_dir());
2593 Ok(())
2594 }
2595
2596 #[tokio::test]
2597 #[traced_test]
2598 async fn ignore_existing_copies_when_dest_missing() -> Result<(), anyhow::Error> {
2599 let tmp_dir = testutils::create_temp_dir().await?;
2600 let test_path = tmp_dir.as_path();
2601 let src_file = test_path.join("src.txt");
2602 let dst_file = test_path.join("dst.txt");
2603 tokio::fs::write(&src_file, "source content").await?;
2604 let summary = copy_file(
2605 &PROGRESS,
2606 &src_file,
2607 &dst_file,
2608 &tokio::fs::metadata(&src_file).await?,
2609 &Settings {
2610 dereference: false,
2611 fail_early: false,
2612 overwrite: false,
2613 overwrite_compare: Default::default(),
2614 overwrite_filter: None,
2615 ignore_existing: true,
2616 chunk_size: 0,
2617 skip_specials: false,
2618 remote_copy_buffer_size: 0,
2619 filter: None,
2620 dry_run: None,
2621 delete: None,
2622 },
2623 &NO_PRESERVE_SETTINGS,
2624 false,
2625 )
2626 .await?;
2627 assert_eq!(summary.files_copied, 1);
2628 let content = tokio::fs::read_to_string(&dst_file).await?;
2629 assert_eq!(content, "source content");
2630 Ok(())
2631 }
2632
2633 #[tokio::test]
2634 #[traced_test]
2635 async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
2636 let tmp_dir = testutils::create_temp_dir().await?;
2638 let test_path = tmp_dir.as_path();
2639 let baz_file = test_path.join("baz_file.txt");
2641 tokio::fs::write(&baz_file, "final content").await?;
2642 let bar_link = test_path.join("bar_link");
2643 let foo_link = test_path.join("foo_link");
2644 tokio::fs::symlink(&baz_file, &bar_link).await?;
2646 tokio::fs::symlink(&bar_link, &foo_link).await?;
2647 let src_dir = test_path.join("src_chain");
2649 tokio::fs::create_dir(&src_dir).await?;
2650 tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
2652 tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
2653 tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
2654 let summary = copy(
2656 &PROGRESS,
2657 &src_dir,
2658 &test_path.join("dst_with_deref"),
2659 &Settings {
2660 dereference: true, fail_early: false,
2662 overwrite: false,
2663 overwrite_compare: filecmp::MetadataCmpSettings {
2664 size: true,
2665 mtime: true,
2666 ..Default::default()
2667 },
2668 overwrite_filter: None,
2669 ignore_existing: false,
2670 chunk_size: 0,
2671 skip_specials: false,
2672 remote_copy_buffer_size: 0,
2673 filter: None,
2674 dry_run: None,
2675 delete: None,
2676 },
2677 &NO_PRESERVE_SETTINGS,
2678 false,
2679 )
2680 .await?;
2681 assert_eq!(summary.files_copied, 3); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1);
2684 let dst_dir = test_path.join("dst_with_deref");
2685 let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
2687 let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
2688 let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
2689 assert_eq!(foo_content, "final content");
2690 assert_eq!(bar_content, "final content");
2691 assert_eq!(baz_content, "final content");
2692 assert!(dst_dir.join("foo").is_file());
2694 assert!(dst_dir.join("bar").is_file());
2695 assert!(dst_dir.join("baz").is_file());
2696 assert!(!dst_dir.join("foo").is_symlink());
2697 assert!(!dst_dir.join("bar").is_symlink());
2698 assert!(!dst_dir.join("baz").is_symlink());
2699 Ok(())
2700 }
2701
2702 #[tokio::test]
2703 #[traced_test]
2704 async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
2705 let tmp_dir = testutils::create_temp_dir().await?;
2706 let test_path = tmp_dir.as_path();
2707 let target_dir = test_path.join("target_dir");
2709 tokio::fs::create_dir(&target_dir).await?;
2710 tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
2711 tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
2713 tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
2714 tokio::fs::set_permissions(
2715 &target_dir.join("file1.txt"),
2716 std::fs::Permissions::from_mode(0o644),
2717 )
2718 .await?;
2719 tokio::fs::set_permissions(
2720 &target_dir.join("file2.txt"),
2721 std::fs::Permissions::from_mode(0o600),
2722 )
2723 .await?;
2724 let dir_symlink = test_path.join("dir_symlink");
2726 tokio::fs::symlink(&target_dir, &dir_symlink).await?;
2727 let summary = copy(
2729 &PROGRESS,
2730 &dir_symlink,
2731 &test_path.join("copied_dir"),
2732 &Settings {
2733 dereference: true, fail_early: false,
2735 overwrite: false,
2736 overwrite_compare: filecmp::MetadataCmpSettings {
2737 size: true,
2738 mtime: true,
2739 ..Default::default()
2740 },
2741 overwrite_filter: None,
2742 ignore_existing: false,
2743 chunk_size: 0,
2744 skip_specials: false,
2745 remote_copy_buffer_size: 0,
2746 filter: None,
2747 dry_run: None,
2748 delete: None,
2749 },
2750 &DO_PRESERVE_SETTINGS,
2751 false,
2752 )
2753 .await?;
2754 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");
2758 assert!(copied_dir.is_dir());
2760 assert!(!copied_dir.is_symlink()); let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
2763 let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
2764 assert_eq!(file1_content, "content1");
2765 assert_eq!(file2_content, "content2");
2766 let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
2768 let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
2769 let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
2770 assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
2771 assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
2772 assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
2773 Ok(())
2774 }
2775
2776 #[tokio::test]
2777 #[traced_test]
2778 async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
2779 let tmp_dir = testutils::create_temp_dir().await?;
2780 let test_path = tmp_dir.as_path();
2781 let file1 = test_path.join("file1.txt");
2783 let file2 = test_path.join("file2.txt");
2784 tokio::fs::write(&file1, "content1").await?;
2785 tokio::fs::write(&file2, "content2").await?;
2786 tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
2787 tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
2788 let symlink1 = test_path.join("symlink1");
2790 let symlink2 = test_path.join("symlink2");
2791 tokio::fs::symlink(&file1, &symlink1).await?;
2792 tokio::fs::symlink(&file2, &symlink2).await?;
2793 let summary1 = copy(
2795 &PROGRESS,
2796 &symlink1,
2797 &test_path.join("copied_file1.txt"),
2798 &Settings {
2799 dereference: true, fail_early: false,
2801 overwrite: false,
2802 overwrite_compare: filecmp::MetadataCmpSettings::default(),
2803 overwrite_filter: None,
2804 ignore_existing: false,
2805 chunk_size: 0,
2806 skip_specials: false,
2807 remote_copy_buffer_size: 0,
2808 filter: None,
2809 dry_run: None,
2810 delete: None,
2811 },
2812 &DO_PRESERVE_SETTINGS, false,
2814 )
2815 .await?;
2816 let summary2 = copy(
2817 &PROGRESS,
2818 &symlink2,
2819 &test_path.join("copied_file2.txt"),
2820 &Settings {
2821 dereference: true,
2822 fail_early: false,
2823 overwrite: false,
2824 overwrite_compare: filecmp::MetadataCmpSettings::default(),
2825 overwrite_filter: None,
2826 ignore_existing: false,
2827 chunk_size: 0,
2828 skip_specials: false,
2829 remote_copy_buffer_size: 0,
2830 filter: None,
2831 dry_run: None,
2832 delete: None,
2833 },
2834 &DO_PRESERVE_SETTINGS,
2835 false,
2836 )
2837 .await?;
2838 assert_eq!(summary1.files_copied, 1);
2839 assert_eq!(summary1.symlinks_created, 0);
2840 assert_eq!(summary2.files_copied, 1);
2841 assert_eq!(summary2.symlinks_created, 0);
2842 let copied1 = test_path.join("copied_file1.txt");
2843 let copied2 = test_path.join("copied_file2.txt");
2844 assert!(copied1.is_file());
2846 assert!(!copied1.is_symlink());
2847 assert!(copied2.is_file());
2848 assert!(!copied2.is_symlink());
2849 let content1 = tokio::fs::read_to_string(&copied1).await?;
2851 let content2 = tokio::fs::read_to_string(&copied2).await?;
2852 assert_eq!(content1, "content1");
2853 assert_eq!(content2, "content2");
2854 let copied1_metadata = tokio::fs::metadata(&copied1).await?;
2856 let copied2_metadata = tokio::fs::metadata(&copied2).await?;
2857 assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
2858 assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
2859 Ok(())
2860 }
2861
2862 #[tokio::test]
2863 #[traced_test]
2864 async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
2865 let tmp_dir = testutils::setup_test_dir().await?;
2866 tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
2868 tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
2870 let summary = copy(
2871 &PROGRESS,
2872 &tmp_dir.join("foo"),
2873 &tmp_dir.join("bar"),
2874 &Settings {
2875 dereference: true, fail_early: false,
2877 overwrite: false,
2878 overwrite_compare: filecmp::MetadataCmpSettings {
2879 size: true,
2880 mtime: true,
2881 ..Default::default()
2882 },
2883 overwrite_filter: None,
2884 ignore_existing: false,
2885 chunk_size: 0,
2886 skip_specials: false,
2887 remote_copy_buffer_size: 0,
2888 filter: None,
2889 dry_run: None,
2890 delete: None,
2891 },
2892 &DO_PRESERVE_SETTINGS,
2893 false,
2894 )
2895 .await?;
2896 assert_eq!(summary.files_copied, 13); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 5);
2899 tokio::process::Command::new("cp")
2901 .args(["-r", "-L"])
2902 .arg(tmp_dir.join("foo"))
2903 .arg(tmp_dir.join("bar-cp"))
2904 .output()
2905 .await?;
2906 testutils::check_dirs_identical(
2907 &tmp_dir.join("bar"),
2908 &tmp_dir.join("bar-cp"),
2909 testutils::FileEqualityCheck::Basic,
2910 )
2911 .await?;
2912 Ok(())
2913 }
2914
2915 mod error_message_tests {
2917 use super::*;
2918
2919 fn get_full_error_message(error: &Error) -> String {
2921 format!("{:#}", error.source)
2922 }
2923
2924 #[tokio::test]
2925 #[traced_test]
2926 async fn test_permission_error_includes_root_cause() -> Result<(), anyhow::Error> {
2927 let tmp_dir = testutils::create_temp_dir().await?;
2928 let unreadable = tmp_dir.join("unreadable.txt");
2929 tokio::fs::write(&unreadable, "test").await?;
2930 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2931
2932 let src_metadata = tokio::fs::symlink_metadata(&unreadable).await?;
2934 let result = copy_file(
2935 &PROGRESS,
2936 &unreadable,
2937 &tmp_dir.join("dest.txt"),
2938 &src_metadata,
2939 &Settings {
2940 dereference: false,
2941 fail_early: false,
2942 overwrite: false,
2943 overwrite_compare: Default::default(),
2944 overwrite_filter: None,
2945 ignore_existing: false,
2946 chunk_size: 0,
2947 skip_specials: false,
2948 remote_copy_buffer_size: 0,
2949 filter: None,
2950 dry_run: None,
2951 delete: None,
2952 },
2953 &NO_PRESERVE_SETTINGS,
2954 false,
2955 )
2956 .await;
2957
2958 assert!(result.is_err(), "Should fail with permission error");
2959 let err_msg = get_full_error_message(&result.unwrap_err());
2960
2961 assert!(
2963 err_msg.to_lowercase().contains("permission")
2964 || err_msg.contains("EACCES")
2965 || err_msg.contains("denied"),
2966 "Error message must include permission-related text. Got: {}",
2967 err_msg
2968 );
2969 Ok(())
2970 }
2971
2972 #[tokio::test]
2973 #[traced_test]
2974 async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
2975 let tmp_dir = testutils::create_temp_dir().await?;
2976
2977 let result = copy(
2978 &PROGRESS,
2979 &tmp_dir.join("does_not_exist.txt"),
2980 &tmp_dir.join("dest.txt"),
2981 &Settings {
2982 dereference: false,
2983 fail_early: false,
2984 overwrite: false,
2985 overwrite_compare: Default::default(),
2986 overwrite_filter: None,
2987 ignore_existing: false,
2988 chunk_size: 0,
2989 skip_specials: false,
2990 remote_copy_buffer_size: 0,
2991 filter: None,
2992 dry_run: None,
2993 delete: None,
2994 },
2995 &NO_PRESERVE_SETTINGS,
2996 false,
2997 )
2998 .await;
2999
3000 assert!(result.is_err());
3001 let err_msg = get_full_error_message(&result.unwrap_err());
3002
3003 assert!(
3004 err_msg.to_lowercase().contains("no such file")
3005 || err_msg.to_lowercase().contains("not found")
3006 || err_msg.contains("ENOENT"),
3007 "Error message must include file not found text. Got: {}",
3008 err_msg
3009 );
3010 Ok(())
3011 }
3012
3013 #[tokio::test]
3014 #[traced_test]
3015 async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
3016 let tmp_dir = testutils::create_temp_dir().await?;
3017 let unreadable_dir = tmp_dir.join("unreadable_dir");
3018 tokio::fs::create_dir(&unreadable_dir).await?;
3019 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
3020 .await?;
3021
3022 let result = copy(
3023 &PROGRESS,
3024 &unreadable_dir,
3025 &tmp_dir.join("dest"),
3026 &Settings {
3027 dereference: false,
3028 fail_early: true,
3029 overwrite: false,
3030 overwrite_compare: Default::default(),
3031 overwrite_filter: None,
3032 ignore_existing: false,
3033 chunk_size: 0,
3034 skip_specials: false,
3035 remote_copy_buffer_size: 0,
3036 filter: None,
3037 dry_run: None,
3038 delete: None,
3039 },
3040 &NO_PRESERVE_SETTINGS,
3041 false,
3042 )
3043 .await;
3044
3045 assert!(result.is_err());
3046 let err_msg = get_full_error_message(&result.unwrap_err());
3047
3048 assert!(
3049 err_msg.to_lowercase().contains("permission")
3050 || err_msg.contains("EACCES")
3051 || err_msg.contains("denied"),
3052 "Error message must include permission-related text. Got: {}",
3053 err_msg
3054 );
3055
3056 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
3058 .await?;
3059 Ok(())
3060 }
3061
3062 #[tokio::test]
3063 #[traced_test]
3064 async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
3065 {
3066 let tmp_dir = testutils::setup_test_dir().await?;
3067 let test_path = tmp_dir.as_path();
3068 let readonly_parent = test_path.join("readonly_dest");
3069 tokio::fs::create_dir(&readonly_parent).await?;
3070 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3071 .await?;
3072
3073 let result = copy(
3074 &PROGRESS,
3075 &test_path.join("foo"),
3076 &readonly_parent.join("copy"),
3077 &Settings {
3078 dereference: false,
3079 fail_early: true,
3080 overwrite: false,
3081 overwrite_compare: Default::default(),
3082 overwrite_filter: None,
3083 ignore_existing: false,
3084 chunk_size: 0,
3085 skip_specials: false,
3086 remote_copy_buffer_size: 0,
3087 filter: None,
3088 dry_run: None,
3089 delete: None,
3090 },
3091 &NO_PRESERVE_SETTINGS,
3092 false,
3093 )
3094 .await;
3095
3096 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
3098 .await?;
3099
3100 assert!(result.is_err(), "copy into read-only parent should fail");
3101 let err_msg = get_full_error_message(&result.unwrap_err());
3102
3103 assert!(
3104 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
3105 "Error message must include permission denied text. Got: {}",
3106 err_msg
3107 );
3108 Ok(())
3109 }
3110 }
3111
3112 mod empty_dir_cleanup_tests {
3113 use super::*;
3114 use crate::filter::FilterSettings;
3115 use std::path::Path;
3116 #[test]
3117 fn test_check_empty_dir_cleanup_no_filter() {
3118 assert_eq!(
3120 check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
3121 EmptyDirAction::Keep
3122 );
3123 }
3124 #[test]
3125 fn test_check_empty_dir_cleanup_something_copied() {
3126 let mut filter = FilterSettings::new();
3128 filter.add_include("*.txt").unwrap();
3129 assert_eq!(
3130 check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
3131 EmptyDirAction::Keep
3132 );
3133 }
3134 #[test]
3135 fn test_check_empty_dir_cleanup_not_created() {
3136 let mut filter = FilterSettings::new();
3138 filter.add_include("*.txt").unwrap();
3139 assert_eq!(
3140 check_empty_dir_cleanup(
3141 Some(&filter),
3142 false,
3143 false,
3144 Path::new("any"),
3145 false,
3146 false
3147 ),
3148 EmptyDirAction::Keep
3149 );
3150 }
3151 #[test]
3152 fn test_check_empty_dir_cleanup_directly_matched() {
3153 let mut filter = FilterSettings::new();
3155 filter.add_include("target/").unwrap();
3156 assert_eq!(
3157 check_empty_dir_cleanup(
3158 Some(&filter),
3159 true,
3160 false,
3161 Path::new("target"),
3162 false,
3163 false
3164 ),
3165 EmptyDirAction::Keep
3166 );
3167 }
3168 #[test]
3169 fn test_check_empty_dir_cleanup_traversed_only() {
3170 let mut filter = FilterSettings::new();
3172 filter.add_include("*.txt").unwrap();
3173 assert_eq!(
3174 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
3175 EmptyDirAction::Remove
3176 );
3177 }
3178 #[test]
3179 fn test_check_empty_dir_cleanup_dry_run() {
3180 let mut filter = FilterSettings::new();
3182 filter.add_include("*.txt").unwrap();
3183 assert_eq!(
3184 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
3185 EmptyDirAction::DryRunSkip
3186 );
3187 }
3188 #[test]
3189 fn test_check_empty_dir_cleanup_root_always_kept() {
3190 let mut filter = FilterSettings::new();
3192 filter.add_include("*.txt").unwrap();
3193 assert_eq!(
3194 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
3195 EmptyDirAction::Keep
3196 );
3197 }
3198 #[test]
3199 fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
3200 let mut filter = FilterSettings::new();
3202 filter.add_include("*.txt").unwrap();
3203 assert_eq!(
3204 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
3205 EmptyDirAction::Keep
3206 );
3207 }
3208 }
3209
3210 #[tokio::test]
3214 #[traced_test]
3215 async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
3216 let tmp_dir = testutils::create_temp_dir().await?;
3217 let test_path = tmp_dir.as_path();
3218 let src_dir = test_path.join("src");
3220 tokio::fs::create_dir(&src_dir).await?;
3221 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
3222 let readable_file = src_dir.join("readable.txt");
3224 tokio::fs::write(&readable_file, "content").await?;
3225 let unreadable_file = src_dir.join("unreadable.txt");
3226 tokio::fs::write(&unreadable_file, "secret").await?;
3227 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3228 .await?;
3229 let dst_dir = test_path.join("dst");
3230 let result = copy(
3232 &PROGRESS,
3233 &src_dir,
3234 &dst_dir,
3235 &Settings {
3236 dereference: false,
3237 fail_early: false,
3238 overwrite: false,
3239 overwrite_compare: Default::default(),
3240 overwrite_filter: None,
3241 ignore_existing: false,
3242 chunk_size: 0,
3243 skip_specials: false,
3244 remote_copy_buffer_size: 0,
3245 filter: None,
3246 dry_run: None,
3247 delete: None,
3248 },
3249 &DO_PRESERVE_SETTINGS,
3250 false,
3251 )
3252 .await;
3253 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
3255 .await?;
3256 assert!(result.is_err(), "copy should fail due to unreadable file");
3258 let error = result.unwrap_err();
3259 assert_eq!(error.summary.files_copied, 1);
3261 assert_eq!(error.summary.directories_created, 1);
3262 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3264 assert!(dst_metadata.is_dir());
3265 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
3266 assert_eq!(
3267 actual_mode, 0o750,
3268 "directory should have preserved source permissions (0o750), got {:o}",
3269 actual_mode
3270 );
3271 Ok(())
3272 }
3273
3274 #[tokio::test]
3276 #[traced_test]
3277 async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error()
3278 -> Result<(), anyhow::Error> {
3279 let tmp_dir = testutils::create_temp_dir().await?;
3280 let test_path = tmp_dir.as_path();
3281 let src_dir = test_path.join("src");
3282 tokio::fs::create_dir(&src_dir).await?;
3283 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
3284 let unreadable_file = src_dir.join("unreadable.txt");
3285 tokio::fs::write(&unreadable_file, "secret").await?;
3286 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
3287 .await?;
3288 let fixed_secs = 946684800;
3289 let fixed_nsec = 123_456_789;
3290 let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
3291 nix::sys::stat::utimensat(
3292 nix::fcntl::AT_FDCWD,
3293 &src_dir,
3294 &fixed_time,
3295 &fixed_time,
3296 nix::sys::stat::UtimensatFlags::NoFollowSymlink,
3297 )?;
3298 let src_metadata = tokio::fs::metadata(&src_dir).await?;
3299 let dst_dir = test_path.join("dst");
3300 let result = copy(
3301 &PROGRESS,
3302 &src_dir,
3303 &dst_dir,
3304 &Settings {
3305 dereference: false,
3306 fail_early: true,
3307 overwrite: false,
3308 overwrite_compare: Default::default(),
3309 overwrite_filter: None,
3310 ignore_existing: false,
3311 chunk_size: 0,
3312 skip_specials: false,
3313 remote_copy_buffer_size: 0,
3314 filter: None,
3315 dry_run: None,
3316 delete: None,
3317 },
3318 &DO_PRESERVE_SETTINGS,
3319 false,
3320 )
3321 .await;
3322 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
3323 .await?;
3324 assert!(result.is_err(), "copy should fail due to unreadable file");
3325 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3326 assert!(dst_metadata.is_dir());
3327 assert_ne!(
3328 (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
3329 (src_metadata.mtime(), src_metadata.mtime_nsec()),
3330 "fail-early should return before applying preserved directory timestamps"
3331 );
3332 Ok(())
3333 }
3334 mod filter_tests {
3335 use super::*;
3336 use crate::filter::FilterSettings;
3337 #[tokio::test]
3341 #[traced_test]
3342 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
3343 let tmp_dir = testutils::setup_test_dir().await?;
3344 let test_path = tmp_dir.as_path();
3345 let mut filter = FilterSettings::new();
3357 filter.add_include("bar/*.txt").unwrap();
3358 let summary = copy(
3359 &PROGRESS,
3360 &test_path.join("foo"),
3361 &test_path.join("dst"),
3362 &Settings {
3363 dereference: false,
3364 fail_early: false,
3365 overwrite: false,
3366 overwrite_compare: Default::default(),
3367 overwrite_filter: None,
3368 ignore_existing: false,
3369 chunk_size: 0,
3370 skip_specials: false,
3371 remote_copy_buffer_size: 0,
3372 filter: Some(filter),
3373 dry_run: None,
3374 delete: None,
3375 },
3376 &NO_PRESERVE_SETTINGS,
3377 false,
3378 )
3379 .await?;
3380 assert_eq!(
3383 summary.files_copied, 3,
3384 "should copy 3 files matching bar/*.txt"
3385 );
3386 assert!(
3388 test_path.join("dst/bar/1.txt").exists(),
3389 "bar/1.txt should be copied"
3390 );
3391 assert!(
3392 test_path.join("dst/bar/2.txt").exists(),
3393 "bar/2.txt should be copied"
3394 );
3395 assert!(
3396 test_path.join("dst/bar/3.txt").exists(),
3397 "bar/3.txt should be copied"
3398 );
3399 assert!(
3401 !test_path.join("dst/0.txt").exists(),
3402 "0.txt should not be copied"
3403 );
3404 Ok(())
3405 }
3406 #[tokio::test]
3408 #[traced_test]
3409 async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
3410 let tmp_dir = testutils::setup_test_dir().await?;
3411 let test_path = tmp_dir.as_path();
3412 let mut filter = FilterSettings::new();
3414 filter.add_include("/bar/**").unwrap();
3415 let summary = copy(
3416 &PROGRESS,
3417 &test_path.join("foo"),
3418 &test_path.join("dst"),
3419 &Settings {
3420 dereference: false,
3421 fail_early: false,
3422 overwrite: false,
3423 overwrite_compare: Default::default(),
3424 overwrite_filter: None,
3425 ignore_existing: false,
3426 chunk_size: 0,
3427 skip_specials: false,
3428 remote_copy_buffer_size: 0,
3429 filter: Some(filter),
3430 dry_run: None,
3431 delete: None,
3432 },
3433 &NO_PRESERVE_SETTINGS,
3434 false,
3435 )
3436 .await?;
3437 assert!(
3439 test_path.join("dst/bar").exists(),
3440 "bar directory should be copied"
3441 );
3442 assert!(
3443 !test_path.join("dst/baz").exists(),
3444 "baz directory should not be copied"
3445 );
3446 assert!(
3447 !test_path.join("dst/0.txt").exists(),
3448 "0.txt should not be copied"
3449 );
3450 assert_eq!(
3452 summary.files_copied, 3,
3453 "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
3454 );
3455 assert_eq!(
3456 summary.directories_created, 2,
3457 "should create 2 directories (root dst + bar)"
3458 );
3459 assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
3461 assert_eq!(
3462 summary.directories_skipped, 1,
3463 "should skip 1 directory (baz)"
3464 );
3465 Ok(())
3466 }
3467 #[tokio::test]
3469 #[traced_test]
3470 async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
3471 let tmp_dir = testutils::setup_test_dir().await?;
3472 let test_path = tmp_dir.as_path();
3473 let mut filter = FilterSettings::new();
3475 filter.add_include("**/*.txt").unwrap();
3476 let summary = copy(
3477 &PROGRESS,
3478 &test_path.join("foo"),
3479 &test_path.join("dst"),
3480 &Settings {
3481 dereference: false,
3482 fail_early: false,
3483 overwrite: false,
3484 overwrite_compare: Default::default(),
3485 overwrite_filter: None,
3486 ignore_existing: false,
3487 chunk_size: 0,
3488 skip_specials: false,
3489 remote_copy_buffer_size: 0,
3490 filter: Some(filter),
3491 dry_run: None,
3492 delete: None,
3493 },
3494 &NO_PRESERVE_SETTINGS,
3495 false,
3496 )
3497 .await?;
3498 assert_eq!(
3500 summary.files_copied, 5,
3501 "should copy all 5 .txt files with **/*.txt pattern"
3502 );
3503 Ok(())
3504 }
3505 #[tokio::test]
3507 #[traced_test]
3508 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
3509 let tmp_dir = testutils::setup_test_dir().await?;
3510 let test_path = tmp_dir.as_path();
3511 let mut filter = FilterSettings::new();
3513 filter.add_exclude("*.txt").unwrap();
3514 let result = copy(
3515 &PROGRESS,
3516 &test_path.join("foo/0.txt"), &test_path.join("dst.txt"),
3518 &Settings {
3519 dereference: false,
3520 fail_early: false,
3521 overwrite: false,
3522 overwrite_compare: Default::default(),
3523 overwrite_filter: None,
3524 ignore_existing: false,
3525 chunk_size: 0,
3526 skip_specials: false,
3527 remote_copy_buffer_size: 0,
3528 filter: Some(filter),
3529 dry_run: None,
3530 delete: None,
3531 },
3532 &NO_PRESERVE_SETTINGS,
3533 false,
3534 )
3535 .await?;
3536 assert_eq!(
3538 result.files_copied, 0,
3539 "file matching exclude pattern should not be copied"
3540 );
3541 assert!(
3542 !test_path.join("dst.txt").exists(),
3543 "excluded file should not exist at destination"
3544 );
3545 Ok(())
3546 }
3547 #[tokio::test]
3549 #[traced_test]
3550 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
3551 let test_path = testutils::create_temp_dir().await?;
3552 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
3554 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
3555 let mut filter = FilterSettings::new();
3557 filter.add_exclude("*_dir/").unwrap();
3558 let result = copy(
3559 &PROGRESS,
3560 &test_path.join("excluded_dir"),
3561 &test_path.join("dst"),
3562 &Settings {
3563 dereference: false,
3564 fail_early: false,
3565 overwrite: false,
3566 overwrite_compare: Default::default(),
3567 overwrite_filter: None,
3568 ignore_existing: false,
3569 chunk_size: 0,
3570 skip_specials: false,
3571 remote_copy_buffer_size: 0,
3572 filter: Some(filter),
3573 dry_run: None,
3574 delete: None,
3575 },
3576 &NO_PRESERVE_SETTINGS,
3577 false,
3578 )
3579 .await?;
3580 assert_eq!(
3582 result.directories_created, 0,
3583 "root directory matching exclude should not be created"
3584 );
3585 assert!(
3586 !test_path.join("dst").exists(),
3587 "excluded root directory should not exist at destination"
3588 );
3589 Ok(())
3590 }
3591 #[tokio::test]
3593 #[traced_test]
3594 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
3595 let test_path = testutils::create_temp_dir().await?;
3596 tokio::fs::write(test_path.join("target.txt"), "content").await?;
3598 tokio::fs::symlink(
3599 test_path.join("target.txt"),
3600 test_path.join("excluded_link"),
3601 )
3602 .await?;
3603 let mut filter = FilterSettings::new();
3605 filter.add_exclude("*_link").unwrap();
3606 let result = copy(
3607 &PROGRESS,
3608 &test_path.join("excluded_link"),
3609 &test_path.join("dst"),
3610 &Settings {
3611 dereference: false,
3612 fail_early: false,
3613 overwrite: false,
3614 overwrite_compare: Default::default(),
3615 overwrite_filter: None,
3616 ignore_existing: false,
3617 chunk_size: 0,
3618 skip_specials: false,
3619 remote_copy_buffer_size: 0,
3620 filter: Some(filter),
3621 dry_run: None,
3622 delete: None,
3623 },
3624 &NO_PRESERVE_SETTINGS,
3625 false,
3626 )
3627 .await?;
3628 assert_eq!(
3630 result.symlinks_created, 0,
3631 "root symlink matching exclude should not be created"
3632 );
3633 assert!(
3634 !test_path.join("dst").exists(),
3635 "excluded root symlink should not exist at destination"
3636 );
3637 Ok(())
3638 }
3639 #[tokio::test]
3641 #[traced_test]
3642 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
3643 let tmp_dir = testutils::setup_test_dir().await?;
3644 let test_path = tmp_dir.as_path();
3645 let mut filter = FilterSettings::new();
3652 filter.add_include("**/*.txt").unwrap();
3653 filter.add_exclude("bar/2.txt").unwrap();
3654 let summary = copy(
3655 &PROGRESS,
3656 &test_path.join("foo"),
3657 &test_path.join("dst"),
3658 &Settings {
3659 dereference: false,
3660 fail_early: false,
3661 overwrite: false,
3662 overwrite_compare: Default::default(),
3663 overwrite_filter: None,
3664 ignore_existing: false,
3665 chunk_size: 0,
3666 skip_specials: false,
3667 remote_copy_buffer_size: 0,
3668 filter: Some(filter),
3669 dry_run: None,
3670 delete: None,
3671 },
3672 &NO_PRESERVE_SETTINGS,
3673 false,
3674 )
3675 .await?;
3676 assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
3680 assert_eq!(
3681 summary.files_skipped, 1,
3682 "should skip 1 file (bar/2.txt excluded)"
3683 );
3684 assert!(
3686 test_path.join("dst/bar/1.txt").exists(),
3687 "bar/1.txt should be copied"
3688 );
3689 assert!(
3690 !test_path.join("dst/bar/2.txt").exists(),
3691 "bar/2.txt should be excluded"
3692 );
3693 assert!(
3694 test_path.join("dst/bar/3.txt").exists(),
3695 "bar/3.txt should be copied"
3696 );
3697 Ok(())
3698 }
3699 #[tokio::test]
3701 #[traced_test]
3702 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
3703 let tmp_dir = testutils::setup_test_dir().await?;
3704 let test_path = tmp_dir.as_path();
3705 let mut filter = FilterSettings::new();
3712 filter.add_exclude("bar/").unwrap();
3713 let summary = copy(
3714 &PROGRESS,
3715 &test_path.join("foo"),
3716 &test_path.join("dst"),
3717 &Settings {
3718 dereference: false,
3719 fail_early: false,
3720 overwrite: false,
3721 overwrite_compare: Default::default(),
3722 overwrite_filter: None,
3723 ignore_existing: false,
3724 chunk_size: 0,
3725 skip_specials: false,
3726 remote_copy_buffer_size: 0,
3727 filter: Some(filter),
3728 dry_run: None,
3729 delete: None,
3730 },
3731 &NO_PRESERVE_SETTINGS,
3732 false,
3733 )
3734 .await?;
3735 assert_eq!(summary.files_copied, 2, "should copy 2 files");
3739 assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
3740 assert_eq!(
3741 summary.directories_created, 2,
3742 "should create 2 directories"
3743 );
3744 assert_eq!(
3745 summary.directories_skipped, 1,
3746 "should skip 1 directory (bar)"
3747 );
3748 assert_eq!(
3749 summary.files_skipped, 0,
3750 "no files skipped (bar contents not counted)"
3751 );
3752 Ok(())
3753 }
3754 #[tokio::test]
3757 #[traced_test]
3758 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
3759 let test_path = testutils::create_temp_dir().await?;
3760 let src_path = test_path.join("src");
3766 tokio::fs::create_dir(&src_path).await?;
3767 tokio::fs::write(src_path.join("foo"), "content").await?;
3768 tokio::fs::write(src_path.join("bar"), "content").await?;
3769 tokio::fs::create_dir(src_path.join("baz")).await?;
3770 let mut filter = FilterSettings::new();
3772 filter.add_include("foo").unwrap();
3773 let summary = copy(
3774 &PROGRESS,
3775 &src_path,
3776 &test_path.join("dst"),
3777 &Settings {
3778 dereference: false,
3779 fail_early: false,
3780 overwrite: false,
3781 overwrite_compare: Default::default(),
3782 overwrite_filter: None,
3783 ignore_existing: false,
3784 chunk_size: 0,
3785 skip_specials: false,
3786 remote_copy_buffer_size: 0,
3787 filter: Some(filter),
3788 dry_run: None,
3789 delete: None,
3790 },
3791 &NO_PRESERVE_SETTINGS,
3792 false,
3793 )
3794 .await?;
3795 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3797 assert_eq!(
3798 summary.directories_created, 1,
3799 "should create only root directory (not empty 'baz')"
3800 );
3801 assert!(
3803 test_path.join("dst").join("foo").exists(),
3804 "foo should be copied"
3805 );
3806 assert!(
3808 !test_path.join("dst").join("bar").exists(),
3809 "bar should not be copied"
3810 );
3811 assert!(
3813 !test_path.join("dst").join("baz").exists(),
3814 "empty baz directory should NOT be created"
3815 );
3816 Ok(())
3817 }
3818 #[tokio::test]
3821 #[traced_test]
3822 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
3823 let test_path = testutils::create_temp_dir().await?;
3824 let src_path = test_path.join("src");
3831 tokio::fs::create_dir(&src_path).await?;
3832 tokio::fs::write(src_path.join("foo"), "content").await?;
3833 tokio::fs::create_dir(src_path.join("baz")).await?;
3834 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
3835 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
3836 let mut filter = FilterSettings::new();
3838 filter.add_include("foo").unwrap();
3839 let summary = copy(
3840 &PROGRESS,
3841 &src_path,
3842 &test_path.join("dst"),
3843 &Settings {
3844 dereference: false,
3845 fail_early: false,
3846 overwrite: false,
3847 overwrite_compare: Default::default(),
3848 overwrite_filter: None,
3849 ignore_existing: false,
3850 chunk_size: 0,
3851 skip_specials: false,
3852 remote_copy_buffer_size: 0,
3853 filter: Some(filter),
3854 dry_run: None,
3855 delete: None,
3856 },
3857 &NO_PRESERVE_SETTINGS,
3858 false,
3859 )
3860 .await?;
3861 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3863 assert_eq!(
3864 summary.files_skipped, 2,
3865 "should skip 2 files (qux and quux)"
3866 );
3867 assert_eq!(
3868 summary.directories_created, 1,
3869 "should create only root directory (not 'baz' with non-matching content)"
3870 );
3871 assert!(
3873 test_path.join("dst").join("foo").exists(),
3874 "foo should be copied"
3875 );
3876 assert!(
3878 !test_path.join("dst").join("baz").exists(),
3879 "baz directory should NOT be created (no matching content inside)"
3880 );
3881 Ok(())
3882 }
3883 #[tokio::test]
3886 #[traced_test]
3887 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
3888 let test_path = testutils::create_temp_dir().await?;
3889 let src_path = test_path.join("src");
3895 tokio::fs::create_dir(&src_path).await?;
3896 tokio::fs::write(src_path.join("foo"), "content").await?;
3897 tokio::fs::write(src_path.join("bar"), "content").await?;
3898 tokio::fs::create_dir(src_path.join("baz")).await?;
3899 let mut filter = FilterSettings::new();
3901 filter.add_include("foo").unwrap();
3902 let summary = copy(
3903 &PROGRESS,
3904 &src_path,
3905 &test_path.join("dst"),
3906 &Settings {
3907 dereference: false,
3908 fail_early: false,
3909 overwrite: false,
3910 overwrite_compare: Default::default(),
3911 overwrite_filter: None,
3912 ignore_existing: false,
3913 chunk_size: 0,
3914 skip_specials: false,
3915 remote_copy_buffer_size: 0,
3916 filter: Some(filter),
3917 dry_run: Some(crate::config::DryRunMode::Explain),
3918 delete: None,
3919 },
3920 &NO_PRESERVE_SETTINGS,
3921 false,
3922 )
3923 .await?;
3924 assert_eq!(
3926 summary.files_copied, 1,
3927 "should report only 'foo' would be copied"
3928 );
3929 assert_eq!(
3930 summary.directories_created, 1,
3931 "should report only root directory would be created (not empty 'baz')"
3932 );
3933 assert!(
3935 !test_path.join("dst").exists(),
3936 "dst should not exist in dry-run"
3937 );
3938 Ok(())
3939 }
3940 #[tokio::test]
3943 #[traced_test]
3944 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
3945 let test_path = testutils::create_temp_dir().await?;
3946 let src_path = test_path.join("src");
3952 tokio::fs::create_dir(&src_path).await?;
3953 tokio::fs::write(src_path.join("foo"), "content").await?;
3954 tokio::fs::write(src_path.join("bar"), "content").await?;
3955 tokio::fs::create_dir(src_path.join("baz")).await?;
3956 let dst_path = test_path.join("dst");
3958 tokio::fs::create_dir(&dst_path).await?;
3959 tokio::fs::create_dir(dst_path.join("baz")).await?;
3960 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
3962 let mut filter = FilterSettings::new();
3964 filter.add_include("foo").unwrap();
3965 let summary = copy(
3966 &PROGRESS,
3967 &src_path,
3968 &dst_path,
3969 &Settings {
3970 dereference: false,
3971 fail_early: false,
3972 overwrite: true, overwrite_compare: Default::default(),
3974 overwrite_filter: None,
3975 ignore_existing: false,
3976 chunk_size: 0,
3977 skip_specials: false,
3978 remote_copy_buffer_size: 0,
3979 filter: Some(filter),
3980 dry_run: None,
3981 delete: None,
3982 },
3983 &NO_PRESERVE_SETTINGS,
3984 false,
3985 )
3986 .await?;
3987 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3989 assert_eq!(
3991 summary.directories_unchanged, 2,
3992 "root dst and baz directories should be unchanged"
3993 );
3994 assert_eq!(
3995 summary.directories_created, 0,
3996 "should not create any directories"
3997 );
3998 assert!(dst_path.join("foo").exists(), "foo should be copied");
4000 assert!(!dst_path.join("bar").exists(), "bar should not be copied");
4002 assert!(
4004 dst_path.join("baz").exists(),
4005 "existing baz directory should still exist"
4006 );
4007 assert!(
4008 dst_path.join("baz").join("marker.txt").exists(),
4009 "existing content in baz should still exist"
4010 );
4011 Ok(())
4012 }
4013 }
4014 mod dry_run_tests {
4015 use super::*;
4016 use crate::filter::FilterSettings;
4017 #[tokio::test]
4020 #[traced_test]
4021 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
4022 let tmp_dir = testutils::setup_test_dir().await?;
4023 let test_path = tmp_dir.as_path();
4024 let dst_path = test_path.join("nonexistent_dst");
4025 assert!(
4027 !dst_path.exists(),
4028 "destination should not exist before dry-run"
4029 );
4030 let summary = copy(
4031 &PROGRESS,
4032 &test_path.join("foo"),
4033 &dst_path,
4034 &Settings {
4035 dereference: false,
4036 fail_early: false,
4037 overwrite: false,
4038 overwrite_compare: Default::default(),
4039 overwrite_filter: None,
4040 ignore_existing: false,
4041 chunk_size: 0,
4042 skip_specials: false,
4043 remote_copy_buffer_size: 0,
4044 filter: None,
4045 dry_run: Some(crate::config::DryRunMode::Brief),
4046 delete: None,
4047 },
4048 &NO_PRESERVE_SETTINGS,
4049 false,
4050 )
4051 .await?;
4052 assert!(
4054 !dst_path.exists(),
4055 "dry-run should not create destination directory"
4056 );
4057 assert!(
4059 summary.directories_created > 0,
4060 "dry-run should report directories that would be created"
4061 );
4062 assert!(
4063 summary.files_copied > 0,
4064 "dry-run should report files that would be copied"
4065 );
4066 Ok(())
4067 }
4068 #[tokio::test]
4072 #[traced_test]
4073 async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
4074 let test_path = testutils::create_temp_dir().await?;
4075 let src_path = test_path.join("src");
4080 tokio::fs::create_dir(&src_path).await?;
4081 tokio::fs::write(src_path.join("bar.log"), "content").await?;
4082 tokio::fs::create_dir(src_path.join("baz")).await?;
4083 let mut filter = FilterSettings::new();
4085 filter.add_include("*.txt").unwrap();
4086 let dst_path = test_path.join("dst");
4087 let summary = copy(
4088 &PROGRESS,
4089 &src_path,
4090 &dst_path,
4091 &Settings {
4092 dereference: false,
4093 fail_early: false,
4094 overwrite: false,
4095 overwrite_compare: Default::default(),
4096 overwrite_filter: None,
4097 ignore_existing: false,
4098 chunk_size: 0,
4099 skip_specials: false,
4100 remote_copy_buffer_size: 0,
4101 filter: Some(filter),
4102 dry_run: None,
4103 delete: None,
4104 },
4105 &NO_PRESERVE_SETTINGS,
4106 false,
4107 )
4108 .await?;
4109 assert_eq!(summary.files_copied, 0, "no files match *.txt");
4111 assert_eq!(
4113 summary.directories_created, 1,
4114 "root directory should always be created"
4115 );
4116 assert!(dst_path.exists(), "root destination directory should exist");
4117 assert!(
4119 !dst_path.join("baz").exists(),
4120 "empty baz should not be created"
4121 );
4122 Ok(())
4123 }
4124 #[tokio::test]
4126 #[traced_test]
4127 async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
4128 {
4129 let test_path = testutils::create_temp_dir().await?;
4130 let src_path = test_path.join("src");
4131 tokio::fs::create_dir(&src_path).await?;
4132 tokio::fs::write(src_path.join("bar.log"), "content").await?;
4133 let mut filter = FilterSettings::new();
4135 filter.add_include("*.txt").unwrap();
4136 let dst_path = test_path.join("dst");
4137 let summary = copy(
4138 &PROGRESS,
4139 &src_path,
4140 &dst_path,
4141 &Settings {
4142 dereference: false,
4143 fail_early: false,
4144 overwrite: false,
4145 overwrite_compare: Default::default(),
4146 overwrite_filter: None,
4147 ignore_existing: false,
4148 chunk_size: 0,
4149 skip_specials: false,
4150 remote_copy_buffer_size: 0,
4151 filter: Some(filter),
4152 dry_run: Some(crate::config::DryRunMode::Explain),
4153 delete: None,
4154 },
4155 &NO_PRESERVE_SETTINGS,
4156 false,
4157 )
4158 .await?;
4159 assert_eq!(summary.files_copied, 0, "no files match *.txt");
4160 assert_eq!(
4161 summary.directories_created, 1,
4162 "root directory should be counted in dry-run"
4163 );
4164 assert!(
4165 !dst_path.exists(),
4166 "nothing should be created in dry-run mode"
4167 );
4168 Ok(())
4169 }
4170 }
4171
4172 mod max_open_files_tests {
4174 use super::*;
4175
4176 #[tokio::test]
4179 #[traced_test]
4180 async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
4181 let tmp_dir = testutils::create_temp_dir().await?;
4182 let src = tmp_dir.join("src");
4183 let dst = tmp_dir.join("dst");
4184 tokio::fs::create_dir(&src).await?;
4185 let file_count = 200;
4186 for i in 0..file_count {
4187 tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
4188 }
4189 throttle::set_max_open_files(4);
4191 let summary = copy(
4192 &PROGRESS,
4193 &src,
4194 &dst,
4195 &Settings {
4196 dereference: false,
4197 fail_early: true,
4198 overwrite: false,
4199 overwrite_compare: Default::default(),
4200 overwrite_filter: None,
4201 ignore_existing: false,
4202 chunk_size: 0,
4203 skip_specials: false,
4204 remote_copy_buffer_size: 0,
4205 filter: None,
4206 dry_run: None,
4207 delete: None,
4208 },
4209 &NO_PRESERVE_SETTINGS,
4210 false,
4211 )
4212 .await?;
4213 assert_eq!(summary.files_copied, file_count);
4214 assert_eq!(summary.directories_created, 1);
4215 for i in 0..file_count {
4216 let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
4217 assert_eq!(content, format!("content-{}", i));
4218 }
4219 Ok(())
4220 }
4221
4222 #[tokio::test]
4225 #[traced_test]
4226 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
4227 let tmp_dir = testutils::create_temp_dir().await?;
4228 let src = tmp_dir.join("src");
4229 let dst = tmp_dir.join("dst");
4230 let depth = 20;
4231 let files_per_level = 5;
4232 let limit = 4;
4233 let mut dir = src.clone();
4235 for level in 0..depth {
4236 tokio::fs::create_dir_all(&dir).await?;
4237 for f in 0..files_per_level {
4238 tokio::fs::write(
4239 dir.join(format!("f{}_{}.txt", level, f)),
4240 format!("L{}F{}", level, f),
4241 )
4242 .await?;
4243 }
4244 dir = dir.join(format!("d{}", level));
4245 }
4246 throttle::set_max_open_files(limit);
4247 let summary = tokio::time::timeout(
4248 std::time::Duration::from_secs(30),
4249 copy(
4250 &PROGRESS,
4251 &src,
4252 &dst,
4253 &Settings {
4254 dereference: false,
4255 fail_early: true,
4256 overwrite: false,
4257 overwrite_compare: Default::default(),
4258 overwrite_filter: None,
4259 ignore_existing: false,
4260 chunk_size: 0,
4261 skip_specials: false,
4262 remote_copy_buffer_size: 0,
4263 filter: None,
4264 dry_run: None,
4265 delete: None,
4266 },
4267 &NO_PRESERVE_SETTINGS,
4268 false,
4269 ),
4270 )
4271 .await
4272 .context("copy timed out — possible deadlock")?
4273 .context("copy failed")?;
4274 assert_eq!(summary.files_copied, depth * files_per_level);
4275 assert_eq!(summary.directories_created, depth);
4276 let mut check_dir = dst.clone();
4278 for level in 0..depth {
4279 let content =
4280 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
4281 assert_eq!(content, format!("L{}F0", level));
4282 check_dir = check_dir.join(format!("d{}", level));
4283 }
4284 Ok(())
4285 }
4286
4287 #[tokio::test]
4297 #[traced_test]
4298 async fn parallel_overwrite_dir_with_file_no_deadlock() -> Result<(), anyhow::Error> {
4299 let tmp_dir = testutils::create_temp_dir().await?;
4300 let src = tmp_dir.join("src");
4301 let dst = tmp_dir.join("dst");
4302 tokio::fs::create_dir(&src).await?;
4303 tokio::fs::create_dir(&dst).await?;
4304 let n = 8;
4308 for i in 0..n {
4309 tokio::fs::write(src.join(format!("e{}", i)), format!("file-{}", i)).await?;
4310 let dst_subdir = dst.join(format!("e{}", i));
4311 tokio::fs::create_dir(&dst_subdir).await?;
4312 for j in 0..3 {
4313 tokio::fs::write(
4314 dst_subdir.join(format!("inner_{}.txt", j)),
4315 format!("inner-{}-{}", i, j),
4316 )
4317 .await?;
4318 }
4319 }
4320 throttle::set_max_open_files(2);
4324 let summary = tokio::time::timeout(
4325 std::time::Duration::from_secs(30),
4326 copy(
4327 &PROGRESS,
4328 &src,
4329 &dst,
4330 &Settings {
4331 dereference: false,
4332 fail_early: true,
4333 overwrite: true,
4334 overwrite_compare: Default::default(),
4335 overwrite_filter: None,
4336 ignore_existing: false,
4337 chunk_size: 0,
4338 skip_specials: false,
4339 remote_copy_buffer_size: 0,
4340 filter: None,
4341 dry_run: None,
4342 delete: None,
4343 },
4344 &NO_PRESERVE_SETTINGS,
4345 false,
4346 ),
4347 )
4348 .await
4349 .context(
4350 "copy timed out — deadlock between copy_file's open-files permit and inner rm",
4351 )?
4352 .context("copy failed")?;
4353 assert_eq!(summary.files_copied, n);
4354 assert_eq!(summary.rm_summary.files_removed, n * 3);
4355 assert_eq!(summary.rm_summary.directories_removed, n);
4356 for i in 0..n {
4357 let path = dst.join(format!("e{}", i));
4358 let content = tokio::fs::read_to_string(&path).await?;
4359 assert_eq!(content, format!("file-{}", i));
4360 }
4361 Ok(())
4362 }
4363 }
4364
4365 mod skip_specials_tests {
4366 use super::*;
4367
4368 #[tokio::test]
4369 #[traced_test]
4370 async fn skip_specials_skips_socket_in_directory() -> Result<(), anyhow::Error> {
4371 let tmp_dir = testutils::setup_test_dir().await?;
4372 let test_path = tmp_dir.as_path();
4373 let src = test_path.join("src_dir");
4374 let dst = test_path.join("dst_dir");
4375 tokio::fs::create_dir(&src).await?;
4376 tokio::fs::write(src.join("file.txt"), "hello").await?;
4377 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
4379 let summary = copy(
4380 &PROGRESS,
4381 &src,
4382 &dst,
4383 &Settings {
4384 dereference: false,
4385 fail_early: false,
4386 overwrite: false,
4387 overwrite_compare: Default::default(),
4388 overwrite_filter: None,
4389 ignore_existing: false,
4390 chunk_size: 0,
4391 skip_specials: true,
4392 remote_copy_buffer_size: 0,
4393 filter: None,
4394 dry_run: None,
4395 delete: None,
4396 },
4397 &NO_PRESERVE_SETTINGS,
4398 false,
4399 )
4400 .await?;
4401 assert_eq!(summary.files_copied, 1);
4402 assert_eq!(summary.specials_skipped, 1);
4403 assert!(dst.join("file.txt").exists());
4404 assert!(!dst.join("test.sock").exists());
4405 Ok(())
4406 }
4407
4408 #[tokio::test]
4409 #[traced_test]
4410 async fn skip_specials_skips_fifo_in_directory() -> Result<(), anyhow::Error> {
4411 let tmp_dir = testutils::setup_test_dir().await?;
4412 let test_path = tmp_dir.as_path();
4413 let src = test_path.join("src_dir");
4414 let dst = test_path.join("dst_dir");
4415 tokio::fs::create_dir(&src).await?;
4416 tokio::fs::write(src.join("file.txt"), "hello").await?;
4417 nix::unistd::mkfifo(
4419 &src.join("test.fifo"),
4420 nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
4421 )?;
4422 let summary = copy(
4423 &PROGRESS,
4424 &src,
4425 &dst,
4426 &Settings {
4427 dereference: false,
4428 fail_early: false,
4429 overwrite: false,
4430 overwrite_compare: Default::default(),
4431 overwrite_filter: None,
4432 ignore_existing: false,
4433 chunk_size: 0,
4434 skip_specials: true,
4435 remote_copy_buffer_size: 0,
4436 filter: None,
4437 dry_run: None,
4438 delete: None,
4439 },
4440 &NO_PRESERVE_SETTINGS,
4441 false,
4442 )
4443 .await?;
4444 assert_eq!(summary.files_copied, 1);
4445 assert_eq!(summary.specials_skipped, 1);
4446 assert!(dst.join("file.txt").exists());
4447 assert!(!dst.join("test.fifo").exists());
4448 Ok(())
4449 }
4450
4451 #[tokio::test]
4452 #[traced_test]
4453 async fn special_file_errors_without_skip_specials() -> Result<(), anyhow::Error> {
4454 let tmp_dir = testutils::setup_test_dir().await?;
4455 let test_path = tmp_dir.as_path();
4456 let src = test_path.join("src_dir");
4457 let dst = test_path.join("dst_dir");
4458 tokio::fs::create_dir(&src).await?;
4459 tokio::fs::write(src.join("file.txt"), "hello").await?;
4460 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
4461 let result = copy(
4462 &PROGRESS,
4463 &src,
4464 &dst,
4465 &Settings {
4466 dereference: false,
4467 fail_early: false,
4468 overwrite: false,
4469 overwrite_compare: Default::default(),
4470 overwrite_filter: None,
4471 ignore_existing: false,
4472 chunk_size: 0,
4473 skip_specials: false,
4474 remote_copy_buffer_size: 0,
4475 filter: None,
4476 dry_run: None,
4477 delete: None,
4478 },
4479 &NO_PRESERVE_SETTINGS,
4480 false,
4481 )
4482 .await;
4483 assert!(result.is_err());
4484 let err = result.unwrap_err();
4485 assert!(
4486 format!("{:#}", err).contains("unsupported src file type"),
4487 "error should mention unsupported file type, got: {:#}",
4488 err
4489 );
4490 Ok(())
4491 }
4492
4493 #[tokio::test]
4494 #[traced_test]
4495 async fn skip_specials_top_level_socket() -> Result<(), anyhow::Error> {
4496 let tmp_dir = testutils::setup_test_dir().await?;
4497 let test_path = tmp_dir.as_path();
4498 let src_socket = test_path.join("test.sock");
4499 let dst = test_path.join("dst.sock");
4500 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
4501 let summary = copy(
4502 &PROGRESS,
4503 &src_socket,
4504 &dst,
4505 &Settings {
4506 dereference: false,
4507 fail_early: false,
4508 overwrite: false,
4509 overwrite_compare: Default::default(),
4510 overwrite_filter: None,
4511 ignore_existing: false,
4512 chunk_size: 0,
4513 skip_specials: true,
4514 remote_copy_buffer_size: 0,
4515 filter: None,
4516 dry_run: None,
4517 delete: None,
4518 },
4519 &NO_PRESERVE_SETTINGS,
4520 false,
4521 )
4522 .await?;
4523 assert_eq!(summary.specials_skipped, 1);
4524 assert_eq!(summary.files_copied, 0);
4525 assert!(!dst.exists());
4526 Ok(())
4527 }
4528 }
4529
4530 #[tokio::test]
4531 #[traced_test]
4532 async fn delete_protects_excluded_then_removes_with_delete_excluded()
4533 -> Result<(), anyhow::Error> {
4534 let tmp_dir = testutils::setup_test_dir().await?;
4535 let test_path = tmp_dir.as_path();
4536 let src = test_path.join("foo");
4537 let dst = test_path.join("bar");
4538 copy(
4539 &PROGRESS,
4540 &src,
4541 &dst,
4542 &settings_with_delete(None),
4543 &DO_PRESERVE_SETTINGS,
4544 false,
4545 )
4546 .await?;
4547 tokio::fs::write(dst.join("keep.log"), b"protected").await?;
4549
4550 let mut filter = crate::filter::FilterSettings::new();
4551 filter.add_exclude("*.log")?;
4552
4553 let mut settings = settings_with_delete(delete_on());
4555 settings.filter = Some(filter.clone());
4556 copy(
4557 &PROGRESS,
4558 &src,
4559 &dst,
4560 &settings,
4561 &DO_PRESERVE_SETTINGS,
4562 false,
4563 )
4564 .await?;
4565 assert!(
4566 dst.join("keep.log").exists(),
4567 "*.log must be protected by default"
4568 );
4569
4570 let mut settings = settings_with_delete(Some(DeleteSettings {
4572 delete_excluded: true,
4573 }));
4574 settings.filter = Some(filter);
4575 copy(
4576 &PROGRESS,
4577 &src,
4578 &dst,
4579 &settings,
4580 &DO_PRESERVE_SETTINGS,
4581 false,
4582 )
4583 .await?;
4584 assert!(!dst.join("keep.log").exists());
4585 Ok(())
4586 }
4587
4588 #[tokio::test]
4589 #[traced_test]
4590 async fn delete_dry_run_reports_without_removing() -> Result<(), anyhow::Error> {
4591 let tmp_dir = testutils::setup_test_dir().await?;
4592 let test_path = tmp_dir.as_path();
4593 let src = test_path.join("foo");
4594 let dst = test_path.join("bar");
4595 copy(
4596 &PROGRESS,
4597 &src,
4598 &dst,
4599 &settings_with_delete(None),
4600 &DO_PRESERVE_SETTINGS,
4601 false,
4602 )
4603 .await?;
4604 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
4605
4606 let mut settings = settings_with_delete(delete_on());
4607 settings.dry_run = Some(crate::config::DryRunMode::Brief);
4608 let summary = copy(
4609 &PROGRESS,
4610 &src,
4611 &dst,
4612 &settings,
4613 &DO_PRESERVE_SETTINGS,
4614 false,
4615 )
4616 .await?;
4617
4618 assert!(
4620 dst.join("stale.txt").exists(),
4621 "dry-run must not remove anything"
4622 );
4623 assert_eq!(summary.rm_summary.files_removed, 1);
4625 Ok(())
4626 }
4627
4628 #[tokio::test]
4629 #[traced_test]
4630 async fn delete_does_not_prune_when_source_unreadable() -> Result<(), anyhow::Error> {
4631 let tmp_dir = testutils::setup_test_dir().await?;
4632 let test_path = tmp_dir.as_path();
4633 let src = test_path.join("foo");
4634 let dst = test_path.join("bar");
4635 copy(
4636 &PROGRESS,
4637 &src,
4638 &dst,
4639 &settings_with_delete(None),
4640 &DO_PRESERVE_SETTINGS,
4641 false,
4642 )
4643 .await?;
4644 tokio::fs::write(dst.join("stale.txt"), b"junk").await?;
4645 let original = tokio::fs::metadata(&src).await?.permissions();
4647 tokio::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).await?;
4648
4649 let result = copy(
4650 &PROGRESS,
4651 &src,
4652 &dst,
4653 &settings_with_delete(delete_on()),
4654 &DO_PRESERVE_SETTINGS,
4655 false,
4656 )
4657 .await;
4658
4659 tokio::fs::set_permissions(&src, original).await?;
4661
4662 assert!(result.is_err(), "unreadable source must error");
4663 assert!(
4664 dst.join("stale.txt").exists(),
4665 "destination must not be pruned when source enumeration fails"
4666 );
4667 Ok(())
4668 }
4669}