1use anyhow::{Context, anyhow};
2use async_recursion::async_recursion;
3use std::os::linux::fs::MetadataExt as LinuxMetadataExt;
4use tracing::instrument;
5
6use crate::copy;
7use crate::copy::{
8 EmptyDirAction, Settings as CopySettings, Summary as CopySummary, check_empty_dir_cleanup,
9};
10use crate::filecmp;
11use crate::preserve;
12use crate::progress;
13use crate::rm;
14use crate::walk::{self, EntryKind};
15
16pub type Error = crate::error::OperationError<Summary>;
19
20#[derive(Debug, Clone)]
21pub struct Settings {
22 pub copy_settings: CopySettings,
23 pub update_compare: filecmp::MetadataCmpSettings,
24 pub update_exclusive: bool,
25 pub filter: Option<crate::filter::FilterSettings>,
27 pub dry_run: Option<crate::config::DryRunMode>,
29 pub preserve: preserve::Settings,
31}
32
33fn skipped_summary_for(kind: EntryKind) -> Summary {
37 let copy_summary = match kind {
38 EntryKind::Dir => CopySummary {
39 directories_skipped: 1,
40 ..Default::default()
41 },
42 EntryKind::Symlink => CopySummary {
43 symlinks_skipped: 1,
44 ..Default::default()
45 },
46 EntryKind::File | EntryKind::Special => CopySummary {
47 files_skipped: 1,
48 ..Default::default()
49 },
50 };
51 Summary {
52 copy_summary,
53 ..Default::default()
54 }
55}
56
57#[derive(Copy, Clone, Debug, Default)]
58pub struct Summary {
59 pub hard_links_created: usize,
60 pub hard_links_unchanged: usize,
61 pub copy_summary: CopySummary,
62}
63
64impl std::ops::Add for Summary {
65 type Output = Self;
66 fn add(self, other: Self) -> Self {
67 Self {
68 hard_links_created: self.hard_links_created + other.hard_links_created,
69 hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
70 copy_summary: self.copy_summary + other.copy_summary,
71 }
72 }
73}
74
75impl std::fmt::Display for Summary {
76 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
77 write!(
78 f,
79 "{}\n\
80 link:\n\
81 -----\n\
82 hard-links created: {}\n\
83 hard links unchanged: {}\n",
84 &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
85 )
86 }
87}
88
89fn is_hard_link(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
90 copy::is_file_type_same(md1, md2)
91 && md2.st_dev() == md1.st_dev()
92 && md2.st_ino() == md1.st_ino()
93}
94
95#[instrument(skip(prog_track, settings))]
96async fn hard_link_helper(
97 prog_track: &'static progress::Progress,
98 src: &std::path::Path,
99 src_metadata: &std::fs::Metadata,
100 dst: &std::path::Path,
101 settings: &Settings,
102) -> Result<Summary, Error> {
103 let mut link_summary = Summary::default();
104 match crate::walk::run_metadata_probed(
105 congestion::Side::Destination,
106 congestion::MetadataOp::HardLink,
107 tokio::fs::hard_link(src, dst),
108 )
109 .await
110 {
111 Ok(()) => {}
112 Err(error)
113 if settings.copy_settings.overwrite
114 && error.kind() == std::io::ErrorKind::AlreadyExists =>
115 {
116 tracing::debug!("'dst' already exists, check if we need to update");
117 let dst_metadata = crate::walk::run_metadata_probed(
118 congestion::Side::Destination,
119 congestion::MetadataOp::Stat,
120 tokio::fs::symlink_metadata(dst),
121 )
122 .await
123 .with_context(|| format!("cannot read {dst:?} metadata"))
124 .map_err(|err| Error::new(err, Default::default()))?;
125 if is_hard_link(src_metadata, &dst_metadata) {
126 tracing::debug!("no change, leaving file as is");
127 prog_track.hard_links_unchanged.inc();
128 return Ok(Summary {
129 hard_links_unchanged: 1,
130 ..Default::default()
131 });
132 }
133 tracing::info!("'dst' file type changed, removing and hard-linking");
134 let rm_summary = rm::rm(
135 prog_track,
136 dst,
137 &rm::Settings {
138 fail_early: settings.copy_settings.fail_early,
139 filter: None,
140 dry_run: None,
141 time_filter: None,
142 },
143 )
144 .await
145 .map_err(|err| {
146 let rm_summary = err.summary;
147 link_summary.copy_summary.rm_summary = rm_summary;
148 Error::new(err.source, link_summary)
149 })?;
150 link_summary.copy_summary.rm_summary = rm_summary;
151 crate::walk::run_metadata_probed(
152 congestion::Side::Destination,
153 congestion::MetadataOp::HardLink,
154 tokio::fs::hard_link(src, dst),
155 )
156 .await
157 .with_context(|| format!("failed to hard link {src:?} to {dst:?}"))
158 .map_err(|err| Error::new(err, link_summary))?;
159 }
160 Err(error) => {
161 return Err(Error::new(
162 anyhow::Error::from(error)
163 .context(format!("failed to hard link {src:?} to {dst:?}")),
164 link_summary,
165 ));
166 }
167 }
168 prog_track.hard_links_created.inc();
169 link_summary.hard_links_created = 1;
170 Ok(link_summary)
171}
172
173#[instrument(skip(prog_track, settings))]
176pub async fn link(
177 prog_track: &'static progress::Progress,
178 cwd: &std::path::Path,
179 src: &std::path::Path,
180 dst: &std::path::Path,
181 update: &Option<std::path::PathBuf>,
182 settings: &Settings,
183 is_fresh: bool,
184) -> Result<Summary, Error> {
185 if let Some(update_path) = update.as_ref()
197 && (settings.update_exclusive || settings.copy_settings.delete.is_some())
198 {
199 match crate::walk::run_metadata_probed(
200 congestion::Side::Source,
201 congestion::MetadataOp::Stat,
202 tokio::fs::symlink_metadata(update_path),
203 )
204 .await
205 {
206 Ok(_) => {}
207 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
208 return Err(Error::new(
209 anyhow!(
210 "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
211 update_path
212 ),
213 Default::default(),
214 ));
215 }
216 Err(err) => {
217 return Err(Error::new(
218 anyhow::Error::new(err).context(format!(
219 "failed reading metadata from update {:?}",
220 update_path
221 )),
222 Default::default(),
223 ));
224 }
225 }
226 }
227 if let Some(ref filter) = settings.filter {
229 let src_name = src.file_name().map(std::path::Path::new);
230 if let Some(name) = src_name {
231 let src_metadata = crate::walk::run_metadata_probed(
232 congestion::Side::Source,
233 congestion::MetadataOp::Stat,
234 tokio::fs::symlink_metadata(src),
235 )
236 .await
237 .with_context(|| format!("failed reading metadata from {:?}", &src))
238 .map_err(|err| Error::new(err, Default::default()))?;
239 let is_dir = src_metadata.is_dir();
240 let result = filter.should_include_root_item(name, is_dir);
241 match result {
242 crate::filter::FilterResult::Included => {}
243 result => {
244 let kind = EntryKind::from_metadata(&src_metadata);
245 if let Some(mode) = settings.dry_run {
246 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
247 }
248 kind.inc_skipped(prog_track);
249 return Ok(skipped_summary_for(kind));
250 }
251 }
252 }
253 }
254 link_internal(
255 prog_track, cwd, src, dst, src, update, settings, is_fresh, None,
256 )
257 .await
258}
259struct DeleteKeepSet {
267 inner: Option<std::collections::HashSet<std::ffi::OsString>>,
268 src_records_disabled: bool,
271}
272
273impl DeleteKeepSet {
274 fn new(
275 delete: Option<©::DeleteSettings>,
276 update_exclusive: bool,
277 update_present: bool,
278 ) -> Self {
279 Self {
280 inner: delete.is_some().then(std::collections::HashSet::new),
281 src_records_disabled: update_exclusive && update_present,
282 }
283 }
284 fn record_src(&mut self, name: &std::ffi::OsStr) {
287 if let Some(set) = &mut self.inner
288 && !self.src_records_disabled
289 {
290 set.insert(name.to_owned());
291 }
292 }
293 fn record_update(&mut self, name: &std::ffi::OsStr) {
295 if let Some(set) = &mut self.inner {
296 set.insert(name.to_owned());
297 }
298 }
299 fn drop_src_when_update_filtered(&mut self, name: &std::ffi::OsStr, src_materialized: bool) {
305 if let Some(set) = &mut self.inner
306 && src_materialized
307 {
308 set.remove(name);
309 }
310 }
311 fn as_set(&self) -> Option<&std::collections::HashSet<std::ffi::OsString>> {
314 self.inner.as_ref()
315 }
316}
317
318#[instrument(skip(prog_track, settings, open_file_guard))]
319#[async_recursion]
320#[allow(clippy::too_many_arguments)]
321async fn link_internal(
322 prog_track: &'static progress::Progress,
323 cwd: &std::path::Path,
324 src: &std::path::Path,
325 dst: &std::path::Path,
326 source_root: &std::path::Path,
327 update: &Option<std::path::PathBuf>,
328 settings: &Settings,
329 mut is_fresh: bool,
330 open_file_guard: Option<throttle::OpenFileGuard>,
331) -> Result<Summary, Error> {
332 let _prog_guard = prog_track.ops.guard();
333 tracing::debug!("reading source metadata");
334 let src_metadata = crate::walk::run_metadata_probed(
335 congestion::Side::Source,
336 congestion::MetadataOp::Stat,
337 tokio::fs::symlink_metadata(src),
338 )
339 .await
340 .with_context(|| format!("failed reading metadata from {:?}", &src))
341 .map_err(|err| Error::new(err, Default::default()))?;
342 let update_metadata_opt = match update {
343 Some(update) => {
344 tracing::debug!("reading 'update' metadata");
345 let update_metadata_res = crate::walk::run_metadata_probed(
346 congestion::Side::Source,
347 congestion::MetadataOp::Stat,
348 tokio::fs::symlink_metadata(update),
349 )
350 .await;
351 match update_metadata_res {
352 Ok(update_metadata) => Some(update_metadata),
353 Err(error) => {
354 if error.kind() == std::io::ErrorKind::NotFound {
355 if settings.update_exclusive {
356 return Ok(Default::default());
358 }
359 None
360 } else {
361 return Err(Error::new(
362 anyhow!("failed reading metadata from {:?}", &update),
363 Default::default(),
364 ));
365 }
366 }
367 }
368 }
369 None => None,
370 };
371 if let Some(update_metadata) = update_metadata_opt.as_ref() {
372 let update = update.as_ref().unwrap();
373 if !copy::is_file_type_same(&src_metadata, update_metadata) {
374 tracing::debug!(
376 "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
377 src,
378 src_metadata.file_type(),
379 update,
380 update_metadata.file_type()
381 );
382 drop(open_file_guard);
391 let filter_base = walk::relative_to_root(src, source_root);
397 let copy_summary = copy::copy_with_filter_base(
398 prog_track,
399 update,
400 dst,
401 &settings.copy_settings,
402 &settings.preserve,
403 is_fresh,
404 filter_base,
405 )
406 .await
407 .map_err(|err| {
408 let copy_summary = err.summary;
409 let link_summary = Summary {
410 copy_summary,
411 ..Default::default()
412 };
413 Error::new(err.source, link_summary)
414 })?;
415 return Ok(Summary {
416 copy_summary,
417 ..Default::default()
418 });
419 }
420 if update_metadata.is_file() {
421 if filecmp::metadata_equal(&settings.update_compare, &src_metadata, update_metadata) {
423 tracing::debug!("no change, hard link 'src'");
424 return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
425 }
426 tracing::debug!(
427 "link: {:?} metadata has changed, copying from {:?}",
428 src,
429 update
430 );
431 let _guard = match open_file_guard {
436 Some(g) => g,
437 None => throttle::open_file_permit().await,
438 };
439 return Ok(Summary {
440 copy_summary: copy::copy_file(
441 prog_track,
442 update,
443 dst,
444 update_metadata,
445 &settings.copy_settings,
446 &settings.preserve,
447 is_fresh,
448 )
449 .await
450 .map_err(|err| {
451 let copy_summary = err.summary;
452 let link_summary = Summary {
453 copy_summary,
454 ..Default::default()
455 };
456 Error::new(err.source, link_summary)
457 })?,
458 ..Default::default()
459 });
460 }
461 if update_metadata.is_symlink() {
462 tracing::debug!("'update' is a symlink so just symlink that");
463 let filter_base = walk::relative_to_root(src, source_root);
469 let copy_summary = copy::copy_with_filter_base(
470 prog_track,
471 update,
472 dst,
473 &settings.copy_settings,
474 &settings.preserve,
475 is_fresh,
476 filter_base,
477 )
478 .await
479 .map_err(|err| {
480 let copy_summary = err.summary;
481 let link_summary = Summary {
482 copy_summary,
483 ..Default::default()
484 };
485 Error::new(err.source, link_summary)
486 })?;
487 return Ok(Summary {
488 copy_summary,
489 ..Default::default()
490 });
491 }
492 } else {
493 tracing::debug!("no 'update' specified");
495 if src_metadata.is_file() {
496 if settings.dry_run.is_some() {
498 crate::dry_run::report_action("link", src, Some(dst), "file");
499 return Ok(Summary {
500 hard_links_created: 1,
501 ..Default::default()
502 });
503 }
504 return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
505 }
506 if src_metadata.is_symlink() {
507 tracing::debug!("'src' is a symlink so just symlink that");
508 let filter_base = walk::relative_to_root(src, source_root);
511 let copy_summary = copy::copy_with_filter_base(
512 prog_track,
513 src,
514 dst,
515 &settings.copy_settings,
516 &settings.preserve,
517 is_fresh,
518 filter_base,
519 )
520 .await
521 .map_err(|err| {
522 let copy_summary = err.summary;
523 let link_summary = Summary {
524 copy_summary,
525 ..Default::default()
526 };
527 Error::new(err.source, link_summary)
528 })?;
529 return Ok(Summary {
530 copy_summary,
531 ..Default::default()
532 });
533 }
534 }
535 if !src_metadata.is_dir() {
536 if settings.copy_settings.skip_specials {
537 tracing::debug!(
538 "skipping special file {:?} (type: {:?})",
539 src,
540 src_metadata.file_type()
541 );
542 if let Some(mode) = settings.dry_run {
543 match mode {
544 crate::config::DryRunMode::Brief => {}
545 crate::config::DryRunMode::All => println!("skip special {:?}", src),
546 crate::config::DryRunMode::Explain => {
547 println!(
548 "skip special {:?} (unsupported file type: {:?})",
549 src,
550 src_metadata.file_type()
551 );
552 }
553 }
554 }
555 prog_track.specials_skipped.inc();
556 return Ok(Summary {
557 copy_summary: CopySummary {
558 specials_skipped: 1,
559 ..Default::default()
560 },
561 ..Default::default()
562 });
563 }
564 return Err(Error::new(
565 anyhow!(
566 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
567 src,
568 dst,
569 src_metadata.file_type()
570 ),
571 Default::default(),
572 ));
573 }
574 assert!(update_metadata_opt.is_none() || update_metadata_opt.as_ref().unwrap().is_dir());
575 tracing::debug!("process contents of 'src' directory");
576 let mut src_entries = tokio::fs::read_dir(src)
577 .await
578 .with_context(|| format!("cannot open directory {src:?} for reading"))
579 .map_err(|err| Error::new(err, Default::default()))?;
580 if settings.dry_run.is_some() {
582 crate::dry_run::report_action("link", src, Some(dst), "dir");
583 }
585 let copy_summary = if settings.dry_run.is_some() {
586 CopySummary {
588 directories_created: 1,
589 ..Default::default()
590 }
591 } else if let Err(error) = crate::walk::run_metadata_probed(
592 congestion::Side::Destination,
593 congestion::MetadataOp::MkDir,
594 tokio::fs::create_dir(dst),
595 )
596 .await
597 {
598 assert!(!is_fresh, "unexpected error creating directory: {:?}", &dst);
599 if settings.copy_settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
600 let dst_metadata = crate::walk::run_metadata_probed(
605 congestion::Side::Destination,
606 congestion::MetadataOp::Stat,
607 tokio::fs::symlink_metadata(dst),
612 )
613 .await
614 .with_context(|| format!("failed reading metadata from {:?}", &dst))
615 .map_err(|err| Error::new(err, Default::default()))?;
616 if dst_metadata.is_dir() {
617 tracing::debug!("'dst' is a directory, leaving it as is");
618 CopySummary {
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 mut copy_summary = CopySummary::default();
625 let rm_summary = rm::rm(
626 prog_track,
627 dst,
628 &rm::Settings {
629 fail_early: settings.copy_settings.fail_early,
630 filter: None,
631 dry_run: None,
632 time_filter: None,
633 },
634 )
635 .await
636 .map_err(|err| {
637 let rm_summary = err.summary;
638 copy_summary.rm_summary = rm_summary;
639 Error::new(
640 err.source,
641 Summary {
642 copy_summary,
643 ..Default::default()
644 },
645 )
646 })?;
647 crate::walk::run_metadata_probed(
648 congestion::Side::Destination,
649 congestion::MetadataOp::MkDir,
650 tokio::fs::create_dir(dst),
651 )
652 .await
653 .with_context(|| format!("cannot create directory {dst:?}"))
654 .map_err(|err| {
655 copy_summary.rm_summary = rm_summary;
656 Error::new(
657 err,
658 Summary {
659 copy_summary,
660 ..Default::default()
661 },
662 )
663 })?;
664 is_fresh = true;
666 CopySummary {
667 rm_summary,
668 directories_created: 1,
669 ..Default::default()
670 }
671 }
672 } else {
673 return Err(error)
674 .with_context(|| format!("cannot create directory {dst:?}"))
675 .map_err(|err| Error::new(err, Default::default()))?;
676 }
677 } else {
678 is_fresh = true;
680 CopySummary {
681 directories_created: 1,
682 ..Default::default()
683 }
684 };
685 let we_created_this_dir = copy_summary.directories_created == 1;
688 let mut link_summary = Summary {
689 copy_summary,
690 ..Default::default()
691 };
692 let mut join_set = tokio::task::JoinSet::new();
693 let errors = crate::error_collector::ErrorCollector::default();
694 let mut processed_files = std::collections::HashSet::new();
696 let mut keep_set = DeleteKeepSet::new(
701 settings.copy_settings.delete.as_ref(),
702 settings.update_exclusive,
703 update.is_some(),
704 );
705 loop {
707 let Some((src_entry, entry_file_type)) =
708 crate::walk::next_entry_probed(&mut src_entries, congestion::Side::Source, || {
709 format!("failed traversing directory {:?}", &src)
710 })
711 .await
712 .map_err(|err| Error::new(err, link_summary))?
713 else {
714 break;
715 };
716 let cwd_path = cwd.to_owned();
717 let entry_path = src_entry.path();
718 let entry_name = entry_path.file_name().unwrap();
719 let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
720 let entry_is_dir = entry_kind == EntryKind::Dir;
721 let entry_is_symlink = entry_kind == EntryKind::Symlink;
722 let relative_path = walk::relative_to_root(&entry_path, source_root);
724 if let Some(skip_result) =
726 walk::should_skip_entry(&settings.filter, relative_path, entry_is_dir)
727 {
728 if let Some(mode) = settings.dry_run {
729 crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
730 }
731 tracing::debug!("skipping {:?} due to filter", &entry_path);
732 link_summary = link_summary + skipped_summary_for(entry_kind);
733 entry_kind.inc_skipped(prog_track);
734 continue;
735 }
736 keep_set.record_src(entry_name);
739 if settings.copy_settings.skip_specials && entry_kind == EntryKind::Special {
741 tracing::debug!("skipping special file {:?}", &entry_path);
742 if let Some(mode) = settings.dry_run {
743 match mode {
744 crate::config::DryRunMode::Brief => {}
745 crate::config::DryRunMode::All => {
746 println!("skip special {:?}", &entry_path)
747 }
748 crate::config::DryRunMode::Explain => {
749 println!(
750 "skip special {:?} (unsupported file type: {:?})",
751 &entry_path,
752 entry_file_type.unwrap()
753 );
754 }
755 }
756 }
757 link_summary.copy_summary.specials_skipped += 1;
758 prog_track.specials_skipped.inc();
759 continue;
760 }
761 processed_files.insert(entry_name.to_owned());
762 let dst_path = dst.join(entry_name);
763 let update_path = update.as_ref().map(|s| s.join(entry_name));
764 if let Some(_mode) = settings.dry_run {
766 crate::dry_run::report_action("link", &entry_path, Some(&dst_path), entry_kind.label());
767 if entry_is_dir {
769 let settings = settings.clone();
770 let source_root = source_root.to_owned();
771 let do_link = || async move {
772 link_internal(
773 prog_track,
774 &cwd_path,
775 &entry_path,
776 &dst_path,
777 &source_root,
778 &update_path,
779 &settings,
780 true,
781 None,
782 )
783 .await
784 };
785 join_set.spawn(do_link());
786 } else if entry_is_symlink {
787 link_summary.copy_summary.symlinks_created += 1;
789 } else {
790 link_summary.hard_links_created += 1;
792 }
793 continue;
794 }
795 let settings = settings.clone();
796 let source_root = source_root.to_owned();
797 let entry_is_regular_file = entry_file_type.as_ref().is_some_and(|ft| ft.is_file());
803 let open_file_guard = if entry_is_regular_file {
804 Some(throttle::open_file_permit().await)
805 } else {
806 None
807 };
808 let do_link = || async move {
809 link_internal(
810 prog_track,
811 &cwd_path,
812 &entry_path,
813 &dst_path,
814 &source_root,
815 &update_path,
816 &settings,
817 is_fresh,
818 open_file_guard,
819 )
820 .await
821 };
822 join_set.spawn(do_link());
823 }
824 drop(src_entries);
827 if update_metadata_opt.is_some() {
829 let update = update.as_ref().unwrap();
830 tracing::debug!("process contents of 'update' directory");
831 let mut update_entries = tokio::fs::read_dir(update)
832 .await
833 .with_context(|| format!("cannot open directory {:?} for reading", &update))
834 .map_err(|err| Error::new(err, link_summary))?;
835 loop {
851 let Some((update_entry, entry_file_type)) = crate::walk::next_entry_probed(
852 &mut update_entries,
853 congestion::Side::Source,
854 || format!("failed traversing directory {:?}", &update),
855 )
856 .await
857 .map_err(|err| Error::new(err, link_summary))?
858 else {
859 break;
860 };
861 let entry_path = update_entry.path();
862 let entry_name = entry_path.file_name().unwrap();
863 if settings.copy_settings.delete.is_some() {
868 let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
869 let relative_path = walk::relative_to_root(src, source_root).join(entry_name);
870 let filtered_out = walk::should_skip_entry(
871 &settings.filter,
872 &relative_path,
873 entry_kind == EntryKind::Dir,
874 )
875 .is_some();
876 if filtered_out {
877 keep_set.drop_src_when_update_filtered(
883 entry_name,
884 processed_files.contains(entry_name),
885 );
886 } else {
887 keep_set.record_update(entry_name);
888 }
889 }
890 if processed_files.contains(entry_name) {
891 continue;
893 }
894 tracing::debug!("found a new entry in the 'update' directory");
895 let dst_path = dst.join(entry_name);
896 let update_path = update.join(entry_name);
897 let filter_base = walk::relative_to_root(src, source_root).join(entry_name);
901 let settings = settings.clone();
902 let do_copy = || async move {
903 let copy_summary = copy::copy_with_filter_base(
904 prog_track,
905 &update_path,
906 &dst_path,
907 &settings.copy_settings,
908 &settings.preserve,
909 is_fresh,
910 &filter_base,
911 )
912 .await
913 .map_err(|err| {
914 link_summary.copy_summary = link_summary.copy_summary + err.summary;
915 Error::new(err.source, link_summary)
916 })?;
917 Ok(Summary {
918 copy_summary,
919 ..Default::default()
920 })
921 };
922 join_set.spawn(do_copy());
923 }
924 drop(update_entries);
927 }
928 while let Some(res) = join_set.join_next().await {
929 match res {
930 Ok(result) => match result {
931 Ok(summary) => link_summary = link_summary + summary,
932 Err(error) => {
933 tracing::error!(
934 "link: {:?} {:?} -> {:?} failed with: {:#}",
935 src,
936 update,
937 dst,
938 &error
939 );
940 link_summary = link_summary + error.summary;
941 if settings.copy_settings.fail_early {
942 return Err(Error::new(error.source, link_summary));
943 }
944 errors.push(error.source);
945 }
946 },
947 Err(error) => {
948 if settings.copy_settings.fail_early {
949 return Err(Error::new(error.into(), link_summary));
950 }
951 errors.push(error.into());
952 }
953 }
954 }
955 if let Some(delete_settings) = &settings.copy_settings.delete {
960 if errors.has_errors() {
961 tracing::warn!(
965 "skipping --delete pruning of {:?} because the link/update pass reported errors",
966 dst
967 );
968 } else {
969 let relative_dir = walk::relative_to_root(src, source_root);
970 match crate::delete::prune_extraneous(
971 prog_track,
972 dst,
973 relative_dir,
974 keep_set
975 .as_set()
976 .expect("--delete is on, so DeleteKeepSet is active"),
977 settings.filter.as_ref(),
978 delete_settings,
979 settings.copy_settings.fail_early,
980 settings.dry_run,
981 )
982 .await
983 {
984 Ok(rm_summary) => {
985 link_summary.copy_summary.rm_summary =
986 link_summary.copy_summary.rm_summary + rm_summary;
987 }
988 Err(err) => {
989 link_summary.copy_summary.rm_summary =
990 link_summary.copy_summary.rm_summary + err.summary;
991 if settings.copy_settings.fail_early {
992 return Err(Error::new(err.source, link_summary));
993 }
994 errors.push(err.source);
995 }
996 }
997 }
998 }
999 let this_dir_count = usize::from(we_created_this_dir);
1002 let child_dirs_created = link_summary
1003 .copy_summary
1004 .directories_created
1005 .saturating_sub(this_dir_count);
1006 let anything_linked = link_summary.hard_links_created > 0
1007 || link_summary.copy_summary.files_copied > 0
1008 || link_summary.copy_summary.symlinks_created > 0
1009 || child_dirs_created > 0;
1010 let relative_path = walk::relative_to_root(src, source_root);
1011 let is_root = src == source_root;
1012 match check_empty_dir_cleanup(
1013 settings.filter.as_ref(),
1014 we_created_this_dir,
1015 anything_linked,
1016 relative_path,
1017 is_root,
1018 settings.dry_run.is_some(),
1019 ) {
1020 EmptyDirAction::Keep => { }
1021 EmptyDirAction::DryRunSkip => {
1022 tracing::debug!(
1023 "dry-run: directory {:?} would not be created (nothing to link inside)",
1024 &dst
1025 );
1026 link_summary.copy_summary.directories_created = 0;
1027 return Ok(link_summary);
1028 }
1029 EmptyDirAction::Remove => {
1030 tracing::debug!(
1031 "directory {:?} has nothing to link inside, removing empty directory",
1032 &dst
1033 );
1034 match crate::walk::run_metadata_probed(
1035 congestion::Side::Destination,
1036 congestion::MetadataOp::RmDir,
1037 tokio::fs::remove_dir(dst),
1038 )
1039 .await
1040 {
1041 Ok(()) => {
1042 link_summary.copy_summary.directories_created = 0;
1043 return Ok(link_summary);
1044 }
1045 Err(err) => {
1046 tracing::debug!(
1048 "failed to remove empty directory {:?}: {:#}, keeping",
1049 &dst,
1050 &err
1051 );
1052 }
1054 }
1055 }
1056 }
1057 tracing::debug!("set 'dst' directory metadata");
1062 let metadata_result = if settings.dry_run.is_some() {
1063 Ok(()) } else {
1065 let preserve_metadata = if let Some(update_metadata) = update_metadata_opt.as_ref() {
1066 update_metadata
1067 } else {
1068 &src_metadata
1069 };
1070 preserve::set_dir_metadata(&settings.preserve, preserve_metadata, dst).await
1071 };
1072 if errors.has_errors() {
1073 if let Err(metadata_err) = metadata_result {
1075 tracing::error!(
1076 "link: {:?} {:?} -> {:?} failed to set directory metadata: {:#}",
1077 src,
1078 update,
1079 dst,
1080 &metadata_err
1081 );
1082 }
1083 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1085 }
1086 metadata_result.map_err(|err| Error::new(err, link_summary))?;
1088 Ok(link_summary)
1089}
1090
1091#[cfg(test)]
1092mod link_tests {
1093 use crate::testutils;
1094 use std::os::unix::fs::PermissionsExt;
1095 use tracing_test::traced_test;
1096
1097 use super::*;
1098
1099 static PROGRESS: std::sync::LazyLock<progress::Progress> =
1100 std::sync::LazyLock::new(progress::Progress::new);
1101
1102 mod delete_keep_set_tests {
1103 use super::super::DeleteKeepSet;
1107 use crate::copy::DeleteSettings;
1108 use std::ffi::{OsStr, OsString};
1109
1110 fn delete_on() -> DeleteSettings {
1111 DeleteSettings {
1112 delete_excluded: false,
1113 }
1114 }
1115
1116 #[test]
1117 fn record_src_no_op_when_delete_off() {
1118 let mut k = DeleteKeepSet::new(None, false, false);
1119 k.record_src(OsStr::new("foo"));
1120 assert!(k.as_set().is_none());
1121 }
1122
1123 #[test]
1124 fn record_src_no_op_under_update_exclusive_with_update() {
1125 let d = delete_on();
1128 let mut k = DeleteKeepSet::new(Some(&d), true, true);
1129 k.record_src(OsStr::new("src_only"));
1130 assert!(!k.as_set().unwrap().contains(OsStr::new("src_only")));
1131 }
1132
1133 #[test]
1134 fn record_src_records_when_update_exclusive_without_update() {
1135 let d = delete_on();
1138 let mut k = DeleteKeepSet::new(Some(&d), true, false);
1139 k.record_src(OsStr::new("foo"));
1140 assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1141 }
1142
1143 #[test]
1144 fn record_src_records_in_normal_delete_mode() {
1145 let d = delete_on();
1146 let mut k = DeleteKeepSet::new(Some(&d), false, false);
1147 k.record_src(OsStr::new("foo"));
1148 assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1149 }
1150
1151 #[test]
1152 fn record_update_always_records_when_delete_on() {
1153 let d = delete_on();
1156 let mut k = DeleteKeepSet::new(Some(&d), true, true);
1157 k.record_update(OsStr::new("from_update"));
1158 assert!(k.as_set().unwrap().contains(OsStr::new("from_update")));
1159 }
1160
1161 #[test]
1162 fn record_update_no_op_when_delete_off() {
1163 let mut k = DeleteKeepSet::new(None, false, false);
1164 k.record_update(OsStr::new("from_update"));
1165 assert!(k.as_set().is_none());
1166 }
1167
1168 #[test]
1169 fn drop_src_when_update_filtered_drops_materialized_src_entry() {
1170 let d = delete_on();
1174 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1175 k.record_src(OsStr::new("node"));
1176 assert!(k.as_set().unwrap().contains(OsStr::new("node")));
1177 k.drop_src_when_update_filtered(OsStr::new("node"), true);
1178 assert!(!k.as_set().unwrap().contains(OsStr::new("node")));
1179 }
1180
1181 #[test]
1182 fn drop_src_when_update_filtered_keeps_skipped_special() {
1183 let d = delete_on();
1187 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1188 k.record_src(OsStr::new("pipe"));
1189 k.drop_src_when_update_filtered(OsStr::new("pipe"), false);
1190 assert!(k.as_set().unwrap().contains(OsStr::new("pipe")));
1191 }
1192
1193 #[test]
1194 fn drop_src_when_update_filtered_no_op_when_delete_off() {
1195 let mut k = DeleteKeepSet::new(None, false, false);
1196 k.drop_src_when_update_filtered(OsStr::new("foo"), true);
1198 assert!(k.as_set().is_none());
1199 }
1200
1201 #[test]
1202 fn full_directory_pass_matches_old_keep_set_semantics() {
1203 let d = delete_on();
1207 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1208
1209 k.record_src(OsStr::new("keep"));
1211 k.record_src(OsStr::new("pipe")); k.record_src(OsStr::new("node"));
1213
1214 k.record_update(OsStr::new("from_upd"));
1216 k.drop_src_when_update_filtered(OsStr::new("node"), true);
1218
1219 let set: std::collections::HashSet<OsString> = k.as_set().unwrap().clone();
1220 let expected: std::collections::HashSet<OsString> = ["keep", "pipe", "from_upd"]
1221 .into_iter()
1222 .map(OsString::from)
1223 .collect();
1224 assert_eq!(set, expected);
1225 }
1226 }
1227
1228 fn common_settings(dereference: bool, overwrite: bool) -> Settings {
1229 Settings {
1230 copy_settings: CopySettings {
1231 dereference,
1232 fail_early: false,
1233 overwrite,
1234 overwrite_compare: filecmp::MetadataCmpSettings {
1235 size: true,
1236 mtime: true,
1237 ..Default::default()
1238 },
1239 overwrite_filter: None,
1240 ignore_existing: false,
1241 chunk_size: 0,
1242 skip_specials: false,
1243 remote_copy_buffer_size: 0,
1244 filter: None,
1245 dry_run: None,
1246 delete: None,
1247 },
1248 update_compare: filecmp::MetadataCmpSettings {
1249 size: true,
1250 mtime: true,
1251 ..Default::default()
1252 },
1253 update_exclusive: false,
1254 filter: None,
1255 dry_run: None,
1256 preserve: preserve::preserve_all(),
1257 }
1258 }
1259
1260 #[tokio::test]
1261 #[traced_test]
1262 async fn test_basic_link() -> Result<(), anyhow::Error> {
1263 let tmp_dir = testutils::setup_test_dir().await?;
1264 let test_path = tmp_dir.as_path();
1265 let summary = link(
1266 &PROGRESS,
1267 test_path,
1268 &test_path.join("foo"),
1269 &test_path.join("bar"),
1270 &None,
1271 &common_settings(false, false),
1272 false,
1273 )
1274 .await?;
1275 assert_eq!(summary.hard_links_created, 5);
1276 assert_eq!(summary.copy_summary.files_copied, 0);
1277 assert_eq!(summary.copy_summary.symlinks_created, 2);
1278 assert_eq!(summary.copy_summary.directories_created, 3);
1279 testutils::check_dirs_identical(
1280 &test_path.join("foo"),
1281 &test_path.join("bar"),
1282 testutils::FileEqualityCheck::Timestamp,
1283 )
1284 .await?;
1285 Ok(())
1286 }
1287
1288 #[tokio::test]
1289 #[traced_test]
1290 async fn test_basic_link_update() -> Result<(), anyhow::Error> {
1291 let tmp_dir = testutils::setup_test_dir().await?;
1292 let test_path = tmp_dir.as_path();
1293 let summary = link(
1294 &PROGRESS,
1295 test_path,
1296 &test_path.join("foo"),
1297 &test_path.join("bar"),
1298 &Some(test_path.join("foo")),
1299 &common_settings(false, false),
1300 false,
1301 )
1302 .await?;
1303 assert_eq!(summary.hard_links_created, 5);
1304 assert_eq!(summary.copy_summary.files_copied, 0);
1305 assert_eq!(summary.copy_summary.symlinks_created, 2);
1306 assert_eq!(summary.copy_summary.directories_created, 3);
1307 testutils::check_dirs_identical(
1308 &test_path.join("foo"),
1309 &test_path.join("bar"),
1310 testutils::FileEqualityCheck::Timestamp,
1311 )
1312 .await?;
1313 Ok(())
1314 }
1315
1316 #[tokio::test]
1317 #[traced_test]
1318 async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
1319 let tmp_dir = testutils::setup_test_dir().await?;
1320 tokio::fs::create_dir(tmp_dir.join("baz")).await?;
1321 let test_path = tmp_dir.as_path();
1322 let summary = link(
1323 &PROGRESS,
1324 test_path,
1325 &test_path.join("baz"), &test_path.join("bar"),
1327 &Some(test_path.join("foo")),
1328 &common_settings(false, false),
1329 false,
1330 )
1331 .await?;
1332 assert_eq!(summary.hard_links_created, 0);
1333 assert_eq!(summary.copy_summary.files_copied, 5);
1334 assert_eq!(summary.copy_summary.symlinks_created, 2);
1335 assert_eq!(summary.copy_summary.directories_created, 3);
1336 testutils::check_dirs_identical(
1337 &test_path.join("foo"),
1338 &test_path.join("bar"),
1339 testutils::FileEqualityCheck::Timestamp,
1340 )
1341 .await?;
1342 Ok(())
1343 }
1344
1345 #[tokio::test]
1346 #[traced_test]
1347 async fn test_link_destination_permission_error_includes_root_cause()
1348 -> Result<(), anyhow::Error> {
1349 let tmp_dir = testutils::setup_test_dir().await?;
1350 let test_path = tmp_dir.as_path();
1351 let readonly_parent = test_path.join("readonly_dest");
1352 tokio::fs::create_dir(&readonly_parent).await?;
1353 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
1354 .await?;
1355
1356 let mut settings = common_settings(false, false);
1357 settings.copy_settings.fail_early = true;
1358
1359 let result = link(
1360 &PROGRESS,
1361 test_path,
1362 &test_path.join("foo"),
1363 &readonly_parent.join("bar"),
1364 &None,
1365 &settings,
1366 false,
1367 )
1368 .await;
1369
1370 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
1372 .await?;
1373
1374 assert!(result.is_err(), "link into read-only parent should fail");
1375 let err = result.unwrap_err();
1376 let err_msg = format!("{:#}", err.source);
1377 assert!(
1378 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
1379 "Error message must include permission denied text. Got: {}",
1380 err_msg
1381 );
1382 Ok(())
1383 }
1384
1385 #[tokio::test]
1386 #[traced_test]
1387 async fn hard_link_file_into_readonly_parent_returns_error() -> Result<(), anyhow::Error> {
1388 let tmp_dir = testutils::setup_test_dir().await?;
1391 let src = tmp_dir.join("src.txt");
1392 tokio::fs::write(&src, "content").await?;
1393 let readonly_parent = tmp_dir.join("readonly_parent");
1394 tokio::fs::create_dir(&readonly_parent).await?;
1395 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
1396 .await?;
1397 let dst = readonly_parent.join("dst.txt");
1398 let settings = common_settings(false, false);
1399 let result = link(&PROGRESS, &tmp_dir, &src, &dst, &None, &settings, false).await;
1400 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
1401 .await?;
1402 let err = result.expect_err("link into read-only parent should fail");
1403 assert_eq!(err.summary.hard_links_created, 0);
1404 let err_msg = format!("{:#}", err.source);
1405 assert!(
1406 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
1407 "error should include root cause, got: {err_msg}"
1408 );
1409 Ok(())
1410 }
1411
1412 pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
1413 let foo_path = tmp_dir.join("update");
1419 tokio::fs::create_dir(&foo_path).await.unwrap();
1420 tokio::fs::write(foo_path.join("0.txt"), "0-new")
1421 .await
1422 .unwrap();
1423 let bar_path = foo_path.join("bar");
1424 tokio::fs::create_dir(&bar_path).await.unwrap();
1425 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1426 .await
1427 .unwrap();
1428 tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
1429 .await
1430 .unwrap();
1431 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1432 Ok(())
1433 }
1434
1435 #[tokio::test]
1436 #[traced_test]
1437 async fn test_link_update() -> Result<(), anyhow::Error> {
1438 let tmp_dir = testutils::setup_test_dir().await?;
1439 setup_update_dir(&tmp_dir).await?;
1440 let test_path = tmp_dir.as_path();
1441 let summary = link(
1442 &PROGRESS,
1443 test_path,
1444 &test_path.join("foo"),
1445 &test_path.join("bar"),
1446 &Some(test_path.join("update")),
1447 &common_settings(false, false),
1448 false,
1449 )
1450 .await?;
1451 assert_eq!(summary.hard_links_created, 2);
1452 assert_eq!(summary.copy_summary.files_copied, 2);
1453 assert_eq!(summary.copy_summary.symlinks_created, 3);
1454 assert_eq!(summary.copy_summary.directories_created, 3);
1455 testutils::check_dirs_identical(
1457 &test_path.join("foo").join("baz"),
1458 &test_path.join("bar").join("baz"),
1459 testutils::FileEqualityCheck::HardLink,
1460 )
1461 .await?;
1462 testutils::check_dirs_identical(
1464 &test_path.join("update"),
1465 &test_path.join("bar"),
1466 testutils::FileEqualityCheck::Timestamp,
1467 )
1468 .await?;
1469 Ok(())
1470 }
1471
1472 #[tokio::test]
1473 #[traced_test]
1474 async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
1475 let tmp_dir = testutils::setup_test_dir().await?;
1476 setup_update_dir(&tmp_dir).await?;
1477 let test_path = tmp_dir.as_path();
1478 let mut settings = common_settings(false, false);
1479 settings.update_exclusive = true;
1480 let summary = link(
1481 &PROGRESS,
1482 test_path,
1483 &test_path.join("foo"),
1484 &test_path.join("bar"),
1485 &Some(test_path.join("update")),
1486 &settings,
1487 false,
1488 )
1489 .await?;
1490 assert_eq!(summary.hard_links_created, 0);
1496 assert_eq!(summary.copy_summary.files_copied, 2);
1497 assert_eq!(summary.copy_summary.symlinks_created, 1);
1498 assert_eq!(summary.copy_summary.directories_created, 2);
1499 testutils::check_dirs_identical(
1501 &test_path.join("update"),
1502 &test_path.join("bar"),
1503 testutils::FileEqualityCheck::Timestamp,
1504 )
1505 .await?;
1506 Ok(())
1507 }
1508
1509 async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
1510 let tmp_dir = testutils::setup_test_dir().await?;
1511 let test_path = tmp_dir.as_path();
1512 let summary = link(
1513 &PROGRESS,
1514 test_path,
1515 &test_path.join("foo"),
1516 &test_path.join("bar"),
1517 &None,
1518 &common_settings(false, false),
1519 false,
1520 )
1521 .await?;
1522 assert_eq!(summary.hard_links_created, 5);
1523 assert_eq!(summary.copy_summary.symlinks_created, 2);
1524 assert_eq!(summary.copy_summary.directories_created, 3);
1525 Ok(tmp_dir)
1526 }
1527
1528 #[tokio::test]
1529 #[traced_test]
1530 async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
1531 let tmp_dir = setup_test_dir_and_link().await?;
1532 let output_path = &tmp_dir.join("bar");
1533 {
1534 let summary = rm::rm(
1545 &PROGRESS,
1546 &output_path.join("bar"),
1547 &rm::Settings {
1548 fail_early: false,
1549 filter: None,
1550 dry_run: None,
1551 time_filter: None,
1552 },
1553 )
1554 .await?
1555 + rm::rm(
1556 &PROGRESS,
1557 &output_path.join("baz").join("5.txt"),
1558 &rm::Settings {
1559 fail_early: false,
1560 filter: None,
1561 dry_run: None,
1562 time_filter: None,
1563 },
1564 )
1565 .await?;
1566 assert_eq!(summary.files_removed, 3);
1567 assert_eq!(summary.symlinks_removed, 1);
1568 assert_eq!(summary.directories_removed, 1);
1569 }
1570 let summary = link(
1571 &PROGRESS,
1572 &tmp_dir,
1573 &tmp_dir.join("foo"),
1574 output_path,
1575 &None,
1576 &common_settings(false, true), false,
1578 )
1579 .await?;
1580 assert_eq!(summary.hard_links_created, 3);
1581 assert_eq!(summary.copy_summary.symlinks_created, 1);
1582 assert_eq!(summary.copy_summary.directories_created, 1);
1583 testutils::check_dirs_identical(
1584 &tmp_dir.join("foo"),
1585 output_path,
1586 testutils::FileEqualityCheck::Timestamp,
1587 )
1588 .await?;
1589 Ok(())
1590 }
1591
1592 #[tokio::test]
1593 #[traced_test]
1594 async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
1595 let tmp_dir = setup_test_dir_and_link().await?;
1596 let output_path = &tmp_dir.join("bar");
1597 {
1598 let summary = rm::rm(
1609 &PROGRESS,
1610 &output_path.join("bar"),
1611 &rm::Settings {
1612 fail_early: false,
1613 filter: None,
1614 dry_run: None,
1615 time_filter: None,
1616 },
1617 )
1618 .await?
1619 + rm::rm(
1620 &PROGRESS,
1621 &output_path.join("baz").join("5.txt"),
1622 &rm::Settings {
1623 fail_early: false,
1624 filter: None,
1625 dry_run: None,
1626 time_filter: None,
1627 },
1628 )
1629 .await?;
1630 assert_eq!(summary.files_removed, 3);
1631 assert_eq!(summary.symlinks_removed, 1);
1632 assert_eq!(summary.directories_removed, 1);
1633 }
1634 setup_update_dir(&tmp_dir).await?;
1635 let summary = link(
1641 &PROGRESS,
1642 &tmp_dir,
1643 &tmp_dir.join("foo"),
1644 output_path,
1645 &Some(tmp_dir.join("update")),
1646 &common_settings(false, true), false,
1648 )
1649 .await?;
1650 assert_eq!(summary.hard_links_created, 1); assert_eq!(summary.copy_summary.files_copied, 2); assert_eq!(summary.copy_summary.symlinks_created, 2); assert_eq!(summary.copy_summary.directories_created, 1);
1654 testutils::check_dirs_identical(
1656 &tmp_dir.join("foo").join("baz"),
1657 &tmp_dir.join("bar").join("baz"),
1658 testutils::FileEqualityCheck::HardLink,
1659 )
1660 .await?;
1661 testutils::check_dirs_identical(
1663 &tmp_dir.join("update"),
1664 &tmp_dir.join("bar"),
1665 testutils::FileEqualityCheck::Timestamp,
1666 )
1667 .await?;
1668 Ok(())
1669 }
1670
1671 #[tokio::test]
1672 #[traced_test]
1673 async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
1674 let tmp_dir = setup_test_dir_and_link().await?;
1675 let output_path = &tmp_dir.join("bar");
1676 {
1677 let bar_path = output_path.join("bar");
1686 let summary = rm::rm(
1687 &PROGRESS,
1688 &bar_path.join("1.txt"),
1689 &rm::Settings {
1690 fail_early: false,
1691 filter: None,
1692 dry_run: None,
1693 time_filter: None,
1694 },
1695 )
1696 .await?
1697 + rm::rm(
1698 &PROGRESS,
1699 &bar_path.join("2.txt"),
1700 &rm::Settings {
1701 fail_early: false,
1702 filter: None,
1703 dry_run: None,
1704 time_filter: None,
1705 },
1706 )
1707 .await?
1708 + rm::rm(
1709 &PROGRESS,
1710 &bar_path.join("3.txt"),
1711 &rm::Settings {
1712 fail_early: false,
1713 filter: None,
1714 dry_run: None,
1715 time_filter: None,
1716 },
1717 )
1718 .await?
1719 + rm::rm(
1720 &PROGRESS,
1721 &output_path.join("baz"),
1722 &rm::Settings {
1723 fail_early: false,
1724 filter: None,
1725 dry_run: None,
1726 time_filter: None,
1727 },
1728 )
1729 .await?;
1730 assert_eq!(summary.files_removed, 4);
1731 assert_eq!(summary.symlinks_removed, 2);
1732 assert_eq!(summary.directories_removed, 1);
1733 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1735 .await
1736 .unwrap();
1737 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1738 .await
1739 .unwrap();
1740 tokio::fs::create_dir(&bar_path.join("3.txt"))
1741 .await
1742 .unwrap();
1743 tokio::fs::write(&output_path.join("baz"), "baz")
1744 .await
1745 .unwrap();
1746 }
1747 let summary = link(
1748 &PROGRESS,
1749 &tmp_dir,
1750 &tmp_dir.join("foo"),
1751 output_path,
1752 &None,
1753 &common_settings(false, true), false,
1755 )
1756 .await?;
1757 assert_eq!(summary.hard_links_created, 4);
1758 assert_eq!(summary.copy_summary.files_copied, 0);
1759 assert_eq!(summary.copy_summary.symlinks_created, 2);
1760 assert_eq!(summary.copy_summary.directories_created, 1);
1761 testutils::check_dirs_identical(
1762 &tmp_dir.join("foo"),
1763 &tmp_dir.join("bar"),
1764 testutils::FileEqualityCheck::HardLink,
1765 )
1766 .await?;
1767 Ok(())
1768 }
1769
1770 #[tokio::test]
1771 #[traced_test]
1772 async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
1773 let tmp_dir = setup_test_dir_and_link().await?;
1774 let output_path = &tmp_dir.join("bar");
1775 {
1776 let bar_path = output_path.join("bar");
1785 let summary = rm::rm(
1786 &PROGRESS,
1787 &bar_path.join("1.txt"),
1788 &rm::Settings {
1789 fail_early: false,
1790 filter: None,
1791 dry_run: None,
1792 time_filter: None,
1793 },
1794 )
1795 .await?
1796 + rm::rm(
1797 &PROGRESS,
1798 &bar_path.join("2.txt"),
1799 &rm::Settings {
1800 fail_early: false,
1801 filter: None,
1802 dry_run: None,
1803 time_filter: None,
1804 },
1805 )
1806 .await?
1807 + rm::rm(
1808 &PROGRESS,
1809 &bar_path.join("3.txt"),
1810 &rm::Settings {
1811 fail_early: false,
1812 filter: None,
1813 dry_run: None,
1814 time_filter: None,
1815 },
1816 )
1817 .await?
1818 + rm::rm(
1819 &PROGRESS,
1820 &output_path.join("baz"),
1821 &rm::Settings {
1822 fail_early: false,
1823 filter: None,
1824 dry_run: None,
1825 time_filter: None,
1826 },
1827 )
1828 .await?;
1829 assert_eq!(summary.files_removed, 4);
1830 assert_eq!(summary.symlinks_removed, 2);
1831 assert_eq!(summary.directories_removed, 1);
1832 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1834 .await
1835 .unwrap();
1836 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1837 .await
1838 .unwrap();
1839 tokio::fs::create_dir(&bar_path.join("3.txt"))
1840 .await
1841 .unwrap();
1842 tokio::fs::write(&output_path.join("baz"), "baz")
1843 .await
1844 .unwrap();
1845 }
1846 let source_path = &tmp_dir.join("foo");
1847 tokio::fs::set_permissions(
1849 &source_path.join("baz"),
1850 std::fs::Permissions::from_mode(0o000),
1851 )
1852 .await?;
1853 match link(
1857 &PROGRESS,
1858 &tmp_dir,
1859 &tmp_dir.join("foo"),
1860 output_path,
1861 &None,
1862 &common_settings(false, true), false,
1864 )
1865 .await
1866 {
1867 Ok(_) => panic!("Expected the link to error!"),
1868 Err(error) => {
1869 tracing::info!("{}", &error);
1870 assert_eq!(error.summary.hard_links_created, 3);
1871 assert_eq!(error.summary.copy_summary.files_copied, 0);
1872 assert_eq!(error.summary.copy_summary.symlinks_created, 0);
1873 assert_eq!(error.summary.copy_summary.directories_created, 0);
1874 assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
1875 assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
1876 assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
1877 }
1878 }
1879 Ok(())
1880 }
1881
1882 #[tokio::test]
1886 #[traced_test]
1887 async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
1888 let tmp_dir = testutils::create_temp_dir().await?;
1889 let test_path = tmp_dir.as_path();
1890 let src_dir = test_path.join("src");
1892 tokio::fs::create_dir(&src_dir).await?;
1893 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
1894 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
1896 let unreadable_subdir = src_dir.join("unreadable_subdir");
1899 tokio::fs::create_dir(&unreadable_subdir).await?;
1900 tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
1901 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
1902 .await?;
1903 let dst_dir = test_path.join("dst");
1904 let result = link(
1906 &PROGRESS,
1907 test_path,
1908 &src_dir,
1909 &dst_dir,
1910 &None,
1911 &common_settings(false, false),
1912 false,
1913 )
1914 .await;
1915 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
1917 .await?;
1918 assert!(
1920 result.is_err(),
1921 "link should fail due to unreadable subdirectory"
1922 );
1923 let error = result.unwrap_err();
1924 assert_eq!(error.summary.hard_links_created, 1);
1926 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
1928 assert!(dst_metadata.is_dir());
1929 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
1930 assert_eq!(
1931 actual_mode, 0o750,
1932 "directory should have preserved source permissions (0o750), got {:o}",
1933 actual_mode
1934 );
1935 Ok(())
1936 }
1937 mod filter_tests {
1938 use super::*;
1939 use crate::filter::FilterSettings;
1940 #[tokio::test]
1942 #[traced_test]
1943 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
1944 let tmp_dir = testutils::setup_test_dir().await?;
1945 let test_path = tmp_dir.as_path();
1946 let mut filter = FilterSettings::new();
1948 filter.add_include("bar/*.txt").unwrap();
1949 let summary = link(
1950 &PROGRESS,
1951 test_path,
1952 &test_path.join("foo"),
1953 &test_path.join("dst"),
1954 &None,
1955 &Settings {
1956 copy_settings: CopySettings {
1957 dereference: false,
1958 fail_early: false,
1959 overwrite: false,
1960 overwrite_compare: Default::default(),
1961 overwrite_filter: None,
1962 ignore_existing: false,
1963 chunk_size: 0,
1964 skip_specials: false,
1965 remote_copy_buffer_size: 0,
1966 filter: None,
1967 dry_run: None,
1968 delete: None,
1969 },
1970 update_compare: Default::default(),
1971 update_exclusive: false,
1972 filter: Some(filter),
1973 dry_run: None,
1974 preserve: preserve::preserve_all(),
1975 },
1976 false,
1977 )
1978 .await?;
1979 assert_eq!(
1981 summary.hard_links_created, 3,
1982 "should link 3 files matching bar/*.txt"
1983 );
1984 assert!(
1986 test_path.join("dst/bar/1.txt").exists(),
1987 "bar/1.txt should be linked"
1988 );
1989 assert!(
1990 test_path.join("dst/bar/2.txt").exists(),
1991 "bar/2.txt should be linked"
1992 );
1993 assert!(
1994 test_path.join("dst/bar/3.txt").exists(),
1995 "bar/3.txt should be linked"
1996 );
1997 assert!(
1999 !test_path.join("dst/0.txt").exists(),
2000 "0.txt should not be linked"
2001 );
2002 Ok(())
2003 }
2004 #[tokio::test]
2006 #[traced_test]
2007 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
2008 let tmp_dir = testutils::setup_test_dir().await?;
2009 let test_path = tmp_dir.as_path();
2010 let mut filter = FilterSettings::new();
2012 filter.add_exclude("*.txt").unwrap();
2013 let summary = link(
2014 &PROGRESS,
2015 test_path,
2016 &test_path.join("foo/0.txt"), &test_path.join("dst/0.txt"),
2018 &None,
2019 &Settings {
2020 copy_settings: CopySettings {
2021 dereference: false,
2022 fail_early: false,
2023 overwrite: false,
2024 overwrite_compare: Default::default(),
2025 overwrite_filter: None,
2026 ignore_existing: false,
2027 chunk_size: 0,
2028 skip_specials: false,
2029 remote_copy_buffer_size: 0,
2030 filter: None,
2031 dry_run: None,
2032 delete: None,
2033 },
2034 update_compare: Default::default(),
2035 update_exclusive: false,
2036 filter: Some(filter),
2037 dry_run: None,
2038 preserve: preserve::preserve_all(),
2039 },
2040 false,
2041 )
2042 .await?;
2043 assert_eq!(
2045 summary.hard_links_created, 0,
2046 "file matching exclude pattern should not be linked"
2047 );
2048 assert!(
2049 !test_path.join("dst/0.txt").exists(),
2050 "excluded file should not exist at destination"
2051 );
2052 Ok(())
2053 }
2054 #[tokio::test]
2056 #[traced_test]
2057 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
2058 let test_path = testutils::create_temp_dir().await?;
2059 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
2061 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
2062 let mut filter = FilterSettings::new();
2064 filter.add_exclude("*_dir/").unwrap();
2065 let result = link(
2066 &PROGRESS,
2067 &test_path,
2068 &test_path.join("excluded_dir"),
2069 &test_path.join("dst"),
2070 &None,
2071 &Settings {
2072 copy_settings: CopySettings {
2073 dereference: false,
2074 fail_early: false,
2075 overwrite: false,
2076 overwrite_compare: Default::default(),
2077 overwrite_filter: None,
2078 ignore_existing: false,
2079 chunk_size: 0,
2080 skip_specials: false,
2081 remote_copy_buffer_size: 0,
2082 filter: None,
2083 dry_run: None,
2084 delete: None,
2085 },
2086 update_compare: Default::default(),
2087 update_exclusive: false,
2088 filter: Some(filter),
2089 dry_run: None,
2090 preserve: preserve::preserve_all(),
2091 },
2092 false,
2093 )
2094 .await?;
2095 assert_eq!(
2097 result.copy_summary.directories_created, 0,
2098 "root directory matching exclude should not be created"
2099 );
2100 assert!(
2101 !test_path.join("dst").exists(),
2102 "excluded root directory should not exist at destination"
2103 );
2104 Ok(())
2105 }
2106 #[tokio::test]
2108 #[traced_test]
2109 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
2110 let test_path = testutils::create_temp_dir().await?;
2111 tokio::fs::write(test_path.join("target.txt"), "content").await?;
2113 tokio::fs::symlink(
2114 test_path.join("target.txt"),
2115 test_path.join("excluded_link"),
2116 )
2117 .await?;
2118 let mut filter = FilterSettings::new();
2120 filter.add_exclude("*_link").unwrap();
2121 let result = link(
2122 &PROGRESS,
2123 &test_path,
2124 &test_path.join("excluded_link"),
2125 &test_path.join("dst"),
2126 &None,
2127 &Settings {
2128 copy_settings: CopySettings {
2129 dereference: false,
2130 fail_early: false,
2131 overwrite: false,
2132 overwrite_compare: Default::default(),
2133 overwrite_filter: None,
2134 ignore_existing: false,
2135 chunk_size: 0,
2136 skip_specials: false,
2137 remote_copy_buffer_size: 0,
2138 filter: None,
2139 dry_run: None,
2140 delete: None,
2141 },
2142 update_compare: Default::default(),
2143 update_exclusive: false,
2144 filter: Some(filter),
2145 dry_run: None,
2146 preserve: preserve::preserve_all(),
2147 },
2148 false,
2149 )
2150 .await?;
2151 assert_eq!(
2153 result.copy_summary.symlinks_created, 0,
2154 "root symlink matching exclude should not be created"
2155 );
2156 assert!(
2157 !test_path.join("dst").exists(),
2158 "excluded root symlink should not exist at destination"
2159 );
2160 Ok(())
2161 }
2162 #[tokio::test]
2164 #[traced_test]
2165 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
2166 let tmp_dir = testutils::setup_test_dir().await?;
2167 let test_path = tmp_dir.as_path();
2168 let mut filter = FilterSettings::new();
2175 filter.add_include("bar/*.txt").unwrap();
2176 filter.add_exclude("bar/2.txt").unwrap();
2177 let summary = link(
2178 &PROGRESS,
2179 test_path,
2180 &test_path.join("foo"),
2181 &test_path.join("dst"),
2182 &None,
2183 &Settings {
2184 copy_settings: CopySettings {
2185 dereference: false,
2186 fail_early: false,
2187 overwrite: false,
2188 overwrite_compare: Default::default(),
2189 overwrite_filter: None,
2190 ignore_existing: false,
2191 chunk_size: 0,
2192 skip_specials: false,
2193 remote_copy_buffer_size: 0,
2194 filter: None,
2195 dry_run: None,
2196 delete: None,
2197 },
2198 update_compare: Default::default(),
2199 update_exclusive: false,
2200 filter: Some(filter),
2201 dry_run: None,
2202 preserve: preserve::preserve_all(),
2203 },
2204 false,
2205 )
2206 .await?;
2207 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
2210 assert_eq!(
2211 summary.copy_summary.files_skipped, 2,
2212 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
2213 );
2214 assert!(
2216 test_path.join("dst/bar/1.txt").exists(),
2217 "bar/1.txt should be linked"
2218 );
2219 assert!(
2220 !test_path.join("dst/bar/2.txt").exists(),
2221 "bar/2.txt should be excluded"
2222 );
2223 assert!(
2224 test_path.join("dst/bar/3.txt").exists(),
2225 "bar/3.txt should be linked"
2226 );
2227 Ok(())
2228 }
2229 #[tokio::test]
2231 #[traced_test]
2232 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
2233 let tmp_dir = testutils::setup_test_dir().await?;
2234 let test_path = tmp_dir.as_path();
2235 let mut filter = FilterSettings::new();
2242 filter.add_exclude("bar/").unwrap();
2243 let summary = link(
2244 &PROGRESS,
2245 test_path,
2246 &test_path.join("foo"),
2247 &test_path.join("dst"),
2248 &None,
2249 &Settings {
2250 copy_settings: CopySettings {
2251 dereference: false,
2252 fail_early: false,
2253 overwrite: false,
2254 overwrite_compare: Default::default(),
2255 overwrite_filter: None,
2256 ignore_existing: false,
2257 chunk_size: 0,
2258 skip_specials: false,
2259 remote_copy_buffer_size: 0,
2260 filter: None,
2261 dry_run: None,
2262 delete: None,
2263 },
2264 update_compare: Default::default(),
2265 update_exclusive: false,
2266 filter: Some(filter),
2267 dry_run: None,
2268 preserve: preserve::preserve_all(),
2269 },
2270 false,
2271 )
2272 .await?;
2273 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
2277 assert_eq!(
2278 summary.copy_summary.symlinks_created, 2,
2279 "should copy 2 symlinks"
2280 );
2281 assert_eq!(
2282 summary.copy_summary.directories_skipped, 1,
2283 "should skip 1 directory (bar)"
2284 );
2285 assert!(
2287 !test_path.join("dst/bar").exists(),
2288 "bar directory should not be linked"
2289 );
2290 Ok(())
2291 }
2292 #[tokio::test]
2295 #[traced_test]
2296 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
2297 let test_path = testutils::create_temp_dir().await?;
2298 let src_path = test_path.join("src");
2304 tokio::fs::create_dir(&src_path).await?;
2305 tokio::fs::write(src_path.join("foo"), "content").await?;
2306 tokio::fs::write(src_path.join("bar"), "content").await?;
2307 tokio::fs::create_dir(src_path.join("baz")).await?;
2308 let mut filter = FilterSettings::new();
2310 filter.add_include("foo").unwrap();
2311 let summary = link(
2312 &PROGRESS,
2313 &test_path,
2314 &src_path,
2315 &test_path.join("dst"),
2316 &None,
2317 &Settings {
2318 copy_settings: copy::Settings {
2319 dereference: false,
2320 fail_early: false,
2321 overwrite: false,
2322 overwrite_compare: Default::default(),
2323 overwrite_filter: None,
2324 ignore_existing: false,
2325 chunk_size: 0,
2326 skip_specials: false,
2327 remote_copy_buffer_size: 0,
2328 filter: None,
2329 dry_run: None,
2330 delete: None,
2331 },
2332 update_compare: Default::default(),
2333 update_exclusive: false,
2334 filter: Some(filter),
2335 dry_run: None,
2336 preserve: preserve::preserve_all(),
2337 },
2338 false,
2339 )
2340 .await?;
2341 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2343 assert_eq!(
2344 summary.copy_summary.directories_created, 1,
2345 "should create only root directory (not empty 'baz')"
2346 );
2347 assert!(
2349 test_path.join("dst").join("foo").exists(),
2350 "foo should be linked"
2351 );
2352 assert!(
2354 !test_path.join("dst").join("bar").exists(),
2355 "bar should not be linked"
2356 );
2357 assert!(
2359 !test_path.join("dst").join("baz").exists(),
2360 "empty baz directory should NOT be created"
2361 );
2362 Ok(())
2363 }
2364 #[tokio::test]
2367 #[traced_test]
2368 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
2369 let test_path = testutils::create_temp_dir().await?;
2370 let src_path = test_path.join("src");
2377 tokio::fs::create_dir(&src_path).await?;
2378 tokio::fs::write(src_path.join("foo"), "content").await?;
2379 tokio::fs::create_dir(src_path.join("baz")).await?;
2380 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
2381 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
2382 let mut filter = FilterSettings::new();
2384 filter.add_include("foo").unwrap();
2385 let summary = link(
2386 &PROGRESS,
2387 &test_path,
2388 &src_path,
2389 &test_path.join("dst"),
2390 &None,
2391 &Settings {
2392 copy_settings: copy::Settings {
2393 dereference: false,
2394 fail_early: false,
2395 overwrite: false,
2396 overwrite_compare: Default::default(),
2397 overwrite_filter: None,
2398 ignore_existing: false,
2399 chunk_size: 0,
2400 skip_specials: false,
2401 remote_copy_buffer_size: 0,
2402 filter: None,
2403 dry_run: None,
2404 delete: None,
2405 },
2406 update_compare: Default::default(),
2407 update_exclusive: false,
2408 filter: Some(filter),
2409 dry_run: None,
2410 preserve: preserve::preserve_all(),
2411 },
2412 false,
2413 )
2414 .await?;
2415 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2417 assert_eq!(
2418 summary.copy_summary.files_skipped, 2,
2419 "should skip 2 files (qux and quux)"
2420 );
2421 assert_eq!(
2422 summary.copy_summary.directories_created, 1,
2423 "should create only root directory (not 'baz' with non-matching content)"
2424 );
2425 assert!(
2427 test_path.join("dst").join("foo").exists(),
2428 "foo should be linked"
2429 );
2430 assert!(
2432 !test_path.join("dst").join("baz").exists(),
2433 "baz directory should NOT be created (no matching content inside)"
2434 );
2435 Ok(())
2436 }
2437 #[tokio::test]
2440 #[traced_test]
2441 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
2442 let test_path = testutils::create_temp_dir().await?;
2443 let src_path = test_path.join("src");
2449 tokio::fs::create_dir(&src_path).await?;
2450 tokio::fs::write(src_path.join("foo"), "content").await?;
2451 tokio::fs::write(src_path.join("bar"), "content").await?;
2452 tokio::fs::create_dir(src_path.join("baz")).await?;
2453 let mut filter = FilterSettings::new();
2455 filter.add_include("foo").unwrap();
2456 let summary = link(
2457 &PROGRESS,
2458 &test_path,
2459 &src_path,
2460 &test_path.join("dst"),
2461 &None,
2462 &Settings {
2463 copy_settings: copy::Settings {
2464 dereference: false,
2465 fail_early: false,
2466 overwrite: false,
2467 overwrite_compare: Default::default(),
2468 overwrite_filter: None,
2469 ignore_existing: false,
2470 chunk_size: 0,
2471 skip_specials: false,
2472 remote_copy_buffer_size: 0,
2473 filter: None,
2474 dry_run: None,
2475 delete: None,
2476 },
2477 update_compare: Default::default(),
2478 update_exclusive: false,
2479 filter: Some(filter),
2480 dry_run: Some(crate::config::DryRunMode::Explain),
2481 preserve: preserve::preserve_all(),
2482 },
2483 false,
2484 )
2485 .await?;
2486 assert_eq!(
2488 summary.hard_links_created, 1,
2489 "should report only 'foo' would be linked"
2490 );
2491 assert_eq!(
2492 summary.copy_summary.directories_created, 1,
2493 "should report only root directory would be created (not empty 'baz')"
2494 );
2495 assert!(
2497 !test_path.join("dst").exists(),
2498 "dst should not exist in dry-run"
2499 );
2500 Ok(())
2501 }
2502 #[tokio::test]
2505 #[traced_test]
2506 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
2507 let test_path = testutils::create_temp_dir().await?;
2508 let src_path = test_path.join("src");
2514 tokio::fs::create_dir(&src_path).await?;
2515 tokio::fs::write(src_path.join("foo"), "content").await?;
2516 tokio::fs::write(src_path.join("bar"), "content").await?;
2517 tokio::fs::create_dir(src_path.join("baz")).await?;
2518 let dst_path = test_path.join("dst");
2520 tokio::fs::create_dir(&dst_path).await?;
2521 tokio::fs::create_dir(dst_path.join("baz")).await?;
2522 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
2524 let mut filter = FilterSettings::new();
2526 filter.add_include("foo").unwrap();
2527 let summary = link(
2528 &PROGRESS,
2529 &test_path,
2530 &src_path,
2531 &dst_path,
2532 &None,
2533 &Settings {
2534 copy_settings: copy::Settings {
2535 dereference: false,
2536 fail_early: false,
2537 overwrite: true, overwrite_compare: Default::default(),
2539 overwrite_filter: None,
2540 ignore_existing: false,
2541 chunk_size: 0,
2542 skip_specials: false,
2543 remote_copy_buffer_size: 0,
2544 filter: None,
2545 dry_run: None,
2546 delete: None,
2547 },
2548 update_compare: Default::default(),
2549 update_exclusive: false,
2550 filter: Some(filter),
2551 dry_run: None,
2552 preserve: preserve::preserve_all(),
2553 },
2554 false,
2555 )
2556 .await?;
2557 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2559 assert_eq!(
2561 summary.copy_summary.directories_unchanged, 2,
2562 "root dst and baz directories should be unchanged"
2563 );
2564 assert_eq!(
2565 summary.copy_summary.directories_created, 0,
2566 "should not create any directories"
2567 );
2568 assert!(dst_path.join("foo").exists(), "foo should be linked");
2570 assert!(!dst_path.join("bar").exists(), "bar should not be linked");
2572 assert!(
2574 dst_path.join("baz").exists(),
2575 "existing baz directory should still exist"
2576 );
2577 assert!(
2578 dst_path.join("baz").join("marker.txt").exists(),
2579 "existing content in baz should still exist"
2580 );
2581 Ok(())
2582 }
2583 }
2584 mod dry_run_tests {
2585 use super::*;
2586 #[tokio::test]
2588 #[traced_test]
2589 async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
2590 let tmp_dir = testutils::setup_test_dir().await?;
2591 let test_path = tmp_dir.as_path();
2592 let src_file = test_path.join("foo/0.txt");
2593 let dst_file = test_path.join("dst_link.txt");
2594 assert!(
2596 !dst_file.exists(),
2597 "destination should not exist before dry-run"
2598 );
2599 let summary = link(
2600 &PROGRESS,
2601 test_path,
2602 &src_file,
2603 &dst_file,
2604 &None,
2605 &Settings {
2606 copy_settings: CopySettings {
2607 dereference: false,
2608 fail_early: false,
2609 overwrite: false,
2610 overwrite_compare: Default::default(),
2611 overwrite_filter: None,
2612 ignore_existing: false,
2613 chunk_size: 0,
2614 skip_specials: false,
2615 remote_copy_buffer_size: 0,
2616 filter: None,
2617 dry_run: None,
2618 delete: None,
2619 },
2620 update_compare: Default::default(),
2621 update_exclusive: false,
2622 filter: None,
2623 dry_run: Some(crate::config::DryRunMode::Brief),
2624 preserve: preserve::preserve_all(),
2625 },
2626 false,
2627 )
2628 .await?;
2629 assert!(!dst_file.exists(), "dry-run should not create hard link");
2631 assert_eq!(
2633 summary.hard_links_created, 1,
2634 "dry-run should report 1 hard link that would be created"
2635 );
2636 Ok(())
2637 }
2638 #[tokio::test]
2640 #[traced_test]
2641 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
2642 let tmp_dir = testutils::setup_test_dir().await?;
2643 let test_path = tmp_dir.as_path();
2644 let dst_path = test_path.join("nonexistent_dst");
2645 assert!(
2647 !dst_path.exists(),
2648 "destination should not exist before dry-run"
2649 );
2650 let summary = link(
2651 &PROGRESS,
2652 test_path,
2653 &test_path.join("foo"),
2654 &dst_path,
2655 &None,
2656 &Settings {
2657 copy_settings: CopySettings {
2658 dereference: false,
2659 fail_early: false,
2660 overwrite: false,
2661 overwrite_compare: Default::default(),
2662 overwrite_filter: None,
2663 ignore_existing: false,
2664 chunk_size: 0,
2665 skip_specials: false,
2666 remote_copy_buffer_size: 0,
2667 filter: None,
2668 dry_run: None,
2669 delete: None,
2670 },
2671 update_compare: Default::default(),
2672 update_exclusive: false,
2673 filter: None,
2674 dry_run: Some(crate::config::DryRunMode::Brief),
2675 preserve: preserve::preserve_all(),
2676 },
2677 false,
2678 )
2679 .await?;
2680 assert!(
2682 !dst_path.exists(),
2683 "dry-run should not create destination directory"
2684 );
2685 assert!(
2687 summary.hard_links_created > 0,
2688 "dry-run should report hard links that would be created"
2689 );
2690 Ok(())
2691 }
2692 #[tokio::test]
2694 #[traced_test]
2695 async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
2696 let tmp_dir = testutils::setup_test_dir().await?;
2697 let test_path = tmp_dir.as_path();
2698 let src_path = test_path.join("foo/baz");
2700 let dst_path = test_path.join("dst_baz");
2701 assert!(
2703 !dst_path.exists(),
2704 "destination should not exist before dry-run"
2705 );
2706 let summary = link(
2707 &PROGRESS,
2708 test_path,
2709 &src_path,
2710 &dst_path,
2711 &None,
2712 &Settings {
2713 copy_settings: CopySettings {
2714 dereference: false,
2715 fail_early: false,
2716 overwrite: false,
2717 overwrite_compare: Default::default(),
2718 overwrite_filter: None,
2719 ignore_existing: false,
2720 chunk_size: 0,
2721 skip_specials: false,
2722 remote_copy_buffer_size: 0,
2723 filter: None,
2724 dry_run: None,
2725 delete: None,
2726 },
2727 update_compare: Default::default(),
2728 update_exclusive: false,
2729 filter: None,
2730 dry_run: Some(crate::config::DryRunMode::Brief),
2731 preserve: preserve::preserve_all(),
2732 },
2733 false,
2734 )
2735 .await?;
2736 assert!(!dst_path.exists(), "dry-run should not create destination");
2738 assert_eq!(
2740 summary.hard_links_created, 1,
2741 "dry-run should report 1 hard link (for 4.txt)"
2742 );
2743 assert_eq!(
2744 summary.copy_summary.symlinks_created, 2,
2745 "dry-run should report 2 symlinks (5.txt and 6.txt)"
2746 );
2747 Ok(())
2748 }
2749 }
2750
2751 #[tokio::test]
2758 #[traced_test]
2759 async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
2760 let tmp_dir = testutils::create_temp_dir().await?;
2761 let test_path = tmp_dir.as_path();
2762 let src_dir = test_path.join("src");
2767 let sub_dir = src_dir.join("sub");
2768 let bad_dir = sub_dir.join("unreadable_dir");
2769 tokio::fs::create_dir_all(&bad_dir).await?;
2770 tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
2771 tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
2772 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
2773 let dst_dir = test_path.join("dst");
2774 let result = link(
2775 &PROGRESS,
2776 test_path,
2777 &src_dir,
2778 &dst_dir,
2779 &None,
2780 &Settings {
2781 copy_settings: CopySettings {
2782 fail_early: true,
2783 ..common_settings(false, false).copy_settings
2784 },
2785 ..common_settings(false, false)
2786 },
2787 false,
2788 )
2789 .await;
2790 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
2792 let error = result.expect_err("link should fail due to unreadable directory");
2793 assert!(
2798 error.summary.copy_summary.directories_created >= 2,
2799 "fail-early summary should include directories from the failing subtree, \
2800 got directories_created={} (expected >= 2: dst/ and dst/sub/)",
2801 error.summary.copy_summary.directories_created
2802 );
2803 Ok(())
2804 }
2805
2806 #[tokio::test]
2807 #[traced_test]
2808 async fn skip_specials_skips_socket_in_link() -> Result<(), anyhow::Error> {
2809 let tmp_dir = testutils::setup_test_dir().await?;
2810 let test_path = tmp_dir.as_path();
2811 let src = test_path.join("src_dir");
2812 let dst = test_path.join("dst_dir");
2813 tokio::fs::create_dir(&src).await?;
2814 tokio::fs::write(src.join("file.txt"), "hello").await?;
2815 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
2816 let mut settings = common_settings(false, false);
2817 settings.copy_settings.skip_specials = true;
2818 let summary = link(&PROGRESS, test_path, &src, &dst, &None, &settings, false).await?;
2819 assert_eq!(summary.hard_links_created, 1);
2820 assert_eq!(summary.copy_summary.specials_skipped, 1);
2821 assert!(dst.join("file.txt").exists());
2822 assert!(!dst.join("test.sock").exists());
2823 Ok(())
2824 }
2825
2826 #[tokio::test]
2827 #[traced_test]
2828 async fn delete_skips_pruning_when_link_has_errors() -> Result<(), anyhow::Error> {
2829 let tmp_dir = testutils::setup_test_dir().await?;
2830 let test_path = tmp_dir.as_path();
2831 let src = test_path.join("foo");
2832 let dst = test_path.join("bar");
2833 link(
2835 &PROGRESS,
2836 test_path,
2837 &src,
2838 &dst,
2839 &None,
2840 &common_settings(false, false),
2841 false,
2842 )
2843 .await?;
2844 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
2846 let unreadable = src.join("baz");
2850 let original = tokio::fs::metadata(&unreadable).await?.permissions();
2851 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2852
2853 let delete_settings = Settings {
2854 copy_settings: CopySettings {
2855 overwrite: true,
2856 fail_early: false,
2857 delete: Some(copy::DeleteSettings {
2858 delete_excluded: false,
2859 }),
2860 ..common_settings(false, true).copy_settings
2861 },
2862 ..common_settings(false, true)
2863 };
2864 let result = link(
2865 &PROGRESS,
2866 test_path,
2867 &src,
2868 &dst,
2869 &None,
2870 &delete_settings,
2871 false,
2872 )
2873 .await;
2874
2875 tokio::fs::set_permissions(&unreadable, original).await?;
2876
2877 assert!(
2878 result.is_err(),
2879 "link of the unreadable directory should fail"
2880 );
2881 assert!(
2882 dst.join("extraneous.txt").exists(),
2883 "pruning must be skipped when the link/update pass reported errors"
2884 );
2885 Ok(())
2886 }
2887
2888 #[tokio::test]
2889 #[traced_test]
2890 async fn skip_specials_top_level_socket_in_link() -> Result<(), anyhow::Error> {
2891 let tmp_dir = testutils::setup_test_dir().await?;
2892 let test_path = tmp_dir.as_path();
2893 let src_socket = test_path.join("test.sock");
2894 let dst = test_path.join("dst.sock");
2895 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
2896 let mut settings = common_settings(false, false);
2897 settings.copy_settings.skip_specials = true;
2898 let summary = link(
2899 &PROGRESS,
2900 test_path,
2901 &src_socket,
2902 &dst,
2903 &None,
2904 &settings,
2905 false,
2906 )
2907 .await?;
2908 assert_eq!(summary.copy_summary.specials_skipped, 1);
2909 assert_eq!(summary.hard_links_created, 0);
2910 assert!(!dst.exists());
2911 Ok(())
2912 }
2913
2914 mod max_open_files_tests {
2916 use super::*;
2917
2918 #[tokio::test]
2921 #[traced_test]
2922 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
2923 let tmp_dir = testutils::create_temp_dir().await?;
2924 let src = tmp_dir.join("src");
2925 let dst = tmp_dir.join("dst");
2926 let depth = 20;
2927 let files_per_level = 5;
2928 let limit = 4;
2929 let mut dir = src.clone();
2931 for level in 0..depth {
2932 tokio::fs::create_dir_all(&dir).await?;
2933 for f in 0..files_per_level {
2934 tokio::fs::write(
2935 dir.join(format!("f{}_{}.txt", level, f)),
2936 format!("L{}F{}", level, f),
2937 )
2938 .await?;
2939 }
2940 dir = dir.join(format!("d{}", level));
2941 }
2942 throttle::set_max_open_files(limit);
2943 let summary = tokio::time::timeout(
2944 std::time::Duration::from_secs(30),
2945 link(
2946 &PROGRESS,
2947 tmp_dir.as_path(),
2948 &src,
2949 &dst,
2950 &None,
2951 &common_settings(false, false),
2952 false,
2953 ),
2954 )
2955 .await
2956 .context("link timed out — possible deadlock")?
2957 .context("link failed")?;
2958 assert_eq!(summary.hard_links_created, depth * files_per_level);
2959 assert_eq!(summary.copy_summary.directories_created, depth);
2960 let mut check_dir = dst.clone();
2962 for level in 0..depth {
2963 let content =
2964 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
2965 assert_eq!(content, format!("L{}F0", level));
2966 check_dir = check_dir.join(format!("d{}", level));
2967 }
2968 Ok(())
2969 }
2970
2971 #[tokio::test]
2982 #[traced_test]
2983 async fn parallel_update_filetype_change_no_deadlock() -> Result<(), anyhow::Error> {
2984 let tmp_dir = testutils::create_temp_dir().await?;
2985 let src = tmp_dir.join("src");
2986 let update = tmp_dir.join("update");
2987 let dst = tmp_dir.join("dst");
2988 tokio::fs::create_dir(&src).await?;
2989 tokio::fs::create_dir(&update).await?;
2990 let n = 8;
2991 for i in 0..n {
2995 tokio::fs::write(src.join(format!("e{}", i)), format!("src-{}", i)).await?;
2996 let upd_subdir = update.join(format!("e{}", i));
2997 tokio::fs::create_dir(&upd_subdir).await?;
2998 for j in 0..3 {
2999 tokio::fs::write(
3000 upd_subdir.join(format!("inner_{}.txt", j)),
3001 format!("upd-{}-{}", i, j),
3002 )
3003 .await?;
3004 }
3005 }
3006 throttle::set_max_open_files(2);
3009 let summary = tokio::time::timeout(
3010 std::time::Duration::from_secs(30),
3011 link(
3012 &PROGRESS,
3013 tmp_dir.as_path(),
3014 &src,
3015 &dst,
3016 &Some(update.clone()),
3017 &common_settings(false, false),
3018 false,
3019 ),
3020 )
3021 .await
3022 .context(
3023 "link timed out — caller-supplied open-files guard not released before copy::copy",
3024 )?
3025 .context("link failed")?;
3026 assert_eq!(summary.copy_summary.directories_created, n + 1); assert_eq!(summary.copy_summary.files_copied, n * 3);
3030 for i in 0..n {
3032 for j in 0..3 {
3033 let content =
3034 tokio::fs::read_to_string(dst.join(format!("e{}/inner_{}.txt", i, j)))
3035 .await?;
3036 assert_eq!(content, format!("upd-{}-{}", i, j));
3037 }
3038 }
3039 Ok(())
3040 }
3041
3042 #[tokio::test]
3050 #[traced_test]
3051 async fn update_only_entries_bounded_no_deadlock() -> Result<(), anyhow::Error> {
3052 let tmp_dir = testutils::create_temp_dir().await?;
3053 let src = tmp_dir.join("src");
3054 let update = tmp_dir.join("update");
3055 let dst = tmp_dir.join("dst");
3056 tokio::fs::create_dir(&src).await?;
3057 tokio::fs::create_dir(&update).await?;
3058 let n = 50;
3061 for i in 0..n {
3062 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
3063 }
3064 throttle::set_max_open_files(2);
3065 let summary = tokio::time::timeout(
3066 std::time::Duration::from_secs(30),
3067 link(
3068 &PROGRESS,
3069 tmp_dir.as_path(),
3070 &src,
3071 &dst,
3072 &Some(update.clone()),
3073 &common_settings(false, false),
3074 false,
3075 ),
3076 )
3077 .await
3078 .context("link timed out — site-3 spawn loop deadlock")?
3079 .context("link failed")?;
3080 assert_eq!(summary.copy_summary.directories_created, 1);
3082 assert_eq!(summary.copy_summary.files_copied, n);
3083 for i in 0..n {
3084 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
3085 assert_eq!(content, format!("upd-{}", i));
3086 }
3087 Ok(())
3088 }
3089
3090 #[tokio::test]
3101 #[traced_test]
3102 async fn update_only_overwrite_preexisting_dirs_no_deadlock() -> Result<(), anyhow::Error> {
3103 let tmp_dir = testutils::create_temp_dir().await?;
3104 let src = tmp_dir.join("src");
3105 let update = tmp_dir.join("update");
3106 let dst = tmp_dir.join("dst");
3107 tokio::fs::create_dir(&src).await?;
3108 tokio::fs::create_dir(&update).await?;
3109 tokio::fs::create_dir(&dst).await?;
3110 let n = 12;
3111 for i in 0..n {
3112 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
3114 let dst_subdir = dst.join(format!("u{}", i));
3118 tokio::fs::create_dir(&dst_subdir).await?;
3119 for j in 0..3 {
3120 tokio::fs::write(
3121 dst_subdir.join(format!("inner_{}.txt", j)),
3122 format!("old-{}-{}", i, j),
3123 )
3124 .await?;
3125 }
3126 }
3127 throttle::set_max_open_files(2);
3129 let summary = tokio::time::timeout(
3130 std::time::Duration::from_secs(30),
3131 link(
3132 &PROGRESS,
3133 tmp_dir.as_path(),
3134 &src,
3135 &dst,
3136 &Some(update.clone()),
3137 &common_settings(false, true), false,
3139 ),
3140 )
3141 .await
3142 .context("link timed out — pending-meta self-deadlock between site 3 and inner rm")?
3143 .context("link failed")?;
3144 assert_eq!(summary.copy_summary.files_copied, n);
3147 assert_eq!(summary.copy_summary.rm_summary.files_removed, n * 3);
3148 assert_eq!(summary.copy_summary.rm_summary.directories_removed, n);
3149 for i in 0..n {
3151 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
3152 assert_eq!(content, format!("upd-{}", i));
3153 }
3154 Ok(())
3155 }
3156 }
3157}