1use anyhow::{Context, anyhow};
2use async_recursion::async_recursion;
3use std::sync::Arc;
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::safedir::Dir;
14use crate::walk::{self, EntryKind, LeafPermit, PermitKind};
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
89#[instrument(skip(prog_track, settings))]
106#[allow(clippy::too_many_arguments)]
107async fn hard_link_entry_fd(
108 prog_track: &'static progress::Progress,
109 src_handle: &crate::safedir::Handle,
110 dst_dir: &Arc<Dir>,
111 dst_name: &std::ffi::OsStr,
112 dst_path: &std::path::Path,
113 settings: &Settings,
114) -> Result<Summary, Error> {
115 let mut link_summary = Summary::default();
116 match dst_dir.hard_link_handle_at(src_handle, dst_name).await {
117 Ok(()) => {}
118 Err(error)
119 if settings.copy_settings.overwrite
120 && error.kind() == std::io::ErrorKind::AlreadyExists =>
121 {
122 tracing::debug!("'dst' already exists, check if we need to update");
123 let dst_handle = dst_dir
124 .child(dst_name)
125 .await
126 .with_context(|| format!("cannot read {dst_path:?} metadata"))
127 .map_err(|err| Error::new(err, Default::default()))?;
128 if dst_handle.kind() == src_handle.kind()
132 && dst_handle.dev() == src_handle.dev()
133 && dst_handle.ino() == src_handle.ino()
134 {
135 tracing::debug!("no change, leaving file as is");
136 prog_track.hard_links_unchanged.inc();
137 return Ok(Summary {
138 hard_links_unchanged: 1,
139 ..Default::default()
140 });
141 }
142 tracing::info!("'dst' file type changed, removing and hard-linking");
143 let rm_summary = copy::remove_existing(
145 prog_track,
146 dst_dir,
147 dst_name,
148 dst_path,
149 &dst_handle,
150 &settings.copy_settings,
151 )
152 .await
153 .map_err(|err| {
154 link_summary.copy_summary.rm_summary = err.summary.rm_summary;
155 Error::new(err.source, link_summary)
156 })?;
157 link_summary.copy_summary.rm_summary = rm_summary;
158 dst_dir
159 .hard_link_handle_at(src_handle, dst_name)
160 .await
161 .with_context(|| format!("failed to hard link to {dst_path:?}"))
162 .map_err(|err| Error::new(err, link_summary))?;
163 }
164 Err(error) => {
165 return Err(Error::new(
166 anyhow::Error::from(error).context(format!("failed to hard link to {dst_path:?}")),
167 link_summary,
168 ));
169 }
170 }
171 prog_track.hard_links_created.inc();
172 link_summary.hard_links_created = 1;
173 Ok(link_summary)
174}
175
176#[instrument(skip(prog_track, settings))]
189pub async fn link(
190 prog_track: &'static progress::Progress,
191 cwd: &std::path::Path,
192 src: &std::path::Path,
193 dst: &std::path::Path,
194 update: &Option<std::path::PathBuf>,
195 settings: &Settings,
196 is_fresh: bool,
197) -> Result<Summary, Error> {
198 let _ = cwd;
201 if let Some(update_path) = update.as_ref()
213 && (settings.update_exclusive || settings.copy_settings.delete.is_some())
214 {
215 match crate::walk::run_metadata_probed(
216 congestion::Side::Source,
217 congestion::MetadataOp::Stat,
218 tokio::fs::symlink_metadata(update_path),
219 )
220 .await
221 {
222 Ok(_) => {}
223 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
224 return Err(Error::new(
225 anyhow!(
226 "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
227 update_path
228 ),
229 Default::default(),
230 ));
231 }
232 Err(err) => {
233 return Err(Error::new(
234 anyhow::Error::new(err).context(format!(
235 "failed reading metadata from update {:?}",
236 update_path
237 )),
238 Default::default(),
239 ));
240 }
241 }
242 }
243 let src_operand = crate::walk::split_root_operand(src)
247 .await
248 .map_err(|err| Error::new(err, Default::default()))?;
249 let src = src_operand.display.as_path();
250 let src_name = src_operand.name.as_os_str();
251 if let Some(ref filter) = settings.filter {
253 let src_metadata = crate::walk::run_metadata_probed(
254 congestion::Side::Source,
255 congestion::MetadataOp::Stat,
256 tokio::fs::symlink_metadata(src),
257 )
258 .await
259 .with_context(|| format!("failed reading metadata from {:?}", &src))
260 .map_err(|err| Error::new(err, Default::default()))?;
261 let is_dir = src_metadata.is_dir();
262 let result = filter.should_include_root_item(std::path::Path::new(src_name), is_dir);
263 match result {
264 crate::filter::FilterResult::Included => {}
265 result => {
266 let kind = EntryKind::from_metadata(&src_metadata);
267 if let Some(mode) = settings.dry_run {
268 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
269 }
270 kind.inc_skipped(prog_track);
271 return Ok(skipped_summary_for(kind));
272 }
273 }
274 }
275 let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
282 return Err(Error::new(
283 anyhow!(
284 "link destination {:?} has no parent directory or file name",
285 dst
286 ),
287 Default::default(),
288 ));
289 };
290 let resolve_parent = |p: &std::path::Path| -> std::path::PathBuf {
292 if p.as_os_str().is_empty() {
293 std::path::PathBuf::from(".")
294 } else {
295 p.to_path_buf()
296 }
297 };
298 let src_parent_path = src_operand.parent.clone();
300 let dst_parent_path = resolve_parent(dst_parent_path);
301 let src_parent = Dir::open_parent_dir(&src_parent_path, congestion::Side::Source)
305 .await
306 .with_context(|| format!("cannot open source parent directory {:?}", src_parent_path))
307 .map_err(|err| Error::new(err, Default::default()))?;
308 let src_parent = Arc::new(src_parent.into_tree());
310 let dst_parent = if settings.dry_run.is_some() {
314 None
315 } else {
316 let dir = Dir::open_parent_dir(&dst_parent_path, congestion::Side::Destination)
319 .await
320 .with_context(|| {
321 format!(
322 "cannot open destination parent directory {:?}",
323 dst_parent_path
324 )
325 })
326 .map_err(|err| Error::new(err, Default::default()))?;
327 Some(Arc::new(dir.into_tree()))
329 };
330 let update_parent = match update.as_ref() {
343 Some(update_path) => {
344 let update_operand = crate::walk::split_root_operand(update_path)
350 .await
351 .map_err(|err| Error::new(err, Default::default()))?;
352 let update_parent_path = update_operand.parent;
353 let update_name = update_operand.name;
354 let fallback_eligible =
357 !settings.update_exclusive && settings.copy_settings.delete.is_none();
358 match Dir::open_parent_dir(&update_parent_path, congestion::Side::Source).await {
359 Ok(dir) => Some((Arc::new(dir.into_tree()), update_name)),
361 Err(err)
362 if fallback_eligible
363 && (matches!(
364 err.kind(),
365 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
366 ) || err.raw_os_error() == Some(libc::ENOTDIR)) =>
367 {
368 tracing::debug!(
372 "update parent {:?} not found ({:#}); falling back to no-update mode",
373 update_parent_path,
374 err
375 );
376 None
377 }
378 Err(err) => {
379 return Err(Error::new(
380 anyhow::Error::new(err).context(format!(
381 "cannot open update parent directory {:?}",
382 update_parent_path
383 )),
384 Default::default(),
385 ));
386 }
387 }
388 }
389 None => None,
390 };
391 let update_ref = update_parent
392 .as_ref()
393 .map(|(dir, name)| (dir, name.as_os_str()));
394 link_internal(
395 prog_track,
396 &src_parent,
397 update_ref,
398 dst_parent.as_ref(),
399 src_name,
400 src,
401 dst,
402 update.as_deref(),
403 std::path::Path::new(""),
404 settings,
405 is_fresh,
406 None,
407 )
408 .await
409}
410struct DeleteKeepSet {
418 inner: Option<std::collections::HashSet<std::ffi::OsString>>,
419 src_records_disabled: bool,
422}
423
424impl DeleteKeepSet {
425 fn new(
426 delete: Option<©::DeleteSettings>,
427 update_exclusive: bool,
428 update_present: bool,
429 ) -> Self {
430 Self {
431 inner: delete.is_some().then(std::collections::HashSet::new),
432 src_records_disabled: update_exclusive && update_present,
433 }
434 }
435 fn record_src(&mut self, name: &std::ffi::OsStr) {
438 if let Some(set) = &mut self.inner
439 && !self.src_records_disabled
440 {
441 set.insert(name.to_owned());
442 }
443 }
444 fn record_update(&mut self, name: &std::ffi::OsStr) {
446 if let Some(set) = &mut self.inner {
447 set.insert(name.to_owned());
448 }
449 }
450 fn as_set(&self) -> Option<&std::collections::HashSet<std::ffi::OsString>> {
453 self.inner.as_ref()
454 }
455}
456
457#[instrument(skip(prog_track, src_parent, update, dst_parent, settings, permit))]
481#[async_recursion]
482#[allow(clippy::too_many_arguments)]
483async fn link_internal(
484 prog_track: &'static progress::Progress,
485 src_parent: &Arc<Dir>,
486 update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
487 dst_parent: Option<&Arc<Dir>>,
488 name: &std::ffi::OsStr,
489 src_root: &std::path::Path,
490 dst_root: &std::path::Path,
491 update_root: Option<&std::path::Path>,
492 rel_path: &std::path::Path,
493 settings: &Settings,
494 is_fresh: bool,
495 permit: Option<LeafPermit>,
496) -> Result<Summary, Error> {
497 let _prog_guard = prog_track.ops.guard();
498 let (src_path, dst_path) = if rel_path.as_os_str().is_empty() {
503 (src_root.to_path_buf(), dst_root.to_path_buf())
504 } else {
505 (src_root.join(rel_path), dst_root.join(rel_path))
506 };
507 let update_path = update_root.map(|root| {
508 if rel_path.as_os_str().is_empty() {
509 root.to_path_buf()
510 } else {
511 root.join(rel_path)
512 }
513 });
514 let dst_name = dst_path
518 .file_name()
519 .ok_or_else(|| {
520 Error::new(
521 anyhow!("link destination {:?} has no file name", &dst_path),
522 Default::default(),
523 )
524 })?
525 .to_owned();
526 tracing::debug!("classifying source entry");
527 let src_handle = src_parent
528 .child(name)
529 .await
530 .with_context(|| format!("failed reading metadata from {:?}", &src_path))
531 .map_err(|err| Error::new(err, Default::default()))?;
532 let mut update_handle = match update {
536 Some((update_dir, update_name)) => {
537 tracing::debug!("classifying 'update' entry");
538 match update_dir.child(update_name).await {
539 Ok(handle) => Some(handle),
540 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
541 if settings.update_exclusive {
542 return Ok(Default::default());
544 }
545 None
546 }
547 Err(error) => {
548 return Err(Error::new(
549 anyhow::Error::new(error)
550 .context(format!("failed reading metadata from {:?}", &update_path)),
551 Default::default(),
552 ));
553 }
554 }
555 }
556 None => None,
557 };
558 if let Some(handle) = update_handle.as_ref() {
569 let update_is_dir = handle.kind() == EntryKind::Dir;
570 let update_excluded = match settings.filter.as_ref() {
578 Some(filter) if rel_path.as_os_str().is_empty() => {
579 let (_, update_name) =
580 update.expect("update_handle is Some only when update is Some");
581 !matches!(
582 filter.should_include_root_item(
583 std::path::Path::new(update_name),
584 update_is_dir,
585 ),
586 crate::filter::FilterResult::Included
587 )
588 }
589 _ => walk::should_skip_entry(&settings.filter, rel_path, update_is_dir).is_some(),
590 };
591 if update_excluded {
592 if settings.update_exclusive {
593 tracing::debug!(
599 "update entry {:?} is filtered out under --update-exclusive; materializing nothing",
600 update_path
601 );
602 return Ok(Default::default());
603 }
604 tracing::debug!(
610 "update entry {:?} is filtered out; falling back to source-only handling",
611 update_path
612 );
613 update_handle = None;
614 }
615 }
616 let is_file_changed_copy = match update_handle.as_ref() {
635 Some(update_handle) => {
636 update_handle.kind() == src_handle.kind()
637 && update_handle.kind() == EntryKind::File
638 && !filecmp::metadata_equal(
639 &settings.update_compare,
640 src_handle.meta(),
641 update_handle.meta(),
642 )
643 }
644 None => false,
645 };
646 let copy_guard: Option<throttle::OpenFileGuard> = if is_file_changed_copy {
647 match permit {
649 Some(LeafPermit::OpenFile(guard)) => Some(guard),
650 _ => None,
653 }
654 } else {
655 drop(permit);
657 None
658 };
659 if let Some(update_handle) = update_handle.as_ref() {
660 let (update_dir, update_name) = update.unwrap();
661 let update_path = update_path.as_deref().unwrap();
662 if update_handle.kind() != src_handle.kind() {
663 tracing::debug!(
665 "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
666 src_path,
667 src_handle.kind(),
668 update_path,
669 update_handle.kind()
670 );
671 return delegate_copy(
677 prog_track,
678 update_dir,
679 dst_parent,
680 update_name,
681 update_path,
682 &dst_path,
683 rel_path,
684 settings,
685 is_fresh,
686 None,
687 )
688 .await;
689 }
690 if update_handle.kind() == EntryKind::File {
691 if filecmp::metadata_equal(
693 &settings.update_compare,
694 src_handle.meta(),
695 update_handle.meta(),
696 ) {
697 tracing::debug!("no change, hard link 'src'");
700 if settings.dry_run.is_some() {
701 crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
702 return Ok(Summary {
703 hard_links_created: 1,
704 ..Default::default()
705 });
706 }
707 let dst_dir =
708 dst_parent.expect("destination parent must be open for a real hard link");
709 return hard_link_entry_fd(
710 prog_track,
711 &src_handle,
712 dst_dir,
713 &dst_name,
714 &dst_path,
715 settings,
716 )
717 .await;
718 }
719 tracing::debug!(
720 "link: {:?} metadata has changed, copying from {:?}",
721 src_path,
722 update_path
723 );
724 return delegate_copy(
729 prog_track,
730 update_dir,
731 dst_parent,
732 update_name,
733 update_path,
734 &dst_path,
735 rel_path,
736 settings,
737 is_fresh,
738 copy_guard,
739 )
740 .await;
741 }
742 if update_handle.kind() == EntryKind::Symlink {
743 tracing::debug!("'update' is a symlink so just symlink that");
745 return delegate_copy(
746 prog_track,
747 update_dir,
748 dst_parent,
749 update_name,
750 update_path,
751 &dst_path,
752 rel_path,
753 settings,
754 is_fresh,
755 None,
756 )
757 .await;
758 }
759 } else {
760 tracing::debug!("no 'update' entry");
765 if src_handle.kind() == EntryKind::File {
766 if settings.dry_run.is_some() {
767 crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
768 return Ok(Summary {
769 hard_links_created: 1,
770 ..Default::default()
771 });
772 }
773 let dst_dir = dst_parent.expect("destination parent must be open for a real hard link");
774 return hard_link_entry_fd(
775 prog_track,
776 &src_handle,
777 dst_dir,
778 &dst_name,
779 &dst_path,
780 settings,
781 )
782 .await;
783 }
784 if src_handle.kind() == EntryKind::Symlink {
785 tracing::debug!("'src' is a symlink so just symlink that");
786 return delegate_copy(
787 prog_track, src_parent, dst_parent, name, &src_path, &dst_path, rel_path, settings,
788 is_fresh, None,
789 )
790 .await;
791 }
792 }
793 if src_handle.kind() != EntryKind::Dir {
794 if settings.copy_settings.skip_specials {
796 tracing::debug!(
797 "skipping special file {:?} (kind: {:?})",
798 src_path,
799 src_handle.kind()
800 );
801 if let Some(mode) = settings.dry_run {
802 match mode {
803 crate::config::DryRunMode::Brief => {}
804 crate::config::DryRunMode::All => println!("skip special {:?}", src_path),
805 crate::config::DryRunMode::Explain => {
806 println!(
807 "skip special {:?} (unsupported file type: {:?})",
808 src_path,
809 src_handle.kind()
810 );
811 }
812 }
813 }
814 prog_track.specials_skipped.inc();
815 return Ok(Summary {
816 copy_summary: CopySummary {
817 specials_skipped: 1,
818 ..Default::default()
819 },
820 ..Default::default()
821 });
822 }
823 return Err(Error::new(
824 anyhow!(
825 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
826 src_path,
827 dst_path,
828 src_handle.kind()
829 ),
830 Default::default(),
831 ));
832 }
833 debug_assert!(
836 update_handle.is_none() || update_handle.as_ref().unwrap().kind() == EntryKind::Dir
837 );
838 let update_for_dir = update.filter(|_| update_handle.is_some());
844 let update_root_for_dir = update_root.filter(|_| update_handle.is_some());
845 link_dir_entry(
846 prog_track,
847 src_parent,
848 update_for_dir,
849 dst_parent,
850 name,
851 &dst_name,
852 src_root,
853 dst_root,
854 update_root_for_dir,
855 rel_path,
856 &src_path,
857 &dst_path,
858 update_path.as_deref().filter(|_| update_handle.is_some()),
859 settings,
860 is_fresh,
861 )
862 .await
863}
864
865#[allow(clippy::too_many_arguments)]
871async fn delegate_copy(
872 prog_track: &'static progress::Progress,
873 src_parent: &Arc<Dir>,
874 dst_parent: Option<&Arc<Dir>>,
875 name: &std::ffi::OsStr,
876 src_path: &std::path::Path,
877 dst_path: &std::path::Path,
878 filter_base: &std::path::Path,
879 settings: &Settings,
880 is_fresh: bool,
881 open_file_guard: Option<throttle::OpenFileGuard>,
882) -> Result<Summary, Error> {
883 let copy_summary = copy::copy_child(
884 prog_track,
885 src_parent,
886 dst_parent,
887 name,
888 src_path,
889 dst_path,
890 filter_base,
891 &settings.copy_settings,
892 &settings.preserve,
893 is_fresh,
894 open_file_guard,
895 )
896 .await
897 .map_err(|err| {
898 let copy_summary = err.summary;
899 Error::new(
900 err.source,
901 Summary {
902 copy_summary,
903 ..Default::default()
904 },
905 )
906 })?;
907 Ok(Summary {
908 copy_summary,
909 ..Default::default()
910 })
911}
912
913#[allow(clippy::too_many_arguments)]
917async fn link_dir_entry(
918 prog_track: &'static progress::Progress,
919 src_parent: &Arc<Dir>,
920 update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
921 dst_parent: Option<&Arc<Dir>>,
922 name: &std::ffi::OsStr,
923 dst_name: &std::ffi::OsStr,
924 src_root: &std::path::Path,
925 dst_root: &std::path::Path,
926 update_root: Option<&std::path::Path>,
927 rel_path: &std::path::Path,
928 src_path: &std::path::Path,
929 dst_path: &std::path::Path,
930 update_path: Option<&std::path::Path>,
931 settings: &Settings,
932 is_fresh: bool,
933) -> Result<Summary, Error> {
934 let src_dir = src_parent
935 .open_dir(name)
936 .await
937 .with_context(|| format!("cannot open directory {:?} for reading", src_path))
938 .map_err(|err| Error::new(err, Default::default()))?;
939 let src_dir = Arc::new(src_dir);
940 let update_dir = match update {
942 Some((update_parent, update_name)) => {
943 let dir = update_parent
944 .open_dir(update_name)
945 .await
946 .with_context(|| {
947 format!("cannot open update directory {:?} for reading", update_path)
948 })
949 .map_err(|err| Error::new(err, Default::default()))?;
950 Some(Arc::new(dir))
951 }
952 None => None,
953 };
954 if settings.dry_run.is_some() {
956 crate::dry_run::report_action("link", src_path, Some(dst_path), "dir");
957 let base = Summary {
958 copy_summary: CopySummary {
959 directories_created: 1, ..Default::default()
961 },
962 ..Default::default()
963 };
964 return link_dir_contents(
965 prog_track,
966 &src_dir,
967 update_dir.as_ref(),
968 None, None, dst_name,
971 src_root,
972 dst_root,
973 update_root,
974 rel_path,
975 src_path,
976 dst_path,
977 true, is_fresh,
979 settings,
980 base,
981 )
982 .await;
983 }
984 let dst_parent = dst_parent.expect("destination parent must be open for a real link");
986 let copy::DirSlot {
987 dir: dst_dir,
988 summary: base,
989 is_fresh: child_is_fresh,
990 we_created,
991 } = match copy::resolve_dst_dir(
992 prog_track,
993 dst_parent,
994 dst_name,
995 dst_path,
996 &settings.copy_settings,
997 is_fresh,
998 )
999 .await
1000 .map_err(|err| {
1001 Error::new(
1002 err.source,
1003 Summary {
1004 copy_summary: err.summary,
1005 ..Default::default()
1006 },
1007 )
1008 })? {
1009 copy::DirResolution::Skip(summary) => {
1010 return Ok(Summary {
1011 copy_summary: summary,
1012 ..Default::default()
1013 });
1014 }
1015 copy::DirResolution::Proceed(slot) => slot,
1016 };
1017 link_dir_contents(
1018 prog_track,
1019 &src_dir,
1020 update_dir.as_ref(),
1021 Some(&dst_dir),
1022 Some(dst_parent),
1023 dst_name,
1024 src_root,
1025 dst_root,
1026 update_root,
1027 rel_path,
1028 src_path,
1029 dst_path,
1030 we_created,
1031 child_is_fresh,
1032 settings,
1033 Summary {
1034 copy_summary: base,
1035 ..Default::default()
1036 },
1037 )
1038 .await
1039}
1040
1041#[allow(clippy::too_many_arguments)]
1049async fn link_dir_contents(
1050 prog_track: &'static progress::Progress,
1051 src_dir: &Arc<Dir>,
1052 update_dir: Option<&Arc<Dir>>,
1053 dst_dir: Option<&Arc<Dir>>,
1054 dst_parent: Option<&Arc<Dir>>,
1055 dst_name: &std::ffi::OsStr,
1056 src_root: &std::path::Path,
1057 dst_root: &std::path::Path,
1058 update_root: Option<&std::path::Path>,
1059 rel_path: &std::path::Path,
1060 src_path: &std::path::Path,
1061 dst_path: &std::path::Path,
1062 we_created_this_dir: bool,
1063 is_fresh: bool,
1064 settings: &Settings,
1065 base: Summary,
1066) -> Result<Summary, Error> {
1067 tracing::debug!("process contents of 'src' directory");
1068 let src_entries = src_dir
1069 .read_entries()
1070 .await
1071 .with_context(|| format!("cannot open directory {src_path:?} for reading"))
1072 .map_err(|err| Error::new(err, base))?;
1073 let mut link_summary = base;
1074 let mut join_set = tokio::task::JoinSet::new();
1075 let errors = crate::error_collector::ErrorCollector::default();
1076 let mut processed_files = std::collections::HashSet::new();
1078 let mut keep_set = DeleteKeepSet::new(
1083 settings.copy_settings.delete.as_ref(),
1084 settings.update_exclusive,
1085 update_dir.is_some(),
1086 );
1087 for (entry_name, hint) in src_entries {
1089 let entry_kind = hint.unwrap_or(EntryKind::File);
1094 let entry_is_symlink = entry_kind == EntryKind::Symlink;
1095 let entry_rel = rel_path.join(&entry_name);
1096 let entry_path = src_path.join(&entry_name);
1097 let entry_is_dir = walk::filter_is_dir(
1105 settings.filter.as_ref(),
1106 src_dir,
1107 &entry_name,
1108 hint,
1109 settings.dry_run.is_some(),
1110 )
1111 .await;
1112 if let Some(skip_result) =
1114 walk::should_skip_entry(&settings.filter, &entry_rel, entry_is_dir)
1115 {
1116 if let Some(mode) = settings.dry_run {
1117 crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
1118 }
1119 tracing::debug!("skipping {:?} due to filter", &entry_path);
1120 link_summary = link_summary + skipped_summary_for(entry_kind);
1121 entry_kind.inc_skipped(prog_track);
1122 continue;
1123 }
1124 keep_set.record_src(&entry_name);
1127 if settings.copy_settings.skip_specials && entry_kind == EntryKind::Special {
1129 tracing::debug!("skipping special file {:?}", &entry_path);
1130 if let Some(mode) = settings.dry_run {
1131 match mode {
1132 crate::config::DryRunMode::Brief => {}
1133 crate::config::DryRunMode::All => {
1134 println!("skip special {:?}", &entry_path)
1135 }
1136 crate::config::DryRunMode::Explain => {
1137 println!(
1138 "skip special {:?} (unsupported file type: {:?})",
1139 &entry_path, entry_kind
1140 );
1141 }
1142 }
1143 }
1144 link_summary.copy_summary.specials_skipped += 1;
1145 prog_track.specials_skipped.inc();
1146 continue;
1147 }
1148 processed_files.insert(entry_name.clone());
1149 if settings.dry_run.is_some() && !entry_is_dir {
1151 let dst_entry_path = dst_path.join(&entry_name);
1152 crate::dry_run::report_action(
1153 "link",
1154 &entry_path,
1155 Some(&dst_entry_path),
1156 entry_kind.label(),
1157 );
1158 if entry_is_symlink {
1159 link_summary.copy_summary.symlinks_created += 1;
1161 } else {
1162 link_summary.hard_links_created += 1;
1167 }
1168 continue;
1169 }
1170 let permit = walk::preacquire_leaf_permit(PermitKind::OpenFile, hint, |h| {
1184 h == Some(EntryKind::File)
1185 })
1186 .await;
1187 let src_parent = Arc::clone(src_dir);
1188 let dst_parent = dst_dir.map(Arc::clone);
1189 let update_parent = update_dir.map(Arc::clone);
1190 let settings = settings.clone();
1191 let src_root = src_root.to_owned();
1192 let dst_root = dst_root.to_owned();
1193 let update_root = update_root.map(std::path::Path::to_path_buf);
1194 let do_link = move || async move {
1195 let update_ref = update_parent
1196 .as_ref()
1197 .map(|dir| (dir, entry_name.as_os_str()));
1198 link_internal(
1199 prog_track,
1200 &src_parent,
1201 update_ref,
1202 dst_parent.as_ref(),
1203 &entry_name,
1204 &src_root,
1205 &dst_root,
1206 update_root.as_deref(),
1207 &entry_rel,
1208 &settings,
1209 is_fresh,
1210 permit,
1211 )
1212 .await
1213 };
1214 join_set.spawn(do_link());
1215 }
1216 if let Some(update_dir) = update_dir {
1218 let update_root = update_root.expect("update_dir present implies update_root present");
1219 tracing::debug!("process contents of 'update' directory");
1220 let update_entries = update_dir
1221 .read_entries()
1222 .await
1223 .with_context(|| {
1224 format!(
1225 "cannot open directory {:?} for reading",
1226 update_path_dbg(update_root, rel_path)
1227 )
1228 })
1229 .map_err(|err| Error::new(err, link_summary))?;
1230 for (entry_name, hint) in update_entries {
1244 let entry_kind = hint.unwrap_or(EntryKind::File);
1245 let entry_rel = rel_path.join(&entry_name);
1246 let entry_is_dir = walk::filter_is_dir(
1251 settings.filter.as_ref(),
1252 update_dir,
1253 &entry_name,
1254 hint,
1255 false,
1258 )
1259 .await;
1260 let skip_result = walk::should_skip_entry(&settings.filter, &entry_rel, entry_is_dir);
1265 let filtered_out = skip_result.is_some();
1266 if settings.copy_settings.delete.is_some() && !filtered_out {
1279 keep_set.record_update(&entry_name);
1280 }
1281 if processed_files.contains(&entry_name) {
1282 continue;
1284 }
1285 if let Some(skip_result) = skip_result {
1289 let update_entry_path = update_root.join(&entry_rel);
1290 if let Some(mode) = settings.dry_run {
1291 crate::dry_run::report_skip(
1292 &update_entry_path,
1293 &skip_result,
1294 mode,
1295 entry_kind.label(),
1296 );
1297 }
1298 tracing::debug!(
1299 "skipping update entry {:?} due to filter",
1300 &update_entry_path
1301 );
1302 link_summary = link_summary + skipped_summary_for(entry_kind);
1303 entry_kind.inc_skipped(prog_track);
1304 continue;
1305 }
1306 tracing::debug!("found a new entry in the 'update' directory");
1307 let update_entry_path = update_root.join(&entry_rel);
1308 let dst_entry_path = dst_path.join(&entry_name);
1309 let update_parent = Arc::clone(update_dir);
1310 let dst_parent = dst_dir.map(Arc::clone);
1311 let settings = settings.clone();
1312 let do_copy = move || async move {
1313 delegate_copy(
1317 prog_track,
1318 &update_parent,
1319 dst_parent.as_ref(),
1320 &entry_name,
1321 &update_entry_path,
1322 &dst_entry_path,
1323 &entry_rel,
1324 &settings,
1325 is_fresh,
1326 None,
1327 )
1328 .await
1329 };
1330 join_set.spawn(do_copy());
1331 }
1332 }
1333 while let Some(res) = join_set.join_next().await {
1334 match res {
1335 Ok(result) => match result {
1336 Ok(summary) => link_summary = link_summary + summary,
1337 Err(error) => {
1338 tracing::error!(
1339 "link: {:?} -> {:?} failed with: {:#}",
1340 src_path,
1341 dst_path,
1342 &error
1343 );
1344 link_summary = link_summary + error.summary;
1345 if settings.copy_settings.fail_early {
1346 return Err(Error::new(error.source, link_summary));
1347 }
1348 errors.push(error.source);
1349 }
1350 },
1351 Err(error) => {
1352 if settings.copy_settings.fail_early {
1353 return Err(Error::new(error.into(), link_summary));
1354 }
1355 errors.push(error.into());
1356 }
1357 }
1358 }
1359 if let Some(delete_settings) = &settings.copy_settings.delete {
1364 if errors.has_errors() {
1365 tracing::warn!(
1368 "skipping --delete pruning of {:?} because the link/update pass reported errors",
1369 dst_path
1370 );
1371 } else {
1372 let prune_dir: Option<Arc<Dir>> = match dst_dir {
1378 Some(dir) => Some(Arc::clone(dir)),
1379 None => {
1380 match Dir::open_root_dir(dst_path, false, congestion::Side::Destination).await {
1381 Ok(dir) => Some(Arc::new(dir)),
1382 Err(err)
1383 if matches!(
1384 err.kind(),
1385 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1386 ) || err.raw_os_error() == Some(libc::ELOOP)
1387 || err.raw_os_error() == Some(libc::ENOTDIR) =>
1388 {
1389 tracing::debug!(
1390 "skipping --delete pruning of {:?}: not a real directory",
1391 dst_path
1392 );
1393 None
1394 }
1395 Err(err) => {
1396 let err = anyhow::Error::new(err).context(format!(
1397 "cannot open destination {dst_path:?} for delete scan"
1398 ));
1399 if settings.copy_settings.fail_early {
1400 return Err(Error::new(err, link_summary));
1401 }
1402 errors.push(err);
1403 None
1404 }
1405 }
1406 }
1407 };
1408 if let Some(prune_dir) = prune_dir {
1409 match crate::delete::prune_extraneous(
1410 prog_track,
1411 &prune_dir,
1412 rel_path,
1413 keep_set
1414 .as_set()
1415 .expect("--delete is on, so DeleteKeepSet is active"),
1416 settings.filter.as_ref(),
1417 delete_settings,
1418 settings.copy_settings.fail_early,
1419 settings.dry_run,
1420 )
1421 .await
1422 {
1423 Ok(rm_summary) => {
1424 link_summary.copy_summary.rm_summary =
1425 link_summary.copy_summary.rm_summary + rm_summary;
1426 }
1427 Err(err) => {
1428 link_summary.copy_summary.rm_summary =
1429 link_summary.copy_summary.rm_summary + err.summary;
1430 if settings.copy_settings.fail_early {
1431 return Err(Error::new(err.source, link_summary));
1432 }
1433 errors.push(err.source);
1434 }
1435 }
1436 }
1437 }
1438 }
1439 let this_dir_count = usize::from(we_created_this_dir);
1442 let child_dirs_created = link_summary
1443 .copy_summary
1444 .directories_created
1445 .saturating_sub(this_dir_count);
1446 let anything_linked = link_summary.hard_links_created > 0
1447 || link_summary.copy_summary.files_copied > 0
1448 || link_summary.copy_summary.symlinks_created > 0
1449 || child_dirs_created > 0;
1450 let is_root = rel_path.as_os_str().is_empty();
1451 match check_empty_dir_cleanup(
1452 settings.filter.as_ref(),
1453 we_created_this_dir,
1454 anything_linked,
1455 rel_path,
1456 is_root,
1457 settings.dry_run.is_some(),
1458 ) {
1459 EmptyDirAction::Keep => { }
1460 EmptyDirAction::DryRunSkip => {
1461 tracing::debug!(
1462 "dry-run: directory {:?} would not be created (nothing to link inside)",
1463 dst_path
1464 );
1465 link_summary.copy_summary.directories_created = 0;
1466 if errors.has_errors() {
1470 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1471 }
1472 return Ok(link_summary);
1473 }
1474 EmptyDirAction::Remove => {
1475 tracing::debug!(
1476 "directory {:?} has nothing to link inside, removing empty directory",
1477 dst_path
1478 );
1479 let rmdir_result = match dst_parent {
1484 Some(dst_parent) => dst_parent.rmdir_at(dst_name).await,
1485 None => {
1486 crate::walk::run_metadata_probed(
1487 congestion::Side::Destination,
1488 congestion::MetadataOp::RmDir,
1489 tokio::fs::remove_dir(dst_path),
1490 )
1491 .await
1492 }
1493 };
1494 match rmdir_result {
1495 Ok(()) => {
1496 link_summary.copy_summary.directories_created = 0;
1497 if errors.has_errors() {
1501 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1502 }
1503 return Ok(link_summary);
1504 }
1505 Err(err) => {
1506 tracing::debug!(
1508 "failed to remove empty directory {:?}: {:#}, keeping",
1509 dst_path,
1510 &err
1511 );
1512 }
1514 }
1515 }
1516 }
1517 tracing::debug!("set 'dst' directory metadata");
1524 let metadata_result = match dst_dir {
1525 Some(dst_dir) => {
1526 let meta_dir = update_dir.unwrap_or(src_dir);
1527 match meta_dir.meta().await {
1528 Ok(preserve_meta) => {
1529 crate::safedir::set_dir_metadata_fd(&settings.preserve, &preserve_meta, dst_dir)
1530 .await
1531 }
1532 Err(e) => Err(e),
1533 }
1534 }
1535 None => Ok(()),
1536 };
1537 if errors.has_errors() {
1538 if let Err(metadata_err) = metadata_result {
1540 tracing::error!(
1541 "link: {:?} -> {:?} failed to set directory metadata: {:#}",
1542 src_path,
1543 dst_path,
1544 &metadata_err
1545 );
1546 }
1547 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1549 }
1550 metadata_result
1552 .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
1553 .map_err(|err| Error::new(err, link_summary))?;
1554 Ok(link_summary)
1555}
1556
1557fn update_path_dbg(
1559 update_root: &std::path::Path,
1560 rel_path: &std::path::Path,
1561) -> std::path::PathBuf {
1562 if rel_path.as_os_str().is_empty() {
1563 update_root.to_path_buf()
1564 } else {
1565 update_root.join(rel_path)
1566 }
1567}
1568
1569#[cfg(test)]
1570mod link_tests {
1571 use crate::rm;
1572 use crate::testutils;
1573 use std::os::unix::fs::PermissionsExt;
1574 use tracing_test::traced_test;
1575
1576 use super::*;
1577
1578 static PROGRESS: std::sync::LazyLock<progress::Progress> =
1579 std::sync::LazyLock::new(progress::Progress::new);
1580
1581 mod delete_keep_set_tests {
1582 use super::super::DeleteKeepSet;
1586 use crate::copy::DeleteSettings;
1587 use std::ffi::{OsStr, OsString};
1588
1589 fn delete_on() -> DeleteSettings {
1590 DeleteSettings {
1591 delete_excluded: false,
1592 }
1593 }
1594
1595 #[test]
1596 fn record_src_no_op_when_delete_off() {
1597 let mut k = DeleteKeepSet::new(None, false, false);
1598 k.record_src(OsStr::new("foo"));
1599 assert!(k.as_set().is_none());
1600 }
1601
1602 #[test]
1603 fn record_src_no_op_under_update_exclusive_with_update() {
1604 let d = delete_on();
1607 let mut k = DeleteKeepSet::new(Some(&d), true, true);
1608 k.record_src(OsStr::new("src_only"));
1609 assert!(!k.as_set().unwrap().contains(OsStr::new("src_only")));
1610 }
1611
1612 #[test]
1613 fn record_src_records_when_update_exclusive_without_update() {
1614 let d = delete_on();
1617 let mut k = DeleteKeepSet::new(Some(&d), true, false);
1618 k.record_src(OsStr::new("foo"));
1619 assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1620 }
1621
1622 #[test]
1623 fn record_src_records_in_normal_delete_mode() {
1624 let d = delete_on();
1625 let mut k = DeleteKeepSet::new(Some(&d), false, false);
1626 k.record_src(OsStr::new("foo"));
1627 assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1628 }
1629
1630 #[test]
1631 fn record_update_always_records_when_delete_on() {
1632 let d = delete_on();
1635 let mut k = DeleteKeepSet::new(Some(&d), true, true);
1636 k.record_update(OsStr::new("from_update"));
1637 assert!(k.as_set().unwrap().contains(OsStr::new("from_update")));
1638 }
1639
1640 #[test]
1641 fn record_update_no_op_when_delete_off() {
1642 let mut k = DeleteKeepSet::new(None, false, false);
1643 k.record_update(OsStr::new("from_update"));
1644 assert!(k.as_set().is_none());
1645 }
1646
1647 #[test]
1648 fn filtered_out_update_keeps_materialized_src_entry_in_normal_mode() {
1649 let d = delete_on();
1656 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1657 k.record_src(OsStr::new("node"));
1658 assert!(
1660 k.as_set().unwrap().contains(OsStr::new("node")),
1661 "src entry must stay in the keep-set when its update counterpart is filtered out"
1662 );
1663 }
1664
1665 #[test]
1666 fn filtered_out_update_keeps_skipped_special() {
1667 let d = delete_on();
1672 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1673 k.record_src(OsStr::new("pipe"));
1674 assert!(k.as_set().unwrap().contains(OsStr::new("pipe")));
1675 }
1676
1677 #[test]
1678 fn full_directory_pass_keep_set_union_semantics() {
1679 let d = delete_on();
1688 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1689
1690 k.record_src(OsStr::new("keep"));
1692 k.record_src(OsStr::new("pipe")); k.record_src(OsStr::new("node"));
1694
1695 k.record_update(OsStr::new("from_upd"));
1698
1699 let set: std::collections::HashSet<OsString> = k.as_set().unwrap().clone();
1700 let expected: std::collections::HashSet<OsString> =
1701 ["keep", "pipe", "node", "from_upd"]
1702 .into_iter()
1703 .map(OsString::from)
1704 .collect();
1705 assert_eq!(set, expected);
1706 }
1707 }
1708
1709 fn common_settings(dereference: bool, overwrite: bool) -> Settings {
1710 Settings {
1711 copy_settings: CopySettings {
1712 dereference,
1713 fail_early: false,
1714 overwrite,
1715 overwrite_compare: filecmp::MetadataCmpSettings {
1716 size: true,
1717 mtime: true,
1718 ..Default::default()
1719 },
1720 overwrite_filter: None,
1721 ignore_existing: false,
1722 chunk_size: 0,
1723 skip_specials: false,
1724 remote_copy_buffer_size: 0,
1725 filter: None,
1726 dry_run: None,
1727 delete: None,
1728 },
1729 update_compare: filecmp::MetadataCmpSettings {
1730 size: true,
1731 mtime: true,
1732 ..Default::default()
1733 },
1734 update_exclusive: false,
1735 filter: None,
1736 dry_run: None,
1737 preserve: preserve::preserve_all(),
1738 }
1739 }
1740
1741 #[tokio::test]
1742 #[traced_test]
1743 async fn test_basic_link() -> Result<(), anyhow::Error> {
1744 let tmp_dir = testutils::setup_test_dir().await?;
1745 let test_path = tmp_dir.as_path();
1746 let summary = link(
1747 &PROGRESS,
1748 test_path,
1749 &test_path.join("foo"),
1750 &test_path.join("bar"),
1751 &None,
1752 &common_settings(false, false),
1753 false,
1754 )
1755 .await?;
1756 assert_eq!(summary.hard_links_created, 5);
1757 assert_eq!(summary.copy_summary.files_copied, 0);
1758 assert_eq!(summary.copy_summary.symlinks_created, 2);
1759 assert_eq!(summary.copy_summary.directories_created, 3);
1760 testutils::check_dirs_identical(
1761 &test_path.join("foo"),
1762 &test_path.join("bar"),
1763 testutils::FileEqualityCheck::Timestamp,
1764 )
1765 .await?;
1766 Ok(())
1767 }
1768
1769 #[tokio::test]
1773 async fn links_dot_dot_source_operand() -> Result<(), anyhow::Error> {
1774 use std::os::unix::fs::MetadataExt;
1775 let tmp = testutils::create_temp_dir().await?;
1776 let tree = tmp.join("tree");
1777 tokio::fs::create_dir(&tree).await?;
1778 tokio::fs::write(tree.join("a.txt"), "hello").await?;
1779 tokio::fs::create_dir(tree.join("sub")).await?;
1780 let src = tree.join("sub").join(".."); let dst = tmp.join("dst");
1782 let summary = link(
1783 &PROGRESS,
1784 &tmp,
1785 &src,
1786 &dst,
1787 &None,
1788 &common_settings(false, false),
1789 false,
1790 )
1791 .await?;
1792 assert_eq!(
1793 summary.hard_links_created, 1,
1794 "the dot-dot source's file must be hard-linked"
1795 );
1796 assert!(
1797 dst.join("sub").is_dir(),
1798 "the dot-dot source's subdir must be created"
1799 );
1800 let src_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
1802 let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
1803 assert_eq!(src_ino, dst_ino, "dst must be a hard link to the src inode");
1804 Ok(())
1805 }
1806
1807 #[tokio::test]
1813 async fn links_dot_dot_update_operand() -> Result<(), anyhow::Error> {
1814 use std::os::unix::fs::MetadataExt;
1815 let tmp = testutils::create_temp_dir().await?;
1816 let tree = tmp.join("tree");
1817 tokio::fs::create_dir(&tree).await?;
1818 tokio::fs::write(tree.join("a.txt"), "hello").await?;
1819 tokio::fs::create_dir(tree.join("sub")).await?;
1820 let dst = tmp.join("dst");
1821 let update_operand = tree.join("sub").join(".."); let summary = link(
1825 &PROGRESS,
1826 &tmp,
1827 &tree,
1828 &dst,
1829 &Some(update_operand),
1830 &common_settings(false, false),
1831 false,
1832 )
1833 .await?;
1834 assert_eq!(
1835 summary.hard_links_created, 1,
1836 "the file must be hard-linked from the dot-dot update tree"
1837 );
1838 let update_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
1840 let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
1841 assert_eq!(
1842 update_ino, dst_ino,
1843 "dst must be hard-linked from the update tree inode"
1844 );
1845 Ok(())
1846 }
1847
1848 #[tokio::test]
1849 #[traced_test]
1850 async fn test_basic_link_update() -> Result<(), anyhow::Error> {
1851 let tmp_dir = testutils::setup_test_dir().await?;
1852 let test_path = tmp_dir.as_path();
1853 let summary = link(
1854 &PROGRESS,
1855 test_path,
1856 &test_path.join("foo"),
1857 &test_path.join("bar"),
1858 &Some(test_path.join("foo")),
1859 &common_settings(false, false),
1860 false,
1861 )
1862 .await?;
1863 assert_eq!(summary.hard_links_created, 5);
1864 assert_eq!(summary.copy_summary.files_copied, 0);
1865 assert_eq!(summary.copy_summary.symlinks_created, 2);
1866 assert_eq!(summary.copy_summary.directories_created, 3);
1867 testutils::check_dirs_identical(
1868 &test_path.join("foo"),
1869 &test_path.join("bar"),
1870 testutils::FileEqualityCheck::Timestamp,
1871 )
1872 .await?;
1873 Ok(())
1874 }
1875
1876 #[tokio::test]
1877 #[traced_test]
1878 async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
1879 let tmp_dir = testutils::setup_test_dir().await?;
1880 tokio::fs::create_dir(tmp_dir.join("baz")).await?;
1881 let test_path = tmp_dir.as_path();
1882 let summary = link(
1883 &PROGRESS,
1884 test_path,
1885 &test_path.join("baz"), &test_path.join("bar"),
1887 &Some(test_path.join("foo")),
1888 &common_settings(false, false),
1889 false,
1890 )
1891 .await?;
1892 assert_eq!(summary.hard_links_created, 0);
1893 assert_eq!(summary.copy_summary.files_copied, 5);
1894 assert_eq!(summary.copy_summary.symlinks_created, 2);
1895 assert_eq!(summary.copy_summary.directories_created, 3);
1896 testutils::check_dirs_identical(
1897 &test_path.join("foo"),
1898 &test_path.join("bar"),
1899 testutils::FileEqualityCheck::Timestamp,
1900 )
1901 .await?;
1902 Ok(())
1903 }
1904
1905 #[tokio::test]
1906 #[traced_test]
1907 async fn test_link_destination_permission_error_includes_root_cause()
1908 -> Result<(), anyhow::Error> {
1909 let tmp_dir = testutils::setup_test_dir().await?;
1910 let test_path = tmp_dir.as_path();
1911 let readonly_parent = test_path.join("readonly_dest");
1912 tokio::fs::create_dir(&readonly_parent).await?;
1913 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
1914 .await?;
1915
1916 let mut settings = common_settings(false, false);
1917 settings.copy_settings.fail_early = true;
1918
1919 let result = link(
1920 &PROGRESS,
1921 test_path,
1922 &test_path.join("foo"),
1923 &readonly_parent.join("bar"),
1924 &None,
1925 &settings,
1926 false,
1927 )
1928 .await;
1929
1930 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
1932 .await?;
1933
1934 assert!(result.is_err(), "link into read-only parent should fail");
1935 let err = result.unwrap_err();
1936 let err_msg = format!("{:#}", err.source);
1937 assert!(
1938 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
1939 "Error message must include permission denied text. Got: {}",
1940 err_msg
1941 );
1942 Ok(())
1943 }
1944
1945 #[tokio::test]
1946 #[traced_test]
1947 async fn hard_link_file_into_readonly_parent_returns_error() -> Result<(), anyhow::Error> {
1948 let tmp_dir = testutils::setup_test_dir().await?;
1951 let src = tmp_dir.join("src.txt");
1952 tokio::fs::write(&src, "content").await?;
1953 let readonly_parent = tmp_dir.join("readonly_parent");
1954 tokio::fs::create_dir(&readonly_parent).await?;
1955 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
1956 .await?;
1957 let dst = readonly_parent.join("dst.txt");
1958 let settings = common_settings(false, false);
1959 let result = link(&PROGRESS, &tmp_dir, &src, &dst, &None, &settings, false).await;
1960 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
1961 .await?;
1962 let err = result.expect_err("link into read-only parent should fail");
1963 assert_eq!(err.summary.hard_links_created, 0);
1964 let err_msg = format!("{:#}", err.source);
1965 assert!(
1966 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
1967 "error should include root cause, got: {err_msg}"
1968 );
1969 Ok(())
1970 }
1971
1972 pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
1973 let foo_path = tmp_dir.join("update");
1979 tokio::fs::create_dir(&foo_path).await.unwrap();
1980 tokio::fs::write(foo_path.join("0.txt"), "0-new")
1981 .await
1982 .unwrap();
1983 let bar_path = foo_path.join("bar");
1984 tokio::fs::create_dir(&bar_path).await.unwrap();
1985 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1986 .await
1987 .unwrap();
1988 tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
1989 .await
1990 .unwrap();
1991 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1992 Ok(())
1993 }
1994
1995 #[tokio::test]
1996 #[traced_test]
1997 async fn test_link_update() -> Result<(), anyhow::Error> {
1998 let tmp_dir = testutils::setup_test_dir().await?;
1999 setup_update_dir(&tmp_dir).await?;
2000 let test_path = tmp_dir.as_path();
2001 let summary = link(
2002 &PROGRESS,
2003 test_path,
2004 &test_path.join("foo"),
2005 &test_path.join("bar"),
2006 &Some(test_path.join("update")),
2007 &common_settings(false, false),
2008 false,
2009 )
2010 .await?;
2011 assert_eq!(summary.hard_links_created, 2);
2012 assert_eq!(summary.copy_summary.files_copied, 2);
2013 assert_eq!(summary.copy_summary.symlinks_created, 3);
2014 assert_eq!(summary.copy_summary.directories_created, 3);
2015 testutils::check_dirs_identical(
2017 &test_path.join("foo").join("baz"),
2018 &test_path.join("bar").join("baz"),
2019 testutils::FileEqualityCheck::HardLink,
2020 )
2021 .await?;
2022 testutils::check_dirs_identical(
2024 &test_path.join("update"),
2025 &test_path.join("bar"),
2026 testutils::FileEqualityCheck::Timestamp,
2027 )
2028 .await?;
2029 Ok(())
2030 }
2031
2032 #[tokio::test]
2033 #[traced_test]
2034 async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
2035 let tmp_dir = testutils::setup_test_dir().await?;
2036 setup_update_dir(&tmp_dir).await?;
2037 let test_path = tmp_dir.as_path();
2038 let mut settings = common_settings(false, false);
2039 settings.update_exclusive = true;
2040 let summary = link(
2041 &PROGRESS,
2042 test_path,
2043 &test_path.join("foo"),
2044 &test_path.join("bar"),
2045 &Some(test_path.join("update")),
2046 &settings,
2047 false,
2048 )
2049 .await?;
2050 assert_eq!(summary.hard_links_created, 0);
2056 assert_eq!(summary.copy_summary.files_copied, 2);
2057 assert_eq!(summary.copy_summary.symlinks_created, 1);
2058 assert_eq!(summary.copy_summary.directories_created, 2);
2059 testutils::check_dirs_identical(
2061 &test_path.join("update"),
2062 &test_path.join("bar"),
2063 testutils::FileEqualityCheck::Timestamp,
2064 )
2065 .await?;
2066 Ok(())
2067 }
2068
2069 async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
2070 let tmp_dir = testutils::setup_test_dir().await?;
2071 let test_path = tmp_dir.as_path();
2072 let summary = link(
2073 &PROGRESS,
2074 test_path,
2075 &test_path.join("foo"),
2076 &test_path.join("bar"),
2077 &None,
2078 &common_settings(false, false),
2079 false,
2080 )
2081 .await?;
2082 assert_eq!(summary.hard_links_created, 5);
2083 assert_eq!(summary.copy_summary.symlinks_created, 2);
2084 assert_eq!(summary.copy_summary.directories_created, 3);
2085 Ok(tmp_dir)
2086 }
2087
2088 #[tokio::test]
2089 #[traced_test]
2090 async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
2091 let tmp_dir = setup_test_dir_and_link().await?;
2092 let output_path = &tmp_dir.join("bar");
2093 {
2094 let summary = rm::rm(
2105 &PROGRESS,
2106 &output_path.join("bar"),
2107 &rm::Settings {
2108 fail_early: false,
2109 filter: None,
2110 dry_run: None,
2111 time_filter: None,
2112 },
2113 )
2114 .await?
2115 + rm::rm(
2116 &PROGRESS,
2117 &output_path.join("baz").join("5.txt"),
2118 &rm::Settings {
2119 fail_early: false,
2120 filter: None,
2121 dry_run: None,
2122 time_filter: None,
2123 },
2124 )
2125 .await?;
2126 assert_eq!(summary.files_removed, 3);
2127 assert_eq!(summary.symlinks_removed, 1);
2128 assert_eq!(summary.directories_removed, 1);
2129 }
2130 let summary = link(
2131 &PROGRESS,
2132 &tmp_dir,
2133 &tmp_dir.join("foo"),
2134 output_path,
2135 &None,
2136 &common_settings(false, true), false,
2138 )
2139 .await?;
2140 assert_eq!(summary.hard_links_created, 3);
2141 assert_eq!(summary.copy_summary.symlinks_created, 1);
2142 assert_eq!(summary.copy_summary.directories_created, 1);
2143 testutils::check_dirs_identical(
2144 &tmp_dir.join("foo"),
2145 output_path,
2146 testutils::FileEqualityCheck::Timestamp,
2147 )
2148 .await?;
2149 Ok(())
2150 }
2151
2152 #[tokio::test]
2153 #[traced_test]
2154 async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
2155 let tmp_dir = setup_test_dir_and_link().await?;
2156 let output_path = &tmp_dir.join("bar");
2157 {
2158 let summary = rm::rm(
2169 &PROGRESS,
2170 &output_path.join("bar"),
2171 &rm::Settings {
2172 fail_early: false,
2173 filter: None,
2174 dry_run: None,
2175 time_filter: None,
2176 },
2177 )
2178 .await?
2179 + rm::rm(
2180 &PROGRESS,
2181 &output_path.join("baz").join("5.txt"),
2182 &rm::Settings {
2183 fail_early: false,
2184 filter: None,
2185 dry_run: None,
2186 time_filter: None,
2187 },
2188 )
2189 .await?;
2190 assert_eq!(summary.files_removed, 3);
2191 assert_eq!(summary.symlinks_removed, 1);
2192 assert_eq!(summary.directories_removed, 1);
2193 }
2194 setup_update_dir(&tmp_dir).await?;
2195 let summary = link(
2201 &PROGRESS,
2202 &tmp_dir,
2203 &tmp_dir.join("foo"),
2204 output_path,
2205 &Some(tmp_dir.join("update")),
2206 &common_settings(false, true), false,
2208 )
2209 .await?;
2210 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);
2214 testutils::check_dirs_identical(
2216 &tmp_dir.join("foo").join("baz"),
2217 &tmp_dir.join("bar").join("baz"),
2218 testutils::FileEqualityCheck::HardLink,
2219 )
2220 .await?;
2221 testutils::check_dirs_identical(
2223 &tmp_dir.join("update"),
2224 &tmp_dir.join("bar"),
2225 testutils::FileEqualityCheck::Timestamp,
2226 )
2227 .await?;
2228 Ok(())
2229 }
2230
2231 #[tokio::test]
2232 #[traced_test]
2233 async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
2234 let tmp_dir = setup_test_dir_and_link().await?;
2235 let output_path = &tmp_dir.join("bar");
2236 {
2237 let bar_path = output_path.join("bar");
2246 let summary = rm::rm(
2247 &PROGRESS,
2248 &bar_path.join("1.txt"),
2249 &rm::Settings {
2250 fail_early: false,
2251 filter: None,
2252 dry_run: None,
2253 time_filter: None,
2254 },
2255 )
2256 .await?
2257 + rm::rm(
2258 &PROGRESS,
2259 &bar_path.join("2.txt"),
2260 &rm::Settings {
2261 fail_early: false,
2262 filter: None,
2263 dry_run: None,
2264 time_filter: None,
2265 },
2266 )
2267 .await?
2268 + rm::rm(
2269 &PROGRESS,
2270 &bar_path.join("3.txt"),
2271 &rm::Settings {
2272 fail_early: false,
2273 filter: None,
2274 dry_run: None,
2275 time_filter: None,
2276 },
2277 )
2278 .await?
2279 + rm::rm(
2280 &PROGRESS,
2281 &output_path.join("baz"),
2282 &rm::Settings {
2283 fail_early: false,
2284 filter: None,
2285 dry_run: None,
2286 time_filter: None,
2287 },
2288 )
2289 .await?;
2290 assert_eq!(summary.files_removed, 4);
2291 assert_eq!(summary.symlinks_removed, 2);
2292 assert_eq!(summary.directories_removed, 1);
2293 tokio::fs::write(bar_path.join("1.txt"), "1-new")
2295 .await
2296 .unwrap();
2297 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
2298 .await
2299 .unwrap();
2300 tokio::fs::create_dir(&bar_path.join("3.txt"))
2301 .await
2302 .unwrap();
2303 tokio::fs::write(&output_path.join("baz"), "baz")
2304 .await
2305 .unwrap();
2306 }
2307 let summary = link(
2308 &PROGRESS,
2309 &tmp_dir,
2310 &tmp_dir.join("foo"),
2311 output_path,
2312 &None,
2313 &common_settings(false, true), false,
2315 )
2316 .await?;
2317 assert_eq!(summary.hard_links_created, 4);
2318 assert_eq!(summary.copy_summary.files_copied, 0);
2319 assert_eq!(summary.copy_summary.symlinks_created, 2);
2320 assert_eq!(summary.copy_summary.directories_created, 1);
2321 testutils::check_dirs_identical(
2322 &tmp_dir.join("foo"),
2323 &tmp_dir.join("bar"),
2324 testutils::FileEqualityCheck::HardLink,
2325 )
2326 .await?;
2327 Ok(())
2328 }
2329
2330 #[tokio::test]
2331 #[traced_test]
2332 async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
2333 let tmp_dir = setup_test_dir_and_link().await?;
2334 let output_path = &tmp_dir.join("bar");
2335 {
2336 let bar_path = output_path.join("bar");
2345 let summary = rm::rm(
2346 &PROGRESS,
2347 &bar_path.join("1.txt"),
2348 &rm::Settings {
2349 fail_early: false,
2350 filter: None,
2351 dry_run: None,
2352 time_filter: None,
2353 },
2354 )
2355 .await?
2356 + rm::rm(
2357 &PROGRESS,
2358 &bar_path.join("2.txt"),
2359 &rm::Settings {
2360 fail_early: false,
2361 filter: None,
2362 dry_run: None,
2363 time_filter: None,
2364 },
2365 )
2366 .await?
2367 + rm::rm(
2368 &PROGRESS,
2369 &bar_path.join("3.txt"),
2370 &rm::Settings {
2371 fail_early: false,
2372 filter: None,
2373 dry_run: None,
2374 time_filter: None,
2375 },
2376 )
2377 .await?
2378 + rm::rm(
2379 &PROGRESS,
2380 &output_path.join("baz"),
2381 &rm::Settings {
2382 fail_early: false,
2383 filter: None,
2384 dry_run: None,
2385 time_filter: None,
2386 },
2387 )
2388 .await?;
2389 assert_eq!(summary.files_removed, 4);
2390 assert_eq!(summary.symlinks_removed, 2);
2391 assert_eq!(summary.directories_removed, 1);
2392 tokio::fs::write(bar_path.join("1.txt"), "1-new")
2394 .await
2395 .unwrap();
2396 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
2397 .await
2398 .unwrap();
2399 tokio::fs::create_dir(&bar_path.join("3.txt"))
2400 .await
2401 .unwrap();
2402 tokio::fs::write(&output_path.join("baz"), "baz")
2403 .await
2404 .unwrap();
2405 }
2406 let source_path = &tmp_dir.join("foo");
2407 tokio::fs::set_permissions(
2409 &source_path.join("baz"),
2410 std::fs::Permissions::from_mode(0o000),
2411 )
2412 .await?;
2413 match link(
2417 &PROGRESS,
2418 &tmp_dir,
2419 &tmp_dir.join("foo"),
2420 output_path,
2421 &None,
2422 &common_settings(false, true), false,
2424 )
2425 .await
2426 {
2427 Ok(_) => panic!("Expected the link to error!"),
2428 Err(error) => {
2429 tracing::info!("{}", &error);
2430 assert_eq!(error.summary.hard_links_created, 3);
2431 assert_eq!(error.summary.copy_summary.files_copied, 0);
2432 assert_eq!(error.summary.copy_summary.symlinks_created, 0);
2433 assert_eq!(error.summary.copy_summary.directories_created, 0);
2434 assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
2435 assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
2436 assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
2437 }
2438 }
2439 Ok(())
2440 }
2441
2442 #[tokio::test]
2446 #[traced_test]
2447 async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
2448 let tmp_dir = testutils::create_temp_dir().await?;
2449 let test_path = tmp_dir.as_path();
2450 let src_dir = test_path.join("src");
2452 tokio::fs::create_dir(&src_dir).await?;
2453 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
2454 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
2456 let unreadable_subdir = src_dir.join("unreadable_subdir");
2459 tokio::fs::create_dir(&unreadable_subdir).await?;
2460 tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
2461 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
2462 .await?;
2463 let dst_dir = test_path.join("dst");
2464 let result = link(
2466 &PROGRESS,
2467 test_path,
2468 &src_dir,
2469 &dst_dir,
2470 &None,
2471 &common_settings(false, false),
2472 false,
2473 )
2474 .await;
2475 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
2477 .await?;
2478 assert!(
2480 result.is_err(),
2481 "link should fail due to unreadable subdirectory"
2482 );
2483 let error = result.unwrap_err();
2484 assert_eq!(error.summary.hard_links_created, 1);
2486 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2488 assert!(dst_metadata.is_dir());
2489 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
2490 assert_eq!(
2491 actual_mode, 0o750,
2492 "directory should have preserved source permissions (0o750), got {:o}",
2493 actual_mode
2494 );
2495 Ok(())
2496 }
2497 mod filter_tests {
2498 use super::*;
2499 use crate::filter::FilterSettings;
2500 #[tokio::test]
2502 #[traced_test]
2503 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
2504 let tmp_dir = testutils::setup_test_dir().await?;
2505 let test_path = tmp_dir.as_path();
2506 let mut filter = FilterSettings::new();
2508 filter.add_include("bar/*.txt").unwrap();
2509 let summary = link(
2510 &PROGRESS,
2511 test_path,
2512 &test_path.join("foo"),
2513 &test_path.join("dst"),
2514 &None,
2515 &Settings {
2516 copy_settings: CopySettings {
2517 dereference: false,
2518 fail_early: false,
2519 overwrite: false,
2520 overwrite_compare: Default::default(),
2521 overwrite_filter: None,
2522 ignore_existing: false,
2523 chunk_size: 0,
2524 skip_specials: false,
2525 remote_copy_buffer_size: 0,
2526 filter: None,
2527 dry_run: None,
2528 delete: None,
2529 },
2530 update_compare: Default::default(),
2531 update_exclusive: false,
2532 filter: Some(filter),
2533 dry_run: None,
2534 preserve: preserve::preserve_all(),
2535 },
2536 false,
2537 )
2538 .await?;
2539 assert_eq!(
2541 summary.hard_links_created, 3,
2542 "should link 3 files matching bar/*.txt"
2543 );
2544 assert!(
2546 test_path.join("dst/bar/1.txt").exists(),
2547 "bar/1.txt should be linked"
2548 );
2549 assert!(
2550 test_path.join("dst/bar/2.txt").exists(),
2551 "bar/2.txt should be linked"
2552 );
2553 assert!(
2554 test_path.join("dst/bar/3.txt").exists(),
2555 "bar/3.txt should be linked"
2556 );
2557 assert!(
2559 !test_path.join("dst/0.txt").exists(),
2560 "0.txt should not be linked"
2561 );
2562 Ok(())
2563 }
2564 #[tokio::test]
2569 #[traced_test]
2570 async fn test_filter_pruned_empty_dir_surfaces_child_error() -> Result<(), anyhow::Error> {
2571 let tmp_dir = testutils::create_temp_dir().await?;
2572 let test_path = tmp_dir.as_path();
2573 let src_dir = test_path.join("src");
2577 let unreadable = src_dir.join("sub").join("unreadable");
2578 tokio::fs::create_dir_all(&unreadable).await?;
2579 tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
2580 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2581 let mut filter = FilterSettings::new();
2584 filter.add_include("*.match").unwrap();
2585 let mut settings = common_settings(false, false);
2586 settings.filter = Some(filter);
2587 let result = link(
2588 &PROGRESS,
2589 test_path,
2590 &src_dir,
2591 &test_path.join("dst"),
2592 &None,
2593 &settings,
2594 false,
2595 )
2596 .await;
2597 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
2599 assert!(
2600 result.is_err(),
2601 "a child link failure inside a filter-pruned empty directory must surface as an \
2602 error, not be masked as success"
2603 );
2604 Ok(())
2605 }
2606 #[tokio::test]
2609 #[traced_test]
2610 async fn test_filter_pruned_empty_dir_surfaces_child_error_dry_run()
2611 -> Result<(), anyhow::Error> {
2612 let tmp_dir = testutils::create_temp_dir().await?;
2613 let test_path = tmp_dir.as_path();
2614 let src_dir = test_path.join("src");
2615 let unreadable = src_dir.join("sub").join("unreadable");
2616 tokio::fs::create_dir_all(&unreadable).await?;
2617 tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
2618 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2619 let mut filter = FilterSettings::new();
2620 filter.add_include("*.match").unwrap();
2621 let mut settings = common_settings(false, false);
2622 settings.filter = Some(filter);
2623 settings.dry_run = Some(crate::config::DryRunMode::Brief);
2624 settings.copy_settings.dry_run = Some(crate::config::DryRunMode::Brief);
2625 let result = link(
2626 &PROGRESS,
2627 test_path,
2628 &src_dir,
2629 &test_path.join("dst"),
2630 &None,
2631 &settings,
2632 false,
2633 )
2634 .await;
2635 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
2636 assert!(
2637 result.is_err(),
2638 "dry-run must also surface the child error, not report a clean run"
2639 );
2640 Ok(())
2641 }
2642 #[tokio::test]
2644 #[traced_test]
2645 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
2646 let tmp_dir = testutils::setup_test_dir().await?;
2647 let test_path = tmp_dir.as_path();
2648 let mut filter = FilterSettings::new();
2650 filter.add_exclude("*.txt").unwrap();
2651 let summary = link(
2652 &PROGRESS,
2653 test_path,
2654 &test_path.join("foo/0.txt"), &test_path.join("dst/0.txt"),
2656 &None,
2657 &Settings {
2658 copy_settings: CopySettings {
2659 dereference: false,
2660 fail_early: false,
2661 overwrite: false,
2662 overwrite_compare: Default::default(),
2663 overwrite_filter: None,
2664 ignore_existing: false,
2665 chunk_size: 0,
2666 skip_specials: false,
2667 remote_copy_buffer_size: 0,
2668 filter: None,
2669 dry_run: None,
2670 delete: None,
2671 },
2672 update_compare: Default::default(),
2673 update_exclusive: false,
2674 filter: Some(filter),
2675 dry_run: None,
2676 preserve: preserve::preserve_all(),
2677 },
2678 false,
2679 )
2680 .await?;
2681 assert_eq!(
2683 summary.hard_links_created, 0,
2684 "file matching exclude pattern should not be linked"
2685 );
2686 assert!(
2687 !test_path.join("dst/0.txt").exists(),
2688 "excluded file should not exist at destination"
2689 );
2690 Ok(())
2691 }
2692 #[tokio::test]
2694 #[traced_test]
2695 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
2696 let test_path = testutils::create_temp_dir().await?;
2697 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
2699 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
2700 let mut filter = FilterSettings::new();
2702 filter.add_exclude("*_dir/").unwrap();
2703 let result = link(
2704 &PROGRESS,
2705 &test_path,
2706 &test_path.join("excluded_dir"),
2707 &test_path.join("dst"),
2708 &None,
2709 &Settings {
2710 copy_settings: CopySettings {
2711 dereference: false,
2712 fail_early: false,
2713 overwrite: false,
2714 overwrite_compare: Default::default(),
2715 overwrite_filter: None,
2716 ignore_existing: false,
2717 chunk_size: 0,
2718 skip_specials: false,
2719 remote_copy_buffer_size: 0,
2720 filter: None,
2721 dry_run: None,
2722 delete: None,
2723 },
2724 update_compare: Default::default(),
2725 update_exclusive: false,
2726 filter: Some(filter),
2727 dry_run: None,
2728 preserve: preserve::preserve_all(),
2729 },
2730 false,
2731 )
2732 .await?;
2733 assert_eq!(
2735 result.copy_summary.directories_created, 0,
2736 "root directory matching exclude should not be created"
2737 );
2738 assert!(
2739 !test_path.join("dst").exists(),
2740 "excluded root directory should not exist at destination"
2741 );
2742 Ok(())
2743 }
2744 #[tokio::test]
2746 #[traced_test]
2747 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
2748 let test_path = testutils::create_temp_dir().await?;
2749 tokio::fs::write(test_path.join("target.txt"), "content").await?;
2751 tokio::fs::symlink(
2752 test_path.join("target.txt"),
2753 test_path.join("excluded_link"),
2754 )
2755 .await?;
2756 let mut filter = FilterSettings::new();
2758 filter.add_exclude("*_link").unwrap();
2759 let result = link(
2760 &PROGRESS,
2761 &test_path,
2762 &test_path.join("excluded_link"),
2763 &test_path.join("dst"),
2764 &None,
2765 &Settings {
2766 copy_settings: CopySettings {
2767 dereference: false,
2768 fail_early: false,
2769 overwrite: false,
2770 overwrite_compare: Default::default(),
2771 overwrite_filter: None,
2772 ignore_existing: false,
2773 chunk_size: 0,
2774 skip_specials: false,
2775 remote_copy_buffer_size: 0,
2776 filter: None,
2777 dry_run: None,
2778 delete: None,
2779 },
2780 update_compare: Default::default(),
2781 update_exclusive: false,
2782 filter: Some(filter),
2783 dry_run: None,
2784 preserve: preserve::preserve_all(),
2785 },
2786 false,
2787 )
2788 .await?;
2789 assert_eq!(
2791 result.copy_summary.symlinks_created, 0,
2792 "root symlink matching exclude should not be created"
2793 );
2794 assert!(
2795 !test_path.join("dst").exists(),
2796 "excluded root symlink should not exist at destination"
2797 );
2798 Ok(())
2799 }
2800 #[tokio::test]
2802 #[traced_test]
2803 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
2804 let tmp_dir = testutils::setup_test_dir().await?;
2805 let test_path = tmp_dir.as_path();
2806 let mut filter = FilterSettings::new();
2813 filter.add_include("bar/*.txt").unwrap();
2814 filter.add_exclude("bar/2.txt").unwrap();
2815 let summary = link(
2816 &PROGRESS,
2817 test_path,
2818 &test_path.join("foo"),
2819 &test_path.join("dst"),
2820 &None,
2821 &Settings {
2822 copy_settings: CopySettings {
2823 dereference: false,
2824 fail_early: false,
2825 overwrite: false,
2826 overwrite_compare: Default::default(),
2827 overwrite_filter: None,
2828 ignore_existing: false,
2829 chunk_size: 0,
2830 skip_specials: false,
2831 remote_copy_buffer_size: 0,
2832 filter: None,
2833 dry_run: None,
2834 delete: None,
2835 },
2836 update_compare: Default::default(),
2837 update_exclusive: false,
2838 filter: Some(filter),
2839 dry_run: None,
2840 preserve: preserve::preserve_all(),
2841 },
2842 false,
2843 )
2844 .await?;
2845 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
2848 assert_eq!(
2849 summary.copy_summary.files_skipped, 2,
2850 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
2851 );
2852 assert!(
2854 test_path.join("dst/bar/1.txt").exists(),
2855 "bar/1.txt should be linked"
2856 );
2857 assert!(
2858 !test_path.join("dst/bar/2.txt").exists(),
2859 "bar/2.txt should be excluded"
2860 );
2861 assert!(
2862 test_path.join("dst/bar/3.txt").exists(),
2863 "bar/3.txt should be linked"
2864 );
2865 Ok(())
2866 }
2867 #[tokio::test]
2869 #[traced_test]
2870 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
2871 let tmp_dir = testutils::setup_test_dir().await?;
2872 let test_path = tmp_dir.as_path();
2873 let mut filter = FilterSettings::new();
2880 filter.add_exclude("bar/").unwrap();
2881 let summary = link(
2882 &PROGRESS,
2883 test_path,
2884 &test_path.join("foo"),
2885 &test_path.join("dst"),
2886 &None,
2887 &Settings {
2888 copy_settings: CopySettings {
2889 dereference: false,
2890 fail_early: false,
2891 overwrite: false,
2892 overwrite_compare: Default::default(),
2893 overwrite_filter: None,
2894 ignore_existing: false,
2895 chunk_size: 0,
2896 skip_specials: false,
2897 remote_copy_buffer_size: 0,
2898 filter: None,
2899 dry_run: None,
2900 delete: None,
2901 },
2902 update_compare: Default::default(),
2903 update_exclusive: false,
2904 filter: Some(filter),
2905 dry_run: None,
2906 preserve: preserve::preserve_all(),
2907 },
2908 false,
2909 )
2910 .await?;
2911 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
2915 assert_eq!(
2916 summary.copy_summary.symlinks_created, 2,
2917 "should copy 2 symlinks"
2918 );
2919 assert_eq!(
2920 summary.copy_summary.directories_skipped, 1,
2921 "should skip 1 directory (bar)"
2922 );
2923 assert!(
2925 !test_path.join("dst/bar").exists(),
2926 "bar directory should not be linked"
2927 );
2928 Ok(())
2929 }
2930 #[tokio::test]
2933 #[traced_test]
2934 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
2935 let test_path = testutils::create_temp_dir().await?;
2936 let src_path = test_path.join("src");
2942 tokio::fs::create_dir(&src_path).await?;
2943 tokio::fs::write(src_path.join("foo"), "content").await?;
2944 tokio::fs::write(src_path.join("bar"), "content").await?;
2945 tokio::fs::create_dir(src_path.join("baz")).await?;
2946 let mut filter = FilterSettings::new();
2948 filter.add_include("foo").unwrap();
2949 let summary = link(
2950 &PROGRESS,
2951 &test_path,
2952 &src_path,
2953 &test_path.join("dst"),
2954 &None,
2955 &Settings {
2956 copy_settings: copy::Settings {
2957 dereference: false,
2958 fail_early: false,
2959 overwrite: false,
2960 overwrite_compare: Default::default(),
2961 overwrite_filter: None,
2962 ignore_existing: false,
2963 chunk_size: 0,
2964 skip_specials: false,
2965 remote_copy_buffer_size: 0,
2966 filter: None,
2967 dry_run: None,
2968 delete: None,
2969 },
2970 update_compare: Default::default(),
2971 update_exclusive: false,
2972 filter: Some(filter),
2973 dry_run: None,
2974 preserve: preserve::preserve_all(),
2975 },
2976 false,
2977 )
2978 .await?;
2979 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2981 assert_eq!(
2982 summary.copy_summary.directories_created, 1,
2983 "should create only root directory (not empty 'baz')"
2984 );
2985 assert!(
2987 test_path.join("dst").join("foo").exists(),
2988 "foo should be linked"
2989 );
2990 assert!(
2992 !test_path.join("dst").join("bar").exists(),
2993 "bar should not be linked"
2994 );
2995 assert!(
2997 !test_path.join("dst").join("baz").exists(),
2998 "empty baz directory should NOT be created"
2999 );
3000 Ok(())
3001 }
3002 #[tokio::test]
3005 #[traced_test]
3006 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
3007 let test_path = testutils::create_temp_dir().await?;
3008 let src_path = test_path.join("src");
3015 tokio::fs::create_dir(&src_path).await?;
3016 tokio::fs::write(src_path.join("foo"), "content").await?;
3017 tokio::fs::create_dir(src_path.join("baz")).await?;
3018 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
3019 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
3020 let mut filter = FilterSettings::new();
3022 filter.add_include("foo").unwrap();
3023 let summary = link(
3024 &PROGRESS,
3025 &test_path,
3026 &src_path,
3027 &test_path.join("dst"),
3028 &None,
3029 &Settings {
3030 copy_settings: copy::Settings {
3031 dereference: false,
3032 fail_early: false,
3033 overwrite: false,
3034 overwrite_compare: Default::default(),
3035 overwrite_filter: None,
3036 ignore_existing: false,
3037 chunk_size: 0,
3038 skip_specials: false,
3039 remote_copy_buffer_size: 0,
3040 filter: None,
3041 dry_run: None,
3042 delete: None,
3043 },
3044 update_compare: Default::default(),
3045 update_exclusive: false,
3046 filter: Some(filter),
3047 dry_run: None,
3048 preserve: preserve::preserve_all(),
3049 },
3050 false,
3051 )
3052 .await?;
3053 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3055 assert_eq!(
3056 summary.copy_summary.files_skipped, 2,
3057 "should skip 2 files (qux and quux)"
3058 );
3059 assert_eq!(
3060 summary.copy_summary.directories_created, 1,
3061 "should create only root directory (not 'baz' with non-matching content)"
3062 );
3063 assert!(
3065 test_path.join("dst").join("foo").exists(),
3066 "foo should be linked"
3067 );
3068 assert!(
3070 !test_path.join("dst").join("baz").exists(),
3071 "baz directory should NOT be created (no matching content inside)"
3072 );
3073 Ok(())
3074 }
3075 #[tokio::test]
3078 #[traced_test]
3079 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
3080 let test_path = testutils::create_temp_dir().await?;
3081 let src_path = test_path.join("src");
3087 tokio::fs::create_dir(&src_path).await?;
3088 tokio::fs::write(src_path.join("foo"), "content").await?;
3089 tokio::fs::write(src_path.join("bar"), "content").await?;
3090 tokio::fs::create_dir(src_path.join("baz")).await?;
3091 let mut filter = FilterSettings::new();
3093 filter.add_include("foo").unwrap();
3094 let summary = link(
3095 &PROGRESS,
3096 &test_path,
3097 &src_path,
3098 &test_path.join("dst"),
3099 &None,
3100 &Settings {
3101 copy_settings: copy::Settings {
3102 dereference: false,
3103 fail_early: false,
3104 overwrite: false,
3105 overwrite_compare: Default::default(),
3106 overwrite_filter: None,
3107 ignore_existing: false,
3108 chunk_size: 0,
3109 skip_specials: false,
3110 remote_copy_buffer_size: 0,
3111 filter: None,
3112 dry_run: None,
3113 delete: None,
3114 },
3115 update_compare: Default::default(),
3116 update_exclusive: false,
3117 filter: Some(filter),
3118 dry_run: Some(crate::config::DryRunMode::Explain),
3119 preserve: preserve::preserve_all(),
3120 },
3121 false,
3122 )
3123 .await?;
3124 assert_eq!(
3126 summary.hard_links_created, 1,
3127 "should report only 'foo' would be linked"
3128 );
3129 assert_eq!(
3130 summary.copy_summary.directories_created, 1,
3131 "should report only root directory would be created (not empty 'baz')"
3132 );
3133 assert!(
3135 !test_path.join("dst").exists(),
3136 "dst should not exist in dry-run"
3137 );
3138 Ok(())
3139 }
3140 #[tokio::test]
3143 #[traced_test]
3144 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
3145 let test_path = testutils::create_temp_dir().await?;
3146 let src_path = test_path.join("src");
3152 tokio::fs::create_dir(&src_path).await?;
3153 tokio::fs::write(src_path.join("foo"), "content").await?;
3154 tokio::fs::write(src_path.join("bar"), "content").await?;
3155 tokio::fs::create_dir(src_path.join("baz")).await?;
3156 let dst_path = test_path.join("dst");
3158 tokio::fs::create_dir(&dst_path).await?;
3159 tokio::fs::create_dir(dst_path.join("baz")).await?;
3160 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
3162 let mut filter = FilterSettings::new();
3164 filter.add_include("foo").unwrap();
3165 let summary = link(
3166 &PROGRESS,
3167 &test_path,
3168 &src_path,
3169 &dst_path,
3170 &None,
3171 &Settings {
3172 copy_settings: copy::Settings {
3173 dereference: false,
3174 fail_early: false,
3175 overwrite: true, overwrite_compare: Default::default(),
3177 overwrite_filter: None,
3178 ignore_existing: false,
3179 chunk_size: 0,
3180 skip_specials: false,
3181 remote_copy_buffer_size: 0,
3182 filter: None,
3183 dry_run: None,
3184 delete: None,
3185 },
3186 update_compare: Default::default(),
3187 update_exclusive: false,
3188 filter: Some(filter),
3189 dry_run: None,
3190 preserve: preserve::preserve_all(),
3191 },
3192 false,
3193 )
3194 .await?;
3195 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3197 assert_eq!(
3199 summary.copy_summary.directories_unchanged, 2,
3200 "root dst and baz directories should be unchanged"
3201 );
3202 assert_eq!(
3203 summary.copy_summary.directories_created, 0,
3204 "should not create any directories"
3205 );
3206 assert!(dst_path.join("foo").exists(), "foo should be linked");
3208 assert!(!dst_path.join("bar").exists(), "bar should not be linked");
3210 assert!(
3212 dst_path.join("baz").exists(),
3213 "existing baz directory should still exist"
3214 );
3215 assert!(
3216 dst_path.join("baz").join("marker.txt").exists(),
3217 "existing content in baz should still exist"
3218 );
3219 Ok(())
3220 }
3221
3222 #[tokio::test]
3228 #[traced_test]
3229 async fn update_only_excluded_entry_not_copied_without_delete() -> Result<(), anyhow::Error>
3230 {
3231 let test_path = testutils::create_temp_dir().await?;
3232 let src = test_path.join("src");
3236 let update = test_path.join("update");
3237 let dst = test_path.join("dst");
3238 tokio::fs::create_dir(&src).await?;
3239 tokio::fs::create_dir(&update).await?;
3240 tokio::fs::write(src.join("keep.txt"), "keep").await?;
3241 tokio::fs::write(update.join("keep.txt"), "keep").await?;
3242 tokio::fs::write(update.join("extra.txt"), "EXCLUDED").await?;
3243 tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
3244
3245 let mut filter = FilterSettings::new();
3246 filter.add_exclude("extra.txt").unwrap();
3247 let mut settings = common_settings(false, false);
3248 settings.filter = Some(filter);
3249 assert!(settings.copy_settings.delete.is_none());
3251
3252 let summary = link(
3253 &PROGRESS,
3254 &test_path,
3255 &src,
3256 &dst,
3257 &Some(update.clone()),
3258 &settings,
3259 false,
3260 )
3261 .await?;
3262
3263 assert!(
3264 !dst.join("extra.txt").exists(),
3265 "update-only entry matching --exclude must NOT be copied when --delete is off"
3266 );
3267 assert!(
3268 dst.join("wanted.txt").exists(),
3269 "non-excluded update-only entry should be copied"
3270 );
3271 assert!(dst.join("keep.txt").exists(), "shared entry should exist");
3272 assert_eq!(
3273 summary.copy_summary.files_skipped, 1,
3274 "the excluded update-only file should be counted skipped"
3275 );
3276 Ok(())
3277 }
3278
3279 fn are_hardlinked(a: &std::path::Path, b: &std::path::Path) -> bool {
3281 use std::os::unix::fs::MetadataExt;
3282 match (std::fs::symlink_metadata(a), std::fs::symlink_metadata(b)) {
3283 (Ok(ma), Ok(mb)) => ma.ino() == mb.ino() && ma.dev() == mb.dev(),
3284 _ => false,
3285 }
3286 }
3287
3288 #[tokio::test]
3301 #[traced_test]
3302 async fn type_mismatch_excluded_update_dir_not_copied_src_file_kept()
3303 -> Result<(), anyhow::Error> {
3304 let test_path = testutils::create_temp_dir().await?;
3305 let src = test_path.join("src");
3306 let update = test_path.join("update");
3307 let dst = test_path.join("dst");
3308 tokio::fs::create_dir(&src).await?;
3309 tokio::fs::create_dir(&update).await?;
3310 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3312 tokio::fs::create_dir(update.join("cache")).await?;
3313 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3314 tokio::fs::write(src.join("keep.txt"), "keep").await?;
3316 tokio::fs::write(update.join("keep.txt"), "keep").await?;
3317 let keep_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
3323 filetime::set_file_mtime(src.join("keep.txt"), keep_mtime)?;
3324 filetime::set_file_mtime(update.join("keep.txt"), keep_mtime)?;
3325
3326 let mut filter = FilterSettings::new();
3327 filter.add_exclude("cache/").unwrap(); let mut settings = common_settings(false, false);
3329 settings.filter = Some(filter);
3330 assert!(settings.copy_settings.delete.is_none());
3331
3332 let summary = link(
3333 &PROGRESS,
3334 &test_path,
3335 &src,
3336 &dst,
3337 &Some(update.clone()),
3338 &settings,
3339 false,
3340 )
3341 .await?;
3342
3343 assert!(
3345 !dst.join("cache").join("inner.dat").exists(),
3346 "excluded update directory `cache/` must not be copied"
3347 );
3348 assert!(
3349 !dst.join("cache").is_dir(),
3350 "dst/cache must not be the excluded update directory"
3351 );
3352 assert!(
3354 dst.join("cache").is_file(),
3355 "src `cache` file must be materialized when the update dir is excluded"
3356 );
3357 assert_eq!(
3358 tokio::fs::read_to_string(dst.join("cache")).await?,
3359 "SRC-FILE"
3360 );
3361 assert!(
3362 are_hardlinked(&src.join("cache"), &dst.join("cache")),
3363 "the src `cache` file must be hard-linked into the destination"
3364 );
3365 assert!(
3366 dst.join("keep.txt").exists(),
3367 "shared entry should still link"
3368 );
3369 assert_eq!(summary.hard_links_created, 2);
3373 assert_eq!(summary.copy_summary.files_copied, 0);
3374 assert_eq!(
3375 summary.copy_summary.directories_created, 1,
3376 "only the dst root is created; the excluded `cache/` dir must not be"
3377 );
3378 Ok(())
3379 }
3380
3381 #[tokio::test]
3391 #[traced_test]
3392 async fn reverse_type_mismatch_excluded_update_file_not_copied_src_dir_kept()
3393 -> Result<(), anyhow::Error> {
3394 let test_path = testutils::create_temp_dir().await?;
3395 let src = test_path.join("src");
3396 let update = test_path.join("update");
3397 let dst = test_path.join("dst");
3398 tokio::fs::create_dir(&src).await?;
3399 tokio::fs::create_dir(&update).await?;
3400 tokio::fs::create_dir(src.join("data")).await?;
3402 tokio::fs::write(src.join("data").join("inner.txt"), "SRC-DIR-CONTENT").await?;
3403 tokio::fs::write(update.join("data"), "UPDATE-FILE-EXCLUDED").await?;
3404
3405 let mut filter = FilterSettings::new();
3409 filter.add_include("data/").unwrap();
3410 filter.add_include("data/**").unwrap();
3411 let mut settings = common_settings(false, false);
3412 settings.filter = Some(filter);
3413 assert!(settings.copy_settings.delete.is_none());
3414
3415 link(
3416 &PROGRESS,
3417 &test_path,
3418 &src,
3419 &dst,
3420 &Some(update.clone()),
3421 &settings,
3422 false,
3423 )
3424 .await?;
3425
3426 assert!(
3428 dst.join("data").is_dir(),
3429 "src `data` directory must be materialized when the update file is excluded"
3430 );
3431 assert!(
3432 dst.join("data").join("inner.txt").exists(),
3433 "src directory contents must be linked through"
3434 );
3435 assert!(
3436 are_hardlinked(
3437 &src.join("data").join("inner.txt"),
3438 &dst.join("data").join("inner.txt")
3439 ),
3440 "src directory's file must be hard-linked into the destination"
3441 );
3442 Ok(())
3443 }
3444
3445 #[tokio::test]
3451 #[traced_test]
3452 async fn type_mismatch_excluded_update_dir_update_exclusive_materializes_nothing()
3453 -> Result<(), anyhow::Error> {
3454 let test_path = testutils::create_temp_dir().await?;
3455 let src = test_path.join("src");
3456 let update = test_path.join("update");
3457 let dst = test_path.join("dst");
3458 tokio::fs::create_dir(&src).await?;
3459 tokio::fs::create_dir(&update).await?;
3460 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3461 tokio::fs::create_dir(update.join("cache")).await?;
3462 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3463 tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
3465
3466 let mut filter = FilterSettings::new();
3467 filter.add_exclude("cache/").unwrap();
3468 let mut settings = common_settings(false, false);
3469 settings.update_exclusive = true;
3470 settings.filter = Some(filter);
3471
3472 link(
3473 &PROGRESS,
3474 &test_path,
3475 &src,
3476 &dst,
3477 &Some(update.clone()),
3478 &settings,
3479 false,
3480 )
3481 .await?;
3482
3483 assert!(
3484 !dst.join("cache").exists(),
3485 "under --update-exclusive an excluded-update type-mismatch must materialize nothing \
3486 (no excluded dir, no stale src file)"
3487 );
3488 assert!(
3489 dst.join("wanted.txt").exists(),
3490 "filter-passing update-only entries are still copied under --update-exclusive"
3491 );
3492 Ok(())
3493 }
3494
3495 #[tokio::test]
3502 #[traced_test]
3503 async fn type_mismatch_excluded_update_dir_delete_keeps_src_file()
3504 -> Result<(), anyhow::Error> {
3505 let test_path = testutils::create_temp_dir().await?;
3506 let src = test_path.join("src");
3507 let update = test_path.join("update");
3508 let dst = test_path.join("dst");
3509 tokio::fs::create_dir(&src).await?;
3510 tokio::fs::create_dir(&update).await?;
3511 tokio::fs::create_dir(&dst).await?;
3512 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3513 tokio::fs::create_dir(update.join("cache")).await?;
3514 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3515 tokio::fs::write(dst.join("stale.txt"), "stale").await?;
3517
3518 let mut filter = FilterSettings::new();
3519 filter.add_exclude("cache/").unwrap();
3520 let mut settings = common_settings(false, true); settings.filter = Some(filter);
3522 settings.copy_settings.delete = Some(copy::DeleteSettings {
3523 delete_excluded: false,
3524 });
3525
3526 link(
3527 &PROGRESS,
3528 &test_path,
3529 &src,
3530 &dst,
3531 &Some(update.clone()),
3532 &settings,
3533 false,
3534 )
3535 .await?;
3536
3537 assert!(
3538 dst.join("cache").is_file(),
3539 "src `cache` file must survive --delete (kept in the keep-set, not pruned)"
3540 );
3541 assert_eq!(
3542 tokio::fs::read_to_string(dst.join("cache")).await?,
3543 "SRC-FILE"
3544 );
3545 assert!(
3546 !dst.join("cache").is_dir(),
3547 "the excluded update directory must leave no leftover"
3548 );
3549 assert!(
3550 !dst.join("stale.txt").exists(),
3551 "extraneous dst entry must be pruned by --delete"
3552 );
3553 Ok(())
3554 }
3555 }
3556 mod dry_run_tests {
3557 use super::*;
3558 #[tokio::test]
3560 #[traced_test]
3561 async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
3562 let tmp_dir = testutils::setup_test_dir().await?;
3563 let test_path = tmp_dir.as_path();
3564 let src_file = test_path.join("foo/0.txt");
3565 let dst_file = test_path.join("dst_link.txt");
3566 assert!(
3568 !dst_file.exists(),
3569 "destination should not exist before dry-run"
3570 );
3571 let summary = link(
3572 &PROGRESS,
3573 test_path,
3574 &src_file,
3575 &dst_file,
3576 &None,
3577 &Settings {
3578 copy_settings: CopySettings {
3579 dereference: false,
3580 fail_early: false,
3581 overwrite: false,
3582 overwrite_compare: Default::default(),
3583 overwrite_filter: None,
3584 ignore_existing: false,
3585 chunk_size: 0,
3586 skip_specials: false,
3587 remote_copy_buffer_size: 0,
3588 filter: None,
3589 dry_run: None,
3590 delete: None,
3591 },
3592 update_compare: Default::default(),
3593 update_exclusive: false,
3594 filter: None,
3595 dry_run: Some(crate::config::DryRunMode::Brief),
3596 preserve: preserve::preserve_all(),
3597 },
3598 false,
3599 )
3600 .await?;
3601 assert!(!dst_file.exists(), "dry-run should not create hard link");
3603 assert_eq!(
3605 summary.hard_links_created, 1,
3606 "dry-run should report 1 hard link that would be created"
3607 );
3608 Ok(())
3609 }
3610 #[tokio::test]
3612 #[traced_test]
3613 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
3614 let tmp_dir = testutils::setup_test_dir().await?;
3615 let test_path = tmp_dir.as_path();
3616 let dst_path = test_path.join("nonexistent_dst");
3617 assert!(
3619 !dst_path.exists(),
3620 "destination should not exist before dry-run"
3621 );
3622 let summary = link(
3623 &PROGRESS,
3624 test_path,
3625 &test_path.join("foo"),
3626 &dst_path,
3627 &None,
3628 &Settings {
3629 copy_settings: CopySettings {
3630 dereference: false,
3631 fail_early: false,
3632 overwrite: false,
3633 overwrite_compare: Default::default(),
3634 overwrite_filter: None,
3635 ignore_existing: false,
3636 chunk_size: 0,
3637 skip_specials: false,
3638 remote_copy_buffer_size: 0,
3639 filter: None,
3640 dry_run: None,
3641 delete: None,
3642 },
3643 update_compare: Default::default(),
3644 update_exclusive: false,
3645 filter: None,
3646 dry_run: Some(crate::config::DryRunMode::Brief),
3647 preserve: preserve::preserve_all(),
3648 },
3649 false,
3650 )
3651 .await?;
3652 assert!(
3654 !dst_path.exists(),
3655 "dry-run should not create destination directory"
3656 );
3657 assert!(
3659 summary.hard_links_created > 0,
3660 "dry-run should report hard links that would be created"
3661 );
3662 Ok(())
3663 }
3664 #[tokio::test]
3666 #[traced_test]
3667 async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
3668 let tmp_dir = testutils::setup_test_dir().await?;
3669 let test_path = tmp_dir.as_path();
3670 let src_path = test_path.join("foo/baz");
3672 let dst_path = test_path.join("dst_baz");
3673 assert!(
3675 !dst_path.exists(),
3676 "destination should not exist before dry-run"
3677 );
3678 let summary = link(
3679 &PROGRESS,
3680 test_path,
3681 &src_path,
3682 &dst_path,
3683 &None,
3684 &Settings {
3685 copy_settings: CopySettings {
3686 dereference: false,
3687 fail_early: false,
3688 overwrite: false,
3689 overwrite_compare: Default::default(),
3690 overwrite_filter: None,
3691 ignore_existing: false,
3692 chunk_size: 0,
3693 skip_specials: false,
3694 remote_copy_buffer_size: 0,
3695 filter: None,
3696 dry_run: None,
3697 delete: None,
3698 },
3699 update_compare: Default::default(),
3700 update_exclusive: false,
3701 filter: None,
3702 dry_run: Some(crate::config::DryRunMode::Brief),
3703 preserve: preserve::preserve_all(),
3704 },
3705 false,
3706 )
3707 .await?;
3708 assert!(!dst_path.exists(), "dry-run should not create destination");
3710 assert_eq!(
3712 summary.hard_links_created, 1,
3713 "dry-run should report 1 hard link (for 4.txt)"
3714 );
3715 assert_eq!(
3716 summary.copy_summary.symlinks_created, 2,
3717 "dry-run should report 2 symlinks (5.txt and 6.txt)"
3718 );
3719 Ok(())
3720 }
3721 }
3722
3723 #[tokio::test]
3730 #[traced_test]
3731 async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
3732 let tmp_dir = testutils::create_temp_dir().await?;
3733 let test_path = tmp_dir.as_path();
3734 let src_dir = test_path.join("src");
3739 let sub_dir = src_dir.join("sub");
3740 let bad_dir = sub_dir.join("unreadable_dir");
3741 tokio::fs::create_dir_all(&bad_dir).await?;
3742 tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
3743 tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
3744 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
3745 let dst_dir = test_path.join("dst");
3746 let result = link(
3747 &PROGRESS,
3748 test_path,
3749 &src_dir,
3750 &dst_dir,
3751 &None,
3752 &Settings {
3753 copy_settings: CopySettings {
3754 fail_early: true,
3755 ..common_settings(false, false).copy_settings
3756 },
3757 ..common_settings(false, false)
3758 },
3759 false,
3760 )
3761 .await;
3762 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
3764 let error = result.expect_err("link should fail due to unreadable directory");
3765 assert!(
3770 error.summary.copy_summary.directories_created >= 2,
3771 "fail-early summary should include directories from the failing subtree, \
3772 got directories_created={} (expected >= 2: dst/ and dst/sub/)",
3773 error.summary.copy_summary.directories_created
3774 );
3775 Ok(())
3776 }
3777
3778 #[tokio::test]
3779 #[traced_test]
3780 async fn skip_specials_skips_socket_in_link() -> Result<(), anyhow::Error> {
3781 let tmp_dir = testutils::setup_test_dir().await?;
3782 let test_path = tmp_dir.as_path();
3783 let src = test_path.join("src_dir");
3784 let dst = test_path.join("dst_dir");
3785 tokio::fs::create_dir(&src).await?;
3786 tokio::fs::write(src.join("file.txt"), "hello").await?;
3787 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
3788 let mut settings = common_settings(false, false);
3789 settings.copy_settings.skip_specials = true;
3790 let summary = link(&PROGRESS, test_path, &src, &dst, &None, &settings, false).await?;
3791 assert_eq!(summary.hard_links_created, 1);
3792 assert_eq!(summary.copy_summary.specials_skipped, 1);
3793 assert!(dst.join("file.txt").exists());
3794 assert!(!dst.join("test.sock").exists());
3795 Ok(())
3796 }
3797
3798 #[tokio::test]
3799 #[traced_test]
3800 async fn delete_skips_pruning_when_link_has_errors() -> Result<(), anyhow::Error> {
3801 let tmp_dir = testutils::setup_test_dir().await?;
3802 let test_path = tmp_dir.as_path();
3803 let src = test_path.join("foo");
3804 let dst = test_path.join("bar");
3805 link(
3807 &PROGRESS,
3808 test_path,
3809 &src,
3810 &dst,
3811 &None,
3812 &common_settings(false, false),
3813 false,
3814 )
3815 .await?;
3816 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
3818 let unreadable = src.join("baz");
3822 let original = tokio::fs::metadata(&unreadable).await?.permissions();
3823 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3824
3825 let delete_settings = Settings {
3826 copy_settings: CopySettings {
3827 overwrite: true,
3828 fail_early: false,
3829 delete: Some(copy::DeleteSettings {
3830 delete_excluded: false,
3831 }),
3832 ..common_settings(false, true).copy_settings
3833 },
3834 ..common_settings(false, true)
3835 };
3836 let result = link(
3837 &PROGRESS,
3838 test_path,
3839 &src,
3840 &dst,
3841 &None,
3842 &delete_settings,
3843 false,
3844 )
3845 .await;
3846
3847 tokio::fs::set_permissions(&unreadable, original).await?;
3848
3849 assert!(
3850 result.is_err(),
3851 "link of the unreadable directory should fail"
3852 );
3853 assert!(
3854 dst.join("extraneous.txt").exists(),
3855 "pruning must be skipped when the link/update pass reported errors"
3856 );
3857 Ok(())
3858 }
3859
3860 #[tokio::test]
3861 #[traced_test]
3862 async fn skip_specials_top_level_socket_in_link() -> Result<(), anyhow::Error> {
3863 let tmp_dir = testutils::setup_test_dir().await?;
3864 let test_path = tmp_dir.as_path();
3865 let src_socket = test_path.join("test.sock");
3866 let dst = test_path.join("dst.sock");
3867 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
3868 let mut settings = common_settings(false, false);
3869 settings.copy_settings.skip_specials = true;
3870 let summary = link(
3871 &PROGRESS,
3872 test_path,
3873 &src_socket,
3874 &dst,
3875 &None,
3876 &settings,
3877 false,
3878 )
3879 .await?;
3880 assert_eq!(summary.copy_summary.specials_skipped, 1);
3881 assert_eq!(summary.hard_links_created, 0);
3882 assert!(!dst.exists());
3883 Ok(())
3884 }
3885
3886 mod max_open_files_tests {
3888 use super::*;
3889
3890 #[tokio::test]
3893 #[traced_test]
3894 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
3895 let tmp_dir = testutils::create_temp_dir().await?;
3896 let src = tmp_dir.join("src");
3897 let dst = tmp_dir.join("dst");
3898 let depth = 20;
3899 let files_per_level = 5;
3900 let limit = 4;
3901 let mut dir = src.clone();
3903 for level in 0..depth {
3904 tokio::fs::create_dir_all(&dir).await?;
3905 for f in 0..files_per_level {
3906 tokio::fs::write(
3907 dir.join(format!("f{}_{}.txt", level, f)),
3908 format!("L{}F{}", level, f),
3909 )
3910 .await?;
3911 }
3912 dir = dir.join(format!("d{}", level));
3913 }
3914 throttle::set_max_open_files(limit);
3915 let summary = tokio::time::timeout(
3916 std::time::Duration::from_secs(30),
3917 link(
3918 &PROGRESS,
3919 tmp_dir.as_path(),
3920 &src,
3921 &dst,
3922 &None,
3923 &common_settings(false, false),
3924 false,
3925 ),
3926 )
3927 .await
3928 .context("link timed out — possible deadlock")?
3929 .context("link failed")?;
3930 assert_eq!(summary.hard_links_created, depth * files_per_level);
3931 assert_eq!(summary.copy_summary.directories_created, depth);
3932 let mut check_dir = dst.clone();
3934 for level in 0..depth {
3935 let content =
3936 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
3937 assert_eq!(content, format!("L{}F0", level));
3938 check_dir = check_dir.join(format!("d{}", level));
3939 }
3940 Ok(())
3941 }
3942
3943 #[tokio::test]
3954 #[traced_test]
3955 async fn parallel_update_filetype_change_no_deadlock() -> Result<(), anyhow::Error> {
3956 let tmp_dir = testutils::create_temp_dir().await?;
3957 let src = tmp_dir.join("src");
3958 let update = tmp_dir.join("update");
3959 let dst = tmp_dir.join("dst");
3960 tokio::fs::create_dir(&src).await?;
3961 tokio::fs::create_dir(&update).await?;
3962 let n = 8;
3963 for i in 0..n {
3967 tokio::fs::write(src.join(format!("e{}", i)), format!("src-{}", i)).await?;
3968 let upd_subdir = update.join(format!("e{}", i));
3969 tokio::fs::create_dir(&upd_subdir).await?;
3970 for j in 0..3 {
3971 tokio::fs::write(
3972 upd_subdir.join(format!("inner_{}.txt", j)),
3973 format!("upd-{}-{}", i, j),
3974 )
3975 .await?;
3976 }
3977 }
3978 throttle::set_max_open_files(2);
3981 let summary = tokio::time::timeout(
3982 std::time::Duration::from_secs(30),
3983 link(
3984 &PROGRESS,
3985 tmp_dir.as_path(),
3986 &src,
3987 &dst,
3988 &Some(update.clone()),
3989 &common_settings(false, false),
3990 false,
3991 ),
3992 )
3993 .await
3994 .context(
3995 "link timed out — caller-supplied open-files guard not released before copy::copy",
3996 )?
3997 .context("link failed")?;
3998 assert_eq!(summary.copy_summary.directories_created, n + 1); assert_eq!(summary.copy_summary.files_copied, n * 3);
4002 for i in 0..n {
4004 for j in 0..3 {
4005 let content =
4006 tokio::fs::read_to_string(dst.join(format!("e{}/inner_{}.txt", i, j)))
4007 .await?;
4008 assert_eq!(content, format!("upd-{}-{}", i, j));
4009 }
4010 }
4011 Ok(())
4012 }
4013
4014 #[tokio::test]
4022 #[traced_test]
4023 async fn update_only_entries_bounded_no_deadlock() -> Result<(), anyhow::Error> {
4024 let tmp_dir = testutils::create_temp_dir().await?;
4025 let src = tmp_dir.join("src");
4026 let update = tmp_dir.join("update");
4027 let dst = tmp_dir.join("dst");
4028 tokio::fs::create_dir(&src).await?;
4029 tokio::fs::create_dir(&update).await?;
4030 let n = 50;
4033 for i in 0..n {
4034 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
4035 }
4036 throttle::set_max_open_files(2);
4037 let summary = tokio::time::timeout(
4038 std::time::Duration::from_secs(30),
4039 link(
4040 &PROGRESS,
4041 tmp_dir.as_path(),
4042 &src,
4043 &dst,
4044 &Some(update.clone()),
4045 &common_settings(false, false),
4046 false,
4047 ),
4048 )
4049 .await
4050 .context("link timed out — site-3 spawn loop deadlock")?
4051 .context("link failed")?;
4052 assert_eq!(summary.copy_summary.directories_created, 1);
4054 assert_eq!(summary.copy_summary.files_copied, n);
4055 for i in 0..n {
4056 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
4057 assert_eq!(content, format!("upd-{}", i));
4058 }
4059 Ok(())
4060 }
4061
4062 #[tokio::test]
4073 #[traced_test]
4074 async fn update_only_overwrite_preexisting_dirs_no_deadlock() -> Result<(), anyhow::Error> {
4075 let tmp_dir = testutils::create_temp_dir().await?;
4076 let src = tmp_dir.join("src");
4077 let update = tmp_dir.join("update");
4078 let dst = tmp_dir.join("dst");
4079 tokio::fs::create_dir(&src).await?;
4080 tokio::fs::create_dir(&update).await?;
4081 tokio::fs::create_dir(&dst).await?;
4082 let n = 12;
4083 for i in 0..n {
4084 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
4086 let dst_subdir = dst.join(format!("u{}", i));
4090 tokio::fs::create_dir(&dst_subdir).await?;
4091 for j in 0..3 {
4092 tokio::fs::write(
4093 dst_subdir.join(format!("inner_{}.txt", j)),
4094 format!("old-{}-{}", i, j),
4095 )
4096 .await?;
4097 }
4098 }
4099 throttle::set_max_open_files(2);
4101 let summary = tokio::time::timeout(
4102 std::time::Duration::from_secs(30),
4103 link(
4104 &PROGRESS,
4105 tmp_dir.as_path(),
4106 &src,
4107 &dst,
4108 &Some(update.clone()),
4109 &common_settings(false, true), false,
4111 ),
4112 )
4113 .await
4114 .context("link timed out — pending-meta self-deadlock between site 3 and inner rm")?
4115 .context("link failed")?;
4116 assert_eq!(summary.copy_summary.files_copied, n);
4119 assert_eq!(summary.copy_summary.rm_summary.files_removed, n * 3);
4120 assert_eq!(summary.copy_summary.rm_summary.directories_removed, n);
4121 for i in 0..n {
4123 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
4124 assert_eq!(content, format!("upd-{}", i));
4125 }
4126 Ok(())
4127 }
4128 }
4129
4130 mod race_tests {
4138 use super::*;
4139
4140 fn spawn_file_symlink_swapper(
4144 dir: std::path::PathBuf,
4145 entry_name: &'static str,
4146 sentinel: std::path::PathBuf,
4147 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
4148 ) -> std::thread::JoinHandle<()> {
4149 std::thread::spawn(move || {
4150 let entry = dir.join(entry_name);
4151 let staged_real = dir.join("__staged_real");
4152 let staged_link = dir.join("__staged_link");
4153 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
4154 let _ = std::fs::remove_file(&staged_real);
4155 if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
4156 continue;
4157 }
4158 let _ = std::fs::rename(&staged_real, &entry);
4159 let _ = std::fs::remove_file(&staged_link);
4160 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
4161 let _ = std::fs::rename(&staged_link, &entry);
4162 }
4163 })
4164 }
4165
4166 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4167 #[traced_test]
4168 async fn hard_link_entry_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
4169 let tmp_dir = testutils::create_temp_dir().await?;
4170 let test_path = tmp_dir.as_path();
4171 let sentinel = test_path.join("sentinel_secret");
4174 tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
4175 let sentinel_links_before = {
4176 use std::os::unix::fs::MetadataExt;
4177 tokio::fs::symlink_metadata(&sentinel).await?.nlink()
4178 };
4179 let src = test_path.join("src");
4180 let sub = src.join("sub");
4181 tokio::fs::create_dir(&src).await?;
4182 tokio::fs::create_dir(&sub).await?;
4183 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
4184
4185 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4186 let swapper =
4187 spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
4188
4189 let settings = common_settings(false, true);
4192 let mut caught_swaps = 0usize;
4193 let mut linked_real = 0usize;
4194 for i in 0..200 {
4195 let dst = test_path.join(format!("dst_{i}"));
4196 let result = tokio::time::timeout(
4197 std::time::Duration::from_secs(30),
4198 link(&PROGRESS, test_path, &src, &dst, &None, &settings, false),
4199 )
4200 .await
4201 .expect("link must not hang under concurrent swapping");
4202 match result {
4203 Ok(_) => {}
4204 Err(_) => caught_swaps += 1, }
4206 let entry_dst = dst.join("sub").join("entry");
4212 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
4213 && md.file_type().is_file()
4214 {
4215 let content = tokio::fs::read_to_string(&entry_dst).await?;
4216 assert_ne!(
4217 content, "SENTINEL_SECRET_CONTENT",
4218 "iteration {i}: sentinel content leaked into the destination as a regular file"
4219 );
4220 assert_eq!(
4221 content, "REAL_CONTENT",
4222 "iteration {i}: a regular destination file must hold the real content"
4223 );
4224 linked_real += 1;
4225 }
4226 let _ = tokio::fs::remove_dir_all(&dst).await;
4227 }
4228
4229 stop.store(true, std::sync::atomic::Ordering::Relaxed);
4230 swapper.join().expect("swapper thread panicked");
4231
4232 let sentinel_links_after = {
4235 use std::os::unix::fs::MetadataExt;
4236 tokio::fs::symlink_metadata(&sentinel).await?.nlink()
4237 };
4238 assert_eq!(
4239 sentinel_links_after, sentinel_links_before,
4240 "the sentinel file must never gain a hard link (linkat must not follow the symlink)"
4241 );
4242 tracing::info!(
4245 "link file/symlink swap: caught_swaps={caught_swaps}, linked_real={linked_real}"
4246 );
4247 assert!(
4248 caught_swaps + linked_real > 0,
4249 "expected at least one observable outcome across 200 iterations"
4250 );
4251 Ok(())
4252 }
4253 }
4254}