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(Debug, Clone)]
42pub struct Settings {
43 pub dereference: bool,
44 pub fail_early: bool,
45 pub overwrite: bool,
46 pub overwrite_compare: filecmp::MetadataCmpSettings,
47 pub chunk_size: u64,
48 pub remote_copy_buffer_size: usize,
54 pub filter: Option<crate::filter::FilterSettings>,
56 pub dry_run: Option<crate::config::DryRunMode>,
58}
59
60fn report_dry_run_copy(src: &std::path::Path, dst: &std::path::Path, entry_type: &str) {
62 println!("would copy {} {:?} -> {:?}", entry_type, src, dst);
63}
64
65fn report_dry_run_skip(
67 path: &std::path::Path,
68 result: &FilterResult,
69 mode: DryRunMode,
70 entry_type: &str,
71) {
72 match mode {
73 DryRunMode::Brief => { }
74 DryRunMode::All => {
75 println!("skip {} {:?}", entry_type, path);
76 }
77 DryRunMode::Explain => match result {
78 FilterResult::ExcludedByDefault => {
79 println!(
80 "skip {} {:?} (no include pattern matched)",
81 entry_type, path
82 );
83 }
84 FilterResult::ExcludedByPattern(pattern) => {
85 println!("skip {} {:?} (excluded by '{}')", entry_type, path, pattern);
86 }
87 FilterResult::Included => { }
88 },
89 }
90}
91
92fn should_skip_entry(
94 filter: &Option<FilterSettings>,
95 relative_path: &std::path::Path,
96 is_dir: bool,
97) -> Option<FilterResult> {
98 if let Some(ref f) = filter {
99 let result = f.should_include(relative_path, is_dir);
100 match result {
101 FilterResult::Included => None,
102 _ => Some(result),
103 }
104 } else {
105 None
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum EmptyDirAction {
113 Keep,
115 Remove,
117 DryRunSkip,
119}
120
121pub fn check_empty_dir_cleanup(
136 filter: Option<&FilterSettings>,
137 we_created_dir: bool,
138 anything_copied: bool,
139 relative_path: &std::path::Path,
140 is_root: bool,
141 is_dry_run: bool,
142) -> EmptyDirAction {
143 if filter.is_none() || anything_copied {
145 return EmptyDirAction::Keep;
146 }
147 if !we_created_dir {
149 return EmptyDirAction::Keep;
150 }
151 if is_root {
153 return EmptyDirAction::Keep;
154 }
155 let f = filter.unwrap();
157 if f.directly_matches_include(relative_path, true) {
159 return EmptyDirAction::Keep;
160 }
161 if is_dry_run {
163 EmptyDirAction::DryRunSkip
164 } else {
165 EmptyDirAction::Remove
166 }
167}
168
169#[instrument]
170pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
171 let ft1 = md1.file_type();
172 let ft2 = md2.file_type();
173 ft1.is_dir() == ft2.is_dir()
174 && ft1.is_file() == ft2.is_file()
175 && ft1.is_symlink() == ft2.is_symlink()
176}
177
178#[instrument(skip(prog_track, src_metadata, settings, preserve))]
179pub async fn copy_file(
180 prog_track: &'static progress::Progress,
181 src: &std::path::Path,
182 dst: &std::path::Path,
183 src_metadata: &std::fs::Metadata,
184 settings: &Settings,
185 preserve: &preserve::Settings,
186 is_fresh: bool,
187) -> Result<Summary, Error> {
188 if settings.dry_run.is_some() {
190 report_dry_run_copy(src, dst, "file");
191 return Ok(Summary {
192 files_copied: 1,
193 bytes_copied: src_metadata.len(),
194 ..Default::default()
195 });
196 }
197 tracing::debug!("opening 'src' for reading and 'dst' for writing");
198 get_file_iops_tokens(settings.chunk_size, src_metadata.size()).await;
199 let mut rm_summary = RmSummary::default();
200 if !is_fresh && dst.exists() {
201 if settings.overwrite {
202 tracing::debug!("file exists, check if it's identical");
203 let dst_metadata = tokio::fs::symlink_metadata(dst)
204 .await
205 .with_context(|| format!("failed reading metadata from {:?}", &dst))
206 .map_err(|err| Error::new(err, Default::default()))?;
207 if is_file_type_same(src_metadata, &dst_metadata)
208 && filecmp::metadata_equal(&settings.overwrite_compare, src_metadata, &dst_metadata)
209 {
210 tracing::debug!("file is identical, skipping");
211 prog_track.files_unchanged.inc();
212 return Ok(Summary {
213 files_unchanged: 1,
214 ..Default::default()
215 });
216 }
217 tracing::info!("file is different, removing existing file");
218 rm_summary = rm::rm(
220 prog_track,
221 dst,
222 &RmSettings {
223 fail_early: settings.fail_early,
224 filter: None,
225 dry_run: None,
226 },
227 )
228 .await
229 .map_err(|err| {
230 let rm_summary = err.summary;
231 let copy_summary = Summary {
232 rm_summary,
233 ..Default::default()
234 };
235 Error::new(err.source, copy_summary)
236 })?;
237 } else {
238 return Err(Error::new(
239 anyhow!(
240 "destination {:?} already exists, did you intend to specify --overwrite?",
241 dst
242 ),
243 Default::default(),
244 ));
245 }
246 }
247 tracing::debug!("copying data");
248 let mut copy_summary = Summary {
249 rm_summary,
250 ..Default::default()
251 };
252 tokio::fs::copy(src, dst)
253 .await
254 .with_context(|| format!("failed copying {:?} to {:?}", &src, &dst))
255 .map_err(|err| Error::new(err, copy_summary))?;
256 prog_track.files_copied.inc();
257 prog_track.bytes_copied.add(src_metadata.len());
258 tracing::debug!("setting permissions");
259 preserve::set_file_metadata(preserve, src_metadata, dst)
260 .await
261 .map_err(|err| Error::new(err, copy_summary))?;
262 copy_summary.bytes_copied += src_metadata.len();
264 copy_summary.files_copied += 1;
265 Ok(copy_summary)
266}
267
268#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
269pub struct Summary {
270 pub bytes_copied: u64,
271 pub files_copied: usize,
272 pub symlinks_created: usize,
273 pub directories_created: usize,
274 pub files_unchanged: usize,
275 pub symlinks_unchanged: usize,
276 pub directories_unchanged: usize,
277 pub files_skipped: usize,
278 pub symlinks_skipped: usize,
279 pub directories_skipped: usize,
280 pub rm_summary: RmSummary,
281}
282
283impl std::ops::Add for Summary {
284 type Output = Self;
285 fn add(self, other: Self) -> Self {
286 Self {
287 bytes_copied: self.bytes_copied + other.bytes_copied,
288 files_copied: self.files_copied + other.files_copied,
289 symlinks_created: self.symlinks_created + other.symlinks_created,
290 directories_created: self.directories_created + other.directories_created,
291 files_unchanged: self.files_unchanged + other.files_unchanged,
292 symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
293 directories_unchanged: self.directories_unchanged + other.directories_unchanged,
294 files_skipped: self.files_skipped + other.files_skipped,
295 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
296 directories_skipped: self.directories_skipped + other.directories_skipped,
297 rm_summary: self.rm_summary + other.rm_summary,
298 }
299 }
300}
301
302impl std::fmt::Display for Summary {
303 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
304 write!(
305 f,
306 "copy:\n\
307 -----\n\
308 bytes copied: {}\n\
309 files copied: {}\n\
310 symlinks created: {}\n\
311 directories created: {}\n\
312 files unchanged: {}\n\
313 symlinks unchanged: {}\n\
314 directories unchanged: {}\n\
315 files skipped: {}\n\
316 symlinks skipped: {}\n\
317 directories skipped: {}\n\
318 \n\
319 delete:\n\
320 -------\n\
321 {}",
322 bytesize::ByteSize(self.bytes_copied),
323 self.files_copied,
324 self.symlinks_created,
325 self.directories_created,
326 self.files_unchanged,
327 self.symlinks_unchanged,
328 self.directories_unchanged,
329 self.files_skipped,
330 self.symlinks_skipped,
331 self.directories_skipped,
332 &self.rm_summary,
333 )
334 }
335}
336
337#[instrument(skip(prog_track, settings, preserve))]
340pub async fn copy(
341 prog_track: &'static progress::Progress,
342 src: &std::path::Path,
343 dst: &std::path::Path,
344 settings: &Settings,
345 preserve: &preserve::Settings,
346 is_fresh: bool,
347) -> Result<Summary, Error> {
348 if let Some(ref filter) = settings.filter {
350 let src_name = src.file_name().map(std::path::Path::new);
351 if let Some(name) = src_name {
352 let src_metadata = tokio::fs::symlink_metadata(src)
353 .await
354 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
355 .map_err(|err| Error::new(err, Default::default()))?;
356 let is_dir = src_metadata.is_dir();
357 let result = filter.should_include_root_item(name, is_dir);
358 match result {
359 crate::filter::FilterResult::Included => {}
360 result => {
361 if let Some(mode) = settings.dry_run {
362 let entry_type = if src_metadata.is_dir() {
363 "directory"
364 } else if src_metadata.is_symlink() {
365 "symlink"
366 } else {
367 "file"
368 };
369 report_dry_run_skip(src, &result, mode, entry_type);
370 }
371 let skipped_summary = if src_metadata.is_dir() {
373 prog_track.directories_skipped.inc();
374 Summary {
375 directories_skipped: 1,
376 ..Default::default()
377 }
378 } else if src_metadata.is_symlink() {
379 prog_track.symlinks_skipped.inc();
380 Summary {
381 symlinks_skipped: 1,
382 ..Default::default()
383 }
384 } else {
385 prog_track.files_skipped.inc();
386 Summary {
387 files_skipped: 1,
388 ..Default::default()
389 }
390 };
391 return Ok(skipped_summary);
392 }
393 }
394 }
395 }
396 copy_internal(
397 prog_track, src, dst, src, settings, preserve, is_fresh, None,
398 )
399 .await
400}
401
402#[instrument(skip(prog_track, settings, preserve, open_file_guard))]
403#[async_recursion]
404#[allow(clippy::too_many_arguments)]
405async fn copy_internal(
406 prog_track: &'static progress::Progress,
407 src: &std::path::Path,
408 dst: &std::path::Path,
409 source_root: &std::path::Path,
410 settings: &Settings,
411 preserve: &preserve::Settings,
412 mut is_fresh: bool,
413 open_file_guard: Option<throttle::OpenFileGuard>,
414) -> Result<Summary, Error> {
415 let _ops_guard = prog_track.ops.guard();
416 tracing::debug!("reading source metadata");
417 let src_metadata = tokio::fs::symlink_metadata(src)
418 .await
419 .with_context(|| format!("failed reading metadata from src: {:?}", &src))
420 .map_err(|err| Error::new(err, Default::default()))?;
421 if settings.dereference && src_metadata.is_symlink() {
422 debug_assert!(
423 open_file_guard.is_none(),
424 "open file guard should not be pre-acquired for symlinks"
425 );
426 let link = tokio::fs::canonicalize(&src)
427 .await
428 .with_context(|| format!("failed reading src symlink {:?}", &src))
429 .map_err(|err| Error::new(err, Default::default()))?;
430 return copy(prog_track, &link, dst, settings, preserve, is_fresh).await;
431 }
432 if src_metadata.is_file() {
433 let _guard = match open_file_guard {
435 Some(g) => g,
436 None => throttle::open_file_permit().await,
437 };
438 return copy_file(
439 prog_track,
440 src,
441 dst,
442 &src_metadata,
443 settings,
444 preserve,
445 is_fresh,
446 )
447 .await;
448 }
449 debug_assert!(
450 open_file_guard.is_none(),
451 "open file guard should not be pre-acquired for directories or symlinks"
452 );
453 if src_metadata.is_symlink() {
454 if settings.dry_run.is_some() {
456 report_dry_run_copy(src, dst, "symlink");
457 return Ok(Summary {
458 symlinks_created: 1,
459 ..Default::default()
460 });
461 }
462 let mut rm_summary = RmSummary::default();
463 let link = tokio::fs::read_link(src)
464 .await
465 .with_context(|| format!("failed reading symlink {:?}", &src))
466 .map_err(|err| Error::new(err, Default::default()))?;
467 if let Err(error) = tokio::fs::symlink(&link, dst).await {
469 if settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
470 let dst_metadata = tokio::fs::symlink_metadata(dst)
471 .await
472 .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
473 .map_err(|err| Error::new(err, Default::default()))?;
474 if is_file_type_same(&src_metadata, &dst_metadata) {
475 let dst_link = tokio::fs::read_link(dst)
476 .await
477 .with_context(|| format!("failed reading dst symlink: {:?}", &dst))
478 .map_err(|err| Error::new(err, Default::default()))?;
479 if link == dst_link {
480 tracing::debug!(
481 "'dst' is a symlink and points to the same location as 'src'"
482 );
483 if preserve.symlink.any() {
484 let dst_metadata = tokio::fs::symlink_metadata(dst)
486 .await
487 .with_context(|| {
488 format!("failed reading metadata from dst: {:?}", &dst)
489 })
490 .map_err(|err| Error::new(err, Default::default()))?;
491 if !filecmp::metadata_equal(
492 &settings.overwrite_compare,
493 &src_metadata,
494 &dst_metadata,
495 ) {
496 tracing::debug!("'dst' metadata is different, updating");
497 preserve::set_symlink_metadata(preserve, &src_metadata, dst)
498 .await
499 .map_err(|err| Error::new(err, Default::default()))?;
500 prog_track.symlinks_removed.inc();
501 prog_track.symlinks_created.inc();
502 return Ok(Summary {
503 rm_summary: RmSummary {
504 symlinks_removed: 1,
505 ..Default::default()
506 },
507 symlinks_created: 1,
508 ..Default::default()
509 });
510 }
511 }
512 tracing::debug!("symlink already exists, skipping");
513 prog_track.symlinks_unchanged.inc();
514 return Ok(Summary {
515 symlinks_unchanged: 1,
516 ..Default::default()
517 });
518 }
519 tracing::debug!("'dst' is a symlink but points to a different path, updating");
520 } else {
521 tracing::info!("'dst' is not a symlink, updating");
522 }
523 rm_summary = rm::rm(
524 prog_track,
525 dst,
526 &RmSettings {
527 fail_early: settings.fail_early,
528 filter: None,
529 dry_run: None,
530 },
531 )
532 .await
533 .map_err(|err| {
534 let rm_summary = err.summary;
535 let copy_summary = Summary {
536 rm_summary,
537 ..Default::default()
538 };
539 Error::new(err.source, copy_summary)
540 })?;
541 tokio::fs::symlink(&link, dst)
542 .await
543 .with_context(|| format!("failed creating symlink {:?}", &dst))
544 .map_err(|err| {
545 let copy_summary = Summary {
546 rm_summary,
547 ..Default::default()
548 };
549 Error::new(err, copy_summary)
550 })?;
551 } else {
552 return Err(Error::new(
553 anyhow!("failed creating symlink {:?}", &dst),
554 Default::default(),
555 ));
556 }
557 }
558 preserve::set_symlink_metadata(preserve, &src_metadata, dst)
559 .await
560 .map_err(|err| {
561 let copy_summary = Summary {
562 rm_summary,
563 ..Default::default()
564 };
565 Error::new(err, copy_summary)
566 })?;
567 prog_track.symlinks_created.inc();
568 return Ok(Summary {
569 rm_summary,
570 symlinks_created: 1,
571 ..Default::default()
572 });
573 }
574 if !src_metadata.is_dir() {
575 return Err(Error::new(
576 anyhow!(
577 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
578 src,
579 dst,
580 src_metadata.file_type()
581 ),
582 Default::default(),
583 ));
584 }
585 if settings.dry_run.is_some() {
587 report_dry_run_copy(src, dst, "dir");
588 }
590 tracing::debug!("process contents of 'src' directory");
591 let mut entries = tokio::fs::read_dir(src)
592 .await
593 .with_context(|| format!("cannot open directory {src:?} for reading"))
594 .map_err(|err| Error::new(err, Default::default()))?;
595 let mut copy_summary = if settings.dry_run.is_some() {
597 Summary {
598 directories_created: 1, ..Default::default()
600 }
601 } else if let Err(error) = tokio::fs::create_dir(dst).await {
602 assert!(
603 !is_fresh,
604 "unexpected error creating directory: {dst:?}: {error}"
605 );
606 if settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
607 let dst_metadata = tokio::fs::metadata(dst)
612 .await
613 .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
614 .map_err(|err| Error::new(err, Default::default()))?;
615 if dst_metadata.is_dir() {
616 tracing::debug!("'dst' is a directory, leaving it as is");
617 prog_track.directories_unchanged.inc();
618 Summary {
619 directories_unchanged: 1,
620 ..Default::default()
621 }
622 } else {
623 tracing::info!("'dst' is not a directory, removing and creating a new one");
624 let rm_summary = rm::rm(
625 prog_track,
626 dst,
627 &RmSettings {
628 fail_early: settings.fail_early,
629 filter: None,
630 dry_run: None,
631 },
632 )
633 .await
634 .map_err(|err| {
635 let rm_summary = err.summary;
636 let copy_summary = Summary {
637 rm_summary,
638 ..Default::default()
639 };
640 Error::new(err.source, copy_summary)
641 })?;
642 tokio::fs::create_dir(dst)
643 .await
644 .with_context(|| format!("cannot create directory {dst:?}"))
645 .map_err(|err| {
646 let copy_summary = Summary {
647 rm_summary,
648 ..Default::default()
649 };
650 Error::new(err, copy_summary)
651 })?;
652 is_fresh = true;
654 prog_track.directories_created.inc();
655 Summary {
656 rm_summary,
657 directories_created: 1,
658 ..Default::default()
659 }
660 }
661 } else {
662 let error = Err::<(), std::io::Error>(error)
663 .with_context(|| format!("cannot create directory {:?}", dst))
664 .unwrap_err();
665 tracing::error!("{:#}", &error);
666 return Err(Error::new(error, Default::default()));
667 }
668 } else {
669 is_fresh = true;
671 prog_track.directories_created.inc();
672 Summary {
673 directories_created: 1,
674 ..Default::default()
675 }
676 };
677 let we_created_this_dir = copy_summary.directories_created == 1;
680 let mut join_set = tokio::task::JoinSet::new();
681 let errors = crate::error_collector::ErrorCollector::default();
682 while let Some(entry) = entries
683 .next_entry()
684 .await
685 .with_context(|| format!("failed traversing src directory {:?}", &src))
686 .map_err(|err| Error::new(err, copy_summary))?
687 {
688 throttle::get_ops_token().await;
692 let entry_path = entry.path();
693 let entry_name = entry_path.file_name().unwrap();
694 let dst_path = dst.join(entry_name);
695 let entry_file_type = entry.file_type().await.ok();
697 let entry_is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
698 let entry_is_symlink = entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false);
699 let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
701 if let Some(skip_result) = should_skip_entry(&settings.filter, relative_path, entry_is_dir)
703 {
704 if let Some(mode) = settings.dry_run {
705 let entry_type = if entry_is_dir {
706 "dir"
707 } else if entry_is_symlink {
708 "symlink"
709 } else {
710 "file"
711 };
712 report_dry_run_skip(&entry_path, &skip_result, mode, entry_type);
713 }
714 tracing::debug!("skipping {:?} due to filter", &entry_path);
715 if entry_is_dir {
717 copy_summary.directories_skipped += 1;
718 prog_track.directories_skipped.inc();
719 } else if entry_is_symlink {
720 copy_summary.symlinks_skipped += 1;
721 prog_track.symlinks_skipped.inc();
722 } else {
723 copy_summary.files_skipped += 1;
724 prog_track.files_skipped.inc();
725 }
726 continue;
727 }
728 let entry_is_regular_file = entry_file_type.as_ref().is_some_and(|ft| ft.is_file());
736 let open_file_guard = if entry_is_regular_file {
737 Some(throttle::open_file_permit().await)
738 } else {
739 None
740 };
741 let settings = settings.clone();
744 let preserve = *preserve;
745 let source_root = source_root.to_owned();
746 let do_copy = || async move {
747 copy_internal(
748 prog_track,
749 &entry_path,
750 &dst_path,
751 &source_root,
752 &settings,
753 &preserve,
754 is_fresh,
755 open_file_guard,
756 )
757 .await
758 };
759 join_set.spawn(do_copy());
760 }
761 drop(entries);
764 while let Some(res) = join_set.join_next().await {
765 match res {
766 Ok(result) => match result {
767 Ok(summary) => copy_summary = copy_summary + summary,
768 Err(error) => {
769 tracing::error!("copy: {:?} -> {:?} failed with: {:#}", src, dst, &error);
770 copy_summary = copy_summary + error.summary;
771 if settings.fail_early {
772 return Err(Error::new(error.source, copy_summary));
773 }
774 errors.push(error.source);
775 }
776 },
777 Err(error) => {
778 if settings.fail_early {
779 return Err(Error::new(error.into(), copy_summary));
780 }
781 errors.push(error.into());
782 }
783 }
784 }
785 let this_dir_count = usize::from(we_created_this_dir);
788 let child_dirs_created = copy_summary
789 .directories_created
790 .saturating_sub(this_dir_count);
791 let anything_copied = copy_summary.files_copied > 0
792 || copy_summary.symlinks_created > 0
793 || child_dirs_created > 0;
794 let relative_path = src.strip_prefix(source_root).unwrap_or(src);
795 let is_root = src == source_root;
796 match check_empty_dir_cleanup(
797 settings.filter.as_ref(),
798 we_created_this_dir,
799 anything_copied,
800 relative_path,
801 is_root,
802 settings.dry_run.is_some(),
803 ) {
804 EmptyDirAction::Keep => { }
805 EmptyDirAction::DryRunSkip => {
806 tracing::debug!(
807 "dry-run: directory {:?} would not be created (nothing to copy inside)",
808 &dst
809 );
810 copy_summary.directories_created = 0;
811 return Ok(copy_summary);
812 }
813 EmptyDirAction::Remove => {
814 tracing::debug!(
815 "directory {:?} has nothing to copy inside, removing empty directory",
816 &dst
817 );
818 match tokio::fs::remove_dir(dst).await {
819 Ok(()) => {
820 copy_summary.directories_created = 0;
821 return Ok(copy_summary);
822 }
823 Err(err) => {
824 tracing::debug!(
826 "failed to remove empty directory {:?}: {:#}, keeping",
827 &dst,
828 &err
829 );
830 }
832 }
833 }
834 }
835 tracing::debug!("set 'dst' directory metadata");
840 let metadata_result = if settings.dry_run.is_some() {
841 Ok(()) } else {
843 preserve::set_dir_metadata(preserve, &src_metadata, dst).await
844 };
845 if errors.has_errors() {
846 if let Err(metadata_err) = metadata_result {
848 tracing::error!(
849 "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
850 src,
851 dst,
852 &metadata_err
853 );
854 }
855 return Err(Error::new(errors.into_error().unwrap(), copy_summary));
857 }
858 metadata_result.map_err(|err| Error::new(err, copy_summary))?;
860 Ok(copy_summary)
861}
862
863#[cfg(test)]
864mod copy_tests {
865 use crate::testutils;
866 use anyhow::Context;
867 use std::os::unix::fs::MetadataExt;
868 use std::os::unix::fs::PermissionsExt;
869 use tracing_test::traced_test;
870
871 use super::*;
872
873 static PROGRESS: std::sync::LazyLock<progress::Progress> =
874 std::sync::LazyLock::new(progress::Progress::new);
875 static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
876 std::sync::LazyLock::new(preserve::preserve_none);
877 static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
878 std::sync::LazyLock::new(preserve::preserve_all);
879
880 #[tokio::test]
881 #[traced_test]
882 async fn check_basic_copy() -> Result<(), anyhow::Error> {
883 let tmp_dir = testutils::setup_test_dir().await?;
884 let test_path = tmp_dir.as_path();
885 let summary = copy(
886 &PROGRESS,
887 &test_path.join("foo"),
888 &test_path.join("bar"),
889 &Settings {
890 dereference: false,
891 fail_early: false,
892 overwrite: false,
893 overwrite_compare: filecmp::MetadataCmpSettings {
894 size: true,
895 mtime: true,
896 ..Default::default()
897 },
898 chunk_size: 0,
899 remote_copy_buffer_size: 0,
900 filter: None,
901 dry_run: None,
902 },
903 &NO_PRESERVE_SETTINGS,
904 false,
905 )
906 .await?;
907 assert_eq!(summary.files_copied, 5);
908 assert_eq!(summary.symlinks_created, 2);
909 assert_eq!(summary.directories_created, 3);
910 testutils::check_dirs_identical(
911 &test_path.join("foo"),
912 &test_path.join("bar"),
913 testutils::FileEqualityCheck::Basic,
914 )
915 .await?;
916 Ok(())
917 }
918
919 #[tokio::test]
920 #[traced_test]
921 async fn no_read_permission() -> Result<(), anyhow::Error> {
922 let tmp_dir = testutils::setup_test_dir().await?;
923 let test_path = tmp_dir.as_path();
924 let filepaths = vec![
925 test_path.join("foo").join("0.txt"),
926 test_path.join("foo").join("baz"),
927 ];
928 for fpath in &filepaths {
929 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
931 }
932 match copy(
933 &PROGRESS,
934 &test_path.join("foo"),
935 &test_path.join("bar"),
936 &Settings {
937 dereference: false,
938 fail_early: false,
939 overwrite: false,
940 overwrite_compare: filecmp::MetadataCmpSettings {
941 size: true,
942 mtime: true,
943 ..Default::default()
944 },
945 chunk_size: 0,
946 remote_copy_buffer_size: 0,
947 filter: None,
948 dry_run: None,
949 },
950 &NO_PRESERVE_SETTINGS,
951 false,
952 )
953 .await
954 {
955 Ok(_) => panic!("Expected the copy to error!"),
956 Err(error) => {
957 tracing::info!("{}", &error);
958 assert_eq!(error.summary.files_copied, 3);
969 assert_eq!(error.summary.symlinks_created, 0);
970 assert_eq!(error.summary.directories_created, 2);
971 }
972 }
973 for fpath in &filepaths {
975 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
976 if tokio::fs::symlink_metadata(fpath).await?.is_file() {
977 tokio::fs::remove_file(fpath).await?;
978 } else {
979 tokio::fs::remove_dir_all(fpath).await?;
980 }
981 }
982 testutils::check_dirs_identical(
983 &test_path.join("foo"),
984 &test_path.join("bar"),
985 testutils::FileEqualityCheck::Basic,
986 )
987 .await?;
988 Ok(())
989 }
990
991 #[tokio::test]
992 #[traced_test]
993 async fn check_default_mode() -> Result<(), anyhow::Error> {
994 let tmp_dir = testutils::setup_test_dir().await?;
995 tokio::fs::set_permissions(
997 tmp_dir.join("foo").join("0.txt"),
998 std::fs::Permissions::from_mode(0o700),
999 )
1000 .await?;
1001 let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
1003 tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
1004 .await?;
1005 let test_path = tmp_dir.as_path();
1006 let summary = copy(
1007 &PROGRESS,
1008 &test_path.join("foo"),
1009 &test_path.join("bar"),
1010 &Settings {
1011 dereference: false,
1012 fail_early: false,
1013 overwrite: false,
1014 overwrite_compare: filecmp::MetadataCmpSettings {
1015 size: true,
1016 mtime: true,
1017 ..Default::default()
1018 },
1019 chunk_size: 0,
1020 remote_copy_buffer_size: 0,
1021 filter: None,
1022 dry_run: None,
1023 },
1024 &NO_PRESERVE_SETTINGS,
1025 false,
1026 )
1027 .await?;
1028 assert_eq!(summary.files_copied, 5);
1029 assert_eq!(summary.symlinks_created, 2);
1030 assert_eq!(summary.directories_created, 3);
1031 tokio::fs::set_permissions(
1033 &exec_sticky_file,
1034 std::fs::Permissions::from_mode(
1035 std::fs::symlink_metadata(&exec_sticky_file)?
1036 .permissions()
1037 .mode()
1038 & 0o0777,
1039 ),
1040 )
1041 .await?;
1042 testutils::check_dirs_identical(
1043 &test_path.join("foo"),
1044 &test_path.join("bar"),
1045 testutils::FileEqualityCheck::Basic,
1046 )
1047 .await?;
1048 Ok(())
1049 }
1050
1051 #[tokio::test]
1052 #[traced_test]
1053 async fn no_write_permission() -> Result<(), anyhow::Error> {
1054 let tmp_dir = testutils::setup_test_dir().await?;
1055 let test_path = tmp_dir.as_path();
1056 let non_exec_dir = test_path.join("foo").join("bogey");
1058 tokio::fs::create_dir(&non_exec_dir).await?;
1059 tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
1060 tokio::fs::set_permissions(
1062 &test_path.join("foo").join("baz"),
1063 std::fs::Permissions::from_mode(0o500),
1064 )
1065 .await?;
1066 tokio::fs::set_permissions(
1068 &test_path.join("foo").join("baz").join("4.txt"),
1069 std::fs::Permissions::from_mode(0o440),
1070 )
1071 .await?;
1072 let summary = copy(
1073 &PROGRESS,
1074 &test_path.join("foo"),
1075 &test_path.join("bar"),
1076 &Settings {
1077 dereference: false,
1078 fail_early: false,
1079 overwrite: false,
1080 overwrite_compare: filecmp::MetadataCmpSettings {
1081 size: true,
1082 mtime: true,
1083 ..Default::default()
1084 },
1085 chunk_size: 0,
1086 remote_copy_buffer_size: 0,
1087 filter: None,
1088 dry_run: None,
1089 },
1090 &NO_PRESERVE_SETTINGS,
1091 false,
1092 )
1093 .await?;
1094 assert_eq!(summary.files_copied, 5);
1095 assert_eq!(summary.symlinks_created, 2);
1096 assert_eq!(summary.directories_created, 4);
1097 testutils::check_dirs_identical(
1098 &test_path.join("foo"),
1099 &test_path.join("bar"),
1100 testutils::FileEqualityCheck::Basic,
1101 )
1102 .await?;
1103 Ok(())
1104 }
1105
1106 #[tokio::test]
1107 #[traced_test]
1108 async fn dereference() -> Result<(), anyhow::Error> {
1109 let tmp_dir = testutils::setup_test_dir().await?;
1110 let test_path = tmp_dir.as_path();
1111 let src1 = &test_path.join("foo").join("bar").join("2.txt");
1113 let src2 = &test_path.join("foo").join("bar").join("3.txt");
1114 let test_mode = 0o440;
1115 for f in [src1, src2] {
1116 tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
1117 }
1118 let summary = copy(
1119 &PROGRESS,
1120 &test_path.join("foo"),
1121 &test_path.join("bar"),
1122 &Settings {
1123 dereference: true, fail_early: false,
1125 overwrite: false,
1126 overwrite_compare: filecmp::MetadataCmpSettings {
1127 size: true,
1128 mtime: true,
1129 ..Default::default()
1130 },
1131 chunk_size: 0,
1132 remote_copy_buffer_size: 0,
1133 filter: None,
1134 dry_run: None,
1135 },
1136 &NO_PRESERVE_SETTINGS,
1137 false,
1138 )
1139 .await?;
1140 assert_eq!(summary.files_copied, 7);
1141 assert_eq!(summary.symlinks_created, 0);
1142 assert_eq!(summary.directories_created, 3);
1143 let dst1 = &test_path.join("bar").join("baz").join("5.txt");
1149 let dst2 = &test_path.join("bar").join("baz").join("6.txt");
1150 for f in [dst1, dst2] {
1151 let metadata = tokio::fs::symlink_metadata(f)
1152 .await
1153 .with_context(|| format!("failed reading metadata from {:?}", &f))?;
1154 assert!(metadata.is_file());
1155 assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
1157 }
1158 Ok(())
1159 }
1160
1161 async fn cp_compare(
1162 cp_args: &[&str],
1163 rcp_settings: &Settings,
1164 preserve: bool,
1165 ) -> Result<(), anyhow::Error> {
1166 let tmp_dir = testutils::setup_test_dir().await?;
1167 let test_path = tmp_dir.as_path();
1168 let cp_output = tokio::process::Command::new("cp")
1170 .args(cp_args)
1171 .arg(test_path.join("foo"))
1172 .arg(test_path.join("bar"))
1173 .output()
1174 .await?;
1175 assert!(cp_output.status.success());
1176 let summary = copy(
1178 &PROGRESS,
1179 &test_path.join("foo"),
1180 &test_path.join("baz"),
1181 rcp_settings,
1182 if preserve {
1183 &DO_PRESERVE_SETTINGS
1184 } else {
1185 &NO_PRESERVE_SETTINGS
1186 },
1187 false,
1188 )
1189 .await?;
1190 if rcp_settings.dereference {
1191 assert_eq!(summary.files_copied, 7);
1192 assert_eq!(summary.symlinks_created, 0);
1193 } else {
1194 assert_eq!(summary.files_copied, 5);
1195 assert_eq!(summary.symlinks_created, 2);
1196 }
1197 assert_eq!(summary.directories_created, 3);
1198 testutils::check_dirs_identical(
1199 &test_path.join("bar"),
1200 &test_path.join("baz"),
1201 if preserve {
1202 testutils::FileEqualityCheck::Timestamp
1203 } else {
1204 testutils::FileEqualityCheck::Basic
1205 },
1206 )
1207 .await?;
1208 Ok(())
1209 }
1210
1211 #[tokio::test]
1212 #[traced_test]
1213 async fn test_cp_compat() -> Result<(), anyhow::Error> {
1214 cp_compare(
1215 &["-r"],
1216 &Settings {
1217 dereference: false,
1218 fail_early: false,
1219 overwrite: false,
1220 overwrite_compare: filecmp::MetadataCmpSettings {
1221 size: true,
1222 mtime: true,
1223 ..Default::default()
1224 },
1225 chunk_size: 0,
1226 remote_copy_buffer_size: 0,
1227 filter: None,
1228 dry_run: None,
1229 },
1230 false,
1231 )
1232 .await?;
1233 Ok(())
1234 }
1235
1236 #[tokio::test]
1237 #[traced_test]
1238 async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
1239 cp_compare(
1240 &["-r", "-p"],
1241 &Settings {
1242 dereference: false,
1243 fail_early: false,
1244 overwrite: false,
1245 overwrite_compare: filecmp::MetadataCmpSettings {
1246 size: true,
1247 mtime: true,
1248 ..Default::default()
1249 },
1250 chunk_size: 0,
1251 remote_copy_buffer_size: 0,
1252 filter: None,
1253 dry_run: None,
1254 },
1255 true,
1256 )
1257 .await?;
1258 Ok(())
1259 }
1260
1261 #[tokio::test]
1262 #[traced_test]
1263 async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
1264 cp_compare(
1265 &["-r", "-L"],
1266 &Settings {
1267 dereference: true,
1268 fail_early: false,
1269 overwrite: false,
1270 overwrite_compare: filecmp::MetadataCmpSettings {
1271 size: true,
1272 mtime: true,
1273 ..Default::default()
1274 },
1275 chunk_size: 0,
1276 remote_copy_buffer_size: 0,
1277 filter: None,
1278 dry_run: None,
1279 },
1280 false,
1281 )
1282 .await?;
1283 Ok(())
1284 }
1285
1286 #[tokio::test]
1287 #[traced_test]
1288 async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
1289 cp_compare(
1290 &["-r", "-p", "-L"],
1291 &Settings {
1292 dereference: true,
1293 fail_early: false,
1294 overwrite: false,
1295 overwrite_compare: filecmp::MetadataCmpSettings {
1296 size: true,
1297 mtime: true,
1298 ..Default::default()
1299 },
1300 chunk_size: 0,
1301 remote_copy_buffer_size: 0,
1302 filter: None,
1303 dry_run: None,
1304 },
1305 true,
1306 )
1307 .await?;
1308 Ok(())
1309 }
1310
1311 async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
1312 let tmp_dir = testutils::setup_test_dir().await?;
1313 let test_path = tmp_dir.as_path();
1314 let summary = copy(
1315 &PROGRESS,
1316 &test_path.join("foo"),
1317 &test_path.join("bar"),
1318 &Settings {
1319 dereference: false,
1320 fail_early: false,
1321 overwrite: false,
1322 overwrite_compare: filecmp::MetadataCmpSettings {
1323 size: true,
1324 mtime: true,
1325 ..Default::default()
1326 },
1327 chunk_size: 0,
1328 remote_copy_buffer_size: 0,
1329 filter: None,
1330 dry_run: None,
1331 },
1332 &DO_PRESERVE_SETTINGS,
1333 false,
1334 )
1335 .await?;
1336 assert_eq!(summary.files_copied, 5);
1337 assert_eq!(summary.symlinks_created, 2);
1338 assert_eq!(summary.directories_created, 3);
1339 Ok(tmp_dir)
1340 }
1341
1342 #[tokio::test]
1343 #[traced_test]
1344 async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
1345 let tmp_dir = setup_test_dir_and_copy().await?;
1346 let output_path = &tmp_dir.join("bar");
1347 {
1348 let summary = rm::rm(
1359 &PROGRESS,
1360 &output_path.join("bar"),
1361 &RmSettings {
1362 fail_early: false,
1363 filter: None,
1364 dry_run: None,
1365 },
1366 )
1367 .await?
1368 + rm::rm(
1369 &PROGRESS,
1370 &output_path.join("baz").join("5.txt"),
1371 &RmSettings {
1372 fail_early: false,
1373 filter: None,
1374 dry_run: None,
1375 },
1376 )
1377 .await?;
1378 assert_eq!(summary.files_removed, 3);
1379 assert_eq!(summary.symlinks_removed, 1);
1380 assert_eq!(summary.directories_removed, 1);
1381 }
1382 let summary = copy(
1383 &PROGRESS,
1384 &tmp_dir.join("foo"),
1385 output_path,
1386 &Settings {
1387 dereference: false,
1388 fail_early: false,
1389 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1391 size: true,
1392 mtime: true,
1393 ..Default::default()
1394 },
1395 chunk_size: 0,
1396 remote_copy_buffer_size: 0,
1397 filter: None,
1398 dry_run: None,
1399 },
1400 &DO_PRESERVE_SETTINGS,
1401 false,
1402 )
1403 .await?;
1404 assert_eq!(summary.files_copied, 3);
1405 assert_eq!(summary.symlinks_created, 1);
1406 assert_eq!(summary.directories_created, 1);
1407 testutils::check_dirs_identical(
1408 &tmp_dir.join("foo"),
1409 output_path,
1410 testutils::FileEqualityCheck::Timestamp,
1411 )
1412 .await?;
1413 Ok(())
1414 }
1415
1416 #[tokio::test]
1417 #[traced_test]
1418 async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
1419 let tmp_dir = setup_test_dir_and_copy().await?;
1420 let output_path = &tmp_dir.join("bar");
1421 {
1422 let summary = rm::rm(
1433 &PROGRESS,
1434 &output_path.join("bar").join("1.txt"),
1435 &RmSettings {
1436 fail_early: false,
1437 filter: None,
1438 dry_run: None,
1439 },
1440 )
1441 .await?
1442 + rm::rm(
1443 &PROGRESS,
1444 &output_path.join("baz"),
1445 &RmSettings {
1446 fail_early: false,
1447 filter: None,
1448 dry_run: None,
1449 },
1450 )
1451 .await?;
1452 assert_eq!(summary.files_removed, 2);
1453 assert_eq!(summary.symlinks_removed, 2);
1454 assert_eq!(summary.directories_removed, 1);
1455 }
1456 {
1457 tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
1459 tokio::fs::write(&output_path.join("baz"), "baz").await?;
1461 }
1462 let summary = copy(
1463 &PROGRESS,
1464 &tmp_dir.join("foo"),
1465 output_path,
1466 &Settings {
1467 dereference: false,
1468 fail_early: false,
1469 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1471 size: true,
1472 mtime: true,
1473 ..Default::default()
1474 },
1475 chunk_size: 0,
1476 remote_copy_buffer_size: 0,
1477 filter: None,
1478 dry_run: None,
1479 },
1480 &DO_PRESERVE_SETTINGS,
1481 false,
1482 )
1483 .await?;
1484 assert_eq!(summary.rm_summary.files_removed, 1);
1485 assert_eq!(summary.rm_summary.symlinks_removed, 0);
1486 assert_eq!(summary.rm_summary.directories_removed, 1);
1487 assert_eq!(summary.files_copied, 2);
1488 assert_eq!(summary.symlinks_created, 2);
1489 assert_eq!(summary.directories_created, 1);
1490 testutils::check_dirs_identical(
1491 &tmp_dir.join("foo"),
1492 output_path,
1493 testutils::FileEqualityCheck::Timestamp,
1494 )
1495 .await?;
1496 Ok(())
1497 }
1498
1499 #[tokio::test]
1500 #[traced_test]
1501 async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
1502 let tmp_dir = setup_test_dir_and_copy().await?;
1503 let output_path = &tmp_dir.join("bar");
1504 {
1505 let summary = rm::rm(
1512 &PROGRESS,
1513 &output_path.join("baz").join("4.txt"),
1514 &RmSettings {
1515 fail_early: false,
1516 filter: None,
1517 dry_run: None,
1518 },
1519 )
1520 .await?
1521 + rm::rm(
1522 &PROGRESS,
1523 &output_path.join("baz").join("5.txt"),
1524 &RmSettings {
1525 fail_early: false,
1526 filter: None,
1527 dry_run: None,
1528 },
1529 )
1530 .await?;
1531 assert_eq!(summary.files_removed, 1);
1532 assert_eq!(summary.symlinks_removed, 1);
1533 assert_eq!(summary.directories_removed, 0);
1534 }
1535 {
1536 tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
1538 tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
1540 }
1541 let summary = copy(
1542 &PROGRESS,
1543 &tmp_dir.join("foo"),
1544 output_path,
1545 &Settings {
1546 dereference: false,
1547 fail_early: false,
1548 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1550 size: true,
1551 mtime: true,
1552 ..Default::default()
1553 },
1554 chunk_size: 0,
1555 remote_copy_buffer_size: 0,
1556 filter: None,
1557 dry_run: None,
1558 },
1559 &DO_PRESERVE_SETTINGS,
1560 false,
1561 )
1562 .await?;
1563 assert_eq!(summary.rm_summary.files_removed, 1);
1564 assert_eq!(summary.rm_summary.symlinks_removed, 1);
1565 assert_eq!(summary.rm_summary.directories_removed, 0);
1566 assert_eq!(summary.files_copied, 1);
1567 assert_eq!(summary.symlinks_created, 1);
1568 assert_eq!(summary.directories_created, 0);
1569 testutils::check_dirs_identical(
1570 &tmp_dir.join("foo"),
1571 output_path,
1572 testutils::FileEqualityCheck::Timestamp,
1573 )
1574 .await?;
1575 Ok(())
1576 }
1577
1578 #[tokio::test]
1579 #[traced_test]
1580 async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
1581 let tmp_dir = setup_test_dir_and_copy().await?;
1582 let output_path = &tmp_dir.join("bar");
1583 {
1584 let summary = rm::rm(
1594 &PROGRESS,
1595 &output_path.join("bar"),
1596 &RmSettings {
1597 fail_early: false,
1598 filter: None,
1599 dry_run: None,
1600 },
1601 )
1602 .await?
1603 + rm::rm(
1604 &PROGRESS,
1605 &output_path.join("baz").join("5.txt"),
1606 &RmSettings {
1607 fail_early: false,
1608 filter: None,
1609 dry_run: None,
1610 },
1611 )
1612 .await?;
1613 assert_eq!(summary.files_removed, 3);
1614 assert_eq!(summary.symlinks_removed, 1);
1615 assert_eq!(summary.directories_removed, 1);
1616 }
1617 {
1618 tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
1620 tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
1622 }
1623 let summary = copy(
1624 &PROGRESS,
1625 &tmp_dir.join("foo"),
1626 output_path,
1627 &Settings {
1628 dereference: false,
1629 fail_early: false,
1630 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1632 size: true,
1633 mtime: true,
1634 ..Default::default()
1635 },
1636 chunk_size: 0,
1637 remote_copy_buffer_size: 0,
1638 filter: None,
1639 dry_run: None,
1640 },
1641 &DO_PRESERVE_SETTINGS,
1642 false,
1643 )
1644 .await?;
1645 assert_eq!(summary.rm_summary.files_removed, 0);
1646 assert_eq!(summary.rm_summary.symlinks_removed, 1);
1647 assert_eq!(summary.rm_summary.directories_removed, 1);
1648 assert_eq!(summary.files_copied, 3);
1649 assert_eq!(summary.symlinks_created, 1);
1650 assert_eq!(summary.directories_created, 1);
1651 assert_eq!(summary.files_unchanged, 2);
1652 assert_eq!(summary.symlinks_unchanged, 1);
1653 assert_eq!(summary.directories_unchanged, 2);
1654 testutils::check_dirs_identical(
1655 &tmp_dir.join("foo"),
1656 output_path,
1657 testutils::FileEqualityCheck::Timestamp,
1658 )
1659 .await?;
1660 Ok(())
1661 }
1662
1663 #[tokio::test]
1664 #[traced_test]
1665 async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
1666 let tmp_dir = testutils::setup_test_dir().await?;
1667 let test_path = tmp_dir.as_path();
1668 let summary = copy(
1669 &PROGRESS,
1670 &test_path.join("foo"),
1671 &test_path.join("bar"),
1672 &Settings {
1673 dereference: false,
1674 fail_early: false,
1675 overwrite: false,
1676 overwrite_compare: filecmp::MetadataCmpSettings {
1677 size: true,
1678 mtime: true,
1679 ..Default::default()
1680 },
1681 chunk_size: 0,
1682 remote_copy_buffer_size: 0,
1683 filter: None,
1684 dry_run: None,
1685 },
1686 &NO_PRESERVE_SETTINGS, false,
1688 )
1689 .await?;
1690 assert_eq!(summary.files_copied, 5);
1691 assert_eq!(summary.symlinks_created, 2);
1692 assert_eq!(summary.directories_created, 3);
1693 let source_path = &test_path.join("foo");
1694 let output_path = &tmp_dir.join("bar");
1695 tokio::fs::set_permissions(
1697 &source_path.join("bar"),
1698 std::fs::Permissions::from_mode(0o000),
1699 )
1700 .await?;
1701 tokio::fs::set_permissions(
1702 &source_path.join("baz").join("4.txt"),
1703 std::fs::Permissions::from_mode(0o000),
1704 )
1705 .await?;
1706 match copy(
1714 &PROGRESS,
1715 &tmp_dir.join("foo"),
1716 output_path,
1717 &Settings {
1718 dereference: false,
1719 fail_early: false,
1720 overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
1722 size: true,
1723 mtime: true,
1724 ..Default::default()
1725 },
1726 chunk_size: 0,
1727 remote_copy_buffer_size: 0,
1728 filter: None,
1729 dry_run: None,
1730 },
1731 &DO_PRESERVE_SETTINGS,
1732 false,
1733 )
1734 .await
1735 {
1736 Ok(_) => panic!("Expected the copy to error!"),
1737 Err(error) => {
1738 tracing::info!("{}", &error);
1739 assert_eq!(error.summary.files_copied, 1);
1740 assert_eq!(error.summary.symlinks_created, 2);
1741 assert_eq!(error.summary.directories_created, 0);
1742 assert_eq!(error.summary.rm_summary.files_removed, 2);
1743 assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
1744 assert_eq!(error.summary.rm_summary.directories_removed, 0);
1745 }
1746 }
1747 Ok(())
1748 }
1749
1750 #[tokio::test]
1751 #[traced_test]
1752 async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
1753 let tmp_dir = testutils::create_temp_dir().await?;
1755 let test_path = tmp_dir.as_path();
1756 let baz_file = test_path.join("baz_file.txt");
1758 tokio::fs::write(&baz_file, "final content").await?;
1759 let bar_link = test_path.join("bar_link");
1760 let foo_link = test_path.join("foo_link");
1761 tokio::fs::symlink(&baz_file, &bar_link).await?;
1763 tokio::fs::symlink(&bar_link, &foo_link).await?;
1764 let src_dir = test_path.join("src_chain");
1766 tokio::fs::create_dir(&src_dir).await?;
1767 tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
1769 tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
1770 tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
1771 let summary = copy(
1773 &PROGRESS,
1774 &src_dir,
1775 &test_path.join("dst_with_deref"),
1776 &Settings {
1777 dereference: true, fail_early: false,
1779 overwrite: false,
1780 overwrite_compare: filecmp::MetadataCmpSettings {
1781 size: true,
1782 mtime: true,
1783 ..Default::default()
1784 },
1785 chunk_size: 0,
1786 remote_copy_buffer_size: 0,
1787 filter: None,
1788 dry_run: None,
1789 },
1790 &NO_PRESERVE_SETTINGS,
1791 false,
1792 )
1793 .await?;
1794 assert_eq!(summary.files_copied, 3); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1);
1797 let dst_dir = test_path.join("dst_with_deref");
1798 let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
1800 let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
1801 let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
1802 assert_eq!(foo_content, "final content");
1803 assert_eq!(bar_content, "final content");
1804 assert_eq!(baz_content, "final content");
1805 assert!(dst_dir.join("foo").is_file());
1807 assert!(dst_dir.join("bar").is_file());
1808 assert!(dst_dir.join("baz").is_file());
1809 assert!(!dst_dir.join("foo").is_symlink());
1810 assert!(!dst_dir.join("bar").is_symlink());
1811 assert!(!dst_dir.join("baz").is_symlink());
1812 Ok(())
1813 }
1814
1815 #[tokio::test]
1816 #[traced_test]
1817 async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
1818 let tmp_dir = testutils::create_temp_dir().await?;
1819 let test_path = tmp_dir.as_path();
1820 let target_dir = test_path.join("target_dir");
1822 tokio::fs::create_dir(&target_dir).await?;
1823 tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
1824 tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
1826 tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
1827 tokio::fs::set_permissions(
1828 &target_dir.join("file1.txt"),
1829 std::fs::Permissions::from_mode(0o644),
1830 )
1831 .await?;
1832 tokio::fs::set_permissions(
1833 &target_dir.join("file2.txt"),
1834 std::fs::Permissions::from_mode(0o600),
1835 )
1836 .await?;
1837 let dir_symlink = test_path.join("dir_symlink");
1839 tokio::fs::symlink(&target_dir, &dir_symlink).await?;
1840 let summary = copy(
1842 &PROGRESS,
1843 &dir_symlink,
1844 &test_path.join("copied_dir"),
1845 &Settings {
1846 dereference: true, fail_early: false,
1848 overwrite: false,
1849 overwrite_compare: filecmp::MetadataCmpSettings {
1850 size: true,
1851 mtime: true,
1852 ..Default::default()
1853 },
1854 chunk_size: 0,
1855 remote_copy_buffer_size: 0,
1856 filter: None,
1857 dry_run: None,
1858 },
1859 &DO_PRESERVE_SETTINGS,
1860 false,
1861 )
1862 .await?;
1863 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");
1867 assert!(copied_dir.is_dir());
1869 assert!(!copied_dir.is_symlink()); let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
1872 let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
1873 assert_eq!(file1_content, "content1");
1874 assert_eq!(file2_content, "content2");
1875 let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
1877 let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
1878 let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
1879 assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
1880 assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
1881 assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
1882 Ok(())
1883 }
1884
1885 #[tokio::test]
1886 #[traced_test]
1887 async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
1888 let tmp_dir = testutils::create_temp_dir().await?;
1889 let test_path = tmp_dir.as_path();
1890 let file1 = test_path.join("file1.txt");
1892 let file2 = test_path.join("file2.txt");
1893 tokio::fs::write(&file1, "content1").await?;
1894 tokio::fs::write(&file2, "content2").await?;
1895 tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
1896 tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
1897 let symlink1 = test_path.join("symlink1");
1899 let symlink2 = test_path.join("symlink2");
1900 tokio::fs::symlink(&file1, &symlink1).await?;
1901 tokio::fs::symlink(&file2, &symlink2).await?;
1902 let summary1 = copy(
1904 &PROGRESS,
1905 &symlink1,
1906 &test_path.join("copied_file1.txt"),
1907 &Settings {
1908 dereference: true, fail_early: false,
1910 overwrite: false,
1911 overwrite_compare: filecmp::MetadataCmpSettings::default(),
1912 chunk_size: 0,
1913 remote_copy_buffer_size: 0,
1914 filter: None,
1915 dry_run: None,
1916 },
1917 &DO_PRESERVE_SETTINGS, false,
1919 )
1920 .await?;
1921 let summary2 = copy(
1922 &PROGRESS,
1923 &symlink2,
1924 &test_path.join("copied_file2.txt"),
1925 &Settings {
1926 dereference: true,
1927 fail_early: false,
1928 overwrite: false,
1929 overwrite_compare: filecmp::MetadataCmpSettings::default(),
1930 chunk_size: 0,
1931 remote_copy_buffer_size: 0,
1932 filter: None,
1933 dry_run: None,
1934 },
1935 &DO_PRESERVE_SETTINGS,
1936 false,
1937 )
1938 .await?;
1939 assert_eq!(summary1.files_copied, 1);
1940 assert_eq!(summary1.symlinks_created, 0);
1941 assert_eq!(summary2.files_copied, 1);
1942 assert_eq!(summary2.symlinks_created, 0);
1943 let copied1 = test_path.join("copied_file1.txt");
1944 let copied2 = test_path.join("copied_file2.txt");
1945 assert!(copied1.is_file());
1947 assert!(!copied1.is_symlink());
1948 assert!(copied2.is_file());
1949 assert!(!copied2.is_symlink());
1950 let content1 = tokio::fs::read_to_string(&copied1).await?;
1952 let content2 = tokio::fs::read_to_string(&copied2).await?;
1953 assert_eq!(content1, "content1");
1954 assert_eq!(content2, "content2");
1955 let copied1_metadata = tokio::fs::metadata(&copied1).await?;
1957 let copied2_metadata = tokio::fs::metadata(&copied2).await?;
1958 assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
1959 assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
1960 Ok(())
1961 }
1962
1963 #[tokio::test]
1964 #[traced_test]
1965 async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
1966 let tmp_dir = testutils::setup_test_dir().await?;
1967 tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
1969 tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
1971 let summary = copy(
1972 &PROGRESS,
1973 &tmp_dir.join("foo"),
1974 &tmp_dir.join("bar"),
1975 &Settings {
1976 dereference: true, fail_early: false,
1978 overwrite: false,
1979 overwrite_compare: filecmp::MetadataCmpSettings {
1980 size: true,
1981 mtime: true,
1982 ..Default::default()
1983 },
1984 chunk_size: 0,
1985 remote_copy_buffer_size: 0,
1986 filter: None,
1987 dry_run: None,
1988 },
1989 &DO_PRESERVE_SETTINGS,
1990 false,
1991 )
1992 .await?;
1993 assert_eq!(summary.files_copied, 13); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 5);
1996 tokio::process::Command::new("cp")
1998 .args(["-r", "-L"])
1999 .arg(tmp_dir.join("foo"))
2000 .arg(tmp_dir.join("bar-cp"))
2001 .output()
2002 .await?;
2003 testutils::check_dirs_identical(
2004 &tmp_dir.join("bar"),
2005 &tmp_dir.join("bar-cp"),
2006 testutils::FileEqualityCheck::Basic,
2007 )
2008 .await?;
2009 Ok(())
2010 }
2011
2012 mod error_message_tests {
2014 use super::*;
2015
2016 fn get_full_error_message(error: &Error) -> String {
2018 format!("{:#}", error.source)
2019 }
2020
2021 #[tokio::test]
2022 #[traced_test]
2023 async fn test_permission_error_includes_root_cause() -> Result<(), anyhow::Error> {
2024 let tmp_dir = testutils::create_temp_dir().await?;
2025 let unreadable = tmp_dir.join("unreadable.txt");
2026 tokio::fs::write(&unreadable, "test").await?;
2027 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2028
2029 let src_metadata = tokio::fs::symlink_metadata(&unreadable).await?;
2031 let result = copy_file(
2032 &PROGRESS,
2033 &unreadable,
2034 &tmp_dir.join("dest.txt"),
2035 &src_metadata,
2036 &Settings {
2037 dereference: false,
2038 fail_early: false,
2039 overwrite: false,
2040 overwrite_compare: Default::default(),
2041 chunk_size: 0,
2042 remote_copy_buffer_size: 0,
2043 filter: None,
2044 dry_run: None,
2045 },
2046 &NO_PRESERVE_SETTINGS,
2047 false,
2048 )
2049 .await;
2050
2051 assert!(result.is_err(), "Should fail with permission error");
2052 let err_msg = get_full_error_message(&result.unwrap_err());
2053
2054 assert!(
2056 err_msg.to_lowercase().contains("permission")
2057 || err_msg.contains("EACCES")
2058 || err_msg.contains("denied"),
2059 "Error message must include permission-related text. Got: {}",
2060 err_msg
2061 );
2062 Ok(())
2063 }
2064
2065 #[tokio::test]
2066 #[traced_test]
2067 async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
2068 let tmp_dir = testutils::create_temp_dir().await?;
2069
2070 let result = copy(
2071 &PROGRESS,
2072 &tmp_dir.join("does_not_exist.txt"),
2073 &tmp_dir.join("dest.txt"),
2074 &Settings {
2075 dereference: false,
2076 fail_early: false,
2077 overwrite: false,
2078 overwrite_compare: Default::default(),
2079 chunk_size: 0,
2080 remote_copy_buffer_size: 0,
2081 filter: None,
2082 dry_run: None,
2083 },
2084 &NO_PRESERVE_SETTINGS,
2085 false,
2086 )
2087 .await;
2088
2089 assert!(result.is_err());
2090 let err_msg = get_full_error_message(&result.unwrap_err());
2091
2092 assert!(
2093 err_msg.to_lowercase().contains("no such file")
2094 || err_msg.to_lowercase().contains("not found")
2095 || err_msg.contains("ENOENT"),
2096 "Error message must include file not found text. Got: {}",
2097 err_msg
2098 );
2099 Ok(())
2100 }
2101
2102 #[tokio::test]
2103 #[traced_test]
2104 async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
2105 let tmp_dir = testutils::create_temp_dir().await?;
2106 let unreadable_dir = tmp_dir.join("unreadable_dir");
2107 tokio::fs::create_dir(&unreadable_dir).await?;
2108 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
2109 .await?;
2110
2111 let result = copy(
2112 &PROGRESS,
2113 &unreadable_dir,
2114 &tmp_dir.join("dest"),
2115 &Settings {
2116 dereference: false,
2117 fail_early: true,
2118 overwrite: false,
2119 overwrite_compare: Default::default(),
2120 chunk_size: 0,
2121 remote_copy_buffer_size: 0,
2122 filter: None,
2123 dry_run: None,
2124 },
2125 &NO_PRESERVE_SETTINGS,
2126 false,
2127 )
2128 .await;
2129
2130 assert!(result.is_err());
2131 let err_msg = get_full_error_message(&result.unwrap_err());
2132
2133 assert!(
2134 err_msg.to_lowercase().contains("permission")
2135 || err_msg.contains("EACCES")
2136 || err_msg.contains("denied"),
2137 "Error message must include permission-related text. Got: {}",
2138 err_msg
2139 );
2140
2141 tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
2143 .await?;
2144 Ok(())
2145 }
2146
2147 #[tokio::test]
2148 #[traced_test]
2149 async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
2150 {
2151 let tmp_dir = testutils::setup_test_dir().await?;
2152 let test_path = tmp_dir.as_path();
2153 let readonly_parent = test_path.join("readonly_dest");
2154 tokio::fs::create_dir(&readonly_parent).await?;
2155 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
2156 .await?;
2157
2158 let result = copy(
2159 &PROGRESS,
2160 &test_path.join("foo"),
2161 &readonly_parent.join("copy"),
2162 &Settings {
2163 dereference: false,
2164 fail_early: true,
2165 overwrite: false,
2166 overwrite_compare: Default::default(),
2167 chunk_size: 0,
2168 remote_copy_buffer_size: 0,
2169 filter: None,
2170 dry_run: None,
2171 },
2172 &NO_PRESERVE_SETTINGS,
2173 false,
2174 )
2175 .await;
2176
2177 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
2179 .await?;
2180
2181 assert!(result.is_err(), "copy into read-only parent should fail");
2182 let err_msg = get_full_error_message(&result.unwrap_err());
2183
2184 assert!(
2185 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
2186 "Error message must include permission denied text. Got: {}",
2187 err_msg
2188 );
2189 Ok(())
2190 }
2191 }
2192
2193 mod empty_dir_cleanup_tests {
2194 use super::*;
2195 use std::path::Path;
2196 #[test]
2197 fn test_check_empty_dir_cleanup_no_filter() {
2198 assert_eq!(
2200 check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
2201 EmptyDirAction::Keep
2202 );
2203 }
2204 #[test]
2205 fn test_check_empty_dir_cleanup_something_copied() {
2206 let mut filter = FilterSettings::new();
2208 filter.add_include("*.txt").unwrap();
2209 assert_eq!(
2210 check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
2211 EmptyDirAction::Keep
2212 );
2213 }
2214 #[test]
2215 fn test_check_empty_dir_cleanup_not_created() {
2216 let mut filter = FilterSettings::new();
2218 filter.add_include("*.txt").unwrap();
2219 assert_eq!(
2220 check_empty_dir_cleanup(
2221 Some(&filter),
2222 false,
2223 false,
2224 Path::new("any"),
2225 false,
2226 false
2227 ),
2228 EmptyDirAction::Keep
2229 );
2230 }
2231 #[test]
2232 fn test_check_empty_dir_cleanup_directly_matched() {
2233 let mut filter = FilterSettings::new();
2235 filter.add_include("target/").unwrap();
2236 assert_eq!(
2237 check_empty_dir_cleanup(
2238 Some(&filter),
2239 true,
2240 false,
2241 Path::new("target"),
2242 false,
2243 false
2244 ),
2245 EmptyDirAction::Keep
2246 );
2247 }
2248 #[test]
2249 fn test_check_empty_dir_cleanup_traversed_only() {
2250 let mut filter = FilterSettings::new();
2252 filter.add_include("*.txt").unwrap();
2253 assert_eq!(
2254 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
2255 EmptyDirAction::Remove
2256 );
2257 }
2258 #[test]
2259 fn test_check_empty_dir_cleanup_dry_run() {
2260 let mut filter = FilterSettings::new();
2262 filter.add_include("*.txt").unwrap();
2263 assert_eq!(
2264 check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
2265 EmptyDirAction::DryRunSkip
2266 );
2267 }
2268 #[test]
2269 fn test_check_empty_dir_cleanup_root_always_kept() {
2270 let mut filter = FilterSettings::new();
2272 filter.add_include("*.txt").unwrap();
2273 assert_eq!(
2274 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
2275 EmptyDirAction::Keep
2276 );
2277 }
2278 #[test]
2279 fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
2280 let mut filter = FilterSettings::new();
2282 filter.add_include("*.txt").unwrap();
2283 assert_eq!(
2284 check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
2285 EmptyDirAction::Keep
2286 );
2287 }
2288 }
2289
2290 #[tokio::test]
2294 #[traced_test]
2295 async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
2296 let tmp_dir = testutils::create_temp_dir().await?;
2297 let test_path = tmp_dir.as_path();
2298 let src_dir = test_path.join("src");
2300 tokio::fs::create_dir(&src_dir).await?;
2301 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
2302 let readable_file = src_dir.join("readable.txt");
2304 tokio::fs::write(&readable_file, "content").await?;
2305 let unreadable_file = src_dir.join("unreadable.txt");
2306 tokio::fs::write(&unreadable_file, "secret").await?;
2307 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
2308 .await?;
2309 let dst_dir = test_path.join("dst");
2310 let result = copy(
2312 &PROGRESS,
2313 &src_dir,
2314 &dst_dir,
2315 &Settings {
2316 dereference: false,
2317 fail_early: false,
2318 overwrite: false,
2319 overwrite_compare: Default::default(),
2320 chunk_size: 0,
2321 remote_copy_buffer_size: 0,
2322 filter: None,
2323 dry_run: None,
2324 },
2325 &DO_PRESERVE_SETTINGS,
2326 false,
2327 )
2328 .await;
2329 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
2331 .await?;
2332 assert!(result.is_err(), "copy should fail due to unreadable file");
2334 let error = result.unwrap_err();
2335 assert_eq!(error.summary.files_copied, 1);
2337 assert_eq!(error.summary.directories_created, 1);
2338 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2340 assert!(dst_metadata.is_dir());
2341 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
2342 assert_eq!(
2343 actual_mode, 0o750,
2344 "directory should have preserved source permissions (0o750), got {:o}",
2345 actual_mode
2346 );
2347 Ok(())
2348 }
2349
2350 #[tokio::test]
2352 #[traced_test]
2353 async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error(
2354 ) -> Result<(), anyhow::Error> {
2355 let tmp_dir = testutils::create_temp_dir().await?;
2356 let test_path = tmp_dir.as_path();
2357 let src_dir = test_path.join("src");
2358 tokio::fs::create_dir(&src_dir).await?;
2359 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
2360 let unreadable_file = src_dir.join("unreadable.txt");
2361 tokio::fs::write(&unreadable_file, "secret").await?;
2362 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
2363 .await?;
2364 let fixed_secs = 946684800;
2365 let fixed_nsec = 123_456_789;
2366 let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
2367 nix::sys::stat::utimensat(
2368 nix::fcntl::AT_FDCWD,
2369 &src_dir,
2370 &fixed_time,
2371 &fixed_time,
2372 nix::sys::stat::UtimensatFlags::NoFollowSymlink,
2373 )?;
2374 let src_metadata = tokio::fs::metadata(&src_dir).await?;
2375 let dst_dir = test_path.join("dst");
2376 let result = copy(
2377 &PROGRESS,
2378 &src_dir,
2379 &dst_dir,
2380 &Settings {
2381 dereference: false,
2382 fail_early: true,
2383 overwrite: false,
2384 overwrite_compare: Default::default(),
2385 chunk_size: 0,
2386 remote_copy_buffer_size: 0,
2387 filter: None,
2388 dry_run: None,
2389 },
2390 &DO_PRESERVE_SETTINGS,
2391 false,
2392 )
2393 .await;
2394 tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
2395 .await?;
2396 assert!(result.is_err(), "copy should fail due to unreadable file");
2397 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2398 assert!(dst_metadata.is_dir());
2399 assert_ne!(
2400 (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
2401 (src_metadata.mtime(), src_metadata.mtime_nsec()),
2402 "fail-early should return before applying preserved directory timestamps"
2403 );
2404 Ok(())
2405 }
2406 mod filter_tests {
2407 use super::*;
2408 use crate::filter::FilterSettings;
2409 #[tokio::test]
2413 #[traced_test]
2414 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
2415 let tmp_dir = testutils::setup_test_dir().await?;
2416 let test_path = tmp_dir.as_path();
2417 let mut filter = FilterSettings::new();
2429 filter.add_include("bar/*.txt").unwrap();
2430 let summary = copy(
2431 &PROGRESS,
2432 &test_path.join("foo"),
2433 &test_path.join("dst"),
2434 &Settings {
2435 dereference: false,
2436 fail_early: false,
2437 overwrite: false,
2438 overwrite_compare: Default::default(),
2439 chunk_size: 0,
2440 remote_copy_buffer_size: 0,
2441 filter: Some(filter),
2442 dry_run: None,
2443 },
2444 &NO_PRESERVE_SETTINGS,
2445 false,
2446 )
2447 .await?;
2448 assert_eq!(
2451 summary.files_copied, 3,
2452 "should copy 3 files matching bar/*.txt"
2453 );
2454 assert!(
2456 test_path.join("dst/bar/1.txt").exists(),
2457 "bar/1.txt should be copied"
2458 );
2459 assert!(
2460 test_path.join("dst/bar/2.txt").exists(),
2461 "bar/2.txt should be copied"
2462 );
2463 assert!(
2464 test_path.join("dst/bar/3.txt").exists(),
2465 "bar/3.txt should be copied"
2466 );
2467 assert!(
2469 !test_path.join("dst/0.txt").exists(),
2470 "0.txt should not be copied"
2471 );
2472 Ok(())
2473 }
2474 #[tokio::test]
2476 #[traced_test]
2477 async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
2478 let tmp_dir = testutils::setup_test_dir().await?;
2479 let test_path = tmp_dir.as_path();
2480 let mut filter = FilterSettings::new();
2482 filter.add_include("/bar/**").unwrap();
2483 let summary = copy(
2484 &PROGRESS,
2485 &test_path.join("foo"),
2486 &test_path.join("dst"),
2487 &Settings {
2488 dereference: false,
2489 fail_early: false,
2490 overwrite: false,
2491 overwrite_compare: Default::default(),
2492 chunk_size: 0,
2493 remote_copy_buffer_size: 0,
2494 filter: Some(filter),
2495 dry_run: None,
2496 },
2497 &NO_PRESERVE_SETTINGS,
2498 false,
2499 )
2500 .await?;
2501 assert!(
2503 test_path.join("dst/bar").exists(),
2504 "bar directory should be copied"
2505 );
2506 assert!(
2507 !test_path.join("dst/baz").exists(),
2508 "baz directory should not be copied"
2509 );
2510 assert!(
2511 !test_path.join("dst/0.txt").exists(),
2512 "0.txt should not be copied"
2513 );
2514 assert_eq!(
2516 summary.files_copied, 3,
2517 "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
2518 );
2519 assert_eq!(
2520 summary.directories_created, 2,
2521 "should create 2 directories (root dst + bar)"
2522 );
2523 assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
2525 assert_eq!(
2526 summary.directories_skipped, 1,
2527 "should skip 1 directory (baz)"
2528 );
2529 Ok(())
2530 }
2531 #[tokio::test]
2533 #[traced_test]
2534 async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
2535 let tmp_dir = testutils::setup_test_dir().await?;
2536 let test_path = tmp_dir.as_path();
2537 let mut filter = FilterSettings::new();
2539 filter.add_include("**/*.txt").unwrap();
2540 let summary = copy(
2541 &PROGRESS,
2542 &test_path.join("foo"),
2543 &test_path.join("dst"),
2544 &Settings {
2545 dereference: false,
2546 fail_early: false,
2547 overwrite: false,
2548 overwrite_compare: Default::default(),
2549 chunk_size: 0,
2550 remote_copy_buffer_size: 0,
2551 filter: Some(filter),
2552 dry_run: None,
2553 },
2554 &NO_PRESERVE_SETTINGS,
2555 false,
2556 )
2557 .await?;
2558 assert_eq!(
2560 summary.files_copied, 5,
2561 "should copy all 5 .txt files with **/*.txt pattern"
2562 );
2563 Ok(())
2564 }
2565 #[tokio::test]
2567 #[traced_test]
2568 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
2569 let tmp_dir = testutils::setup_test_dir().await?;
2570 let test_path = tmp_dir.as_path();
2571 let mut filter = FilterSettings::new();
2573 filter.add_exclude("*.txt").unwrap();
2574 let result = copy(
2575 &PROGRESS,
2576 &test_path.join("foo/0.txt"), &test_path.join("dst.txt"),
2578 &Settings {
2579 dereference: false,
2580 fail_early: false,
2581 overwrite: false,
2582 overwrite_compare: Default::default(),
2583 chunk_size: 0,
2584 remote_copy_buffer_size: 0,
2585 filter: Some(filter),
2586 dry_run: None,
2587 },
2588 &NO_PRESERVE_SETTINGS,
2589 false,
2590 )
2591 .await?;
2592 assert_eq!(
2594 result.files_copied, 0,
2595 "file matching exclude pattern should not be copied"
2596 );
2597 assert!(
2598 !test_path.join("dst.txt").exists(),
2599 "excluded file should not exist at destination"
2600 );
2601 Ok(())
2602 }
2603 #[tokio::test]
2605 #[traced_test]
2606 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
2607 let test_path = testutils::create_temp_dir().await?;
2608 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
2610 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
2611 let mut filter = FilterSettings::new();
2613 filter.add_exclude("*_dir/").unwrap();
2614 let result = copy(
2615 &PROGRESS,
2616 &test_path.join("excluded_dir"),
2617 &test_path.join("dst"),
2618 &Settings {
2619 dereference: false,
2620 fail_early: false,
2621 overwrite: false,
2622 overwrite_compare: Default::default(),
2623 chunk_size: 0,
2624 remote_copy_buffer_size: 0,
2625 filter: Some(filter),
2626 dry_run: None,
2627 },
2628 &NO_PRESERVE_SETTINGS,
2629 false,
2630 )
2631 .await?;
2632 assert_eq!(
2634 result.directories_created, 0,
2635 "root directory matching exclude should not be created"
2636 );
2637 assert!(
2638 !test_path.join("dst").exists(),
2639 "excluded root directory should not exist at destination"
2640 );
2641 Ok(())
2642 }
2643 #[tokio::test]
2645 #[traced_test]
2646 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
2647 let test_path = testutils::create_temp_dir().await?;
2648 tokio::fs::write(test_path.join("target.txt"), "content").await?;
2650 tokio::fs::symlink(
2651 test_path.join("target.txt"),
2652 test_path.join("excluded_link"),
2653 )
2654 .await?;
2655 let mut filter = FilterSettings::new();
2657 filter.add_exclude("*_link").unwrap();
2658 let result = copy(
2659 &PROGRESS,
2660 &test_path.join("excluded_link"),
2661 &test_path.join("dst"),
2662 &Settings {
2663 dereference: false,
2664 fail_early: false,
2665 overwrite: false,
2666 overwrite_compare: Default::default(),
2667 chunk_size: 0,
2668 remote_copy_buffer_size: 0,
2669 filter: Some(filter),
2670 dry_run: None,
2671 },
2672 &NO_PRESERVE_SETTINGS,
2673 false,
2674 )
2675 .await?;
2676 assert_eq!(
2678 result.symlinks_created, 0,
2679 "root symlink matching exclude should not be created"
2680 );
2681 assert!(
2682 !test_path.join("dst").exists(),
2683 "excluded root symlink should not exist at destination"
2684 );
2685 Ok(())
2686 }
2687 #[tokio::test]
2689 #[traced_test]
2690 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
2691 let tmp_dir = testutils::setup_test_dir().await?;
2692 let test_path = tmp_dir.as_path();
2693 let mut filter = FilterSettings::new();
2700 filter.add_include("**/*.txt").unwrap();
2701 filter.add_exclude("bar/2.txt").unwrap();
2702 let summary = copy(
2703 &PROGRESS,
2704 &test_path.join("foo"),
2705 &test_path.join("dst"),
2706 &Settings {
2707 dereference: false,
2708 fail_early: false,
2709 overwrite: false,
2710 overwrite_compare: Default::default(),
2711 chunk_size: 0,
2712 remote_copy_buffer_size: 0,
2713 filter: Some(filter),
2714 dry_run: None,
2715 },
2716 &NO_PRESERVE_SETTINGS,
2717 false,
2718 )
2719 .await?;
2720 assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
2724 assert_eq!(
2725 summary.files_skipped, 1,
2726 "should skip 1 file (bar/2.txt excluded)"
2727 );
2728 assert!(
2730 test_path.join("dst/bar/1.txt").exists(),
2731 "bar/1.txt should be copied"
2732 );
2733 assert!(
2734 !test_path.join("dst/bar/2.txt").exists(),
2735 "bar/2.txt should be excluded"
2736 );
2737 assert!(
2738 test_path.join("dst/bar/3.txt").exists(),
2739 "bar/3.txt should be copied"
2740 );
2741 Ok(())
2742 }
2743 #[tokio::test]
2745 #[traced_test]
2746 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
2747 let tmp_dir = testutils::setup_test_dir().await?;
2748 let test_path = tmp_dir.as_path();
2749 let mut filter = FilterSettings::new();
2756 filter.add_exclude("bar/").unwrap();
2757 let summary = copy(
2758 &PROGRESS,
2759 &test_path.join("foo"),
2760 &test_path.join("dst"),
2761 &Settings {
2762 dereference: false,
2763 fail_early: false,
2764 overwrite: false,
2765 overwrite_compare: Default::default(),
2766 chunk_size: 0,
2767 remote_copy_buffer_size: 0,
2768 filter: Some(filter),
2769 dry_run: None,
2770 },
2771 &NO_PRESERVE_SETTINGS,
2772 false,
2773 )
2774 .await?;
2775 assert_eq!(summary.files_copied, 2, "should copy 2 files");
2779 assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
2780 assert_eq!(
2781 summary.directories_created, 2,
2782 "should create 2 directories"
2783 );
2784 assert_eq!(
2785 summary.directories_skipped, 1,
2786 "should skip 1 directory (bar)"
2787 );
2788 assert_eq!(
2789 summary.files_skipped, 0,
2790 "no files skipped (bar contents not counted)"
2791 );
2792 Ok(())
2793 }
2794 #[tokio::test]
2797 #[traced_test]
2798 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
2799 let test_path = testutils::create_temp_dir().await?;
2800 let src_path = test_path.join("src");
2806 tokio::fs::create_dir(&src_path).await?;
2807 tokio::fs::write(src_path.join("foo"), "content").await?;
2808 tokio::fs::write(src_path.join("bar"), "content").await?;
2809 tokio::fs::create_dir(src_path.join("baz")).await?;
2810 let mut filter = FilterSettings::new();
2812 filter.add_include("foo").unwrap();
2813 let summary = copy(
2814 &PROGRESS,
2815 &src_path,
2816 &test_path.join("dst"),
2817 &Settings {
2818 dereference: false,
2819 fail_early: false,
2820 overwrite: false,
2821 overwrite_compare: Default::default(),
2822 chunk_size: 0,
2823 remote_copy_buffer_size: 0,
2824 filter: Some(filter),
2825 dry_run: None,
2826 },
2827 &NO_PRESERVE_SETTINGS,
2828 false,
2829 )
2830 .await?;
2831 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
2833 assert_eq!(
2834 summary.directories_created, 1,
2835 "should create only root directory (not empty 'baz')"
2836 );
2837 assert!(
2839 test_path.join("dst").join("foo").exists(),
2840 "foo should be copied"
2841 );
2842 assert!(
2844 !test_path.join("dst").join("bar").exists(),
2845 "bar should not be copied"
2846 );
2847 assert!(
2849 !test_path.join("dst").join("baz").exists(),
2850 "empty baz directory should NOT be created"
2851 );
2852 Ok(())
2853 }
2854 #[tokio::test]
2857 #[traced_test]
2858 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
2859 let test_path = testutils::create_temp_dir().await?;
2860 let src_path = test_path.join("src");
2867 tokio::fs::create_dir(&src_path).await?;
2868 tokio::fs::write(src_path.join("foo"), "content").await?;
2869 tokio::fs::create_dir(src_path.join("baz")).await?;
2870 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
2871 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
2872 let mut filter = FilterSettings::new();
2874 filter.add_include("foo").unwrap();
2875 let summary = copy(
2876 &PROGRESS,
2877 &src_path,
2878 &test_path.join("dst"),
2879 &Settings {
2880 dereference: false,
2881 fail_early: false,
2882 overwrite: false,
2883 overwrite_compare: Default::default(),
2884 chunk_size: 0,
2885 remote_copy_buffer_size: 0,
2886 filter: Some(filter),
2887 dry_run: None,
2888 },
2889 &NO_PRESERVE_SETTINGS,
2890 false,
2891 )
2892 .await?;
2893 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
2895 assert_eq!(
2896 summary.files_skipped, 2,
2897 "should skip 2 files (qux and quux)"
2898 );
2899 assert_eq!(
2900 summary.directories_created, 1,
2901 "should create only root directory (not 'baz' with non-matching content)"
2902 );
2903 assert!(
2905 test_path.join("dst").join("foo").exists(),
2906 "foo should be copied"
2907 );
2908 assert!(
2910 !test_path.join("dst").join("baz").exists(),
2911 "baz directory should NOT be created (no matching content inside)"
2912 );
2913 Ok(())
2914 }
2915 #[tokio::test]
2918 #[traced_test]
2919 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
2920 let test_path = testutils::create_temp_dir().await?;
2921 let src_path = test_path.join("src");
2927 tokio::fs::create_dir(&src_path).await?;
2928 tokio::fs::write(src_path.join("foo"), "content").await?;
2929 tokio::fs::write(src_path.join("bar"), "content").await?;
2930 tokio::fs::create_dir(src_path.join("baz")).await?;
2931 let mut filter = FilterSettings::new();
2933 filter.add_include("foo").unwrap();
2934 let summary = copy(
2935 &PROGRESS,
2936 &src_path,
2937 &test_path.join("dst"),
2938 &Settings {
2939 dereference: false,
2940 fail_early: false,
2941 overwrite: false,
2942 overwrite_compare: Default::default(),
2943 chunk_size: 0,
2944 remote_copy_buffer_size: 0,
2945 filter: Some(filter),
2946 dry_run: Some(crate::config::DryRunMode::Explain),
2947 },
2948 &NO_PRESERVE_SETTINGS,
2949 false,
2950 )
2951 .await?;
2952 assert_eq!(
2954 summary.files_copied, 1,
2955 "should report only 'foo' would be copied"
2956 );
2957 assert_eq!(
2958 summary.directories_created, 1,
2959 "should report only root directory would be created (not empty 'baz')"
2960 );
2961 assert!(
2963 !test_path.join("dst").exists(),
2964 "dst should not exist in dry-run"
2965 );
2966 Ok(())
2967 }
2968 #[tokio::test]
2971 #[traced_test]
2972 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
2973 let test_path = testutils::create_temp_dir().await?;
2974 let src_path = test_path.join("src");
2980 tokio::fs::create_dir(&src_path).await?;
2981 tokio::fs::write(src_path.join("foo"), "content").await?;
2982 tokio::fs::write(src_path.join("bar"), "content").await?;
2983 tokio::fs::create_dir(src_path.join("baz")).await?;
2984 let dst_path = test_path.join("dst");
2986 tokio::fs::create_dir(&dst_path).await?;
2987 tokio::fs::create_dir(dst_path.join("baz")).await?;
2988 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
2990 let mut filter = FilterSettings::new();
2992 filter.add_include("foo").unwrap();
2993 let summary = copy(
2994 &PROGRESS,
2995 &src_path,
2996 &dst_path,
2997 &Settings {
2998 dereference: false,
2999 fail_early: false,
3000 overwrite: true, overwrite_compare: Default::default(),
3002 chunk_size: 0,
3003 remote_copy_buffer_size: 0,
3004 filter: Some(filter),
3005 dry_run: None,
3006 },
3007 &NO_PRESERVE_SETTINGS,
3008 false,
3009 )
3010 .await?;
3011 assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3013 assert_eq!(
3015 summary.directories_unchanged, 2,
3016 "root dst and baz directories should be unchanged"
3017 );
3018 assert_eq!(
3019 summary.directories_created, 0,
3020 "should not create any directories"
3021 );
3022 assert!(dst_path.join("foo").exists(), "foo should be copied");
3024 assert!(!dst_path.join("bar").exists(), "bar should not be copied");
3026 assert!(
3028 dst_path.join("baz").exists(),
3029 "existing baz directory should still exist"
3030 );
3031 assert!(
3032 dst_path.join("baz").join("marker.txt").exists(),
3033 "existing content in baz should still exist"
3034 );
3035 Ok(())
3036 }
3037 }
3038 mod dry_run_tests {
3039 use super::*;
3040 #[tokio::test]
3043 #[traced_test]
3044 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
3045 let tmp_dir = testutils::setup_test_dir().await?;
3046 let test_path = tmp_dir.as_path();
3047 let dst_path = test_path.join("nonexistent_dst");
3048 assert!(
3050 !dst_path.exists(),
3051 "destination should not exist before dry-run"
3052 );
3053 let summary = copy(
3054 &PROGRESS,
3055 &test_path.join("foo"),
3056 &dst_path,
3057 &Settings {
3058 dereference: false,
3059 fail_early: false,
3060 overwrite: false,
3061 overwrite_compare: Default::default(),
3062 chunk_size: 0,
3063 remote_copy_buffer_size: 0,
3064 filter: None,
3065 dry_run: Some(crate::config::DryRunMode::Brief),
3066 },
3067 &NO_PRESERVE_SETTINGS,
3068 false,
3069 )
3070 .await?;
3071 assert!(
3073 !dst_path.exists(),
3074 "dry-run should not create destination directory"
3075 );
3076 assert!(
3078 summary.directories_created > 0,
3079 "dry-run should report directories that would be created"
3080 );
3081 assert!(
3082 summary.files_copied > 0,
3083 "dry-run should report files that would be copied"
3084 );
3085 Ok(())
3086 }
3087 #[tokio::test]
3091 #[traced_test]
3092 async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
3093 let test_path = testutils::create_temp_dir().await?;
3094 let src_path = test_path.join("src");
3099 tokio::fs::create_dir(&src_path).await?;
3100 tokio::fs::write(src_path.join("bar.log"), "content").await?;
3101 tokio::fs::create_dir(src_path.join("baz")).await?;
3102 let mut filter = FilterSettings::new();
3104 filter.add_include("*.txt").unwrap();
3105 let dst_path = test_path.join("dst");
3106 let summary = copy(
3107 &PROGRESS,
3108 &src_path,
3109 &dst_path,
3110 &Settings {
3111 dereference: false,
3112 fail_early: false,
3113 overwrite: false,
3114 overwrite_compare: Default::default(),
3115 chunk_size: 0,
3116 remote_copy_buffer_size: 0,
3117 filter: Some(filter),
3118 dry_run: None,
3119 },
3120 &NO_PRESERVE_SETTINGS,
3121 false,
3122 )
3123 .await?;
3124 assert_eq!(summary.files_copied, 0, "no files match *.txt");
3126 assert_eq!(
3128 summary.directories_created, 1,
3129 "root directory should always be created"
3130 );
3131 assert!(dst_path.exists(), "root destination directory should exist");
3132 assert!(
3134 !dst_path.join("baz").exists(),
3135 "empty baz should not be created"
3136 );
3137 Ok(())
3138 }
3139 #[tokio::test]
3141 #[traced_test]
3142 async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
3143 {
3144 let test_path = testutils::create_temp_dir().await?;
3145 let src_path = test_path.join("src");
3146 tokio::fs::create_dir(&src_path).await?;
3147 tokio::fs::write(src_path.join("bar.log"), "content").await?;
3148 let mut filter = FilterSettings::new();
3150 filter.add_include("*.txt").unwrap();
3151 let dst_path = test_path.join("dst");
3152 let summary = copy(
3153 &PROGRESS,
3154 &src_path,
3155 &dst_path,
3156 &Settings {
3157 dereference: false,
3158 fail_early: false,
3159 overwrite: false,
3160 overwrite_compare: Default::default(),
3161 chunk_size: 0,
3162 remote_copy_buffer_size: 0,
3163 filter: Some(filter),
3164 dry_run: Some(crate::config::DryRunMode::Explain),
3165 },
3166 &NO_PRESERVE_SETTINGS,
3167 false,
3168 )
3169 .await?;
3170 assert_eq!(summary.files_copied, 0, "no files match *.txt");
3171 assert_eq!(
3172 summary.directories_created, 1,
3173 "root directory should be counted in dry-run"
3174 );
3175 assert!(
3176 !dst_path.exists(),
3177 "nothing should be created in dry-run mode"
3178 );
3179 Ok(())
3180 }
3181 }
3182
3183 mod max_open_files_tests {
3185 use super::*;
3186
3187 #[tokio::test]
3190 #[traced_test]
3191 async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
3192 let tmp_dir = testutils::create_temp_dir().await?;
3193 let src = tmp_dir.join("src");
3194 let dst = tmp_dir.join("dst");
3195 tokio::fs::create_dir(&src).await?;
3196 let file_count = 200;
3197 for i in 0..file_count {
3198 tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
3199 }
3200 throttle::set_max_open_files(4);
3202 let summary = copy(
3203 &PROGRESS,
3204 &src,
3205 &dst,
3206 &Settings {
3207 dereference: false,
3208 fail_early: true,
3209 overwrite: false,
3210 overwrite_compare: Default::default(),
3211 chunk_size: 0,
3212 remote_copy_buffer_size: 0,
3213 filter: None,
3214 dry_run: None,
3215 },
3216 &NO_PRESERVE_SETTINGS,
3217 false,
3218 )
3219 .await?;
3220 assert_eq!(summary.files_copied, file_count);
3221 assert_eq!(summary.directories_created, 1);
3222 for i in 0..file_count {
3223 let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
3224 assert_eq!(content, format!("content-{}", i));
3225 }
3226 Ok(())
3227 }
3228
3229 #[tokio::test]
3232 #[traced_test]
3233 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
3234 let tmp_dir = testutils::create_temp_dir().await?;
3235 let src = tmp_dir.join("src");
3236 let dst = tmp_dir.join("dst");
3237 let depth = 20;
3238 let files_per_level = 5;
3239 let limit = 4;
3240 let mut dir = src.clone();
3242 for level in 0..depth {
3243 tokio::fs::create_dir_all(&dir).await?;
3244 for f in 0..files_per_level {
3245 tokio::fs::write(
3246 dir.join(format!("f{}_{}.txt", level, f)),
3247 format!("L{}F{}", level, f),
3248 )
3249 .await?;
3250 }
3251 dir = dir.join(format!("d{}", level));
3252 }
3253 throttle::set_max_open_files(limit);
3254 let summary = tokio::time::timeout(
3255 std::time::Duration::from_secs(30),
3256 copy(
3257 &PROGRESS,
3258 &src,
3259 &dst,
3260 &Settings {
3261 dereference: false,
3262 fail_early: true,
3263 overwrite: false,
3264 overwrite_compare: Default::default(),
3265 chunk_size: 0,
3266 remote_copy_buffer_size: 0,
3267 filter: None,
3268 dry_run: None,
3269 },
3270 &NO_PRESERVE_SETTINGS,
3271 false,
3272 ),
3273 )
3274 .await
3275 .context("copy timed out — possible deadlock")?
3276 .context("copy failed")?;
3277 assert_eq!(summary.files_copied, depth * files_per_level);
3278 assert_eq!(summary.directories_created, depth);
3279 let mut check_dir = dst.clone();
3281 for level in 0..depth {
3282 let content =
3283 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
3284 assert_eq!(content, format!("L{}F0", level));
3285 check_dir = check_dir.join(format!("d{}", level));
3286 }
3287 Ok(())
3288 }
3289 }
3290}