1use std::os::unix::fs::MetadataExt;
2
3use anyhow::{anyhow, Context};
4use async_recursion::async_recursion;
5use throttle::get_file_iops_tokens;
6use tracing::instrument;
7
8use crate::config::DryRunMode;
9use crate::filecmp;
10use crate::filter::{FilterResult, FilterSettings};
11use crate::preserve;
12use crate::progress;
13use crate::rm;
14use crate::rm::{Settings as RmSettings, Summary as RmSummary};
15
16#[derive(Debug, thiserror::Error)]
27#[error("{source:#}")]
28pub struct Error {
29 #[source]
30 pub source: anyhow::Error,
31 pub summary: Summary,
32}
33
34impl Error {
35 #[must_use]
36 pub fn new(source: anyhow::Error, summary: Summary) -> Self {
37 Error { source, summary }
38 }
39}
40
41#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
46pub enum OverwriteFilter {
47 #[value(name = "newer")]
49 Newer,
50}
51
52impl std::fmt::Display for OverwriteFilter {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match self {
55 OverwriteFilter::Newer => write!(f, "newer"),
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
61pub struct Settings {
62 pub dereference: bool,
63 pub fail_early: bool,
64 pub overwrite: bool,
65 pub overwrite_compare: filecmp::MetadataCmpSettings,
66 pub overwrite_filter: Option<OverwriteFilter>,
67 pub ignore_existing: bool,
68 pub chunk_size: u64,
69 pub remote_copy_buffer_size: usize,
75 pub filter: Option<crate::filter::FilterSettings>,
77 pub dry_run: Option<crate::config::DryRunMode>,
79}
80
81fn report_dry_run_copy(src: &std::path::Path, dst: &std::path::Path, entry_type: &str) {
83 println!("would copy {} {:?} -> {:?}", entry_type, src, dst);
84}
85
86fn report_dry_run_skip(
88 path: &std::path::Path,
89 result: &FilterResult,
90 mode: DryRunMode,
91 entry_type: &str,
92) {
93 match mode {
94 DryRunMode::Brief => { }
95 DryRunMode::All => {
96 println!("skip {} {:?}", entry_type, path);
97 }
98 DryRunMode::Explain => match result {
99 FilterResult::ExcludedByDefault => {
100 println!(
101 "skip {} {:?} (no include pattern matched)",
102 entry_type, path
103 );
104 }
105 FilterResult::ExcludedByPattern(pattern) => {
106 println!("skip {} {:?} (excluded by '{}')", entry_type, path, pattern);
107 }
108 FilterResult::Included => { }
109 },
110 }
111}
112
113fn should_skip_entry(
115 filter: &Option<FilterSettings>,
116 relative_path: &std::path::Path,
117 is_dir: bool,
118) -> Option<FilterResult> {
119 if let Some(ref f) = filter {
120 let result = f.should_include(relative_path, is_dir);
121 match result {
122 FilterResult::Included => None,
123 _ => Some(result),
124 }
125 } else {
126 None
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum EmptyDirAction {
134 Keep,
136 Remove,
138 DryRunSkip,
140}
141
142pub fn check_empty_dir_cleanup(
157 filter: Option<&FilterSettings>,
158 we_created_dir: bool,
159 anything_copied: bool,
160 relative_path: &std::path::Path,
161 is_root: bool,
162 is_dry_run: bool,
163) -> EmptyDirAction {
164 if filter.is_none() || anything_copied {
166 return EmptyDirAction::Keep;
167 }
168 if !we_created_dir {
170 return EmptyDirAction::Keep;
171 }
172 if is_root {
174 return EmptyDirAction::Keep;
175 }
176 let f = filter.unwrap();
178 if f.directly_matches_include(relative_path, true) {
180 return EmptyDirAction::Keep;
181 }
182 if is_dry_run {
184 EmptyDirAction::DryRunSkip
185 } else {
186 EmptyDirAction::Remove
187 }
188}
189
190#[instrument]
191pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
192 let ft1 = md1.file_type();
193 let ft2 = md2.file_type();
194 ft1.is_dir() == ft2.is_dir()
195 && ft1.is_file() == ft2.is_file()
196 && ft1.is_symlink() == ft2.is_symlink()
197}
198
199#[instrument(skip(prog_track, src_metadata, settings, preserve))]
200pub async fn copy_file(
201 prog_track: &'static progress::Progress,
202 src: &std::path::Path,
203 dst: &std::path::Path,
204 src_metadata: &std::fs::Metadata,
205 settings: &Settings,
206 preserve: &preserve::Settings,
207 is_fresh: bool,
208) -> Result<Summary, Error> {
209 if !is_fresh && settings.ignore_existing && tokio::fs::symlink_metadata(dst).await.is_ok() {
212 if let Some(mode) = settings.dry_run {
213 match mode {
214 DryRunMode::Brief => {}
215 DryRunMode::All => println!("skip file {:?}", dst),
216 DryRunMode::Explain => println!("skip file {:?} (destination exists)", dst),
217 }
218 }
219 tracing::debug!("destination exists, skipping (--ignore-existing)");
220 prog_track.files_unchanged.inc();
221 return Ok(Summary {
222 files_unchanged: 1,
223 ..Default::default()
224 });
225 }
226 if settings.dry_run.is_some() {
228 report_dry_run_copy(src, dst, "file");
229 return Ok(Summary {
230 files_copied: 1,
231 bytes_copied: src_metadata.len(),
232 ..Default::default()
233 });
234 }
235 tracing::debug!("opening 'src' for reading and 'dst' for writing");
236 get_file_iops_tokens(settings.chunk_size, src_metadata.size()).await;
237 let mut rm_summary = RmSummary::default();
238 if !is_fresh && dst.exists() {
239 if settings.overwrite {
240 tracing::debug!("file exists, check if it's identical");
241 let dst_metadata = tokio::fs::symlink_metadata(dst)
242 .await
243 .with_context(|| format!("failed reading metadata from {:?}", &dst))
244 .map_err(|err| Error::new(err, Default::default()))?;
245 if is_file_type_same(src_metadata, &dst_metadata) {
246 if filecmp::metadata_equal(&settings.overwrite_compare, src_metadata, &dst_metadata)
247 {
248 tracing::debug!("file is identical, skipping");
249 prog_track.files_unchanged.inc();
250 return Ok(Summary {
251 files_unchanged: 1,
252 ..Default::default()
253 });
254 }
255 if let Some(OverwriteFilter::Newer) = settings.overwrite_filter {
256 if filecmp::dest_is_newer(src_metadata, &dst_metadata) {
257 tracing::debug!("dest is newer than source, skipping");
258 prog_track.files_unchanged.inc();
259 return Ok(Summary {
260 files_unchanged: 1,
261 ..Default::default()
262 });
263 }
264 }
265 }
266 tracing::info!("file is different, removing existing file");
267 rm_summary = rm::rm(
269 prog_track,
270 dst,
271 &RmSettings {
272 fail_early: settings.fail_early,
273 filter: None,
274 dry_run: None,
275 },
276 )
277 .await
278 .map_err(|err| {
279 let rm_summary = err.summary;
280 let copy_summary = Summary {
281 rm_summary,
282 ..Default::default()
283 };
284 Error::new(err.source, copy_summary)
285 })?;
286 } else {
287 return Err(Error::new(
288 anyhow!(
289 "destination {:?} already exists, did you intend to specify --overwrite?",
290 dst
291 ),
292 Default::default(),
293 ));
294 }
295 }
296 tracing::debug!("copying data");
297 let mut copy_summary = Summary {
298 rm_summary,
299 ..Default::default()
300 };
301 tokio::fs::copy(src, dst)
302 .await
303 .with_context(|| format!("failed copying {:?} to {:?}", &src, &dst))
304 .map_err(|err| Error::new(err, copy_summary))?;
305 prog_track.files_copied.inc();
306 prog_track.bytes_copied.add(src_metadata.len());
307 tracing::debug!("setting permissions");
308 preserve::set_file_metadata(preserve, src_metadata, dst)
309 .await
310 .map_err(|err| Error::new(err, copy_summary))?;
311 copy_summary.bytes_copied += src_metadata.len();
313 copy_summary.files_copied += 1;
314 Ok(copy_summary)
315}
316
317#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
318pub struct Summary {
319 pub bytes_copied: u64,
320 pub files_copied: usize,
321 pub symlinks_created: usize,
322 pub directories_created: usize,
323 pub files_unchanged: usize,
324 pub symlinks_unchanged: usize,
325 pub directories_unchanged: usize,
326 pub files_skipped: usize,
327 pub symlinks_skipped: usize,
328 pub directories_skipped: usize,
329 pub rm_summary: RmSummary,
330}
331
332impl std::ops::Add for Summary {
333 type Output = Self;
334 fn add(self, other: Self) -> Self {
335 Self {
336 bytes_copied: self.bytes_copied + other.bytes_copied,
337 files_copied: self.files_copied + other.files_copied,
338 symlinks_created: self.symlinks_created + other.symlinks_created,
339 directories_created: self.directories_created + other.directories_created,
340 files_unchanged: self.files_unchanged + other.files_unchanged,
341 symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
342 directories_unchanged: self.directories_unchanged + other.directories_unchanged,
343 files_skipped: self.files_skipped + other.files_skipped,
344 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
345 directories_skipped: self.directories_skipped + other.directories_skipped,
346 rm_summary: self.rm_summary + other.rm_summary,
347 }
348 }
349}
350
351impl std::fmt::Display for Summary {
352 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
353 write!(
354 f,
355 "copy:\n\
356 -----\n\
357 bytes copied: {}\n\
358 files copied: {}\n\
359 symlinks created: {}\n\
360 directories created: {}\n\
361 files unchanged: {}\n\
362 symlinks unchanged: {}\n\
363 directories unchanged: {}\n\
364 files skipped: {}\n\
365 symlinks skipped: {}\n\
366 directories skipped: {}\n\
367 \n\
368 delete:\n\
369 -------\n\
370 {}",
371 bytesize::ByteSize(self.bytes_copied),
372 self.files_copied,
373 self.symlinks_created,
374 self.directories_created,
375 self.files_unchanged,
376 self.symlinks_unchanged,
377 self.directories_unchanged,
378 self.files_skipped,
379 self.symlinks_skipped,
380 self.directories_skipped,
381 &self.rm_summary,
382 )
383 }
384}
385
386#[instrument(skip(prog_track, settings, preserve))]
389pub async fn copy(
390 prog_track: &'static progress::Progress,
391 src: &std::path::Path,
392 dst: &std::path::Path,
393 settings: &Settings,
394 preserve: &preserve::Settings,
395 is_fresh: bool,
396) -> Result<Summary, Error> {
397 if let Some(ref filter) = settings.filter {
399 let src_name = src.file_name().map(std::path::Path::new);
400 if let Some(name) = src_name {
401 let src_metadata = tokio::fs::symlink_metadata(src)
402 .await
403 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
404 .map_err(|err| Error::new(err, Default::default()))?;
405 let is_dir = src_metadata.is_dir();
406 let result = filter.should_include_root_item(name, is_dir);
407 match result {
408 crate::filter::FilterResult::Included => {}
409 result => {
410 if let Some(mode) = settings.dry_run {
411 let entry_type = if src_metadata.is_dir() {
412 "directory"
413 } else if src_metadata.is_symlink() {
414 "symlink"
415 } else {
416 "file"
417 };
418 report_dry_run_skip(src, &result, mode, entry_type);
419 }
420 let skipped_summary = if src_metadata.is_dir() {
422 prog_track.directories_skipped.inc();
423 Summary {
424 directories_skipped: 1,
425 ..Default::default()
426 }
427 } else if src_metadata.is_symlink() {
428 prog_track.symlinks_skipped.inc();
429 Summary {
430 symlinks_skipped: 1,
431 ..Default::default()
432 }
433 } else {
434 prog_track.files_skipped.inc();
435 Summary {
436 files_skipped: 1,
437 ..Default::default()
438 }
439 };
440 return Ok(skipped_summary);
441 }
442 }
443 }
444 }
445 copy_internal(
446 prog_track, src, dst, src, settings, preserve, is_fresh, None,
447 )
448 .await
449}
450
451#[instrument(skip(prog_track, settings, preserve, open_file_guard))]
452#[async_recursion]
453#[allow(clippy::too_many_arguments)]
454async fn copy_internal(
455 prog_track: &'static progress::Progress,
456 src: &std::path::Path,
457 dst: &std::path::Path,
458 source_root: &std::path::Path,
459 settings: &Settings,
460 preserve: &preserve::Settings,
461 mut is_fresh: bool,
462 open_file_guard: Option<throttle::OpenFileGuard>,
463) -> Result<Summary, Error> {
464 let _ops_guard = prog_track.ops.guard();
465 tracing::debug!("reading source metadata");
466 let src_metadata = tokio::fs::symlink_metadata(src)
467 .await
468 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
469 .map_err(|err| Error::new(err, Default::default()))?;
470 if settings.dereference && src_metadata.is_symlink() {
471 debug_assert!(
472 open_file_guard.is_none(),
473 "open file guard should not be pre-acquired for symlinks"
474 );
475 let link = tokio::fs::canonicalize(&src)
476 .await
477 .with_context(|| format!("failed reading src symlink {:?}", &src))
478 .map_err(|err| Error::new(err, Default::default()))?;
479 return copy(prog_track, &link, dst, settings, preserve, is_fresh).await;
480 }
481 if src_metadata.is_file() {
482 let _guard = match open_file_guard {
484 Some(g) => g,
485 None => throttle::open_file_permit().await,
486 };
487 return copy_file(
488 prog_track,
489 src,
490 dst,
491 &src_metadata,
492 settings,
493 preserve,
494 is_fresh,
495 )
496 .await;
497 }
498 debug_assert!(
499 open_file_guard.is_none(),
500 "open file guard should not be pre-acquired for directories or symlinks"
501 );
502 if src_metadata.is_symlink() {
503 if !is_fresh && settings.ignore_existing && tokio::fs::symlink_metadata(dst).await.is_ok() {
506 if let Some(mode) = settings.dry_run {
507 match mode {
508 DryRunMode::Brief => {}
509 DryRunMode::All => println!("skip symlink {:?}", dst),
510 DryRunMode::Explain => println!("skip symlink {:?} (destination exists)", dst),
511 }
512 }
513 tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
514 prog_track.symlinks_unchanged.inc();
515 return Ok(Summary {
516 symlinks_unchanged: 1,
517 ..Default::default()
518 });
519 }
520 if settings.dry_run.is_some() {
522 report_dry_run_copy(src, dst, "symlink");
523 return Ok(Summary {
524 symlinks_created: 1,
525 ..Default::default()
526 });
527 }
528 let mut rm_summary = RmSummary::default();
529 let link = tokio::fs::read_link(src)
530 .await
531 .with_context(|| format!("failed reading symlink {:?}", &src))
532 .map_err(|err| Error::new(err, Default::default()))?;
533 if let Err(error) = tokio::fs::symlink(&link, dst).await {
535 if settings.ignore_existing && error.kind() == std::io::ErrorKind::AlreadyExists {
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.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
544 let dst_metadata = tokio::fs::symlink_metadata(dst)
545 .await
546 .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
547 .map_err(|err| Error::new(err, Default::default()))?;
548 if is_file_type_same(&src_metadata, &dst_metadata) {
549 let dst_link = tokio::fs::read_link(dst)
550 .await
551 .with_context(|| format!("failed reading dst symlink: {:?}", &dst))
552 .map_err(|err| Error::new(err, Default::default()))?;
553 if link == dst_link {
554 tracing::debug!(
555 "'dst' is a symlink and points to the same location as 'src'"
556 );
557 if preserve.symlink.any() {
558 let dst_metadata = tokio::fs::symlink_metadata(dst)
560 .await
561 .with_context(|| {
562 format!("failed reading metadata from dst: {:?}", &dst)
563 })
564 .map_err(|err| Error::new(err, Default::default()))?;
565 if !filecmp::metadata_equal(
566 &settings.overwrite_compare,
567 &src_metadata,
568 &dst_metadata,
569 ) {
570 tracing::debug!("'dst' metadata is different, updating");
571 preserve::set_symlink_metadata(preserve, &src_metadata, dst)
572 .await
573 .map_err(|err| Error::new(err, Default::default()))?;
574 prog_track.symlinks_removed.inc();
575 prog_track.symlinks_created.inc();
576 return Ok(Summary {
577 rm_summary: RmSummary {
578 symlinks_removed: 1,
579 ..Default::default()
580 },
581 symlinks_created: 1,
582 ..Default::default()
583 });
584 }
585 }
586 tracing::debug!("symlink already exists, skipping");
587 prog_track.symlinks_unchanged.inc();
588 return Ok(Summary {
589 symlinks_unchanged: 1,
590 ..Default::default()
591 });
592 }
593 tracing::debug!("'dst' is a symlink but points to a different path, updating");
594 } else {
595 tracing::info!("'dst' is not a symlink, updating");
596 }
597 rm_summary = rm::rm(
598 prog_track,
599 dst,
600 &RmSettings {
601 fail_early: settings.fail_early,
602 filter: None,
603 dry_run: None,
604 },
605 )
606 .await
607 .map_err(|err| {
608 let rm_summary = err.summary;
609 let copy_summary = Summary {
610 rm_summary,
611 ..Default::default()
612 };
613 Error::new(err.source, copy_summary)
614 })?;
615 tokio::fs::symlink(&link, dst)
616 .await
617 .with_context(|| format!("failed creating symlink {:?}", &dst))
618 .map_err(|err| {
619 let copy_summary = Summary {
620 rm_summary,
621 ..Default::default()
622 };
623 Error::new(err, copy_summary)
624 })?;
625 } else {
626 return Err(Error::new(
627 anyhow!("failed creating symlink {:?}", &dst),
628 Default::default(),
629 ));
630 }
631 }
632 preserve::set_symlink_metadata(preserve, &src_metadata, dst)
633 .await
634 .map_err(|err| {
635 let copy_summary = Summary {
636 rm_summary,
637 ..Default::default()
638 };
639 Error::new(err, copy_summary)
640 })?;
641 prog_track.symlinks_created.inc();
642 return Ok(Summary {
643 rm_summary,
644 symlinks_created: 1,
645 ..Default::default()
646 });
647 }
648 if !src_metadata.is_dir() {
649 return Err(Error::new(
650 anyhow!(
651 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
652 src,
653 dst,
654 src_metadata.file_type()
655 ),
656 Default::default(),
657 ));
658 }
659 if settings.dry_run.is_some() {
661 if settings.ignore_existing
662 && !is_fresh
663 && tokio::fs::symlink_metadata(dst).await.is_ok()
664 && !dst.is_dir()
665 {
666 if let Some(mode) = settings.dry_run {
668 match mode {
669 DryRunMode::Brief => {}
670 DryRunMode::All => println!("skip dir {:?}", dst),
671 DryRunMode::Explain => {
672 println!("skip dir {:?} (destination exists, not a directory)", dst);
673 }
674 }
675 }
676 return Ok(Summary {
677 directories_unchanged: 1,
678 ..Default::default()
679 });
680 }
681 report_dry_run_copy(src, dst, "dir");
682 }
684 tracing::debug!("process contents of 'src' directory");
685 let mut entries = tokio::fs::read_dir(src)
686 .await
687 .with_context(|| format!("cannot open directory {src:?} for reading"))
688 .map_err(|err| Error::new(err, Default::default()))?;
689 let mut copy_summary = if settings.dry_run.is_some() {
691 Summary {
692 directories_created: 1, ..Default::default()
694 }
695 } else if let Err(error) = tokio::fs::create_dir(dst).await {
696 assert!(
697 !is_fresh,
698 "unexpected error creating directory: {dst:?}: {error}"
699 );
700 if (settings.overwrite || settings.ignore_existing)
701 && error.kind() == std::io::ErrorKind::AlreadyExists
702 {
703 let dst_metadata = tokio::fs::symlink_metadata(dst)
708 .await
709 .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
710 .map_err(|err| Error::new(err, Default::default()))?;
711 if dst_metadata.is_dir() {
712 tracing::debug!("'dst' is a directory, leaving it as is");
713 prog_track.directories_unchanged.inc();
714 Summary {
715 directories_unchanged: 1,
716 ..Default::default()
717 }
718 } else if settings.ignore_existing {
719 tracing::debug!("destination exists but is not a directory, skipping subtree (--ignore-existing)");
722 prog_track.directories_unchanged.inc();
723 return Ok(Summary {
724 directories_unchanged: 1,
725 ..Default::default()
726 });
727 } else {
728 tracing::info!("'dst' is not a directory, removing and creating a new one");
729 let rm_summary = rm::rm(
730 prog_track,
731 dst,
732 &RmSettings {
733 fail_early: settings.fail_early,
734 filter: None,
735 dry_run: None,
736 },
737 )
738 .await
739 .map_err(|err| {
740 let rm_summary = err.summary;
741 let copy_summary = Summary {
742 rm_summary,
743 ..Default::default()
744 };
745 Error::new(err.source, copy_summary)
746 })?;
747 tokio::fs::create_dir(dst)
748 .await
749 .with_context(|| format!("cannot create directory {dst:?}"))
750 .map_err(|err| {
751 let copy_summary = Summary {
752 rm_summary,
753 ..Default::default()
754 };
755 Error::new(err, copy_summary)
756 })?;
757 is_fresh = true;
759 prog_track.directories_created.inc();
760 Summary {
761 rm_summary,
762 directories_created: 1,
763 ..Default::default()
764 }
765 }
766 } else {
767 let error = Err::<(), std::io::Error>(error)
768 .with_context(|| format!("cannot create directory {:?}", dst))
769 .unwrap_err();
770 tracing::error!("{:#}", &error);
771 return Err(Error::new(error, Default::default()));
772 }
773 } else {
774 is_fresh = true;
776 prog_track.directories_created.inc();
777 Summary {
778 directories_created: 1,
779 ..Default::default()
780 }
781 };
782 let we_created_this_dir = copy_summary.directories_created == 1;
785 let mut join_set = tokio::task::JoinSet::new();
786 let errors = crate::error_collector::ErrorCollector::default();
787 while let Some(entry) = entries
788 .next_entry()
789 .await
790 .with_context(|| format!("failed traversing src directory {:?}", &src))
791 .map_err(|err| Error::new(err, copy_summary))?
792 {
793 throttle::get_ops_token().await;
797 let entry_path = entry.path();
798 let entry_name = entry_path.file_name().unwrap();
799 let dst_path = dst.join(entry_name);
800 let entry_file_type = entry.file_type().await.ok();
802 let entry_is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
803 let entry_is_symlink = entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false);
804 let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
806 if let Some(skip_result) = should_skip_entry(&settings.filter, relative_path, entry_is_dir)
808 {
809 if let Some(mode) = settings.dry_run {
810 let entry_type = if entry_is_dir {
811 "dir"
812 } else if entry_is_symlink {
813 "symlink"
814 } else {
815 "file"
816 };
817 report_dry_run_skip(&entry_path, &skip_result, mode, entry_type);
818 }
819 tracing::debug!("skipping {:?} due to filter", &entry_path);
820 if entry_is_dir {
822 copy_summary.directories_skipped += 1;
823 prog_track.directories_skipped.inc();
824 } else if entry_is_symlink {
825 copy_summary.symlinks_skipped += 1;
826 prog_track.symlinks_skipped.inc();
827 } else {
828 copy_summary.files_skipped += 1;
829 prog_track.files_skipped.inc();
830 }
831 continue;
832 }
833 let entry_is_regular_file = entry_file_type.as_ref().is_some_and(|ft| ft.is_file());
841 let open_file_guard = if entry_is_regular_file {
842 Some(throttle::open_file_permit().await)
843 } else {
844 None
845 };
846 let settings = settings.clone();
849 let preserve = *preserve;
850 let source_root = source_root.to_owned();
851 let do_copy = || async move {
852 copy_internal(
853 prog_track,
854 &entry_path,
855 &dst_path,
856 &source_root,
857 &settings,
858 &preserve,
859 is_fresh,
860 open_file_guard,
861 )
862 .await
863 };
864 join_set.spawn(do_copy());
865 }
866 drop(entries);
869 while let Some(res) = join_set.join_next().await {
870 match res {
871 Ok(result) => match result {
872 Ok(summary) => copy_summary = copy_summary + summary,
873 Err(error) => {
874 tracing::error!("copy: {:?} -> {:?} failed with: {:#}", src, dst, &error);
875 copy_summary = copy_summary + error.summary;
876 if settings.fail_early {
877 return Err(Error::new(error.source, copy_summary));
878 }
879 errors.push(error.source);
880 }
881 },
882 Err(error) => {
883 if settings.fail_early {
884 return Err(Error::new(error.into(), copy_summary));
885 }
886 errors.push(error.into());
887 }
888 }
889 }
890 let this_dir_count = usize::from(we_created_this_dir);
893 let child_dirs_created = copy_summary
894 .directories_created
895 .saturating_sub(this_dir_count);
896 let anything_copied = copy_summary.files_copied > 0
897 || copy_summary.symlinks_created > 0
898 || child_dirs_created > 0;
899 let relative_path = src.strip_prefix(source_root).unwrap_or(src);
900 let is_root = src == source_root;
901 match check_empty_dir_cleanup(
902 settings.filter.as_ref(),
903 we_created_this_dir,
904 anything_copied,
905 relative_path,
906 is_root,
907 settings.dry_run.is_some(),
908 ) {
909 EmptyDirAction::Keep => { }
910 EmptyDirAction::DryRunSkip => {
911 tracing::debug!(
912 "dry-run: directory {:?} would not be created (nothing to copy inside)",
913 &dst
914 );
915 copy_summary.directories_created = 0;
916 return Ok(copy_summary);
917 }
918 EmptyDirAction::Remove => {
919 tracing::debug!(
920 "directory {:?} has nothing to copy inside, removing empty directory",
921 &dst
922 );
923 match tokio::fs::remove_dir(dst).await {
924 Ok(()) => {
925 copy_summary.directories_created = 0;
926 return Ok(copy_summary);
927 }
928 Err(err) => {
929 tracing::debug!(
931 "failed to remove empty directory {:?}: {:#}, keeping",
932 &dst,
933 &err
934 );
935 }
937 }
938 }
939 }
940 tracing::debug!("set 'dst' directory metadata");
945 let metadata_result = if settings.dry_run.is_some() {
946 Ok(()) } else {
948 preserve::set_dir_metadata(preserve, &src_metadata, dst).await
949 };
950 if errors.has_errors() {
951 if let Err(metadata_err) = metadata_result {
953 tracing::error!(
954 "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
955 src,
956 dst,
957 &metadata_err
958 );
959 }
960 return Err(Error::new(errors.into_error().unwrap(), copy_summary));
962 }
963 metadata_result.map_err(|err| Error::new(err, copy_summary))?;
965 Ok(copy_summary)
966}
967
968#[cfg(test)]
969mod copy_tests {
970 use crate::testutils;
971 use anyhow::Context;
972 use std::os::unix::fs::MetadataExt;
973 use std::os::unix::fs::PermissionsExt;
974 use tracing_test::traced_test;
975
976 use super::*;
977
978 static PROGRESS: std::sync::LazyLock<progress::Progress> =
979 std::sync::LazyLock::new(progress::Progress::new);
980 static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
981 std::sync::LazyLock::new(preserve::preserve_none);
982 static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
983 std::sync::LazyLock::new(preserve::preserve_all);
984
985 #[tokio::test]
986 #[traced_test]
987 async fn check_basic_copy() -> Result<(), anyhow::Error> {
988 let tmp_dir = testutils::setup_test_dir().await?;
989 let test_path = tmp_dir.as_path();
990 let summary = copy(
991 &PROGRESS,
992 &test_path.join("foo"),
993 &test_path.join("bar"),
994 &Settings {
995 dereference: false,
996 fail_early: false,
997 overwrite: false,
998 overwrite_compare: filecmp::MetadataCmpSettings {
999 size: true,
1000 mtime: true,
1001 ..Default::default()
1002 },
1003 overwrite_filter: None,
1004 ignore_existing: false,
1005 chunk_size: 0,
1006 remote_copy_buffer_size: 0,
1007 filter: None,
1008 dry_run: None,
1009 },
1010 &NO_PRESERVE_SETTINGS,
1011 false,
1012 )
1013 .await?;
1014 assert_eq!(summary.files_copied, 5);
1015 assert_eq!(summary.symlinks_created, 2);
1016 assert_eq!(summary.directories_created, 3);
1017 testutils::check_dirs_identical(
1018 &test_path.join("foo"),
1019 &test_path.join("bar"),
1020 testutils::FileEqualityCheck::Basic,
1021 )
1022 .await?;
1023 Ok(())
1024 }
1025
1026 #[tokio::test]
1027 #[traced_test]
1028 async fn no_read_permission() -> Result<(), anyhow::Error> {
1029 let tmp_dir = testutils::setup_test_dir().await?;
1030 let test_path = tmp_dir.as_path();
1031 let filepaths = vec![
1032 test_path.join("foo").join("0.txt"),
1033 test_path.join("foo").join("baz"),
1034 ];
1035 for fpath in &filepaths {
1036 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
1038 }
1039 match copy(
1040 &PROGRESS,
1041 &test_path.join("foo"),
1042 &test_path.join("bar"),
1043 &Settings {
1044 dereference: false,
1045 fail_early: false,
1046 overwrite: false,
1047 overwrite_compare: filecmp::MetadataCmpSettings {
1048 size: true,
1049 mtime: true,
1050 ..Default::default()
1051 },
1052 overwrite_filter: None,
1053 ignore_existing: false,
1054 chunk_size: 0,
1055 remote_copy_buffer_size: 0,
1056 filter: None,
1057 dry_run: None,
1058 },
1059 &NO_PRESERVE_SETTINGS,
1060 false,
1061 )
1062 .await
1063 {
1064 Ok(_) => panic!("Expected the copy to error!"),
1065 Err(error) => {
1066 tracing::info!("{}", &error);
1067 assert_eq!(error.summary.files_copied, 3);
1078 assert_eq!(error.summary.symlinks_created, 0);
1079 assert_eq!(error.summary.directories_created, 2);
1080 }
1081 }
1082 for fpath in &filepaths {
1084 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
1085 if tokio::fs::symlink_metadata(fpath).await?.is_file() {
1086 tokio::fs::remove_file(fpath).await?;
1087 } else {
1088 tokio::fs::remove_dir_all(fpath).await?;
1089 }
1090 }
1091 testutils::check_dirs_identical(
1092 &test_path.join("foo"),
1093 &test_path.join("bar"),
1094 testutils::FileEqualityCheck::Basic,
1095 )
1096 .await?;
1097 Ok(())
1098 }
1099
1100 #[tokio::test]
1101 #[traced_test]
1102 async fn check_default_mode() -> Result<(), anyhow::Error> {
1103 let tmp_dir = testutils::setup_test_dir().await?;
1104 tokio::fs::set_permissions(
1106 tmp_dir.join("foo").join("0.txt"),
1107 std::fs::Permissions::from_mode(0o700),
1108 )
1109 .await?;
1110 let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
1112 tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
1113 .await?;
1114 let test_path = tmp_dir.as_path();
1115 let summary = copy(
1116 &PROGRESS,
1117 &test_path.join("foo"),
1118 &test_path.join("bar"),
1119 &Settings {
1120 dereference: false,
1121 fail_early: false,
1122 overwrite: false,
1123 overwrite_compare: filecmp::MetadataCmpSettings {
1124 size: true,
1125 mtime: true,
1126 ..Default::default()
1127 },
1128 overwrite_filter: None,
1129 ignore_existing: false,
1130 chunk_size: 0,
1131 remote_copy_buffer_size: 0,
1132 filter: None,
1133 dry_run: None,
1134 },
1135 &NO_PRESERVE_SETTINGS,
1136 false,
1137 )
1138 .await?;
1139 assert_eq!(summary.files_copied, 5);
1140 assert_eq!(summary.symlinks_created, 2);
1141 assert_eq!(summary.directories_created, 3);
1142 tokio::fs::set_permissions(
1144 &exec_sticky_file,
1145 std::fs::Permissions::from_mode(
1146 std::fs::symlink_metadata(&exec_sticky_file)?
1147 .permissions()
1148 .mode()
1149 & 0o0777,
1150 ),
1151 )
1152 .await?;
1153 testutils::check_dirs_identical(
1154 &test_path.join("foo"),
1155 &test_path.join("bar"),
1156 testutils::FileEqualityCheck::Basic,
1157 )
1158 .await?;
1159 Ok(())
1160 }
1161
1162 #[tokio::test]
1163 #[traced_test]
1164 async fn no_write_permission() -> Result<(), anyhow::Error> {
1165 let tmp_dir = testutils::setup_test_dir().await?;
1166 let test_path = tmp_dir.as_path();
1167 let non_exec_dir = test_path.join("foo").join("bogey");
1169 tokio::fs::create_dir(&non_exec_dir).await?;
1170 tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
1171 tokio::fs::set_permissions(
1173 &test_path.join("foo").join("baz"),
1174 std::fs::Permissions::from_mode(0o500),
1175 )
1176 .await?;
1177 tokio::fs::set_permissions(
1179 &test_path.join("foo").join("baz").join("4.txt"),
1180 std::fs::Permissions::from_mode(0o440),
1181 )
1182 .await?;
1183 let summary = copy(
1184 &PROGRESS,
1185 &test_path.join("foo"),
1186 &test_path.join("bar"),
1187 &Settings {
1188 dereference: false,
1189 fail_early: false,
1190 overwrite: false,
1191 overwrite_compare: filecmp::MetadataCmpSettings {
1192 size: true,
1193 mtime: true,
1194 ..Default::default()
1195 },
1196 overwrite_filter: None,
1197 ignore_existing: false,
1198 chunk_size: 0,
1199 remote_copy_buffer_size: 0,
1200 filter: None,
1201 dry_run: None,
1202 },
1203 &NO_PRESERVE_SETTINGS,
1204 false,
1205 )
1206 .await?;
1207 assert_eq!(summary.files_copied, 5);
1208 assert_eq!(summary.symlinks_created, 2);
1209 assert_eq!(summary.directories_created, 4);
1210 testutils::check_dirs_identical(
1211 &test_path.join("foo"),
1212 &test_path.join("bar"),
1213 testutils::FileEqualityCheck::Basic,
1214 )
1215 .await?;
1216 Ok(())
1217 }
1218
1219 #[tokio::test]
1220 #[traced_test]
1221 async fn dereference() -> Result<(), anyhow::Error> {
1222 let tmp_dir = testutils::setup_test_dir().await?;
1223 let test_path = tmp_dir.as_path();
1224 let src1 = &test_path.join("foo").join("bar").join("2.txt");
1226 let src2 = &test_path.join("foo").join("bar").join("3.txt");
1227 let test_mode = 0o440;
1228 for f in [src1, src2] {
1229 tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
1230 }
1231 let summary = copy(
1232 &PROGRESS,
1233 &test_path.join("foo"),
1234 &test_path.join("bar"),
1235 &Settings {
1236 dereference: true, fail_early: false,
1238 overwrite: false,
1239 overwrite_compare: filecmp::MetadataCmpSettings {
1240 size: true,
1241 mtime: true,
1242 ..Default::default()
1243 },
1244 overwrite_filter: None,
1245 ignore_existing: false,
1246 chunk_size: 0,
1247 remote_copy_buffer_size: 0,
1248 filter: None,
1249 dry_run: None,
1250 },
1251 &NO_PRESERVE_SETTINGS,
1252 false,
1253 )
1254 .await?;
1255 assert_eq!(summary.files_copied, 7);
1256 assert_eq!(summary.symlinks_created, 0);
1257 assert_eq!(summary.directories_created, 3);
1258 let dst1 = &test_path.join("bar").join("baz").join("5.txt");
1264 let dst2 = &test_path.join("bar").join("baz").join("6.txt");
1265 for f in [dst1, dst2] {
1266 let metadata = tokio::fs::symlink_metadata(f)
1267 .await
1268 .with_context(|| format!("failed reading metadata from {:?}", &f))?;
1269 assert!(metadata.is_file());
1270 assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
1272 }
1273 Ok(())
1274 }
1275
1276 async fn cp_compare(
1277 cp_args: &[&str],
1278 rcp_settings: &Settings,
1279 preserve: bool,
1280 ) -> Result<(), anyhow::Error> {
1281 let tmp_dir = testutils::setup_test_dir().await?;
1282 let test_path = tmp_dir.as_path();
1283 let cp_output = tokio::process::Command::new("cp")
1285 .args(cp_args)
1286 .arg(test_path.join("foo"))
1287 .arg(test_path.join("bar"))
1288 .output()
1289 .await?;
1290 assert!(cp_output.status.success());
1291 let summary = copy(
1293 &PROGRESS,
1294 &test_path.join("foo"),
1295 &test_path.join("baz"),
1296 rcp_settings,
1297 if preserve {
1298 &DO_PRESERVE_SETTINGS
1299 } else {
1300 &NO_PRESERVE_SETTINGS
1301 },
1302 false,
1303 )
1304 .await?;
1305 if rcp_settings.dereference {
1306 assert_eq!(summary.files_copied, 7);
1307 assert_eq!(summary.symlinks_created, 0);
1308 } else {
1309 assert_eq!(summary.files_copied, 5);
1310 assert_eq!(summary.symlinks_created, 2);
1311 }
1312 assert_eq!(summary.directories_created, 3);
1313 testutils::check_dirs_identical(
1314 &test_path.join("bar"),
1315 &test_path.join("baz"),
1316 if preserve {
1317 testutils::FileEqualityCheck::Timestamp
1318 } else {
1319 testutils::FileEqualityCheck::Basic
1320 },
1321 )
1322 .await?;
1323 Ok(())
1324 }
1325
1326 #[tokio::test]
1327 #[traced_test]
1328 async fn test_cp_compat() -> Result<(), anyhow::Error> {
1329 cp_compare(
1330 &["-r"],
1331 &Settings {
1332 dereference: false,
1333 fail_early: false,
1334 overwrite: false,
1335 overwrite_compare: filecmp::MetadataCmpSettings {
1336 size: true,
1337 mtime: true,
1338 ..Default::default()
1339 },
1340 overwrite_filter: None,
1341 ignore_existing: false,
1342 chunk_size: 0,
1343 remote_copy_buffer_size: 0,
1344 filter: None,
1345 dry_run: None,
1346 },
1347 false,
1348 )
1349 .await?;
1350 Ok(())
1351 }
1352
1353 #[tokio::test]
1354 #[traced_test]
1355 async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
1356 cp_compare(
1357 &["-r", "-p"],
1358 &Settings {
1359 dereference: false,
1360 fail_early: false,
1361 overwrite: false,
1362 overwrite_compare: filecmp::MetadataCmpSettings {
1363 size: true,
1364 mtime: true,
1365 ..Default::default()
1366 },
1367 overwrite_filter: None,
1368 ignore_existing: false,
1369 chunk_size: 0,
1370 remote_copy_buffer_size: 0,
1371 filter: None,
1372 dry_run: None,
1373 },
1374 true,
1375 )
1376 .await?;
1377 Ok(())
1378 }
1379
1380 #[tokio::test]
1381 #[traced_test]
1382 async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
1383 cp_compare(
1384 &["-r", "-L"],
1385 &Settings {
1386 dereference: true,
1387 fail_early: false,
1388 overwrite: false,
1389 overwrite_compare: filecmp::MetadataCmpSettings {
1390 size: true,
1391 mtime: true,
1392 ..Default::default()
1393 },
1394 overwrite_filter: None,
1395 ignore_existing: false,
1396 chunk_size: 0,
1397 remote_copy_buffer_size: 0,
1398 filter: None,
1399 dry_run: None,
1400 },
1401 false,
1402 )
1403 .await?;
1404 Ok(())
1405 }
1406
1407 #[tokio::test]
1408 #[traced_test]
1409 async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
1410 cp_compare(
1411 &["-r", "-p", "-L"],
1412 &Settings {
1413 dereference: true,
1414 fail_early: false,
1415 overwrite: false,
1416 overwrite_compare: filecmp::MetadataCmpSettings {
1417 size: true,
1418 mtime: true,
1419 ..Default::default()
1420 },
1421 overwrite_filter: None,
1422 ignore_existing: false,
1423 chunk_size: 0,
1424 remote_copy_buffer_size: 0,
1425 filter: None,
1426 dry_run: None,
1427 },
1428 true,
1429 )
1430 .await?;
1431 Ok(())
1432 }
1433
1434 async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
1435 let tmp_dir = testutils::setup_test_dir().await?;
1436 let test_path = tmp_dir.as_path();
1437 let summary = copy(
1438 &PROGRESS,
1439 &test_path.join("foo"),
1440 &test_path.join("bar"),
1441 &Settings {
1442 dereference: false,
1443 fail_early: false,
1444 overwrite: false,
1445 overwrite_compare: filecmp::MetadataCmpSettings {
1446 size: true,
1447 mtime: true,
1448 ..Default::default()
1449 },
1450 overwrite_filter: None,
1451 ignore_existing: false,
1452 chunk_size: 0,
1453 remote_copy_buffer_size: 0,
1454 filter: None,
1455 dry_run: None,
1456 },
1457 &DO_PRESERVE_SETTINGS,
1458 false,
1459 )
1460 .await?;
1461 assert_eq!(summary.files_copied, 5);
1462 assert_eq!(summary.symlinks_created, 2);
1463 assert_eq!(summary.directories_created, 3);
1464 Ok(tmp_dir)
1465 }
1466
1467 #[tokio::test]
1468 #[traced_test]
1469 async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
1470 let tmp_dir = setup_test_dir_and_copy().await?;
1471 let output_path = &tmp_dir.join("bar");
1472 {
1473 let summary = rm::rm(
1484 &PROGRESS,
1485 &output_path.join("bar"),
1486 &RmSettings {
1487 fail_early: false,
1488 filter: None,
1489 dry_run: None,
1490 },
1491 )
1492 .await?
1493 + rm::rm(
1494 &PROGRESS,
1495 &output_path.join("baz").join("5.txt"),
1496 &RmSettings {
1497 fail_early: false,
1498 filter: None,
1499 dry_run: None,
1500 },
1501 )
1502 .await?;
1503 assert_eq!(summary.files_removed, 3);
1504 assert_eq!(summary.symlinks_removed, 1);
1505 assert_eq!(summary.directories_removed, 1);
1506 }
1507 let summary = copy(
1508 &PROGRESS,
1509 &tmp_dir.join("foo"),
1510 output_path,
1511 &Settings {
1512 dereference: false,
1513 fail_early: false,
1514 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1516 size: true,
1517 mtime: true,
1518 ..Default::default()
1519 },
1520 overwrite_filter: None,
1521 ignore_existing: false,
1522 chunk_size: 0,
1523 remote_copy_buffer_size: 0,
1524 filter: None,
1525 dry_run: None,
1526 },
1527 &DO_PRESERVE_SETTINGS,
1528 false,
1529 )
1530 .await?;
1531 assert_eq!(summary.files_copied, 3);
1532 assert_eq!(summary.symlinks_created, 1);
1533 assert_eq!(summary.directories_created, 1);
1534 testutils::check_dirs_identical(
1535 &tmp_dir.join("foo"),
1536 output_path,
1537 testutils::FileEqualityCheck::Timestamp,
1538 )
1539 .await?;
1540 Ok(())
1541 }
1542
1543 #[tokio::test]
1544 #[traced_test]
1545 async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
1546 let tmp_dir = setup_test_dir_and_copy().await?;
1547 let output_path = &tmp_dir.join("bar");
1548 {
1549 let summary = rm::rm(
1560 &PROGRESS,
1561 &output_path.join("bar").join("1.txt"),
1562 &RmSettings {
1563 fail_early: false,
1564 filter: None,
1565 dry_run: None,
1566 },
1567 )
1568 .await?
1569 + rm::rm(
1570 &PROGRESS,
1571 &output_path.join("baz"),
1572 &RmSettings {
1573 fail_early: false,
1574 filter: None,
1575 dry_run: None,
1576 },
1577 )
1578 .await?;
1579 assert_eq!(summary.files_removed, 2);
1580 assert_eq!(summary.symlinks_removed, 2);
1581 assert_eq!(summary.directories_removed, 1);
1582 }
1583 {
1584 tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
1586 tokio::fs::write(&output_path.join("baz"), "baz").await?;
1588 }
1589 let summary = copy(
1590 &PROGRESS,
1591 &tmp_dir.join("foo"),
1592 output_path,
1593 &Settings {
1594 dereference: false,
1595 fail_early: false,
1596 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1598 size: true,
1599 mtime: true,
1600 ..Default::default()
1601 },
1602 overwrite_filter: None,
1603 ignore_existing: false,
1604 chunk_size: 0,
1605 remote_copy_buffer_size: 0,
1606 filter: None,
1607 dry_run: None,
1608 },
1609 &DO_PRESERVE_SETTINGS,
1610 false,
1611 )
1612 .await?;
1613 assert_eq!(summary.rm_summary.files_removed, 1);
1614 assert_eq!(summary.rm_summary.symlinks_removed, 0);
1615 assert_eq!(summary.rm_summary.directories_removed, 1);
1616 assert_eq!(summary.files_copied, 2);
1617 assert_eq!(summary.symlinks_created, 2);
1618 assert_eq!(summary.directories_created, 1);
1619 testutils::check_dirs_identical(
1620 &tmp_dir.join("foo"),
1621 output_path,
1622 testutils::FileEqualityCheck::Timestamp,
1623 )
1624 .await?;
1625 Ok(())
1626 }
1627
1628 #[tokio::test]
1629 #[traced_test]
1630 async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
1631 let tmp_dir = setup_test_dir_and_copy().await?;
1632 let output_path = &tmp_dir.join("bar");
1633 {
1634 let summary = rm::rm(
1641 &PROGRESS,
1642 &output_path.join("baz").join("4.txt"),
1643 &RmSettings {
1644 fail_early: false,
1645 filter: None,
1646 dry_run: None,
1647 },
1648 )
1649 .await?
1650 + rm::rm(
1651 &PROGRESS,
1652 &output_path.join("baz").join("5.txt"),
1653 &RmSettings {
1654 fail_early: false,
1655 filter: None,
1656 dry_run: None,
1657 },
1658 )
1659 .await?;
1660 assert_eq!(summary.files_removed, 1);
1661 assert_eq!(summary.symlinks_removed, 1);
1662 assert_eq!(summary.directories_removed, 0);
1663 }
1664 {
1665 tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
1667 tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
1669 }
1670 let summary = copy(
1671 &PROGRESS,
1672 &tmp_dir.join("foo"),
1673 output_path,
1674 &Settings {
1675 dereference: false,
1676 fail_early: false,
1677 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1679 size: true,
1680 mtime: true,
1681 ..Default::default()
1682 },
1683 overwrite_filter: None,
1684 ignore_existing: false,
1685 chunk_size: 0,
1686 remote_copy_buffer_size: 0,
1687 filter: None,
1688 dry_run: None,
1689 },
1690 &DO_PRESERVE_SETTINGS,
1691 false,
1692 )
1693 .await?;
1694 assert_eq!(summary.rm_summary.files_removed, 1);
1695 assert_eq!(summary.rm_summary.symlinks_removed, 1);
1696 assert_eq!(summary.rm_summary.directories_removed, 0);
1697 assert_eq!(summary.files_copied, 1);
1698 assert_eq!(summary.symlinks_created, 1);
1699 assert_eq!(summary.directories_created, 0);
1700 testutils::check_dirs_identical(
1701 &tmp_dir.join("foo"),
1702 output_path,
1703 testutils::FileEqualityCheck::Timestamp,
1704 )
1705 .await?;
1706 Ok(())
1707 }
1708
1709 #[tokio::test]
1710 #[traced_test]
1711 async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
1712 let tmp_dir = setup_test_dir_and_copy().await?;
1713 let output_path = &tmp_dir.join("bar");
1714 {
1715 let summary = rm::rm(
1725 &PROGRESS,
1726 &output_path.join("bar"),
1727 &RmSettings {
1728 fail_early: false,
1729 filter: None,
1730 dry_run: None,
1731 },
1732 )
1733 .await?
1734 + rm::rm(
1735 &PROGRESS,
1736 &output_path.join("baz").join("5.txt"),
1737 &RmSettings {
1738 fail_early: false,
1739 filter: None,
1740 dry_run: None,
1741 },
1742 )
1743 .await?;
1744 assert_eq!(summary.files_removed, 3);
1745 assert_eq!(summary.symlinks_removed, 1);
1746 assert_eq!(summary.directories_removed, 1);
1747 }
1748 {
1749 tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
1751 tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
1753 }
1754 let summary = copy(
1755 &PROGRESS,
1756 &tmp_dir.join("foo"),
1757 output_path,
1758 &Settings {
1759 dereference: false,
1760 fail_early: false,
1761 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1763 size: true,
1764 mtime: true,
1765 ..Default::default()
1766 },
1767 overwrite_filter: None,
1768 ignore_existing: false,
1769 chunk_size: 0,
1770 remote_copy_buffer_size: 0,
1771 filter: None,
1772 dry_run: None,
1773 },
1774 &DO_PRESERVE_SETTINGS,
1775 false,
1776 )
1777 .await?;
1778 assert_eq!(summary.rm_summary.files_removed, 0);
1779 assert_eq!(summary.rm_summary.symlinks_removed, 1);
1780 assert_eq!(summary.rm_summary.directories_removed, 1);
1781 assert_eq!(summary.files_copied, 3);
1782 assert_eq!(summary.symlinks_created, 1);
1783 assert_eq!(summary.directories_created, 1);
1784 assert_eq!(summary.files_unchanged, 2);
1785 assert_eq!(summary.symlinks_unchanged, 1);
1786 assert_eq!(summary.directories_unchanged, 2);
1787 testutils::check_dirs_identical(
1788 &tmp_dir.join("foo"),
1789 output_path,
1790 testutils::FileEqualityCheck::Timestamp,
1791 )
1792 .await?;
1793 Ok(())
1794 }
1795
1796 #[tokio::test]
1797 #[traced_test]
1798 async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
1799 let tmp_dir = testutils::setup_test_dir().await?;
1800 let test_path = tmp_dir.as_path();
1801 let summary = copy(
1802 &PROGRESS,
1803 &test_path.join("foo"),
1804 &test_path.join("bar"),
1805 &Settings {
1806 dereference: false,
1807 fail_early: false,
1808 overwrite: false,
1809 overwrite_compare: filecmp::MetadataCmpSettings {
1810 size: true,
1811 mtime: true,
1812 ..Default::default()
1813 },
1814 overwrite_filter: None,
1815 ignore_existing: false,
1816 chunk_size: 0,
1817 remote_copy_buffer_size: 0,
1818 filter: None,
1819 dry_run: None,
1820 },
1821 &NO_PRESERVE_SETTINGS, false,
1823 )
1824 .await?;
1825 assert_eq!(summary.files_copied, 5);
1826 assert_eq!(summary.symlinks_created, 2);
1827 assert_eq!(summary.directories_created, 3);
1828 let source_path = &test_path.join("foo");
1829 let output_path = &tmp_dir.join("bar");
1830 tokio::fs::set_permissions(
1832 &source_path.join("bar"),
1833 std::fs::Permissions::from_mode(0o000),
1834 )
1835 .await?;
1836 tokio::fs::set_permissions(
1837 &source_path.join("baz").join("4.txt"),
1838 std::fs::Permissions::from_mode(0o000),
1839 )
1840 .await?;
1841 match copy(
1849 &PROGRESS,
1850 &tmp_dir.join("foo"),
1851 output_path,
1852 &Settings {
1853 dereference: false,
1854 fail_early: false,
1855 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1857 size: true,
1858 mtime: true,
1859 ..Default::default()
1860 },
1861 overwrite_filter: None,
1862 ignore_existing: false,
1863 chunk_size: 0,
1864 remote_copy_buffer_size: 0,
1865 filter: None,
1866 dry_run: None,
1867 },
1868 &DO_PRESERVE_SETTINGS,
1869 false,
1870 )
1871 .await
1872 {
1873 Ok(_) => panic!("Expected the copy to error!"),
1874 Err(error) => {
1875 tracing::info!("{}", &error);
1876 assert_eq!(error.summary.files_copied, 1);
1877 assert_eq!(error.summary.symlinks_created, 2);
1878 assert_eq!(error.summary.directories_created, 0);
1879 assert_eq!(error.summary.rm_summary.files_removed, 2);
1880 assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
1881 assert_eq!(error.summary.rm_summary.directories_removed, 0);
1882 }
1883 }
1884 Ok(())
1885 }
1886
1887 #[tokio::test]
1888 #[traced_test]
1889 async fn overwrite_filter_newer_skips_when_dest_is_newer() -> Result<(), anyhow::Error> {
1890 let tmp_dir = testutils::create_temp_dir().await?;
1891 let test_path = tmp_dir.as_path();
1892 let src_file = test_path.join("src.txt");
1893 let dst_file = test_path.join("dst.txt");
1894 tokio::fs::write(&dst_file, "newer content").await?;
1896 let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
1898 filetime::set_file_mtime(&dst_file, future_time)?;
1899 tokio::fs::write(&src_file, "older content").await?;
1900 let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
1901 filetime::set_file_mtime(&src_file, past_time)?;
1902 let summary = copy_file(
1903 &PROGRESS,
1904 &src_file,
1905 &dst_file,
1906 &tokio::fs::metadata(&src_file).await?,
1907 &Settings {
1908 dereference: false,
1909 fail_early: false,
1910 overwrite: true,
1911 overwrite_compare: filecmp::MetadataCmpSettings {
1912 size: true,
1913 mtime: true,
1914 ..Default::default()
1915 },
1916 overwrite_filter: Some(OverwriteFilter::Newer),
1917 ignore_existing: false,
1918 chunk_size: 0,
1919 remote_copy_buffer_size: 0,
1920 filter: None,
1921 dry_run: None,
1922 },
1923 &NO_PRESERVE_SETTINGS,
1924 false,
1925 )
1926 .await?;
1927 assert_eq!(summary.files_unchanged, 1);
1928 assert_eq!(summary.files_copied, 0);
1929 let content = tokio::fs::read_to_string(&dst_file).await?;
1931 assert_eq!(content, "newer content");
1932 Ok(())
1933 }
1934
1935 #[tokio::test]
1936 #[traced_test]
1937 async fn overwrite_filter_newer_copies_when_dest_is_older() -> Result<(), anyhow::Error> {
1938 let tmp_dir = testutils::create_temp_dir().await?;
1939 let test_path = tmp_dir.as_path();
1940 let src_file = test_path.join("src.txt");
1941 let dst_file = test_path.join("dst.txt");
1942 tokio::fs::write(&dst_file, "old content").await?;
1944 let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
1945 filetime::set_file_mtime(&dst_file, past_time)?;
1946 tokio::fs::write(&src_file, "new content").await?;
1947 let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
1948 filetime::set_file_mtime(&src_file, future_time)?;
1949 let summary = copy_file(
1950 &PROGRESS,
1951 &src_file,
1952 &dst_file,
1953 &tokio::fs::metadata(&src_file).await?,
1954 &Settings {
1955 dereference: false,
1956 fail_early: false,
1957 overwrite: true,
1958 overwrite_compare: filecmp::MetadataCmpSettings {
1959 size: true,
1960 mtime: true,
1961 ..Default::default()
1962 },
1963 overwrite_filter: Some(OverwriteFilter::Newer),
1964 ignore_existing: false,
1965 chunk_size: 0,
1966 remote_copy_buffer_size: 0,
1967 filter: None,
1968 dry_run: None,
1969 },
1970 &NO_PRESERVE_SETTINGS,
1971 false,
1972 )
1973 .await?;
1974 assert_eq!(summary.files_copied, 1);
1975 assert_eq!(summary.files_unchanged, 0);
1976 let content = tokio::fs::read_to_string(&dst_file).await?;
1978 assert_eq!(content, "new content");
1979 Ok(())
1980 }
1981
1982 #[tokio::test]
1983 #[traced_test]
1984 async fn overwrite_filter_newer_copies_when_same_mtime() -> Result<(), anyhow::Error> {
1985 let tmp_dir = testutils::create_temp_dir().await?;
1986 let test_path = tmp_dir.as_path();
1987 let src_file = test_path.join("src.txt");
1988 let dst_file = test_path.join("dst.txt");
1989 tokio::fs::write(&dst_file, "old").await?;
1991 tokio::fs::write(&src_file, "new content").await?;
1992 let same_time = filetime::FileTime::from_unix_time(1_500_000_000, 0);
1993 filetime::set_file_mtime(&dst_file, same_time)?;
1994 filetime::set_file_mtime(&src_file, same_time)?;
1995 let summary = copy_file(
1996 &PROGRESS,
1997 &src_file,
1998 &dst_file,
1999 &tokio::fs::metadata(&src_file).await?,
2000 &Settings {
2001 dereference: false,
2002 fail_early: false,
2003 overwrite: true,
2004 overwrite_compare: filecmp::MetadataCmpSettings {
2005 size: true,
2006 mtime: true,
2007 ..Default::default()
2008 },
2009 overwrite_filter: Some(OverwriteFilter::Newer),
2010 ignore_existing: false,
2011 chunk_size: 0,
2012 remote_copy_buffer_size: 0,
2013 filter: None,
2014 dry_run: None,
2015 },
2016 &NO_PRESERVE_SETTINGS,
2017 false,
2018 )
2019 .await?;
2020 assert_eq!(summary.files_copied, 1);
2022 assert_eq!(summary.files_unchanged, 0);
2023 let content = tokio::fs::read_to_string(&dst_file).await?;
2024 assert_eq!(content, "new content");
2025 Ok(())
2026 }
2027
2028 #[tokio::test]
2029 #[traced_test]
2030 async fn overwrite_without_filter_copies_when_dest_is_newer() -> Result<(), anyhow::Error> {
2031 let tmp_dir = testutils::create_temp_dir().await?;
2032 let test_path = tmp_dir.as_path();
2033 let src_file = test_path.join("src.txt");
2034 let dst_file = test_path.join("dst.txt");
2035 tokio::fs::write(&dst_file, "newer content").await?;
2037 let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
2038 filetime::set_file_mtime(&dst_file, future_time)?;
2039 tokio::fs::write(&src_file, "older content").await?;
2040 let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
2041 filetime::set_file_mtime(&src_file, past_time)?;
2042 let summary = copy_file(
2043 &PROGRESS,
2044 &src_file,
2045 &dst_file,
2046 &tokio::fs::metadata(&src_file).await?,
2047 &Settings {
2048 dereference: false,
2049 fail_early: false,
2050 overwrite: true,
2051 overwrite_compare: filecmp::MetadataCmpSettings {
2052 size: true,
2053 mtime: true,
2054 ..Default::default()
2055 },
2056 overwrite_filter: None,
2057 ignore_existing: false,
2058 chunk_size: 0,
2059 remote_copy_buffer_size: 0,
2060 filter: None,
2061 dry_run: None,
2062 },
2063 &NO_PRESERVE_SETTINGS,
2064 false,
2065 )
2066 .await?;
2067 assert_eq!(summary.files_copied, 1);
2069 let content = tokio::fs::read_to_string(&dst_file).await?;
2070 assert_eq!(content, "older content");
2071 Ok(())
2072 }
2073
2074 #[tokio::test]
2075 #[traced_test]
2076 async fn ignore_existing_skips_when_dest_exists() -> Result<(), anyhow::Error> {
2077 let tmp_dir = testutils::create_temp_dir().await?;
2078 let test_path = tmp_dir.as_path();
2079 let src_file = test_path.join("src.txt");
2080 let dst_file = test_path.join("dst.txt");
2081 tokio::fs::write(&src_file, "source content").await?;
2082 tokio::fs::write(&dst_file, "dest content").await?;
2083 let summary = copy_file(
2084 &PROGRESS,
2085 &src_file,
2086 &dst_file,
2087 &tokio::fs::metadata(&src_file).await?,
2088 &Settings {
2089 dereference: false,
2090 fail_early: false,
2091 overwrite: false,
2092 overwrite_compare: Default::default(),
2093 overwrite_filter: None,
2094 ignore_existing: true,
2095 chunk_size: 0,
2096 remote_copy_buffer_size: 0,
2097 filter: None,
2098 dry_run: None,
2099 },
2100 &NO_PRESERVE_SETTINGS,
2101 false,
2102 )
2103 .await?;
2104 assert_eq!(summary.files_unchanged, 1);
2105 assert_eq!(summary.files_copied, 0);
2106 let content = tokio::fs::read_to_string(&dst_file).await?;
2108 assert_eq!(content, "dest content");
2109 Ok(())
2110 }
2111
2112 #[tokio::test]
2113 #[traced_test]
2114 async fn ignore_existing_skips_when_dest_is_different_type() -> Result<(), anyhow::Error> {
2115 let tmp_dir = testutils::create_temp_dir().await?;
2116 let test_path = tmp_dir.as_path();
2117 let src_file = test_path.join("src.txt");
2118 let dst_dir = test_path.join("dst.txt");
2119 tokio::fs::write(&src_file, "source content").await?;
2120 tokio::fs::create_dir(&dst_dir).await?;
2122 let summary = copy_file(
2123 &PROGRESS,
2124 &src_file,
2125 &dst_dir,
2126 &tokio::fs::metadata(&src_file).await?,
2127 &Settings {
2128 dereference: false,
2129 fail_early: false,
2130 overwrite: false,
2131 overwrite_compare: Default::default(),
2132 overwrite_filter: None,
2133 ignore_existing: true,
2134 chunk_size: 0,
2135 remote_copy_buffer_size: 0,
2136 filter: None,
2137 dry_run: None,
2138 },
2139 &NO_PRESERVE_SETTINGS,
2140 false,
2141 )
2142 .await?;
2143 assert_eq!(summary.files_unchanged, 1);
2144 assert_eq!(summary.files_copied, 0);
2145 assert!(dst_dir.is_dir());
2147 Ok(())
2148 }
2149
2150 #[tokio::test]
2151 #[traced_test]
2152 async fn ignore_existing_copies_when_dest_missing() -> Result<(), anyhow::Error> {
2153 let tmp_dir = testutils::create_temp_dir().await?;
2154 let test_path = tmp_dir.as_path();
2155 let src_file = test_path.join("src.txt");
2156 let dst_file = test_path.join("dst.txt");
2157 tokio::fs::write(&src_file, "source content").await?;
2158 let summary = copy_file(
2159 &PROGRESS,
2160 &src_file,
2161 &dst_file,
2162 &tokio::fs::metadata(&src_file).await?,
2163 &Settings {
2164 dereference: false,
2165 fail_early: false,
2166 overwrite: false,
2167 overwrite_compare: Default::default(),
2168 overwrite_filter: None,
2169 ignore_existing: true,
2170 chunk_size: 0,
2171 remote_copy_buffer_size: 0,
2172 filter: None,
2173 dry_run: None,
2174 },
2175 &NO_PRESERVE_SETTINGS,
2176 false,
2177 )
2178 .await?;
2179 assert_eq!(summary.files_copied, 1);
2180 let content = tokio::fs::read_to_string(&dst_file).await?;
2181 assert_eq!(content, "source content");
2182 Ok(())
2183 }
2184
2185 #[tokio::test]
2186 #[traced_test]
2187 async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
2188 let tmp_dir = testutils::create_temp_dir().await?;
2190 let test_path = tmp_dir.as_path();
2191 let baz_file = test_path.join("baz_file.txt");
2193 tokio::fs::write(&baz_file, "final content").await?;
2194 let bar_link = test_path.join("bar_link");
2195 let foo_link = test_path.join("foo_link");
2196 tokio::fs::symlink(&baz_file, &bar_link).await?;
2198 tokio::fs::symlink(&bar_link, &foo_link).await?;
2199 let src_dir = test_path.join("src_chain");
2201 tokio::fs::create_dir(&src_dir).await?;
2202 tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
2204 tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
2205 tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
2206 let summary = copy(
2208 &PROGRESS,
2209 &src_dir,
2210 &test_path.join("dst_with_deref"),
2211 &Settings {
2212 dereference: true, fail_early: false,
2214 overwrite: false,
2215 overwrite_compare: filecmp::MetadataCmpSettings {
2216 size: true,
2217 mtime: true,
2218 ..Default::default()
2219 },
2220 overwrite_filter: None,
2221 ignore_existing: false,
2222 chunk_size: 0,
2223 remote_copy_buffer_size: 0,
2224 filter: None,
2225 dry_run: None,
2226 },
2227 &NO_PRESERVE_SETTINGS,
2228 false,
2229 )
2230 .await?;
2231 assert_eq!(summary.files_copied, 3); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1);
2234 let dst_dir = test_path.join("dst_with_deref");
2235 let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
2237 let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
2238 let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
2239 assert_eq!(foo_content, "final content");
2240 assert_eq!(bar_content, "final content");
2241 assert_eq!(baz_content, "final content");
2242 assert!(dst_dir.join("foo").is_file());
2244 assert!(dst_dir.join("bar").is_file());
2245 assert!(dst_dir.join("baz").is_file());
2246 assert!(!dst_dir.join("foo").is_symlink());
2247 assert!(!dst_dir.join("bar").is_symlink());
2248 assert!(!dst_dir.join("baz").is_symlink());
2249 Ok(())
2250 }
2251
2252 #[tokio::test]
2253 #[traced_test]
2254 async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
2255 let tmp_dir = testutils::create_temp_dir().await?;
2256 let test_path = tmp_dir.as_path();
2257 let target_dir = test_path.join("target_dir");
2259 tokio::fs::create_dir(&target_dir).await?;
2260 tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
2261 tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
2263 tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
2264 tokio::fs::set_permissions(
2265 &target_dir.join("file1.txt"),
2266 std::fs::Permissions::from_mode(0o644),
2267 )
2268 .await?;
2269 tokio::fs::set_permissions(
2270 &target_dir.join("file2.txt"),
2271 std::fs::Permissions::from_mode(0o600),
2272 )
2273 .await?;
2274 let dir_symlink = test_path.join("dir_symlink");
2276 tokio::fs::symlink(&target_dir, &dir_symlink).await?;
2277 let summary = copy(
2279 &PROGRESS,
2280 &dir_symlink,
2281 &test_path.join("copied_dir"),
2282 &Settings {
2283 dereference: true, fail_early: false,
2285 overwrite: false,
2286 overwrite_compare: filecmp::MetadataCmpSettings {
2287 size: true,
2288 mtime: true,
2289 ..Default::default()
2290 },
2291 overwrite_filter: None,
2292 ignore_existing: false,
2293 chunk_size: 0,
2294 remote_copy_buffer_size: 0,
2295 filter: None,
2296 dry_run: None,
2297 },
2298 &DO_PRESERVE_SETTINGS,
2299 false,
2300 )
2301 .await?;
2302 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");
2306 assert!(copied_dir.is_dir());
2308 assert!(!copied_dir.is_symlink()); let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
2311 let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
2312 assert_eq!(file1_content, "content1");
2313 assert_eq!(file2_content, "content2");
2314 let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
2316 let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
2317 let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
2318 assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
2319 assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
2320 assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
2321 Ok(())
2322 }
2323
2324 #[tokio::test]
2325 #[traced_test]
2326 async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
2327 let tmp_dir = testutils::create_temp_dir().await?;
2328 let test_path = tmp_dir.as_path();
2329 let file1 = test_path.join("file1.txt");
2331 let file2 = test_path.join("file2.txt");
2332 tokio::fs::write(&file1, "content1").await?;
2333 tokio::fs::write(&file2, "content2").await?;
2334 tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
2335 tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
2336 let symlink1 = test_path.join("symlink1");
2338 let symlink2 = test_path.join("symlink2");
2339 tokio::fs::symlink(&file1, &symlink1).await?;
2340 tokio::fs::symlink(&file2, &symlink2).await?;
2341 let summary1 = copy(
2343 &PROGRESS,
2344 &symlink1,
2345 &test_path.join("copied_file1.txt"),
2346 &Settings {
2347 dereference: true, fail_early: false,
2349 overwrite: false,
2350 overwrite_compare: filecmp::MetadataCmpSettings::default(),
2351 overwrite_filter: None,
2352 ignore_existing: false,
2353 chunk_size: 0,
2354 remote_copy_buffer_size: 0,
2355 filter: None,
2356 dry_run: None,
2357 },
2358 &DO_PRESERVE_SETTINGS, false,
2360 )
2361 .await?;
2362 let summary2 = copy(
2363 &PROGRESS,
2364 &symlink2,
2365 &test_path.join("copied_file2.txt"),
2366 &Settings {
2367 dereference: true,
2368 fail_early: false,
2369 overwrite: false,
2370 overwrite_compare: filecmp::MetadataCmpSettings::default(),
2371 overwrite_filter: None,
2372 ignore_existing: false,
2373 chunk_size: 0,
2374 remote_copy_buffer_size: 0,
2375 filter: None,
2376 dry_run: None,
2377 },
2378 &DO_PRESERVE_SETTINGS,
2379 false,
2380 )
2381 .await?;
2382 assert_eq!(summary1.files_copied, 1);
2383 assert_eq!(summary1.symlinks_created, 0);
2384 assert_eq!(summary2.files_copied, 1);
2385 assert_eq!(summary2.symlinks_created, 0);
2386 let copied1 = test_path.join("copied_file1.txt");
2387 let copied2 = test_path.join("copied_file2.txt");
2388 assert!(copied1.is_file());
2390 assert!(!copied1.is_symlink());
2391 assert!(copied2.is_file());
2392 assert!(!copied2.is_symlink());
2393 let content1 = tokio::fs::read_to_string(&copied1).await?;
2395 let content2 = tokio::fs::read_to_string(&copied2).await?;
2396 assert_eq!(content1, "content1");
2397 assert_eq!(content2, "content2");
2398 let copied1_metadata = tokio::fs::metadata(&copied1).await?;
2400 let copied2_metadata = tokio::fs::metadata(&copied2).await?;
2401 assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
2402 assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
2403 Ok(())
2404 }
2405
2406 #[tokio::test]
2407 #[traced_test]
2408 async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
2409 let tmp_dir = testutils::setup_test_dir().await?;
2410 tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
2412 tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
2414 let summary = copy(
2415 &PROGRESS,
2416 &tmp_dir.join("foo"),
2417 &tmp_dir.join("bar"),
2418 &Settings {
2419 dereference: true, fail_early: false,
2421 overwrite: false,
2422 overwrite_compare: filecmp::MetadataCmpSettings {
2423 size: true,
2424 mtime: true,
2425 ..Default::default()
2426 },
2427 overwrite_filter: None,
2428 ignore_existing: false,
2429 chunk_size: 0,
2430 remote_copy_buffer_size: 0,
2431 filter: None,
2432 dry_run: None,
2433 },
2434 &DO_PRESERVE_SETTINGS,
2435 false,
2436 )
2437 .await?;
2438 assert_eq!(summary.files_copied, 13); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 5);
2441 tokio::process::Command::new("cp")
2443 .args(["-r", "-L"])
2444 .arg(tmp_dir.join("foo"))
2445 .arg(tmp_dir.join("bar-cp"))
2446 .output()
2447 .await?;
2448 testutils::check_dirs_identical(
2449 &tmp_dir.join("bar"),
2450 &tmp_dir.join("bar-cp"),
2451 testutils::FileEqualityCheck::Basic,
2452 )
2453 .await?;
2454 Ok(())
2455 }
2456
2457 mod error_message_tests {
2459 use super::*;
2460
2461 fn get_full_error_message(error: &Error) -> String {
2463 format!("{:#}", error.source)
2464 }
2465
2466 #[tokio::test]
2467 #[traced_test]
2468 async fn test_permission_error_includes_root_cause() -> Result<(), anyhow::Error> {
2469 let tmp_dir = testutils::create_temp_dir().await?;
2470 let unreadable = tmp_dir.join("unreadable.txt");
2471 tokio::fs::write(&unreadable, "test").await?;
2472 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2473
2474 let src_metadata = tokio::fs::symlink_metadata(&unreadable).await?;
2476 let result = copy_file(
2477 &PROGRESS,
2478 &unreadable,
2479 &tmp_dir.join("dest.txt"),
2480 &src_metadata,
2481 &Settings {
2482 dereference: false,
2483 fail_early: false,
2484 overwrite: false,
2485 overwrite_compare: Default::default(),
2486 overwrite_filter: None,
2487 ignore_existing: false,
2488 chunk_size: 0,
2489 remote_copy_buffer_size: 0,
2490 filter: None,
2491 dry_run: None,
2492 },
2493 &NO_PRESERVE_SETTINGS,
2494 false,
2495 )
2496 .await;
2497
2498 assert!(result.is_err(), "Should fail with permission error");
2499 let err_msg = get_full_error_message(&result.unwrap_err());
2500
2501 assert!(
2503 err_msg.to_lowercase().contains("permission")
2504 || err_msg.contains("EACCES")
2505 || err_msg.contains("denied"),
2506 "Error message must include permission-related text. Got: {}",
2507 err_msg
2508 );
2509 Ok(())
2510 }
2511
2512 #[tokio::test]
2513 #[traced_test]
2514 async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
2515 let tmp_dir = testutils::create_temp_dir().await?;
2516
2517 let result = copy(
2518 &PROGRESS,
2519 &tmp_dir.join("does_not_exist.txt"),
2520 &tmp_dir.join("dest.txt"),
2521 &Settings {
2522 dereference: false,
2523 fail_early: false,
2524 overwrite: false,
2525 overwrite_compare: Default::default(),
2526 overwrite_filter: None,
2527 ignore_existing: false,
2528 chunk_size: 0,
2529 remote_copy_buffer_size: 0,
2530 filter: None,
2531 dry_run: None,
2532 },
2533 &NO_PRESERVE_SETTINGS,
2534 false,
2535 )
2536 .await;
2537
2538 assert!(result.is_err());
2539 let err_msg = get_full_error_message(&result.unwrap_err());
2540
2541 assert!(
2542 err_msg.to_lowercase().contains("no such file")
2543 || err_msg.to_lowercase().contains("not found")
2544 || err_msg.contains("ENOENT"),
2545 "Error message must include file not found text. Got: {}",
2546 err_msg
2547 );
2548 Ok(())
2549 }
2550
2551 #[tokio::test]
2552 #[traced_test]
2553 async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
2554 let tmp_dir = testutils::create_temp_dir().await?;
2555 let unreadable_dir = tmp_dir.join("unreadable_dir");
2556 tokio::fs::create_dir(&unreadable_dir).await?;
2557 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
2558 .await?;
2559
2560 let result = copy(
2561 &PROGRESS,
2562 &unreadable_dir,
2563 &tmp_dir.join("dest"),
2564 &Settings {
2565 dereference: false,
2566 fail_early: true,
2567 overwrite: false,
2568 overwrite_compare: Default::default(),
2569 overwrite_filter: None,
2570 ignore_existing: false,
2571 chunk_size: 0,
2572 remote_copy_buffer_size: 0,
2573 filter: None,
2574 dry_run: None,
2575 },
2576 &NO_PRESERVE_SETTINGS,
2577 false,
2578 )
2579 .await;
2580
2581 assert!(result.is_err());
2582 let err_msg = get_full_error_message(&result.unwrap_err());
2583
2584 assert!(
2585 err_msg.to_lowercase().contains("permission")
2586 || err_msg.contains("EACCES")
2587 || err_msg.contains("denied"),
2588 "Error message must include permission-related text. Got: {}",
2589 err_msg
2590 );
2591
2592 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
2594 .await?;
2595 Ok(())
2596 }
2597
2598 #[tokio::test]
2599 #[traced_test]
2600 async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
2601 {
2602 let tmp_dir = testutils::setup_test_dir().await?;
2603 let test_path = tmp_dir.as_path();
2604 let readonly_parent = test_path.join("readonly_dest");
2605 tokio::fs::create_dir(&readonly_parent).await?;
2606 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
2607 .await?;
2608
2609 let result = copy(
2610 &PROGRESS,
2611 &test_path.join("foo"),
2612 &readonly_parent.join("copy"),
2613 &Settings {
2614 dereference: false,
2615 fail_early: true,
2616 overwrite: false,
2617 overwrite_compare: Default::default(),
2618 overwrite_filter: None,
2619 ignore_existing: false,
2620 chunk_size: 0,
2621 remote_copy_buffer_size: 0,
2622 filter: None,
2623 dry_run: None,
2624 },
2625 &NO_PRESERVE_SETTINGS,
2626 false,
2627 )
2628 .await;
2629
2630 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
2632 .await?;
2633
2634 assert!(result.is_err(), "copy into read-only parent should fail");
2635 let err_msg = get_full_error_message(&result.unwrap_err());
2636
2637 assert!(
2638 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
2639 "Error message must include permission denied text. Got: {}",
2640 err_msg
2641 );
2642 Ok(())
2643 }
2644 }
2645
2646 mod empty_dir_cleanup_tests {
2647 use super::*;
2648 use std::path::Path;
2649 #[test]
2650 fn test_check_empty_dir_cleanup_no_filter() {
2651 assert_eq!(
2653 check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
2654 EmptyDirAction::Keep
2655 );
2656 }
2657 #[test]
2658 fn test_check_empty_dir_cleanup_something_copied() {
2659 let mut filter = FilterSettings::new();
2661 filter.add_include("*.txt").unwrap();
2662 assert_eq!(
2663 check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
2664 EmptyDirAction::Keep
2665 );
2666 }
2667 #[test]
2668 fn test_check_empty_dir_cleanup_not_created() {
2669 let mut filter = FilterSettings::new();
2671 filter.add_include("*.txt").unwrap();
2672 assert_eq!(
2673 check_empty_dir_cleanup(
2674 Some(&filter),
2675 false,
2676 false,
2677 Path::new("any"),
2678 false,
2679 false
2680 ),
2681 EmptyDirAction::Keep
2682 );
2683 }
2684 #[test]
2685 fn test_check_empty_dir_cleanup_directly_matched() {
2686 let mut filter = FilterSettings::new();
2688 filter.add_include("target/").unwrap();
2689 assert_eq!(
2690 check_empty_dir_cleanup(
2691 Some(&filter),
2692 true,
2693 false,
2694 Path::new("target"),
2695 false,
2696 false
2697 ),
2698 EmptyDirAction::Keep
2699 );
2700 }
2701 #[test]
2702 fn test_check_empty_dir_cleanup_traversed_only() {
2703 let mut filter = FilterSettings::new();
2705 filter.add_include("*.txt").unwrap();
2706 assert_eq!(
2707 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
2708 EmptyDirAction::Remove
2709 );
2710 }
2711 #[test]
2712 fn test_check_empty_dir_cleanup_dry_run() {
2713 let mut filter = FilterSettings::new();
2715 filter.add_include("*.txt").unwrap();
2716 assert_eq!(
2717 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
2718 EmptyDirAction::DryRunSkip
2719 );
2720 }
2721 #[test]
2722 fn test_check_empty_dir_cleanup_root_always_kept() {
2723 let mut filter = FilterSettings::new();
2725 filter.add_include("*.txt").unwrap();
2726 assert_eq!(
2727 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
2728 EmptyDirAction::Keep
2729 );
2730 }
2731 #[test]
2732 fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
2733 let mut filter = FilterSettings::new();
2735 filter.add_include("*.txt").unwrap();
2736 assert_eq!(
2737 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
2738 EmptyDirAction::Keep
2739 );
2740 }
2741 }
2742
2743 #[tokio::test]
2747 #[traced_test]
2748 async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
2749 let tmp_dir = testutils::create_temp_dir().await?;
2750 let test_path = tmp_dir.as_path();
2751 let src_dir = test_path.join("src");
2753 tokio::fs::create_dir(&src_dir).await?;
2754 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
2755 let readable_file = src_dir.join("readable.txt");
2757 tokio::fs::write(&readable_file, "content").await?;
2758 let unreadable_file = src_dir.join("unreadable.txt");
2759 tokio::fs::write(&unreadable_file, "secret").await?;
2760 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
2761 .await?;
2762 let dst_dir = test_path.join("dst");
2763 let result = copy(
2765 &PROGRESS,
2766 &src_dir,
2767 &dst_dir,
2768 &Settings {
2769 dereference: false,
2770 fail_early: false,
2771 overwrite: false,
2772 overwrite_compare: Default::default(),
2773 overwrite_filter: None,
2774 ignore_existing: false,
2775 chunk_size: 0,
2776 remote_copy_buffer_size: 0,
2777 filter: None,
2778 dry_run: None,
2779 },
2780 &DO_PRESERVE_SETTINGS,
2781 false,
2782 )
2783 .await;
2784 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
2786 .await?;
2787 assert!(result.is_err(), "copy should fail due to unreadable file");
2789 let error = result.unwrap_err();
2790 assert_eq!(error.summary.files_copied, 1);
2792 assert_eq!(error.summary.directories_created, 1);
2793 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2795 assert!(dst_metadata.is_dir());
2796 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
2797 assert_eq!(
2798 actual_mode, 0o750,
2799 "directory should have preserved source permissions (0o750), got {:o}",
2800 actual_mode
2801 );
2802 Ok(())
2803 }
2804
2805 #[tokio::test]
2807 #[traced_test]
2808 async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error(
2809 ) -> Result<(), anyhow::Error> {
2810 let tmp_dir = testutils::create_temp_dir().await?;
2811 let test_path = tmp_dir.as_path();
2812 let src_dir = test_path.join("src");
2813 tokio::fs::create_dir(&src_dir).await?;
2814 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
2815 let unreadable_file = src_dir.join("unreadable.txt");
2816 tokio::fs::write(&unreadable_file, "secret").await?;
2817 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
2818 .await?;
2819 let fixed_secs = 946684800;
2820 let fixed_nsec = 123_456_789;
2821 let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
2822 nix::sys::stat::utimensat(
2823 nix::fcntl::AT_FDCWD,
2824 &src_dir,
2825 &fixed_time,
2826 &fixed_time,
2827 nix::sys::stat::UtimensatFlags::NoFollowSymlink,
2828 )?;
2829 let src_metadata = tokio::fs::metadata(&src_dir).await?;
2830 let dst_dir = test_path.join("dst");
2831 let result = copy(
2832 &PROGRESS,
2833 &src_dir,
2834 &dst_dir,
2835 &Settings {
2836 dereference: false,
2837 fail_early: true,
2838 overwrite: false,
2839 overwrite_compare: Default::default(),
2840 overwrite_filter: None,
2841 ignore_existing: false,
2842 chunk_size: 0,
2843 remote_copy_buffer_size: 0,
2844 filter: None,
2845 dry_run: None,
2846 },
2847 &DO_PRESERVE_SETTINGS,
2848 false,
2849 )
2850 .await;
2851 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
2852 .await?;
2853 assert!(result.is_err(), "copy should fail due to unreadable file");
2854 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2855 assert!(dst_metadata.is_dir());
2856 assert_ne!(
2857 (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
2858 (src_metadata.mtime(), src_metadata.mtime_nsec()),
2859 "fail-early should return before applying preserved directory timestamps"
2860 );
2861 Ok(())
2862 }
2863 mod filter_tests {
2864 use super::*;
2865 use crate::filter::FilterSettings;
2866 #[tokio::test]
2870 #[traced_test]
2871 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
2872 let tmp_dir = testutils::setup_test_dir().await?;
2873 let test_path = tmp_dir.as_path();
2874 let mut filter = FilterSettings::new();
2886 filter.add_include("bar/*.txt").unwrap();
2887 let summary = copy(
2888 &PROGRESS,
2889 &test_path.join("foo"),
2890 &test_path.join("dst"),
2891 &Settings {
2892 dereference: false,
2893 fail_early: false,
2894 overwrite: false,
2895 overwrite_compare: Default::default(),
2896 overwrite_filter: None,
2897 ignore_existing: false,
2898 chunk_size: 0,
2899 remote_copy_buffer_size: 0,
2900 filter: Some(filter),
2901 dry_run: None,
2902 },
2903 &NO_PRESERVE_SETTINGS,
2904 false,
2905 )
2906 .await?;
2907 assert_eq!(
2910 summary.files_copied, 3,
2911 "should copy 3 files matching bar/*.txt"
2912 );
2913 assert!(
2915 test_path.join("dst/bar/1.txt").exists(),
2916 "bar/1.txt should be copied"
2917 );
2918 assert!(
2919 test_path.join("dst/bar/2.txt").exists(),
2920 "bar/2.txt should be copied"
2921 );
2922 assert!(
2923 test_path.join("dst/bar/3.txt").exists(),
2924 "bar/3.txt should be copied"
2925 );
2926 assert!(
2928 !test_path.join("dst/0.txt").exists(),
2929 "0.txt should not be copied"
2930 );
2931 Ok(())
2932 }
2933 #[tokio::test]
2935 #[traced_test]
2936 async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
2937 let tmp_dir = testutils::setup_test_dir().await?;
2938 let test_path = tmp_dir.as_path();
2939 let mut filter = FilterSettings::new();
2941 filter.add_include("/bar/**").unwrap();
2942 let summary = copy(
2943 &PROGRESS,
2944 &test_path.join("foo"),
2945 &test_path.join("dst"),
2946 &Settings {
2947 dereference: false,
2948 fail_early: false,
2949 overwrite: false,
2950 overwrite_compare: Default::default(),
2951 overwrite_filter: None,
2952 ignore_existing: false,
2953 chunk_size: 0,
2954 remote_copy_buffer_size: 0,
2955 filter: Some(filter),
2956 dry_run: None,
2957 },
2958 &NO_PRESERVE_SETTINGS,
2959 false,
2960 )
2961 .await?;
2962 assert!(
2964 test_path.join("dst/bar").exists(),
2965 "bar directory should be copied"
2966 );
2967 assert!(
2968 !test_path.join("dst/baz").exists(),
2969 "baz directory should not be copied"
2970 );
2971 assert!(
2972 !test_path.join("dst/0.txt").exists(),
2973 "0.txt should not be copied"
2974 );
2975 assert_eq!(
2977 summary.files_copied, 3,
2978 "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
2979 );
2980 assert_eq!(
2981 summary.directories_created, 2,
2982 "should create 2 directories (root dst + bar)"
2983 );
2984 assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
2986 assert_eq!(
2987 summary.directories_skipped, 1,
2988 "should skip 1 directory (baz)"
2989 );
2990 Ok(())
2991 }
2992 #[tokio::test]
2994 #[traced_test]
2995 async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
2996 let tmp_dir = testutils::setup_test_dir().await?;
2997 let test_path = tmp_dir.as_path();
2998 let mut filter = FilterSettings::new();
3000 filter.add_include("**/*.txt").unwrap();
3001 let summary = copy(
3002 &PROGRESS,
3003 &test_path.join("foo"),
3004 &test_path.join("dst"),
3005 &Settings {
3006 dereference: false,
3007 fail_early: false,
3008 overwrite: false,
3009 overwrite_compare: Default::default(),
3010 overwrite_filter: None,
3011 ignore_existing: false,
3012 chunk_size: 0,
3013 remote_copy_buffer_size: 0,
3014 filter: Some(filter),
3015 dry_run: None,
3016 },
3017 &NO_PRESERVE_SETTINGS,
3018 false,
3019 )
3020 .await?;
3021 assert_eq!(
3023 summary.files_copied, 5,
3024 "should copy all 5 .txt files with **/*.txt pattern"
3025 );
3026 Ok(())
3027 }
3028 #[tokio::test]
3030 #[traced_test]
3031 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
3032 let tmp_dir = testutils::setup_test_dir().await?;
3033 let test_path = tmp_dir.as_path();
3034 let mut filter = FilterSettings::new();
3036 filter.add_exclude("*.txt").unwrap();
3037 let result = copy(
3038 &PROGRESS,
3039 &test_path.join("foo/0.txt"), &test_path.join("dst.txt"),
3041 &Settings {
3042 dereference: false,
3043 fail_early: false,
3044 overwrite: false,
3045 overwrite_compare: Default::default(),
3046 overwrite_filter: None,
3047 ignore_existing: false,
3048 chunk_size: 0,
3049 remote_copy_buffer_size: 0,
3050 filter: Some(filter),
3051 dry_run: None,
3052 },
3053 &NO_PRESERVE_SETTINGS,
3054 false,
3055 )
3056 .await?;
3057 assert_eq!(
3059 result.files_copied, 0,
3060 "file matching exclude pattern should not be copied"
3061 );
3062 assert!(
3063 !test_path.join("dst.txt").exists(),
3064 "excluded file should not exist at destination"
3065 );
3066 Ok(())
3067 }
3068 #[tokio::test]
3070 #[traced_test]
3071 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
3072 let test_path = testutils::create_temp_dir().await?;
3073 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
3075 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
3076 let mut filter = FilterSettings::new();
3078 filter.add_exclude("*_dir/").unwrap();
3079 let result = copy(
3080 &PROGRESS,
3081 &test_path.join("excluded_dir"),
3082 &test_path.join("dst"),
3083 &Settings {
3084 dereference: false,
3085 fail_early: false,
3086 overwrite: false,
3087 overwrite_compare: Default::default(),
3088 overwrite_filter: None,
3089 ignore_existing: false,
3090 chunk_size: 0,
3091 remote_copy_buffer_size: 0,
3092 filter: Some(filter),
3093 dry_run: None,
3094 },
3095 &NO_PRESERVE_SETTINGS,
3096 false,
3097 )
3098 .await?;
3099 assert_eq!(
3101 result.directories_created, 0,
3102 "root directory matching exclude should not be created"
3103 );
3104 assert!(
3105 !test_path.join("dst").exists(),
3106 "excluded root directory should not exist at destination"
3107 );
3108 Ok(())
3109 }
3110 #[tokio::test]
3112 #[traced_test]
3113 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
3114 let test_path = testutils::create_temp_dir().await?;
3115 tokio::fs::write(test_path.join("target.txt"), "content").await?;
3117 tokio::fs::symlink(
3118 test_path.join("target.txt"),
3119 test_path.join("excluded_link"),
3120 )
3121 .await?;
3122 let mut filter = FilterSettings::new();
3124 filter.add_exclude("*_link").unwrap();
3125 let result = copy(
3126 &PROGRESS,
3127 &test_path.join("excluded_link"),
3128 &test_path.join("dst"),
3129 &Settings {
3130 dereference: false,
3131 fail_early: false,
3132 overwrite: false,
3133 overwrite_compare: Default::default(),
3134 overwrite_filter: None,
3135 ignore_existing: false,
3136 chunk_size: 0,
3137 remote_copy_buffer_size: 0,
3138 filter: Some(filter),
3139 dry_run: None,
3140 },
3141 &NO_PRESERVE_SETTINGS,
3142 false,
3143 )
3144 .await?;
3145 assert_eq!(
3147 result.symlinks_created, 0,
3148 "root symlink matching exclude should not be created"
3149 );
3150 assert!(
3151 !test_path.join("dst").exists(),
3152 "excluded root symlink should not exist at destination"
3153 );
3154 Ok(())
3155 }
3156 #[tokio::test]
3158 #[traced_test]
3159 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
3160 let tmp_dir = testutils::setup_test_dir().await?;
3161 let test_path = tmp_dir.as_path();
3162 let mut filter = FilterSettings::new();
3169 filter.add_include("**/*.txt").unwrap();
3170 filter.add_exclude("bar/2.txt").unwrap();
3171 let summary = copy(
3172 &PROGRESS,
3173 &test_path.join("foo"),
3174 &test_path.join("dst"),
3175 &Settings {
3176 dereference: false,
3177 fail_early: false,
3178 overwrite: false,
3179 overwrite_compare: Default::default(),
3180 overwrite_filter: None,
3181 ignore_existing: false,
3182 chunk_size: 0,
3183 remote_copy_buffer_size: 0,
3184 filter: Some(filter),
3185 dry_run: None,
3186 },
3187 &NO_PRESERVE_SETTINGS,
3188 false,
3189 )
3190 .await?;
3191 assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
3195 assert_eq!(
3196 summary.files_skipped, 1,
3197 "should skip 1 file (bar/2.txt excluded)"
3198 );
3199 assert!(
3201 test_path.join("dst/bar/1.txt").exists(),
3202 "bar/1.txt should be copied"
3203 );
3204 assert!(
3205 !test_path.join("dst/bar/2.txt").exists(),
3206 "bar/2.txt should be excluded"
3207 );
3208 assert!(
3209 test_path.join("dst/bar/3.txt").exists(),
3210 "bar/3.txt should be copied"
3211 );
3212 Ok(())
3213 }
3214 #[tokio::test]
3216 #[traced_test]
3217 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
3218 let tmp_dir = testutils::setup_test_dir().await?;
3219 let test_path = tmp_dir.as_path();
3220 let mut filter = FilterSettings::new();
3227 filter.add_exclude("bar/").unwrap();
3228 let summary = copy(
3229 &PROGRESS,
3230 &test_path.join("foo"),
3231 &test_path.join("dst"),
3232 &Settings {
3233 dereference: false,
3234 fail_early: false,
3235 overwrite: false,
3236 overwrite_compare: Default::default(),
3237 overwrite_filter: None,
3238 ignore_existing: false,
3239 chunk_size: 0,
3240 remote_copy_buffer_size: 0,
3241 filter: Some(filter),
3242 dry_run: None,
3243 },
3244 &NO_PRESERVE_SETTINGS,
3245 false,
3246 )
3247 .await?;
3248 assert_eq!(summary.files_copied, 2, "should copy 2 files");
3252 assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
3253 assert_eq!(
3254 summary.directories_created, 2,
3255 "should create 2 directories"
3256 );
3257 assert_eq!(
3258 summary.directories_skipped, 1,
3259 "should skip 1 directory (bar)"
3260 );
3261 assert_eq!(
3262 summary.files_skipped, 0,
3263 "no files skipped (bar contents not counted)"
3264 );
3265 Ok(())
3266 }
3267 #[tokio::test]
3270 #[traced_test]
3271 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
3272 let test_path = testutils::create_temp_dir().await?;
3273 let src_path = test_path.join("src");
3279 tokio::fs::create_dir(&src_path).await?;
3280 tokio::fs::write(src_path.join("foo"), "content").await?;
3281 tokio::fs::write(src_path.join("bar"), "content").await?;
3282 tokio::fs::create_dir(src_path.join("baz")).await?;
3283 let mut filter = FilterSettings::new();
3285 filter.add_include("foo").unwrap();
3286 let summary = copy(
3287 &PROGRESS,
3288 &src_path,
3289 &test_path.join("dst"),
3290 &Settings {
3291 dereference: false,
3292 fail_early: false,
3293 overwrite: false,
3294 overwrite_compare: Default::default(),
3295 overwrite_filter: None,
3296 ignore_existing: false,
3297 chunk_size: 0,
3298 remote_copy_buffer_size: 0,
3299 filter: Some(filter),
3300 dry_run: None,
3301 },
3302 &NO_PRESERVE_SETTINGS,
3303 false,
3304 )
3305 .await?;
3306 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3308 assert_eq!(
3309 summary.directories_created, 1,
3310 "should create only root directory (not empty 'baz')"
3311 );
3312 assert!(
3314 test_path.join("dst").join("foo").exists(),
3315 "foo should be copied"
3316 );
3317 assert!(
3319 !test_path.join("dst").join("bar").exists(),
3320 "bar should not be copied"
3321 );
3322 assert!(
3324 !test_path.join("dst").join("baz").exists(),
3325 "empty baz directory should NOT be created"
3326 );
3327 Ok(())
3328 }
3329 #[tokio::test]
3332 #[traced_test]
3333 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
3334 let test_path = testutils::create_temp_dir().await?;
3335 let src_path = test_path.join("src");
3342 tokio::fs::create_dir(&src_path).await?;
3343 tokio::fs::write(src_path.join("foo"), "content").await?;
3344 tokio::fs::create_dir(src_path.join("baz")).await?;
3345 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
3346 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
3347 let mut filter = FilterSettings::new();
3349 filter.add_include("foo").unwrap();
3350 let summary = copy(
3351 &PROGRESS,
3352 &src_path,
3353 &test_path.join("dst"),
3354 &Settings {
3355 dereference: false,
3356 fail_early: false,
3357 overwrite: false,
3358 overwrite_compare: Default::default(),
3359 overwrite_filter: None,
3360 ignore_existing: false,
3361 chunk_size: 0,
3362 remote_copy_buffer_size: 0,
3363 filter: Some(filter),
3364 dry_run: None,
3365 },
3366 &NO_PRESERVE_SETTINGS,
3367 false,
3368 )
3369 .await?;
3370 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3372 assert_eq!(
3373 summary.files_skipped, 2,
3374 "should skip 2 files (qux and quux)"
3375 );
3376 assert_eq!(
3377 summary.directories_created, 1,
3378 "should create only root directory (not 'baz' with non-matching content)"
3379 );
3380 assert!(
3382 test_path.join("dst").join("foo").exists(),
3383 "foo should be copied"
3384 );
3385 assert!(
3387 !test_path.join("dst").join("baz").exists(),
3388 "baz directory should NOT be created (no matching content inside)"
3389 );
3390 Ok(())
3391 }
3392 #[tokio::test]
3395 #[traced_test]
3396 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
3397 let test_path = testutils::create_temp_dir().await?;
3398 let src_path = test_path.join("src");
3404 tokio::fs::create_dir(&src_path).await?;
3405 tokio::fs::write(src_path.join("foo"), "content").await?;
3406 tokio::fs::write(src_path.join("bar"), "content").await?;
3407 tokio::fs::create_dir(src_path.join("baz")).await?;
3408 let mut filter = FilterSettings::new();
3410 filter.add_include("foo").unwrap();
3411 let summary = copy(
3412 &PROGRESS,
3413 &src_path,
3414 &test_path.join("dst"),
3415 &Settings {
3416 dereference: false,
3417 fail_early: false,
3418 overwrite: false,
3419 overwrite_compare: Default::default(),
3420 overwrite_filter: None,
3421 ignore_existing: false,
3422 chunk_size: 0,
3423 remote_copy_buffer_size: 0,
3424 filter: Some(filter),
3425 dry_run: Some(crate::config::DryRunMode::Explain),
3426 },
3427 &NO_PRESERVE_SETTINGS,
3428 false,
3429 )
3430 .await?;
3431 assert_eq!(
3433 summary.files_copied, 1,
3434 "should report only 'foo' would be copied"
3435 );
3436 assert_eq!(
3437 summary.directories_created, 1,
3438 "should report only root directory would be created (not empty 'baz')"
3439 );
3440 assert!(
3442 !test_path.join("dst").exists(),
3443 "dst should not exist in dry-run"
3444 );
3445 Ok(())
3446 }
3447 #[tokio::test]
3450 #[traced_test]
3451 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
3452 let test_path = testutils::create_temp_dir().await?;
3453 let src_path = test_path.join("src");
3459 tokio::fs::create_dir(&src_path).await?;
3460 tokio::fs::write(src_path.join("foo"), "content").await?;
3461 tokio::fs::write(src_path.join("bar"), "content").await?;
3462 tokio::fs::create_dir(src_path.join("baz")).await?;
3463 let dst_path = test_path.join("dst");
3465 tokio::fs::create_dir(&dst_path).await?;
3466 tokio::fs::create_dir(dst_path.join("baz")).await?;
3467 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
3469 let mut filter = FilterSettings::new();
3471 filter.add_include("foo").unwrap();
3472 let summary = copy(
3473 &PROGRESS,
3474 &src_path,
3475 &dst_path,
3476 &Settings {
3477 dereference: false,
3478 fail_early: false,
3479 overwrite: true, overwrite_compare: Default::default(),
3481 overwrite_filter: None,
3482 ignore_existing: false,
3483 chunk_size: 0,
3484 remote_copy_buffer_size: 0,
3485 filter: Some(filter),
3486 dry_run: None,
3487 },
3488 &NO_PRESERVE_SETTINGS,
3489 false,
3490 )
3491 .await?;
3492 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3494 assert_eq!(
3496 summary.directories_unchanged, 2,
3497 "root dst and baz directories should be unchanged"
3498 );
3499 assert_eq!(
3500 summary.directories_created, 0,
3501 "should not create any directories"
3502 );
3503 assert!(dst_path.join("foo").exists(), "foo should be copied");
3505 assert!(!dst_path.join("bar").exists(), "bar should not be copied");
3507 assert!(
3509 dst_path.join("baz").exists(),
3510 "existing baz directory should still exist"
3511 );
3512 assert!(
3513 dst_path.join("baz").join("marker.txt").exists(),
3514 "existing content in baz should still exist"
3515 );
3516 Ok(())
3517 }
3518 }
3519 mod dry_run_tests {
3520 use super::*;
3521 #[tokio::test]
3524 #[traced_test]
3525 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
3526 let tmp_dir = testutils::setup_test_dir().await?;
3527 let test_path = tmp_dir.as_path();
3528 let dst_path = test_path.join("nonexistent_dst");
3529 assert!(
3531 !dst_path.exists(),
3532 "destination should not exist before dry-run"
3533 );
3534 let summary = copy(
3535 &PROGRESS,
3536 &test_path.join("foo"),
3537 &dst_path,
3538 &Settings {
3539 dereference: false,
3540 fail_early: false,
3541 overwrite: false,
3542 overwrite_compare: Default::default(),
3543 overwrite_filter: None,
3544 ignore_existing: false,
3545 chunk_size: 0,
3546 remote_copy_buffer_size: 0,
3547 filter: None,
3548 dry_run: Some(crate::config::DryRunMode::Brief),
3549 },
3550 &NO_PRESERVE_SETTINGS,
3551 false,
3552 )
3553 .await?;
3554 assert!(
3556 !dst_path.exists(),
3557 "dry-run should not create destination directory"
3558 );
3559 assert!(
3561 summary.directories_created > 0,
3562 "dry-run should report directories that would be created"
3563 );
3564 assert!(
3565 summary.files_copied > 0,
3566 "dry-run should report files that would be copied"
3567 );
3568 Ok(())
3569 }
3570 #[tokio::test]
3574 #[traced_test]
3575 async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
3576 let test_path = testutils::create_temp_dir().await?;
3577 let src_path = test_path.join("src");
3582 tokio::fs::create_dir(&src_path).await?;
3583 tokio::fs::write(src_path.join("bar.log"), "content").await?;
3584 tokio::fs::create_dir(src_path.join("baz")).await?;
3585 let mut filter = FilterSettings::new();
3587 filter.add_include("*.txt").unwrap();
3588 let dst_path = test_path.join("dst");
3589 let summary = copy(
3590 &PROGRESS,
3591 &src_path,
3592 &dst_path,
3593 &Settings {
3594 dereference: false,
3595 fail_early: false,
3596 overwrite: false,
3597 overwrite_compare: Default::default(),
3598 overwrite_filter: None,
3599 ignore_existing: false,
3600 chunk_size: 0,
3601 remote_copy_buffer_size: 0,
3602 filter: Some(filter),
3603 dry_run: None,
3604 },
3605 &NO_PRESERVE_SETTINGS,
3606 false,
3607 )
3608 .await?;
3609 assert_eq!(summary.files_copied, 0, "no files match *.txt");
3611 assert_eq!(
3613 summary.directories_created, 1,
3614 "root directory should always be created"
3615 );
3616 assert!(dst_path.exists(), "root destination directory should exist");
3617 assert!(
3619 !dst_path.join("baz").exists(),
3620 "empty baz should not be created"
3621 );
3622 Ok(())
3623 }
3624 #[tokio::test]
3626 #[traced_test]
3627 async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
3628 {
3629 let test_path = testutils::create_temp_dir().await?;
3630 let src_path = test_path.join("src");
3631 tokio::fs::create_dir(&src_path).await?;
3632 tokio::fs::write(src_path.join("bar.log"), "content").await?;
3633 let mut filter = FilterSettings::new();
3635 filter.add_include("*.txt").unwrap();
3636 let dst_path = test_path.join("dst");
3637 let summary = copy(
3638 &PROGRESS,
3639 &src_path,
3640 &dst_path,
3641 &Settings {
3642 dereference: false,
3643 fail_early: false,
3644 overwrite: false,
3645 overwrite_compare: Default::default(),
3646 overwrite_filter: None,
3647 ignore_existing: false,
3648 chunk_size: 0,
3649 remote_copy_buffer_size: 0,
3650 filter: Some(filter),
3651 dry_run: Some(crate::config::DryRunMode::Explain),
3652 },
3653 &NO_PRESERVE_SETTINGS,
3654 false,
3655 )
3656 .await?;
3657 assert_eq!(summary.files_copied, 0, "no files match *.txt");
3658 assert_eq!(
3659 summary.directories_created, 1,
3660 "root directory should be counted in dry-run"
3661 );
3662 assert!(
3663 !dst_path.exists(),
3664 "nothing should be created in dry-run mode"
3665 );
3666 Ok(())
3667 }
3668 }
3669
3670 mod max_open_files_tests {
3672 use super::*;
3673
3674 #[tokio::test]
3677 #[traced_test]
3678 async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
3679 let tmp_dir = testutils::create_temp_dir().await?;
3680 let src = tmp_dir.join("src");
3681 let dst = tmp_dir.join("dst");
3682 tokio::fs::create_dir(&src).await?;
3683 let file_count = 200;
3684 for i in 0..file_count {
3685 tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
3686 }
3687 throttle::set_max_open_files(4);
3689 let summary = copy(
3690 &PROGRESS,
3691 &src,
3692 &dst,
3693 &Settings {
3694 dereference: false,
3695 fail_early: true,
3696 overwrite: false,
3697 overwrite_compare: Default::default(),
3698 overwrite_filter: None,
3699 ignore_existing: false,
3700 chunk_size: 0,
3701 remote_copy_buffer_size: 0,
3702 filter: None,
3703 dry_run: None,
3704 },
3705 &NO_PRESERVE_SETTINGS,
3706 false,
3707 )
3708 .await?;
3709 assert_eq!(summary.files_copied, file_count);
3710 assert_eq!(summary.directories_created, 1);
3711 for i in 0..file_count {
3712 let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
3713 assert_eq!(content, format!("content-{}", i));
3714 }
3715 Ok(())
3716 }
3717
3718 #[tokio::test]
3721 #[traced_test]
3722 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
3723 let tmp_dir = testutils::create_temp_dir().await?;
3724 let src = tmp_dir.join("src");
3725 let dst = tmp_dir.join("dst");
3726 let depth = 20;
3727 let files_per_level = 5;
3728 let limit = 4;
3729 let mut dir = src.clone();
3731 for level in 0..depth {
3732 tokio::fs::create_dir_all(&dir).await?;
3733 for f in 0..files_per_level {
3734 tokio::fs::write(
3735 dir.join(format!("f{}_{}.txt", level, f)),
3736 format!("L{}F{}", level, f),
3737 )
3738 .await?;
3739 }
3740 dir = dir.join(format!("d{}", level));
3741 }
3742 throttle::set_max_open_files(limit);
3743 let summary = tokio::time::timeout(
3744 std::time::Duration::from_secs(30),
3745 copy(
3746 &PROGRESS,
3747 &src,
3748 &dst,
3749 &Settings {
3750 dereference: false,
3751 fail_early: true,
3752 overwrite: false,
3753 overwrite_compare: Default::default(),
3754 overwrite_filter: None,
3755 ignore_existing: false,
3756 chunk_size: 0,
3757 remote_copy_buffer_size: 0,
3758 filter: None,
3759 dry_run: None,
3760 },
3761 &NO_PRESERVE_SETTINGS,
3762 false,
3763 ),
3764 )
3765 .await
3766 .context("copy timed out — possible deadlock")?
3767 .context("copy failed")?;
3768 assert_eq!(summary.files_copied, depth * files_per_level);
3769 assert_eq!(summary.directories_created, depth);
3770 let mut check_dir = dst.clone();
3772 for level in 0..depth {
3773 let content =
3774 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
3775 assert_eq!(content, format!("L{}F0", level));
3776 check_dir = check_dir.join(format!("d{}", level));
3777 }
3778 Ok(())
3779 }
3780 }
3781}