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::{self, Dir, Handle};
14use crate::walk::{
15 self, AdmittedEntry, AdmittedLeaf, EntryAdmission, EntryKind, LeafPermit, PermitKind,
16};
17
18pub type Error = crate::error::OperationError<Summary>;
21
22#[derive(Debug, Clone)]
23pub struct Settings {
24 pub copy_settings: CopySettings,
25 pub update_compare: filecmp::MetadataCmpSettings,
26 pub update_exclusive: bool,
27 pub filter: Option<crate::filter::FilterSettings>,
29 pub dry_run: Option<crate::config::DryRunMode>,
31 pub preserve: preserve::Settings,
33}
34
35#[derive(Copy, Clone, Debug, Eq, PartialEq)]
36enum UpdateRootRequirement {
37 Optional,
38 RequiredForDestructiveOperation,
39}
40
41impl UpdateRootRequirement {
42 fn from_settings(settings: &Settings) -> Self {
43 if settings.update_exclusive || settings.copy_settings.delete.is_some() {
44 Self::RequiredForDestructiveOperation
45 } else {
46 Self::Optional
47 }
48 }
49
50 fn is_required(self) -> bool {
51 self == Self::RequiredForDestructiveOperation
52 }
53
54 fn for_entry(self, rel_path: &std::path::Path) -> Self {
55 if rel_path.as_os_str().is_empty() {
56 self
57 } else {
58 Self::Optional
59 }
60 }
61
62 #[allow(clippy::result_large_err)]
63 fn classify<T>(
64 self,
65 result: std::io::Result<T>,
66 update_path: &std::path::Path,
67 ) -> Result<Option<T>, Error> {
68 match result {
69 Ok(value) => Ok(Some(value)),
70 Err(error) if error.kind() == std::io::ErrorKind::NotFound && !self.is_required() => {
71 Ok(None)
72 }
73 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
74 Err(missing_destructive_update_error(update_path))
75 }
76 Err(error) => Err(Error::new(
77 anyhow::Error::new(error)
78 .context(format!("failed reading metadata from {update_path:?}")),
79 Default::default(),
80 )),
81 }
82 }
83}
84
85fn missing_destructive_update_error(update_path: &std::path::Path) -> Error {
86 Error::new(
87 anyhow!(
88 "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
89 update_path
90 ),
91 Default::default(),
92 )
93}
94
95fn skipped_summary_for(kind: EntryKind) -> Summary {
99 let copy_summary = match kind {
100 EntryKind::Dir => CopySummary {
101 directories_skipped: 1,
102 ..Default::default()
103 },
104 EntryKind::Symlink => CopySummary {
105 symlinks_skipped: 1,
106 ..Default::default()
107 },
108 EntryKind::File | EntryKind::Special => CopySummary {
109 files_skipped: 1,
110 ..Default::default()
111 },
112 };
113 Summary {
114 copy_summary,
115 ..Default::default()
116 }
117}
118
119#[derive(Copy, Clone, Debug, Default)]
120pub struct Summary {
121 pub hard_links_created: usize,
122 pub hard_links_unchanged: usize,
123 pub copy_summary: CopySummary,
124}
125
126impl std::ops::Add for Summary {
127 type Output = Self;
128 fn add(self, other: Self) -> Self {
129 Self {
130 hard_links_created: self.hard_links_created + other.hard_links_created,
131 hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
132 copy_summary: self.copy_summary + other.copy_summary,
133 }
134 }
135}
136
137impl std::fmt::Display for Summary {
138 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
139 write!(
140 f,
141 "{}\n\
142 link:\n\
143 -----\n\
144 hard-links created: {}\n\
145 hard links unchanged: {}\n",
146 &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
147 )
148 }
149}
150
151#[instrument(skip(prog_track, settings))]
172#[allow(clippy::too_many_arguments)]
173async fn hard_link_entry_fd(
174 prog_track: &'static progress::Progress,
175 src_handle: &crate::safedir::Handle,
176 dst_dir: &Arc<Dir>,
177 dst_name: &std::ffi::OsStr,
178 dst_path: &std::path::Path,
179 settings: &Settings,
180) -> Result<Summary, Error> {
181 let mut link_summary = Summary::default();
182 match dst_dir.hard_link_handle_at(src_handle, dst_name).await {
183 Ok(()) => {}
184 Err(error)
185 if settings.copy_settings.overwrite
186 && error.kind() == std::io::ErrorKind::AlreadyExists =>
187 {
188 tracing::debug!("'dst' already exists, check if we need to update");
189 let dst_handle = dst_dir
190 .child(dst_name)
191 .await
192 .with_context(|| format!("cannot read {dst_path:?} metadata"))
193 .map_err(|err| Error::new(err, Default::default()))?;
194 if dst_handle.kind() == src_handle.kind()
198 && dst_handle.dev() == src_handle.dev()
199 && dst_handle.ino() == src_handle.ino()
200 {
201 tracing::debug!("no change, leaving file as is");
202 prog_track.hard_links_unchanged.inc();
203 return Ok(Summary {
204 hard_links_unchanged: 1,
205 ..Default::default()
206 });
207 }
208 tracing::info!("'dst' file type changed, removing and hard-linking");
209 let rm_summary = copy::remove_existing(
211 prog_track,
212 dst_dir,
213 dst_name,
214 dst_path,
215 dst_handle.into_removal_snapshot(),
216 &settings.copy_settings,
217 )
218 .await
219 .map_err(|err| {
220 link_summary.copy_summary.rm_summary = err.summary.rm_summary;
221 Error::new(err.source, link_summary)
222 })?;
223 link_summary.copy_summary.rm_summary = rm_summary;
224 dst_dir
225 .hard_link_handle_at(src_handle, dst_name)
226 .await
227 .with_context(|| format!("failed to hard link to {dst_path:?}"))
228 .map_err(|err| Error::new(err, link_summary))?;
229 }
230 Err(error) => {
231 return Err(Error::new(
232 anyhow::Error::from(error).context(format!("failed to hard link to {dst_path:?}")),
233 link_summary,
234 ));
235 }
236 }
237 prog_track.hard_links_created.inc();
238 link_summary.hard_links_created = 1;
239 Ok(link_summary)
240}
241
242#[instrument(skip(prog_track, settings))]
256pub async fn link(
257 prog_track: &'static progress::Progress,
258 cwd: &std::path::Path,
259 src: &std::path::Path,
260 dst: &std::path::Path,
261 update: &Option<std::path::PathBuf>,
262 settings: &Settings,
263 is_fresh: bool,
264) -> Result<Summary, Error> {
265 crate::walk_driver::scope_tasks(link_inner(
266 prog_track, cwd, src, dst, update, settings, is_fresh,
267 ))
268 .await
269}
270
271#[cfg(test)]
272type AfterUpdatePrecheckHook = Box<dyn FnOnce() + Send>;
273
274#[cfg(test)]
275static AFTER_UPDATE_PRECHECK_HOOKS: std::sync::LazyLock<
276 std::sync::Mutex<std::collections::HashMap<std::path::PathBuf, AfterUpdatePrecheckHook>>,
277> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
278
279#[cfg(test)]
280struct AfterUpdatePrecheckHookRegistration {
281 update_path: std::path::PathBuf,
282}
283
284#[cfg(test)]
285impl Drop for AfterUpdatePrecheckHookRegistration {
286 fn drop(&mut self) {
287 AFTER_UPDATE_PRECHECK_HOOKS
288 .lock()
289 .unwrap_or_else(std::sync::PoisonError::into_inner)
290 .remove(&self.update_path);
291 }
292}
293
294#[cfg(test)]
295fn install_after_update_precheck_hook(
296 update_path: std::path::PathBuf,
297 hook: AfterUpdatePrecheckHook,
298) -> AfterUpdatePrecheckHookRegistration {
299 let replaced = AFTER_UPDATE_PRECHECK_HOOKS
300 .lock()
301 .unwrap_or_else(std::sync::PoisonError::into_inner)
302 .insert(update_path.clone(), hook);
303 assert!(replaced.is_none(), "update precheck hook already installed");
304 AfterUpdatePrecheckHookRegistration { update_path }
305}
306
307#[cfg(test)]
308fn run_after_update_precheck_hook(update_path: &std::path::Path) {
309 let hook = AFTER_UPDATE_PRECHECK_HOOKS
310 .lock()
311 .unwrap_or_else(std::sync::PoisonError::into_inner)
312 .remove(update_path);
313 if let Some(hook) = hook {
314 hook();
315 }
316}
317
318async fn link_inner(
319 prog_track: &'static progress::Progress,
320 cwd: &std::path::Path,
321 src: &std::path::Path,
322 dst: &std::path::Path,
323 update: &Option<std::path::PathBuf>,
324 settings: &Settings,
325 is_fresh: bool,
326) -> Result<Summary, Error> {
327 let _ = cwd;
330 let update_root_requirement = UpdateRootRequirement::from_settings(settings);
331 let permit = walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
334 enum LinkRootSetup {
335 Complete(Summary),
336 Ready {
337 src_operand: crate::walk::RootOperand,
338 src_parent: Arc<Dir>,
339 dst_parent: Option<Arc<Dir>>,
340 update_parent: Option<(Arc<Dir>, std::ffi::OsString)>,
341 admission: LinkEntryAdmission,
342 },
343 }
344 let setup_admission = permit.as_ref().map(LeafPermit::admission);
345 let setup = safedir::with_optional_fd_admission(setup_admission, async move {
346 if let Some(update_path) = update.as_ref() {
358 if crate::safedir::strict_operand_resolution() {
359 let kind =
366 crate::safedir::strict_probe_dst_kind(update_path, congestion::Side::Source)
367 .await
368 .map_err(|err| {
369 Error::new(
370 anyhow::Error::new(err).context(format!(
371 "failed reading metadata from update {update_path:?}"
372 )),
373 Default::default(),
374 )
375 })?;
376 if update_root_requirement.is_required() && kind.is_none() {
377 return Err(missing_destructive_update_error(update_path));
378 }
379 } else if update_root_requirement.is_required() {
380 match crate::walk::run_metadata_probed(
381 congestion::Side::Source,
382 congestion::MetadataOp::Stat,
383 tokio::fs::symlink_metadata(update_path),
384 )
385 .await
386 {
387 Ok(_) => {}
388 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
389 return Err(missing_destructive_update_error(update_path));
390 }
391 Err(err) => {
392 return Err(Error::new(
393 anyhow::Error::new(err).context(format!(
394 "failed reading metadata from update {:?}",
395 update_path
396 )),
397 Default::default(),
398 ));
399 }
400 }
401 }
402 }
403 #[cfg(test)]
404 if let Some(update_path) = update.as_ref() {
405 run_after_update_precheck_hook(update_path);
406 }
407 let src_operand = crate::walk::split_root_operand(src)
411 .await
412 .map_err(|err| Error::new(err, Default::default()))?;
413 let src = src_operand.display.as_path();
414 let src_name = src_operand.name.as_os_str();
415 let strict_src_parent: Option<Arc<Dir>> = if crate::safedir::strict_operand_resolution() {
422 let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
423 .await
424 .with_context(|| {
425 format!(
426 "cannot open source parent directory {:?}",
427 src_operand.parent
428 )
429 })
430 .map_err(|err| Error::new(err, Default::default()))?;
431 Some(Arc::new(parent.into_tree()))
433 } else {
434 None
435 };
436 let dst_parent_path_opt: Option<&std::path::Path> = match (dst.parent(), dst.file_name()) {
441 (Some(parent), Some(_)) if parent.as_os_str().is_empty() => {
442 Some(std::path::Path::new("."))
443 }
444 (Some(parent), Some(_)) => Some(parent),
445 _ => None,
446 };
447 let strict_dst_parent: Option<Arc<Dir>> = match (
453 crate::safedir::strict_operand_resolution(),
454 dst_parent_path_opt,
455 ) {
456 (true, Some(dst_parent_path)) => {
457 match Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination).await {
458 Ok(parent) => Some(Arc::new(parent.into_tree())),
459 Err(err)
460 if err.kind() == std::io::ErrorKind::NotFound
461 || err.raw_os_error() == Some(libc::ENOTDIR) =>
462 {
463 None
464 }
465 Err(err) => {
466 return Err(Error::new(
467 anyhow::Error::new(err).context(format!(
468 "cannot open destination parent directory {dst_parent_path:?}"
469 )),
470 Default::default(),
471 ));
472 }
473 }
474 }
475 _ => None,
477 };
478 if strict_src_parent.is_none()
485 && let (Some(filter), Some(update_path)) = (settings.filter.as_ref(), update.as_ref())
486 {
487 let src_metadata = crate::walk::run_metadata_probed(
488 congestion::Side::Source,
489 congestion::MetadataOp::Stat,
490 tokio::fs::symlink_metadata(src),
491 )
492 .await;
493 let update_metadata = crate::walk::run_metadata_probed(
494 congestion::Side::Source,
495 congestion::MetadataOp::Stat,
496 tokio::fs::symlink_metadata(update_path),
497 )
498 .await;
499 if let (Ok(src_metadata), Ok(update_metadata)) = (src_metadata, update_metadata)
500 && let RootFilterSelection::Skip { kind, result } = select_filtered_root(
501 filter,
502 src_name,
503 EntryKind::from_metadata(&src_metadata),
504 Some(EntryKind::from_metadata(&update_metadata)),
505 settings.update_exclusive,
506 )
507 {
508 if let Some(mode) = settings.dry_run {
509 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
510 }
511 kind.inc_skipped(prog_track);
512 return Ok(LinkRootSetup::Complete(skipped_summary_for(kind)));
513 }
514 }
515 if update.is_none()
519 && let Some(ref filter) = settings.filter
520 {
521 let (kind, is_dir) = match &strict_src_parent {
522 Some(parent) => {
524 let root_handle = parent
525 .child(src_name)
526 .await
527 .with_context(|| format!("failed reading metadata from {:?}", &src))
528 .map_err(|err| Error::new(err, Default::default()))?;
529 (root_handle.kind(), root_handle.kind() == EntryKind::Dir)
530 }
531 None => {
532 let src_metadata = crate::walk::run_metadata_probed(
533 congestion::Side::Source,
534 congestion::MetadataOp::Stat,
535 tokio::fs::symlink_metadata(src),
536 )
537 .await
538 .with_context(|| format!("failed reading metadata from {:?}", &src))
539 .map_err(|err| Error::new(err, Default::default()))?;
540 (
541 EntryKind::from_metadata(&src_metadata),
542 src_metadata.is_dir(),
543 )
544 }
545 };
546 let result = filter.should_include_root_item(std::path::Path::new(src_name), is_dir);
547 match result {
548 crate::filter::FilterResult::Included => {}
549 result => {
550 if let Some(mode) = settings.dry_run {
551 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
552 }
553 kind.inc_skipped(prog_track);
554 return Ok(LinkRootSetup::Complete(skipped_summary_for(kind)));
555 }
556 }
557 }
558 let src_parent = match strict_src_parent {
564 Some(parent) => parent,
565 None => {
566 let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
567 .await
568 .with_context(|| {
569 format!(
570 "cannot open source parent directory {:?}",
571 src_operand.parent
572 )
573 })
574 .map_err(|err| Error::new(err, Default::default()))?;
575 Arc::new(parent.into_tree())
577 }
578 };
579 let update_parent = match update.as_ref() {
592 Some(update_path) => {
593 let update_operand = crate::walk::split_root_operand(update_path)
599 .await
600 .map_err(|err| Error::new(err, Default::default()))?;
601 let update_parent_path = update_operand.parent;
602 let update_name = update_operand.name;
603 let fallback_eligible = !update_root_requirement.is_required();
606 match Dir::open_parent_dir(&update_parent_path, congestion::Side::Source).await {
607 Ok(dir) => Some((Arc::new(dir.into_tree()), update_name)),
609 Err(err)
610 if fallback_eligible
611 && (matches!(
612 err.kind(),
613 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
614 ) || err.raw_os_error() == Some(libc::ENOTDIR)) =>
615 {
616 tracing::debug!(
620 "update parent {:?} not found ({:#}); falling back to no-update mode",
621 update_parent_path,
622 err
623 );
624 None
625 }
626 Err(err) => {
627 return Err(Error::new(
628 anyhow::Error::new(err).context(format!(
629 "cannot open update parent directory {:?}",
630 update_parent_path
631 )),
632 Default::default(),
633 ));
634 }
635 }
636 }
637 None => None,
638 };
639 let admission = match (settings.filter.as_ref(), update.as_ref()) {
644 (Some(filter), Some(update_path)) => {
645 let src_handle = src_parent
646 .child(src_name)
647 .await
648 .with_context(|| format!("failed reading metadata from {src:?}"))
649 .map_err(|err| Error::new(err, Default::default()))?;
650 let mut entry = AdmittedLinkEntry::new(src_handle, permit);
651 entry.update_handle = match update_parent.as_ref() {
652 Some((parent, update_name)) => update_root_requirement
653 .classify(parent.child(update_name).await, update_path)?,
654 None => None,
655 };
656 if settings.update_exclusive && entry.update_handle.is_none() {
657 return Ok(LinkRootSetup::Complete(Default::default()));
658 }
659 match select_filtered_root(
660 filter,
661 src_name,
662 entry.src_handle.kind(),
663 entry.update_handle.as_ref().map(Handle::kind),
664 settings.update_exclusive,
665 ) {
666 RootFilterSelection::SourceOnly => entry.update_handle = None,
667 RootFilterSelection::WithUpdate => {}
668 RootFilterSelection::Skip { kind, result } => {
669 if let Some(mode) = settings.dry_run {
670 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
671 }
672 kind.inc_skipped(prog_track);
673 return Ok(LinkRootSetup::Complete(skipped_summary_for(kind)));
674 }
675 }
676 LinkEntryAdmission::Filtered(entry)
677 }
678 _ => LinkEntryAdmission::from(EntryAdmission::from(permit)),
679 };
680 let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
684 return Err(Error::new(
685 anyhow!(
686 "link destination {:?} has no parent directory or file name",
687 dst
688 ),
689 Default::default(),
690 ));
691 };
692 let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
693 std::path::Path::new(".")
694 } else {
695 dst_parent_path
696 };
697 let root_acl_notice = crate::safedir::RootAclNotice {
710 file_acl_preserved: update.is_none() || settings.preserve.file.acl,
711 ..crate::safedir::RootAclNotice::for_preserve(&settings.preserve)
712 };
713 match &admission {
714 LinkEntryAdmission::Filtered(entry) => {
715 crate::safedir::warn_if_root_acl_unpreserved(
716 &entry.src_handle,
717 src,
718 root_acl_notice,
719 )
720 .await;
721 }
722 LinkEntryAdmission::Unclassified { .. } => {
723 crate::safedir::warn_if_root_acl_unpreserved_at(
724 &src_parent,
725 src_name,
726 src,
727 root_acl_notice,
728 )
729 .await;
730 }
731 }
732 let dst_parent = if settings.dry_run.is_some() {
738 None
739 } else {
740 match strict_dst_parent {
741 Some(parent) => Some(parent),
742 None => {
743 let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
744 .await
745 .with_context(|| {
746 format!(
747 "cannot open destination parent directory {:?}",
748 dst_parent_path
749 )
750 })
751 .map_err(|err| Error::new(err, Default::default()))?;
752 Some(Arc::new(dir.into_tree()))
754 }
755 }
756 };
757 Ok(LinkRootSetup::Ready {
758 src_operand,
759 src_parent,
760 dst_parent,
761 update_parent,
762 admission,
763 })
764 })
765 .await?;
766 let (src_operand, src_parent, dst_parent, update_parent, admission) = match setup {
767 LinkRootSetup::Complete(summary) => return Ok(summary),
768 LinkRootSetup::Ready {
769 src_operand,
770 src_parent,
771 dst_parent,
772 update_parent,
773 admission,
774 } => (
775 src_operand,
776 src_parent,
777 dst_parent,
778 update_parent,
779 admission,
780 ),
781 };
782 let src = src_operand.display.as_path();
783 let src_name = src_operand.name.as_os_str();
784 let update_ref = update_parent
785 .as_ref()
786 .map(|(dir, name)| (dir, name.as_os_str()));
787 link_internal(
788 prog_track,
789 &src_parent,
790 update_ref,
791 dst_parent.as_ref(),
792 src_name,
793 src,
794 dst,
795 update.as_deref(),
796 std::path::Path::new(""),
797 update_root_requirement,
798 settings,
799 is_fresh,
800 admission,
801 )
802 .await
803 .map(|result| result.summary)
804}
805struct DeleteKeepSet {
812 inner: Option<std::collections::HashSet<std::ffi::OsString>>,
813}
814
815impl DeleteKeepSet {
816 fn new(delete: Option<©::DeleteSettings>) -> Self {
817 Self {
818 inner: delete.is_some().then(std::collections::HashSet::new),
819 }
820 }
821 fn record_exact(&mut self, name: std::ffi::OsString) {
823 if let Some(set) = &mut self.inner {
824 set.insert(name);
825 }
826 }
827 fn as_set(&self) -> Option<&std::collections::HashSet<std::ffi::OsString>> {
830 self.inner.as_ref()
831 }
832}
833
834struct AdmittedLinkEntry<S = Handle, U = Handle, P = LeafPermit> {
836 src_handle: S,
837 update_handle: Option<U>,
838 permit: Option<P>,
839}
840
841enum LinkEntryAdmission {
843 Unclassified {
844 admission: EntryAdmission,
845 hinted_src_handle: Option<Handle>,
846 },
847 Filtered(AdmittedLinkEntry),
848}
849
850struct LinkEntryResult {
852 summary: Summary,
853 protect_destination: bool,
854}
855
856impl LinkEntryResult {
857 fn selected(summary: Summary) -> Self {
858 Self {
859 summary,
860 protect_destination: true,
861 }
862 }
863
864 fn filtered(summary: Summary) -> Self {
865 Self {
866 summary,
867 protect_destination: false,
868 }
869 }
870}
871
872struct LinkTaskResult {
874 summary: Summary,
875 keep_name: Option<std::ffi::OsString>,
876}
877
878impl LinkTaskResult {
879 fn from_link(name: std::ffi::OsString, result: LinkEntryResult) -> Self {
880 Self {
881 summary: result.summary,
882 keep_name: result.protect_destination.then_some(name),
883 }
884 }
885
886 fn from_update(name: std::ffi::OsString, result: LinkEntryResult) -> Self {
887 Self::from_link(name, result)
888 }
889
890 fn fold(self, summary: &mut Summary, keep_set: &mut DeleteKeepSet) {
891 if let Some(name) = self.keep_name {
892 keep_set.record_exact(name);
893 }
894 *summary = *summary + self.summary;
895 }
896}
897
898impl LinkEntryAdmission {
899 fn admission(&self) -> Option<throttle::FdAdmission> {
900 match self {
901 Self::Unclassified { admission, .. } => admission.admission(),
902 Self::Filtered(entry) => entry.permit.as_ref().map(LeafPermit::admission),
903 }
904 }
905}
906
907impl From<EntryAdmission> for LinkEntryAdmission {
908 fn from(admission: EntryAdmission) -> Self {
909 Self::Unclassified {
910 admission,
911 hinted_src_handle: None,
912 }
913 }
914}
915
916enum RootFilterSelection {
918 SourceOnly,
919 WithUpdate,
920 Skip {
921 kind: EntryKind,
922 result: crate::filter::FilterResult,
923 },
924}
925
926fn select_filtered_root(
928 filter: &crate::filter::FilterSettings,
929 logical_name: &std::ffi::OsStr,
930 src_kind: EntryKind,
931 update_kind: Option<EntryKind>,
932 update_exclusive: bool,
933) -> RootFilterSelection {
934 let logical_name = std::path::Path::new(logical_name);
935 let src_result = filter.should_include_root_item(logical_name, src_kind == EntryKind::Dir);
936 let src_included = matches!(&src_result, crate::filter::FilterResult::Included);
937 let Some(update_kind) = update_kind else {
938 return if src_included {
939 RootFilterSelection::SourceOnly
940 } else {
941 RootFilterSelection::Skip {
942 kind: src_kind,
943 result: src_result,
944 }
945 };
946 };
947 let update_result =
948 filter.should_include_root_item(logical_name, update_kind == EntryKind::Dir);
949 if matches!(&update_result, crate::filter::FilterResult::Included) {
950 RootFilterSelection::WithUpdate
951 } else if !update_exclusive && src_included {
952 RootFilterSelection::SourceOnly
953 } else {
954 let (kind, result) = if update_exclusive {
955 (update_kind, update_result)
956 } else {
957 (src_kind, src_result)
958 };
959 RootFilterSelection::Skip { kind, result }
960 }
961}
962
963fn select_filtered_nested(
965 filter: &crate::filter::FilterSettings,
966 logical_path: &std::path::Path,
967 src_kind: EntryKind,
968 update_kind: Option<EntryKind>,
969 update_exclusive: bool,
970) -> RootFilterSelection {
971 let src_result = filter.should_include(logical_path, src_kind == EntryKind::Dir);
972 let src_included = matches!(&src_result, crate::filter::FilterResult::Included);
973 let Some(update_kind) = update_kind else {
974 return if src_included {
975 RootFilterSelection::SourceOnly
976 } else {
977 RootFilterSelection::Skip {
978 kind: src_kind,
979 result: src_result,
980 }
981 };
982 };
983 let update_result = filter.should_include(logical_path, update_kind == EntryKind::Dir);
984 if matches!(&update_result, crate::filter::FilterResult::Included) {
985 RootFilterSelection::WithUpdate
986 } else if !update_exclusive && src_included {
987 RootFilterSelection::SourceOnly
988 } else {
989 let (kind, result) = if update_exclusive {
990 (update_kind, update_result)
991 } else {
992 (src_kind, src_result)
993 };
994 RootFilterSelection::Skip { kind, result }
995 }
996}
997
998fn drop_link_handles_before_permit<S, U, P>(
999 src_handle: S,
1000 update_handle: Option<U>,
1001 permit: Option<P>,
1002) {
1003 drop(src_handle);
1004 drop(update_handle);
1005 drop(permit);
1006}
1007
1008impl AdmittedLinkEntry<Handle, Handle, LeafPermit> {
1009 fn new(src_handle: Handle, permit: Option<LeafPermit>) -> Self {
1010 Self {
1011 src_handle,
1012 update_handle: None,
1013 permit,
1014 }
1015 }
1016
1017 #[allow(clippy::result_large_err)]
1020 fn into_source_leaf(self) -> Result<AdmittedLeaf, Self> {
1021 let Self {
1022 src_handle,
1023 update_handle,
1024 permit,
1025 } = self;
1026 match AdmittedLeaf::try_new(src_handle, permit) {
1027 Ok(leaf) => {
1028 drop(update_handle);
1029 Ok(leaf)
1030 }
1031 Err((src_handle, permit)) => Err(Self {
1032 src_handle,
1033 update_handle,
1034 permit,
1035 }),
1036 }
1037 }
1038
1039 fn into_update_entry(self) -> AdmittedEntry {
1040 let Self {
1041 src_handle,
1042 update_handle,
1043 permit,
1044 } = self;
1045 drop(src_handle);
1046 let update_handle =
1047 update_handle.expect("update action requires a classified update entry");
1048 AdmittedEntry::new(update_handle, permit)
1049 }
1050
1051 fn into_source_entry(self) -> AdmittedEntry {
1052 let Self {
1053 src_handle,
1054 update_handle,
1055 permit,
1056 } = self;
1057 drop(update_handle);
1058 AdmittedEntry::new(src_handle, permit)
1059 }
1060
1061 fn close_for_directory(self) -> bool {
1062 let Self {
1063 src_handle,
1064 update_handle,
1065 permit,
1066 } = self;
1067 let has_update_dir = update_handle
1068 .as_ref()
1069 .is_some_and(|handle| handle.kind() == EntryKind::Dir);
1070 drop_link_handles_before_permit(src_handle, update_handle, permit);
1071 has_update_dir
1072 }
1073}
1074
1075#[instrument(skip(prog_track, src_parent, update, dst_parent, settings, admission))]
1090#[async_recursion]
1091#[allow(clippy::too_many_arguments)]
1092async fn link_internal(
1093 prog_track: &'static progress::Progress,
1094 src_parent: &Arc<Dir>,
1095 update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
1096 dst_parent: Option<&Arc<Dir>>,
1097 name: &std::ffi::OsStr,
1098 src_root: &std::path::Path,
1099 dst_root: &std::path::Path,
1100 update_root: Option<&std::path::Path>,
1101 rel_path: &std::path::Path,
1102 update_root_requirement: UpdateRootRequirement,
1103 settings: &Settings,
1104 is_fresh: bool,
1105 admission: LinkEntryAdmission,
1106) -> Result<LinkEntryResult, Error> {
1107 let _prog_guard = prog_track.ops.guard();
1108 let (src_path, dst_path) = if rel_path.as_os_str().is_empty() {
1113 (src_root.to_path_buf(), dst_root.to_path_buf())
1114 } else {
1115 (src_root.join(rel_path), dst_root.join(rel_path))
1116 };
1117 let update_path = update_root.map(|root| {
1118 if rel_path.as_os_str().is_empty() {
1119 root.to_path_buf()
1120 } else {
1121 root.join(rel_path)
1122 }
1123 });
1124 let dst_name = dst_path
1128 .file_name()
1129 .ok_or_else(|| {
1130 Error::new(
1131 anyhow!("link destination {:?} has no file name", &dst_path),
1132 Default::default(),
1133 )
1134 })?
1135 .to_owned();
1136 let mut admission = admission;
1137 if let LinkEntryAdmission::Unclassified {
1138 admission: entry_admission,
1139 hinted_src_handle,
1140 } = &mut admission
1141 {
1142 let current = std::mem::replace(entry_admission, EntryAdmission::RootOrDelegated);
1143 *entry_admission = if update.is_some() {
1144 current.require_admission()
1145 } else {
1146 current
1147 };
1148 if matches!(entry_admission, EntryAdmission::HintedDirectory) {
1152 tracing::debug!("classifying hinted source entry");
1153 let handle = walk::classify_entry(src_parent, name)
1154 .await
1155 .with_context(|| format!("failed reading metadata from {:?}", &src_path))
1156 .map_err(|err| Error::new(err, Default::default()))?;
1157 if handle.kind() == EntryKind::Dir {
1158 *hinted_src_handle = Some(handle);
1159 } else {
1160 drop(handle);
1161 *entry_admission = EntryAdmission::RootOrDelegated;
1162 }
1163 }
1164 if hinted_src_handle.is_none() || update.is_some() {
1165 let current = std::mem::replace(entry_admission, EntryAdmission::RootOrDelegated);
1166 *entry_admission = walk::ensure_entry_admission(PermitKind::OpenFile, current).await;
1167 }
1168 }
1169 let weak_admission = admission.admission();
1170
1171 enum LinkDispatch {
1172 Complete(LinkEntryResult),
1173 Directory { has_update_dir: bool },
1174 }
1175
1176 let dispatch = safedir::with_optional_fd_admission(weak_admission, async {
1177 let (mut entry, root_filter_applied) = match admission {
1178 LinkEntryAdmission::Filtered(entry) => (entry, true),
1179 LinkEntryAdmission::Unclassified {
1180 admission,
1181 hinted_src_handle,
1182 } => {
1183 let src_handle = match hinted_src_handle {
1184 Some(handle) => handle,
1185 None => {
1186 tracing::debug!("classifying source entry");
1187 walk::classify_entry(src_parent, name)
1188 .await
1189 .with_context(|| {
1190 format!("failed reading metadata from {:?}", &src_path)
1191 })
1192 .map_err(|err| Error::new(err, Default::default()))?
1193 }
1194 };
1195 let mut entry = AdmittedLinkEntry::new(src_handle, admission.into_permit());
1196 entry.update_handle = match update {
1200 Some((update_dir, update_name)) => {
1201 tracing::debug!("classifying 'update' entry");
1202 let update_path = update_path
1203 .as_deref()
1204 .expect("update directory present implies update path present");
1205 update_root_requirement
1206 .for_entry(rel_path)
1207 .classify(update_dir.child(update_name).await, update_path)?
1208 }
1209 None => None,
1210 };
1211 if update.is_some() && settings.update_exclusive && entry.update_handle.is_none() {
1212 return Ok(LinkDispatch::Complete(LinkEntryResult::filtered(
1213 Default::default(),
1214 )));
1215 }
1216 (entry, false)
1217 }
1218 };
1219 if !root_filter_applied
1223 && rel_path.as_os_str().is_empty()
1224 && let Some(filter) = settings.filter.as_ref()
1225 {
1226 match select_filtered_root(
1227 filter,
1228 name,
1229 entry.src_handle.kind(),
1230 entry.update_handle.as_ref().map(Handle::kind),
1231 settings.update_exclusive,
1232 ) {
1233 RootFilterSelection::SourceOnly => entry.update_handle = None,
1234 RootFilterSelection::WithUpdate => {}
1235 RootFilterSelection::Skip { kind, result } => {
1236 if let Some(mode) = settings.dry_run {
1237 crate::dry_run::report_skip(&src_path, &result, mode, kind.label_long());
1238 }
1239 kind.inc_skipped(prog_track);
1240 return Ok(LinkDispatch::Complete(LinkEntryResult::filtered(
1241 skipped_summary_for(kind),
1242 )));
1243 }
1244 }
1245 } else if !rel_path.as_os_str().is_empty()
1249 && let Some(filter) = settings.filter.as_ref()
1250 {
1251 match select_filtered_nested(
1252 filter,
1253 rel_path,
1254 entry.src_handle.kind(),
1255 entry.update_handle.as_ref().map(Handle::kind),
1256 settings.update_exclusive,
1257 ) {
1258 RootFilterSelection::SourceOnly => entry.update_handle = None,
1259 RootFilterSelection::WithUpdate => {}
1260 RootFilterSelection::Skip { kind, result } => {
1261 if let Some(mode) = settings.dry_run {
1262 crate::dry_run::report_skip(&src_path, &result, mode, kind.label_long());
1263 }
1264 kind.inc_skipped(prog_track);
1265 return Ok(LinkDispatch::Complete(LinkEntryResult::filtered(
1266 skipped_summary_for(kind),
1267 )));
1268 }
1269 }
1270 }
1271 if let Some(update_entry) = entry.update_handle.as_ref() {
1278 let (update_dir, update_name) = update.unwrap();
1279 let update_path = update_path.as_deref().unwrap();
1280 if update_entry.kind() != entry.src_handle.kind() {
1281 tracing::debug!(
1283 "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
1284 src_path,
1285 entry.src_handle.kind(),
1286 update_path,
1287 update_entry.kind()
1288 );
1289 let admission = entry.into_update_entry();
1295 return delegate_copy(
1296 prog_track,
1297 update_dir,
1298 dst_parent,
1299 update_name,
1300 update_path,
1301 &dst_path,
1302 rel_path,
1303 copy::DeleteScanAnchor::new(dst_root, rel_path),
1304 settings,
1305 is_fresh,
1306 admission,
1307 )
1308 .await
1309 .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1310 }
1311 if update_entry.kind() == EntryKind::File {
1312 if filecmp::metadata_equal(
1314 &settings.update_compare,
1315 entry.src_handle.meta(),
1316 update_entry.meta(),
1317 ) {
1318 tracing::debug!("no change, hard link 'src'");
1320 let leaf = match entry.into_source_leaf() {
1321 Ok(leaf) => leaf,
1322 Err(entry) => {
1323 return Ok(LinkDispatch::Directory {
1324 has_update_dir: entry.close_for_directory(),
1325 });
1326 }
1327 };
1328 if settings.dry_run.is_some() {
1329 crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
1330 return Ok(LinkDispatch::Complete(LinkEntryResult::selected(Summary {
1331 hard_links_created: 1,
1332 ..Default::default()
1333 })));
1334 }
1335 let dst_dir =
1336 dst_parent.expect("destination parent must be open for a real hard link");
1337 return hard_link_entry_fd(
1338 prog_track,
1339 leaf.handle(),
1340 dst_dir,
1341 &dst_name,
1342 &dst_path,
1343 settings,
1344 )
1345 .await
1346 .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1347 }
1348 tracing::debug!(
1349 "link: {:?} metadata has changed, copying from {:?}",
1350 src_path,
1351 update_path
1352 );
1353 let admission = entry.into_update_entry();
1355 return delegate_copy(
1356 prog_track,
1357 update_dir,
1358 dst_parent,
1359 update_name,
1360 update_path,
1361 &dst_path,
1362 rel_path,
1363 copy::DeleteScanAnchor::new(dst_root, rel_path),
1364 settings,
1365 is_fresh,
1366 admission,
1367 )
1368 .await
1369 .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1370 }
1371 if update_entry.kind() == EntryKind::Symlink {
1372 tracing::debug!("'update' is a symlink so just symlink that");
1375 let admission = entry.into_update_entry();
1376 return delegate_copy(
1377 prog_track,
1378 update_dir,
1379 dst_parent,
1380 update_name,
1381 update_path,
1382 &dst_path,
1383 rel_path,
1384 copy::DeleteScanAnchor::new(dst_root, rel_path),
1385 settings,
1386 is_fresh,
1387 admission,
1388 )
1389 .await
1390 .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1391 }
1392 } else {
1393 tracing::debug!("no 'update' entry");
1396 if entry.src_handle.kind() == EntryKind::File {
1397 let leaf = match entry.into_source_leaf() {
1398 Ok(leaf) => leaf,
1399 Err(entry) => {
1400 return Ok(LinkDispatch::Directory {
1401 has_update_dir: entry.close_for_directory(),
1402 });
1403 }
1404 };
1405 if settings.dry_run.is_some() {
1406 crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
1407 return Ok(LinkDispatch::Complete(LinkEntryResult::selected(Summary {
1408 hard_links_created: 1,
1409 ..Default::default()
1410 })));
1411 }
1412 let dst_dir =
1413 dst_parent.expect("destination parent must be open for a real hard link");
1414 return hard_link_entry_fd(
1415 prog_track,
1416 leaf.handle(),
1417 dst_dir,
1418 &dst_name,
1419 &dst_path,
1420 settings,
1421 )
1422 .await
1423 .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1424 }
1425 if entry.src_handle.kind() == EntryKind::Symlink {
1426 tracing::debug!("'src' is a symlink so just symlink that");
1427 let admission = entry.into_source_entry();
1428 return delegate_copy(
1429 prog_track,
1430 src_parent,
1431 dst_parent,
1432 name,
1433 &src_path,
1434 &dst_path,
1435 rel_path,
1436 copy::DeleteScanAnchor::new(dst_root, rel_path),
1437 settings,
1438 is_fresh,
1439 admission,
1440 )
1441 .await
1442 .map(|summary| LinkDispatch::Complete(LinkEntryResult::selected(summary)));
1443 }
1444 }
1445 if entry.src_handle.kind() != EntryKind::Dir {
1446 if settings.copy_settings.skip_specials {
1449 tracing::debug!(
1450 "skipping special file {:?} (kind: {:?})",
1451 src_path,
1452 entry.src_handle.kind()
1453 );
1454 if let Some(mode) = settings.dry_run {
1455 match mode {
1456 crate::config::DryRunMode::Brief => {}
1457 crate::config::DryRunMode::All => println!("skip special {:?}", src_path),
1458 crate::config::DryRunMode::Explain => {
1459 println!(
1460 "skip special {:?} (unsupported file type: {:?})",
1461 src_path,
1462 entry.src_handle.kind()
1463 );
1464 }
1465 }
1466 }
1467 prog_track.specials_skipped.inc();
1468 return Ok(LinkDispatch::Complete(LinkEntryResult::selected(Summary {
1469 copy_summary: CopySummary {
1470 specials_skipped: 1,
1471 ..Default::default()
1472 },
1473 ..Default::default()
1474 })));
1475 }
1476 return Err(Error::new(
1477 anyhow!(
1478 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
1479 src_path,
1480 dst_path,
1481 entry.src_handle.kind()
1482 ),
1483 Default::default(),
1484 ));
1485 }
1486 debug_assert!(
1489 entry.update_handle.is_none()
1490 || entry.update_handle.as_ref().unwrap().kind() == EntryKind::Dir
1491 );
1492 let has_update_dir = entry.close_for_directory();
1498 Ok(LinkDispatch::Directory { has_update_dir })
1499 })
1500 .await?;
1501 let has_update_dir = match dispatch {
1502 LinkDispatch::Complete(summary) => return Ok(summary),
1503 LinkDispatch::Directory { has_update_dir } => has_update_dir,
1504 };
1505 let update_for_dir = update.filter(|_| has_update_dir);
1506 let update_root_for_dir = update_root.filter(|_| has_update_dir);
1507 let update_path_for_dir = update_path.as_deref().filter(|_| has_update_dir);
1508 link_dir_entry(
1509 prog_track,
1510 src_parent,
1511 update_for_dir,
1512 dst_parent,
1513 name,
1514 &dst_name,
1515 src_root,
1516 dst_root,
1517 update_root_for_dir,
1518 rel_path,
1519 &src_path,
1520 &dst_path,
1521 update_path_for_dir,
1522 settings,
1523 is_fresh,
1524 )
1525 .await
1526 .map(LinkEntryResult::selected)
1527}
1528
1529#[allow(clippy::too_many_arguments)]
1536async fn delegate_copy(
1537 prog_track: &'static progress::Progress,
1538 src_parent: &Arc<Dir>,
1539 dst_parent: Option<&Arc<Dir>>,
1540 name: &std::ffi::OsStr,
1541 src_path: &std::path::Path,
1542 dst_path: &std::path::Path,
1543 filter_base: &std::path::Path,
1544 delete_scan_anchor: copy::DeleteScanAnchor,
1545 settings: &Settings,
1546 is_fresh: bool,
1547 admission: AdmittedEntry,
1548) -> Result<Summary, Error> {
1549 let normalized_copy_settings =
1553 (settings.copy_settings.dry_run != settings.dry_run).then(|| {
1554 let mut copy_settings = settings.copy_settings.clone();
1555 copy_settings.dry_run = settings.dry_run;
1556 copy_settings
1557 });
1558 let copy_settings = normalized_copy_settings
1559 .as_ref()
1560 .unwrap_or(&settings.copy_settings);
1561 let copy_summary = copy::copy_child(
1562 prog_track,
1563 src_parent,
1564 dst_parent,
1565 name,
1566 src_path,
1567 dst_path,
1568 filter_base,
1569 copy_settings,
1570 &settings.preserve,
1571 is_fresh,
1572 delete_scan_anchor,
1573 copy::CopyEntryAdmission::Filtered(admission),
1574 )
1575 .await
1576 .map_err(|err| {
1577 let copy_summary = err.summary;
1578 Error::new(
1579 err.source,
1580 Summary {
1581 copy_summary,
1582 ..Default::default()
1583 },
1584 )
1585 })?;
1586 Ok(Summary {
1587 copy_summary,
1588 ..Default::default()
1589 })
1590}
1591
1592#[allow(clippy::too_many_arguments)]
1596async fn link_dir_entry(
1597 prog_track: &'static progress::Progress,
1598 src_parent: &Arc<Dir>,
1599 update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
1600 dst_parent: Option<&Arc<Dir>>,
1601 name: &std::ffi::OsStr,
1602 dst_name: &std::ffi::OsStr,
1603 src_root: &std::path::Path,
1604 dst_root: &std::path::Path,
1605 update_root: Option<&std::path::Path>,
1606 rel_path: &std::path::Path,
1607 src_path: &std::path::Path,
1608 dst_path: &std::path::Path,
1609 update_path: Option<&std::path::Path>,
1610 settings: &Settings,
1611 is_fresh: bool,
1612) -> Result<Summary, Error> {
1613 let src_dir = src_parent
1614 .open_dir(name)
1615 .await
1616 .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1617 .map_err(|err| Error::new(err, Default::default()))?;
1618 let src_dir = Arc::new(src_dir);
1619 let update_dir = match update {
1621 Some((update_parent, update_name)) => {
1622 let dir = update_parent
1623 .open_dir(update_name)
1624 .await
1625 .with_context(|| {
1626 format!("cannot open update directory {:?} for reading", update_path)
1627 })
1628 .map_err(|err| Error::new(err, Default::default()))?;
1629 Some(Arc::new(dir))
1630 }
1631 None => None,
1632 };
1633 if settings.dry_run.is_some() {
1635 crate::dry_run::report_action("link", src_path, Some(dst_path), "dir");
1636 let base = Summary {
1637 copy_summary: CopySummary {
1638 directories_created: 1, ..Default::default()
1640 },
1641 ..Default::default()
1642 };
1643 return link_dir_contents(
1644 prog_track,
1645 &src_dir,
1646 update_dir.as_ref(),
1647 None, None, dst_name,
1650 src_root,
1651 dst_root,
1652 update_root,
1653 rel_path,
1654 src_path,
1655 dst_path,
1656 true, is_fresh,
1658 None, settings,
1660 base,
1661 SourceEntries::Live,
1662 UpdateEntries::Live,
1663 )
1664 .await;
1665 }
1666 let dst_parent = dst_parent.expect("destination parent must be open for a real link");
1668 let copy::DirSlot {
1669 dir: dst_dir,
1670 summary: base,
1671 is_fresh: child_is_fresh,
1672 we_created,
1673 reused_lock,
1674 } = match copy::resolve_dst_dir(
1675 prog_track,
1676 dst_parent,
1677 dst_name,
1678 dst_path,
1679 &settings.copy_settings,
1680 )
1681 .await
1682 .map_err(|err| {
1683 Error::new(
1684 err.source,
1685 Summary {
1686 copy_summary: err.summary,
1687 ..Default::default()
1688 },
1689 )
1690 })? {
1691 copy::DirResolution::Skip(summary) => {
1692 return Ok(Summary {
1693 copy_summary: summary,
1694 ..Default::default()
1695 });
1696 }
1697 copy::DirResolution::Proceed(slot) => slot,
1698 };
1699 link_dir_contents(
1700 prog_track,
1701 &src_dir,
1702 update_dir.as_ref(),
1703 Some(&dst_dir),
1704 Some(dst_parent),
1705 dst_name,
1706 src_root,
1707 dst_root,
1708 update_root,
1709 rel_path,
1710 src_path,
1711 dst_path,
1712 we_created,
1713 child_is_fresh,
1714 reused_lock,
1715 settings,
1716 Summary {
1717 copy_summary: base,
1718 ..Default::default()
1719 },
1720 SourceEntries::Live,
1721 UpdateEntries::Live,
1722 )
1723 .await
1724}
1725
1726fn source_entry_admission(hint: Option<EntryKind>, has_update: bool) -> EntryAdmission {
1731 let admission = EntryAdmission::from_hint(hint);
1732 if has_update {
1733 admission.require_admission()
1736 } else {
1737 admission
1738 }
1739}
1740
1741fn requires_exact_entry_outcome(settings: &Settings) -> bool {
1743 settings.copy_settings.delete.is_some() || settings.dry_run.is_some()
1744}
1745
1746enum SourceEntryDecision {
1748 Filtered {
1750 kind: EntryKind,
1751 result: crate::filter::FilterResult,
1752 },
1753 Dispatch(EntryAdmission),
1755}
1756
1757fn select_source_for_dispatch(
1761 hint: Option<EntryKind>,
1762 relative_path: &std::path::Path,
1763 settings: &Settings,
1764 has_update: bool,
1765) -> SourceEntryDecision {
1766 if let Some(kind) = hint
1767 && let Some(result) = walk::should_skip_entry_ref(
1768 settings.filter.as_ref(),
1769 relative_path,
1770 kind == EntryKind::Dir,
1771 )
1772 && !requires_exact_entry_outcome(settings)
1773 {
1774 return SourceEntryDecision::Filtered { kind, result };
1775 }
1776 SourceEntryDecision::Dispatch(source_entry_admission(hint, has_update))
1777}
1778
1779enum UpdateOnlyDecision {
1781 Skipped {
1783 kind: EntryKind,
1784 result: crate::filter::FilterResult,
1785 },
1786 Admitted(AdmittedEntry),
1788}
1789
1790fn select_exact_update_only_entry(
1791 entry: AdmittedEntry,
1792 relative_path: &std::path::Path,
1793 filter: Option<&crate::filter::FilterSettings>,
1794) -> UpdateOnlyDecision {
1795 let kind = entry.kind();
1796 match walk::should_skip_entry_ref(filter, relative_path, kind == EntryKind::Dir) {
1797 Some(result) => UpdateOnlyDecision::Skipped { kind, result },
1798 None => UpdateOnlyDecision::Admitted(entry),
1799 }
1800}
1801
1802enum UpdateOnlyDispatch {
1804 Skipped {
1806 kind: EntryKind,
1807 result: crate::filter::FilterResult,
1808 },
1809 Dispatch(EntryAdmission),
1811}
1812
1813fn select_update_only_for_dispatch(
1815 hint: Option<EntryKind>,
1816 relative_path: &std::path::Path,
1817 settings: &Settings,
1818) -> UpdateOnlyDispatch {
1819 if let Some(kind) = hint
1820 && let Some(result) = walk::should_skip_entry_ref(
1821 settings.filter.as_ref(),
1822 relative_path,
1823 kind == EntryKind::Dir,
1824 )
1825 && !requires_exact_entry_outcome(settings)
1826 {
1827 return UpdateOnlyDispatch::Skipped { kind, result };
1828 }
1829 UpdateOnlyDispatch::Dispatch(EntryAdmission::from_hint(hint))
1830}
1831
1832async fn select_exact_update_only_for_dispatch(
1837 update_dir: &Arc<Dir>,
1838 name: &std::ffi::OsStr,
1839 mut admission: EntryAdmission,
1840 relative_path: &std::path::Path,
1841 filter: Option<&crate::filter::FilterSettings>,
1842) -> std::io::Result<UpdateOnlyDecision> {
1843 let entry = if matches!(admission, EntryAdmission::HintedDirectory) {
1844 let handle = walk::classify_entry(update_dir, name).await?;
1845 if handle.kind() == EntryKind::Dir {
1846 AdmittedEntry::new(handle, None)
1847 } else {
1848 drop(handle);
1849 admission = EntryAdmission::RootOrDelegated;
1850 let admission = walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
1851 walk::classify_admitted_entry(update_dir, name, admission.into_permit()).await?
1852 }
1853 } else {
1854 let admission = walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
1855 walk::classify_admitted_entry(update_dir, name, admission.into_permit()).await?
1856 };
1857 Ok(select_exact_update_only_entry(entry, relative_path, filter))
1858}
1859
1860#[allow(clippy::too_many_arguments)]
1863async fn process_update_only_entry(
1864 prog_track: &'static progress::Progress,
1865 update_parent: Arc<Dir>,
1866 dst_parent: Option<Arc<Dir>>,
1867 entry_name: std::ffi::OsString,
1868 update_entry_path: std::path::PathBuf,
1869 dst_entry_path: std::path::PathBuf,
1870 entry_rel: std::path::PathBuf,
1871 delete_scan_anchor: copy::DeleteScanAnchor,
1872 settings: Settings,
1873 is_fresh: bool,
1874 admission: EntryAdmission,
1875) -> Result<LinkTaskResult, Error> {
1876 let decision = select_exact_update_only_for_dispatch(
1877 &update_parent,
1878 &entry_name,
1879 admission,
1880 &entry_rel,
1881 settings.filter.as_ref(),
1882 )
1883 .await
1884 .with_context(|| format!("failed reading metadata from {update_entry_path:?}"))
1885 .map_err(|err| Error::new(err, Default::default()))?;
1886 let entry = match decision {
1887 UpdateOnlyDecision::Skipped { kind, result } => {
1888 if let Some(mode) = settings.dry_run {
1889 crate::dry_run::report_skip(&update_entry_path, &result, mode, kind.label());
1890 }
1891 tracing::debug!(
1892 "skipping update entry {:?} due to filter",
1893 &update_entry_path
1894 );
1895 kind.inc_skipped(prog_track);
1896 return Ok(LinkTaskResult::from_update(
1897 entry_name,
1898 LinkEntryResult::filtered(skipped_summary_for(kind)),
1899 ));
1900 }
1901 UpdateOnlyDecision::Admitted(entry) => entry,
1902 };
1903 let entry_kind = entry.kind();
1904 if settings.copy_settings.skip_specials && entry_kind == EntryKind::Special {
1905 tracing::debug!("skipping special file {:?}", &update_entry_path);
1906 if let Some(mode) = settings.dry_run {
1907 match mode {
1908 crate::config::DryRunMode::Brief => {}
1909 crate::config::DryRunMode::All => println!("skip special {:?}", &update_entry_path),
1910 crate::config::DryRunMode::Explain => {
1911 println!(
1912 "skip special {:?} (unsupported file type: {:?})",
1913 &update_entry_path, entry_kind
1914 );
1915 }
1916 }
1917 }
1918 prog_track.specials_skipped.inc();
1919 return Ok(LinkTaskResult::from_update(
1920 entry_name,
1921 LinkEntryResult::selected(Summary {
1922 copy_summary: CopySummary {
1923 specials_skipped: 1,
1924 ..Default::default()
1925 },
1926 ..Default::default()
1927 }),
1928 ));
1929 }
1930 tracing::debug!("found a new entry in the 'update' directory");
1931 let summary = delegate_copy(
1932 prog_track,
1933 &update_parent,
1934 dst_parent.as_ref(),
1935 &entry_name,
1936 &update_entry_path,
1937 &dst_entry_path,
1938 &entry_rel,
1939 delete_scan_anchor,
1940 &settings,
1941 is_fresh,
1942 entry,
1943 )
1944 .await?;
1945 Ok(LinkTaskResult::from_update(
1946 entry_name,
1947 LinkEntryResult::selected(summary),
1948 ))
1949}
1950
1951fn fold_link_task_result(
1952 result: Result<Result<LinkTaskResult, Error>, tokio::task::JoinError>,
1953 link_summary: &mut Summary,
1954 keep_set: &mut DeleteKeepSet,
1955 errors: &crate::error_collector::ErrorCollector,
1956 fail_early: bool,
1957 src_path: &std::path::Path,
1958 dst_path: &std::path::Path,
1959) -> Result<(), anyhow::Error> {
1960 match result {
1961 Ok(Ok(result)) => result.fold(link_summary, keep_set),
1962 Ok(Err(error)) => {
1963 tracing::error!(
1964 "link: {:?} -> {:?} failed with: {:#}",
1965 src_path,
1966 dst_path,
1967 &error
1968 );
1969 *link_summary = *link_summary + error.summary;
1970 if fail_early {
1971 return Err(error.source);
1972 }
1973 errors.push(error.source);
1974 }
1975 Err(join_error) => {
1976 if fail_early {
1977 return Err(join_error.into());
1978 }
1979 errors.push(join_error.into());
1980 }
1981 }
1982 Ok(())
1983}
1984
1985#[allow(clippy::too_many_arguments)]
1989async fn ensure_link_admission(
1990 admission: EntryAdmission,
1991 join_set: &mut tokio::task::JoinSet<Result<LinkTaskResult, Error>>,
1992 link_summary: &mut Summary,
1993 keep_set: &mut DeleteKeepSet,
1994 errors: &crate::error_collector::ErrorCollector,
1995 fail_early: bool,
1996 src_path: &std::path::Path,
1997 dst_path: &std::path::Path,
1998) -> Result<EntryAdmission, anyhow::Error> {
1999 let admission = walk::ensure_entry_admission(PermitKind::OpenFile, admission);
2000 tokio::pin!(admission);
2001 loop {
2002 let has_tasks = !join_set.is_empty();
2003 tokio::select! {
2004 biased;
2005 result = join_set.join_next(), if has_tasks => {
2006 fold_link_task_result(
2007 result.expect("a non-empty link JoinSet must yield one task result"),
2008 link_summary,
2009 keep_set,
2010 errors,
2011 fail_early,
2012 src_path,
2013 dst_path,
2014 )?;
2015 }
2016 admission = &mut admission => return Ok(admission),
2017 }
2018 }
2019}
2020
2021enum SourceEntries {
2022 Live,
2023 #[cfg(test)]
2024 Injected(Vec<(std::ffi::OsString, Option<EntryKind>)>),
2025}
2026
2027enum UpdateEntries {
2028 Live,
2029 #[cfg(test)]
2030 Injected(Vec<(std::ffi::OsString, Option<EntryKind>)>),
2031}
2032
2033#[allow(clippy::too_many_arguments)]
2041async fn link_dir_contents(
2042 prog_track: &'static progress::Progress,
2043 src_dir: &Arc<Dir>,
2044 update_dir: Option<&Arc<Dir>>,
2045 dst_dir: Option<&Arc<Dir>>,
2046 dst_parent: Option<&Arc<Dir>>,
2047 dst_name: &std::ffi::OsStr,
2048 src_root: &std::path::Path,
2049 dst_root: &std::path::Path,
2050 update_root: Option<&std::path::Path>,
2051 rel_path: &std::path::Path,
2052 src_path: &std::path::Path,
2053 dst_path: &std::path::Path,
2054 we_created_this_dir: bool,
2055 is_fresh: bool,
2056 reused_lock: Option<crate::safedir::ReusedDirLock>,
2057 settings: &Settings,
2058 base: Summary,
2059 source_entries: SourceEntries,
2060 update_entries: UpdateEntries,
2061) -> Result<Summary, Error> {
2062 tracing::debug!("process contents of 'src' directory");
2063 let src_entries = match source_entries {
2064 SourceEntries::Live => src_dir
2065 .read_entries()
2066 .await
2067 .with_context(|| format!("cannot open directory {src_path:?} for reading"))
2068 .map_err(|err| Error::new(err, base))?,
2069 #[cfg(test)]
2070 SourceEntries::Injected(entries) => entries,
2071 };
2072 let mut link_summary = base;
2073 let mut join_set = tokio::task::JoinSet::new();
2074 let errors = crate::error_collector::ErrorCollector::default();
2075 let mut processed_files = std::collections::HashSet::new();
2078 let mut keep_set = DeleteKeepSet::new(settings.copy_settings.delete.as_ref());
2080 for (entry_name, hint) in src_entries {
2082 let entry_rel = rel_path.join(&entry_name);
2083 let entry_path = src_path.join(&entry_name);
2084 let decision = select_source_for_dispatch(hint, &entry_rel, settings, update_dir.is_some());
2085 let admission = match decision {
2086 SourceEntryDecision::Filtered { kind, result } => {
2087 if let Some(mode) = settings.dry_run {
2088 crate::dry_run::report_skip(&entry_path, &result, mode, kind.label());
2089 }
2090 tracing::debug!("skipping {:?} due to filter", &entry_path);
2091 link_summary = link_summary + skipped_summary_for(kind);
2092 kind.inc_skipped(prog_track);
2093 continue;
2094 }
2095 SourceEntryDecision::Dispatch(admission) => admission,
2096 };
2097 processed_files.insert(entry_name.clone());
2098 let admission = match ensure_link_admission(
2099 admission,
2100 &mut join_set,
2101 &mut link_summary,
2102 &mut keep_set,
2103 &errors,
2104 settings.copy_settings.fail_early,
2105 src_path,
2106 dst_path,
2107 )
2108 .await
2109 {
2110 Ok(admission) => admission,
2111 Err(error) => {
2112 crate::walk_driver::abort_and_join(&mut join_set).await;
2113 return Err(Error::new(error, link_summary));
2114 }
2115 };
2116 let src_parent = Arc::clone(src_dir);
2122 let dst_parent = dst_dir.map(Arc::clone);
2123 let update_parent = update_dir.map(Arc::clone);
2124 let settings = settings.clone();
2125 let src_root = src_root.to_owned();
2126 let dst_root = dst_root.to_owned();
2127 let update_root = update_root.map(std::path::Path::to_path_buf);
2128 let do_link = move || async move {
2129 let update_ref = update_parent
2130 .as_ref()
2131 .map(|dir| (dir, entry_name.as_os_str()));
2132 link_internal(
2133 prog_track,
2134 &src_parent,
2135 update_ref,
2136 dst_parent.as_ref(),
2137 &entry_name,
2138 &src_root,
2139 &dst_root,
2140 update_root.as_deref(),
2141 &entry_rel,
2142 UpdateRootRequirement::Optional,
2143 &settings,
2144 is_fresh,
2145 admission.into(),
2146 )
2147 .await
2148 .map(|result| LinkTaskResult::from_link(entry_name, result))
2149 };
2150 crate::walk_driver::spawn_tracked(&mut join_set, do_link());
2151 }
2152 if let Some(update_dir) = update_dir {
2154 let update_root = update_root.expect("update_dir present implies update_root present");
2155 tracing::debug!("process contents of 'update' directory");
2156 let update_entries = match update_entries {
2157 UpdateEntries::Live => update_dir
2158 .read_entries()
2159 .await
2160 .with_context(|| {
2161 format!(
2162 "cannot open directory {:?} for reading",
2163 update_path_dbg(update_root, rel_path)
2164 )
2165 })
2166 .map_err(|err| Error::new(err, link_summary))?,
2167 #[cfg(test)]
2168 UpdateEntries::Injected(entries) => entries,
2169 };
2170 for (entry_name, hint) in update_entries {
2175 let entry_rel = rel_path.join(&entry_name);
2176 let update_entry_path = update_root.join(&entry_rel);
2177 if processed_files.contains(&entry_name) {
2178 continue;
2181 }
2182 let admission = match select_update_only_for_dispatch(hint, &entry_rel, settings) {
2183 UpdateOnlyDispatch::Skipped { kind, result } => {
2184 if let Some(mode) = settings.dry_run {
2185 crate::dry_run::report_skip(
2186 &update_entry_path,
2187 &result,
2188 mode,
2189 kind.label(),
2190 );
2191 }
2192 tracing::debug!(
2193 "skipping update entry {:?} due to filter",
2194 &update_entry_path
2195 );
2196 link_summary = link_summary + skipped_summary_for(kind);
2197 kind.inc_skipped(prog_track);
2198 continue;
2199 }
2200 UpdateOnlyDispatch::Dispatch(admission) => admission,
2201 };
2202 let admission = match ensure_link_admission(
2203 admission,
2204 &mut join_set,
2205 &mut link_summary,
2206 &mut keep_set,
2207 &errors,
2208 settings.copy_settings.fail_early,
2209 src_path,
2210 dst_path,
2211 )
2212 .await
2213 {
2214 Ok(admission) => admission,
2215 Err(error) => {
2216 crate::walk_driver::abort_and_join(&mut join_set).await;
2217 return Err(Error::new(error, link_summary));
2218 }
2219 };
2220 let dst_entry_path = dst_path.join(&entry_name);
2224 let update_parent = Arc::clone(update_dir);
2225 let dst_parent = dst_dir.map(Arc::clone);
2226 let settings = settings.clone();
2227 let delete_scan_anchor = copy::DeleteScanAnchor::new(dst_root, &entry_rel);
2228 let do_copy = move || {
2229 process_update_only_entry(
2233 prog_track,
2234 update_parent,
2235 dst_parent,
2236 entry_name,
2237 update_entry_path,
2238 dst_entry_path,
2239 entry_rel,
2240 delete_scan_anchor,
2241 settings,
2242 is_fresh,
2243 admission,
2244 )
2245 };
2246 crate::walk_driver::spawn_tracked(&mut join_set, do_copy());
2247 }
2248 }
2249 while let Some(res) = join_set.join_next().await {
2250 if let Err(error) = fold_link_task_result(
2251 res,
2252 &mut link_summary,
2253 &mut keep_set,
2254 &errors,
2255 settings.copy_settings.fail_early,
2256 src_path,
2257 dst_path,
2258 ) {
2259 crate::walk_driver::abort_and_join(&mut join_set).await;
2260 return Err(Error::new(error, link_summary));
2261 }
2262 }
2263 if let Some(delete_settings) = &settings.copy_settings.delete {
2268 if errors.has_errors() {
2269 tracing::warn!(
2272 "skipping --delete pruning of {:?} because the link/update pass reported errors",
2273 dst_path
2274 );
2275 } else {
2276 let prune_dir: Option<Arc<Dir>> = match dst_dir {
2281 Some(dir) => Some(Arc::clone(dir)),
2282 None => match crate::safedir::open_existing_dir_beneath_operand(
2283 dst_root,
2284 rel_path,
2285 congestion::Side::Destination,
2286 )
2287 .await
2288 {
2289 Ok(Some(dir)) => Some(Arc::new(dir)),
2290 Ok(None) => {
2291 tracing::debug!(
2292 "skipping --delete pruning of {:?}: not a real directory",
2293 dst_path
2294 );
2295 None
2296 }
2297 Err(err) => {
2298 let err = anyhow::Error::new(err).context(format!(
2299 "cannot open destination {dst_path:?} for delete scan"
2300 ));
2301 if settings.copy_settings.fail_early {
2302 return Err(Error::new(err, link_summary));
2303 }
2304 errors.push(err);
2305 None
2306 }
2307 },
2308 };
2309 if let Some(prune_dir) = prune_dir {
2310 match crate::delete::prune_extraneous(
2311 prog_track,
2312 &prune_dir,
2313 rel_path,
2314 keep_set
2315 .as_set()
2316 .expect("--delete is on, so DeleteKeepSet is active"),
2317 settings.filter.as_ref(),
2318 delete_settings,
2319 settings.copy_settings.fail_early,
2320 settings.dry_run,
2321 )
2322 .await
2323 {
2324 Ok(rm_summary) => {
2325 link_summary.copy_summary.rm_summary =
2326 link_summary.copy_summary.rm_summary + rm_summary;
2327 }
2328 Err(err) => {
2329 link_summary.copy_summary.rm_summary =
2330 link_summary.copy_summary.rm_summary + err.summary;
2331 if settings.copy_settings.fail_early {
2332 return Err(Error::new(err.source, link_summary));
2333 }
2334 errors.push(err.source);
2335 }
2336 }
2337 }
2338 }
2339 }
2340 let this_dir_count = usize::from(we_created_this_dir);
2343 let child_dirs_created = link_summary
2344 .copy_summary
2345 .directories_created
2346 .saturating_sub(this_dir_count);
2347 let anything_linked = link_summary.hard_links_created > 0
2348 || link_summary.copy_summary.files_copied > 0
2349 || link_summary.copy_summary.symlinks_created > 0
2350 || child_dirs_created > 0;
2351 let is_root = rel_path.as_os_str().is_empty();
2352 match check_empty_dir_cleanup(
2353 settings.filter.as_ref(),
2354 we_created_this_dir,
2355 anything_linked,
2356 rel_path,
2357 is_root,
2358 settings.dry_run.is_some(),
2359 ) {
2360 EmptyDirAction::Keep => { }
2361 EmptyDirAction::DryRunSkip => {
2362 tracing::debug!(
2363 "dry-run: directory {:?} would not be created (nothing to link inside)",
2364 dst_path
2365 );
2366 link_summary.copy_summary.directories_created = 0;
2367 if errors.has_errors() {
2371 return Err(Error::new(errors.into_error().unwrap(), link_summary));
2372 }
2373 return Ok(link_summary);
2374 }
2375 EmptyDirAction::Remove => {
2376 tracing::debug!(
2377 "directory {:?} has nothing to link inside, removing empty directory",
2378 dst_path
2379 );
2380 let rmdir_result = match dst_parent {
2385 Some(dst_parent) => dst_parent.rmdir_at(dst_name).await,
2386 None => {
2387 crate::walk::run_metadata_probed(
2388 congestion::Side::Destination,
2389 congestion::MetadataOp::RmDir,
2390 tokio::fs::remove_dir(dst_path),
2391 )
2392 .await
2393 }
2394 };
2395 match rmdir_result {
2396 Ok(()) => {
2397 link_summary.copy_summary.directories_created = 0;
2398 if errors.has_errors() {
2402 return Err(Error::new(errors.into_error().unwrap(), link_summary));
2403 }
2404 return Ok(link_summary);
2405 }
2406 Err(err) => {
2407 tracing::debug!(
2409 "failed to remove empty directory {:?}: {:#}, keeping",
2410 dst_path,
2411 &err
2412 );
2413 }
2415 }
2416 }
2417 }
2418 tracing::debug!("set 'dst' directory metadata");
2425 let metadata_result = match dst_dir {
2426 Some(dst_dir) => {
2427 let meta_dir = update_dir.unwrap_or(src_dir);
2428 async {
2433 let preserve_meta = meta_dir.meta().await?;
2434 let preserve_acls = if settings.preserve.dir.acl {
2438 Some(meta_dir.read_acls().await?)
2439 } else {
2440 None
2441 };
2442 crate::safedir::set_reused_dir_metadata_fd(
2443 &settings.preserve,
2444 &preserve_meta,
2445 preserve_acls.as_ref(),
2446 reused_lock,
2447 dst_dir,
2448 )
2449 .await
2450 }
2451 .await
2452 }
2453 None => Ok(()),
2454 };
2455 if errors.has_errors() {
2456 if let Err(metadata_err) = metadata_result {
2458 tracing::error!(
2459 "link: {:?} -> {:?} failed to set directory metadata: {:#}",
2460 src_path,
2461 dst_path,
2462 &metadata_err
2463 );
2464 }
2465 return Err(Error::new(errors.into_error().unwrap(), link_summary));
2467 }
2468 metadata_result
2470 .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
2471 .map_err(|err| Error::new(err, link_summary))?;
2472 Ok(link_summary)
2473}
2474
2475fn update_path_dbg(
2477 update_root: &std::path::Path,
2478 rel_path: &std::path::Path,
2479) -> std::path::PathBuf {
2480 if rel_path.as_os_str().is_empty() {
2481 update_root.to_path_buf()
2482 } else {
2483 update_root.join(rel_path)
2484 }
2485}
2486
2487#[cfg(test)]
2488mod link_tests {
2489 use crate::rm;
2490 use crate::testutils;
2491 use std::os::unix::fs::PermissionsExt;
2492 use tracing_test::traced_test;
2493
2494 use super::*;
2495
2496 static PROGRESS: std::sync::LazyLock<progress::Progress> =
2497 std::sync::LazyLock::new(progress::Progress::new);
2498
2499 #[test]
2500 fn directory_close_primitive_drops_both_handles_before_permit() {
2501 let events = testutils::DropEvents::default();
2502 drop_link_handles_before_permit(
2503 events.probe("source handle"),
2504 Some(events.probe("update handle")),
2505 Some(events.probe("permit")),
2506 );
2507 assert_eq!(
2508 events.snapshot(),
2509 ["source handle", "update handle", "permit"]
2510 );
2511 }
2512
2513 #[test]
2514 fn admitted_link_entry_drops_both_handles_before_permit() {
2515 let events = testutils::DropEvents::default();
2516 let entry = AdmittedLinkEntry {
2517 src_handle: events.probe("source handle"),
2518 update_handle: Some(events.probe("update handle")),
2519 permit: Some(events.probe("permit")),
2520 };
2521 drop(entry);
2522 assert_eq!(
2523 events.snapshot(),
2524 ["source handle", "update handle", "permit"]
2525 );
2526 }
2527
2528 mod delete_keep_set_tests {
2529 use super::super::DeleteKeepSet;
2533 use crate::copy::DeleteSettings;
2534 use std::ffi::{OsStr, OsString};
2535
2536 fn delete_on() -> DeleteSettings {
2537 DeleteSettings {
2538 delete_excluded: false,
2539 }
2540 }
2541
2542 #[test]
2543 fn exact_result_no_op_when_delete_off() {
2544 let mut k = DeleteKeepSet::new(None);
2545 k.record_exact(OsString::from("foo"));
2546 assert!(k.as_set().is_none());
2547 }
2548
2549 #[test]
2550 fn exact_materialized_update_exclusive_duplicate_is_retained() {
2551 let d = delete_on();
2552 let mut k = DeleteKeepSet::new(Some(&d));
2553 let mut summary = super::super::Summary::default();
2554 super::super::LinkTaskResult::from_link(
2555 OsString::from("node"),
2556 super::super::LinkEntryResult::selected(Default::default()),
2557 )
2558 .fold(&mut summary, &mut k);
2559 assert!(
2560 k.as_set().unwrap().contains(OsStr::new("node")),
2561 "the exact worker's materialized update selection must own the keep decision"
2562 );
2563 }
2564
2565 #[test]
2566 fn selected_source_entry_is_retained_in_normal_mode() {
2567 let d = delete_on();
2568 let mut k = DeleteKeepSet::new(Some(&d));
2569 let mut summary = super::super::Summary::default();
2570 super::super::LinkTaskResult::from_link(
2571 OsString::from("node"),
2572 super::super::LinkEntryResult::selected(Default::default()),
2573 )
2574 .fold(&mut summary, &mut k);
2575 assert!(
2576 k.as_set().unwrap().contains(OsStr::new("node")),
2577 "the exact worker's selected source outcome must protect its destination"
2578 );
2579 }
2580
2581 #[test]
2582 fn full_directory_pass_keep_set_union_semantics() {
2583 let d = delete_on();
2592 let mut k = DeleteKeepSet::new(Some(&d));
2593
2594 let mut summary = super::super::Summary::default();
2595 for name in ["keep", "pipe", "node"] {
2596 super::super::LinkTaskResult::from_link(
2597 OsString::from(name),
2598 super::super::LinkEntryResult::selected(Default::default()),
2599 )
2600 .fold(&mut summary, &mut k);
2601 }
2602 super::super::LinkTaskResult::from_update(
2603 OsString::from("from_upd"),
2604 super::super::LinkEntryResult::selected(Default::default()),
2605 )
2606 .fold(&mut summary, &mut k);
2607
2608 let set: std::collections::HashSet<OsString> = k.as_set().unwrap().clone();
2609 let expected: std::collections::HashSet<OsString> =
2610 ["keep", "pipe", "node", "from_upd"]
2611 .into_iter()
2612 .map(OsString::from)
2613 .collect();
2614 assert_eq!(set, expected);
2615 }
2616 }
2617
2618 fn common_settings(dereference: bool, overwrite: bool) -> Settings {
2619 Settings {
2620 copy_settings: CopySettings {
2621 dereference,
2622 fail_early: false,
2623 overwrite,
2624 overwrite_compare: filecmp::MetadataCmpSettings {
2625 size: true,
2626 mtime: true,
2627 ..Default::default()
2628 },
2629 overwrite_filter: None,
2630 ignore_existing: false,
2631 chunk_size: 0,
2632 skip_specials: false,
2633 remote_copy_buffer_size: 0,
2634 filter: None,
2635 dry_run: None,
2636 delete: None,
2637 },
2638 update_compare: filecmp::MetadataCmpSettings {
2639 size: true,
2640 mtime: true,
2641 ..Default::default()
2642 },
2643 update_exclusive: false,
2644 filter: None,
2645 dry_run: None,
2646 preserve: preserve::preserve_all(),
2647 }
2648 }
2649
2650 struct RestoreRenamedUpdateRoot {
2651 original: std::path::PathBuf,
2652 renamed: std::path::PathBuf,
2653 }
2654
2655 impl Drop for RestoreRenamedUpdateRoot {
2656 fn drop(&mut self) {
2657 if self.renamed.exists() {
2658 let _ = std::fs::rename(&self.renamed, &self.original);
2659 }
2660 }
2661 }
2662
2663 struct DisappearingUpdateRootHook {
2664 _registration: AfterUpdatePrecheckHookRegistration,
2665 _cleanup: std::sync::Arc<std::sync::Mutex<Option<RestoreRenamedUpdateRoot>>>,
2666 }
2667
2668 fn disappear_update_root_after_precheck(
2669 update: &std::path::Path,
2670 ) -> DisappearingUpdateRootHook {
2671 let original = update.to_owned();
2672 let renamed = update.with_extension("removed-after-precheck");
2673 let cleanup = std::sync::Arc::new(std::sync::Mutex::new(None));
2674 let hook_cleanup = Arc::clone(&cleanup);
2675 let registration = install_after_update_precheck_hook(
2676 original.clone(),
2677 Box::new(move || {
2678 std::fs::rename(&original, &renamed)
2679 .expect("rename update root after its early precheck");
2680 *hook_cleanup
2681 .lock()
2682 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2683 Some(RestoreRenamedUpdateRoot { original, renamed });
2684 }),
2685 );
2686 DisappearingUpdateRootHook {
2687 _registration: registration,
2688 _cleanup: cleanup,
2689 }
2690 }
2691
2692 async fn setup_disappearing_update_root_case() -> Result<
2693 (
2694 std::path::PathBuf,
2695 std::path::PathBuf,
2696 std::path::PathBuf,
2697 std::path::PathBuf,
2698 ),
2699 anyhow::Error,
2700 > {
2701 let test_path = testutils::create_temp_dir().await?;
2702 let src = test_path.join("src");
2703 let update = test_path.join("update");
2704 let dst = test_path.join("dst");
2705 tokio::fs::create_dir(&src).await?;
2706 tokio::fs::create_dir(&update).await?;
2707 tokio::fs::create_dir(&dst).await?;
2708 tokio::fs::write(src.join("source-only.txt"), "SOURCE").await?;
2709 tokio::fs::write(update.join("protected.txt"), "UPDATE").await?;
2710 tokio::fs::write(dst.join("source-only.txt"), "DESTINATION").await?;
2711 tokio::fs::write(dst.join("protected.txt"), "PROTECTED DESTINATION").await?;
2712 Ok((test_path, src, update, dst))
2713 }
2714
2715 fn assert_missing_destructive_update_error(error: &Error) {
2716 let message = format!("{:#}", error.source);
2717 assert!(
2718 message.contains("--update path") && message.contains("does not exist"),
2719 "missing destructive update error was unclear: {message}"
2720 );
2721 }
2722
2723 #[tokio::test]
2724 async fn update_exclusive_errors_if_update_root_disappears_after_precheck()
2725 -> Result<(), anyhow::Error> {
2726 let (test_path, src, update, dst) = setup_disappearing_update_root_case().await?;
2727 let _hook = disappear_update_root_after_precheck(&update);
2728 let mut settings = common_settings(false, true);
2729 settings.update_exclusive = true;
2730 let result = link(
2731 &PROGRESS,
2732 &test_path,
2733 &src,
2734 &dst,
2735 &Some(update.clone()),
2736 &settings,
2737 false,
2738 )
2739 .await;
2740 assert_eq!(
2741 tokio::fs::read_to_string(dst.join("source-only.txt")).await?,
2742 "DESTINATION"
2743 );
2744 assert_eq!(
2745 tokio::fs::read_to_string(dst.join("protected.txt")).await?,
2746 "PROTECTED DESTINATION"
2747 );
2748 let error = result.expect_err("a vanished update root must fail update-exclusive");
2749 assert_missing_destructive_update_error(&error);
2750 Ok(())
2751 }
2752
2753 #[tokio::test]
2754 async fn delete_update_does_not_prune_if_update_root_disappears_after_precheck()
2755 -> Result<(), anyhow::Error> {
2756 let (test_path, src, update, dst) = setup_disappearing_update_root_case().await?;
2757 let _hook = disappear_update_root_after_precheck(&update);
2758 let mut settings = common_settings(false, true);
2759 settings.copy_settings.delete = Some(copy::DeleteSettings {
2760 delete_excluded: false,
2761 });
2762 settings.filter = Some(crate::filter::FilterSettings::new());
2763 let result = link(
2764 &PROGRESS,
2765 &test_path,
2766 &src,
2767 &dst,
2768 &Some(update.clone()),
2769 &settings,
2770 false,
2771 )
2772 .await;
2773 assert_eq!(
2774 tokio::fs::read_to_string(dst.join("source-only.txt")).await?,
2775 "DESTINATION"
2776 );
2777 assert_eq!(
2778 tokio::fs::read_to_string(dst.join("protected.txt")).await?,
2779 "PROTECTED DESTINATION"
2780 );
2781 let error = result.expect_err("a vanished update root must stop delete pruning");
2782 assert_missing_destructive_update_error(&error);
2783 Ok(())
2784 }
2785
2786 #[tokio::test]
2787 async fn plain_update_falls_back_if_update_root_disappears_after_precheck()
2788 -> Result<(), anyhow::Error> {
2789 let (test_path, src, update, dst) = setup_disappearing_update_root_case().await?;
2790 let _hook = disappear_update_root_after_precheck(&update);
2791 link(
2792 &PROGRESS,
2793 &test_path,
2794 &src,
2795 &dst,
2796 &Some(update),
2797 &common_settings(false, true),
2798 false,
2799 )
2800 .await?;
2801 assert_eq!(
2802 tokio::fs::read_to_string(dst.join("source-only.txt")).await?,
2803 "SOURCE"
2804 );
2805 assert_eq!(
2806 tokio::fs::read_to_string(dst.join("protected.txt")).await?,
2807 "PROTECTED DESTINATION"
2808 );
2809 Ok(())
2810 }
2811
2812 #[tokio::test]
2813 async fn update_exclusive_without_update_links_the_source_normally() -> Result<(), anyhow::Error>
2814 {
2815 use std::os::unix::fs::MetadataExt;
2816 let test_path = testutils::create_temp_dir().await?;
2817 let src = test_path.join("src");
2818 let dst = test_path.join("dst");
2819 tokio::fs::write(&src, "SOURCE").await?;
2820 let mut settings = common_settings(false, false);
2821 settings.update_exclusive = true;
2822 let summary = link(&PROGRESS, &test_path, &src, &dst, &None, &settings, false).await?;
2823 assert_eq!(summary.hard_links_created, 1);
2824 assert_eq!(tokio::fs::read_to_string(&dst).await?, "SOURCE");
2825 let src_metadata = tokio::fs::metadata(&src).await?;
2826 let dst_metadata = tokio::fs::metadata(&dst).await?;
2827 assert_eq!(
2828 (src_metadata.dev(), src_metadata.ino()),
2829 (dst_metadata.dev(), dst_metadata.ino())
2830 );
2831 Ok(())
2832 }
2833
2834 #[tokio::test]
2835 #[traced_test]
2836 async fn test_basic_link() -> Result<(), anyhow::Error> {
2837 let tmp_dir = testutils::setup_test_dir().await?;
2838 let test_path = tmp_dir.as_path();
2839 let summary = link(
2840 &PROGRESS,
2841 test_path,
2842 &test_path.join("foo"),
2843 &test_path.join("bar"),
2844 &None,
2845 &common_settings(false, false),
2846 false,
2847 )
2848 .await?;
2849 assert_eq!(summary.hard_links_created, 5);
2850 assert_eq!(summary.copy_summary.files_copied, 0);
2851 assert_eq!(summary.copy_summary.symlinks_created, 2);
2852 assert_eq!(summary.copy_summary.directories_created, 3);
2853 testutils::check_dirs_identical(
2854 &test_path.join("foo"),
2855 &test_path.join("bar"),
2856 testutils::FileEqualityCheck::Timestamp,
2857 )
2858 .await?;
2859 Ok(())
2860 }
2861
2862 #[tokio::test]
2866 async fn links_dot_dot_source_operand() -> Result<(), anyhow::Error> {
2867 use std::os::unix::fs::MetadataExt;
2868 let tmp = testutils::create_temp_dir().await?;
2869 let tree = tmp.join("tree");
2870 tokio::fs::create_dir(&tree).await?;
2871 tokio::fs::write(tree.join("a.txt"), "hello").await?;
2872 tokio::fs::create_dir(tree.join("sub")).await?;
2873 let src = tree.join("sub").join(".."); let dst = tmp.join("dst");
2875 let summary = link(
2876 &PROGRESS,
2877 &tmp,
2878 &src,
2879 &dst,
2880 &None,
2881 &common_settings(false, false),
2882 false,
2883 )
2884 .await?;
2885 assert_eq!(
2886 summary.hard_links_created, 1,
2887 "the dot-dot source's file must be hard-linked"
2888 );
2889 assert!(
2890 dst.join("sub").is_dir(),
2891 "the dot-dot source's subdir must be created"
2892 );
2893 let src_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
2895 let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
2896 assert_eq!(src_ino, dst_ino, "dst must be a hard link to the src inode");
2897 Ok(())
2898 }
2899
2900 #[tokio::test]
2906 async fn links_dot_dot_update_operand() -> Result<(), anyhow::Error> {
2907 use std::os::unix::fs::MetadataExt;
2908 let tmp = testutils::create_temp_dir().await?;
2909 let tree = tmp.join("tree");
2910 tokio::fs::create_dir(&tree).await?;
2911 tokio::fs::write(tree.join("a.txt"), "hello").await?;
2912 tokio::fs::create_dir(tree.join("sub")).await?;
2913 let dst = tmp.join("dst");
2914 let update_operand = tree.join("sub").join(".."); let summary = link(
2918 &PROGRESS,
2919 &tmp,
2920 &tree,
2921 &dst,
2922 &Some(update_operand),
2923 &common_settings(false, false),
2924 false,
2925 )
2926 .await?;
2927 assert_eq!(
2928 summary.hard_links_created, 1,
2929 "the file must be hard-linked from the dot-dot update tree"
2930 );
2931 let update_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
2933 let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
2934 assert_eq!(
2935 update_ino, dst_ino,
2936 "dst must be hard-linked from the update tree inode"
2937 );
2938 Ok(())
2939 }
2940
2941 #[tokio::test]
2942 #[traced_test]
2943 async fn test_basic_link_update() -> Result<(), anyhow::Error> {
2944 let tmp_dir = testutils::setup_test_dir().await?;
2945 let test_path = tmp_dir.as_path();
2946 let summary = link(
2947 &PROGRESS,
2948 test_path,
2949 &test_path.join("foo"),
2950 &test_path.join("bar"),
2951 &Some(test_path.join("foo")),
2952 &common_settings(false, false),
2953 false,
2954 )
2955 .await?;
2956 assert_eq!(summary.hard_links_created, 5);
2957 assert_eq!(summary.copy_summary.files_copied, 0);
2958 assert_eq!(summary.copy_summary.symlinks_created, 2);
2959 assert_eq!(summary.copy_summary.directories_created, 3);
2960 testutils::check_dirs_identical(
2961 &test_path.join("foo"),
2962 &test_path.join("bar"),
2963 testutils::FileEqualityCheck::Timestamp,
2964 )
2965 .await?;
2966 Ok(())
2967 }
2968
2969 #[tokio::test]
2970 #[traced_test]
2971 async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
2972 let tmp_dir = testutils::setup_test_dir().await?;
2973 tokio::fs::create_dir(tmp_dir.join("baz")).await?;
2974 let test_path = tmp_dir.as_path();
2975 let summary = link(
2976 &PROGRESS,
2977 test_path,
2978 &test_path.join("baz"), &test_path.join("bar"),
2980 &Some(test_path.join("foo")),
2981 &common_settings(false, false),
2982 false,
2983 )
2984 .await?;
2985 assert_eq!(summary.hard_links_created, 0);
2986 assert_eq!(summary.copy_summary.files_copied, 5);
2987 assert_eq!(summary.copy_summary.symlinks_created, 2);
2988 assert_eq!(summary.copy_summary.directories_created, 3);
2989 testutils::check_dirs_identical(
2990 &test_path.join("foo"),
2991 &test_path.join("bar"),
2992 testutils::FileEqualityCheck::Timestamp,
2993 )
2994 .await?;
2995 Ok(())
2996 }
2997
2998 #[tokio::test]
2999 #[traced_test]
3000 async fn test_link_destination_permission_error_includes_root_cause()
3001 -> Result<(), anyhow::Error> {
3002 let tmp_dir = testutils::setup_test_dir().await?;
3003 let test_path = tmp_dir.as_path();
3004 let readonly_parent = test_path.join("readonly_dest");
3005 tokio::fs::create_dir(&readonly_parent).await?;
3006 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3007 .await?;
3008
3009 let mut settings = common_settings(false, false);
3010 settings.copy_settings.fail_early = true;
3011
3012 let result = link(
3013 &PROGRESS,
3014 test_path,
3015 &test_path.join("foo"),
3016 &readonly_parent.join("bar"),
3017 &None,
3018 &settings,
3019 false,
3020 )
3021 .await;
3022
3023 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
3025 .await?;
3026
3027 assert!(result.is_err(), "link into read-only parent should fail");
3028 let err = result.unwrap_err();
3029 let err_msg = format!("{:#}", err.source);
3030 assert!(
3031 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
3032 "Error message must include permission denied text. Got: {}",
3033 err_msg
3034 );
3035 Ok(())
3036 }
3037
3038 #[tokio::test]
3039 #[traced_test]
3040 async fn hard_link_file_into_readonly_parent_returns_error() -> Result<(), anyhow::Error> {
3041 let tmp_dir = testutils::setup_test_dir().await?;
3044 let src = tmp_dir.join("src.txt");
3045 tokio::fs::write(&src, "content").await?;
3046 let readonly_parent = tmp_dir.join("readonly_parent");
3047 tokio::fs::create_dir(&readonly_parent).await?;
3048 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
3049 .await?;
3050 let dst = readonly_parent.join("dst.txt");
3051 let settings = common_settings(false, false);
3052 let result = link(&PROGRESS, &tmp_dir, &src, &dst, &None, &settings, false).await;
3053 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
3054 .await?;
3055 let err = result.expect_err("link into read-only parent should fail");
3056 assert_eq!(err.summary.hard_links_created, 0);
3057 let err_msg = format!("{:#}", err.source);
3058 assert!(
3059 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
3060 "error should include root cause, got: {err_msg}"
3061 );
3062 Ok(())
3063 }
3064
3065 pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
3066 let foo_path = tmp_dir.join("update");
3072 tokio::fs::create_dir(&foo_path).await.unwrap();
3073 tokio::fs::write(foo_path.join("0.txt"), "0-new")
3074 .await
3075 .unwrap();
3076 let bar_path = foo_path.join("bar");
3077 tokio::fs::create_dir(&bar_path).await.unwrap();
3078 tokio::fs::write(bar_path.join("1.txt"), "1-new")
3079 .await
3080 .unwrap();
3081 tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
3082 .await
3083 .unwrap();
3084 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
3085 Ok(())
3086 }
3087
3088 #[tokio::test]
3089 #[traced_test]
3090 async fn test_link_update() -> Result<(), anyhow::Error> {
3091 let tmp_dir = testutils::setup_test_dir().await?;
3092 setup_update_dir(&tmp_dir).await?;
3093 let test_path = tmp_dir.as_path();
3094 let summary = link(
3095 &PROGRESS,
3096 test_path,
3097 &test_path.join("foo"),
3098 &test_path.join("bar"),
3099 &Some(test_path.join("update")),
3100 &common_settings(false, false),
3101 false,
3102 )
3103 .await?;
3104 assert_eq!(summary.hard_links_created, 2);
3105 assert_eq!(summary.copy_summary.files_copied, 2);
3106 assert_eq!(summary.copy_summary.symlinks_created, 3);
3107 assert_eq!(summary.copy_summary.directories_created, 3);
3108 testutils::check_dirs_identical(
3110 &test_path.join("foo").join("baz"),
3111 &test_path.join("bar").join("baz"),
3112 testutils::FileEqualityCheck::HardLink,
3113 )
3114 .await?;
3115 testutils::check_dirs_identical(
3117 &test_path.join("update"),
3118 &test_path.join("bar"),
3119 testutils::FileEqualityCheck::Timestamp,
3120 )
3121 .await?;
3122 Ok(())
3123 }
3124
3125 #[tokio::test]
3126 #[traced_test]
3127 async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
3128 let tmp_dir = testutils::setup_test_dir().await?;
3129 setup_update_dir(&tmp_dir).await?;
3130 let test_path = tmp_dir.as_path();
3131 let mut settings = common_settings(false, false);
3132 settings.update_exclusive = true;
3133 let summary = link(
3134 &PROGRESS,
3135 test_path,
3136 &test_path.join("foo"),
3137 &test_path.join("bar"),
3138 &Some(test_path.join("update")),
3139 &settings,
3140 false,
3141 )
3142 .await?;
3143 assert_eq!(summary.hard_links_created, 0);
3149 assert_eq!(summary.copy_summary.files_copied, 2);
3150 assert_eq!(summary.copy_summary.symlinks_created, 1);
3151 assert_eq!(summary.copy_summary.directories_created, 2);
3152 testutils::check_dirs_identical(
3154 &test_path.join("update"),
3155 &test_path.join("bar"),
3156 testutils::FileEqualityCheck::Timestamp,
3157 )
3158 .await?;
3159 Ok(())
3160 }
3161
3162 async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
3163 let tmp_dir = testutils::setup_test_dir().await?;
3164 let test_path = tmp_dir.as_path();
3165 let summary = link(
3166 &PROGRESS,
3167 test_path,
3168 &test_path.join("foo"),
3169 &test_path.join("bar"),
3170 &None,
3171 &common_settings(false, false),
3172 false,
3173 )
3174 .await?;
3175 assert_eq!(summary.hard_links_created, 5);
3176 assert_eq!(summary.copy_summary.symlinks_created, 2);
3177 assert_eq!(summary.copy_summary.directories_created, 3);
3178 Ok(tmp_dir)
3179 }
3180
3181 #[tokio::test]
3182 #[traced_test]
3183 async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
3184 let tmp_dir = setup_test_dir_and_link().await?;
3185 let output_path = &tmp_dir.join("bar");
3186 {
3187 let summary = rm::rm(
3198 &PROGRESS,
3199 &output_path.join("bar"),
3200 &rm::Settings {
3201 fail_early: false,
3202 filter: None,
3203 dry_run: None,
3204 time_filter: None,
3205 },
3206 )
3207 .await?
3208 + rm::rm(
3209 &PROGRESS,
3210 &output_path.join("baz").join("5.txt"),
3211 &rm::Settings {
3212 fail_early: false,
3213 filter: None,
3214 dry_run: None,
3215 time_filter: None,
3216 },
3217 )
3218 .await?;
3219 assert_eq!(summary.files_removed, 3);
3220 assert_eq!(summary.symlinks_removed, 1);
3221 assert_eq!(summary.directories_removed, 1);
3222 }
3223 let summary = link(
3224 &PROGRESS,
3225 &tmp_dir,
3226 &tmp_dir.join("foo"),
3227 output_path,
3228 &None,
3229 &common_settings(false, true), false,
3231 )
3232 .await?;
3233 assert_eq!(summary.hard_links_created, 3);
3234 assert_eq!(summary.copy_summary.symlinks_created, 1);
3235 assert_eq!(summary.copy_summary.directories_created, 1);
3236 testutils::check_dirs_identical(
3237 &tmp_dir.join("foo"),
3238 output_path,
3239 testutils::FileEqualityCheck::Timestamp,
3240 )
3241 .await?;
3242 Ok(())
3243 }
3244
3245 #[tokio::test]
3246 #[traced_test]
3247 async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
3248 let tmp_dir = setup_test_dir_and_link().await?;
3249 let output_path = &tmp_dir.join("bar");
3250 {
3251 let summary = rm::rm(
3262 &PROGRESS,
3263 &output_path.join("bar"),
3264 &rm::Settings {
3265 fail_early: false,
3266 filter: None,
3267 dry_run: None,
3268 time_filter: None,
3269 },
3270 )
3271 .await?
3272 + rm::rm(
3273 &PROGRESS,
3274 &output_path.join("baz").join("5.txt"),
3275 &rm::Settings {
3276 fail_early: false,
3277 filter: None,
3278 dry_run: None,
3279 time_filter: None,
3280 },
3281 )
3282 .await?;
3283 assert_eq!(summary.files_removed, 3);
3284 assert_eq!(summary.symlinks_removed, 1);
3285 assert_eq!(summary.directories_removed, 1);
3286 }
3287 setup_update_dir(&tmp_dir).await?;
3288 let summary = link(
3294 &PROGRESS,
3295 &tmp_dir,
3296 &tmp_dir.join("foo"),
3297 output_path,
3298 &Some(tmp_dir.join("update")),
3299 &common_settings(false, true), false,
3301 )
3302 .await?;
3303 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);
3307 testutils::check_dirs_identical(
3309 &tmp_dir.join("foo").join("baz"),
3310 &tmp_dir.join("bar").join("baz"),
3311 testutils::FileEqualityCheck::HardLink,
3312 )
3313 .await?;
3314 testutils::check_dirs_identical(
3316 &tmp_dir.join("update"),
3317 &tmp_dir.join("bar"),
3318 testutils::FileEqualityCheck::Timestamp,
3319 )
3320 .await?;
3321 Ok(())
3322 }
3323
3324 #[tokio::test]
3325 #[traced_test]
3326 async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
3327 let tmp_dir = setup_test_dir_and_link().await?;
3328 let output_path = &tmp_dir.join("bar");
3329 {
3330 let bar_path = output_path.join("bar");
3339 let summary = rm::rm(
3340 &PROGRESS,
3341 &bar_path.join("1.txt"),
3342 &rm::Settings {
3343 fail_early: false,
3344 filter: None,
3345 dry_run: None,
3346 time_filter: None,
3347 },
3348 )
3349 .await?
3350 + rm::rm(
3351 &PROGRESS,
3352 &bar_path.join("2.txt"),
3353 &rm::Settings {
3354 fail_early: false,
3355 filter: None,
3356 dry_run: None,
3357 time_filter: None,
3358 },
3359 )
3360 .await?
3361 + rm::rm(
3362 &PROGRESS,
3363 &bar_path.join("3.txt"),
3364 &rm::Settings {
3365 fail_early: false,
3366 filter: None,
3367 dry_run: None,
3368 time_filter: None,
3369 },
3370 )
3371 .await?
3372 + rm::rm(
3373 &PROGRESS,
3374 &output_path.join("baz"),
3375 &rm::Settings {
3376 fail_early: false,
3377 filter: None,
3378 dry_run: None,
3379 time_filter: None,
3380 },
3381 )
3382 .await?;
3383 assert_eq!(summary.files_removed, 4);
3384 assert_eq!(summary.symlinks_removed, 2);
3385 assert_eq!(summary.directories_removed, 1);
3386 tokio::fs::write(bar_path.join("1.txt"), "1-new")
3388 .await
3389 .unwrap();
3390 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
3391 .await
3392 .unwrap();
3393 tokio::fs::create_dir(&bar_path.join("3.txt"))
3394 .await
3395 .unwrap();
3396 tokio::fs::write(&output_path.join("baz"), "baz")
3397 .await
3398 .unwrap();
3399 }
3400 let summary = link(
3401 &PROGRESS,
3402 &tmp_dir,
3403 &tmp_dir.join("foo"),
3404 output_path,
3405 &None,
3406 &common_settings(false, true), false,
3408 )
3409 .await?;
3410 assert_eq!(summary.hard_links_created, 4);
3411 assert_eq!(summary.copy_summary.files_copied, 0);
3412 assert_eq!(summary.copy_summary.symlinks_created, 2);
3413 assert_eq!(summary.copy_summary.directories_created, 1);
3414 testutils::check_dirs_identical(
3415 &tmp_dir.join("foo"),
3416 &tmp_dir.join("bar"),
3417 testutils::FileEqualityCheck::HardLink,
3418 )
3419 .await?;
3420 Ok(())
3421 }
3422
3423 #[tokio::test]
3424 #[traced_test]
3425 async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
3426 let tmp_dir = setup_test_dir_and_link().await?;
3427 let output_path = &tmp_dir.join("bar");
3428 {
3429 let bar_path = output_path.join("bar");
3438 let summary = rm::rm(
3439 &PROGRESS,
3440 &bar_path.join("1.txt"),
3441 &rm::Settings {
3442 fail_early: false,
3443 filter: None,
3444 dry_run: None,
3445 time_filter: None,
3446 },
3447 )
3448 .await?
3449 + rm::rm(
3450 &PROGRESS,
3451 &bar_path.join("2.txt"),
3452 &rm::Settings {
3453 fail_early: false,
3454 filter: None,
3455 dry_run: None,
3456 time_filter: None,
3457 },
3458 )
3459 .await?
3460 + rm::rm(
3461 &PROGRESS,
3462 &bar_path.join("3.txt"),
3463 &rm::Settings {
3464 fail_early: false,
3465 filter: None,
3466 dry_run: None,
3467 time_filter: None,
3468 },
3469 )
3470 .await?
3471 + rm::rm(
3472 &PROGRESS,
3473 &output_path.join("baz"),
3474 &rm::Settings {
3475 fail_early: false,
3476 filter: None,
3477 dry_run: None,
3478 time_filter: None,
3479 },
3480 )
3481 .await?;
3482 assert_eq!(summary.files_removed, 4);
3483 assert_eq!(summary.symlinks_removed, 2);
3484 assert_eq!(summary.directories_removed, 1);
3485 tokio::fs::write(bar_path.join("1.txt"), "1-new")
3487 .await
3488 .unwrap();
3489 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
3490 .await
3491 .unwrap();
3492 tokio::fs::create_dir(&bar_path.join("3.txt"))
3493 .await
3494 .unwrap();
3495 tokio::fs::write(&output_path.join("baz"), "baz")
3496 .await
3497 .unwrap();
3498 }
3499 let source_path = &tmp_dir.join("foo");
3500 tokio::fs::set_permissions(
3502 &source_path.join("baz"),
3503 std::fs::Permissions::from_mode(0o000),
3504 )
3505 .await?;
3506 match link(
3510 &PROGRESS,
3511 &tmp_dir,
3512 &tmp_dir.join("foo"),
3513 output_path,
3514 &None,
3515 &common_settings(false, true), false,
3517 )
3518 .await
3519 {
3520 Ok(_) => panic!("Expected the link to error!"),
3521 Err(error) => {
3522 tracing::info!("{:#}", &error);
3523 assert_eq!(error.summary.hard_links_created, 3);
3524 assert_eq!(error.summary.copy_summary.files_copied, 0);
3525 assert_eq!(error.summary.copy_summary.symlinks_created, 0);
3526 assert_eq!(error.summary.copy_summary.directories_created, 0);
3527 assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
3528 assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
3529 assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
3530 }
3531 }
3532 Ok(())
3533 }
3534
3535 #[tokio::test]
3539 #[traced_test]
3540 async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
3541 let tmp_dir = testutils::create_temp_dir().await?;
3542 let test_path = tmp_dir.as_path();
3543 let src_dir = test_path.join("src");
3545 tokio::fs::create_dir(&src_dir).await?;
3546 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
3547 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
3549 let unreadable_subdir = src_dir.join("unreadable_subdir");
3552 tokio::fs::create_dir(&unreadable_subdir).await?;
3553 tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
3554 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
3555 .await?;
3556 let dst_dir = test_path.join("dst");
3557 let result = link(
3559 &PROGRESS,
3560 test_path,
3561 &src_dir,
3562 &dst_dir,
3563 &None,
3564 &common_settings(false, false),
3565 false,
3566 )
3567 .await;
3568 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
3570 .await?;
3571 assert!(
3573 result.is_err(),
3574 "link should fail due to unreadable subdirectory"
3575 );
3576 let error = result.unwrap_err();
3577 assert_eq!(error.summary.hard_links_created, 1);
3579 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
3581 assert!(dst_metadata.is_dir());
3582 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
3583 assert_eq!(
3584 actual_mode, 0o750,
3585 "directory should have preserved source permissions (0o750), got {:o}",
3586 actual_mode
3587 );
3588 Ok(())
3589 }
3590 mod filter_tests {
3591 use super::*;
3592 use crate::filter::FilterSettings;
3593
3594 async fn run_with_open_file_cleanup<F>(
3595 admission: &testutils::AdmissionLimit,
3596 operation: F,
3597 ) -> Result<F::Output, anyhow::Error>
3598 where
3599 F: std::future::Future,
3600 {
3601 let output = admission
3602 .run_with_timeout(std::time::Duration::from_secs(20), operation)
3603 .await
3604 .context("admission-sensitive filter operation did not finish")?;
3605 let permit = admission
3606 .run_with_timeout(
3607 std::time::Duration::from_secs(20),
3608 throttle::open_file_permit(),
3609 )
3610 .await
3611 .context("filter operation did not return open-file capacity")?;
3612 drop(permit);
3613 Ok(output)
3614 }
3615
3616 #[tokio::test]
3618 #[traced_test]
3619 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
3620 let tmp_dir = testutils::setup_test_dir().await?;
3621 let test_path = tmp_dir.as_path();
3622 let mut filter = FilterSettings::new();
3624 filter.add_include("bar/*.txt").unwrap();
3625 let summary = link(
3626 &PROGRESS,
3627 test_path,
3628 &test_path.join("foo"),
3629 &test_path.join("dst"),
3630 &None,
3631 &Settings {
3632 copy_settings: CopySettings {
3633 dereference: false,
3634 fail_early: false,
3635 overwrite: false,
3636 overwrite_compare: Default::default(),
3637 overwrite_filter: None,
3638 ignore_existing: false,
3639 chunk_size: 0,
3640 skip_specials: false,
3641 remote_copy_buffer_size: 0,
3642 filter: None,
3643 dry_run: None,
3644 delete: None,
3645 },
3646 update_compare: Default::default(),
3647 update_exclusive: false,
3648 filter: Some(filter),
3649 dry_run: None,
3650 preserve: preserve::preserve_all(),
3651 },
3652 false,
3653 )
3654 .await?;
3655 assert_eq!(
3657 summary.hard_links_created, 3,
3658 "should link 3 files matching bar/*.txt"
3659 );
3660 assert!(
3662 test_path.join("dst/bar/1.txt").exists(),
3663 "bar/1.txt should be linked"
3664 );
3665 assert!(
3666 test_path.join("dst/bar/2.txt").exists(),
3667 "bar/2.txt should be linked"
3668 );
3669 assert!(
3670 test_path.join("dst/bar/3.txt").exists(),
3671 "bar/3.txt should be linked"
3672 );
3673 assert!(
3675 !test_path.join("dst/0.txt").exists(),
3676 "0.txt should not be linked"
3677 );
3678 Ok(())
3679 }
3680 #[tokio::test]
3685 #[traced_test]
3686 async fn test_filter_pruned_empty_dir_surfaces_child_error() -> Result<(), anyhow::Error> {
3687 let tmp_dir = testutils::create_temp_dir().await?;
3688 let test_path = tmp_dir.as_path();
3689 let src_dir = test_path.join("src");
3693 let unreadable = src_dir.join("sub").join("unreadable");
3694 tokio::fs::create_dir_all(&unreadable).await?;
3695 tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
3696 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3697 let mut filter = FilterSettings::new();
3700 filter.add_include("*.match").unwrap();
3701 let mut settings = common_settings(false, false);
3702 settings.filter = Some(filter);
3703 let result = link(
3704 &PROGRESS,
3705 test_path,
3706 &src_dir,
3707 &test_path.join("dst"),
3708 &None,
3709 &settings,
3710 false,
3711 )
3712 .await;
3713 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
3715 assert!(
3716 result.is_err(),
3717 "a child link failure inside a filter-pruned empty directory must surface as an \
3718 error, not be masked as success"
3719 );
3720 Ok(())
3721 }
3722 #[tokio::test]
3725 #[traced_test]
3726 async fn test_filter_pruned_empty_dir_surfaces_child_error_dry_run()
3727 -> Result<(), anyhow::Error> {
3728 let tmp_dir = testutils::create_temp_dir().await?;
3729 let test_path = tmp_dir.as_path();
3730 let src_dir = test_path.join("src");
3731 let unreadable = src_dir.join("sub").join("unreadable");
3732 tokio::fs::create_dir_all(&unreadable).await?;
3733 tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
3734 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3735 let mut filter = FilterSettings::new();
3736 filter.add_include("*.match").unwrap();
3737 let mut settings = common_settings(false, false);
3738 settings.filter = Some(filter);
3739 settings.dry_run = Some(crate::config::DryRunMode::Brief);
3740 settings.copy_settings.dry_run = Some(crate::config::DryRunMode::Brief);
3741 let result = link(
3742 &PROGRESS,
3743 test_path,
3744 &src_dir,
3745 &test_path.join("dst"),
3746 &None,
3747 &settings,
3748 false,
3749 )
3750 .await;
3751 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
3752 assert!(
3753 result.is_err(),
3754 "dry-run must also surface the child error, not report a clean run"
3755 );
3756 Ok(())
3757 }
3758 #[tokio::test]
3760 #[traced_test]
3761 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
3762 let tmp_dir = testutils::setup_test_dir().await?;
3763 let test_path = tmp_dir.as_path();
3764 let mut filter = FilterSettings::new();
3766 filter.add_exclude("*.txt").unwrap();
3767 let summary = link(
3768 &PROGRESS,
3769 test_path,
3770 &test_path.join("foo/0.txt"), &test_path.join("dst/0.txt"),
3772 &None,
3773 &Settings {
3774 copy_settings: CopySettings {
3775 dereference: false,
3776 fail_early: false,
3777 overwrite: false,
3778 overwrite_compare: Default::default(),
3779 overwrite_filter: None,
3780 ignore_existing: false,
3781 chunk_size: 0,
3782 skip_specials: false,
3783 remote_copy_buffer_size: 0,
3784 filter: None,
3785 dry_run: None,
3786 delete: None,
3787 },
3788 update_compare: Default::default(),
3789 update_exclusive: false,
3790 filter: Some(filter),
3791 dry_run: None,
3792 preserve: preserve::preserve_all(),
3793 },
3794 false,
3795 )
3796 .await?;
3797 assert_eq!(
3799 summary.hard_links_created, 0,
3800 "file matching exclude pattern should not be linked"
3801 );
3802 assert!(
3803 !test_path.join("dst/0.txt").exists(),
3804 "excluded file should not exist at destination"
3805 );
3806 Ok(())
3807 }
3808 #[tokio::test]
3810 #[traced_test]
3811 async fn update_root_filter_uses_source_name_in_union_mode() -> Result<(), anyhow::Error> {
3812 let test_path = testutils::create_temp_dir().await?;
3813 let src = test_path.join("foo");
3814 let update = test_path.join("bar");
3815 let dst = test_path.join("dst");
3816 tokio::fs::write(&src, "SRC").await?;
3817 tokio::fs::write(&update, "UPDATED").await?;
3818 let mut filter = FilterSettings::new();
3819 filter.add_include("foo").unwrap();
3820 let mut settings = common_settings(false, false);
3821 settings.copy_settings.filter = Some(filter.clone());
3822 settings.filter = Some(filter);
3823 let summary = link(
3824 &PROGRESS,
3825 &test_path,
3826 &src,
3827 &dst,
3828 &Some(update),
3829 &settings,
3830 false,
3831 )
3832 .await?;
3833 assert_eq!(tokio::fs::read_to_string(&dst).await?, "UPDATED");
3834 assert_eq!(summary.copy_summary.files_copied, 1);
3835 Ok(())
3836 }
3837 #[tokio::test]
3839 #[traced_test]
3840 async fn update_root_filter_uses_source_name_in_exclusive_mode() -> Result<(), anyhow::Error>
3841 {
3842 let test_path = testutils::create_temp_dir().await?;
3843 let src = test_path.join("foo");
3844 let update = test_path.join("bar");
3845 let dst = test_path.join("dst");
3846 tokio::fs::write(&src, "SRC").await?;
3847 tokio::fs::write(&update, "UPDATED").await?;
3848 let mut filter = FilterSettings::new();
3849 filter.add_include("foo").unwrap();
3850 let mut settings = common_settings(false, false);
3851 settings.update_exclusive = true;
3852 settings.copy_settings.filter = Some(filter.clone());
3853 settings.filter = Some(filter);
3854 let summary = link(
3855 &PROGRESS,
3856 &test_path,
3857 &src,
3858 &dst,
3859 &Some(update),
3860 &settings,
3861 false,
3862 )
3863 .await?;
3864 assert_eq!(tokio::fs::read_to_string(&dst).await?, "UPDATED");
3865 assert_eq!(summary.copy_summary.files_copied, 1);
3866 Ok(())
3867 }
3868 #[tokio::test]
3870 #[traced_test]
3871 async fn update_directory_can_include_root_excluded_for_source_file()
3872 -> Result<(), anyhow::Error> {
3873 let test_path = testutils::create_temp_dir().await?;
3874 let src_parent = test_path.join("source");
3875 let update_parent = test_path.join("update");
3876 let src = src_parent.join("data");
3877 let update = update_parent.join("data");
3878 let dst = test_path.join("dst");
3879 tokio::fs::create_dir(&src_parent).await?;
3880 tokio::fs::create_dir(&update_parent).await?;
3881 tokio::fs::write(&src, "SOURCE-FILE").await?;
3882 tokio::fs::create_dir(&update).await?;
3883 tokio::fs::write(update.join("inner.txt"), "UPDATE-DIR").await?;
3884 let mut filter = FilterSettings::new();
3885 filter.add_include("data/").unwrap();
3886 filter.add_include("data/**").unwrap();
3887 let mut settings = common_settings(false, false);
3888 settings.filter = Some(filter);
3889 let summary = link(
3890 &PROGRESS,
3891 &test_path,
3892 &src,
3893 &dst,
3894 &Some(update),
3895 &settings,
3896 false,
3897 )
3898 .await?;
3899 assert_eq!(
3900 tokio::fs::read_to_string(dst.join("inner.txt")).await?,
3901 "UPDATE-DIR"
3902 );
3903 assert_eq!(summary.copy_summary.files_copied, 1);
3904 Ok(())
3905 }
3906 #[tokio::test]
3908 #[traced_test]
3909 async fn filtered_update_file_swap_to_directory_never_materializes_replacement()
3910 -> Result<(), anyhow::Error> {
3911 let test_path = testutils::create_temp_dir().await?;
3912 let src = test_path.join("src");
3913 let update = test_path.join("update");
3914 let dst = test_path.join("dst");
3915 tokio::fs::write(&src, "SRC").await?;
3916 tokio::fs::write(&update, "SELECTED-UPDATE-FILE").await?;
3917
3918 let admission_limits = testutils::AdmissionLimit::new().await;
3919 admission_limits.set_files_in_flight(1);
3920 let permit = walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
3921 let src_parent = Arc::new(
3922 Dir::open_parent_dir(&test_path, congestion::Side::Source)
3923 .await?
3924 .into_tree(),
3925 );
3926 let dst_parent = Arc::new(
3927 Dir::open_parent_dir(&test_path, congestion::Side::Destination)
3928 .await?
3929 .into_tree(),
3930 );
3931 let src_handle = src_parent.child(std::ffi::OsStr::new("src")).await?;
3932 let update_handle = src_parent.child(std::ffi::OsStr::new("update")).await?;
3933
3934 let mut filter = FilterSettings::new();
3935 filter.add_exclude("src/")?;
3936 assert!(matches!(
3937 select_filtered_root(
3938 &filter,
3939 std::ffi::OsStr::new("src"),
3940 src_handle.kind(),
3941 Some(update_handle.kind()),
3942 false,
3943 ),
3944 RootFilterSelection::WithUpdate
3945 ));
3946 let mut entry = AdmittedLinkEntry::new(src_handle, permit);
3947 entry.update_handle = Some(update_handle);
3948
3949 tokio::fs::remove_file(&update).await?;
3952 tokio::fs::create_dir(&update).await?;
3953 tokio::fs::write(update.join("replacement.txt"), "UNSELECTED").await?;
3954 let mut settings = common_settings(false, false);
3955 settings.filter = Some(filter);
3956
3957 let result = run_with_open_file_cleanup(
3958 &admission_limits,
3959 link_internal(
3960 &PROGRESS,
3961 &src_parent,
3962 Some((&src_parent, std::ffi::OsStr::new("update"))),
3963 Some(&dst_parent),
3964 std::ffi::OsStr::new("src"),
3965 &src,
3966 &dst,
3967 Some(&update),
3968 std::path::Path::new(""),
3969 UpdateRootRequirement::Optional,
3970 &settings,
3971 false,
3972 LinkEntryAdmission::Filtered(entry),
3973 ),
3974 )
3975 .await?;
3976
3977 assert!(
3978 result.is_err(),
3979 "a selected file reopened as a directory must fail closed"
3980 );
3981 assert!(
3982 !dst.exists(),
3983 "a failed-closed type swap must not create a destination"
3984 );
3985 Ok(())
3986 }
3987
3988 #[tokio::test]
3990 #[traced_test]
3991 async fn filtered_source_only_symlink_swap_to_directory_never_materializes_replacement()
3992 -> Result<(), anyhow::Error> {
3993 let test_path = testutils::create_temp_dir().await?;
3994 let src = test_path.join("src");
3995 let update = test_path.join("update");
3996 let dst = test_path.join("dst");
3997 tokio::fs::write(test_path.join("target"), "TARGET").await?;
3998 tokio::fs::symlink("target", &src).await?;
3999 tokio::fs::create_dir(&update).await?;
4000
4001 let admission_limits = testutils::AdmissionLimit::new().await;
4002 admission_limits.set_files_in_flight(1);
4003 let permit = walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
4004 let parent = Arc::new(
4005 Dir::open_parent_dir(&test_path, congestion::Side::Source)
4006 .await?
4007 .into_tree(),
4008 );
4009 let dst_parent = Arc::new(
4010 Dir::open_parent_dir(&test_path, congestion::Side::Destination)
4011 .await?
4012 .into_tree(),
4013 );
4014 let src_handle = parent.child(std::ffi::OsStr::new("src")).await?;
4015 let update_handle = parent.child(std::ffi::OsStr::new("update")).await?;
4016 let mut filter = FilterSettings::new();
4017 filter.add_exclude("src/")?;
4018 assert!(matches!(
4019 select_filtered_root(
4020 &filter,
4021 std::ffi::OsStr::new("src"),
4022 src_handle.kind(),
4023 Some(update_handle.kind()),
4024 false,
4025 ),
4026 RootFilterSelection::SourceOnly
4027 ));
4028 drop(update_handle);
4029 let entry = AdmittedLinkEntry::new(src_handle, permit);
4030
4031 tokio::fs::remove_file(&src).await?;
4034 tokio::fs::create_dir(&src).await?;
4035 tokio::fs::write(src.join("replacement.txt"), "UNSELECTED").await?;
4036 let mut settings = common_settings(false, false);
4037 settings.filter = Some(filter.clone());
4038 settings.copy_settings.filter = Some(filter);
4039
4040 run_with_open_file_cleanup(
4041 &admission_limits,
4042 link_internal(
4043 &PROGRESS,
4044 &parent,
4045 Some((&parent, std::ffi::OsStr::new("update"))),
4046 Some(&dst_parent),
4047 std::ffi::OsStr::new("src"),
4048 &src,
4049 &dst,
4050 Some(&update),
4051 std::path::Path::new(""),
4052 UpdateRootRequirement::Optional,
4053 &settings,
4054 false,
4055 LinkEntryAdmission::Filtered(entry),
4056 ),
4057 )
4058 .await??;
4059
4060 assert!(
4061 !dst.is_dir(),
4062 "the replacement directory passed the selected symlink's filter decision"
4063 );
4064 assert_eq!(
4065 tokio::fs::read_link(&dst).await?,
4066 std::path::Path::new("target")
4067 );
4068 Ok(())
4069 }
4070
4071 #[tokio::test]
4073 #[traced_test]
4074 async fn admitted_update_file_swap_to_file_copies_replacement_content()
4075 -> Result<(), anyhow::Error> {
4076 let test_path = testutils::create_temp_dir().await?;
4077 let src = test_path.join("src");
4078 let update = test_path.join("update");
4079 let dst = test_path.join("dst");
4080 tokio::fs::write(&src, "S").await?;
4081 tokio::fs::write(&update, "SELECTED-UPDATE").await?;
4082
4083 let admission_limits = testutils::AdmissionLimit::new().await;
4084 admission_limits.set_files_in_flight(1);
4085 let permit = walk::ensure_leaf_permit(PermitKind::OpenFile, None).await;
4086 let parent = Arc::new(
4087 Dir::open_parent_dir(&test_path, congestion::Side::Source)
4088 .await?
4089 .into_tree(),
4090 );
4091 let dst_parent = Arc::new(
4092 Dir::open_parent_dir(&test_path, congestion::Side::Destination)
4093 .await?
4094 .into_tree(),
4095 );
4096 let src_handle = parent.child(std::ffi::OsStr::new("src")).await?;
4097 let update_handle = parent.child(std::ffi::OsStr::new("update")).await?;
4098 let mut entry = AdmittedLinkEntry::new(src_handle, permit);
4099 entry.update_handle = Some(update_handle);
4100
4101 tokio::fs::remove_file(&update).await?;
4102 tokio::fs::write(&update, "REPLACEMENT-CONTENT").await?;
4103 run_with_open_file_cleanup(
4104 &admission_limits,
4105 link_internal(
4106 &PROGRESS,
4107 &parent,
4108 Some((&parent, std::ffi::OsStr::new("update"))),
4109 Some(&dst_parent),
4110 std::ffi::OsStr::new("src"),
4111 &src,
4112 &dst,
4113 Some(&update),
4114 std::path::Path::new(""),
4115 UpdateRootRequirement::Optional,
4116 &common_settings(false, false),
4117 false,
4118 LinkEntryAdmission::Filtered(entry),
4119 ),
4120 )
4121 .await??;
4122
4123 assert_eq!(
4124 tokio::fs::read_to_string(&dst).await?,
4125 "REPLACEMENT-CONTENT"
4126 );
4127 Ok(())
4128 }
4129
4130 #[tokio::test]
4132 #[traced_test]
4133 async fn update_only_unknown_file_swap_to_excluded_directory_never_materializes_replacement()
4134 -> Result<(), anyhow::Error> {
4135 let test_path = testutils::create_temp_dir().await?;
4136 let update = test_path.join("update");
4137 let dst = test_path.join("dst");
4138 let admission_limits = testutils::AdmissionLimit::new().await;
4139 admission_limits.set_files_in_flight(1);
4140 tokio::fs::create_dir(&update).await?;
4141 tokio::fs::create_dir(&dst).await?;
4142 let entry_path = update.join("node");
4143 let dst_path = dst.join("node");
4144 tokio::fs::write(&entry_path, "SELECTED-UPDATE-FILE").await?;
4145 let update_parent =
4146 Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
4147 let dst_parent =
4148 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4149 let mut filter = FilterSettings::new();
4150 filter.add_exclude("node/")?;
4151 let mut settings = common_settings(false, false);
4152 settings.filter = Some(filter.clone());
4153 settings.copy_settings.filter = Some(filter);
4154 let admission =
4155 walk::ensure_entry_admission(PermitKind::OpenFile, EntryAdmission::RootOrDelegated)
4156 .await;
4157 let selected = select_exact_update_only_for_dispatch(
4158 &update_parent,
4159 std::ffi::OsStr::new("node"),
4160 admission,
4161 std::path::Path::new("node"),
4162 settings.filter.as_ref(),
4163 )
4164 .await?;
4165 let UpdateOnlyDecision::Admitted(entry) = selected else {
4166 panic!("the forced-unknown file must pass the non-directory filter")
4167 };
4168 assert_eq!(entry.kind(), EntryKind::File);
4169
4170 tokio::fs::remove_file(&entry_path).await?;
4171 tokio::fs::create_dir(&entry_path).await?;
4172 tokio::fs::write(entry_path.join("replacement.txt"), "UNSELECTED").await?;
4173 let result = run_with_open_file_cleanup(
4174 &admission_limits,
4175 delegate_copy(
4176 &PROGRESS,
4177 &update_parent,
4178 Some(&dst_parent),
4179 std::ffi::OsStr::new("node"),
4180 &entry_path,
4181 &dst_path,
4182 std::path::Path::new("node"),
4183 copy::DeleteScanAnchor::new(&dst, std::path::Path::new("node")),
4184 &settings,
4185 false,
4186 entry,
4187 ),
4188 )
4189 .await?;
4190
4191 assert!(
4192 result.is_err(),
4193 "a selected file reopened as a directory must fail closed"
4194 );
4195 assert!(
4196 !dst_path.exists(),
4197 "a failed-closed type swap must not create a destination"
4198 );
4199 Ok(())
4200 }
4201
4202 #[tokio::test]
4204 #[traced_test]
4205 async fn nested_source_unknown_file_swap_to_excluded_directory_is_rechecked()
4206 -> Result<(), anyhow::Error> {
4207 let test_path = testutils::create_temp_dir().await?;
4208 let src = test_path.join("src");
4209 let dst = test_path.join("dst");
4210 tokio::fs::create_dir(&src).await?;
4211 tokio::fs::create_dir(&dst).await?;
4212 let entry_path = src.join("node");
4213 tokio::fs::write(&entry_path, "SELECTED-FILE").await?;
4214 tokio::fs::write(dst.join("node"), "STALE-DESTINATION").await?;
4215 let src_parent =
4216 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
4217 let dst_parent =
4218 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4219 let mut filter = FilterSettings::new();
4220 filter.add_exclude("node/")?;
4221 let initially_is_dir = walk::filter_is_dir(
4222 Some(&filter),
4223 &src_parent,
4224 std::ffi::OsStr::new("node"),
4225 None,
4226 false,
4227 )
4228 .await?;
4229 assert!(!initially_is_dir);
4230 assert!(
4231 walk::should_skip_entry(&Some(filter.clone()), std::path::Path::new("node"), false)
4232 .is_none()
4233 );
4234
4235 tokio::fs::remove_file(&entry_path).await?;
4236 tokio::fs::create_dir(&entry_path).await?;
4237 tokio::fs::write(entry_path.join("replacement.txt"), "UNSELECTED").await?;
4238 let mut settings = common_settings(false, false);
4239 settings.filter = Some(filter.clone());
4240 settings.copy_settings.filter = Some(filter);
4241 let delete_settings = copy::DeleteSettings {
4242 delete_excluded: true,
4243 };
4244 let mut keep_set = DeleteKeepSet::new(Some(&delete_settings));
4245 let result = crate::walk_driver::scope_tasks(link_internal(
4246 &PROGRESS,
4247 &src_parent,
4248 None,
4249 Some(&dst_parent),
4250 std::ffi::OsStr::new("node"),
4251 &src,
4252 &dst,
4253 None,
4254 std::path::Path::new("node"),
4255 UpdateRootRequirement::Optional,
4256 &settings,
4257 false,
4258 EntryAdmission::RootOrDelegated.into(),
4259 ))
4260 .await?;
4261 let mut summary = Summary::default();
4262 LinkTaskResult::from_link(std::ffi::OsString::from("node"), result)
4263 .fold(&mut summary, &mut keep_set);
4264 crate::delete::prune_extraneous(
4265 &PROGRESS,
4266 &dst_parent,
4267 std::path::Path::new(""),
4268 keep_set.as_set().expect("delete is enabled"),
4269 settings.filter.as_ref(),
4270 &delete_settings,
4271 false,
4272 None,
4273 )
4274 .await?;
4275
4276 assert!(
4277 !dst.join("node").exists(),
4278 "the exact filtered result did not remove the stale outer keep decision"
4279 );
4280 assert_eq!(summary.copy_summary.directories_skipped, 1);
4281 Ok(())
4282 }
4283
4284 #[tokio::test]
4287 #[traced_test]
4288 async fn destructive_source_stale_directory_hint_rechecks_included_file()
4289 -> Result<(), anyhow::Error> {
4290 let test_path = testutils::create_temp_dir().await?;
4291 let src = test_path.join("src");
4292 let dst = test_path.join("dst");
4293 tokio::fs::create_dir(&src).await?;
4294 tokio::fs::create_dir(&dst).await?;
4295 tokio::fs::write(src.join("node"), "CURRENT-SOURCE").await?;
4296 tokio::fs::write(dst.join("node"), "STALE-DESTINATION").await?;
4297 let src_dir =
4298 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
4299 let dst_dir =
4300 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4301
4302 let mut filter = FilterSettings::new();
4303 filter.add_exclude("node/")?;
4304 let delete_settings = copy::DeleteSettings {
4305 delete_excluded: false,
4306 };
4307 let mut settings = common_settings(false, true);
4308 settings.filter = Some(filter.clone());
4309 settings.copy_settings.filter = Some(filter);
4310 settings.copy_settings.delete = Some(delete_settings.clone());
4311 let decision = select_source_for_dispatch(
4312 Some(EntryKind::Dir),
4313 std::path::Path::new("node"),
4314 &settings,
4315 false,
4316 );
4317
4318 let mut summary = Summary::default();
4319 let mut keep_set = DeleteKeepSet::new(Some(&delete_settings));
4320 match decision {
4321 SourceEntryDecision::Filtered { kind, .. } => {
4322 summary = summary + skipped_summary_for(kind);
4323 }
4324 SourceEntryDecision::Dispatch(admission) => {
4325 let admission =
4326 walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
4327 let result = crate::walk_driver::scope_tasks(link_internal(
4328 &PROGRESS,
4329 &src_dir,
4330 None,
4331 Some(&dst_dir),
4332 std::ffi::OsStr::new("node"),
4333 &src,
4334 &dst,
4335 None,
4336 std::path::Path::new("node"),
4337 UpdateRootRequirement::Optional,
4338 &settings,
4339 false,
4340 admission.into(),
4341 ))
4342 .await?;
4343 LinkTaskResult::from_link(std::ffi::OsString::from("node"), result)
4344 .fold(&mut summary, &mut keep_set);
4345 }
4346 }
4347 crate::delete::prune_extraneous(
4348 &PROGRESS,
4349 &dst_dir,
4350 std::path::Path::new(""),
4351 keep_set.as_set().expect("delete is enabled"),
4352 settings.filter.as_ref(),
4353 &delete_settings,
4354 false,
4355 None,
4356 )
4357 .await?;
4358
4359 assert_eq!(
4360 tokio::fs::read_to_string(dst.join("node")).await?,
4361 "CURRENT-SOURCE",
4362 "the exact included file must materialize and protect its destination name"
4363 );
4364 assert_eq!(summary.hard_links_created, 1);
4365 Ok(())
4366 }
4367
4368 #[tokio::test]
4371 #[traced_test]
4372 async fn dry_run_source_stale_directory_hint_reports_included_file()
4373 -> Result<(), anyhow::Error> {
4374 let test_path = testutils::create_temp_dir().await?;
4375 let src = test_path.join("src");
4376 let dst = test_path.join("dst");
4377 tokio::fs::create_dir(&src).await?;
4378 tokio::fs::write(src.join("node"), "CURRENT-SOURCE").await?;
4379 let src_dir =
4380 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
4381
4382 let mut filter = FilterSettings::new();
4383 filter.add_exclude("node/")?;
4384 let mut settings = common_settings(false, false);
4385 settings.filter = Some(filter.clone());
4386 settings.copy_settings.filter = Some(filter);
4387 settings.dry_run = Some(crate::config::DryRunMode::Brief);
4388 let decision = select_source_for_dispatch(
4389 Some(EntryKind::Dir),
4390 std::path::Path::new("node"),
4391 &settings,
4392 false,
4393 );
4394
4395 let mut summary = Summary::default();
4396 let mut keep_set = DeleteKeepSet::new(None);
4397 match decision {
4398 SourceEntryDecision::Filtered { kind, .. } => {
4399 summary = summary + skipped_summary_for(kind);
4400 }
4401 SourceEntryDecision::Dispatch(admission) => {
4402 let admission =
4403 walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
4404 let result = crate::walk_driver::scope_tasks(link_internal(
4405 &PROGRESS,
4406 &src_dir,
4407 None,
4408 None,
4409 std::ffi::OsStr::new("node"),
4410 &src,
4411 &dst,
4412 None,
4413 std::path::Path::new("node"),
4414 UpdateRootRequirement::Optional,
4415 &settings,
4416 false,
4417 admission.into(),
4418 ))
4419 .await?;
4420 LinkTaskResult::from_link(std::ffi::OsString::from("node"), result)
4421 .fold(&mut summary, &mut keep_set);
4422 }
4423 }
4424
4425 assert_eq!(summary.hard_links_created, 1);
4426 assert_eq!(summary.copy_summary.directories_skipped, 0);
4427 assert!(!dst.join("node").exists());
4428 Ok(())
4429 }
4430
4431 #[tokio::test]
4434 #[traced_test]
4435 async fn skip_specials_stale_special_hint_rechecks_regular_file()
4436 -> Result<(), anyhow::Error> {
4437 let test_path = testutils::create_temp_dir().await?;
4438 let src = test_path.join("src");
4439 let dst = test_path.join("dst");
4440 tokio::fs::create_dir(&src).await?;
4441 tokio::fs::create_dir(&dst).await?;
4442 tokio::fs::write(src.join("node"), "CURRENT-SOURCE").await?;
4443 tokio::fs::write(dst.join("node"), "STALE-DESTINATION").await?;
4444 let src_dir =
4445 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
4446 let dst_dir =
4447 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4448
4449 let delete_settings = copy::DeleteSettings {
4450 delete_excluded: false,
4451 };
4452 let mut settings = common_settings(false, true);
4453 settings.copy_settings.skip_specials = true;
4454 settings.copy_settings.delete = Some(delete_settings.clone());
4455 let decision = select_source_for_dispatch(
4456 Some(EntryKind::Special),
4457 std::path::Path::new("node"),
4458 &settings,
4459 false,
4460 );
4461
4462 let mut summary = Summary::default();
4463 let mut keep_set = DeleteKeepSet::new(Some(&delete_settings));
4464 match decision {
4465 SourceEntryDecision::Filtered { kind, .. } => {
4466 summary = summary + skipped_summary_for(kind);
4467 }
4468 SourceEntryDecision::Dispatch(admission) => {
4469 let admission =
4470 walk::ensure_entry_admission(PermitKind::OpenFile, admission).await;
4471 let result = crate::walk_driver::scope_tasks(link_internal(
4472 &PROGRESS,
4473 &src_dir,
4474 None,
4475 Some(&dst_dir),
4476 std::ffi::OsStr::new("node"),
4477 &src,
4478 &dst,
4479 None,
4480 std::path::Path::new("node"),
4481 UpdateRootRequirement::Optional,
4482 &settings,
4483 false,
4484 admission.into(),
4485 ))
4486 .await?;
4487 LinkTaskResult::from_link(std::ffi::OsString::from("node"), result)
4488 .fold(&mut summary, &mut keep_set);
4489 }
4490 }
4491 crate::delete::prune_extraneous(
4492 &PROGRESS,
4493 &dst_dir,
4494 std::path::Path::new(""),
4495 keep_set.as_set().expect("delete is enabled"),
4496 None,
4497 &delete_settings,
4498 false,
4499 None,
4500 )
4501 .await?;
4502
4503 assert_eq!(
4504 tokio::fs::read_to_string(dst.join("node")).await?,
4505 "CURRENT-SOURCE",
4506 "the exact regular file must replace stale destination content"
4507 );
4508 assert_eq!(summary.hard_links_created, 1);
4509 assert_eq!(summary.copy_summary.specials_skipped, 0);
4510 Ok(())
4511 }
4512
4513 #[tokio::test]
4516 #[traced_test]
4517 async fn destructive_update_stale_directory_hint_rechecks_included_file()
4518 -> Result<(), anyhow::Error> {
4519 let test_path = testutils::create_temp_dir().await?;
4520 let update = test_path.join("update");
4521 let dst = test_path.join("dst");
4522 tokio::fs::create_dir(&update).await?;
4523 tokio::fs::create_dir(&dst).await?;
4524 tokio::fs::write(update.join("node"), "CURRENT-UPDATE").await?;
4525 tokio::fs::write(dst.join("node"), "STALE-DESTINATION").await?;
4526 let update_dir =
4527 Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
4528 let dst_dir =
4529 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
4530
4531 let mut filter = FilterSettings::new();
4532 filter.add_exclude("node/")?;
4533 let delete_settings = copy::DeleteSettings {
4534 delete_excluded: false,
4535 };
4536 let mut settings = common_settings(false, true);
4537 settings.filter = Some(filter.clone());
4538 settings.copy_settings.filter = Some(filter);
4539 settings.copy_settings.delete = Some(delete_settings.clone());
4540 let decision = select_exact_update_only_for_dispatch(
4541 &update_dir,
4542 std::ffi::OsStr::new("node"),
4543 EntryAdmission::HintedDirectory,
4544 std::path::Path::new("node"),
4545 settings.filter.as_ref(),
4546 )
4547 .await?;
4548
4549 let mut summary = Summary::default();
4550 let mut keep_set = DeleteKeepSet::new(Some(&delete_settings));
4551 match decision {
4552 UpdateOnlyDecision::Skipped { kind, .. } => {
4553 summary = summary + skipped_summary_for(kind);
4554 }
4555 UpdateOnlyDecision::Admitted(entry) => {
4556 let result = delegate_copy(
4557 &PROGRESS,
4558 &update_dir,
4559 Some(&dst_dir),
4560 std::ffi::OsStr::new("node"),
4561 &update.join("node"),
4562 &dst.join("node"),
4563 std::path::Path::new("node"),
4564 copy::DeleteScanAnchor::new(&dst, std::path::Path::new("node")),
4565 &settings,
4566 false,
4567 entry,
4568 )
4569 .await?;
4570 LinkTaskResult::from_update(
4571 std::ffi::OsString::from("node"),
4572 LinkEntryResult::selected(result),
4573 )
4574 .fold(&mut summary, &mut keep_set);
4575 }
4576 }
4577 crate::delete::prune_extraneous(
4578 &PROGRESS,
4579 &dst_dir,
4580 std::path::Path::new(""),
4581 keep_set.as_set().expect("delete is enabled"),
4582 settings.filter.as_ref(),
4583 &delete_settings,
4584 false,
4585 None,
4586 )
4587 .await?;
4588
4589 assert_eq!(
4590 tokio::fs::read_to_string(dst.join("node")).await?,
4591 "CURRENT-UPDATE",
4592 "the exact included update file must materialize and protect its destination name"
4593 );
4594 assert_eq!(summary.copy_summary.files_copied, 1);
4595 Ok(())
4596 }
4597
4598 #[tokio::test]
4600 #[traced_test]
4601 async fn dry_run_update_stale_directory_hint_reports_included_file()
4602 -> Result<(), anyhow::Error> {
4603 let test_path = testutils::create_temp_dir().await?;
4604 let update = test_path.join("update");
4605 let dst = test_path.join("dst");
4606 tokio::fs::create_dir(&update).await?;
4607 tokio::fs::write(update.join("node"), "CURRENT-UPDATE").await?;
4608 let update_dir =
4609 Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
4610
4611 let mut filter = FilterSettings::new();
4612 filter.add_exclude("node/")?;
4613 let mut settings = common_settings(false, false);
4614 settings.filter = Some(filter.clone());
4615 settings.copy_settings.filter = Some(filter);
4616 settings.dry_run = Some(crate::config::DryRunMode::Brief);
4617 let decision = select_exact_update_only_for_dispatch(
4618 &update_dir,
4619 std::ffi::OsStr::new("node"),
4620 EntryAdmission::HintedDirectory,
4621 std::path::Path::new("node"),
4622 settings.filter.as_ref(),
4623 )
4624 .await?;
4625
4626 let mut summary = Summary::default();
4627 let mut keep_set = DeleteKeepSet::new(None);
4628 match decision {
4629 UpdateOnlyDecision::Skipped { kind, .. } => {
4630 summary = summary + skipped_summary_for(kind);
4631 }
4632 UpdateOnlyDecision::Admitted(entry) => {
4633 let result = delegate_copy(
4634 &PROGRESS,
4635 &update_dir,
4636 None,
4637 std::ffi::OsStr::new("node"),
4638 &update.join("node"),
4639 &dst.join("node"),
4640 std::path::Path::new("node"),
4641 copy::DeleteScanAnchor::new(&dst, std::path::Path::new("node")),
4642 &settings,
4643 false,
4644 entry,
4645 )
4646 .await?;
4647 LinkTaskResult::from_update(
4648 std::ffi::OsString::from("node"),
4649 LinkEntryResult::selected(result),
4650 )
4651 .fold(&mut summary, &mut keep_set);
4652 }
4653 }
4654
4655 assert_eq!(summary.copy_summary.files_copied, 1);
4656 assert_eq!(summary.copy_summary.directories_skipped, 0);
4657 assert!(!dst.join("node").exists());
4658 Ok(())
4659 }
4660
4661 #[tokio::test]
4663 #[traced_test]
4664 async fn excluded_dual_root_skips_before_destination_validation()
4665 -> Result<(), anyhow::Error> {
4666 let test_path = testutils::create_temp_dir().await?;
4667 let src_parent = test_path.join("source");
4668 let update_parent = test_path.join("update");
4669 let src = src_parent.join("blocked");
4670 let update = update_parent.join("blocked");
4671 tokio::fs::create_dir(&src_parent).await?;
4672 tokio::fs::create_dir(&update_parent).await?;
4673 tokio::fs::write(&src, "SRC").await?;
4674 tokio::fs::write(&update, "UPDATE").await?;
4675 let mut filter = FilterSettings::new();
4676 filter.add_exclude("blocked").unwrap();
4677 let mut settings = common_settings(false, false);
4678 settings.filter = Some(filter);
4679 let summary = link(
4680 &PROGRESS,
4681 &test_path,
4682 &src,
4683 std::path::Path::new("/"),
4684 &Some(update),
4685 &settings,
4686 false,
4687 )
4688 .await?;
4689 assert_eq!(summary.copy_summary.files_skipped, 1);
4690 Ok(())
4691 }
4692
4693 #[tokio::test]
4696 async fn excluded_source_only_root_skips_under_execute_only_parent()
4697 -> Result<(), anyhow::Error> {
4698 let test_path = testutils::create_temp_dir().await?;
4699 let parent = test_path.join("parent");
4700 let src = parent.join("src");
4701 let dst = test_path.join("dst");
4702 tokio::fs::create_dir_all(&src).await?;
4703 tokio::fs::write(src.join("leaf"), b"leaf").await?;
4704 let mut filter = FilterSettings::new();
4705 filter.add_exclude("src")?;
4706 let mut settings = common_settings(false, false);
4707 settings.filter = Some(filter.clone());
4708 settings.copy_settings.filter = Some(filter);
4709 tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o111)).await?;
4710 let result = link(&PROGRESS, &test_path, &src, &dst, &None, &settings, false).await;
4711 tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).await?;
4712 let summary = result?;
4713 assert_eq!(summary.copy_summary.directories_skipped, 1);
4714 assert!(!dst.exists());
4715 Ok(())
4716 }
4717
4718 #[tokio::test]
4720 #[traced_test]
4721 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
4722 let test_path = testutils::create_temp_dir().await?;
4723 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
4725 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
4726 let mut filter = FilterSettings::new();
4728 filter.add_exclude("*_dir/").unwrap();
4729 let result = link(
4730 &PROGRESS,
4731 &test_path,
4732 &test_path.join("excluded_dir"),
4733 &test_path.join("dst"),
4734 &None,
4735 &Settings {
4736 copy_settings: CopySettings {
4737 dereference: false,
4738 fail_early: false,
4739 overwrite: false,
4740 overwrite_compare: Default::default(),
4741 overwrite_filter: None,
4742 ignore_existing: false,
4743 chunk_size: 0,
4744 skip_specials: false,
4745 remote_copy_buffer_size: 0,
4746 filter: None,
4747 dry_run: None,
4748 delete: None,
4749 },
4750 update_compare: Default::default(),
4751 update_exclusive: false,
4752 filter: Some(filter),
4753 dry_run: None,
4754 preserve: preserve::preserve_all(),
4755 },
4756 false,
4757 )
4758 .await?;
4759 assert_eq!(
4761 result.copy_summary.directories_created, 0,
4762 "root directory matching exclude should not be created"
4763 );
4764 assert!(
4765 !test_path.join("dst").exists(),
4766 "excluded root directory should not exist at destination"
4767 );
4768 Ok(())
4769 }
4770 #[tokio::test]
4772 #[traced_test]
4773 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
4774 let test_path = testutils::create_temp_dir().await?;
4775 tokio::fs::write(test_path.join("target.txt"), "content").await?;
4777 tokio::fs::symlink(
4778 test_path.join("target.txt"),
4779 test_path.join("excluded_link"),
4780 )
4781 .await?;
4782 let mut filter = FilterSettings::new();
4784 filter.add_exclude("*_link").unwrap();
4785 let result = link(
4786 &PROGRESS,
4787 &test_path,
4788 &test_path.join("excluded_link"),
4789 &test_path.join("dst"),
4790 &None,
4791 &Settings {
4792 copy_settings: CopySettings {
4793 dereference: false,
4794 fail_early: false,
4795 overwrite: false,
4796 overwrite_compare: Default::default(),
4797 overwrite_filter: None,
4798 ignore_existing: false,
4799 chunk_size: 0,
4800 skip_specials: false,
4801 remote_copy_buffer_size: 0,
4802 filter: None,
4803 dry_run: None,
4804 delete: None,
4805 },
4806 update_compare: Default::default(),
4807 update_exclusive: false,
4808 filter: Some(filter),
4809 dry_run: None,
4810 preserve: preserve::preserve_all(),
4811 },
4812 false,
4813 )
4814 .await?;
4815 assert_eq!(
4817 result.copy_summary.symlinks_created, 0,
4818 "root symlink matching exclude should not be created"
4819 );
4820 assert!(
4821 !test_path.join("dst").exists(),
4822 "excluded root symlink should not exist at destination"
4823 );
4824 Ok(())
4825 }
4826 #[tokio::test]
4828 #[traced_test]
4829 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
4830 let tmp_dir = testutils::setup_test_dir().await?;
4831 let test_path = tmp_dir.as_path();
4832 let mut filter = FilterSettings::new();
4839 filter.add_include("bar/*.txt").unwrap();
4840 filter.add_exclude("bar/2.txt").unwrap();
4841 let summary = link(
4842 &PROGRESS,
4843 test_path,
4844 &test_path.join("foo"),
4845 &test_path.join("dst"),
4846 &None,
4847 &Settings {
4848 copy_settings: CopySettings {
4849 dereference: false,
4850 fail_early: false,
4851 overwrite: false,
4852 overwrite_compare: Default::default(),
4853 overwrite_filter: None,
4854 ignore_existing: false,
4855 chunk_size: 0,
4856 skip_specials: false,
4857 remote_copy_buffer_size: 0,
4858 filter: None,
4859 dry_run: None,
4860 delete: None,
4861 },
4862 update_compare: Default::default(),
4863 update_exclusive: false,
4864 filter: Some(filter),
4865 dry_run: None,
4866 preserve: preserve::preserve_all(),
4867 },
4868 false,
4869 )
4870 .await?;
4871 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
4874 assert_eq!(
4875 summary.copy_summary.files_skipped, 2,
4876 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
4877 );
4878 assert!(
4880 test_path.join("dst/bar/1.txt").exists(),
4881 "bar/1.txt should be linked"
4882 );
4883 assert!(
4884 !test_path.join("dst/bar/2.txt").exists(),
4885 "bar/2.txt should be excluded"
4886 );
4887 assert!(
4888 test_path.join("dst/bar/3.txt").exists(),
4889 "bar/3.txt should be linked"
4890 );
4891 Ok(())
4892 }
4893 #[tokio::test]
4895 #[traced_test]
4896 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
4897 let tmp_dir = testutils::setup_test_dir().await?;
4898 let test_path = tmp_dir.as_path();
4899 let mut filter = FilterSettings::new();
4906 filter.add_exclude("bar/").unwrap();
4907 let summary = link(
4908 &PROGRESS,
4909 test_path,
4910 &test_path.join("foo"),
4911 &test_path.join("dst"),
4912 &None,
4913 &Settings {
4914 copy_settings: CopySettings {
4915 dereference: false,
4916 fail_early: false,
4917 overwrite: false,
4918 overwrite_compare: Default::default(),
4919 overwrite_filter: None,
4920 ignore_existing: false,
4921 chunk_size: 0,
4922 skip_specials: false,
4923 remote_copy_buffer_size: 0,
4924 filter: None,
4925 dry_run: None,
4926 delete: None,
4927 },
4928 update_compare: Default::default(),
4929 update_exclusive: false,
4930 filter: Some(filter),
4931 dry_run: None,
4932 preserve: preserve::preserve_all(),
4933 },
4934 false,
4935 )
4936 .await?;
4937 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
4941 assert_eq!(
4942 summary.copy_summary.symlinks_created, 2,
4943 "should copy 2 symlinks"
4944 );
4945 assert_eq!(
4946 summary.copy_summary.directories_skipped, 1,
4947 "should skip 1 directory (bar)"
4948 );
4949 assert!(
4951 !test_path.join("dst/bar").exists(),
4952 "bar directory should not be linked"
4953 );
4954 Ok(())
4955 }
4956 #[tokio::test]
4959 #[traced_test]
4960 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
4961 let test_path = testutils::create_temp_dir().await?;
4962 let src_path = test_path.join("src");
4968 tokio::fs::create_dir(&src_path).await?;
4969 tokio::fs::write(src_path.join("foo"), "content").await?;
4970 tokio::fs::write(src_path.join("bar"), "content").await?;
4971 tokio::fs::create_dir(src_path.join("baz")).await?;
4972 let mut filter = FilterSettings::new();
4974 filter.add_include("foo").unwrap();
4975 let summary = link(
4976 &PROGRESS,
4977 &test_path,
4978 &src_path,
4979 &test_path.join("dst"),
4980 &None,
4981 &Settings {
4982 copy_settings: copy::Settings {
4983 dereference: false,
4984 fail_early: false,
4985 overwrite: false,
4986 overwrite_compare: Default::default(),
4987 overwrite_filter: None,
4988 ignore_existing: false,
4989 chunk_size: 0,
4990 skip_specials: false,
4991 remote_copy_buffer_size: 0,
4992 filter: None,
4993 dry_run: None,
4994 delete: None,
4995 },
4996 update_compare: Default::default(),
4997 update_exclusive: false,
4998 filter: Some(filter),
4999 dry_run: None,
5000 preserve: preserve::preserve_all(),
5001 },
5002 false,
5003 )
5004 .await?;
5005 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
5007 assert_eq!(
5008 summary.copy_summary.directories_created, 1,
5009 "should create only root directory (not empty 'baz')"
5010 );
5011 assert!(
5013 test_path.join("dst").join("foo").exists(),
5014 "foo should be linked"
5015 );
5016 assert!(
5018 !test_path.join("dst").join("bar").exists(),
5019 "bar should not be linked"
5020 );
5021 assert!(
5023 !test_path.join("dst").join("baz").exists(),
5024 "empty baz directory should NOT be created"
5025 );
5026 Ok(())
5027 }
5028 #[tokio::test]
5031 #[traced_test]
5032 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
5033 let test_path = testutils::create_temp_dir().await?;
5034 let src_path = test_path.join("src");
5041 tokio::fs::create_dir(&src_path).await?;
5042 tokio::fs::write(src_path.join("foo"), "content").await?;
5043 tokio::fs::create_dir(src_path.join("baz")).await?;
5044 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
5045 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
5046 let mut filter = FilterSettings::new();
5048 filter.add_include("foo").unwrap();
5049 let summary = link(
5050 &PROGRESS,
5051 &test_path,
5052 &src_path,
5053 &test_path.join("dst"),
5054 &None,
5055 &Settings {
5056 copy_settings: copy::Settings {
5057 dereference: false,
5058 fail_early: false,
5059 overwrite: false,
5060 overwrite_compare: Default::default(),
5061 overwrite_filter: None,
5062 ignore_existing: false,
5063 chunk_size: 0,
5064 skip_specials: false,
5065 remote_copy_buffer_size: 0,
5066 filter: None,
5067 dry_run: None,
5068 delete: None,
5069 },
5070 update_compare: Default::default(),
5071 update_exclusive: false,
5072 filter: Some(filter),
5073 dry_run: None,
5074 preserve: preserve::preserve_all(),
5075 },
5076 false,
5077 )
5078 .await?;
5079 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
5081 assert_eq!(
5082 summary.copy_summary.files_skipped, 2,
5083 "should skip 2 files (qux and quux)"
5084 );
5085 assert_eq!(
5086 summary.copy_summary.directories_created, 1,
5087 "should create only root directory (not 'baz' with non-matching content)"
5088 );
5089 assert!(
5091 test_path.join("dst").join("foo").exists(),
5092 "foo should be linked"
5093 );
5094 assert!(
5096 !test_path.join("dst").join("baz").exists(),
5097 "baz directory should NOT be created (no matching content inside)"
5098 );
5099 Ok(())
5100 }
5101 #[tokio::test]
5104 #[traced_test]
5105 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
5106 let test_path = testutils::create_temp_dir().await?;
5107 let src_path = test_path.join("src");
5113 tokio::fs::create_dir(&src_path).await?;
5114 tokio::fs::write(src_path.join("foo"), "content").await?;
5115 tokio::fs::write(src_path.join("bar"), "content").await?;
5116 tokio::fs::create_dir(src_path.join("baz")).await?;
5117 let mut filter = FilterSettings::new();
5119 filter.add_include("foo").unwrap();
5120 let summary = link(
5121 &PROGRESS,
5122 &test_path,
5123 &src_path,
5124 &test_path.join("dst"),
5125 &None,
5126 &Settings {
5127 copy_settings: copy::Settings {
5128 dereference: false,
5129 fail_early: false,
5130 overwrite: false,
5131 overwrite_compare: Default::default(),
5132 overwrite_filter: None,
5133 ignore_existing: false,
5134 chunk_size: 0,
5135 skip_specials: false,
5136 remote_copy_buffer_size: 0,
5137 filter: None,
5138 dry_run: None,
5139 delete: None,
5140 },
5141 update_compare: Default::default(),
5142 update_exclusive: false,
5143 filter: Some(filter),
5144 dry_run: Some(crate::config::DryRunMode::Explain),
5145 preserve: preserve::preserve_all(),
5146 },
5147 false,
5148 )
5149 .await?;
5150 assert_eq!(
5152 summary.hard_links_created, 1,
5153 "should report only 'foo' would be linked"
5154 );
5155 assert_eq!(
5156 summary.copy_summary.directories_created, 1,
5157 "should report only root directory would be created (not empty 'baz')"
5158 );
5159 assert!(
5161 !test_path.join("dst").exists(),
5162 "dst should not exist in dry-run"
5163 );
5164 Ok(())
5165 }
5166 #[tokio::test]
5169 #[traced_test]
5170 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
5171 let test_path = testutils::create_temp_dir().await?;
5172 let src_path = test_path.join("src");
5178 tokio::fs::create_dir(&src_path).await?;
5179 tokio::fs::write(src_path.join("foo"), "content").await?;
5180 tokio::fs::write(src_path.join("bar"), "content").await?;
5181 tokio::fs::create_dir(src_path.join("baz")).await?;
5182 let dst_path = test_path.join("dst");
5184 tokio::fs::create_dir(&dst_path).await?;
5185 tokio::fs::create_dir(dst_path.join("baz")).await?;
5186 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
5188 let mut filter = FilterSettings::new();
5190 filter.add_include("foo").unwrap();
5191 let summary = link(
5192 &PROGRESS,
5193 &test_path,
5194 &src_path,
5195 &dst_path,
5196 &None,
5197 &Settings {
5198 copy_settings: copy::Settings {
5199 dereference: false,
5200 fail_early: false,
5201 overwrite: true, overwrite_compare: Default::default(),
5203 overwrite_filter: None,
5204 ignore_existing: false,
5205 chunk_size: 0,
5206 skip_specials: false,
5207 remote_copy_buffer_size: 0,
5208 filter: None,
5209 dry_run: None,
5210 delete: None,
5211 },
5212 update_compare: Default::default(),
5213 update_exclusive: false,
5214 filter: Some(filter),
5215 dry_run: None,
5216 preserve: preserve::preserve_all(),
5217 },
5218 false,
5219 )
5220 .await?;
5221 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
5223 assert_eq!(
5225 summary.copy_summary.directories_unchanged, 2,
5226 "root dst and baz directories should be unchanged"
5227 );
5228 assert_eq!(
5229 summary.copy_summary.directories_created, 0,
5230 "should not create any directories"
5231 );
5232 assert!(dst_path.join("foo").exists(), "foo should be linked");
5234 assert!(!dst_path.join("bar").exists(), "bar should not be linked");
5236 assert!(
5238 dst_path.join("baz").exists(),
5239 "existing baz directory should still exist"
5240 );
5241 assert!(
5242 dst_path.join("baz").join("marker.txt").exists(),
5243 "existing content in baz should still exist"
5244 );
5245 Ok(())
5246 }
5247
5248 #[tokio::test]
5254 #[traced_test]
5255 async fn update_only_excluded_entry_not_copied_without_delete() -> Result<(), anyhow::Error>
5256 {
5257 let test_path = testutils::create_temp_dir().await?;
5258 let src = test_path.join("src");
5262 let update = test_path.join("update");
5263 let dst = test_path.join("dst");
5264 tokio::fs::create_dir(&src).await?;
5265 tokio::fs::create_dir(&update).await?;
5266 tokio::fs::write(src.join("keep.txt"), "keep").await?;
5267 tokio::fs::write(update.join("keep.txt"), "keep").await?;
5268 tokio::fs::write(update.join("extra.txt"), "EXCLUDED").await?;
5269 tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
5270
5271 let mut filter = FilterSettings::new();
5272 filter.add_exclude("extra.txt").unwrap();
5273 let mut settings = common_settings(false, false);
5274 settings.filter = Some(filter);
5275 assert!(settings.copy_settings.delete.is_none());
5277
5278 let summary = link(
5279 &PROGRESS,
5280 &test_path,
5281 &src,
5282 &dst,
5283 &Some(update.clone()),
5284 &settings,
5285 false,
5286 )
5287 .await?;
5288
5289 assert!(
5290 !dst.join("extra.txt").exists(),
5291 "update-only entry matching --exclude must NOT be copied when --delete is off"
5292 );
5293 assert!(
5294 dst.join("wanted.txt").exists(),
5295 "non-excluded update-only entry should be copied"
5296 );
5297 assert!(dst.join("keep.txt").exists(), "shared entry should exist");
5298 assert_eq!(
5299 summary.copy_summary.files_skipped, 1,
5300 "the excluded update-only file should be counted skipped"
5301 );
5302 Ok(())
5303 }
5304
5305 fn are_hardlinked(a: &std::path::Path, b: &std::path::Path) -> bool {
5307 use std::os::unix::fs::MetadataExt;
5308 match (std::fs::symlink_metadata(a), std::fs::symlink_metadata(b)) {
5309 (Ok(ma), Ok(mb)) => ma.ino() == mb.ino() && ma.dev() == mb.dev(),
5310 _ => false,
5311 }
5312 }
5313
5314 #[tokio::test]
5327 #[traced_test]
5328 async fn type_mismatch_excluded_update_dir_not_copied_src_file_kept()
5329 -> Result<(), anyhow::Error> {
5330 let test_path = testutils::create_temp_dir().await?;
5331 let src = test_path.join("src");
5332 let update = test_path.join("update");
5333 let dst = test_path.join("dst");
5334 tokio::fs::create_dir(&src).await?;
5335 tokio::fs::create_dir(&update).await?;
5336 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
5338 tokio::fs::create_dir(update.join("cache")).await?;
5339 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
5340 tokio::fs::write(src.join("keep.txt"), "keep").await?;
5342 tokio::fs::write(update.join("keep.txt"), "keep").await?;
5343 let keep_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
5349 filetime::set_file_mtime(src.join("keep.txt"), keep_mtime)?;
5350 filetime::set_file_mtime(update.join("keep.txt"), keep_mtime)?;
5351
5352 let mut filter = FilterSettings::new();
5353 filter.add_exclude("cache/").unwrap(); let mut settings = common_settings(false, false);
5355 settings.filter = Some(filter);
5356 assert!(settings.copy_settings.delete.is_none());
5357
5358 let summary = link(
5359 &PROGRESS,
5360 &test_path,
5361 &src,
5362 &dst,
5363 &Some(update.clone()),
5364 &settings,
5365 false,
5366 )
5367 .await?;
5368
5369 assert!(
5371 !dst.join("cache").join("inner.dat").exists(),
5372 "excluded update directory `cache/` must not be copied"
5373 );
5374 assert!(
5375 !dst.join("cache").is_dir(),
5376 "dst/cache must not be the excluded update directory"
5377 );
5378 assert!(
5380 dst.join("cache").is_file(),
5381 "src `cache` file must be materialized when the update dir is excluded"
5382 );
5383 assert_eq!(
5384 tokio::fs::read_to_string(dst.join("cache")).await?,
5385 "SRC-FILE"
5386 );
5387 assert!(
5388 are_hardlinked(&src.join("cache"), &dst.join("cache")),
5389 "the src `cache` file must be hard-linked into the destination"
5390 );
5391 assert!(
5392 dst.join("keep.txt").exists(),
5393 "shared entry should still link"
5394 );
5395 assert_eq!(summary.hard_links_created, 2);
5399 assert_eq!(summary.copy_summary.files_copied, 0);
5400 assert_eq!(
5401 summary.copy_summary.directories_created, 1,
5402 "only the dst root is created; the excluded `cache/` dir must not be"
5403 );
5404 Ok(())
5405 }
5406
5407 #[tokio::test]
5417 #[traced_test]
5418 async fn reverse_type_mismatch_excluded_update_file_not_copied_src_dir_kept()
5419 -> Result<(), anyhow::Error> {
5420 let test_path = testutils::create_temp_dir().await?;
5421 let src = test_path.join("src");
5422 let update = test_path.join("update");
5423 let dst = test_path.join("dst");
5424 tokio::fs::create_dir(&src).await?;
5425 tokio::fs::create_dir(&update).await?;
5426 tokio::fs::create_dir(src.join("data")).await?;
5428 tokio::fs::write(src.join("data").join("inner.txt"), "SRC-DIR-CONTENT").await?;
5429 tokio::fs::write(update.join("data"), "UPDATE-FILE-EXCLUDED").await?;
5430
5431 let mut filter = FilterSettings::new();
5435 filter.add_include("data/").unwrap();
5436 filter.add_include("data/**").unwrap();
5437 let mut settings = common_settings(false, false);
5438 settings.filter = Some(filter);
5439 assert!(settings.copy_settings.delete.is_none());
5440
5441 link(
5442 &PROGRESS,
5443 &test_path,
5444 &src,
5445 &dst,
5446 &Some(update.clone()),
5447 &settings,
5448 false,
5449 )
5450 .await?;
5451
5452 assert!(
5454 dst.join("data").is_dir(),
5455 "src `data` directory must be materialized when the update file is excluded"
5456 );
5457 assert!(
5458 dst.join("data").join("inner.txt").exists(),
5459 "src directory contents must be linked through"
5460 );
5461 assert!(
5462 are_hardlinked(
5463 &src.join("data").join("inner.txt"),
5464 &dst.join("data").join("inner.txt")
5465 ),
5466 "src directory's file must be hard-linked into the destination"
5467 );
5468 Ok(())
5469 }
5470
5471 #[tokio::test]
5477 #[traced_test]
5478 async fn type_mismatch_excluded_update_dir_update_exclusive_materializes_nothing()
5479 -> Result<(), anyhow::Error> {
5480 let test_path = testutils::create_temp_dir().await?;
5481 let src = test_path.join("src");
5482 let update = test_path.join("update");
5483 let dst = test_path.join("dst");
5484 tokio::fs::create_dir(&src).await?;
5485 tokio::fs::create_dir(&update).await?;
5486 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
5487 tokio::fs::create_dir(update.join("cache")).await?;
5488 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
5489 tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
5491
5492 let mut filter = FilterSettings::new();
5493 filter.add_exclude("cache/").unwrap();
5494 let mut settings = common_settings(false, false);
5495 settings.update_exclusive = true;
5496 settings.filter = Some(filter);
5497
5498 link(
5499 &PROGRESS,
5500 &test_path,
5501 &src,
5502 &dst,
5503 &Some(update.clone()),
5504 &settings,
5505 false,
5506 )
5507 .await?;
5508
5509 assert!(
5510 !dst.join("cache").exists(),
5511 "under --update-exclusive an excluded-update type-mismatch must materialize nothing \
5512 (no excluded dir, no stale src file)"
5513 );
5514 assert!(
5515 dst.join("wanted.txt").exists(),
5516 "filter-passing update-only entries are still copied under --update-exclusive"
5517 );
5518 Ok(())
5519 }
5520
5521 #[tokio::test]
5528 #[traced_test]
5529 async fn type_mismatch_excluded_update_dir_delete_keeps_src_file()
5530 -> Result<(), anyhow::Error> {
5531 let test_path = testutils::create_temp_dir().await?;
5532 let src = test_path.join("src");
5533 let update = test_path.join("update");
5534 let dst = test_path.join("dst");
5535 tokio::fs::create_dir(&src).await?;
5536 tokio::fs::create_dir(&update).await?;
5537 tokio::fs::create_dir(&dst).await?;
5538 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
5539 tokio::fs::create_dir(update.join("cache")).await?;
5540 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
5541 tokio::fs::write(dst.join("stale.txt"), "stale").await?;
5543
5544 let mut filter = FilterSettings::new();
5545 filter.add_exclude("cache/").unwrap();
5546 let mut settings = common_settings(false, true); settings.filter = Some(filter);
5548 settings.copy_settings.delete = Some(copy::DeleteSettings {
5549 delete_excluded: false,
5550 });
5551
5552 link(
5553 &PROGRESS,
5554 &test_path,
5555 &src,
5556 &dst,
5557 &Some(update.clone()),
5558 &settings,
5559 false,
5560 )
5561 .await?;
5562
5563 assert!(
5564 dst.join("cache").is_file(),
5565 "src `cache` file must survive --delete (kept in the keep-set, not pruned)"
5566 );
5567 assert_eq!(
5568 tokio::fs::read_to_string(dst.join("cache")).await?,
5569 "SRC-FILE"
5570 );
5571 assert!(
5572 !dst.join("cache").is_dir(),
5573 "the excluded update directory must leave no leftover"
5574 );
5575 assert!(
5576 !dst.join("stale.txt").exists(),
5577 "extraneous dst entry must be pruned by --delete"
5578 );
5579 Ok(())
5580 }
5581 }
5582 mod dry_run_tests {
5583 use super::*;
5584
5585 #[tokio::test]
5586 async fn update_delete_preview_keeps_direct_and_delegated_pruning_beneath_outer_dst()
5587 -> Result<(), anyhow::Error> {
5588 let root = testutils::create_temp_dir().await?;
5589 let src = root.join("src");
5590 let update = root.join("update");
5591 let outside = root.join("outside");
5592 let dst = root.join("dst");
5593 tokio::fs::create_dir_all(src.join("direct")).await?;
5594 tokio::fs::create_dir_all(update.join("delegated")).await?;
5595 tokio::fs::create_dir_all(outside.join("direct")).await?;
5596 tokio::fs::create_dir_all(outside.join("delegated")).await?;
5597 tokio::fs::write(outside.join("direct/stale"), "DIRECT-OUTSIDE").await?;
5598 tokio::fs::write(outside.join("delegated/stale"), "DELEGATED-OUTSIDE").await?;
5599 tokio::fs::symlink(&outside, &dst).await?;
5600 let mut settings = common_settings(false, true);
5601 settings.dry_run = Some(crate::config::DryRunMode::Brief);
5602 settings.copy_settings.dry_run = settings.dry_run;
5603 settings.copy_settings.delete = Some(copy::DeleteSettings {
5604 delete_excluded: false,
5605 });
5606 let summary = link(
5607 &PROGRESS,
5608 &root,
5609 &src,
5610 &dst,
5611 &Some(update.clone()),
5612 &settings,
5613 false,
5614 )
5615 .await?;
5616 assert_eq!(summary.copy_summary.rm_summary.files_removed, 0);
5617 assert_eq!(
5618 tokio::fs::read_to_string(outside.join("direct/stale")).await?,
5619 "DIRECT-OUTSIDE"
5620 );
5621 assert_eq!(
5622 tokio::fs::read_to_string(outside.join("delegated/stale")).await?,
5623 "DELEGATED-OUTSIDE"
5624 );
5625
5626 let real_dst = root.join("real-dst");
5627 tokio::fs::create_dir_all(real_dst.join("direct")).await?;
5628 tokio::fs::create_dir_all(real_dst.join("delegated")).await?;
5629 tokio::fs::write(real_dst.join("delegated/stale"), "INSIDE-STALE").await?;
5630 let real_summary = link(
5631 &PROGRESS,
5632 &root,
5633 &src,
5634 &real_dst,
5635 &Some(update),
5636 &settings,
5637 false,
5638 )
5639 .await?;
5640 assert_eq!(real_summary.copy_summary.rm_summary.files_removed, 1);
5641 assert_eq!(
5642 tokio::fs::read_to_string(real_dst.join("delegated/stale")).await?,
5643 "INSIDE-STALE"
5644 );
5645 Ok(())
5646 }
5647
5648 #[tokio::test]
5649 async fn update_exclusive_delete_preview_keeps_selected_shared_file()
5650 -> Result<(), anyhow::Error> {
5651 let root = testutils::create_temp_dir().await?;
5652 let src = root.join("src");
5653 let update = root.join("update");
5654 let dst = root.join("dst");
5655 tokio::fs::create_dir(&src).await?;
5656 tokio::fs::create_dir(&update).await?;
5657 tokio::fs::create_dir(&dst).await?;
5658 tokio::fs::write(src.join("shared"), b"old").await?;
5659 tokio::fs::write(update.join("shared"), b"replacement").await?;
5660 tokio::fs::write(dst.join("shared"), b"destination").await?;
5661 tokio::fs::write(dst.join("stale"), b"stale").await?;
5662
5663 let mut settings = common_settings(false, false);
5664 settings.update_exclusive = true;
5665 settings.dry_run = Some(crate::config::DryRunMode::Brief);
5666 settings.copy_settings.dry_run = settings.dry_run;
5667 settings.copy_settings.delete = Some(copy::DeleteSettings {
5668 delete_excluded: false,
5669 });
5670
5671 let summary = link(
5672 &PROGRESS,
5673 &root,
5674 &src,
5675 &dst,
5676 &Some(update),
5677 &settings,
5678 false,
5679 )
5680 .await?;
5681
5682 assert_eq!(summary.hard_links_created, 0);
5683 assert_eq!(summary.copy_summary.files_copied, 1);
5684 assert_eq!(summary.copy_summary.rm_summary.files_removed, 1);
5685 assert!(dst.join("shared").exists());
5686 assert!(dst.join("stale").exists());
5687 Ok(())
5688 }
5689
5690 #[tokio::test]
5691 async fn update_exclusive_delete_preview_omits_source_only_file()
5692 -> Result<(), anyhow::Error> {
5693 let root = testutils::create_temp_dir().await?;
5694 let src = root.join("src");
5695 let update = root.join("update");
5696 let dst = root.join("dst");
5697 tokio::fs::create_dir(&src).await?;
5698 tokio::fs::create_dir(&update).await?;
5699 tokio::fs::create_dir(&dst).await?;
5700 tokio::fs::write(src.join("source-only"), b"source").await?;
5701 tokio::fs::write(dst.join("source-only"), b"destination").await?;
5702
5703 let mut settings = common_settings(false, false);
5704 settings.update_exclusive = true;
5705 settings.dry_run = Some(crate::config::DryRunMode::Brief);
5706 settings.copy_settings.dry_run = settings.dry_run;
5707 settings.copy_settings.delete = Some(copy::DeleteSettings {
5708 delete_excluded: false,
5709 });
5710
5711 let summary = link(
5712 &PROGRESS,
5713 &root,
5714 &src,
5715 &dst,
5716 &Some(update),
5717 &settings,
5718 false,
5719 )
5720 .await?;
5721
5722 assert_eq!(summary.hard_links_created, 0);
5723 assert_eq!(summary.copy_summary.files_copied, 0);
5724 assert_eq!(summary.copy_summary.rm_summary.files_removed, 1);
5725 assert!(dst.join("source-only").exists());
5726 Ok(())
5727 }
5728
5729 #[tokio::test]
5731 #[traced_test]
5732 async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
5733 let tmp_dir = testutils::setup_test_dir().await?;
5734 let test_path = tmp_dir.as_path();
5735 let src_file = test_path.join("foo/0.txt");
5736 let dst_file = test_path.join("dst_link.txt");
5737 assert!(
5739 !dst_file.exists(),
5740 "destination should not exist before dry-run"
5741 );
5742 let summary = link(
5743 &PROGRESS,
5744 test_path,
5745 &src_file,
5746 &dst_file,
5747 &None,
5748 &Settings {
5749 copy_settings: CopySettings {
5750 dereference: false,
5751 fail_early: false,
5752 overwrite: false,
5753 overwrite_compare: Default::default(),
5754 overwrite_filter: None,
5755 ignore_existing: false,
5756 chunk_size: 0,
5757 skip_specials: false,
5758 remote_copy_buffer_size: 0,
5759 filter: None,
5760 dry_run: None,
5761 delete: None,
5762 },
5763 update_compare: Default::default(),
5764 update_exclusive: false,
5765 filter: None,
5766 dry_run: Some(crate::config::DryRunMode::Brief),
5767 preserve: preserve::preserve_all(),
5768 },
5769 false,
5770 )
5771 .await?;
5772 assert!(!dst_file.exists(), "dry-run should not create hard link");
5774 assert_eq!(
5776 summary.hard_links_created, 1,
5777 "dry-run should report 1 hard link that would be created"
5778 );
5779 Ok(())
5780 }
5781 #[tokio::test]
5783 #[traced_test]
5784 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
5785 let tmp_dir = testutils::setup_test_dir().await?;
5786 let test_path = tmp_dir.as_path();
5787 let dst_path = test_path.join("nonexistent_dst");
5788 assert!(
5790 !dst_path.exists(),
5791 "destination should not exist before dry-run"
5792 );
5793 let summary = link(
5794 &PROGRESS,
5795 test_path,
5796 &test_path.join("foo"),
5797 &dst_path,
5798 &None,
5799 &Settings {
5800 copy_settings: CopySettings {
5801 dereference: false,
5802 fail_early: false,
5803 overwrite: false,
5804 overwrite_compare: Default::default(),
5805 overwrite_filter: None,
5806 ignore_existing: false,
5807 chunk_size: 0,
5808 skip_specials: false,
5809 remote_copy_buffer_size: 0,
5810 filter: None,
5811 dry_run: None,
5812 delete: None,
5813 },
5814 update_compare: Default::default(),
5815 update_exclusive: false,
5816 filter: None,
5817 dry_run: Some(crate::config::DryRunMode::Brief),
5818 preserve: preserve::preserve_all(),
5819 },
5820 false,
5821 )
5822 .await?;
5823 assert!(
5825 !dst_path.exists(),
5826 "dry-run should not create destination directory"
5827 );
5828 assert!(
5830 summary.hard_links_created > 0,
5831 "dry-run should report hard links that would be created"
5832 );
5833 Ok(())
5834 }
5835 #[tokio::test]
5837 #[traced_test]
5838 async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
5839 let tmp_dir = testutils::setup_test_dir().await?;
5840 let test_path = tmp_dir.as_path();
5841 let src_path = test_path.join("foo/baz");
5843 let dst_path = test_path.join("dst_baz");
5844 assert!(
5846 !dst_path.exists(),
5847 "destination should not exist before dry-run"
5848 );
5849 let summary = link(
5850 &PROGRESS,
5851 test_path,
5852 &src_path,
5853 &dst_path,
5854 &None,
5855 &Settings {
5856 copy_settings: CopySettings {
5857 dereference: false,
5858 fail_early: false,
5859 overwrite: false,
5860 overwrite_compare: Default::default(),
5861 overwrite_filter: None,
5862 ignore_existing: false,
5863 chunk_size: 0,
5864 skip_specials: false,
5865 remote_copy_buffer_size: 0,
5866 filter: None,
5867 dry_run: None,
5868 delete: None,
5869 },
5870 update_compare: Default::default(),
5871 update_exclusive: false,
5872 filter: None,
5873 dry_run: Some(crate::config::DryRunMode::Brief),
5874 preserve: preserve::preserve_all(),
5875 },
5876 false,
5877 )
5878 .await?;
5879 assert!(!dst_path.exists(), "dry-run should not create destination");
5881 assert_eq!(
5883 summary.hard_links_created, 1,
5884 "dry-run should report 1 hard link (for 4.txt)"
5885 );
5886 assert_eq!(
5887 summary.copy_summary.symlinks_created, 2,
5888 "dry-run should report 2 symlinks (5.txt and 6.txt)"
5889 );
5890 Ok(())
5891 }
5892 }
5893
5894 #[tokio::test]
5901 #[traced_test]
5902 async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
5903 let tmp_dir = testutils::create_temp_dir().await?;
5904 let test_path = tmp_dir.as_path();
5905 let src_dir = test_path.join("src");
5910 let sub_dir = src_dir.join("sub");
5911 let bad_dir = sub_dir.join("unreadable_dir");
5912 tokio::fs::create_dir_all(&bad_dir).await?;
5913 tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
5914 tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
5915 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
5916 let dst_dir = test_path.join("dst");
5917 let result = link(
5918 &PROGRESS,
5919 test_path,
5920 &src_dir,
5921 &dst_dir,
5922 &None,
5923 &Settings {
5924 copy_settings: CopySettings {
5925 fail_early: true,
5926 ..common_settings(false, false).copy_settings
5927 },
5928 ..common_settings(false, false)
5929 },
5930 false,
5931 )
5932 .await;
5933 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
5935 let error = result.expect_err("link should fail due to unreadable directory");
5936 assert!(
5941 error.summary.copy_summary.directories_created >= 2,
5942 "fail-early summary should include directories from the failing subtree, \
5943 got directories_created={} (expected >= 2: dst/ and dst/sub/)",
5944 error.summary.copy_summary.directories_created
5945 );
5946 Ok(())
5947 }
5948
5949 #[tokio::test]
5950 #[traced_test]
5951 async fn skip_specials_skips_socket_in_link() -> Result<(), anyhow::Error> {
5952 let tmp_dir = testutils::setup_test_dir().await?;
5953 let test_path = tmp_dir.as_path();
5954 let src = test_path.join("src_dir");
5955 let dst = test_path.join("dst_dir");
5956 tokio::fs::create_dir(&src).await?;
5957 tokio::fs::write(src.join("file.txt"), "hello").await?;
5958 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
5959 let mut settings = common_settings(false, false);
5960 settings.copy_settings.skip_specials = true;
5961 let summary = link(&PROGRESS, test_path, &src, &dst, &None, &settings, false).await?;
5962 assert_eq!(summary.hard_links_created, 1);
5963 assert_eq!(summary.copy_summary.specials_skipped, 1);
5964 assert!(dst.join("file.txt").exists());
5965 assert!(!dst.join("test.sock").exists());
5966 Ok(())
5967 }
5968
5969 #[tokio::test]
5970 #[traced_test]
5971 async fn delete_skips_pruning_when_link_has_errors() -> Result<(), anyhow::Error> {
5972 let tmp_dir = testutils::setup_test_dir().await?;
5973 let test_path = tmp_dir.as_path();
5974 let src = test_path.join("foo");
5975 let dst = test_path.join("bar");
5976 link(
5978 &PROGRESS,
5979 test_path,
5980 &src,
5981 &dst,
5982 &None,
5983 &common_settings(false, false),
5984 false,
5985 )
5986 .await?;
5987 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
5989 let unreadable = src.join("baz");
5993 let original = tokio::fs::metadata(&unreadable).await?.permissions();
5994 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
5995
5996 let delete_settings = Settings {
5997 copy_settings: CopySettings {
5998 overwrite: true,
5999 fail_early: false,
6000 delete: Some(copy::DeleteSettings {
6001 delete_excluded: false,
6002 }),
6003 ..common_settings(false, true).copy_settings
6004 },
6005 ..common_settings(false, true)
6006 };
6007 let result = link(
6008 &PROGRESS,
6009 test_path,
6010 &src,
6011 &dst,
6012 &None,
6013 &delete_settings,
6014 false,
6015 )
6016 .await;
6017
6018 tokio::fs::set_permissions(&unreadable, original).await?;
6019
6020 assert!(
6021 result.is_err(),
6022 "link of the unreadable directory should fail"
6023 );
6024 assert!(
6025 dst.join("extraneous.txt").exists(),
6026 "pruning must be skipped when the link/update pass reported errors"
6027 );
6028 Ok(())
6029 }
6030
6031 #[tokio::test]
6032 #[traced_test]
6033 async fn skip_specials_top_level_socket_in_link() -> Result<(), anyhow::Error> {
6034 let tmp_dir = testutils::setup_test_dir().await?;
6035 let test_path = tmp_dir.as_path();
6036 let src_socket = test_path.join("test.sock");
6037 let dst = test_path.join("dst.sock");
6038 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
6039 let mut settings = common_settings(false, false);
6040 settings.copy_settings.skip_specials = true;
6041 let summary = link(
6042 &PROGRESS,
6043 test_path,
6044 &src_socket,
6045 &dst,
6046 &None,
6047 &settings,
6048 false,
6049 )
6050 .await?;
6051 assert_eq!(summary.copy_summary.specials_skipped, 1);
6052 assert_eq!(summary.hard_links_created, 0);
6053 assert!(!dst.exists());
6054 Ok(())
6055 }
6056
6057 mod max_files_in_flight_tests {
6059 use super::*;
6060
6061 fn available_open_file_capacity(capacity: usize) -> usize {
6062 let waker = futures::task::noop_waker();
6063 let mut context = std::task::Context::from_waker(&waker);
6064 let mut permits = Vec::new();
6065 for _ in 0..capacity {
6066 let mut permit = Box::pin(throttle::open_file_permit());
6067 match std::future::Future::poll(permit.as_mut(), &mut context) {
6068 std::task::Poll::Ready(permit) => permits.push(permit),
6069 std::task::Poll::Pending => break,
6070 }
6071 }
6072 permits.len()
6073 }
6074
6075 async fn reacquire_all_open_file_capacity(
6076 admission: &testutils::AdmissionLimit,
6077 capacity: usize,
6078 ) -> Result<(), anyhow::Error> {
6079 let permits = admission
6080 .run_with_timeout(std::time::Duration::from_secs(20), async {
6081 let mut permits = Vec::new();
6082 for _ in 0..capacity {
6083 permits.push(throttle::open_file_permit().await);
6084 }
6085 permits
6086 })
6087 .await
6088 .context("link operation did not return all open-file capacity")?;
6089 drop(permits);
6090 Ok(())
6091 }
6092
6093 #[tokio::test]
6094 async fn update_directory_transition_retains_exact_handle_and_held_admission()
6095 -> anyhow::Result<()> {
6096 use std::os::fd::AsRawFd as _;
6097
6098 let root = testutils::create_temp_dir().await?;
6099 tokio::fs::write(root.join("source"), b"source").await?;
6100 tokio::fs::create_dir(root.join("update")).await?;
6101 let admission = testutils::AdmissionLimit::new().await;
6102 admission.set_files_in_flight(1);
6103 let parent = Dir::open_root_dir(&root, false, congestion::Side::Source).await?;
6104 let src_handle = parent.child(std::ffi::OsStr::new("source")).await?;
6105 let update_handle = parent.child(std::ffi::OsStr::new("update")).await?;
6106 let src_fd = src_handle.as_fd().as_raw_fd();
6107 let update_fd = update_handle.as_fd().as_raw_fd();
6108 let src_probe = testutils::FdIdentityProbe::capture(src_fd);
6109 let update_probe = testutils::FdIdentityProbe::capture(update_fd);
6110 let (src_probe, update_probe) = match (src_probe, update_probe) {
6111 (Ok(src_probe), Ok(update_probe)) => (src_probe, update_probe),
6112 (src_probe, update_probe) => {
6113 let probe_error = match src_probe {
6114 Err(error) => error,
6115 Ok(_) => match update_probe {
6116 Err(error) => error,
6117 Ok(_) => {
6118 unreachable!("at least one fd identity probe must have failed")
6119 }
6120 },
6121 };
6122 drop(src_handle);
6123 drop(update_handle);
6124 drop(parent);
6125 drop(admission);
6126 let cleanup_result = tokio::fs::remove_dir_all(root).await;
6127 return match cleanup_result {
6128 Ok(()) => Err(probe_error.into()),
6129 Err(cleanup_error) => Err(anyhow::Error::new(probe_error).context(
6130 format!("link fd-lifetime fixture did not clean up: {cleanup_error:#}"),
6131 )),
6132 };
6133 }
6134 };
6135 let mut entry = AdmittedLinkEntry::new(
6136 src_handle,
6137 Some(LeafPermit::OpenFile(throttle::open_file_permit().await)),
6138 );
6139 entry.update_handle = Some(update_handle);
6140
6141 let update_entry = entry.into_update_entry();
6142 let retained_directory = update_entry.kind() == EntryKind::Dir;
6143 let transferred_held_admission = update_entry.admission().is_some();
6144 let source_closed = src_probe.original_is_closed();
6146 let update_stayed_open = update_probe.original_is_closed().map(|closed| !closed);
6147 let mut next_permit = Box::pin(throttle::open_file_permit());
6148 let (capacity_stayed_held, early_permit) = match futures::poll!(next_permit.as_mut()) {
6149 std::task::Poll::Pending => (true, None),
6150 std::task::Poll::Ready(permit) => (false, Some(permit)),
6151 };
6152 drop(update_entry);
6153 let update_closed = update_probe.original_is_closed();
6154 let reacquire_result = match early_permit {
6155 Some(permit) => Ok(permit),
6156 None => {
6157 tokio::time::timeout(std::time::Duration::from_secs(1), next_permit.as_mut())
6158 .await
6159 .context("the delegated directory did not return open-file capacity")
6160 }
6161 };
6162 let reacquire_result = reacquire_result.map(drop);
6163 drop(parent);
6164 drop(admission);
6165 let cleanup_result = tokio::fs::remove_dir_all(root).await;
6166 let source_closed = source_closed?;
6167 let update_stayed_open = update_stayed_open?;
6168 let update_closed = update_closed?;
6169 reacquire_result?;
6170 cleanup_result?;
6171 assert!(
6172 retained_directory,
6173 "the admitted update owner lost its authoritative directory type"
6174 );
6175 assert!(transferred_held_admission);
6176 assert!(source_closed);
6177 assert!(update_stayed_open);
6178 assert!(update_closed);
6179 assert!(
6180 capacity_stayed_held,
6181 "the admitted update owner released capacity before copy dispatch"
6182 );
6183 Ok(())
6184 }
6185
6186 #[tokio::test]
6188 async fn filtered_hinted_file_does_not_wait_for_admission() -> Result<(), anyhow::Error> {
6189 let root = testutils::create_temp_dir().await?;
6190 let src = root.join("src");
6191 tokio::fs::create_dir(&src).await?;
6192 tokio::fs::write(src.join("leaf"), b"x").await?;
6193 let admission = testutils::AdmissionLimit::new().await;
6194 admission.set_files_in_flight(1);
6195 let held = throttle::open_file_permit().await;
6196 let src_dir =
6197 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6198 let hints = src_dir.read_entries().await?;
6199 assert_eq!(
6200 hints,
6201 vec![(std::ffi::OsString::from("leaf"), Some(EntryKind::File))],
6202 "the fixture filesystem must provide the reliable hint this test exercises"
6203 );
6204 let mut filter = crate::filter::FilterSettings::new();
6205 filter.add_exclude("leaf")?;
6206 let mut settings = common_settings(false, false);
6207 settings.filter = Some(filter.clone());
6208 settings.copy_settings.filter = Some(filter);
6209 let dst = root.join("dst");
6210 let operation = link_dir_contents(
6211 &PROGRESS,
6212 &src_dir,
6213 None,
6214 None,
6215 None,
6216 std::ffi::OsStr::new("dst"),
6217 &src,
6218 &dst,
6219 None,
6220 std::path::Path::new(""),
6221 &src,
6222 &dst,
6223 false,
6224 false,
6225 None,
6226 &settings,
6227 Summary::default(),
6228 SourceEntries::Live,
6229 UpdateEntries::Live,
6230 );
6231 let result = admission
6232 .run_with_timeout(std::time::Duration::from_secs(1), operation)
6233 .await;
6234 drop(held);
6235 let summary = result.context("rlink filter skip waited for open-file admission")??;
6236 assert_eq!(summary.copy_summary.files_skipped, 1);
6237 assert_eq!(summary.hard_links_created, 0);
6238 Ok(())
6239 }
6240
6241 #[tokio::test]
6243 async fn filtered_hinted_update_file_does_not_wait_for_admission()
6244 -> Result<(), anyhow::Error> {
6245 let root = testutils::create_temp_dir().await?;
6246 let src = root.join("src");
6247 let update = root.join("update");
6248 tokio::fs::create_dir(&src).await?;
6249 tokio::fs::create_dir(&update).await?;
6250 tokio::fs::write(update.join("leaf"), b"x").await?;
6251 let admission = testutils::AdmissionLimit::new().await;
6252 admission.set_files_in_flight(1);
6253 let held = throttle::open_file_permit().await;
6254 let src_dir =
6255 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6256 let update_dir =
6257 Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
6258 let hints = update_dir.read_entries().await?;
6259 assert_eq!(
6260 hints,
6261 vec![(std::ffi::OsString::from("leaf"), Some(EntryKind::File))],
6262 "the fixture filesystem must provide the reliable hint this test exercises"
6263 );
6264 let mut filter = crate::filter::FilterSettings::new();
6265 filter.add_exclude("leaf")?;
6266 let mut settings = common_settings(false, false);
6267 settings.filter = Some(filter.clone());
6268 settings.copy_settings.filter = Some(filter);
6269 let dst = root.join("dst");
6270 let operation = crate::walk_driver::scope_tasks(link_dir_contents(
6271 &PROGRESS,
6272 &src_dir,
6273 Some(&update_dir),
6274 None,
6275 None,
6276 std::ffi::OsStr::new("dst"),
6277 &src,
6278 &dst,
6279 Some(&update),
6280 std::path::Path::new(""),
6281 &src,
6282 &dst,
6283 false,
6284 false,
6285 None,
6286 &settings,
6287 Summary::default(),
6288 SourceEntries::Live,
6289 UpdateEntries::Live,
6290 ));
6291 let result = admission
6292 .run_with_timeout(std::time::Duration::from_secs(1), operation)
6293 .await;
6294 drop(held);
6295 let summary =
6296 result.context("rlink update filter skip waited for open-file admission")??;
6297 assert_eq!(summary.copy_summary.files_skipped, 1);
6298 assert_eq!(summary.copy_summary.files_copied, 0);
6299 Ok(())
6300 }
6301
6302 #[test]
6303 fn source_directory_hint_requires_admission_only_for_a_dual_tree_decision() {
6304 assert!(matches!(
6305 source_entry_admission(Some(EntryKind::Dir), true),
6306 EntryAdmission::RootOrDelegated
6307 ));
6308 assert!(matches!(
6309 source_entry_admission(Some(EntryKind::Dir), false),
6310 EntryAdmission::HintedDirectory
6311 ));
6312 }
6313
6314 #[tokio::test]
6316 async fn hinted_directory_classifies_while_pool_is_saturated() -> Result<(), anyhow::Error>
6317 {
6318 let root = testutils::create_temp_dir().await?;
6319 let src_path = root.join("src");
6320 let dst_path = root.join("dst");
6321 tokio::fs::create_dir(&src_path).await?;
6322 let admission = testutils::AdmissionLimit::new().await;
6323 admission.set_files_in_flight(1);
6324 let held = throttle::open_file_permit().await;
6325 let src_parent = Arc::new(
6326 Dir::open_parent_dir(&root, congestion::Side::Source)
6327 .await?
6328 .into_tree(),
6329 );
6330 let mut settings = common_settings(false, false);
6331 settings.dry_run = Some(crate::config::DryRunMode::Brief);
6332 settings.copy_settings.dry_run = settings.dry_run;
6333 let result = admission
6334 .run_with_timeout(
6335 std::time::Duration::from_secs(1),
6336 link_internal(
6337 &PROGRESS,
6338 &src_parent,
6339 None,
6340 None,
6341 std::ffi::OsStr::new("src"),
6342 &src_path,
6343 &dst_path,
6344 None,
6345 std::path::Path::new(""),
6346 UpdateRootRequirement::Optional,
6347 &settings,
6348 false,
6349 walk::EntryAdmission::HintedDirectory.into(),
6350 ),
6351 )
6352 .await;
6353 drop(held);
6354 let summary = result.context("hinted rlink directory waited for admission")??;
6355 assert_eq!(summary.summary.copy_summary.directories_created, 1);
6356 Ok(())
6357 }
6358
6359 #[tokio::test(flavor = "current_thread")]
6362 async fn hinted_source_with_update_admits_before_source_classification()
6363 -> Result<(), anyhow::Error> {
6364 let root = testutils::create_temp_dir().await?;
6365 let src_root = root.join("src");
6366 let update_root = root.join("update");
6367 let dst_root = root.join("dst");
6368 tokio::fs::create_dir(&src_root).await?;
6369 tokio::fs::create_dir(&update_root).await?;
6370 tokio::fs::create_dir(src_root.join("entry")).await?;
6371 tokio::fs::write(update_root.join("entry"), b"x").await?;
6372
6373 let admission = testutils::AdmissionLimit::new().await;
6374 admission.set_files_in_flight(1);
6375 let stat_resource =
6376 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6377 admission.set_max_ops_in_flight(stat_resource, 1);
6378 let src_parent =
6379 Arc::new(Dir::open_root_dir(&src_root, false, congestion::Side::Source).await?);
6380 let update_parent =
6381 Arc::new(Dir::open_root_dir(&update_root, false, congestion::Side::Source).await?);
6382 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6383 let mut settings = common_settings(false, false);
6384 settings.dry_run = Some(crate::config::DryRunMode::Brief);
6385 settings.copy_settings.dry_run = settings.dry_run;
6386
6387 let operation = link_internal(
6388 &PROGRESS,
6389 &src_parent,
6390 Some((&update_parent, std::ffi::OsStr::new("entry"))),
6391 None,
6392 std::ffi::OsStr::new("entry"),
6393 &src_root,
6394 &dst_root,
6395 Some(&update_root),
6396 std::path::Path::new("entry"),
6397 UpdateRootRequirement::Optional,
6398 &settings,
6399 false,
6400 EntryAdmission::HintedDirectory.into(),
6401 );
6402 tokio::pin!(operation);
6403 assert!(
6404 futures::poll!(operation.as_mut()).is_pending(),
6405 "source classification must stop at the saturated Stat gate"
6406 );
6407 let mut permit_probe = Box::pin(throttle::open_file_permit());
6408 let admitted_before_source = futures::poll!(permit_probe.as_mut()).is_pending();
6409 drop(permit_probe);
6410
6411 drop(held_stat);
6412 let summary = admission
6413 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6414 .await
6415 .context("rlink did not resume after source classification was released")??;
6416 assert!(
6417 admitted_before_source,
6418 "the hinted source opened before admission for its separate update decision"
6419 );
6420 assert_eq!(summary.summary.copy_summary.files_copied, 1);
6421 Ok(())
6422 }
6423
6424 #[tokio::test]
6426 async fn hinted_update_directory_classifies_while_pool_is_saturated()
6427 -> Result<(), anyhow::Error> {
6428 let root = testutils::create_temp_dir().await?;
6429 let src = root.join("src");
6430 let update = root.join("update");
6431 let dst = root.join("dst");
6432 tokio::fs::create_dir(&src).await?;
6433 tokio::fs::create_dir(&update).await?;
6434 tokio::fs::create_dir(update.join("dir")).await?;
6435 let admission = testutils::AdmissionLimit::new().await;
6436 admission.set_files_in_flight(1);
6437 let held = throttle::open_file_permit().await;
6438 let src_dir =
6439 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6440 let update_dir =
6441 Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
6442 let mut settings = common_settings(false, false);
6443 settings.dry_run = Some(crate::config::DryRunMode::Brief);
6444 settings.copy_settings.dry_run = settings.dry_run;
6445 let operation = crate::walk_driver::scope_tasks(link_dir_contents(
6446 &PROGRESS,
6447 &src_dir,
6448 Some(&update_dir),
6449 None,
6450 None,
6451 std::ffi::OsStr::new("dst"),
6452 &src,
6453 &dst,
6454 Some(&update),
6455 std::path::Path::new(""),
6456 &src,
6457 &dst,
6458 false,
6459 false,
6460 None,
6461 &settings,
6462 Summary::default(),
6463 SourceEntries::Live,
6464 UpdateEntries::Live,
6465 ));
6466 let result = admission
6467 .run_with_timeout(std::time::Duration::from_secs(1), operation)
6468 .await;
6469 drop(held);
6470 let summary =
6471 result.context("hinted update directory waited for delegated copy admission")??;
6472 assert_eq!(summary.copy_summary.directories_created, 1);
6473 Ok(())
6474 }
6475
6476 #[tokio::test]
6480 async fn filtered_public_root_waits_for_admission_before_setup() -> Result<(), anyhow::Error>
6481 {
6482 let root = testutils::create_temp_dir().await?;
6483 let src = root.join("src");
6484 let dst = root.join("dst");
6485 tokio::fs::write(&src, b"x").await?;
6486 let mut filter = crate::filter::FilterSettings::new();
6487 filter.add_exclude("src")?;
6488 let mut settings = common_settings(false, false);
6489 settings.filter = Some(filter.clone());
6490 settings.copy_settings.filter = Some(filter);
6491 let admission = testutils::AdmissionLimit::new().await;
6492 admission.set_files_in_flight(1);
6493 let stat_resource =
6494 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6495 admission.set_max_ops_in_flight(stat_resource, 1);
6496 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6497 let operation = link(&PROGRESS, &root, &src, &dst, &None, &settings, false);
6498 tokio::pin!(operation);
6499 let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
6500 let mut second_permit = Box::pin(throttle::open_file_permit());
6501 let setup_bypassed_admission = futures::poll!(second_permit.as_mut()).is_ready();
6502 drop(second_permit);
6503 drop(held_stat);
6504 let result = admission
6505 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6506 .await;
6507 assert!(
6508 stopped_at_stat_gate,
6509 "rlink root did not reach the held stat gate"
6510 );
6511 let summary =
6512 result.context("rlink root did not resume after stat capacity was released")??;
6513 assert!(
6514 !setup_bypassed_admission,
6515 "rlink performed root setup before open-file admission"
6516 );
6517 assert_eq!(summary.copy_summary.files_skipped, 1);
6518 Ok(())
6519 }
6520
6521 #[tokio::test]
6524 async fn root_acquires_capacity_before_classification() -> Result<(), anyhow::Error> {
6525 let root = testutils::create_temp_dir().await?;
6526 let src_path = root.join("src");
6527 let dst_path = root.join("dst");
6528 tokio::fs::write(&src_path, b"x").await?;
6529 let admission = testutils::AdmissionLimit::new().await;
6530 let src_parent = Arc::new(
6531 Dir::open_parent_dir(&root, congestion::Side::Source)
6532 .await?
6533 .into_tree(),
6534 );
6535 let mut settings = common_settings(false, false);
6536 settings.dry_run = Some(crate::config::DryRunMode::Brief);
6537 settings.copy_settings.dry_run = settings.dry_run;
6538 admission.set_files_in_flight(1);
6539 let stat_resource =
6540 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6541 admission.set_max_ops_in_flight(stat_resource, 1);
6542 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6543 let operation = link_internal(
6544 &PROGRESS,
6545 &src_parent,
6546 None,
6547 None,
6548 std::ffi::OsStr::new("src"),
6549 &src_path,
6550 &dst_path,
6551 None,
6552 std::path::Path::new(""),
6553 UpdateRootRequirement::Optional,
6554 &settings,
6555 false,
6556 EntryAdmission::RootOrDelegated.into(),
6557 );
6558 tokio::pin!(operation);
6559 let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
6560 let mut second_permit = Box::pin(throttle::open_file_permit());
6561 let classification_bypassed_admission =
6562 futures::poll!(second_permit.as_mut()).is_ready();
6563 drop(second_permit);
6564 drop(held_stat);
6565 let result = admission
6566 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6567 .await;
6568 assert!(
6569 stopped_at_stat_gate,
6570 "rlink root did not reach the held stat gate"
6571 );
6572 assert!(
6573 !classification_bypassed_admission,
6574 "rlink reached fd-based classification before open-file admission"
6575 );
6576 let summary = result.context(
6577 "rlink root did not resume after classification capacity was released",
6578 )??;
6579 assert_eq!(summary.summary.hard_links_created, 1);
6580 Ok(())
6581 }
6582
6583 #[tokio::test]
6586 async fn hard_link_holds_open_file_capacity_until_link_finishes()
6587 -> Result<(), anyhow::Error> {
6588 let root = testutils::create_temp_dir().await?;
6589 let src_root = root.join("src");
6590 let dst_root = root.join("dst");
6591 tokio::fs::create_dir(&src_root).await?;
6592 tokio::fs::create_dir(&dst_root).await?;
6593 tokio::fs::write(src_root.join("entry"), b"x").await?;
6594 let src_parent =
6595 Arc::new(Dir::open_root_dir(&src_root, false, congestion::Side::Source).await?);
6596 let dst_parent = Arc::new(
6597 Dir::open_root_dir(&dst_root, false, congestion::Side::Destination).await?,
6598 );
6599 let admission = testutils::AdmissionLimit::new().await;
6600 admission.set_files_in_flight(1);
6601 let permit = Some(LeafPermit::OpenFile(throttle::open_file_permit().await));
6602 let hard_link_resource = throttle::Resource::meta(
6603 throttle::Side::Destination,
6604 throttle::MetadataOp::HardLink,
6605 );
6606 admission.set_max_ops_in_flight(hard_link_resource, 1);
6607 let held_hard_link = throttle::ops_in_flight_permit(hard_link_resource).await;
6608 let dst_entry = dst_root.join("entry");
6609 let task_dst_entry = dst_entry.clone();
6610 let mut task = tokio::spawn(async move {
6611 link_internal(
6612 &PROGRESS,
6613 &src_parent,
6614 None,
6615 Some(&dst_parent),
6616 std::ffi::OsStr::new("entry"),
6617 &src_root.join("entry"),
6618 &task_dst_entry,
6619 None,
6620 std::path::Path::new(""),
6621 UpdateRootRequirement::Optional,
6622 &common_settings(false, false),
6623 true,
6624 EntryAdmission::from(permit).into(),
6625 )
6626 .await
6627 });
6628 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6629 let second_permit = tokio::time::timeout(
6630 std::time::Duration::from_millis(500),
6631 throttle::open_file_permit(),
6632 )
6633 .await;
6634 let released_before_link_finished = second_permit.is_ok();
6635 drop(second_permit);
6636 drop(held_hard_link);
6637 let task_result = admission
6638 .run_with_timeout(std::time::Duration::from_secs(20), &mut task)
6639 .await
6640 .context("hard link did not resume after metadata capacity was released")?;
6641 let summary = task_result?.map_err(|error| error.source)?;
6642 assert!(
6643 !released_before_link_finished,
6644 "hard-link work released its open-file permit before linkat completed"
6645 );
6646 assert_eq!(summary.summary.hard_links_created, 1);
6647 assert!(dst_entry.exists());
6648 Ok(())
6649 }
6650
6651 #[tokio::test]
6654 #[traced_test]
6655 async fn deep_tree_no_deadlock_under_files_in_flight_saturation()
6656 -> Result<(), anyhow::Error> {
6657 let tmp_dir = testutils::create_temp_dir().await?;
6658 let src = tmp_dir.join("src");
6659 let dst = tmp_dir.join("dst");
6660 let depth = 20;
6661 let files_per_level = 5;
6662 let limit = 1;
6663 let mut dir = src.clone();
6665 for level in 0..depth {
6666 tokio::fs::create_dir_all(&dir).await?;
6667 for f in 0..files_per_level {
6668 tokio::fs::write(
6669 dir.join(format!("f{}_{}.txt", level, f)),
6670 format!("L{}F{}", level, f),
6671 )
6672 .await?;
6673 }
6674 dir = dir.join(format!("d{}", level));
6675 }
6676 let admission = testutils::AdmissionLimit::new().await;
6677 admission.set_files_in_flight(limit);
6678 let summary = admission
6679 .run_with_timeout(
6680 std::time::Duration::from_secs(30),
6681 link(
6682 &PROGRESS,
6683 tmp_dir.as_path(),
6684 &src,
6685 &dst,
6686 &None,
6687 &common_settings(false, false),
6688 false,
6689 ),
6690 )
6691 .await
6692 .context("link timed out — possible deadlock")?
6693 .context("link failed")?;
6694 assert_eq!(summary.hard_links_created, depth * files_per_level);
6695 assert_eq!(summary.copy_summary.directories_created, depth);
6696 let mut check_dir = dst.clone();
6698 for level in 0..depth {
6699 let content =
6700 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
6701 assert_eq!(content, format!("L{}F0", level));
6702 check_dir = check_dir.join(format!("d{}", level));
6703 }
6704 Ok(())
6705 }
6706
6707 #[tokio::test]
6711 #[traced_test]
6712 async fn matching_dual_directory_chain_releases_admission_before_recursion()
6713 -> Result<(), anyhow::Error> {
6714 let tmp_dir = testutils::create_temp_dir().await?;
6715 let src = tmp_dir.join("src");
6716 let update = tmp_dir.join("update");
6717 let dst = tmp_dir.join("dst");
6718 tokio::fs::create_dir(&src).await?;
6719 tokio::fs::create_dir(&update).await?;
6720 let depth = 12;
6721 let mut src_dir = src.clone();
6722 let mut update_dir = update.clone();
6723 for level in 0..depth {
6724 let name = format!("d{level}");
6725 src_dir.push(&name);
6726 update_dir.push(&name);
6727 tokio::fs::create_dir(&src_dir).await?;
6728 tokio::fs::create_dir(&update_dir).await?;
6729 }
6730 let admission = testutils::AdmissionLimit::new().await;
6731 admission.set_files_in_flight(1);
6732 let summary = admission
6733 .run_with_timeout(
6734 std::time::Duration::from_secs(5),
6735 link(
6736 &PROGRESS,
6737 tmp_dir.as_path(),
6738 &src,
6739 &dst,
6740 &Some(update),
6741 &common_settings(false, false),
6742 false,
6743 ),
6744 )
6745 .await
6746 .context("link timed out — direct dual-directory recursion retained admission")?
6747 .context("link failed")?;
6748 assert_eq!(summary.copy_summary.directories_created, depth + 1);
6749 assert!(
6750 src_dir
6751 .strip_prefix(&src)
6752 .is_ok_and(|tail| dst.join(tail).is_dir())
6753 );
6754 Ok(())
6755 }
6756
6757 #[tokio::test]
6767 #[traced_test]
6768 async fn type_changed_directory_releases_admission_before_recursion()
6769 -> Result<(), anyhow::Error> {
6770 let tmp_dir = testutils::create_temp_dir().await?;
6771 let src = tmp_dir.join("src");
6772 let update = tmp_dir.join("update");
6773 let dst = tmp_dir.join("dst");
6774 tokio::fs::create_dir(&src).await?;
6775 tokio::fs::create_dir(&update).await?;
6776 let n = 8;
6777 for i in 0..n {
6781 tokio::fs::write(src.join(format!("e{}", i)), format!("src-{}", i)).await?;
6782 let upd_subdir = update.join(format!("e{}", i));
6783 tokio::fs::create_dir(&upd_subdir).await?;
6784 for j in 0..3 {
6785 tokio::fs::write(
6786 upd_subdir.join(format!("inner_{}.txt", j)),
6787 format!("upd-{}-{}", i, j),
6788 )
6789 .await?;
6790 }
6791 }
6792 let admission = testutils::AdmissionLimit::new().await;
6794 admission.set_files_in_flight(2);
6795 let summary = admission
6796 .run_with_timeout(
6797 std::time::Duration::from_secs(30),
6798 link(
6799 &PROGRESS,
6800 tmp_dir.as_path(),
6801 &src,
6802 &dst,
6803 &Some(update.clone()),
6804 &common_settings(false, false),
6805 false,
6806 ),
6807 )
6808 .await
6809 .context(
6810 "link timed out — delegated admission was retained during directory descent",
6811 )?
6812 .context("link failed")?;
6813 assert_eq!(summary.copy_summary.directories_created, n + 1); assert_eq!(summary.copy_summary.files_copied, n * 3);
6817 for i in 0..n {
6819 for j in 0..3 {
6820 let content =
6821 tokio::fs::read_to_string(dst.join(format!("e{}/inner_{}.txt", i, j)))
6822 .await?;
6823 assert_eq!(content, format!("upd-{}-{}", i, j));
6824 }
6825 }
6826 Ok(())
6827 }
6828
6829 #[tokio::test(flavor = "current_thread")]
6832 async fn source_fail_early_reaps_error_while_later_admission_is_saturated()
6833 -> Result<(), anyhow::Error> {
6834 let root = testutils::create_temp_dir().await?;
6835 let src = root.join("src");
6836 let dst = root.join("dst");
6837 tokio::fs::create_dir(&src).await?;
6838 tokio::fs::create_dir(&dst).await?;
6839 tokio::fs::write(src.join("vanished"), b"vanished").await?;
6840 tokio::fs::symlink("target-a", src.join("blocked-a")).await?;
6841 tokio::fs::symlink("target-b", src.join("blocked-b")).await?;
6842 tokio::fs::write(src.join("later"), b"later").await?;
6843 tokio::fs::write(src.join("uninjected"), b"uninjected").await?;
6844 let admission = testutils::AdmissionLimit::new().await;
6845 admission.set_files_in_flight(2);
6846 let stat_resource =
6847 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
6848 admission.set_max_ops_in_flight(stat_resource, 1);
6849 let readlink_resource =
6850 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::ReadLink);
6851 admission.set_max_ops_in_flight(readlink_resource, 1);
6852 let src_dir =
6853 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6854 let dst_dir =
6855 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
6856 let dst_parent =
6857 Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
6858 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
6859 let mut held_readlink = Some(throttle::ops_in_flight_permit(readlink_resource).await);
6860 let mut filter = crate::filter::FilterSettings::new();
6861 filter.add_exclude("never/")?;
6862 let mut settings = common_settings(false, false);
6863 settings.filter = Some(filter.clone());
6864 settings.copy_settings.filter = Some(filter);
6865 settings.copy_settings.fail_early = true;
6866 let operation = crate::walk_driver::scope_tasks(link_dir_contents(
6867 &PROGRESS,
6868 &src_dir,
6869 None,
6870 Some(&dst_dir),
6871 Some(&dst_parent),
6872 std::ffi::OsStr::new("dst"),
6873 &src,
6874 &dst,
6875 None,
6876 std::path::Path::new(""),
6877 &src,
6878 &dst,
6879 false,
6880 false,
6881 None,
6882 &settings,
6883 Summary::default(),
6884 SourceEntries::Injected(vec![
6885 (std::ffi::OsString::from("vanished"), Some(EntryKind::File)),
6886 (
6887 std::ffi::OsString::from("blocked-a"),
6888 Some(EntryKind::Symlink),
6889 ),
6890 (
6891 std::ffi::OsString::from("blocked-b"),
6892 Some(EntryKind::Symlink),
6893 ),
6894 (std::ffi::OsString::from("later"), Some(EntryKind::File)),
6895 ]),
6896 UpdateEntries::Live,
6897 ));
6898 tokio::pin!(operation);
6899 let mut completed_while_gated = None;
6900 let saturated = tokio::time::timeout(std::time::Duration::from_secs(5), async {
6901 loop {
6902 if let std::task::Poll::Ready(output) = futures::poll!(operation.as_mut()) {
6903 completed_while_gated = Some(output);
6904 return;
6905 }
6906 if available_open_file_capacity(2) == 0 {
6907 return;
6908 }
6909 tokio::task::yield_now().await;
6910 }
6911 })
6912 .await;
6913 let remove_result = tokio::fs::remove_file(src.join("vanished")).await;
6914 drop(held_stat);
6915 if let Some(output) = completed_while_gated {
6916 drop(held_readlink.take());
6917 reacquire_all_open_file_capacity(&admission, 2).await?;
6918 return Err(anyhow!(
6919 "source link completed before its gated workers saturated admission: {:?}",
6920 output.err().map(|error| format!("{:#}", error.source))
6921 ));
6922 }
6923 if let Err(error) = saturated {
6924 drop(held_readlink.take());
6925 let _ = admission
6926 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6927 .await;
6928 reacquire_all_open_file_capacity(&admission, 2).await?;
6929 return Err(error).context("source workers did not saturate open-file admission");
6930 }
6931 if let Err(error) = remove_result {
6932 drop(held_readlink.take());
6933 let _ = admission
6934 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6935 .await;
6936 reacquire_all_open_file_capacity(&admission, 2).await?;
6937 return Err(error).context("failed to remove the injected source entry");
6938 }
6939 let result =
6940 tokio::time::timeout(std::time::Duration::from_secs(1), operation.as_mut()).await;
6941 drop(held_readlink.take());
6942 if result.is_err() {
6943 let _ = admission
6944 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
6945 .await;
6946 }
6947 reacquire_all_open_file_capacity(&admission, 2).await?;
6948 let error = result
6949 .context("fail-early source error was not reaped during admission")?
6950 .expect_err("the vanished source entry must fail the link");
6951 assert!(format!("{:#}", error.source).contains("vanished"));
6952 assert!(!dst.join("later").exists());
6953 assert!(!dst.join("uninjected").exists());
6954 Ok(())
6955 }
6956
6957 #[tokio::test(flavor = "current_thread")]
6960 async fn source_unknown_classification_error_keeps_going_with_siblings()
6961 -> Result<(), anyhow::Error> {
6962 let root = testutils::create_temp_dir().await?;
6963 let src = root.join("src");
6964 let dst = root.join("dst");
6965 tokio::fs::create_dir(&src).await?;
6966 tokio::fs::create_dir(&dst).await?;
6967 tokio::fs::write(src.join("first"), b"first").await?;
6968 tokio::fs::write(src.join("last"), b"last").await?;
6969 tokio::fs::write(src.join("uninjected"), b"uninjected").await?;
6970 let admission = testutils::AdmissionLimit::new().await;
6971 admission.set_files_in_flight(3);
6972 let src_dir =
6973 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
6974 let dst_dir =
6975 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
6976 let dst_parent =
6977 Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
6978 let mut filter = crate::filter::FilterSettings::new();
6979 filter.add_exclude("never/")?;
6980 let mut settings = common_settings(false, false);
6981 settings.filter = Some(filter.clone());
6982 settings.copy_settings.filter = Some(filter);
6983 let operation = crate::walk_driver::scope_tasks(link_dir_contents(
6984 &PROGRESS,
6985 &src_dir,
6986 None,
6987 Some(&dst_dir),
6988 Some(&dst_parent),
6989 std::ffi::OsStr::new("dst"),
6990 &src,
6991 &dst,
6992 None,
6993 std::path::Path::new(""),
6994 &src,
6995 &dst,
6996 false,
6997 false,
6998 None,
6999 &settings,
7000 Summary::default(),
7001 SourceEntries::Injected(vec![
7002 (std::ffi::OsString::from("first"), None),
7003 (std::ffi::OsString::from("vanished"), None),
7004 (std::ffi::OsString::from("last"), None),
7005 ]),
7006 UpdateEntries::Live,
7007 ));
7008 let result = admission
7009 .run_with_timeout(std::time::Duration::from_secs(20), operation)
7010 .await;
7011 reacquire_all_open_file_capacity(&admission, 3).await?;
7012 let error = result
7013 .context("keep-going unknown-source link did not terminate")?
7014 .expect_err("the injected vanished source must fail");
7015 assert_eq!(error.summary.hard_links_created, 2);
7016 assert_eq!(tokio::fs::read(dst.join("first")).await?, b"first");
7017 assert_eq!(tokio::fs::read(dst.join("last")).await?, b"last");
7018 assert!(!dst.join("uninjected").exists());
7019 Ok(())
7020 }
7021
7022 #[tokio::test]
7025 async fn source_unknown_excluded_directory_uses_directory_skip_accounting()
7026 -> Result<(), anyhow::Error> {
7027 let root = testutils::create_temp_dir().await?;
7028 let src = root.join("src");
7029 let dst = root.join("dst");
7030 tokio::fs::create_dir(&src).await?;
7031 tokio::fs::create_dir(&dst).await?;
7032 tokio::fs::create_dir(src.join("node")).await?;
7033 tokio::fs::write(src.join("uninjected"), b"uninjected").await?;
7034 let src_dir =
7035 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
7036 let dst_dir =
7037 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
7038 let dst_parent =
7039 Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
7040 let mut filter = crate::filter::FilterSettings::new();
7041 filter.add_exclude("node/")?;
7042 let mut settings = common_settings(false, false);
7043 settings.filter = Some(filter.clone());
7044 settings.copy_settings.filter = Some(filter);
7045 let summary = crate::walk_driver::scope_tasks(link_dir_contents(
7046 &PROGRESS,
7047 &src_dir,
7048 None,
7049 Some(&dst_dir),
7050 Some(&dst_parent),
7051 std::ffi::OsStr::new("dst"),
7052 &src,
7053 &dst,
7054 None,
7055 std::path::Path::new(""),
7056 &src,
7057 &dst,
7058 false,
7059 false,
7060 None,
7061 &settings,
7062 Summary::default(),
7063 SourceEntries::Injected(vec![(std::ffi::OsString::from("node"), None)]),
7064 UpdateEntries::Live,
7065 ))
7066 .await?;
7067 assert_eq!(summary.copy_summary.directories_skipped, 1);
7068 assert_eq!(summary.copy_summary.files_skipped, 0);
7069 assert!(!dst.join("node").exists());
7070 assert!(!dst.join("uninjected").exists());
7071 Ok(())
7072 }
7073
7074 #[tokio::test(flavor = "current_thread")]
7077 async fn source_unknown_workers_classify_concurrently() -> Result<(), anyhow::Error> {
7078 let root = testutils::create_temp_dir().await?;
7079 let src = root.join("src");
7080 let dst = root.join("dst");
7081 tokio::fs::create_dir(&src).await?;
7082 tokio::fs::create_dir(&dst).await?;
7083 tokio::fs::write(src.join("first"), b"first").await?;
7084 tokio::fs::write(src.join("second"), b"second").await?;
7085 tokio::fs::write(src.join("uninjected"), b"uninjected").await?;
7086 let admission = testutils::AdmissionLimit::new().await;
7087 admission.set_files_in_flight(2);
7088 let stat_resource =
7089 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
7090 admission.set_max_ops_in_flight(stat_resource, 1);
7091 let src_dir =
7092 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
7093 let dst_dir =
7094 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
7095 let dst_parent =
7096 Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
7097 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
7098 let mut filter = crate::filter::FilterSettings::new();
7099 filter.add_exclude("never/")?;
7100 let mut settings = common_settings(false, false);
7101 settings.filter = Some(filter.clone());
7102 settings.copy_settings.filter = Some(filter);
7103 let operation = crate::walk_driver::scope_tasks(link_dir_contents(
7104 &PROGRESS,
7105 &src_dir,
7106 None,
7107 Some(&dst_dir),
7108 Some(&dst_parent),
7109 std::ffi::OsStr::new("dst"),
7110 &src,
7111 &dst,
7112 None,
7113 std::path::Path::new(""),
7114 &src,
7115 &dst,
7116 false,
7117 false,
7118 None,
7119 &settings,
7120 Summary::default(),
7121 SourceEntries::Injected(vec![
7122 (std::ffi::OsString::from("first"), None),
7123 (std::ffi::OsString::from("second"), None),
7124 ]),
7125 UpdateEntries::Live,
7126 ));
7127 tokio::pin!(operation);
7128 let mut completed_while_gated = None;
7129 let concurrent = tokio::time::timeout(std::time::Duration::from_secs(5), async {
7130 loop {
7131 if let std::task::Poll::Ready(output) = futures::poll!(operation.as_mut()) {
7132 completed_while_gated = Some(output);
7133 return;
7134 }
7135 if available_open_file_capacity(2) == 0 {
7136 return;
7137 }
7138 tokio::task::yield_now().await;
7139 }
7140 })
7141 .await;
7142 drop(held_stat);
7143 let task_result = admission
7144 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
7145 .await;
7146 reacquire_all_open_file_capacity(&admission, 2).await?;
7147 if let Some(output) = completed_while_gated {
7148 return Err(anyhow!(
7149 "unknown source link completed before gated classifications: {:?}",
7150 output.err().map(|error| format!("{:#}", error.source))
7151 ));
7152 }
7153 concurrent.context("unknown source workers did not classify concurrently")?;
7154 let summary = task_result
7155 .context("unknown source classifications did not resume after stat release")??;
7156 assert_eq!(summary.hard_links_created, 2);
7157 assert!(!dst.join("uninjected").exists());
7158 Ok(())
7159 }
7160
7161 #[tokio::test(flavor = "current_thread")]
7164 async fn update_only_workers_classify_concurrently() -> Result<(), anyhow::Error> {
7165 let root = testutils::create_temp_dir().await?;
7166 let src = root.join("src");
7167 let update = root.join("update");
7168 let dst = root.join("dst");
7169 tokio::fs::create_dir(&src).await?;
7170 tokio::fs::create_dir(&update).await?;
7171 tokio::fs::create_dir(&dst).await?;
7172 tokio::fs::write(update.join("first"), b"first").await?;
7173 tokio::fs::write(update.join("second"), b"second").await?;
7174 tokio::fs::write(update.join("uninjected"), b"uninjected").await?;
7175
7176 let admission = testutils::AdmissionLimit::new().await;
7177 admission.set_files_in_flight(2);
7178 let src_dir =
7179 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
7180 let update_dir =
7181 Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
7182 let dst_dir =
7183 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
7184 let dst_parent =
7185 Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
7186 let stat_resource =
7187 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
7188 admission.set_max_ops_in_flight(stat_resource, 1);
7189 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
7190 let mut filter = crate::filter::FilterSettings::new();
7191 filter.add_exclude("never/")?;
7192 let mut settings = common_settings(false, false);
7193 settings.filter = Some(filter.clone());
7194 settings.copy_settings.filter = Some(filter);
7195 let operation = crate::walk_driver::scope_tasks(link_dir_contents(
7196 &PROGRESS,
7197 &src_dir,
7198 Some(&update_dir),
7199 Some(&dst_dir),
7200 Some(&dst_parent),
7201 std::ffi::OsStr::new("dst"),
7202 &src,
7203 &dst,
7204 Some(&update),
7205 std::path::Path::new(""),
7206 &src,
7207 &dst,
7208 false,
7209 false,
7210 None,
7211 &settings,
7212 Summary::default(),
7213 SourceEntries::Live,
7214 UpdateEntries::Injected(vec![
7215 (std::ffi::OsString::from("first"), Some(EntryKind::File)),
7216 (std::ffi::OsString::from("second"), Some(EntryKind::File)),
7217 ]),
7218 ));
7219 tokio::pin!(operation);
7220 let available_while_classifying = loop {
7221 assert!(
7222 futures::poll!(operation.as_mut()).is_pending(),
7223 "update-only link completed while exact classifications were gated"
7224 );
7225 let mut available = Vec::new();
7226 for _ in 0..2 {
7227 let mut probe = Box::pin(throttle::open_file_permit());
7228 match futures::poll!(probe.as_mut()) {
7229 std::task::Poll::Ready(permit) => available.push(permit),
7230 std::task::Poll::Pending => break,
7231 }
7232 }
7233 let count = available.len();
7234 drop(available);
7235 if count < 2 {
7236 break count;
7237 }
7238 tokio::task::yield_now().await;
7239 };
7240 drop(held_stat);
7241 assert!(
7242 available_while_classifying == 0,
7243 "the first exact update-only classification serialized its sibling"
7244 );
7245 let summary = admission
7246 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
7247 .await
7248 .context("update-only classifications did not resume after stat release")??;
7249 assert_eq!(summary.copy_summary.files_copied, 2);
7250 Ok(())
7251 }
7252
7253 #[tokio::test(flavor = "current_thread")]
7256 async fn update_only_classification_error_keeps_going_and_folds_siblings()
7257 -> Result<(), anyhow::Error> {
7258 let root = testutils::create_temp_dir().await?;
7259 let src = root.join("src");
7260 let update = root.join("update");
7261 let dst = root.join("dst");
7262 tokio::fs::create_dir(&src).await?;
7263 tokio::fs::create_dir(&update).await?;
7264 tokio::fs::create_dir(&dst).await?;
7265 for name in ["first", "middle", "last"] {
7266 tokio::fs::write(update.join(name), name.as_bytes()).await?;
7267 }
7268 tokio::fs::write(update.join("uninjected"), b"uninjected").await?;
7269
7270 let admission = testutils::AdmissionLimit::new().await;
7271 admission.set_files_in_flight(3);
7272 let src_dir =
7273 Arc::new(Dir::open_root_dir(&src, false, congestion::Side::Source).await?);
7274 let update_dir =
7275 Arc::new(Dir::open_root_dir(&update, false, congestion::Side::Source).await?);
7276 let dst_dir =
7277 Arc::new(Dir::open_root_dir(&dst, false, congestion::Side::Destination).await?);
7278 let dst_parent =
7279 Arc::new(Dir::open_root_dir(&root, false, congestion::Side::Destination).await?);
7280 let stat_resource =
7281 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
7282 admission.set_max_ops_in_flight(stat_resource, 1);
7283 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
7284 let vanished = std::ffi::OsString::from("middle");
7285 let successful = [
7286 std::ffi::OsString::from("first"),
7287 std::ffi::OsString::from("last"),
7288 ];
7289 let settings = common_settings(false, false);
7290 let operation = crate::walk_driver::scope_tasks(link_dir_contents(
7291 &PROGRESS,
7292 &src_dir,
7293 Some(&update_dir),
7294 Some(&dst_dir),
7295 Some(&dst_parent),
7296 std::ffi::OsStr::new("dst"),
7297 &src,
7298 &dst,
7299 Some(&update),
7300 std::path::Path::new(""),
7301 &src,
7302 &dst,
7303 false,
7304 false,
7305 None,
7306 &settings,
7307 Summary::default(),
7308 SourceEntries::Live,
7309 UpdateEntries::Injected(vec![
7310 (std::ffi::OsString::from("first"), Some(EntryKind::File)),
7311 (std::ffi::OsString::from("middle"), Some(EntryKind::File)),
7312 (std::ffi::OsString::from("last"), Some(EntryKind::File)),
7313 ]),
7314 ));
7315 tokio::pin!(operation);
7316 loop {
7317 assert!(
7318 futures::poll!(operation.as_mut()).is_pending(),
7319 "update-only link completed while exact classifications were gated"
7320 );
7321 let mut available = Vec::new();
7322 for _ in 0..3 {
7323 let mut probe = Box::pin(throttle::open_file_permit());
7324 match futures::poll!(probe.as_mut()) {
7325 std::task::Poll::Ready(permit) => available.push(permit),
7326 std::task::Poll::Pending => break,
7327 }
7328 }
7329 let count = available.len();
7330 drop(available);
7331 if count < 3 {
7332 break;
7333 }
7334 tokio::task::yield_now().await;
7335 }
7336 tokio::fs::remove_file(update.join(&vanished)).await?;
7337 drop(held_stat);
7338 let error = admission
7339 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
7340 .await
7341 .context("keep-going update-only link did not terminate")?
7342 .expect_err("the vanished update-only entry classification must fail");
7343 assert_eq!(
7344 error.summary.copy_summary.files_copied, 2,
7345 "the combined error summary must include both successful siblings"
7346 );
7347 assert!(format!("{:#}", error.source).contains(vanished.to_string_lossy().as_ref()));
7348 for name in successful {
7349 assert_eq!(
7350 tokio::fs::read(dst.join(&name)).await?,
7351 name.as_encoded_bytes(),
7352 "successful sibling {name:?} did not materialize"
7353 );
7354 }
7355 assert!(!dst.join(vanished).exists());
7356 Ok(())
7357 }
7358
7359 #[tokio::test]
7363 async fn update_only_spawning_stops_at_open_file_capacity() -> Result<(), anyhow::Error> {
7364 let tmp_dir = testutils::create_temp_dir().await?;
7365 let src = tmp_dir.join("src");
7366 let update = tmp_dir.join("update");
7367 let dst = tmp_dir.join("dst");
7368 tokio::fs::create_dir(&src).await?;
7369 tokio::fs::create_dir(&update).await?;
7370 let n = 8;
7371 for i in 0..n {
7372 tokio::fs::symlink(format!("target-{i}"), update.join(format!("u{i}"))).await?;
7373 }
7374 let progress: &'static progress::Progress =
7375 Box::leak(Box::new(progress::Progress::new()));
7376 let admission = testutils::AdmissionLimit::new().await;
7377 admission.set_files_in_flight(1);
7378 let readlink_resource =
7379 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::ReadLink);
7380 admission.set_max_ops_in_flight(readlink_resource, 1);
7381 let held_readlink = throttle::ops_in_flight_permit(readlink_resource).await;
7382 let mut task = tokio::spawn(async move {
7383 link(
7384 progress,
7385 &tmp_dir,
7386 &src,
7387 &dst,
7388 &Some(update),
7389 &common_settings(false, false),
7390 false,
7391 )
7392 .await
7393 });
7394 let first_worker = tokio::time::timeout(std::time::Duration::from_secs(20), async {
7395 loop {
7396 if progress.ops.get().started >= 2 {
7397 break;
7398 }
7399 tokio::task::yield_now().await;
7400 }
7401 })
7402 .await;
7403 if let Err(error) = first_worker {
7404 drop(held_readlink);
7405 admission.quiesce(&mut task).await;
7406 return Err(error).context("the first update-only worker did not start");
7407 }
7408 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7409 let started_while_blocked = progress.ops.get().started;
7410 drop(held_readlink);
7411 let task_result = admission
7412 .run_with_timeout(std::time::Duration::from_secs(20), &mut task)
7413 .await
7414 .context("update-only copies did not resume after ReadLink was released")?;
7415 let summary = task_result?.map_err(|error| error.source)?;
7416 assert_eq!(
7417 started_while_blocked, 2,
7418 "root plus exactly one admitted update-only worker should be live"
7419 );
7420 assert_eq!(summary.copy_summary.symlinks_created, n);
7421 Ok(())
7422 }
7423
7424 #[tokio::test]
7432 #[traced_test]
7433 async fn update_only_entries_bounded_no_deadlock() -> Result<(), anyhow::Error> {
7434 let tmp_dir = testutils::create_temp_dir().await?;
7435 let src = tmp_dir.join("src");
7436 let update = tmp_dir.join("update");
7437 let dst = tmp_dir.join("dst");
7438 tokio::fs::create_dir(&src).await?;
7439 tokio::fs::create_dir(&update).await?;
7440 let n = 50;
7443 for i in 0..n {
7444 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
7445 }
7446 let admission = testutils::AdmissionLimit::new().await;
7447 admission.set_files_in_flight(2);
7448 let summary = admission
7449 .run_with_timeout(
7450 std::time::Duration::from_secs(30),
7451 link(
7452 &PROGRESS,
7453 tmp_dir.as_path(),
7454 &src,
7455 &dst,
7456 &Some(update.clone()),
7457 &common_settings(false, false),
7458 false,
7459 ),
7460 )
7461 .await
7462 .context("link timed out — site-3 spawn loop deadlock")?
7463 .context("link failed")?;
7464 assert_eq!(summary.copy_summary.directories_created, 1);
7466 assert_eq!(summary.copy_summary.files_copied, n);
7467 for i in 0..n {
7468 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
7469 assert_eq!(content, format!("upd-{}", i));
7470 }
7471 Ok(())
7472 }
7473
7474 #[tokio::test]
7484 #[traced_test]
7485 async fn update_only_overwrite_preexisting_dirs_no_deadlock() -> Result<(), anyhow::Error> {
7486 let tmp_dir = testutils::create_temp_dir().await?;
7487 let src = tmp_dir.join("src");
7488 let update = tmp_dir.join("update");
7489 let dst = tmp_dir.join("dst");
7490 tokio::fs::create_dir(&src).await?;
7491 tokio::fs::create_dir(&update).await?;
7492 tokio::fs::create_dir(&dst).await?;
7493 let n = 12;
7494 for i in 0..n {
7495 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
7497 let dst_subdir = dst.join(format!("u{}", i));
7501 tokio::fs::create_dir(&dst_subdir).await?;
7502 for j in 0..3 {
7503 tokio::fs::write(
7504 dst_subdir.join(format!("inner_{}.txt", j)),
7505 format!("old-{}-{}", i, j),
7506 )
7507 .await?;
7508 }
7509 }
7510 let admission = testutils::AdmissionLimit::new().await;
7512 admission.set_files_in_flight(2);
7513 let summary = admission
7514 .run_with_timeout(
7515 std::time::Duration::from_secs(30),
7516 link(
7517 &PROGRESS,
7518 tmp_dir.as_path(),
7519 &src,
7520 &dst,
7521 &Some(update.clone()),
7522 &common_settings(false, true), false,
7524 ),
7525 )
7526 .await
7527 .context("link timed out — pending-meta self-deadlock between site 3 and inner rm")?
7528 .context("link failed")?;
7529 assert_eq!(summary.copy_summary.files_copied, n);
7532 assert_eq!(summary.copy_summary.rm_summary.files_removed, n * 3);
7533 assert_eq!(summary.copy_summary.rm_summary.directories_removed, n);
7534 for i in 0..n {
7536 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
7537 assert_eq!(content, format!("upd-{}", i));
7538 }
7539 Ok(())
7540 }
7541 }
7542
7543 mod race_tests {
7551 use super::*;
7552
7553 fn spawn_file_symlink_swapper(
7557 dir: std::path::PathBuf,
7558 entry_name: &'static str,
7559 sentinel: std::path::PathBuf,
7560 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
7561 ) -> std::thread::JoinHandle<()> {
7562 std::thread::spawn(move || {
7563 let entry = dir.join(entry_name);
7564 let staged_real = dir.join("__staged_real");
7565 let staged_link = dir.join("__staged_link");
7566 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
7567 let _ = std::fs::remove_file(&staged_real);
7568 if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
7569 continue;
7570 }
7571 let _ = std::fs::rename(&staged_real, &entry);
7572 let _ = std::fs::remove_file(&staged_link);
7573 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
7574 let _ = std::fs::rename(&staged_link, &entry);
7575 }
7576 })
7577 }
7578
7579 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7580 #[traced_test]
7581 async fn hard_link_entry_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
7582 let tmp_dir = testutils::create_temp_dir().await?;
7583 let test_path = tmp_dir.as_path();
7584 let sentinel = test_path.join("sentinel_secret");
7587 tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
7588 let sentinel_links_before = {
7589 use std::os::unix::fs::MetadataExt;
7590 tokio::fs::symlink_metadata(&sentinel).await?.nlink()
7591 };
7592 let src = test_path.join("src");
7593 let sub = src.join("sub");
7594 tokio::fs::create_dir(&src).await?;
7595 tokio::fs::create_dir(&sub).await?;
7596 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
7597
7598 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7599 let swapper =
7600 spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
7601
7602 let settings = common_settings(false, true);
7605 let mut caught_swaps = 0usize;
7606 let mut linked_real = 0usize;
7607 for i in 0..200 {
7608 let dst = test_path.join(format!("dst_{i}"));
7609 let result = tokio::time::timeout(
7610 std::time::Duration::from_secs(30),
7611 link(&PROGRESS, test_path, &src, &dst, &None, &settings, false),
7612 )
7613 .await
7614 .expect("link must not hang under concurrent swapping");
7615 match result {
7616 Ok(_) => {}
7617 Err(_) => caught_swaps += 1, }
7619 let entry_dst = dst.join("sub").join("entry");
7625 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
7626 && md.file_type().is_file()
7627 {
7628 let content = tokio::fs::read_to_string(&entry_dst).await?;
7629 assert_ne!(
7630 content, "SENTINEL_SECRET_CONTENT",
7631 "iteration {i}: sentinel content leaked into the destination as a regular file"
7632 );
7633 assert_eq!(
7634 content, "REAL_CONTENT",
7635 "iteration {i}: a regular destination file must hold the real content"
7636 );
7637 linked_real += 1;
7638 }
7639 let _ = tokio::fs::remove_dir_all(&dst).await;
7640 }
7641
7642 stop.store(true, std::sync::atomic::Ordering::Relaxed);
7643 swapper.join().expect("swapper thread panicked");
7644
7645 let sentinel_links_after = {
7648 use std::os::unix::fs::MetadataExt;
7649 tokio::fs::symlink_metadata(&sentinel).await?.nlink()
7650 };
7651 assert_eq!(
7652 sentinel_links_after, sentinel_links_before,
7653 "the sentinel file must never gain a hard link (linkat must not follow the symlink)"
7654 );
7655 tracing::info!(
7658 "link file/symlink swap: caught_swaps={caught_swaps}, linked_real={linked_real}"
7659 );
7660 assert!(
7661 caught_swaps + linked_real > 0,
7662 "expected at least one observable outcome across 200 iterations"
7663 );
7664 Ok(())
7665 }
7666 }
7667}