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 let destructive = settings.update_exclusive || settings.copy_settings.delete.is_some();
214 if crate::safedir::strict_operand_resolution() {
215 let kind = crate::safedir::strict_probe_dst_kind(update_path, congestion::Side::Source)
222 .await
223 .map_err(|err| {
224 Error::new(
225 anyhow::Error::new(err).context(format!(
226 "failed reading metadata from update {update_path:?}"
227 )),
228 Default::default(),
229 )
230 })?;
231 if destructive && kind.is_none() {
232 return Err(Error::new(
233 anyhow!(
234 "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
235 update_path
236 ),
237 Default::default(),
238 ));
239 }
240 } else if destructive {
241 match crate::walk::run_metadata_probed(
242 congestion::Side::Source,
243 congestion::MetadataOp::Stat,
244 tokio::fs::symlink_metadata(update_path),
245 )
246 .await
247 {
248 Ok(_) => {}
249 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
250 return Err(Error::new(
251 anyhow!(
252 "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
253 update_path
254 ),
255 Default::default(),
256 ));
257 }
258 Err(err) => {
259 return Err(Error::new(
260 anyhow::Error::new(err).context(format!(
261 "failed reading metadata from update {:?}",
262 update_path
263 )),
264 Default::default(),
265 ));
266 }
267 }
268 }
269 }
270 let src_operand = crate::walk::split_root_operand(src)
274 .await
275 .map_err(|err| Error::new(err, Default::default()))?;
276 let src = src_operand.display.as_path();
277 let src_name = src_operand.name.as_os_str();
278 let strict_src_parent: Option<Arc<Dir>> = if crate::safedir::strict_operand_resolution() {
285 let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
286 .await
287 .with_context(|| {
288 format!(
289 "cannot open source parent directory {:?}",
290 src_operand.parent
291 )
292 })
293 .map_err(|err| Error::new(err, Default::default()))?;
294 Some(Arc::new(parent.into_tree()))
296 } else {
297 None
298 };
299 let dst_parent_path_opt: Option<&std::path::Path> = match (dst.parent(), dst.file_name()) {
304 (Some(parent), Some(_)) if parent.as_os_str().is_empty() => Some(std::path::Path::new(".")),
305 (Some(parent), Some(_)) => Some(parent),
306 _ => None,
307 };
308 let strict_dst_parent: Option<Arc<Dir>> = match (
314 crate::safedir::strict_operand_resolution(),
315 dst_parent_path_opt,
316 ) {
317 (true, Some(dst_parent_path)) => {
318 match Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination).await {
319 Ok(parent) => Some(Arc::new(parent.into_tree())),
320 Err(err)
321 if err.kind() == std::io::ErrorKind::NotFound
322 || err.raw_os_error() == Some(libc::ENOTDIR) =>
323 {
324 None
325 }
326 Err(err) => {
327 return Err(Error::new(
328 anyhow::Error::new(err).context(format!(
329 "cannot open destination parent directory {dst_parent_path:?}"
330 )),
331 Default::default(),
332 ));
333 }
334 }
335 }
336 _ => None,
338 };
339 if let Some(ref filter) = settings.filter {
341 let (kind, is_dir) = match &strict_src_parent {
342 Some(parent) => {
344 let root_handle = parent
345 .child(src_name)
346 .await
347 .with_context(|| format!("failed reading metadata from {:?}", &src))
348 .map_err(|err| Error::new(err, Default::default()))?;
349 (root_handle.kind(), root_handle.kind() == EntryKind::Dir)
350 }
351 None => {
352 let src_metadata = crate::walk::run_metadata_probed(
353 congestion::Side::Source,
354 congestion::MetadataOp::Stat,
355 tokio::fs::symlink_metadata(src),
356 )
357 .await
358 .with_context(|| format!("failed reading metadata from {:?}", &src))
359 .map_err(|err| Error::new(err, Default::default()))?;
360 (
361 EntryKind::from_metadata(&src_metadata),
362 src_metadata.is_dir(),
363 )
364 }
365 };
366 let result = filter.should_include_root_item(std::path::Path::new(src_name), is_dir);
367 match result {
368 crate::filter::FilterResult::Included => {}
369 result => {
370 if let Some(mode) = settings.dry_run {
371 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
372 }
373 kind.inc_skipped(prog_track);
374 return Ok(skipped_summary_for(kind));
375 }
376 }
377 }
378 let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
382 return Err(Error::new(
383 anyhow!(
384 "link destination {:?} has no parent directory or file name",
385 dst
386 ),
387 Default::default(),
388 ));
389 };
390 let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
391 std::path::Path::new(".")
392 } else {
393 dst_parent_path
394 };
395 let src_parent = match strict_src_parent {
401 Some(parent) => parent,
402 None => {
403 let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
404 .await
405 .with_context(|| {
406 format!(
407 "cannot open source parent directory {:?}",
408 src_operand.parent
409 )
410 })
411 .map_err(|err| Error::new(err, Default::default()))?;
412 Arc::new(parent.into_tree())
414 }
415 };
416 let dst_parent = if settings.dry_run.is_some() {
422 None
423 } else {
424 match strict_dst_parent {
425 Some(parent) => Some(parent),
426 None => {
427 let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
428 .await
429 .with_context(|| {
430 format!(
431 "cannot open destination parent directory {:?}",
432 dst_parent_path
433 )
434 })
435 .map_err(|err| Error::new(err, Default::default()))?;
436 Some(Arc::new(dir.into_tree()))
438 }
439 }
440 };
441 let update_parent = match update.as_ref() {
454 Some(update_path) => {
455 let update_operand = crate::walk::split_root_operand(update_path)
461 .await
462 .map_err(|err| Error::new(err, Default::default()))?;
463 let update_parent_path = update_operand.parent;
464 let update_name = update_operand.name;
465 let fallback_eligible =
468 !settings.update_exclusive && settings.copy_settings.delete.is_none();
469 match Dir::open_parent_dir(&update_parent_path, congestion::Side::Source).await {
470 Ok(dir) => Some((Arc::new(dir.into_tree()), update_name)),
472 Err(err)
473 if fallback_eligible
474 && (matches!(
475 err.kind(),
476 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
477 ) || err.raw_os_error() == Some(libc::ENOTDIR)) =>
478 {
479 tracing::debug!(
483 "update parent {:?} not found ({:#}); falling back to no-update mode",
484 update_parent_path,
485 err
486 );
487 None
488 }
489 Err(err) => {
490 return Err(Error::new(
491 anyhow::Error::new(err).context(format!(
492 "cannot open update parent directory {:?}",
493 update_parent_path
494 )),
495 Default::default(),
496 ));
497 }
498 }
499 }
500 None => None,
501 };
502 let update_ref = update_parent
503 .as_ref()
504 .map(|(dir, name)| (dir, name.as_os_str()));
505 link_internal(
506 prog_track,
507 &src_parent,
508 update_ref,
509 dst_parent.as_ref(),
510 src_name,
511 src,
512 dst,
513 update.as_deref(),
514 std::path::Path::new(""),
515 settings,
516 is_fresh,
517 None,
518 )
519 .await
520}
521struct DeleteKeepSet {
529 inner: Option<std::collections::HashSet<std::ffi::OsString>>,
530 src_records_disabled: bool,
533}
534
535impl DeleteKeepSet {
536 fn new(
537 delete: Option<©::DeleteSettings>,
538 update_exclusive: bool,
539 update_present: bool,
540 ) -> Self {
541 Self {
542 inner: delete.is_some().then(std::collections::HashSet::new),
543 src_records_disabled: update_exclusive && update_present,
544 }
545 }
546 fn record_src(&mut self, name: &std::ffi::OsStr) {
549 if let Some(set) = &mut self.inner
550 && !self.src_records_disabled
551 {
552 set.insert(name.to_owned());
553 }
554 }
555 fn record_update(&mut self, name: &std::ffi::OsStr) {
557 if let Some(set) = &mut self.inner {
558 set.insert(name.to_owned());
559 }
560 }
561 fn as_set(&self) -> Option<&std::collections::HashSet<std::ffi::OsString>> {
564 self.inner.as_ref()
565 }
566}
567
568#[instrument(skip(prog_track, src_parent, update, dst_parent, settings, permit))]
592#[async_recursion]
593#[allow(clippy::too_many_arguments)]
594async fn link_internal(
595 prog_track: &'static progress::Progress,
596 src_parent: &Arc<Dir>,
597 update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
598 dst_parent: Option<&Arc<Dir>>,
599 name: &std::ffi::OsStr,
600 src_root: &std::path::Path,
601 dst_root: &std::path::Path,
602 update_root: Option<&std::path::Path>,
603 rel_path: &std::path::Path,
604 settings: &Settings,
605 is_fresh: bool,
606 permit: Option<LeafPermit>,
607) -> Result<Summary, Error> {
608 let _prog_guard = prog_track.ops.guard();
609 let (src_path, dst_path) = if rel_path.as_os_str().is_empty() {
614 (src_root.to_path_buf(), dst_root.to_path_buf())
615 } else {
616 (src_root.join(rel_path), dst_root.join(rel_path))
617 };
618 let update_path = update_root.map(|root| {
619 if rel_path.as_os_str().is_empty() {
620 root.to_path_buf()
621 } else {
622 root.join(rel_path)
623 }
624 });
625 let dst_name = dst_path
629 .file_name()
630 .ok_or_else(|| {
631 Error::new(
632 anyhow!("link destination {:?} has no file name", &dst_path),
633 Default::default(),
634 )
635 })?
636 .to_owned();
637 tracing::debug!("classifying source entry");
638 let src_handle = src_parent
639 .child(name)
640 .await
641 .with_context(|| format!("failed reading metadata from {:?}", &src_path))
642 .map_err(|err| Error::new(err, Default::default()))?;
643 let mut update_handle = match update {
647 Some((update_dir, update_name)) => {
648 tracing::debug!("classifying 'update' entry");
649 match update_dir.child(update_name).await {
650 Ok(handle) => Some(handle),
651 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
652 if settings.update_exclusive {
653 return Ok(Default::default());
655 }
656 None
657 }
658 Err(error) => {
659 return Err(Error::new(
660 anyhow::Error::new(error)
661 .context(format!("failed reading metadata from {:?}", &update_path)),
662 Default::default(),
663 ));
664 }
665 }
666 }
667 None => None,
668 };
669 if let Some(handle) = update_handle.as_ref() {
680 let update_is_dir = handle.kind() == EntryKind::Dir;
681 let update_excluded = match settings.filter.as_ref() {
689 Some(filter) if rel_path.as_os_str().is_empty() => {
690 let (_, update_name) =
691 update.expect("update_handle is Some only when update is Some");
692 !matches!(
693 filter.should_include_root_item(
694 std::path::Path::new(update_name),
695 update_is_dir,
696 ),
697 crate::filter::FilterResult::Included
698 )
699 }
700 _ => walk::should_skip_entry(&settings.filter, rel_path, update_is_dir).is_some(),
701 };
702 if update_excluded {
703 if settings.update_exclusive {
704 tracing::debug!(
710 "update entry {:?} is filtered out under --update-exclusive; materializing nothing",
711 update_path
712 );
713 return Ok(Default::default());
714 }
715 tracing::debug!(
721 "update entry {:?} is filtered out; falling back to source-only handling",
722 update_path
723 );
724 update_handle = None;
725 }
726 }
727 let is_file_changed_copy = match update_handle.as_ref() {
746 Some(update_handle) => {
747 update_handle.kind() == src_handle.kind()
748 && update_handle.kind() == EntryKind::File
749 && !filecmp::metadata_equal(
750 &settings.update_compare,
751 src_handle.meta(),
752 update_handle.meta(),
753 )
754 }
755 None => false,
756 };
757 let copy_guard: Option<throttle::OpenFileGuard> = if is_file_changed_copy {
758 match permit {
760 Some(LeafPermit::OpenFile(guard)) => Some(guard),
761 _ => None,
764 }
765 } else {
766 drop(permit);
768 None
769 };
770 if let Some(update_handle) = update_handle.as_ref() {
771 let (update_dir, update_name) = update.unwrap();
772 let update_path = update_path.as_deref().unwrap();
773 if update_handle.kind() != src_handle.kind() {
774 tracing::debug!(
776 "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
777 src_path,
778 src_handle.kind(),
779 update_path,
780 update_handle.kind()
781 );
782 return delegate_copy(
788 prog_track,
789 update_dir,
790 dst_parent,
791 update_name,
792 update_path,
793 &dst_path,
794 rel_path,
795 settings,
796 is_fresh,
797 None,
798 )
799 .await;
800 }
801 if update_handle.kind() == EntryKind::File {
802 if filecmp::metadata_equal(
804 &settings.update_compare,
805 src_handle.meta(),
806 update_handle.meta(),
807 ) {
808 tracing::debug!("no change, hard link 'src'");
811 if settings.dry_run.is_some() {
812 crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
813 return Ok(Summary {
814 hard_links_created: 1,
815 ..Default::default()
816 });
817 }
818 let dst_dir =
819 dst_parent.expect("destination parent must be open for a real hard link");
820 return hard_link_entry_fd(
821 prog_track,
822 &src_handle,
823 dst_dir,
824 &dst_name,
825 &dst_path,
826 settings,
827 )
828 .await;
829 }
830 tracing::debug!(
831 "link: {:?} metadata has changed, copying from {:?}",
832 src_path,
833 update_path
834 );
835 return delegate_copy(
840 prog_track,
841 update_dir,
842 dst_parent,
843 update_name,
844 update_path,
845 &dst_path,
846 rel_path,
847 settings,
848 is_fresh,
849 copy_guard,
850 )
851 .await;
852 }
853 if update_handle.kind() == EntryKind::Symlink {
854 tracing::debug!("'update' is a symlink so just symlink that");
856 return delegate_copy(
857 prog_track,
858 update_dir,
859 dst_parent,
860 update_name,
861 update_path,
862 &dst_path,
863 rel_path,
864 settings,
865 is_fresh,
866 None,
867 )
868 .await;
869 }
870 } else {
871 tracing::debug!("no 'update' entry");
876 if src_handle.kind() == EntryKind::File {
877 if settings.dry_run.is_some() {
878 crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
879 return Ok(Summary {
880 hard_links_created: 1,
881 ..Default::default()
882 });
883 }
884 let dst_dir = dst_parent.expect("destination parent must be open for a real hard link");
885 return hard_link_entry_fd(
886 prog_track,
887 &src_handle,
888 dst_dir,
889 &dst_name,
890 &dst_path,
891 settings,
892 )
893 .await;
894 }
895 if src_handle.kind() == EntryKind::Symlink {
896 tracing::debug!("'src' is a symlink so just symlink that");
897 return delegate_copy(
898 prog_track, src_parent, dst_parent, name, &src_path, &dst_path, rel_path, settings,
899 is_fresh, None,
900 )
901 .await;
902 }
903 }
904 if src_handle.kind() != EntryKind::Dir {
905 if settings.copy_settings.skip_specials {
907 tracing::debug!(
908 "skipping special file {:?} (kind: {:?})",
909 src_path,
910 src_handle.kind()
911 );
912 if let Some(mode) = settings.dry_run {
913 match mode {
914 crate::config::DryRunMode::Brief => {}
915 crate::config::DryRunMode::All => println!("skip special {:?}", src_path),
916 crate::config::DryRunMode::Explain => {
917 println!(
918 "skip special {:?} (unsupported file type: {:?})",
919 src_path,
920 src_handle.kind()
921 );
922 }
923 }
924 }
925 prog_track.specials_skipped.inc();
926 return Ok(Summary {
927 copy_summary: CopySummary {
928 specials_skipped: 1,
929 ..Default::default()
930 },
931 ..Default::default()
932 });
933 }
934 return Err(Error::new(
935 anyhow!(
936 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
937 src_path,
938 dst_path,
939 src_handle.kind()
940 ),
941 Default::default(),
942 ));
943 }
944 debug_assert!(
947 update_handle.is_none() || update_handle.as_ref().unwrap().kind() == EntryKind::Dir
948 );
949 let update_for_dir = update.filter(|_| update_handle.is_some());
955 let update_root_for_dir = update_root.filter(|_| update_handle.is_some());
956 link_dir_entry(
957 prog_track,
958 src_parent,
959 update_for_dir,
960 dst_parent,
961 name,
962 &dst_name,
963 src_root,
964 dst_root,
965 update_root_for_dir,
966 rel_path,
967 &src_path,
968 &dst_path,
969 update_path.as_deref().filter(|_| update_handle.is_some()),
970 settings,
971 is_fresh,
972 )
973 .await
974}
975
976#[allow(clippy::too_many_arguments)]
982async fn delegate_copy(
983 prog_track: &'static progress::Progress,
984 src_parent: &Arc<Dir>,
985 dst_parent: Option<&Arc<Dir>>,
986 name: &std::ffi::OsStr,
987 src_path: &std::path::Path,
988 dst_path: &std::path::Path,
989 filter_base: &std::path::Path,
990 settings: &Settings,
991 is_fresh: bool,
992 open_file_guard: Option<throttle::OpenFileGuard>,
993) -> Result<Summary, Error> {
994 let copy_summary = copy::copy_child(
995 prog_track,
996 src_parent,
997 dst_parent,
998 name,
999 src_path,
1000 dst_path,
1001 filter_base,
1002 &settings.copy_settings,
1003 &settings.preserve,
1004 is_fresh,
1005 open_file_guard,
1006 )
1007 .await
1008 .map_err(|err| {
1009 let copy_summary = err.summary;
1010 Error::new(
1011 err.source,
1012 Summary {
1013 copy_summary,
1014 ..Default::default()
1015 },
1016 )
1017 })?;
1018 Ok(Summary {
1019 copy_summary,
1020 ..Default::default()
1021 })
1022}
1023
1024#[allow(clippy::too_many_arguments)]
1028async fn link_dir_entry(
1029 prog_track: &'static progress::Progress,
1030 src_parent: &Arc<Dir>,
1031 update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
1032 dst_parent: Option<&Arc<Dir>>,
1033 name: &std::ffi::OsStr,
1034 dst_name: &std::ffi::OsStr,
1035 src_root: &std::path::Path,
1036 dst_root: &std::path::Path,
1037 update_root: Option<&std::path::Path>,
1038 rel_path: &std::path::Path,
1039 src_path: &std::path::Path,
1040 dst_path: &std::path::Path,
1041 update_path: Option<&std::path::Path>,
1042 settings: &Settings,
1043 is_fresh: bool,
1044) -> Result<Summary, Error> {
1045 let src_dir = src_parent
1046 .open_dir(name)
1047 .await
1048 .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1049 .map_err(|err| Error::new(err, Default::default()))?;
1050 let src_dir = Arc::new(src_dir);
1051 let update_dir = match update {
1053 Some((update_parent, update_name)) => {
1054 let dir = update_parent
1055 .open_dir(update_name)
1056 .await
1057 .with_context(|| {
1058 format!("cannot open update directory {:?} for reading", update_path)
1059 })
1060 .map_err(|err| Error::new(err, Default::default()))?;
1061 Some(Arc::new(dir))
1062 }
1063 None => None,
1064 };
1065 if settings.dry_run.is_some() {
1067 crate::dry_run::report_action("link", src_path, Some(dst_path), "dir");
1068 let base = Summary {
1069 copy_summary: CopySummary {
1070 directories_created: 1, ..Default::default()
1072 },
1073 ..Default::default()
1074 };
1075 return link_dir_contents(
1076 prog_track,
1077 &src_dir,
1078 update_dir.as_ref(),
1079 None, None, dst_name,
1082 src_root,
1083 dst_root,
1084 update_root,
1085 rel_path,
1086 src_path,
1087 dst_path,
1088 true, is_fresh,
1090 settings,
1091 base,
1092 )
1093 .await;
1094 }
1095 let dst_parent = dst_parent.expect("destination parent must be open for a real link");
1097 let copy::DirSlot {
1098 dir: dst_dir,
1099 summary: base,
1100 is_fresh: child_is_fresh,
1101 we_created,
1102 } = match copy::resolve_dst_dir(
1103 prog_track,
1104 dst_parent,
1105 dst_name,
1106 dst_path,
1107 &settings.copy_settings,
1108 is_fresh,
1109 )
1110 .await
1111 .map_err(|err| {
1112 Error::new(
1113 err.source,
1114 Summary {
1115 copy_summary: err.summary,
1116 ..Default::default()
1117 },
1118 )
1119 })? {
1120 copy::DirResolution::Skip(summary) => {
1121 return Ok(Summary {
1122 copy_summary: summary,
1123 ..Default::default()
1124 });
1125 }
1126 copy::DirResolution::Proceed(slot) => slot,
1127 };
1128 link_dir_contents(
1129 prog_track,
1130 &src_dir,
1131 update_dir.as_ref(),
1132 Some(&dst_dir),
1133 Some(dst_parent),
1134 dst_name,
1135 src_root,
1136 dst_root,
1137 update_root,
1138 rel_path,
1139 src_path,
1140 dst_path,
1141 we_created,
1142 child_is_fresh,
1143 settings,
1144 Summary {
1145 copy_summary: base,
1146 ..Default::default()
1147 },
1148 )
1149 .await
1150}
1151
1152#[allow(clippy::too_many_arguments)]
1160async fn link_dir_contents(
1161 prog_track: &'static progress::Progress,
1162 src_dir: &Arc<Dir>,
1163 update_dir: Option<&Arc<Dir>>,
1164 dst_dir: Option<&Arc<Dir>>,
1165 dst_parent: Option<&Arc<Dir>>,
1166 dst_name: &std::ffi::OsStr,
1167 src_root: &std::path::Path,
1168 dst_root: &std::path::Path,
1169 update_root: Option<&std::path::Path>,
1170 rel_path: &std::path::Path,
1171 src_path: &std::path::Path,
1172 dst_path: &std::path::Path,
1173 we_created_this_dir: bool,
1174 is_fresh: bool,
1175 settings: &Settings,
1176 base: Summary,
1177) -> Result<Summary, Error> {
1178 tracing::debug!("process contents of 'src' directory");
1179 let src_entries = src_dir
1180 .read_entries()
1181 .await
1182 .with_context(|| format!("cannot open directory {src_path:?} for reading"))
1183 .map_err(|err| Error::new(err, base))?;
1184 let mut link_summary = base;
1185 let mut join_set = tokio::task::JoinSet::new();
1186 let errors = crate::error_collector::ErrorCollector::default();
1187 let mut processed_files = std::collections::HashSet::new();
1189 let mut keep_set = DeleteKeepSet::new(
1194 settings.copy_settings.delete.as_ref(),
1195 settings.update_exclusive,
1196 update_dir.is_some(),
1197 );
1198 for (entry_name, hint) in src_entries {
1200 let entry_kind = hint.unwrap_or(EntryKind::File);
1205 let entry_is_symlink = entry_kind == EntryKind::Symlink;
1206 let entry_rel = rel_path.join(&entry_name);
1207 let entry_path = src_path.join(&entry_name);
1208 let entry_is_dir = walk::filter_is_dir(
1216 settings.filter.as_ref(),
1217 src_dir,
1218 &entry_name,
1219 hint,
1220 settings.dry_run.is_some(),
1221 )
1222 .await;
1223 if let Some(skip_result) =
1225 walk::should_skip_entry(&settings.filter, &entry_rel, entry_is_dir)
1226 {
1227 if let Some(mode) = settings.dry_run {
1228 crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
1229 }
1230 tracing::debug!("skipping {:?} due to filter", &entry_path);
1231 link_summary = link_summary + skipped_summary_for(entry_kind);
1232 entry_kind.inc_skipped(prog_track);
1233 continue;
1234 }
1235 keep_set.record_src(&entry_name);
1238 if settings.copy_settings.skip_specials && entry_kind == EntryKind::Special {
1240 tracing::debug!("skipping special file {:?}", &entry_path);
1241 if let Some(mode) = settings.dry_run {
1242 match mode {
1243 crate::config::DryRunMode::Brief => {}
1244 crate::config::DryRunMode::All => {
1245 println!("skip special {:?}", &entry_path)
1246 }
1247 crate::config::DryRunMode::Explain => {
1248 println!(
1249 "skip special {:?} (unsupported file type: {:?})",
1250 &entry_path, entry_kind
1251 );
1252 }
1253 }
1254 }
1255 link_summary.copy_summary.specials_skipped += 1;
1256 prog_track.specials_skipped.inc();
1257 continue;
1258 }
1259 processed_files.insert(entry_name.clone());
1260 if settings.dry_run.is_some() && !entry_is_dir {
1262 let dst_entry_path = dst_path.join(&entry_name);
1263 crate::dry_run::report_action(
1264 "link",
1265 &entry_path,
1266 Some(&dst_entry_path),
1267 entry_kind.label(),
1268 );
1269 if entry_is_symlink {
1270 link_summary.copy_summary.symlinks_created += 1;
1272 } else {
1273 link_summary.hard_links_created += 1;
1278 }
1279 continue;
1280 }
1281 let permit = walk::preacquire_leaf_permit(PermitKind::OpenFile, hint, |h| {
1295 h == Some(EntryKind::File)
1296 })
1297 .await;
1298 let src_parent = Arc::clone(src_dir);
1299 let dst_parent = dst_dir.map(Arc::clone);
1300 let update_parent = update_dir.map(Arc::clone);
1301 let settings = settings.clone();
1302 let src_root = src_root.to_owned();
1303 let dst_root = dst_root.to_owned();
1304 let update_root = update_root.map(std::path::Path::to_path_buf);
1305 let do_link = move || async move {
1306 let update_ref = update_parent
1307 .as_ref()
1308 .map(|dir| (dir, entry_name.as_os_str()));
1309 link_internal(
1310 prog_track,
1311 &src_parent,
1312 update_ref,
1313 dst_parent.as_ref(),
1314 &entry_name,
1315 &src_root,
1316 &dst_root,
1317 update_root.as_deref(),
1318 &entry_rel,
1319 &settings,
1320 is_fresh,
1321 permit,
1322 )
1323 .await
1324 };
1325 join_set.spawn(do_link());
1326 }
1327 if let Some(update_dir) = update_dir {
1329 let update_root = update_root.expect("update_dir present implies update_root present");
1330 tracing::debug!("process contents of 'update' directory");
1331 let update_entries = update_dir
1332 .read_entries()
1333 .await
1334 .with_context(|| {
1335 format!(
1336 "cannot open directory {:?} for reading",
1337 update_path_dbg(update_root, rel_path)
1338 )
1339 })
1340 .map_err(|err| Error::new(err, link_summary))?;
1341 for (entry_name, hint) in update_entries {
1355 let entry_kind = hint.unwrap_or(EntryKind::File);
1356 let entry_rel = rel_path.join(&entry_name);
1357 let entry_is_dir = walk::filter_is_dir(
1362 settings.filter.as_ref(),
1363 update_dir,
1364 &entry_name,
1365 hint,
1366 false,
1369 )
1370 .await;
1371 let skip_result = walk::should_skip_entry(&settings.filter, &entry_rel, entry_is_dir);
1376 let filtered_out = skip_result.is_some();
1377 if settings.copy_settings.delete.is_some() && !filtered_out {
1390 keep_set.record_update(&entry_name);
1391 }
1392 if processed_files.contains(&entry_name) {
1393 continue;
1395 }
1396 if let Some(skip_result) = skip_result {
1400 let update_entry_path = update_root.join(&entry_rel);
1401 if let Some(mode) = settings.dry_run {
1402 crate::dry_run::report_skip(
1403 &update_entry_path,
1404 &skip_result,
1405 mode,
1406 entry_kind.label(),
1407 );
1408 }
1409 tracing::debug!(
1410 "skipping update entry {:?} due to filter",
1411 &update_entry_path
1412 );
1413 link_summary = link_summary + skipped_summary_for(entry_kind);
1414 entry_kind.inc_skipped(prog_track);
1415 continue;
1416 }
1417 tracing::debug!("found a new entry in the 'update' directory");
1418 let update_entry_path = update_root.join(&entry_rel);
1419 let dst_entry_path = dst_path.join(&entry_name);
1420 let update_parent = Arc::clone(update_dir);
1421 let dst_parent = dst_dir.map(Arc::clone);
1422 let settings = settings.clone();
1423 let do_copy = move || async move {
1424 delegate_copy(
1428 prog_track,
1429 &update_parent,
1430 dst_parent.as_ref(),
1431 &entry_name,
1432 &update_entry_path,
1433 &dst_entry_path,
1434 &entry_rel,
1435 &settings,
1436 is_fresh,
1437 None,
1438 )
1439 .await
1440 };
1441 join_set.spawn(do_copy());
1442 }
1443 }
1444 while let Some(res) = join_set.join_next().await {
1445 match res {
1446 Ok(result) => match result {
1447 Ok(summary) => link_summary = link_summary + summary,
1448 Err(error) => {
1449 tracing::error!(
1450 "link: {:?} -> {:?} failed with: {:#}",
1451 src_path,
1452 dst_path,
1453 &error
1454 );
1455 link_summary = link_summary + error.summary;
1456 if settings.copy_settings.fail_early {
1457 return Err(Error::new(error.source, link_summary));
1458 }
1459 errors.push(error.source);
1460 }
1461 },
1462 Err(error) => {
1463 if settings.copy_settings.fail_early {
1464 return Err(Error::new(error.into(), link_summary));
1465 }
1466 errors.push(error.into());
1467 }
1468 }
1469 }
1470 if let Some(delete_settings) = &settings.copy_settings.delete {
1475 if errors.has_errors() {
1476 tracing::warn!(
1479 "skipping --delete pruning of {:?} because the link/update pass reported errors",
1480 dst_path
1481 );
1482 } else {
1483 let prune_dir: Option<Arc<Dir>> = match dst_dir {
1496 Some(dir) => Some(Arc::clone(dir)),
1497 None => {
1498 match Dir::open_root_dir(dst_path, false, congestion::Side::Destination).await {
1499 Ok(dir) => Some(Arc::new(dir)),
1500 Err(err)
1501 if matches!(
1502 err.kind(),
1503 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1504 ) || err.raw_os_error() == Some(libc::ELOOP)
1505 || err.raw_os_error() == Some(libc::ENOTDIR) =>
1506 {
1507 tracing::debug!(
1508 "skipping --delete pruning of {:?}: not a real directory",
1509 dst_path
1510 );
1511 None
1512 }
1513 Err(err) => {
1514 let err = anyhow::Error::new(err).context(format!(
1515 "cannot open destination {dst_path:?} for delete scan"
1516 ));
1517 if settings.copy_settings.fail_early {
1518 return Err(Error::new(err, link_summary));
1519 }
1520 errors.push(err);
1521 None
1522 }
1523 }
1524 }
1525 };
1526 if let Some(prune_dir) = prune_dir {
1527 match crate::delete::prune_extraneous(
1528 prog_track,
1529 &prune_dir,
1530 rel_path,
1531 keep_set
1532 .as_set()
1533 .expect("--delete is on, so DeleteKeepSet is active"),
1534 settings.filter.as_ref(),
1535 delete_settings,
1536 settings.copy_settings.fail_early,
1537 settings.dry_run,
1538 )
1539 .await
1540 {
1541 Ok(rm_summary) => {
1542 link_summary.copy_summary.rm_summary =
1543 link_summary.copy_summary.rm_summary + rm_summary;
1544 }
1545 Err(err) => {
1546 link_summary.copy_summary.rm_summary =
1547 link_summary.copy_summary.rm_summary + err.summary;
1548 if settings.copy_settings.fail_early {
1549 return Err(Error::new(err.source, link_summary));
1550 }
1551 errors.push(err.source);
1552 }
1553 }
1554 }
1555 }
1556 }
1557 let this_dir_count = usize::from(we_created_this_dir);
1560 let child_dirs_created = link_summary
1561 .copy_summary
1562 .directories_created
1563 .saturating_sub(this_dir_count);
1564 let anything_linked = link_summary.hard_links_created > 0
1565 || link_summary.copy_summary.files_copied > 0
1566 || link_summary.copy_summary.symlinks_created > 0
1567 || child_dirs_created > 0;
1568 let is_root = rel_path.as_os_str().is_empty();
1569 match check_empty_dir_cleanup(
1570 settings.filter.as_ref(),
1571 we_created_this_dir,
1572 anything_linked,
1573 rel_path,
1574 is_root,
1575 settings.dry_run.is_some(),
1576 ) {
1577 EmptyDirAction::Keep => { }
1578 EmptyDirAction::DryRunSkip => {
1579 tracing::debug!(
1580 "dry-run: directory {:?} would not be created (nothing to link inside)",
1581 dst_path
1582 );
1583 link_summary.copy_summary.directories_created = 0;
1584 if errors.has_errors() {
1588 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1589 }
1590 return Ok(link_summary);
1591 }
1592 EmptyDirAction::Remove => {
1593 tracing::debug!(
1594 "directory {:?} has nothing to link inside, removing empty directory",
1595 dst_path
1596 );
1597 let rmdir_result = match dst_parent {
1602 Some(dst_parent) => dst_parent.rmdir_at(dst_name).await,
1603 None => {
1604 crate::walk::run_metadata_probed(
1605 congestion::Side::Destination,
1606 congestion::MetadataOp::RmDir,
1607 tokio::fs::remove_dir(dst_path),
1608 )
1609 .await
1610 }
1611 };
1612 match rmdir_result {
1613 Ok(()) => {
1614 link_summary.copy_summary.directories_created = 0;
1615 if errors.has_errors() {
1619 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1620 }
1621 return Ok(link_summary);
1622 }
1623 Err(err) => {
1624 tracing::debug!(
1626 "failed to remove empty directory {:?}: {:#}, keeping",
1627 dst_path,
1628 &err
1629 );
1630 }
1632 }
1633 }
1634 }
1635 tracing::debug!("set 'dst' directory metadata");
1642 let metadata_result = match dst_dir {
1643 Some(dst_dir) => {
1644 let meta_dir = update_dir.unwrap_or(src_dir);
1645 match meta_dir.meta().await {
1646 Ok(preserve_meta) => {
1647 crate::safedir::set_dir_metadata_fd(&settings.preserve, &preserve_meta, dst_dir)
1648 .await
1649 }
1650 Err(e) => Err(e),
1651 }
1652 }
1653 None => Ok(()),
1654 };
1655 if errors.has_errors() {
1656 if let Err(metadata_err) = metadata_result {
1658 tracing::error!(
1659 "link: {:?} -> {:?} failed to set directory metadata: {:#}",
1660 src_path,
1661 dst_path,
1662 &metadata_err
1663 );
1664 }
1665 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1667 }
1668 metadata_result
1670 .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
1671 .map_err(|err| Error::new(err, link_summary))?;
1672 Ok(link_summary)
1673}
1674
1675fn update_path_dbg(
1677 update_root: &std::path::Path,
1678 rel_path: &std::path::Path,
1679) -> std::path::PathBuf {
1680 if rel_path.as_os_str().is_empty() {
1681 update_root.to_path_buf()
1682 } else {
1683 update_root.join(rel_path)
1684 }
1685}
1686
1687#[cfg(test)]
1688mod link_tests {
1689 use crate::rm;
1690 use crate::testutils;
1691 use std::os::unix::fs::PermissionsExt;
1692 use tracing_test::traced_test;
1693
1694 use super::*;
1695
1696 static PROGRESS: std::sync::LazyLock<progress::Progress> =
1697 std::sync::LazyLock::new(progress::Progress::new);
1698
1699 mod delete_keep_set_tests {
1700 use super::super::DeleteKeepSet;
1704 use crate::copy::DeleteSettings;
1705 use std::ffi::{OsStr, OsString};
1706
1707 fn delete_on() -> DeleteSettings {
1708 DeleteSettings {
1709 delete_excluded: false,
1710 }
1711 }
1712
1713 #[test]
1714 fn record_src_no_op_when_delete_off() {
1715 let mut k = DeleteKeepSet::new(None, false, false);
1716 k.record_src(OsStr::new("foo"));
1717 assert!(k.as_set().is_none());
1718 }
1719
1720 #[test]
1721 fn record_src_no_op_under_update_exclusive_with_update() {
1722 let d = delete_on();
1725 let mut k = DeleteKeepSet::new(Some(&d), true, true);
1726 k.record_src(OsStr::new("src_only"));
1727 assert!(!k.as_set().unwrap().contains(OsStr::new("src_only")));
1728 }
1729
1730 #[test]
1731 fn record_src_records_when_update_exclusive_without_update() {
1732 let d = delete_on();
1735 let mut k = DeleteKeepSet::new(Some(&d), true, false);
1736 k.record_src(OsStr::new("foo"));
1737 assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1738 }
1739
1740 #[test]
1741 fn record_src_records_in_normal_delete_mode() {
1742 let d = delete_on();
1743 let mut k = DeleteKeepSet::new(Some(&d), false, false);
1744 k.record_src(OsStr::new("foo"));
1745 assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1746 }
1747
1748 #[test]
1749 fn record_update_always_records_when_delete_on() {
1750 let d = delete_on();
1753 let mut k = DeleteKeepSet::new(Some(&d), true, true);
1754 k.record_update(OsStr::new("from_update"));
1755 assert!(k.as_set().unwrap().contains(OsStr::new("from_update")));
1756 }
1757
1758 #[test]
1759 fn record_update_no_op_when_delete_off() {
1760 let mut k = DeleteKeepSet::new(None, false, false);
1761 k.record_update(OsStr::new("from_update"));
1762 assert!(k.as_set().is_none());
1763 }
1764
1765 #[test]
1766 fn filtered_out_update_keeps_materialized_src_entry_in_normal_mode() {
1767 let d = delete_on();
1774 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1775 k.record_src(OsStr::new("node"));
1776 assert!(
1778 k.as_set().unwrap().contains(OsStr::new("node")),
1779 "src entry must stay in the keep-set when its update counterpart is filtered out"
1780 );
1781 }
1782
1783 #[test]
1784 fn filtered_out_update_keeps_skipped_special() {
1785 let d = delete_on();
1790 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1791 k.record_src(OsStr::new("pipe"));
1792 assert!(k.as_set().unwrap().contains(OsStr::new("pipe")));
1793 }
1794
1795 #[test]
1796 fn full_directory_pass_keep_set_union_semantics() {
1797 let d = delete_on();
1806 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1807
1808 k.record_src(OsStr::new("keep"));
1810 k.record_src(OsStr::new("pipe")); k.record_src(OsStr::new("node"));
1812
1813 k.record_update(OsStr::new("from_upd"));
1816
1817 let set: std::collections::HashSet<OsString> = k.as_set().unwrap().clone();
1818 let expected: std::collections::HashSet<OsString> =
1819 ["keep", "pipe", "node", "from_upd"]
1820 .into_iter()
1821 .map(OsString::from)
1822 .collect();
1823 assert_eq!(set, expected);
1824 }
1825 }
1826
1827 fn common_settings(dereference: bool, overwrite: bool) -> Settings {
1828 Settings {
1829 copy_settings: CopySettings {
1830 dereference,
1831 fail_early: false,
1832 overwrite,
1833 overwrite_compare: filecmp::MetadataCmpSettings {
1834 size: true,
1835 mtime: true,
1836 ..Default::default()
1837 },
1838 overwrite_filter: None,
1839 ignore_existing: false,
1840 chunk_size: 0,
1841 skip_specials: false,
1842 remote_copy_buffer_size: 0,
1843 filter: None,
1844 dry_run: None,
1845 delete: None,
1846 },
1847 update_compare: filecmp::MetadataCmpSettings {
1848 size: true,
1849 mtime: true,
1850 ..Default::default()
1851 },
1852 update_exclusive: false,
1853 filter: None,
1854 dry_run: None,
1855 preserve: preserve::preserve_all(),
1856 }
1857 }
1858
1859 #[tokio::test]
1860 #[traced_test]
1861 async fn test_basic_link() -> Result<(), anyhow::Error> {
1862 let tmp_dir = testutils::setup_test_dir().await?;
1863 let test_path = tmp_dir.as_path();
1864 let summary = link(
1865 &PROGRESS,
1866 test_path,
1867 &test_path.join("foo"),
1868 &test_path.join("bar"),
1869 &None,
1870 &common_settings(false, false),
1871 false,
1872 )
1873 .await?;
1874 assert_eq!(summary.hard_links_created, 5);
1875 assert_eq!(summary.copy_summary.files_copied, 0);
1876 assert_eq!(summary.copy_summary.symlinks_created, 2);
1877 assert_eq!(summary.copy_summary.directories_created, 3);
1878 testutils::check_dirs_identical(
1879 &test_path.join("foo"),
1880 &test_path.join("bar"),
1881 testutils::FileEqualityCheck::Timestamp,
1882 )
1883 .await?;
1884 Ok(())
1885 }
1886
1887 #[tokio::test]
1891 async fn links_dot_dot_source_operand() -> Result<(), anyhow::Error> {
1892 use std::os::unix::fs::MetadataExt;
1893 let tmp = testutils::create_temp_dir().await?;
1894 let tree = tmp.join("tree");
1895 tokio::fs::create_dir(&tree).await?;
1896 tokio::fs::write(tree.join("a.txt"), "hello").await?;
1897 tokio::fs::create_dir(tree.join("sub")).await?;
1898 let src = tree.join("sub").join(".."); let dst = tmp.join("dst");
1900 let summary = link(
1901 &PROGRESS,
1902 &tmp,
1903 &src,
1904 &dst,
1905 &None,
1906 &common_settings(false, false),
1907 false,
1908 )
1909 .await?;
1910 assert_eq!(
1911 summary.hard_links_created, 1,
1912 "the dot-dot source's file must be hard-linked"
1913 );
1914 assert!(
1915 dst.join("sub").is_dir(),
1916 "the dot-dot source's subdir must be created"
1917 );
1918 let src_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
1920 let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
1921 assert_eq!(src_ino, dst_ino, "dst must be a hard link to the src inode");
1922 Ok(())
1923 }
1924
1925 #[tokio::test]
1931 async fn links_dot_dot_update_operand() -> Result<(), anyhow::Error> {
1932 use std::os::unix::fs::MetadataExt;
1933 let tmp = testutils::create_temp_dir().await?;
1934 let tree = tmp.join("tree");
1935 tokio::fs::create_dir(&tree).await?;
1936 tokio::fs::write(tree.join("a.txt"), "hello").await?;
1937 tokio::fs::create_dir(tree.join("sub")).await?;
1938 let dst = tmp.join("dst");
1939 let update_operand = tree.join("sub").join(".."); let summary = link(
1943 &PROGRESS,
1944 &tmp,
1945 &tree,
1946 &dst,
1947 &Some(update_operand),
1948 &common_settings(false, false),
1949 false,
1950 )
1951 .await?;
1952 assert_eq!(
1953 summary.hard_links_created, 1,
1954 "the file must be hard-linked from the dot-dot update tree"
1955 );
1956 let update_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
1958 let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
1959 assert_eq!(
1960 update_ino, dst_ino,
1961 "dst must be hard-linked from the update tree inode"
1962 );
1963 Ok(())
1964 }
1965
1966 #[tokio::test]
1967 #[traced_test]
1968 async fn test_basic_link_update() -> Result<(), anyhow::Error> {
1969 let tmp_dir = testutils::setup_test_dir().await?;
1970 let test_path = tmp_dir.as_path();
1971 let summary = link(
1972 &PROGRESS,
1973 test_path,
1974 &test_path.join("foo"),
1975 &test_path.join("bar"),
1976 &Some(test_path.join("foo")),
1977 &common_settings(false, false),
1978 false,
1979 )
1980 .await?;
1981 assert_eq!(summary.hard_links_created, 5);
1982 assert_eq!(summary.copy_summary.files_copied, 0);
1983 assert_eq!(summary.copy_summary.symlinks_created, 2);
1984 assert_eq!(summary.copy_summary.directories_created, 3);
1985 testutils::check_dirs_identical(
1986 &test_path.join("foo"),
1987 &test_path.join("bar"),
1988 testutils::FileEqualityCheck::Timestamp,
1989 )
1990 .await?;
1991 Ok(())
1992 }
1993
1994 #[tokio::test]
1995 #[traced_test]
1996 async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
1997 let tmp_dir = testutils::setup_test_dir().await?;
1998 tokio::fs::create_dir(tmp_dir.join("baz")).await?;
1999 let test_path = tmp_dir.as_path();
2000 let summary = link(
2001 &PROGRESS,
2002 test_path,
2003 &test_path.join("baz"), &test_path.join("bar"),
2005 &Some(test_path.join("foo")),
2006 &common_settings(false, false),
2007 false,
2008 )
2009 .await?;
2010 assert_eq!(summary.hard_links_created, 0);
2011 assert_eq!(summary.copy_summary.files_copied, 5);
2012 assert_eq!(summary.copy_summary.symlinks_created, 2);
2013 assert_eq!(summary.copy_summary.directories_created, 3);
2014 testutils::check_dirs_identical(
2015 &test_path.join("foo"),
2016 &test_path.join("bar"),
2017 testutils::FileEqualityCheck::Timestamp,
2018 )
2019 .await?;
2020 Ok(())
2021 }
2022
2023 #[tokio::test]
2024 #[traced_test]
2025 async fn test_link_destination_permission_error_includes_root_cause()
2026 -> Result<(), anyhow::Error> {
2027 let tmp_dir = testutils::setup_test_dir().await?;
2028 let test_path = tmp_dir.as_path();
2029 let readonly_parent = test_path.join("readonly_dest");
2030 tokio::fs::create_dir(&readonly_parent).await?;
2031 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
2032 .await?;
2033
2034 let mut settings = common_settings(false, false);
2035 settings.copy_settings.fail_early = true;
2036
2037 let result = link(
2038 &PROGRESS,
2039 test_path,
2040 &test_path.join("foo"),
2041 &readonly_parent.join("bar"),
2042 &None,
2043 &settings,
2044 false,
2045 )
2046 .await;
2047
2048 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
2050 .await?;
2051
2052 assert!(result.is_err(), "link into read-only parent should fail");
2053 let err = result.unwrap_err();
2054 let err_msg = format!("{:#}", err.source);
2055 assert!(
2056 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
2057 "Error message must include permission denied text. Got: {}",
2058 err_msg
2059 );
2060 Ok(())
2061 }
2062
2063 #[tokio::test]
2064 #[traced_test]
2065 async fn hard_link_file_into_readonly_parent_returns_error() -> Result<(), anyhow::Error> {
2066 let tmp_dir = testutils::setup_test_dir().await?;
2069 let src = tmp_dir.join("src.txt");
2070 tokio::fs::write(&src, "content").await?;
2071 let readonly_parent = tmp_dir.join("readonly_parent");
2072 tokio::fs::create_dir(&readonly_parent).await?;
2073 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
2074 .await?;
2075 let dst = readonly_parent.join("dst.txt");
2076 let settings = common_settings(false, false);
2077 let result = link(&PROGRESS, &tmp_dir, &src, &dst, &None, &settings, false).await;
2078 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
2079 .await?;
2080 let err = result.expect_err("link into read-only parent should fail");
2081 assert_eq!(err.summary.hard_links_created, 0);
2082 let err_msg = format!("{:#}", err.source);
2083 assert!(
2084 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
2085 "error should include root cause, got: {err_msg}"
2086 );
2087 Ok(())
2088 }
2089
2090 pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
2091 let foo_path = tmp_dir.join("update");
2097 tokio::fs::create_dir(&foo_path).await.unwrap();
2098 tokio::fs::write(foo_path.join("0.txt"), "0-new")
2099 .await
2100 .unwrap();
2101 let bar_path = foo_path.join("bar");
2102 tokio::fs::create_dir(&bar_path).await.unwrap();
2103 tokio::fs::write(bar_path.join("1.txt"), "1-new")
2104 .await
2105 .unwrap();
2106 tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
2107 .await
2108 .unwrap();
2109 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
2110 Ok(())
2111 }
2112
2113 #[tokio::test]
2114 #[traced_test]
2115 async fn test_link_update() -> Result<(), anyhow::Error> {
2116 let tmp_dir = testutils::setup_test_dir().await?;
2117 setup_update_dir(&tmp_dir).await?;
2118 let test_path = tmp_dir.as_path();
2119 let summary = link(
2120 &PROGRESS,
2121 test_path,
2122 &test_path.join("foo"),
2123 &test_path.join("bar"),
2124 &Some(test_path.join("update")),
2125 &common_settings(false, false),
2126 false,
2127 )
2128 .await?;
2129 assert_eq!(summary.hard_links_created, 2);
2130 assert_eq!(summary.copy_summary.files_copied, 2);
2131 assert_eq!(summary.copy_summary.symlinks_created, 3);
2132 assert_eq!(summary.copy_summary.directories_created, 3);
2133 testutils::check_dirs_identical(
2135 &test_path.join("foo").join("baz"),
2136 &test_path.join("bar").join("baz"),
2137 testutils::FileEqualityCheck::HardLink,
2138 )
2139 .await?;
2140 testutils::check_dirs_identical(
2142 &test_path.join("update"),
2143 &test_path.join("bar"),
2144 testutils::FileEqualityCheck::Timestamp,
2145 )
2146 .await?;
2147 Ok(())
2148 }
2149
2150 #[tokio::test]
2151 #[traced_test]
2152 async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
2153 let tmp_dir = testutils::setup_test_dir().await?;
2154 setup_update_dir(&tmp_dir).await?;
2155 let test_path = tmp_dir.as_path();
2156 let mut settings = common_settings(false, false);
2157 settings.update_exclusive = true;
2158 let summary = link(
2159 &PROGRESS,
2160 test_path,
2161 &test_path.join("foo"),
2162 &test_path.join("bar"),
2163 &Some(test_path.join("update")),
2164 &settings,
2165 false,
2166 )
2167 .await?;
2168 assert_eq!(summary.hard_links_created, 0);
2174 assert_eq!(summary.copy_summary.files_copied, 2);
2175 assert_eq!(summary.copy_summary.symlinks_created, 1);
2176 assert_eq!(summary.copy_summary.directories_created, 2);
2177 testutils::check_dirs_identical(
2179 &test_path.join("update"),
2180 &test_path.join("bar"),
2181 testutils::FileEqualityCheck::Timestamp,
2182 )
2183 .await?;
2184 Ok(())
2185 }
2186
2187 async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
2188 let tmp_dir = testutils::setup_test_dir().await?;
2189 let test_path = tmp_dir.as_path();
2190 let summary = link(
2191 &PROGRESS,
2192 test_path,
2193 &test_path.join("foo"),
2194 &test_path.join("bar"),
2195 &None,
2196 &common_settings(false, false),
2197 false,
2198 )
2199 .await?;
2200 assert_eq!(summary.hard_links_created, 5);
2201 assert_eq!(summary.copy_summary.symlinks_created, 2);
2202 assert_eq!(summary.copy_summary.directories_created, 3);
2203 Ok(tmp_dir)
2204 }
2205
2206 #[tokio::test]
2207 #[traced_test]
2208 async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
2209 let tmp_dir = setup_test_dir_and_link().await?;
2210 let output_path = &tmp_dir.join("bar");
2211 {
2212 let summary = rm::rm(
2223 &PROGRESS,
2224 &output_path.join("bar"),
2225 &rm::Settings {
2226 fail_early: false,
2227 filter: None,
2228 dry_run: None,
2229 time_filter: None,
2230 },
2231 )
2232 .await?
2233 + rm::rm(
2234 &PROGRESS,
2235 &output_path.join("baz").join("5.txt"),
2236 &rm::Settings {
2237 fail_early: false,
2238 filter: None,
2239 dry_run: None,
2240 time_filter: None,
2241 },
2242 )
2243 .await?;
2244 assert_eq!(summary.files_removed, 3);
2245 assert_eq!(summary.symlinks_removed, 1);
2246 assert_eq!(summary.directories_removed, 1);
2247 }
2248 let summary = link(
2249 &PROGRESS,
2250 &tmp_dir,
2251 &tmp_dir.join("foo"),
2252 output_path,
2253 &None,
2254 &common_settings(false, true), false,
2256 )
2257 .await?;
2258 assert_eq!(summary.hard_links_created, 3);
2259 assert_eq!(summary.copy_summary.symlinks_created, 1);
2260 assert_eq!(summary.copy_summary.directories_created, 1);
2261 testutils::check_dirs_identical(
2262 &tmp_dir.join("foo"),
2263 output_path,
2264 testutils::FileEqualityCheck::Timestamp,
2265 )
2266 .await?;
2267 Ok(())
2268 }
2269
2270 #[tokio::test]
2271 #[traced_test]
2272 async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
2273 let tmp_dir = setup_test_dir_and_link().await?;
2274 let output_path = &tmp_dir.join("bar");
2275 {
2276 let summary = rm::rm(
2287 &PROGRESS,
2288 &output_path.join("bar"),
2289 &rm::Settings {
2290 fail_early: false,
2291 filter: None,
2292 dry_run: None,
2293 time_filter: None,
2294 },
2295 )
2296 .await?
2297 + rm::rm(
2298 &PROGRESS,
2299 &output_path.join("baz").join("5.txt"),
2300 &rm::Settings {
2301 fail_early: false,
2302 filter: None,
2303 dry_run: None,
2304 time_filter: None,
2305 },
2306 )
2307 .await?;
2308 assert_eq!(summary.files_removed, 3);
2309 assert_eq!(summary.symlinks_removed, 1);
2310 assert_eq!(summary.directories_removed, 1);
2311 }
2312 setup_update_dir(&tmp_dir).await?;
2313 let summary = link(
2319 &PROGRESS,
2320 &tmp_dir,
2321 &tmp_dir.join("foo"),
2322 output_path,
2323 &Some(tmp_dir.join("update")),
2324 &common_settings(false, true), false,
2326 )
2327 .await?;
2328 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);
2332 testutils::check_dirs_identical(
2334 &tmp_dir.join("foo").join("baz"),
2335 &tmp_dir.join("bar").join("baz"),
2336 testutils::FileEqualityCheck::HardLink,
2337 )
2338 .await?;
2339 testutils::check_dirs_identical(
2341 &tmp_dir.join("update"),
2342 &tmp_dir.join("bar"),
2343 testutils::FileEqualityCheck::Timestamp,
2344 )
2345 .await?;
2346 Ok(())
2347 }
2348
2349 #[tokio::test]
2350 #[traced_test]
2351 async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
2352 let tmp_dir = setup_test_dir_and_link().await?;
2353 let output_path = &tmp_dir.join("bar");
2354 {
2355 let bar_path = output_path.join("bar");
2364 let summary = rm::rm(
2365 &PROGRESS,
2366 &bar_path.join("1.txt"),
2367 &rm::Settings {
2368 fail_early: false,
2369 filter: None,
2370 dry_run: None,
2371 time_filter: None,
2372 },
2373 )
2374 .await?
2375 + rm::rm(
2376 &PROGRESS,
2377 &bar_path.join("2.txt"),
2378 &rm::Settings {
2379 fail_early: false,
2380 filter: None,
2381 dry_run: None,
2382 time_filter: None,
2383 },
2384 )
2385 .await?
2386 + rm::rm(
2387 &PROGRESS,
2388 &bar_path.join("3.txt"),
2389 &rm::Settings {
2390 fail_early: false,
2391 filter: None,
2392 dry_run: None,
2393 time_filter: None,
2394 },
2395 )
2396 .await?
2397 + rm::rm(
2398 &PROGRESS,
2399 &output_path.join("baz"),
2400 &rm::Settings {
2401 fail_early: false,
2402 filter: None,
2403 dry_run: None,
2404 time_filter: None,
2405 },
2406 )
2407 .await?;
2408 assert_eq!(summary.files_removed, 4);
2409 assert_eq!(summary.symlinks_removed, 2);
2410 assert_eq!(summary.directories_removed, 1);
2411 tokio::fs::write(bar_path.join("1.txt"), "1-new")
2413 .await
2414 .unwrap();
2415 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
2416 .await
2417 .unwrap();
2418 tokio::fs::create_dir(&bar_path.join("3.txt"))
2419 .await
2420 .unwrap();
2421 tokio::fs::write(&output_path.join("baz"), "baz")
2422 .await
2423 .unwrap();
2424 }
2425 let summary = link(
2426 &PROGRESS,
2427 &tmp_dir,
2428 &tmp_dir.join("foo"),
2429 output_path,
2430 &None,
2431 &common_settings(false, true), false,
2433 )
2434 .await?;
2435 assert_eq!(summary.hard_links_created, 4);
2436 assert_eq!(summary.copy_summary.files_copied, 0);
2437 assert_eq!(summary.copy_summary.symlinks_created, 2);
2438 assert_eq!(summary.copy_summary.directories_created, 1);
2439 testutils::check_dirs_identical(
2440 &tmp_dir.join("foo"),
2441 &tmp_dir.join("bar"),
2442 testutils::FileEqualityCheck::HardLink,
2443 )
2444 .await?;
2445 Ok(())
2446 }
2447
2448 #[tokio::test]
2449 #[traced_test]
2450 async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
2451 let tmp_dir = setup_test_dir_and_link().await?;
2452 let output_path = &tmp_dir.join("bar");
2453 {
2454 let bar_path = output_path.join("bar");
2463 let summary = rm::rm(
2464 &PROGRESS,
2465 &bar_path.join("1.txt"),
2466 &rm::Settings {
2467 fail_early: false,
2468 filter: None,
2469 dry_run: None,
2470 time_filter: None,
2471 },
2472 )
2473 .await?
2474 + rm::rm(
2475 &PROGRESS,
2476 &bar_path.join("2.txt"),
2477 &rm::Settings {
2478 fail_early: false,
2479 filter: None,
2480 dry_run: None,
2481 time_filter: None,
2482 },
2483 )
2484 .await?
2485 + rm::rm(
2486 &PROGRESS,
2487 &bar_path.join("3.txt"),
2488 &rm::Settings {
2489 fail_early: false,
2490 filter: None,
2491 dry_run: None,
2492 time_filter: None,
2493 },
2494 )
2495 .await?
2496 + rm::rm(
2497 &PROGRESS,
2498 &output_path.join("baz"),
2499 &rm::Settings {
2500 fail_early: false,
2501 filter: None,
2502 dry_run: None,
2503 time_filter: None,
2504 },
2505 )
2506 .await?;
2507 assert_eq!(summary.files_removed, 4);
2508 assert_eq!(summary.symlinks_removed, 2);
2509 assert_eq!(summary.directories_removed, 1);
2510 tokio::fs::write(bar_path.join("1.txt"), "1-new")
2512 .await
2513 .unwrap();
2514 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
2515 .await
2516 .unwrap();
2517 tokio::fs::create_dir(&bar_path.join("3.txt"))
2518 .await
2519 .unwrap();
2520 tokio::fs::write(&output_path.join("baz"), "baz")
2521 .await
2522 .unwrap();
2523 }
2524 let source_path = &tmp_dir.join("foo");
2525 tokio::fs::set_permissions(
2527 &source_path.join("baz"),
2528 std::fs::Permissions::from_mode(0o000),
2529 )
2530 .await?;
2531 match link(
2535 &PROGRESS,
2536 &tmp_dir,
2537 &tmp_dir.join("foo"),
2538 output_path,
2539 &None,
2540 &common_settings(false, true), false,
2542 )
2543 .await
2544 {
2545 Ok(_) => panic!("Expected the link to error!"),
2546 Err(error) => {
2547 tracing::info!("{}", &error);
2548 assert_eq!(error.summary.hard_links_created, 3);
2549 assert_eq!(error.summary.copy_summary.files_copied, 0);
2550 assert_eq!(error.summary.copy_summary.symlinks_created, 0);
2551 assert_eq!(error.summary.copy_summary.directories_created, 0);
2552 assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
2553 assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
2554 assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
2555 }
2556 }
2557 Ok(())
2558 }
2559
2560 #[tokio::test]
2564 #[traced_test]
2565 async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
2566 let tmp_dir = testutils::create_temp_dir().await?;
2567 let test_path = tmp_dir.as_path();
2568 let src_dir = test_path.join("src");
2570 tokio::fs::create_dir(&src_dir).await?;
2571 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
2572 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
2574 let unreadable_subdir = src_dir.join("unreadable_subdir");
2577 tokio::fs::create_dir(&unreadable_subdir).await?;
2578 tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
2579 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
2580 .await?;
2581 let dst_dir = test_path.join("dst");
2582 let result = link(
2584 &PROGRESS,
2585 test_path,
2586 &src_dir,
2587 &dst_dir,
2588 &None,
2589 &common_settings(false, false),
2590 false,
2591 )
2592 .await;
2593 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
2595 .await?;
2596 assert!(
2598 result.is_err(),
2599 "link should fail due to unreadable subdirectory"
2600 );
2601 let error = result.unwrap_err();
2602 assert_eq!(error.summary.hard_links_created, 1);
2604 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2606 assert!(dst_metadata.is_dir());
2607 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
2608 assert_eq!(
2609 actual_mode, 0o750,
2610 "directory should have preserved source permissions (0o750), got {:o}",
2611 actual_mode
2612 );
2613 Ok(())
2614 }
2615 mod filter_tests {
2616 use super::*;
2617 use crate::filter::FilterSettings;
2618 #[tokio::test]
2620 #[traced_test]
2621 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
2622 let tmp_dir = testutils::setup_test_dir().await?;
2623 let test_path = tmp_dir.as_path();
2624 let mut filter = FilterSettings::new();
2626 filter.add_include("bar/*.txt").unwrap();
2627 let summary = link(
2628 &PROGRESS,
2629 test_path,
2630 &test_path.join("foo"),
2631 &test_path.join("dst"),
2632 &None,
2633 &Settings {
2634 copy_settings: CopySettings {
2635 dereference: false,
2636 fail_early: false,
2637 overwrite: false,
2638 overwrite_compare: Default::default(),
2639 overwrite_filter: None,
2640 ignore_existing: false,
2641 chunk_size: 0,
2642 skip_specials: false,
2643 remote_copy_buffer_size: 0,
2644 filter: None,
2645 dry_run: None,
2646 delete: None,
2647 },
2648 update_compare: Default::default(),
2649 update_exclusive: false,
2650 filter: Some(filter),
2651 dry_run: None,
2652 preserve: preserve::preserve_all(),
2653 },
2654 false,
2655 )
2656 .await?;
2657 assert_eq!(
2659 summary.hard_links_created, 3,
2660 "should link 3 files matching bar/*.txt"
2661 );
2662 assert!(
2664 test_path.join("dst/bar/1.txt").exists(),
2665 "bar/1.txt should be linked"
2666 );
2667 assert!(
2668 test_path.join("dst/bar/2.txt").exists(),
2669 "bar/2.txt should be linked"
2670 );
2671 assert!(
2672 test_path.join("dst/bar/3.txt").exists(),
2673 "bar/3.txt should be linked"
2674 );
2675 assert!(
2677 !test_path.join("dst/0.txt").exists(),
2678 "0.txt should not be linked"
2679 );
2680 Ok(())
2681 }
2682 #[tokio::test]
2687 #[traced_test]
2688 async fn test_filter_pruned_empty_dir_surfaces_child_error() -> Result<(), anyhow::Error> {
2689 let tmp_dir = testutils::create_temp_dir().await?;
2690 let test_path = tmp_dir.as_path();
2691 let src_dir = test_path.join("src");
2695 let unreadable = src_dir.join("sub").join("unreadable");
2696 tokio::fs::create_dir_all(&unreadable).await?;
2697 tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
2698 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2699 let mut filter = FilterSettings::new();
2702 filter.add_include("*.match").unwrap();
2703 let mut settings = common_settings(false, false);
2704 settings.filter = Some(filter);
2705 let result = link(
2706 &PROGRESS,
2707 test_path,
2708 &src_dir,
2709 &test_path.join("dst"),
2710 &None,
2711 &settings,
2712 false,
2713 )
2714 .await;
2715 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
2717 assert!(
2718 result.is_err(),
2719 "a child link failure inside a filter-pruned empty directory must surface as an \
2720 error, not be masked as success"
2721 );
2722 Ok(())
2723 }
2724 #[tokio::test]
2727 #[traced_test]
2728 async fn test_filter_pruned_empty_dir_surfaces_child_error_dry_run()
2729 -> Result<(), anyhow::Error> {
2730 let tmp_dir = testutils::create_temp_dir().await?;
2731 let test_path = tmp_dir.as_path();
2732 let src_dir = test_path.join("src");
2733 let unreadable = src_dir.join("sub").join("unreadable");
2734 tokio::fs::create_dir_all(&unreadable).await?;
2735 tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
2736 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2737 let mut filter = FilterSettings::new();
2738 filter.add_include("*.match").unwrap();
2739 let mut settings = common_settings(false, false);
2740 settings.filter = Some(filter);
2741 settings.dry_run = Some(crate::config::DryRunMode::Brief);
2742 settings.copy_settings.dry_run = Some(crate::config::DryRunMode::Brief);
2743 let result = link(
2744 &PROGRESS,
2745 test_path,
2746 &src_dir,
2747 &test_path.join("dst"),
2748 &None,
2749 &settings,
2750 false,
2751 )
2752 .await;
2753 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
2754 assert!(
2755 result.is_err(),
2756 "dry-run must also surface the child error, not report a clean run"
2757 );
2758 Ok(())
2759 }
2760 #[tokio::test]
2762 #[traced_test]
2763 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
2764 let tmp_dir = testutils::setup_test_dir().await?;
2765 let test_path = tmp_dir.as_path();
2766 let mut filter = FilterSettings::new();
2768 filter.add_exclude("*.txt").unwrap();
2769 let summary = link(
2770 &PROGRESS,
2771 test_path,
2772 &test_path.join("foo/0.txt"), &test_path.join("dst/0.txt"),
2774 &None,
2775 &Settings {
2776 copy_settings: CopySettings {
2777 dereference: false,
2778 fail_early: false,
2779 overwrite: false,
2780 overwrite_compare: Default::default(),
2781 overwrite_filter: None,
2782 ignore_existing: false,
2783 chunk_size: 0,
2784 skip_specials: false,
2785 remote_copy_buffer_size: 0,
2786 filter: None,
2787 dry_run: None,
2788 delete: None,
2789 },
2790 update_compare: Default::default(),
2791 update_exclusive: false,
2792 filter: Some(filter),
2793 dry_run: None,
2794 preserve: preserve::preserve_all(),
2795 },
2796 false,
2797 )
2798 .await?;
2799 assert_eq!(
2801 summary.hard_links_created, 0,
2802 "file matching exclude pattern should not be linked"
2803 );
2804 assert!(
2805 !test_path.join("dst/0.txt").exists(),
2806 "excluded file should not exist at destination"
2807 );
2808 Ok(())
2809 }
2810 #[tokio::test]
2812 #[traced_test]
2813 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
2814 let test_path = testutils::create_temp_dir().await?;
2815 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
2817 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
2818 let mut filter = FilterSettings::new();
2820 filter.add_exclude("*_dir/").unwrap();
2821 let result = link(
2822 &PROGRESS,
2823 &test_path,
2824 &test_path.join("excluded_dir"),
2825 &test_path.join("dst"),
2826 &None,
2827 &Settings {
2828 copy_settings: CopySettings {
2829 dereference: false,
2830 fail_early: false,
2831 overwrite: false,
2832 overwrite_compare: Default::default(),
2833 overwrite_filter: None,
2834 ignore_existing: false,
2835 chunk_size: 0,
2836 skip_specials: false,
2837 remote_copy_buffer_size: 0,
2838 filter: None,
2839 dry_run: None,
2840 delete: None,
2841 },
2842 update_compare: Default::default(),
2843 update_exclusive: false,
2844 filter: Some(filter),
2845 dry_run: None,
2846 preserve: preserve::preserve_all(),
2847 },
2848 false,
2849 )
2850 .await?;
2851 assert_eq!(
2853 result.copy_summary.directories_created, 0,
2854 "root directory matching exclude should not be created"
2855 );
2856 assert!(
2857 !test_path.join("dst").exists(),
2858 "excluded root directory should not exist at destination"
2859 );
2860 Ok(())
2861 }
2862 #[tokio::test]
2864 #[traced_test]
2865 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
2866 let test_path = testutils::create_temp_dir().await?;
2867 tokio::fs::write(test_path.join("target.txt"), "content").await?;
2869 tokio::fs::symlink(
2870 test_path.join("target.txt"),
2871 test_path.join("excluded_link"),
2872 )
2873 .await?;
2874 let mut filter = FilterSettings::new();
2876 filter.add_exclude("*_link").unwrap();
2877 let result = link(
2878 &PROGRESS,
2879 &test_path,
2880 &test_path.join("excluded_link"),
2881 &test_path.join("dst"),
2882 &None,
2883 &Settings {
2884 copy_settings: CopySettings {
2885 dereference: false,
2886 fail_early: false,
2887 overwrite: false,
2888 overwrite_compare: Default::default(),
2889 overwrite_filter: None,
2890 ignore_existing: false,
2891 chunk_size: 0,
2892 skip_specials: false,
2893 remote_copy_buffer_size: 0,
2894 filter: None,
2895 dry_run: None,
2896 delete: None,
2897 },
2898 update_compare: Default::default(),
2899 update_exclusive: false,
2900 filter: Some(filter),
2901 dry_run: None,
2902 preserve: preserve::preserve_all(),
2903 },
2904 false,
2905 )
2906 .await?;
2907 assert_eq!(
2909 result.copy_summary.symlinks_created, 0,
2910 "root symlink matching exclude should not be created"
2911 );
2912 assert!(
2913 !test_path.join("dst").exists(),
2914 "excluded root symlink should not exist at destination"
2915 );
2916 Ok(())
2917 }
2918 #[tokio::test]
2920 #[traced_test]
2921 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
2922 let tmp_dir = testutils::setup_test_dir().await?;
2923 let test_path = tmp_dir.as_path();
2924 let mut filter = FilterSettings::new();
2931 filter.add_include("bar/*.txt").unwrap();
2932 filter.add_exclude("bar/2.txt").unwrap();
2933 let summary = link(
2934 &PROGRESS,
2935 test_path,
2936 &test_path.join("foo"),
2937 &test_path.join("dst"),
2938 &None,
2939 &Settings {
2940 copy_settings: CopySettings {
2941 dereference: false,
2942 fail_early: false,
2943 overwrite: false,
2944 overwrite_compare: Default::default(),
2945 overwrite_filter: None,
2946 ignore_existing: false,
2947 chunk_size: 0,
2948 skip_specials: false,
2949 remote_copy_buffer_size: 0,
2950 filter: None,
2951 dry_run: None,
2952 delete: None,
2953 },
2954 update_compare: Default::default(),
2955 update_exclusive: false,
2956 filter: Some(filter),
2957 dry_run: None,
2958 preserve: preserve::preserve_all(),
2959 },
2960 false,
2961 )
2962 .await?;
2963 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
2966 assert_eq!(
2967 summary.copy_summary.files_skipped, 2,
2968 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
2969 );
2970 assert!(
2972 test_path.join("dst/bar/1.txt").exists(),
2973 "bar/1.txt should be linked"
2974 );
2975 assert!(
2976 !test_path.join("dst/bar/2.txt").exists(),
2977 "bar/2.txt should be excluded"
2978 );
2979 assert!(
2980 test_path.join("dst/bar/3.txt").exists(),
2981 "bar/3.txt should be linked"
2982 );
2983 Ok(())
2984 }
2985 #[tokio::test]
2987 #[traced_test]
2988 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
2989 let tmp_dir = testutils::setup_test_dir().await?;
2990 let test_path = tmp_dir.as_path();
2991 let mut filter = FilterSettings::new();
2998 filter.add_exclude("bar/").unwrap();
2999 let summary = link(
3000 &PROGRESS,
3001 test_path,
3002 &test_path.join("foo"),
3003 &test_path.join("dst"),
3004 &None,
3005 &Settings {
3006 copy_settings: CopySettings {
3007 dereference: false,
3008 fail_early: false,
3009 overwrite: false,
3010 overwrite_compare: Default::default(),
3011 overwrite_filter: None,
3012 ignore_existing: false,
3013 chunk_size: 0,
3014 skip_specials: false,
3015 remote_copy_buffer_size: 0,
3016 filter: None,
3017 dry_run: None,
3018 delete: None,
3019 },
3020 update_compare: Default::default(),
3021 update_exclusive: false,
3022 filter: Some(filter),
3023 dry_run: None,
3024 preserve: preserve::preserve_all(),
3025 },
3026 false,
3027 )
3028 .await?;
3029 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
3033 assert_eq!(
3034 summary.copy_summary.symlinks_created, 2,
3035 "should copy 2 symlinks"
3036 );
3037 assert_eq!(
3038 summary.copy_summary.directories_skipped, 1,
3039 "should skip 1 directory (bar)"
3040 );
3041 assert!(
3043 !test_path.join("dst/bar").exists(),
3044 "bar directory should not be linked"
3045 );
3046 Ok(())
3047 }
3048 #[tokio::test]
3051 #[traced_test]
3052 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
3053 let test_path = testutils::create_temp_dir().await?;
3054 let src_path = test_path.join("src");
3060 tokio::fs::create_dir(&src_path).await?;
3061 tokio::fs::write(src_path.join("foo"), "content").await?;
3062 tokio::fs::write(src_path.join("bar"), "content").await?;
3063 tokio::fs::create_dir(src_path.join("baz")).await?;
3064 let mut filter = FilterSettings::new();
3066 filter.add_include("foo").unwrap();
3067 let summary = link(
3068 &PROGRESS,
3069 &test_path,
3070 &src_path,
3071 &test_path.join("dst"),
3072 &None,
3073 &Settings {
3074 copy_settings: copy::Settings {
3075 dereference: false,
3076 fail_early: false,
3077 overwrite: false,
3078 overwrite_compare: Default::default(),
3079 overwrite_filter: None,
3080 ignore_existing: false,
3081 chunk_size: 0,
3082 skip_specials: false,
3083 remote_copy_buffer_size: 0,
3084 filter: None,
3085 dry_run: None,
3086 delete: None,
3087 },
3088 update_compare: Default::default(),
3089 update_exclusive: false,
3090 filter: Some(filter),
3091 dry_run: None,
3092 preserve: preserve::preserve_all(),
3093 },
3094 false,
3095 )
3096 .await?;
3097 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3099 assert_eq!(
3100 summary.copy_summary.directories_created, 1,
3101 "should create only root directory (not empty 'baz')"
3102 );
3103 assert!(
3105 test_path.join("dst").join("foo").exists(),
3106 "foo should be linked"
3107 );
3108 assert!(
3110 !test_path.join("dst").join("bar").exists(),
3111 "bar should not be linked"
3112 );
3113 assert!(
3115 !test_path.join("dst").join("baz").exists(),
3116 "empty baz directory should NOT be created"
3117 );
3118 Ok(())
3119 }
3120 #[tokio::test]
3123 #[traced_test]
3124 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
3125 let test_path = testutils::create_temp_dir().await?;
3126 let src_path = test_path.join("src");
3133 tokio::fs::create_dir(&src_path).await?;
3134 tokio::fs::write(src_path.join("foo"), "content").await?;
3135 tokio::fs::create_dir(src_path.join("baz")).await?;
3136 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
3137 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
3138 let mut filter = FilterSettings::new();
3140 filter.add_include("foo").unwrap();
3141 let summary = link(
3142 &PROGRESS,
3143 &test_path,
3144 &src_path,
3145 &test_path.join("dst"),
3146 &None,
3147 &Settings {
3148 copy_settings: copy::Settings {
3149 dereference: false,
3150 fail_early: false,
3151 overwrite: false,
3152 overwrite_compare: Default::default(),
3153 overwrite_filter: None,
3154 ignore_existing: false,
3155 chunk_size: 0,
3156 skip_specials: false,
3157 remote_copy_buffer_size: 0,
3158 filter: None,
3159 dry_run: None,
3160 delete: None,
3161 },
3162 update_compare: Default::default(),
3163 update_exclusive: false,
3164 filter: Some(filter),
3165 dry_run: None,
3166 preserve: preserve::preserve_all(),
3167 },
3168 false,
3169 )
3170 .await?;
3171 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3173 assert_eq!(
3174 summary.copy_summary.files_skipped, 2,
3175 "should skip 2 files (qux and quux)"
3176 );
3177 assert_eq!(
3178 summary.copy_summary.directories_created, 1,
3179 "should create only root directory (not 'baz' with non-matching content)"
3180 );
3181 assert!(
3183 test_path.join("dst").join("foo").exists(),
3184 "foo should be linked"
3185 );
3186 assert!(
3188 !test_path.join("dst").join("baz").exists(),
3189 "baz directory should NOT be created (no matching content inside)"
3190 );
3191 Ok(())
3192 }
3193 #[tokio::test]
3196 #[traced_test]
3197 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
3198 let test_path = testutils::create_temp_dir().await?;
3199 let src_path = test_path.join("src");
3205 tokio::fs::create_dir(&src_path).await?;
3206 tokio::fs::write(src_path.join("foo"), "content").await?;
3207 tokio::fs::write(src_path.join("bar"), "content").await?;
3208 tokio::fs::create_dir(src_path.join("baz")).await?;
3209 let mut filter = FilterSettings::new();
3211 filter.add_include("foo").unwrap();
3212 let summary = link(
3213 &PROGRESS,
3214 &test_path,
3215 &src_path,
3216 &test_path.join("dst"),
3217 &None,
3218 &Settings {
3219 copy_settings: copy::Settings {
3220 dereference: false,
3221 fail_early: false,
3222 overwrite: false,
3223 overwrite_compare: Default::default(),
3224 overwrite_filter: None,
3225 ignore_existing: false,
3226 chunk_size: 0,
3227 skip_specials: false,
3228 remote_copy_buffer_size: 0,
3229 filter: None,
3230 dry_run: None,
3231 delete: None,
3232 },
3233 update_compare: Default::default(),
3234 update_exclusive: false,
3235 filter: Some(filter),
3236 dry_run: Some(crate::config::DryRunMode::Explain),
3237 preserve: preserve::preserve_all(),
3238 },
3239 false,
3240 )
3241 .await?;
3242 assert_eq!(
3244 summary.hard_links_created, 1,
3245 "should report only 'foo' would be linked"
3246 );
3247 assert_eq!(
3248 summary.copy_summary.directories_created, 1,
3249 "should report only root directory would be created (not empty 'baz')"
3250 );
3251 assert!(
3253 !test_path.join("dst").exists(),
3254 "dst should not exist in dry-run"
3255 );
3256 Ok(())
3257 }
3258 #[tokio::test]
3261 #[traced_test]
3262 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
3263 let test_path = testutils::create_temp_dir().await?;
3264 let src_path = test_path.join("src");
3270 tokio::fs::create_dir(&src_path).await?;
3271 tokio::fs::write(src_path.join("foo"), "content").await?;
3272 tokio::fs::write(src_path.join("bar"), "content").await?;
3273 tokio::fs::create_dir(src_path.join("baz")).await?;
3274 let dst_path = test_path.join("dst");
3276 tokio::fs::create_dir(&dst_path).await?;
3277 tokio::fs::create_dir(dst_path.join("baz")).await?;
3278 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
3280 let mut filter = FilterSettings::new();
3282 filter.add_include("foo").unwrap();
3283 let summary = link(
3284 &PROGRESS,
3285 &test_path,
3286 &src_path,
3287 &dst_path,
3288 &None,
3289 &Settings {
3290 copy_settings: copy::Settings {
3291 dereference: false,
3292 fail_early: false,
3293 overwrite: true, overwrite_compare: Default::default(),
3295 overwrite_filter: None,
3296 ignore_existing: false,
3297 chunk_size: 0,
3298 skip_specials: false,
3299 remote_copy_buffer_size: 0,
3300 filter: None,
3301 dry_run: None,
3302 delete: None,
3303 },
3304 update_compare: Default::default(),
3305 update_exclusive: false,
3306 filter: Some(filter),
3307 dry_run: None,
3308 preserve: preserve::preserve_all(),
3309 },
3310 false,
3311 )
3312 .await?;
3313 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3315 assert_eq!(
3317 summary.copy_summary.directories_unchanged, 2,
3318 "root dst and baz directories should be unchanged"
3319 );
3320 assert_eq!(
3321 summary.copy_summary.directories_created, 0,
3322 "should not create any directories"
3323 );
3324 assert!(dst_path.join("foo").exists(), "foo should be linked");
3326 assert!(!dst_path.join("bar").exists(), "bar should not be linked");
3328 assert!(
3330 dst_path.join("baz").exists(),
3331 "existing baz directory should still exist"
3332 );
3333 assert!(
3334 dst_path.join("baz").join("marker.txt").exists(),
3335 "existing content in baz should still exist"
3336 );
3337 Ok(())
3338 }
3339
3340 #[tokio::test]
3346 #[traced_test]
3347 async fn update_only_excluded_entry_not_copied_without_delete() -> Result<(), anyhow::Error>
3348 {
3349 let test_path = testutils::create_temp_dir().await?;
3350 let src = test_path.join("src");
3354 let update = test_path.join("update");
3355 let dst = test_path.join("dst");
3356 tokio::fs::create_dir(&src).await?;
3357 tokio::fs::create_dir(&update).await?;
3358 tokio::fs::write(src.join("keep.txt"), "keep").await?;
3359 tokio::fs::write(update.join("keep.txt"), "keep").await?;
3360 tokio::fs::write(update.join("extra.txt"), "EXCLUDED").await?;
3361 tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
3362
3363 let mut filter = FilterSettings::new();
3364 filter.add_exclude("extra.txt").unwrap();
3365 let mut settings = common_settings(false, false);
3366 settings.filter = Some(filter);
3367 assert!(settings.copy_settings.delete.is_none());
3369
3370 let summary = link(
3371 &PROGRESS,
3372 &test_path,
3373 &src,
3374 &dst,
3375 &Some(update.clone()),
3376 &settings,
3377 false,
3378 )
3379 .await?;
3380
3381 assert!(
3382 !dst.join("extra.txt").exists(),
3383 "update-only entry matching --exclude must NOT be copied when --delete is off"
3384 );
3385 assert!(
3386 dst.join("wanted.txt").exists(),
3387 "non-excluded update-only entry should be copied"
3388 );
3389 assert!(dst.join("keep.txt").exists(), "shared entry should exist");
3390 assert_eq!(
3391 summary.copy_summary.files_skipped, 1,
3392 "the excluded update-only file should be counted skipped"
3393 );
3394 Ok(())
3395 }
3396
3397 fn are_hardlinked(a: &std::path::Path, b: &std::path::Path) -> bool {
3399 use std::os::unix::fs::MetadataExt;
3400 match (std::fs::symlink_metadata(a), std::fs::symlink_metadata(b)) {
3401 (Ok(ma), Ok(mb)) => ma.ino() == mb.ino() && ma.dev() == mb.dev(),
3402 _ => false,
3403 }
3404 }
3405
3406 #[tokio::test]
3419 #[traced_test]
3420 async fn type_mismatch_excluded_update_dir_not_copied_src_file_kept()
3421 -> Result<(), anyhow::Error> {
3422 let test_path = testutils::create_temp_dir().await?;
3423 let src = test_path.join("src");
3424 let update = test_path.join("update");
3425 let dst = test_path.join("dst");
3426 tokio::fs::create_dir(&src).await?;
3427 tokio::fs::create_dir(&update).await?;
3428 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3430 tokio::fs::create_dir(update.join("cache")).await?;
3431 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3432 tokio::fs::write(src.join("keep.txt"), "keep").await?;
3434 tokio::fs::write(update.join("keep.txt"), "keep").await?;
3435 let keep_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
3441 filetime::set_file_mtime(src.join("keep.txt"), keep_mtime)?;
3442 filetime::set_file_mtime(update.join("keep.txt"), keep_mtime)?;
3443
3444 let mut filter = FilterSettings::new();
3445 filter.add_exclude("cache/").unwrap(); let mut settings = common_settings(false, false);
3447 settings.filter = Some(filter);
3448 assert!(settings.copy_settings.delete.is_none());
3449
3450 let summary = link(
3451 &PROGRESS,
3452 &test_path,
3453 &src,
3454 &dst,
3455 &Some(update.clone()),
3456 &settings,
3457 false,
3458 )
3459 .await?;
3460
3461 assert!(
3463 !dst.join("cache").join("inner.dat").exists(),
3464 "excluded update directory `cache/` must not be copied"
3465 );
3466 assert!(
3467 !dst.join("cache").is_dir(),
3468 "dst/cache must not be the excluded update directory"
3469 );
3470 assert!(
3472 dst.join("cache").is_file(),
3473 "src `cache` file must be materialized when the update dir is excluded"
3474 );
3475 assert_eq!(
3476 tokio::fs::read_to_string(dst.join("cache")).await?,
3477 "SRC-FILE"
3478 );
3479 assert!(
3480 are_hardlinked(&src.join("cache"), &dst.join("cache")),
3481 "the src `cache` file must be hard-linked into the destination"
3482 );
3483 assert!(
3484 dst.join("keep.txt").exists(),
3485 "shared entry should still link"
3486 );
3487 assert_eq!(summary.hard_links_created, 2);
3491 assert_eq!(summary.copy_summary.files_copied, 0);
3492 assert_eq!(
3493 summary.copy_summary.directories_created, 1,
3494 "only the dst root is created; the excluded `cache/` dir must not be"
3495 );
3496 Ok(())
3497 }
3498
3499 #[tokio::test]
3509 #[traced_test]
3510 async fn reverse_type_mismatch_excluded_update_file_not_copied_src_dir_kept()
3511 -> Result<(), anyhow::Error> {
3512 let test_path = testutils::create_temp_dir().await?;
3513 let src = test_path.join("src");
3514 let update = test_path.join("update");
3515 let dst = test_path.join("dst");
3516 tokio::fs::create_dir(&src).await?;
3517 tokio::fs::create_dir(&update).await?;
3518 tokio::fs::create_dir(src.join("data")).await?;
3520 tokio::fs::write(src.join("data").join("inner.txt"), "SRC-DIR-CONTENT").await?;
3521 tokio::fs::write(update.join("data"), "UPDATE-FILE-EXCLUDED").await?;
3522
3523 let mut filter = FilterSettings::new();
3527 filter.add_include("data/").unwrap();
3528 filter.add_include("data/**").unwrap();
3529 let mut settings = common_settings(false, false);
3530 settings.filter = Some(filter);
3531 assert!(settings.copy_settings.delete.is_none());
3532
3533 link(
3534 &PROGRESS,
3535 &test_path,
3536 &src,
3537 &dst,
3538 &Some(update.clone()),
3539 &settings,
3540 false,
3541 )
3542 .await?;
3543
3544 assert!(
3546 dst.join("data").is_dir(),
3547 "src `data` directory must be materialized when the update file is excluded"
3548 );
3549 assert!(
3550 dst.join("data").join("inner.txt").exists(),
3551 "src directory contents must be linked through"
3552 );
3553 assert!(
3554 are_hardlinked(
3555 &src.join("data").join("inner.txt"),
3556 &dst.join("data").join("inner.txt")
3557 ),
3558 "src directory's file must be hard-linked into the destination"
3559 );
3560 Ok(())
3561 }
3562
3563 #[tokio::test]
3569 #[traced_test]
3570 async fn type_mismatch_excluded_update_dir_update_exclusive_materializes_nothing()
3571 -> Result<(), anyhow::Error> {
3572 let test_path = testutils::create_temp_dir().await?;
3573 let src = test_path.join("src");
3574 let update = test_path.join("update");
3575 let dst = test_path.join("dst");
3576 tokio::fs::create_dir(&src).await?;
3577 tokio::fs::create_dir(&update).await?;
3578 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3579 tokio::fs::create_dir(update.join("cache")).await?;
3580 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3581 tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
3583
3584 let mut filter = FilterSettings::new();
3585 filter.add_exclude("cache/").unwrap();
3586 let mut settings = common_settings(false, false);
3587 settings.update_exclusive = true;
3588 settings.filter = Some(filter);
3589
3590 link(
3591 &PROGRESS,
3592 &test_path,
3593 &src,
3594 &dst,
3595 &Some(update.clone()),
3596 &settings,
3597 false,
3598 )
3599 .await?;
3600
3601 assert!(
3602 !dst.join("cache").exists(),
3603 "under --update-exclusive an excluded-update type-mismatch must materialize nothing \
3604 (no excluded dir, no stale src file)"
3605 );
3606 assert!(
3607 dst.join("wanted.txt").exists(),
3608 "filter-passing update-only entries are still copied under --update-exclusive"
3609 );
3610 Ok(())
3611 }
3612
3613 #[tokio::test]
3620 #[traced_test]
3621 async fn type_mismatch_excluded_update_dir_delete_keeps_src_file()
3622 -> Result<(), anyhow::Error> {
3623 let test_path = testutils::create_temp_dir().await?;
3624 let src = test_path.join("src");
3625 let update = test_path.join("update");
3626 let dst = test_path.join("dst");
3627 tokio::fs::create_dir(&src).await?;
3628 tokio::fs::create_dir(&update).await?;
3629 tokio::fs::create_dir(&dst).await?;
3630 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3631 tokio::fs::create_dir(update.join("cache")).await?;
3632 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3633 tokio::fs::write(dst.join("stale.txt"), "stale").await?;
3635
3636 let mut filter = FilterSettings::new();
3637 filter.add_exclude("cache/").unwrap();
3638 let mut settings = common_settings(false, true); settings.filter = Some(filter);
3640 settings.copy_settings.delete = Some(copy::DeleteSettings {
3641 delete_excluded: false,
3642 });
3643
3644 link(
3645 &PROGRESS,
3646 &test_path,
3647 &src,
3648 &dst,
3649 &Some(update.clone()),
3650 &settings,
3651 false,
3652 )
3653 .await?;
3654
3655 assert!(
3656 dst.join("cache").is_file(),
3657 "src `cache` file must survive --delete (kept in the keep-set, not pruned)"
3658 );
3659 assert_eq!(
3660 tokio::fs::read_to_string(dst.join("cache")).await?,
3661 "SRC-FILE"
3662 );
3663 assert!(
3664 !dst.join("cache").is_dir(),
3665 "the excluded update directory must leave no leftover"
3666 );
3667 assert!(
3668 !dst.join("stale.txt").exists(),
3669 "extraneous dst entry must be pruned by --delete"
3670 );
3671 Ok(())
3672 }
3673 }
3674 mod dry_run_tests {
3675 use super::*;
3676 #[tokio::test]
3678 #[traced_test]
3679 async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
3680 let tmp_dir = testutils::setup_test_dir().await?;
3681 let test_path = tmp_dir.as_path();
3682 let src_file = test_path.join("foo/0.txt");
3683 let dst_file = test_path.join("dst_link.txt");
3684 assert!(
3686 !dst_file.exists(),
3687 "destination should not exist before dry-run"
3688 );
3689 let summary = link(
3690 &PROGRESS,
3691 test_path,
3692 &src_file,
3693 &dst_file,
3694 &None,
3695 &Settings {
3696 copy_settings: CopySettings {
3697 dereference: false,
3698 fail_early: false,
3699 overwrite: false,
3700 overwrite_compare: Default::default(),
3701 overwrite_filter: None,
3702 ignore_existing: false,
3703 chunk_size: 0,
3704 skip_specials: false,
3705 remote_copy_buffer_size: 0,
3706 filter: None,
3707 dry_run: None,
3708 delete: None,
3709 },
3710 update_compare: Default::default(),
3711 update_exclusive: false,
3712 filter: None,
3713 dry_run: Some(crate::config::DryRunMode::Brief),
3714 preserve: preserve::preserve_all(),
3715 },
3716 false,
3717 )
3718 .await?;
3719 assert!(!dst_file.exists(), "dry-run should not create hard link");
3721 assert_eq!(
3723 summary.hard_links_created, 1,
3724 "dry-run should report 1 hard link that would be created"
3725 );
3726 Ok(())
3727 }
3728 #[tokio::test]
3730 #[traced_test]
3731 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
3732 let tmp_dir = testutils::setup_test_dir().await?;
3733 let test_path = tmp_dir.as_path();
3734 let dst_path = test_path.join("nonexistent_dst");
3735 assert!(
3737 !dst_path.exists(),
3738 "destination should not exist before dry-run"
3739 );
3740 let summary = link(
3741 &PROGRESS,
3742 test_path,
3743 &test_path.join("foo"),
3744 &dst_path,
3745 &None,
3746 &Settings {
3747 copy_settings: CopySettings {
3748 dereference: false,
3749 fail_early: false,
3750 overwrite: false,
3751 overwrite_compare: Default::default(),
3752 overwrite_filter: None,
3753 ignore_existing: false,
3754 chunk_size: 0,
3755 skip_specials: false,
3756 remote_copy_buffer_size: 0,
3757 filter: None,
3758 dry_run: None,
3759 delete: None,
3760 },
3761 update_compare: Default::default(),
3762 update_exclusive: false,
3763 filter: None,
3764 dry_run: Some(crate::config::DryRunMode::Brief),
3765 preserve: preserve::preserve_all(),
3766 },
3767 false,
3768 )
3769 .await?;
3770 assert!(
3772 !dst_path.exists(),
3773 "dry-run should not create destination directory"
3774 );
3775 assert!(
3777 summary.hard_links_created > 0,
3778 "dry-run should report hard links that would be created"
3779 );
3780 Ok(())
3781 }
3782 #[tokio::test]
3784 #[traced_test]
3785 async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
3786 let tmp_dir = testutils::setup_test_dir().await?;
3787 let test_path = tmp_dir.as_path();
3788 let src_path = test_path.join("foo/baz");
3790 let dst_path = test_path.join("dst_baz");
3791 assert!(
3793 !dst_path.exists(),
3794 "destination should not exist before dry-run"
3795 );
3796 let summary = link(
3797 &PROGRESS,
3798 test_path,
3799 &src_path,
3800 &dst_path,
3801 &None,
3802 &Settings {
3803 copy_settings: CopySettings {
3804 dereference: false,
3805 fail_early: false,
3806 overwrite: false,
3807 overwrite_compare: Default::default(),
3808 overwrite_filter: None,
3809 ignore_existing: false,
3810 chunk_size: 0,
3811 skip_specials: false,
3812 remote_copy_buffer_size: 0,
3813 filter: None,
3814 dry_run: None,
3815 delete: None,
3816 },
3817 update_compare: Default::default(),
3818 update_exclusive: false,
3819 filter: None,
3820 dry_run: Some(crate::config::DryRunMode::Brief),
3821 preserve: preserve::preserve_all(),
3822 },
3823 false,
3824 )
3825 .await?;
3826 assert!(!dst_path.exists(), "dry-run should not create destination");
3828 assert_eq!(
3830 summary.hard_links_created, 1,
3831 "dry-run should report 1 hard link (for 4.txt)"
3832 );
3833 assert_eq!(
3834 summary.copy_summary.symlinks_created, 2,
3835 "dry-run should report 2 symlinks (5.txt and 6.txt)"
3836 );
3837 Ok(())
3838 }
3839 }
3840
3841 #[tokio::test]
3848 #[traced_test]
3849 async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
3850 let tmp_dir = testutils::create_temp_dir().await?;
3851 let test_path = tmp_dir.as_path();
3852 let src_dir = test_path.join("src");
3857 let sub_dir = src_dir.join("sub");
3858 let bad_dir = sub_dir.join("unreadable_dir");
3859 tokio::fs::create_dir_all(&bad_dir).await?;
3860 tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
3861 tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
3862 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
3863 let dst_dir = test_path.join("dst");
3864 let result = link(
3865 &PROGRESS,
3866 test_path,
3867 &src_dir,
3868 &dst_dir,
3869 &None,
3870 &Settings {
3871 copy_settings: CopySettings {
3872 fail_early: true,
3873 ..common_settings(false, false).copy_settings
3874 },
3875 ..common_settings(false, false)
3876 },
3877 false,
3878 )
3879 .await;
3880 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
3882 let error = result.expect_err("link should fail due to unreadable directory");
3883 assert!(
3888 error.summary.copy_summary.directories_created >= 2,
3889 "fail-early summary should include directories from the failing subtree, \
3890 got directories_created={} (expected >= 2: dst/ and dst/sub/)",
3891 error.summary.copy_summary.directories_created
3892 );
3893 Ok(())
3894 }
3895
3896 #[tokio::test]
3897 #[traced_test]
3898 async fn skip_specials_skips_socket_in_link() -> Result<(), anyhow::Error> {
3899 let tmp_dir = testutils::setup_test_dir().await?;
3900 let test_path = tmp_dir.as_path();
3901 let src = test_path.join("src_dir");
3902 let dst = test_path.join("dst_dir");
3903 tokio::fs::create_dir(&src).await?;
3904 tokio::fs::write(src.join("file.txt"), "hello").await?;
3905 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
3906 let mut settings = common_settings(false, false);
3907 settings.copy_settings.skip_specials = true;
3908 let summary = link(&PROGRESS, test_path, &src, &dst, &None, &settings, false).await?;
3909 assert_eq!(summary.hard_links_created, 1);
3910 assert_eq!(summary.copy_summary.specials_skipped, 1);
3911 assert!(dst.join("file.txt").exists());
3912 assert!(!dst.join("test.sock").exists());
3913 Ok(())
3914 }
3915
3916 #[tokio::test]
3917 #[traced_test]
3918 async fn delete_skips_pruning_when_link_has_errors() -> Result<(), anyhow::Error> {
3919 let tmp_dir = testutils::setup_test_dir().await?;
3920 let test_path = tmp_dir.as_path();
3921 let src = test_path.join("foo");
3922 let dst = test_path.join("bar");
3923 link(
3925 &PROGRESS,
3926 test_path,
3927 &src,
3928 &dst,
3929 &None,
3930 &common_settings(false, false),
3931 false,
3932 )
3933 .await?;
3934 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
3936 let unreadable = src.join("baz");
3940 let original = tokio::fs::metadata(&unreadable).await?.permissions();
3941 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3942
3943 let delete_settings = Settings {
3944 copy_settings: CopySettings {
3945 overwrite: true,
3946 fail_early: false,
3947 delete: Some(copy::DeleteSettings {
3948 delete_excluded: false,
3949 }),
3950 ..common_settings(false, true).copy_settings
3951 },
3952 ..common_settings(false, true)
3953 };
3954 let result = link(
3955 &PROGRESS,
3956 test_path,
3957 &src,
3958 &dst,
3959 &None,
3960 &delete_settings,
3961 false,
3962 )
3963 .await;
3964
3965 tokio::fs::set_permissions(&unreadable, original).await?;
3966
3967 assert!(
3968 result.is_err(),
3969 "link of the unreadable directory should fail"
3970 );
3971 assert!(
3972 dst.join("extraneous.txt").exists(),
3973 "pruning must be skipped when the link/update pass reported errors"
3974 );
3975 Ok(())
3976 }
3977
3978 #[tokio::test]
3979 #[traced_test]
3980 async fn skip_specials_top_level_socket_in_link() -> Result<(), anyhow::Error> {
3981 let tmp_dir = testutils::setup_test_dir().await?;
3982 let test_path = tmp_dir.as_path();
3983 let src_socket = test_path.join("test.sock");
3984 let dst = test_path.join("dst.sock");
3985 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
3986 let mut settings = common_settings(false, false);
3987 settings.copy_settings.skip_specials = true;
3988 let summary = link(
3989 &PROGRESS,
3990 test_path,
3991 &src_socket,
3992 &dst,
3993 &None,
3994 &settings,
3995 false,
3996 )
3997 .await?;
3998 assert_eq!(summary.copy_summary.specials_skipped, 1);
3999 assert_eq!(summary.hard_links_created, 0);
4000 assert!(!dst.exists());
4001 Ok(())
4002 }
4003
4004 mod max_open_files_tests {
4006 use super::*;
4007
4008 #[tokio::test]
4011 #[traced_test]
4012 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
4013 let tmp_dir = testutils::create_temp_dir().await?;
4014 let src = tmp_dir.join("src");
4015 let dst = tmp_dir.join("dst");
4016 let depth = 20;
4017 let files_per_level = 5;
4018 let limit = 4;
4019 let mut dir = src.clone();
4021 for level in 0..depth {
4022 tokio::fs::create_dir_all(&dir).await?;
4023 for f in 0..files_per_level {
4024 tokio::fs::write(
4025 dir.join(format!("f{}_{}.txt", level, f)),
4026 format!("L{}F{}", level, f),
4027 )
4028 .await?;
4029 }
4030 dir = dir.join(format!("d{}", level));
4031 }
4032 throttle::set_max_open_files(limit);
4033 let summary = tokio::time::timeout(
4034 std::time::Duration::from_secs(30),
4035 link(
4036 &PROGRESS,
4037 tmp_dir.as_path(),
4038 &src,
4039 &dst,
4040 &None,
4041 &common_settings(false, false),
4042 false,
4043 ),
4044 )
4045 .await
4046 .context("link timed out — possible deadlock")?
4047 .context("link failed")?;
4048 assert_eq!(summary.hard_links_created, depth * files_per_level);
4049 assert_eq!(summary.copy_summary.directories_created, depth);
4050 let mut check_dir = dst.clone();
4052 for level in 0..depth {
4053 let content =
4054 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
4055 assert_eq!(content, format!("L{}F0", level));
4056 check_dir = check_dir.join(format!("d{}", level));
4057 }
4058 Ok(())
4059 }
4060
4061 #[tokio::test]
4072 #[traced_test]
4073 async fn parallel_update_filetype_change_no_deadlock() -> Result<(), anyhow::Error> {
4074 let tmp_dir = testutils::create_temp_dir().await?;
4075 let src = tmp_dir.join("src");
4076 let update = tmp_dir.join("update");
4077 let dst = tmp_dir.join("dst");
4078 tokio::fs::create_dir(&src).await?;
4079 tokio::fs::create_dir(&update).await?;
4080 let n = 8;
4081 for i in 0..n {
4085 tokio::fs::write(src.join(format!("e{}", i)), format!("src-{}", i)).await?;
4086 let upd_subdir = update.join(format!("e{}", i));
4087 tokio::fs::create_dir(&upd_subdir).await?;
4088 for j in 0..3 {
4089 tokio::fs::write(
4090 upd_subdir.join(format!("inner_{}.txt", j)),
4091 format!("upd-{}-{}", i, j),
4092 )
4093 .await?;
4094 }
4095 }
4096 throttle::set_max_open_files(2);
4099 let summary = tokio::time::timeout(
4100 std::time::Duration::from_secs(30),
4101 link(
4102 &PROGRESS,
4103 tmp_dir.as_path(),
4104 &src,
4105 &dst,
4106 &Some(update.clone()),
4107 &common_settings(false, false),
4108 false,
4109 ),
4110 )
4111 .await
4112 .context(
4113 "link timed out — caller-supplied open-files guard not released before copy::copy",
4114 )?
4115 .context("link failed")?;
4116 assert_eq!(summary.copy_summary.directories_created, n + 1); assert_eq!(summary.copy_summary.files_copied, n * 3);
4120 for i in 0..n {
4122 for j in 0..3 {
4123 let content =
4124 tokio::fs::read_to_string(dst.join(format!("e{}/inner_{}.txt", i, j)))
4125 .await?;
4126 assert_eq!(content, format!("upd-{}-{}", i, j));
4127 }
4128 }
4129 Ok(())
4130 }
4131
4132 #[tokio::test]
4140 #[traced_test]
4141 async fn update_only_entries_bounded_no_deadlock() -> Result<(), anyhow::Error> {
4142 let tmp_dir = testutils::create_temp_dir().await?;
4143 let src = tmp_dir.join("src");
4144 let update = tmp_dir.join("update");
4145 let dst = tmp_dir.join("dst");
4146 tokio::fs::create_dir(&src).await?;
4147 tokio::fs::create_dir(&update).await?;
4148 let n = 50;
4151 for i in 0..n {
4152 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
4153 }
4154 throttle::set_max_open_files(2);
4155 let summary = tokio::time::timeout(
4156 std::time::Duration::from_secs(30),
4157 link(
4158 &PROGRESS,
4159 tmp_dir.as_path(),
4160 &src,
4161 &dst,
4162 &Some(update.clone()),
4163 &common_settings(false, false),
4164 false,
4165 ),
4166 )
4167 .await
4168 .context("link timed out — site-3 spawn loop deadlock")?
4169 .context("link failed")?;
4170 assert_eq!(summary.copy_summary.directories_created, 1);
4172 assert_eq!(summary.copy_summary.files_copied, n);
4173 for i in 0..n {
4174 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
4175 assert_eq!(content, format!("upd-{}", i));
4176 }
4177 Ok(())
4178 }
4179
4180 #[tokio::test]
4191 #[traced_test]
4192 async fn update_only_overwrite_preexisting_dirs_no_deadlock() -> Result<(), anyhow::Error> {
4193 let tmp_dir = testutils::create_temp_dir().await?;
4194 let src = tmp_dir.join("src");
4195 let update = tmp_dir.join("update");
4196 let dst = tmp_dir.join("dst");
4197 tokio::fs::create_dir(&src).await?;
4198 tokio::fs::create_dir(&update).await?;
4199 tokio::fs::create_dir(&dst).await?;
4200 let n = 12;
4201 for i in 0..n {
4202 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
4204 let dst_subdir = dst.join(format!("u{}", i));
4208 tokio::fs::create_dir(&dst_subdir).await?;
4209 for j in 0..3 {
4210 tokio::fs::write(
4211 dst_subdir.join(format!("inner_{}.txt", j)),
4212 format!("old-{}-{}", i, j),
4213 )
4214 .await?;
4215 }
4216 }
4217 throttle::set_max_open_files(2);
4219 let summary = tokio::time::timeout(
4220 std::time::Duration::from_secs(30),
4221 link(
4222 &PROGRESS,
4223 tmp_dir.as_path(),
4224 &src,
4225 &dst,
4226 &Some(update.clone()),
4227 &common_settings(false, true), false,
4229 ),
4230 )
4231 .await
4232 .context("link timed out — pending-meta self-deadlock between site 3 and inner rm")?
4233 .context("link failed")?;
4234 assert_eq!(summary.copy_summary.files_copied, n);
4237 assert_eq!(summary.copy_summary.rm_summary.files_removed, n * 3);
4238 assert_eq!(summary.copy_summary.rm_summary.directories_removed, n);
4239 for i in 0..n {
4241 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
4242 assert_eq!(content, format!("upd-{}", i));
4243 }
4244 Ok(())
4245 }
4246 }
4247
4248 mod race_tests {
4256 use super::*;
4257
4258 fn spawn_file_symlink_swapper(
4262 dir: std::path::PathBuf,
4263 entry_name: &'static str,
4264 sentinel: std::path::PathBuf,
4265 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
4266 ) -> std::thread::JoinHandle<()> {
4267 std::thread::spawn(move || {
4268 let entry = dir.join(entry_name);
4269 let staged_real = dir.join("__staged_real");
4270 let staged_link = dir.join("__staged_link");
4271 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
4272 let _ = std::fs::remove_file(&staged_real);
4273 if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
4274 continue;
4275 }
4276 let _ = std::fs::rename(&staged_real, &entry);
4277 let _ = std::fs::remove_file(&staged_link);
4278 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
4279 let _ = std::fs::rename(&staged_link, &entry);
4280 }
4281 })
4282 }
4283
4284 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4285 #[traced_test]
4286 async fn hard_link_entry_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
4287 let tmp_dir = testutils::create_temp_dir().await?;
4288 let test_path = tmp_dir.as_path();
4289 let sentinel = test_path.join("sentinel_secret");
4292 tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
4293 let sentinel_links_before = {
4294 use std::os::unix::fs::MetadataExt;
4295 tokio::fs::symlink_metadata(&sentinel).await?.nlink()
4296 };
4297 let src = test_path.join("src");
4298 let sub = src.join("sub");
4299 tokio::fs::create_dir(&src).await?;
4300 tokio::fs::create_dir(&sub).await?;
4301 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
4302
4303 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4304 let swapper =
4305 spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
4306
4307 let settings = common_settings(false, true);
4310 let mut caught_swaps = 0usize;
4311 let mut linked_real = 0usize;
4312 for i in 0..200 {
4313 let dst = test_path.join(format!("dst_{i}"));
4314 let result = tokio::time::timeout(
4315 std::time::Duration::from_secs(30),
4316 link(&PROGRESS, test_path, &src, &dst, &None, &settings, false),
4317 )
4318 .await
4319 .expect("link must not hang under concurrent swapping");
4320 match result {
4321 Ok(_) => {}
4322 Err(_) => caught_swaps += 1, }
4324 let entry_dst = dst.join("sub").join("entry");
4330 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
4331 && md.file_type().is_file()
4332 {
4333 let content = tokio::fs::read_to_string(&entry_dst).await?;
4334 assert_ne!(
4335 content, "SENTINEL_SECRET_CONTENT",
4336 "iteration {i}: sentinel content leaked into the destination as a regular file"
4337 );
4338 assert_eq!(
4339 content, "REAL_CONTENT",
4340 "iteration {i}: a regular destination file must hold the real content"
4341 );
4342 linked_real += 1;
4343 }
4344 let _ = tokio::fs::remove_dir_all(&dst).await;
4345 }
4346
4347 stop.store(true, std::sync::atomic::Ordering::Relaxed);
4348 swapper.join().expect("swapper thread panicked");
4349
4350 let sentinel_links_after = {
4353 use std::os::unix::fs::MetadataExt;
4354 tokio::fs::symlink_metadata(&sentinel).await?.nlink()
4355 };
4356 assert_eq!(
4357 sentinel_links_after, sentinel_links_before,
4358 "the sentinel file must never gain a hard link (linkat must not follow the symlink)"
4359 );
4360 tracing::info!(
4363 "link file/symlink swap: caught_swaps={caught_swaps}, linked_real={linked_real}"
4364 );
4365 assert!(
4366 caught_swaps + linked_real > 0,
4367 "expected at least one observable outcome across 200 iterations"
4368 );
4369 Ok(())
4370 }
4371 }
4372}