1use anyhow::{Context, anyhow};
2use async_recursion::async_recursion;
3use std::os::linux::fs::MetadataExt as LinuxMetadataExt;
4use tracing::instrument;
5
6use crate::copy;
7use crate::copy::{
8 EmptyDirAction, Settings as CopySettings, Summary as CopySummary, check_empty_dir_cleanup,
9};
10use crate::filecmp;
11use crate::preserve;
12use crate::progress;
13use crate::rm;
14use crate::walk::{self, EntryKind};
15
16pub type Error = crate::error::OperationError<Summary>;
19
20#[derive(Debug, Clone)]
21pub struct Settings {
22 pub copy_settings: CopySettings,
23 pub update_compare: filecmp::MetadataCmpSettings,
24 pub update_exclusive: bool,
25 pub filter: Option<crate::filter::FilterSettings>,
27 pub dry_run: Option<crate::config::DryRunMode>,
29 pub preserve: preserve::Settings,
31}
32
33fn skipped_summary_for(kind: EntryKind) -> Summary {
37 let copy_summary = match kind {
38 EntryKind::Dir => CopySummary {
39 directories_skipped: 1,
40 ..Default::default()
41 },
42 EntryKind::Symlink => CopySummary {
43 symlinks_skipped: 1,
44 ..Default::default()
45 },
46 EntryKind::File | EntryKind::Special => CopySummary {
47 files_skipped: 1,
48 ..Default::default()
49 },
50 };
51 Summary {
52 copy_summary,
53 ..Default::default()
54 }
55}
56
57#[derive(Copy, Clone, Debug, Default)]
58pub struct Summary {
59 pub hard_links_created: usize,
60 pub hard_links_unchanged: usize,
61 pub copy_summary: CopySummary,
62}
63
64impl std::ops::Add for Summary {
65 type Output = Self;
66 fn add(self, other: Self) -> Self {
67 Self {
68 hard_links_created: self.hard_links_created + other.hard_links_created,
69 hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
70 copy_summary: self.copy_summary + other.copy_summary,
71 }
72 }
73}
74
75impl std::fmt::Display for Summary {
76 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
77 write!(
78 f,
79 "{}\n\
80 link:\n\
81 -----\n\
82 hard-links created: {}\n\
83 hard links unchanged: {}\n",
84 &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
85 )
86 }
87}
88
89fn is_hard_link(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
90 copy::is_file_type_same(md1, md2)
91 && md2.st_dev() == md1.st_dev()
92 && md2.st_ino() == md1.st_ino()
93}
94
95#[instrument(skip(prog_track, settings))]
96async fn hard_link_helper(
97 prog_track: &'static progress::Progress,
98 src: &std::path::Path,
99 src_metadata: &std::fs::Metadata,
100 dst: &std::path::Path,
101 settings: &Settings,
102) -> Result<Summary, Error> {
103 let mut link_summary = Summary::default();
104 match crate::walk::run_metadata_probed(
105 congestion::Side::Destination,
106 congestion::MetadataOp::HardLink,
107 tokio::fs::hard_link(src, dst),
108 )
109 .await
110 {
111 Ok(()) => {}
112 Err(error)
113 if settings.copy_settings.overwrite
114 && error.kind() == std::io::ErrorKind::AlreadyExists =>
115 {
116 tracing::debug!("'dst' already exists, check if we need to update");
117 let dst_metadata = crate::walk::run_metadata_probed(
118 congestion::Side::Destination,
119 congestion::MetadataOp::Stat,
120 tokio::fs::symlink_metadata(dst),
121 )
122 .await
123 .with_context(|| format!("cannot read {dst:?} metadata"))
124 .map_err(|err| Error::new(err, Default::default()))?;
125 if is_hard_link(src_metadata, &dst_metadata) {
126 tracing::debug!("no change, leaving file as is");
127 prog_track.hard_links_unchanged.inc();
128 return Ok(Summary {
129 hard_links_unchanged: 1,
130 ..Default::default()
131 });
132 }
133 tracing::info!("'dst' file type changed, removing and hard-linking");
134 let rm_summary = rm::rm(
135 prog_track,
136 dst,
137 &rm::Settings {
138 fail_early: settings.copy_settings.fail_early,
139 filter: None,
140 dry_run: None,
141 time_filter: None,
142 },
143 )
144 .await
145 .map_err(|err| {
146 let rm_summary = err.summary;
147 link_summary.copy_summary.rm_summary = rm_summary;
148 Error::new(err.source, link_summary)
149 })?;
150 link_summary.copy_summary.rm_summary = rm_summary;
151 crate::walk::run_metadata_probed(
152 congestion::Side::Destination,
153 congestion::MetadataOp::HardLink,
154 tokio::fs::hard_link(src, dst),
155 )
156 .await
157 .with_context(|| format!("failed to hard link {src:?} to {dst:?}"))
158 .map_err(|err| Error::new(err, link_summary))?;
159 }
160 Err(error) => {
161 return Err(Error::new(
162 anyhow::Error::from(error)
163 .context(format!("failed to hard link {src:?} to {dst:?}")),
164 link_summary,
165 ));
166 }
167 }
168 prog_track.hard_links_created.inc();
169 link_summary.hard_links_created = 1;
170 Ok(link_summary)
171}
172
173#[instrument(skip(prog_track, settings))]
176pub async fn link(
177 prog_track: &'static progress::Progress,
178 cwd: &std::path::Path,
179 src: &std::path::Path,
180 dst: &std::path::Path,
181 update: &Option<std::path::PathBuf>,
182 settings: &Settings,
183 is_fresh: bool,
184) -> Result<Summary, Error> {
185 if let Some(ref filter) = settings.filter {
187 let src_name = src.file_name().map(std::path::Path::new);
188 if let Some(name) = src_name {
189 let src_metadata = crate::walk::run_metadata_probed(
190 congestion::Side::Source,
191 congestion::MetadataOp::Stat,
192 tokio::fs::symlink_metadata(src),
193 )
194 .await
195 .with_context(|| format!("failed reading metadata from {:?}", &src))
196 .map_err(|err| Error::new(err, Default::default()))?;
197 let is_dir = src_metadata.is_dir();
198 let result = filter.should_include_root_item(name, is_dir);
199 match result {
200 crate::filter::FilterResult::Included => {}
201 result => {
202 let kind = EntryKind::from_metadata(&src_metadata);
203 if let Some(mode) = settings.dry_run {
204 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
205 }
206 kind.inc_skipped(prog_track);
207 return Ok(skipped_summary_for(kind));
208 }
209 }
210 }
211 }
212 link_internal(
213 prog_track, cwd, src, dst, src, update, settings, is_fresh, None,
214 )
215 .await
216}
217#[instrument(skip(prog_track, settings, open_file_guard))]
218#[async_recursion]
219#[allow(clippy::too_many_arguments)]
220async fn link_internal(
221 prog_track: &'static progress::Progress,
222 cwd: &std::path::Path,
223 src: &std::path::Path,
224 dst: &std::path::Path,
225 source_root: &std::path::Path,
226 update: &Option<std::path::PathBuf>,
227 settings: &Settings,
228 mut is_fresh: bool,
229 open_file_guard: Option<throttle::OpenFileGuard>,
230) -> Result<Summary, Error> {
231 let _prog_guard = prog_track.ops.guard();
232 tracing::debug!("reading source metadata");
233 let src_metadata = crate::walk::run_metadata_probed(
234 congestion::Side::Source,
235 congestion::MetadataOp::Stat,
236 tokio::fs::symlink_metadata(src),
237 )
238 .await
239 .with_context(|| format!("failed reading metadata from {:?}", &src))
240 .map_err(|err| Error::new(err, Default::default()))?;
241 let update_metadata_opt = match update {
242 Some(update) => {
243 tracing::debug!("reading 'update' metadata");
244 let update_metadata_res = crate::walk::run_metadata_probed(
245 congestion::Side::Source,
246 congestion::MetadataOp::Stat,
247 tokio::fs::symlink_metadata(update),
248 )
249 .await;
250 match update_metadata_res {
251 Ok(update_metadata) => Some(update_metadata),
252 Err(error) => {
253 if error.kind() == std::io::ErrorKind::NotFound {
254 if settings.update_exclusive {
255 return Ok(Default::default());
257 }
258 None
259 } else {
260 return Err(Error::new(
261 anyhow!("failed reading metadata from {:?}", &update),
262 Default::default(),
263 ));
264 }
265 }
266 }
267 }
268 None => None,
269 };
270 if let Some(update_metadata) = update_metadata_opt.as_ref() {
271 let update = update.as_ref().unwrap();
272 if !copy::is_file_type_same(&src_metadata, update_metadata) {
273 tracing::debug!(
275 "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
276 src,
277 src_metadata.file_type(),
278 update,
279 update_metadata.file_type()
280 );
281 drop(open_file_guard);
290 let copy_summary = copy::copy(
291 prog_track,
292 update,
293 dst,
294 &settings.copy_settings,
295 &settings.preserve,
296 is_fresh,
297 )
298 .await
299 .map_err(|err| {
300 let copy_summary = err.summary;
301 let link_summary = Summary {
302 copy_summary,
303 ..Default::default()
304 };
305 Error::new(err.source, link_summary)
306 })?;
307 return Ok(Summary {
308 copy_summary,
309 ..Default::default()
310 });
311 }
312 if update_metadata.is_file() {
313 if filecmp::metadata_equal(&settings.update_compare, &src_metadata, update_metadata) {
315 tracing::debug!("no change, hard link 'src'");
316 return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
317 }
318 tracing::debug!(
319 "link: {:?} metadata has changed, copying from {:?}",
320 src,
321 update
322 );
323 let _guard = match open_file_guard {
328 Some(g) => g,
329 None => throttle::open_file_permit().await,
330 };
331 return Ok(Summary {
332 copy_summary: copy::copy_file(
333 prog_track,
334 update,
335 dst,
336 update_metadata,
337 &settings.copy_settings,
338 &settings.preserve,
339 is_fresh,
340 )
341 .await
342 .map_err(|err| {
343 let copy_summary = err.summary;
344 let link_summary = Summary {
345 copy_summary,
346 ..Default::default()
347 };
348 Error::new(err.source, link_summary)
349 })?,
350 ..Default::default()
351 });
352 }
353 if update_metadata.is_symlink() {
354 tracing::debug!("'update' is a symlink so just symlink that");
355 let copy_summary = copy::copy(
357 prog_track,
358 update,
359 dst,
360 &settings.copy_settings,
361 &settings.preserve,
362 is_fresh,
363 )
364 .await
365 .map_err(|err| {
366 let copy_summary = err.summary;
367 let link_summary = Summary {
368 copy_summary,
369 ..Default::default()
370 };
371 Error::new(err.source, link_summary)
372 })?;
373 return Ok(Summary {
374 copy_summary,
375 ..Default::default()
376 });
377 }
378 } else {
379 tracing::debug!("no 'update' specified");
381 if src_metadata.is_file() {
382 if settings.dry_run.is_some() {
384 crate::dry_run::report_action("link", src, Some(dst), "file");
385 return Ok(Summary {
386 hard_links_created: 1,
387 ..Default::default()
388 });
389 }
390 return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
391 }
392 if src_metadata.is_symlink() {
393 tracing::debug!("'src' is a symlink so just symlink that");
394 let copy_summary = copy::copy(
396 prog_track,
397 src,
398 dst,
399 &settings.copy_settings,
400 &settings.preserve,
401 is_fresh,
402 )
403 .await
404 .map_err(|err| {
405 let copy_summary = err.summary;
406 let link_summary = Summary {
407 copy_summary,
408 ..Default::default()
409 };
410 Error::new(err.source, link_summary)
411 })?;
412 return Ok(Summary {
413 copy_summary,
414 ..Default::default()
415 });
416 }
417 }
418 if !src_metadata.is_dir() {
419 if settings.copy_settings.skip_specials {
420 tracing::debug!(
421 "skipping special file {:?} (type: {:?})",
422 src,
423 src_metadata.file_type()
424 );
425 if let Some(mode) = settings.dry_run {
426 match mode {
427 crate::config::DryRunMode::Brief => {}
428 crate::config::DryRunMode::All => println!("skip special {:?}", src),
429 crate::config::DryRunMode::Explain => {
430 println!(
431 "skip special {:?} (unsupported file type: {:?})",
432 src,
433 src_metadata.file_type()
434 );
435 }
436 }
437 }
438 prog_track.specials_skipped.inc();
439 return Ok(Summary {
440 copy_summary: CopySummary {
441 specials_skipped: 1,
442 ..Default::default()
443 },
444 ..Default::default()
445 });
446 }
447 return Err(Error::new(
448 anyhow!(
449 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
450 src,
451 dst,
452 src_metadata.file_type()
453 ),
454 Default::default(),
455 ));
456 }
457 assert!(update_metadata_opt.is_none() || update_metadata_opt.as_ref().unwrap().is_dir());
458 tracing::debug!("process contents of 'src' directory");
459 let mut src_entries = tokio::fs::read_dir(src)
460 .await
461 .with_context(|| format!("cannot open directory {src:?} for reading"))
462 .map_err(|err| Error::new(err, Default::default()))?;
463 if settings.dry_run.is_some() {
465 crate::dry_run::report_action("link", src, Some(dst), "dir");
466 }
468 let copy_summary = if settings.dry_run.is_some() {
469 CopySummary {
471 directories_created: 1,
472 ..Default::default()
473 }
474 } else if let Err(error) = crate::walk::run_metadata_probed(
475 congestion::Side::Destination,
476 congestion::MetadataOp::MkDir,
477 tokio::fs::create_dir(dst),
478 )
479 .await
480 {
481 assert!(!is_fresh, "unexpected error creating directory: {:?}", &dst);
482 if settings.copy_settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
483 let dst_metadata = crate::walk::run_metadata_probed(
488 congestion::Side::Destination,
489 congestion::MetadataOp::Stat,
490 tokio::fs::metadata(dst),
491 )
492 .await
493 .with_context(|| format!("failed reading metadata from {:?}", &dst))
494 .map_err(|err| Error::new(err, Default::default()))?;
495 if dst_metadata.is_dir() {
496 tracing::debug!("'dst' is a directory, leaving it as is");
497 CopySummary {
498 directories_unchanged: 1,
499 ..Default::default()
500 }
501 } else {
502 tracing::info!("'dst' is not a directory, removing and creating a new one");
503 let mut copy_summary = CopySummary::default();
504 let rm_summary = rm::rm(
505 prog_track,
506 dst,
507 &rm::Settings {
508 fail_early: settings.copy_settings.fail_early,
509 filter: None,
510 dry_run: None,
511 time_filter: None,
512 },
513 )
514 .await
515 .map_err(|err| {
516 let rm_summary = err.summary;
517 copy_summary.rm_summary = rm_summary;
518 Error::new(
519 err.source,
520 Summary {
521 copy_summary,
522 ..Default::default()
523 },
524 )
525 })?;
526 crate::walk::run_metadata_probed(
527 congestion::Side::Destination,
528 congestion::MetadataOp::MkDir,
529 tokio::fs::create_dir(dst),
530 )
531 .await
532 .with_context(|| format!("cannot create directory {dst:?}"))
533 .map_err(|err| {
534 copy_summary.rm_summary = rm_summary;
535 Error::new(
536 err,
537 Summary {
538 copy_summary,
539 ..Default::default()
540 },
541 )
542 })?;
543 is_fresh = true;
545 CopySummary {
546 rm_summary,
547 directories_created: 1,
548 ..Default::default()
549 }
550 }
551 } else {
552 return Err(error)
553 .with_context(|| format!("cannot create directory {dst:?}"))
554 .map_err(|err| Error::new(err, Default::default()))?;
555 }
556 } else {
557 is_fresh = true;
559 CopySummary {
560 directories_created: 1,
561 ..Default::default()
562 }
563 };
564 let we_created_this_dir = copy_summary.directories_created == 1;
567 let mut link_summary = Summary {
568 copy_summary,
569 ..Default::default()
570 };
571 let mut join_set = tokio::task::JoinSet::new();
572 let errors = crate::error_collector::ErrorCollector::default();
573 let mut processed_files = std::collections::HashSet::new();
575 loop {
577 let Some((src_entry, entry_file_type)) =
578 crate::walk::next_entry_probed(&mut src_entries, congestion::Side::Source, || {
579 format!("failed traversing directory {:?}", &src)
580 })
581 .await
582 .map_err(|err| Error::new(err, link_summary))?
583 else {
584 break;
585 };
586 let cwd_path = cwd.to_owned();
587 let entry_path = src_entry.path();
588 let entry_name = entry_path.file_name().unwrap();
589 let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
590 let entry_is_dir = entry_kind == EntryKind::Dir;
591 let entry_is_symlink = entry_kind == EntryKind::Symlink;
592 let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
594 if let Some(skip_result) =
596 walk::should_skip_entry(&settings.filter, relative_path, entry_is_dir)
597 {
598 if let Some(mode) = settings.dry_run {
599 crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
600 }
601 tracing::debug!("skipping {:?} due to filter", &entry_path);
602 link_summary = link_summary + skipped_summary_for(entry_kind);
603 entry_kind.inc_skipped(prog_track);
604 continue;
605 }
606 if settings.copy_settings.skip_specials && entry_kind == EntryKind::Special {
608 tracing::debug!("skipping special file {:?}", &entry_path);
609 if let Some(mode) = settings.dry_run {
610 match mode {
611 crate::config::DryRunMode::Brief => {}
612 crate::config::DryRunMode::All => {
613 println!("skip special {:?}", &entry_path)
614 }
615 crate::config::DryRunMode::Explain => {
616 println!(
617 "skip special {:?} (unsupported file type: {:?})",
618 &entry_path,
619 entry_file_type.unwrap()
620 );
621 }
622 }
623 }
624 link_summary.copy_summary.specials_skipped += 1;
625 prog_track.specials_skipped.inc();
626 continue;
627 }
628 processed_files.insert(entry_name.to_owned());
629 let dst_path = dst.join(entry_name);
630 let update_path = update.as_ref().map(|s| s.join(entry_name));
631 if let Some(_mode) = settings.dry_run {
633 crate::dry_run::report_action("link", &entry_path, Some(&dst_path), entry_kind.label());
634 if entry_is_dir {
636 let settings = settings.clone();
637 let source_root = source_root.to_owned();
638 let do_link = || async move {
639 link_internal(
640 prog_track,
641 &cwd_path,
642 &entry_path,
643 &dst_path,
644 &source_root,
645 &update_path,
646 &settings,
647 true,
648 None,
649 )
650 .await
651 };
652 join_set.spawn(do_link());
653 } else if entry_is_symlink {
654 link_summary.copy_summary.symlinks_created += 1;
656 } else {
657 link_summary.hard_links_created += 1;
659 }
660 continue;
661 }
662 let settings = settings.clone();
663 let source_root = source_root.to_owned();
664 let entry_is_regular_file = entry_file_type.as_ref().is_some_and(|ft| ft.is_file());
670 let open_file_guard = if entry_is_regular_file {
671 Some(throttle::open_file_permit().await)
672 } else {
673 None
674 };
675 let do_link = || async move {
676 link_internal(
677 prog_track,
678 &cwd_path,
679 &entry_path,
680 &dst_path,
681 &source_root,
682 &update_path,
683 &settings,
684 is_fresh,
685 open_file_guard,
686 )
687 .await
688 };
689 join_set.spawn(do_link());
690 }
691 drop(src_entries);
694 if update_metadata_opt.is_some() {
696 let update = update.as_ref().unwrap();
697 tracing::debug!("process contents of 'update' directory");
698 let mut update_entries = tokio::fs::read_dir(update)
699 .await
700 .with_context(|| format!("cannot open directory {:?} for reading", &update))
701 .map_err(|err| Error::new(err, link_summary))?;
702 loop {
718 let Some((update_entry, _entry_file_type)) = crate::walk::next_entry_probed(
719 &mut update_entries,
720 congestion::Side::Source,
721 || format!("failed traversing directory {:?}", &update),
722 )
723 .await
724 .map_err(|err| Error::new(err, link_summary))?
725 else {
726 break;
727 };
728 let entry_path = update_entry.path();
729 let entry_name = entry_path.file_name().unwrap();
730 if processed_files.contains(entry_name) {
731 continue;
733 }
734 tracing::debug!("found a new entry in the 'update' directory");
735 let dst_path = dst.join(entry_name);
736 let update_path = update.join(entry_name);
737 let settings = settings.clone();
738 let do_copy = || async move {
739 let copy_summary = copy::copy(
740 prog_track,
741 &update_path,
742 &dst_path,
743 &settings.copy_settings,
744 &settings.preserve,
745 is_fresh,
746 )
747 .await
748 .map_err(|err| {
749 link_summary.copy_summary = link_summary.copy_summary + err.summary;
750 Error::new(err.source, link_summary)
751 })?;
752 Ok(Summary {
753 copy_summary,
754 ..Default::default()
755 })
756 };
757 join_set.spawn(do_copy());
758 }
759 drop(update_entries);
762 }
763 while let Some(res) = join_set.join_next().await {
764 match res {
765 Ok(result) => match result {
766 Ok(summary) => link_summary = link_summary + summary,
767 Err(error) => {
768 tracing::error!(
769 "link: {:?} {:?} -> {:?} failed with: {:#}",
770 src,
771 update,
772 dst,
773 &error
774 );
775 link_summary = link_summary + error.summary;
776 if settings.copy_settings.fail_early {
777 return Err(Error::new(error.source, link_summary));
778 }
779 errors.push(error.source);
780 }
781 },
782 Err(error) => {
783 if settings.copy_settings.fail_early {
784 return Err(Error::new(error.into(), link_summary));
785 }
786 errors.push(error.into());
787 }
788 }
789 }
790 let this_dir_count = usize::from(we_created_this_dir);
793 let child_dirs_created = link_summary
794 .copy_summary
795 .directories_created
796 .saturating_sub(this_dir_count);
797 let anything_linked = link_summary.hard_links_created > 0
798 || link_summary.copy_summary.files_copied > 0
799 || link_summary.copy_summary.symlinks_created > 0
800 || child_dirs_created > 0;
801 let relative_path = src.strip_prefix(source_root).unwrap_or(src);
802 let is_root = src == source_root;
803 match check_empty_dir_cleanup(
804 settings.filter.as_ref(),
805 we_created_this_dir,
806 anything_linked,
807 relative_path,
808 is_root,
809 settings.dry_run.is_some(),
810 ) {
811 EmptyDirAction::Keep => { }
812 EmptyDirAction::DryRunSkip => {
813 tracing::debug!(
814 "dry-run: directory {:?} would not be created (nothing to link inside)",
815 &dst
816 );
817 link_summary.copy_summary.directories_created = 0;
818 return Ok(link_summary);
819 }
820 EmptyDirAction::Remove => {
821 tracing::debug!(
822 "directory {:?} has nothing to link inside, removing empty directory",
823 &dst
824 );
825 match crate::walk::run_metadata_probed(
826 congestion::Side::Destination,
827 congestion::MetadataOp::RmDir,
828 tokio::fs::remove_dir(dst),
829 )
830 .await
831 {
832 Ok(()) => {
833 link_summary.copy_summary.directories_created = 0;
834 return Ok(link_summary);
835 }
836 Err(err) => {
837 tracing::debug!(
839 "failed to remove empty directory {:?}: {:#}, keeping",
840 &dst,
841 &err
842 );
843 }
845 }
846 }
847 }
848 tracing::debug!("set 'dst' directory metadata");
853 let metadata_result = if settings.dry_run.is_some() {
854 Ok(()) } else {
856 let preserve_metadata = if let Some(update_metadata) = update_metadata_opt.as_ref() {
857 update_metadata
858 } else {
859 &src_metadata
860 };
861 preserve::set_dir_metadata(&settings.preserve, preserve_metadata, dst).await
862 };
863 if errors.has_errors() {
864 if let Err(metadata_err) = metadata_result {
866 tracing::error!(
867 "link: {:?} {:?} -> {:?} failed to set directory metadata: {:#}",
868 src,
869 update,
870 dst,
871 &metadata_err
872 );
873 }
874 return Err(Error::new(errors.into_error().unwrap(), link_summary));
876 }
877 metadata_result.map_err(|err| Error::new(err, link_summary))?;
879 Ok(link_summary)
880}
881
882#[cfg(test)]
883mod link_tests {
884 use crate::testutils;
885 use std::os::unix::fs::PermissionsExt;
886 use tracing_test::traced_test;
887
888 use super::*;
889
890 static PROGRESS: std::sync::LazyLock<progress::Progress> =
891 std::sync::LazyLock::new(progress::Progress::new);
892
893 fn common_settings(dereference: bool, overwrite: bool) -> Settings {
894 Settings {
895 copy_settings: CopySettings {
896 dereference,
897 fail_early: false,
898 overwrite,
899 overwrite_compare: filecmp::MetadataCmpSettings {
900 size: true,
901 mtime: true,
902 ..Default::default()
903 },
904 overwrite_filter: None,
905 ignore_existing: false,
906 chunk_size: 0,
907 skip_specials: false,
908 remote_copy_buffer_size: 0,
909 filter: None,
910 dry_run: None,
911 },
912 update_compare: filecmp::MetadataCmpSettings {
913 size: true,
914 mtime: true,
915 ..Default::default()
916 },
917 update_exclusive: false,
918 filter: None,
919 dry_run: None,
920 preserve: preserve::preserve_all(),
921 }
922 }
923
924 #[tokio::test]
925 #[traced_test]
926 async fn test_basic_link() -> Result<(), anyhow::Error> {
927 let tmp_dir = testutils::setup_test_dir().await?;
928 let test_path = tmp_dir.as_path();
929 let summary = link(
930 &PROGRESS,
931 test_path,
932 &test_path.join("foo"),
933 &test_path.join("bar"),
934 &None,
935 &common_settings(false, false),
936 false,
937 )
938 .await?;
939 assert_eq!(summary.hard_links_created, 5);
940 assert_eq!(summary.copy_summary.files_copied, 0);
941 assert_eq!(summary.copy_summary.symlinks_created, 2);
942 assert_eq!(summary.copy_summary.directories_created, 3);
943 testutils::check_dirs_identical(
944 &test_path.join("foo"),
945 &test_path.join("bar"),
946 testutils::FileEqualityCheck::Timestamp,
947 )
948 .await?;
949 Ok(())
950 }
951
952 #[tokio::test]
953 #[traced_test]
954 async fn test_basic_link_update() -> Result<(), anyhow::Error> {
955 let tmp_dir = testutils::setup_test_dir().await?;
956 let test_path = tmp_dir.as_path();
957 let summary = link(
958 &PROGRESS,
959 test_path,
960 &test_path.join("foo"),
961 &test_path.join("bar"),
962 &Some(test_path.join("foo")),
963 &common_settings(false, false),
964 false,
965 )
966 .await?;
967 assert_eq!(summary.hard_links_created, 5);
968 assert_eq!(summary.copy_summary.files_copied, 0);
969 assert_eq!(summary.copy_summary.symlinks_created, 2);
970 assert_eq!(summary.copy_summary.directories_created, 3);
971 testutils::check_dirs_identical(
972 &test_path.join("foo"),
973 &test_path.join("bar"),
974 testutils::FileEqualityCheck::Timestamp,
975 )
976 .await?;
977 Ok(())
978 }
979
980 #[tokio::test]
981 #[traced_test]
982 async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
983 let tmp_dir = testutils::setup_test_dir().await?;
984 tokio::fs::create_dir(tmp_dir.join("baz")).await?;
985 let test_path = tmp_dir.as_path();
986 let summary = link(
987 &PROGRESS,
988 test_path,
989 &test_path.join("baz"), &test_path.join("bar"),
991 &Some(test_path.join("foo")),
992 &common_settings(false, false),
993 false,
994 )
995 .await?;
996 assert_eq!(summary.hard_links_created, 0);
997 assert_eq!(summary.copy_summary.files_copied, 5);
998 assert_eq!(summary.copy_summary.symlinks_created, 2);
999 assert_eq!(summary.copy_summary.directories_created, 3);
1000 testutils::check_dirs_identical(
1001 &test_path.join("foo"),
1002 &test_path.join("bar"),
1003 testutils::FileEqualityCheck::Timestamp,
1004 )
1005 .await?;
1006 Ok(())
1007 }
1008
1009 #[tokio::test]
1010 #[traced_test]
1011 async fn test_link_destination_permission_error_includes_root_cause()
1012 -> Result<(), anyhow::Error> {
1013 let tmp_dir = testutils::setup_test_dir().await?;
1014 let test_path = tmp_dir.as_path();
1015 let readonly_parent = test_path.join("readonly_dest");
1016 tokio::fs::create_dir(&readonly_parent).await?;
1017 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
1018 .await?;
1019
1020 let mut settings = common_settings(false, false);
1021 settings.copy_settings.fail_early = true;
1022
1023 let result = link(
1024 &PROGRESS,
1025 test_path,
1026 &test_path.join("foo"),
1027 &readonly_parent.join("bar"),
1028 &None,
1029 &settings,
1030 false,
1031 )
1032 .await;
1033
1034 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
1036 .await?;
1037
1038 assert!(result.is_err(), "link into read-only parent should fail");
1039 let err = result.unwrap_err();
1040 let err_msg = format!("{:#}", err.source);
1041 assert!(
1042 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
1043 "Error message must include permission denied text. Got: {}",
1044 err_msg
1045 );
1046 Ok(())
1047 }
1048
1049 #[tokio::test]
1050 #[traced_test]
1051 async fn hard_link_file_into_readonly_parent_returns_error() -> Result<(), anyhow::Error> {
1052 let tmp_dir = testutils::setup_test_dir().await?;
1055 let src = tmp_dir.join("src.txt");
1056 tokio::fs::write(&src, "content").await?;
1057 let readonly_parent = tmp_dir.join("readonly_parent");
1058 tokio::fs::create_dir(&readonly_parent).await?;
1059 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
1060 .await?;
1061 let dst = readonly_parent.join("dst.txt");
1062 let settings = common_settings(false, false);
1063 let result = link(&PROGRESS, &tmp_dir, &src, &dst, &None, &settings, false).await;
1064 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
1065 .await?;
1066 let err = result.expect_err("link into read-only parent should fail");
1067 assert_eq!(err.summary.hard_links_created, 0);
1068 let err_msg = format!("{:#}", err.source);
1069 assert!(
1070 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
1071 "error should include root cause, got: {err_msg}"
1072 );
1073 Ok(())
1074 }
1075
1076 pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
1077 let foo_path = tmp_dir.join("update");
1083 tokio::fs::create_dir(&foo_path).await.unwrap();
1084 tokio::fs::write(foo_path.join("0.txt"), "0-new")
1085 .await
1086 .unwrap();
1087 let bar_path = foo_path.join("bar");
1088 tokio::fs::create_dir(&bar_path).await.unwrap();
1089 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1090 .await
1091 .unwrap();
1092 tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
1093 .await
1094 .unwrap();
1095 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1096 Ok(())
1097 }
1098
1099 #[tokio::test]
1100 #[traced_test]
1101 async fn test_link_update() -> Result<(), anyhow::Error> {
1102 let tmp_dir = testutils::setup_test_dir().await?;
1103 setup_update_dir(&tmp_dir).await?;
1104 let test_path = tmp_dir.as_path();
1105 let summary = link(
1106 &PROGRESS,
1107 test_path,
1108 &test_path.join("foo"),
1109 &test_path.join("bar"),
1110 &Some(test_path.join("update")),
1111 &common_settings(false, false),
1112 false,
1113 )
1114 .await?;
1115 assert_eq!(summary.hard_links_created, 2);
1116 assert_eq!(summary.copy_summary.files_copied, 2);
1117 assert_eq!(summary.copy_summary.symlinks_created, 3);
1118 assert_eq!(summary.copy_summary.directories_created, 3);
1119 testutils::check_dirs_identical(
1121 &test_path.join("foo").join("baz"),
1122 &test_path.join("bar").join("baz"),
1123 testutils::FileEqualityCheck::HardLink,
1124 )
1125 .await?;
1126 testutils::check_dirs_identical(
1128 &test_path.join("update"),
1129 &test_path.join("bar"),
1130 testutils::FileEqualityCheck::Timestamp,
1131 )
1132 .await?;
1133 Ok(())
1134 }
1135
1136 #[tokio::test]
1137 #[traced_test]
1138 async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
1139 let tmp_dir = testutils::setup_test_dir().await?;
1140 setup_update_dir(&tmp_dir).await?;
1141 let test_path = tmp_dir.as_path();
1142 let mut settings = common_settings(false, false);
1143 settings.update_exclusive = true;
1144 let summary = link(
1145 &PROGRESS,
1146 test_path,
1147 &test_path.join("foo"),
1148 &test_path.join("bar"),
1149 &Some(test_path.join("update")),
1150 &settings,
1151 false,
1152 )
1153 .await?;
1154 assert_eq!(summary.hard_links_created, 0);
1160 assert_eq!(summary.copy_summary.files_copied, 2);
1161 assert_eq!(summary.copy_summary.symlinks_created, 1);
1162 assert_eq!(summary.copy_summary.directories_created, 2);
1163 testutils::check_dirs_identical(
1165 &test_path.join("update"),
1166 &test_path.join("bar"),
1167 testutils::FileEqualityCheck::Timestamp,
1168 )
1169 .await?;
1170 Ok(())
1171 }
1172
1173 async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
1174 let tmp_dir = testutils::setup_test_dir().await?;
1175 let test_path = tmp_dir.as_path();
1176 let summary = link(
1177 &PROGRESS,
1178 test_path,
1179 &test_path.join("foo"),
1180 &test_path.join("bar"),
1181 &None,
1182 &common_settings(false, false),
1183 false,
1184 )
1185 .await?;
1186 assert_eq!(summary.hard_links_created, 5);
1187 assert_eq!(summary.copy_summary.symlinks_created, 2);
1188 assert_eq!(summary.copy_summary.directories_created, 3);
1189 Ok(tmp_dir)
1190 }
1191
1192 #[tokio::test]
1193 #[traced_test]
1194 async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
1195 let tmp_dir = setup_test_dir_and_link().await?;
1196 let output_path = &tmp_dir.join("bar");
1197 {
1198 let summary = rm::rm(
1209 &PROGRESS,
1210 &output_path.join("bar"),
1211 &rm::Settings {
1212 fail_early: false,
1213 filter: None,
1214 dry_run: None,
1215 time_filter: None,
1216 },
1217 )
1218 .await?
1219 + rm::rm(
1220 &PROGRESS,
1221 &output_path.join("baz").join("5.txt"),
1222 &rm::Settings {
1223 fail_early: false,
1224 filter: None,
1225 dry_run: None,
1226 time_filter: None,
1227 },
1228 )
1229 .await?;
1230 assert_eq!(summary.files_removed, 3);
1231 assert_eq!(summary.symlinks_removed, 1);
1232 assert_eq!(summary.directories_removed, 1);
1233 }
1234 let summary = link(
1235 &PROGRESS,
1236 &tmp_dir,
1237 &tmp_dir.join("foo"),
1238 output_path,
1239 &None,
1240 &common_settings(false, true), false,
1242 )
1243 .await?;
1244 assert_eq!(summary.hard_links_created, 3);
1245 assert_eq!(summary.copy_summary.symlinks_created, 1);
1246 assert_eq!(summary.copy_summary.directories_created, 1);
1247 testutils::check_dirs_identical(
1248 &tmp_dir.join("foo"),
1249 output_path,
1250 testutils::FileEqualityCheck::Timestamp,
1251 )
1252 .await?;
1253 Ok(())
1254 }
1255
1256 #[tokio::test]
1257 #[traced_test]
1258 async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
1259 let tmp_dir = setup_test_dir_and_link().await?;
1260 let output_path = &tmp_dir.join("bar");
1261 {
1262 let summary = rm::rm(
1273 &PROGRESS,
1274 &output_path.join("bar"),
1275 &rm::Settings {
1276 fail_early: false,
1277 filter: None,
1278 dry_run: None,
1279 time_filter: None,
1280 },
1281 )
1282 .await?
1283 + rm::rm(
1284 &PROGRESS,
1285 &output_path.join("baz").join("5.txt"),
1286 &rm::Settings {
1287 fail_early: false,
1288 filter: None,
1289 dry_run: None,
1290 time_filter: None,
1291 },
1292 )
1293 .await?;
1294 assert_eq!(summary.files_removed, 3);
1295 assert_eq!(summary.symlinks_removed, 1);
1296 assert_eq!(summary.directories_removed, 1);
1297 }
1298 setup_update_dir(&tmp_dir).await?;
1299 let summary = link(
1305 &PROGRESS,
1306 &tmp_dir,
1307 &tmp_dir.join("foo"),
1308 output_path,
1309 &Some(tmp_dir.join("update")),
1310 &common_settings(false, true), false,
1312 )
1313 .await?;
1314 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);
1318 testutils::check_dirs_identical(
1320 &tmp_dir.join("foo").join("baz"),
1321 &tmp_dir.join("bar").join("baz"),
1322 testutils::FileEqualityCheck::HardLink,
1323 )
1324 .await?;
1325 testutils::check_dirs_identical(
1327 &tmp_dir.join("update"),
1328 &tmp_dir.join("bar"),
1329 testutils::FileEqualityCheck::Timestamp,
1330 )
1331 .await?;
1332 Ok(())
1333 }
1334
1335 #[tokio::test]
1336 #[traced_test]
1337 async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
1338 let tmp_dir = setup_test_dir_and_link().await?;
1339 let output_path = &tmp_dir.join("bar");
1340 {
1341 let bar_path = output_path.join("bar");
1350 let summary = rm::rm(
1351 &PROGRESS,
1352 &bar_path.join("1.txt"),
1353 &rm::Settings {
1354 fail_early: false,
1355 filter: None,
1356 dry_run: None,
1357 time_filter: None,
1358 },
1359 )
1360 .await?
1361 + rm::rm(
1362 &PROGRESS,
1363 &bar_path.join("2.txt"),
1364 &rm::Settings {
1365 fail_early: false,
1366 filter: None,
1367 dry_run: None,
1368 time_filter: None,
1369 },
1370 )
1371 .await?
1372 + rm::rm(
1373 &PROGRESS,
1374 &bar_path.join("3.txt"),
1375 &rm::Settings {
1376 fail_early: false,
1377 filter: None,
1378 dry_run: None,
1379 time_filter: None,
1380 },
1381 )
1382 .await?
1383 + rm::rm(
1384 &PROGRESS,
1385 &output_path.join("baz"),
1386 &rm::Settings {
1387 fail_early: false,
1388 filter: None,
1389 dry_run: None,
1390 time_filter: None,
1391 },
1392 )
1393 .await?;
1394 assert_eq!(summary.files_removed, 4);
1395 assert_eq!(summary.symlinks_removed, 2);
1396 assert_eq!(summary.directories_removed, 1);
1397 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1399 .await
1400 .unwrap();
1401 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1402 .await
1403 .unwrap();
1404 tokio::fs::create_dir(&bar_path.join("3.txt"))
1405 .await
1406 .unwrap();
1407 tokio::fs::write(&output_path.join("baz"), "baz")
1408 .await
1409 .unwrap();
1410 }
1411 let summary = link(
1412 &PROGRESS,
1413 &tmp_dir,
1414 &tmp_dir.join("foo"),
1415 output_path,
1416 &None,
1417 &common_settings(false, true), false,
1419 )
1420 .await?;
1421 assert_eq!(summary.hard_links_created, 4);
1422 assert_eq!(summary.copy_summary.files_copied, 0);
1423 assert_eq!(summary.copy_summary.symlinks_created, 2);
1424 assert_eq!(summary.copy_summary.directories_created, 1);
1425 testutils::check_dirs_identical(
1426 &tmp_dir.join("foo"),
1427 &tmp_dir.join("bar"),
1428 testutils::FileEqualityCheck::HardLink,
1429 )
1430 .await?;
1431 Ok(())
1432 }
1433
1434 #[tokio::test]
1435 #[traced_test]
1436 async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
1437 let tmp_dir = setup_test_dir_and_link().await?;
1438 let output_path = &tmp_dir.join("bar");
1439 {
1440 let bar_path = output_path.join("bar");
1449 let summary = rm::rm(
1450 &PROGRESS,
1451 &bar_path.join("1.txt"),
1452 &rm::Settings {
1453 fail_early: false,
1454 filter: None,
1455 dry_run: None,
1456 time_filter: None,
1457 },
1458 )
1459 .await?
1460 + rm::rm(
1461 &PROGRESS,
1462 &bar_path.join("2.txt"),
1463 &rm::Settings {
1464 fail_early: false,
1465 filter: None,
1466 dry_run: None,
1467 time_filter: None,
1468 },
1469 )
1470 .await?
1471 + rm::rm(
1472 &PROGRESS,
1473 &bar_path.join("3.txt"),
1474 &rm::Settings {
1475 fail_early: false,
1476 filter: None,
1477 dry_run: None,
1478 time_filter: None,
1479 },
1480 )
1481 .await?
1482 + rm::rm(
1483 &PROGRESS,
1484 &output_path.join("baz"),
1485 &rm::Settings {
1486 fail_early: false,
1487 filter: None,
1488 dry_run: None,
1489 time_filter: None,
1490 },
1491 )
1492 .await?;
1493 assert_eq!(summary.files_removed, 4);
1494 assert_eq!(summary.symlinks_removed, 2);
1495 assert_eq!(summary.directories_removed, 1);
1496 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1498 .await
1499 .unwrap();
1500 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1501 .await
1502 .unwrap();
1503 tokio::fs::create_dir(&bar_path.join("3.txt"))
1504 .await
1505 .unwrap();
1506 tokio::fs::write(&output_path.join("baz"), "baz")
1507 .await
1508 .unwrap();
1509 }
1510 let source_path = &tmp_dir.join("foo");
1511 tokio::fs::set_permissions(
1513 &source_path.join("baz"),
1514 std::fs::Permissions::from_mode(0o000),
1515 )
1516 .await?;
1517 match link(
1521 &PROGRESS,
1522 &tmp_dir,
1523 &tmp_dir.join("foo"),
1524 output_path,
1525 &None,
1526 &common_settings(false, true), false,
1528 )
1529 .await
1530 {
1531 Ok(_) => panic!("Expected the link to error!"),
1532 Err(error) => {
1533 tracing::info!("{}", &error);
1534 assert_eq!(error.summary.hard_links_created, 3);
1535 assert_eq!(error.summary.copy_summary.files_copied, 0);
1536 assert_eq!(error.summary.copy_summary.symlinks_created, 0);
1537 assert_eq!(error.summary.copy_summary.directories_created, 0);
1538 assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
1539 assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
1540 assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
1541 }
1542 }
1543 Ok(())
1544 }
1545
1546 #[tokio::test]
1550 #[traced_test]
1551 async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
1552 let tmp_dir = testutils::create_temp_dir().await?;
1553 let test_path = tmp_dir.as_path();
1554 let src_dir = test_path.join("src");
1556 tokio::fs::create_dir(&src_dir).await?;
1557 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
1558 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
1560 let unreadable_subdir = src_dir.join("unreadable_subdir");
1563 tokio::fs::create_dir(&unreadable_subdir).await?;
1564 tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
1565 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
1566 .await?;
1567 let dst_dir = test_path.join("dst");
1568 let result = link(
1570 &PROGRESS,
1571 test_path,
1572 &src_dir,
1573 &dst_dir,
1574 &None,
1575 &common_settings(false, false),
1576 false,
1577 )
1578 .await;
1579 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
1581 .await?;
1582 assert!(
1584 result.is_err(),
1585 "link should fail due to unreadable subdirectory"
1586 );
1587 let error = result.unwrap_err();
1588 assert_eq!(error.summary.hard_links_created, 1);
1590 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
1592 assert!(dst_metadata.is_dir());
1593 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
1594 assert_eq!(
1595 actual_mode, 0o750,
1596 "directory should have preserved source permissions (0o750), got {:o}",
1597 actual_mode
1598 );
1599 Ok(())
1600 }
1601 mod filter_tests {
1602 use super::*;
1603 use crate::filter::FilterSettings;
1604 #[tokio::test]
1606 #[traced_test]
1607 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
1608 let tmp_dir = testutils::setup_test_dir().await?;
1609 let test_path = tmp_dir.as_path();
1610 let mut filter = FilterSettings::new();
1612 filter.add_include("bar/*.txt").unwrap();
1613 let summary = link(
1614 &PROGRESS,
1615 test_path,
1616 &test_path.join("foo"),
1617 &test_path.join("dst"),
1618 &None,
1619 &Settings {
1620 copy_settings: CopySettings {
1621 dereference: false,
1622 fail_early: false,
1623 overwrite: false,
1624 overwrite_compare: Default::default(),
1625 overwrite_filter: None,
1626 ignore_existing: false,
1627 chunk_size: 0,
1628 skip_specials: false,
1629 remote_copy_buffer_size: 0,
1630 filter: None,
1631 dry_run: None,
1632 },
1633 update_compare: Default::default(),
1634 update_exclusive: false,
1635 filter: Some(filter),
1636 dry_run: None,
1637 preserve: preserve::preserve_all(),
1638 },
1639 false,
1640 )
1641 .await?;
1642 assert_eq!(
1644 summary.hard_links_created, 3,
1645 "should link 3 files matching bar/*.txt"
1646 );
1647 assert!(
1649 test_path.join("dst/bar/1.txt").exists(),
1650 "bar/1.txt should be linked"
1651 );
1652 assert!(
1653 test_path.join("dst/bar/2.txt").exists(),
1654 "bar/2.txt should be linked"
1655 );
1656 assert!(
1657 test_path.join("dst/bar/3.txt").exists(),
1658 "bar/3.txt should be linked"
1659 );
1660 assert!(
1662 !test_path.join("dst/0.txt").exists(),
1663 "0.txt should not be linked"
1664 );
1665 Ok(())
1666 }
1667 #[tokio::test]
1669 #[traced_test]
1670 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
1671 let tmp_dir = testutils::setup_test_dir().await?;
1672 let test_path = tmp_dir.as_path();
1673 let mut filter = FilterSettings::new();
1675 filter.add_exclude("*.txt").unwrap();
1676 let summary = link(
1677 &PROGRESS,
1678 test_path,
1679 &test_path.join("foo/0.txt"), &test_path.join("dst/0.txt"),
1681 &None,
1682 &Settings {
1683 copy_settings: CopySettings {
1684 dereference: false,
1685 fail_early: false,
1686 overwrite: false,
1687 overwrite_compare: Default::default(),
1688 overwrite_filter: None,
1689 ignore_existing: false,
1690 chunk_size: 0,
1691 skip_specials: false,
1692 remote_copy_buffer_size: 0,
1693 filter: None,
1694 dry_run: None,
1695 },
1696 update_compare: Default::default(),
1697 update_exclusive: false,
1698 filter: Some(filter),
1699 dry_run: None,
1700 preserve: preserve::preserve_all(),
1701 },
1702 false,
1703 )
1704 .await?;
1705 assert_eq!(
1707 summary.hard_links_created, 0,
1708 "file matching exclude pattern should not be linked"
1709 );
1710 assert!(
1711 !test_path.join("dst/0.txt").exists(),
1712 "excluded file should not exist at destination"
1713 );
1714 Ok(())
1715 }
1716 #[tokio::test]
1718 #[traced_test]
1719 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
1720 let test_path = testutils::create_temp_dir().await?;
1721 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
1723 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
1724 let mut filter = FilterSettings::new();
1726 filter.add_exclude("*_dir/").unwrap();
1727 let result = link(
1728 &PROGRESS,
1729 &test_path,
1730 &test_path.join("excluded_dir"),
1731 &test_path.join("dst"),
1732 &None,
1733 &Settings {
1734 copy_settings: CopySettings {
1735 dereference: false,
1736 fail_early: false,
1737 overwrite: false,
1738 overwrite_compare: Default::default(),
1739 overwrite_filter: None,
1740 ignore_existing: false,
1741 chunk_size: 0,
1742 skip_specials: false,
1743 remote_copy_buffer_size: 0,
1744 filter: None,
1745 dry_run: None,
1746 },
1747 update_compare: Default::default(),
1748 update_exclusive: false,
1749 filter: Some(filter),
1750 dry_run: None,
1751 preserve: preserve::preserve_all(),
1752 },
1753 false,
1754 )
1755 .await?;
1756 assert_eq!(
1758 result.copy_summary.directories_created, 0,
1759 "root directory matching exclude should not be created"
1760 );
1761 assert!(
1762 !test_path.join("dst").exists(),
1763 "excluded root directory should not exist at destination"
1764 );
1765 Ok(())
1766 }
1767 #[tokio::test]
1769 #[traced_test]
1770 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
1771 let test_path = testutils::create_temp_dir().await?;
1772 tokio::fs::write(test_path.join("target.txt"), "content").await?;
1774 tokio::fs::symlink(
1775 test_path.join("target.txt"),
1776 test_path.join("excluded_link"),
1777 )
1778 .await?;
1779 let mut filter = FilterSettings::new();
1781 filter.add_exclude("*_link").unwrap();
1782 let result = link(
1783 &PROGRESS,
1784 &test_path,
1785 &test_path.join("excluded_link"),
1786 &test_path.join("dst"),
1787 &None,
1788 &Settings {
1789 copy_settings: CopySettings {
1790 dereference: false,
1791 fail_early: false,
1792 overwrite: false,
1793 overwrite_compare: Default::default(),
1794 overwrite_filter: None,
1795 ignore_existing: false,
1796 chunk_size: 0,
1797 skip_specials: false,
1798 remote_copy_buffer_size: 0,
1799 filter: None,
1800 dry_run: None,
1801 },
1802 update_compare: Default::default(),
1803 update_exclusive: false,
1804 filter: Some(filter),
1805 dry_run: None,
1806 preserve: preserve::preserve_all(),
1807 },
1808 false,
1809 )
1810 .await?;
1811 assert_eq!(
1813 result.copy_summary.symlinks_created, 0,
1814 "root symlink matching exclude should not be created"
1815 );
1816 assert!(
1817 !test_path.join("dst").exists(),
1818 "excluded root symlink should not exist at destination"
1819 );
1820 Ok(())
1821 }
1822 #[tokio::test]
1824 #[traced_test]
1825 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
1826 let tmp_dir = testutils::setup_test_dir().await?;
1827 let test_path = tmp_dir.as_path();
1828 let mut filter = FilterSettings::new();
1835 filter.add_include("bar/*.txt").unwrap();
1836 filter.add_exclude("bar/2.txt").unwrap();
1837 let summary = link(
1838 &PROGRESS,
1839 test_path,
1840 &test_path.join("foo"),
1841 &test_path.join("dst"),
1842 &None,
1843 &Settings {
1844 copy_settings: CopySettings {
1845 dereference: false,
1846 fail_early: false,
1847 overwrite: false,
1848 overwrite_compare: Default::default(),
1849 overwrite_filter: None,
1850 ignore_existing: false,
1851 chunk_size: 0,
1852 skip_specials: false,
1853 remote_copy_buffer_size: 0,
1854 filter: None,
1855 dry_run: None,
1856 },
1857 update_compare: Default::default(),
1858 update_exclusive: false,
1859 filter: Some(filter),
1860 dry_run: None,
1861 preserve: preserve::preserve_all(),
1862 },
1863 false,
1864 )
1865 .await?;
1866 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1869 assert_eq!(
1870 summary.copy_summary.files_skipped, 2,
1871 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
1872 );
1873 assert!(
1875 test_path.join("dst/bar/1.txt").exists(),
1876 "bar/1.txt should be linked"
1877 );
1878 assert!(
1879 !test_path.join("dst/bar/2.txt").exists(),
1880 "bar/2.txt should be excluded"
1881 );
1882 assert!(
1883 test_path.join("dst/bar/3.txt").exists(),
1884 "bar/3.txt should be linked"
1885 );
1886 Ok(())
1887 }
1888 #[tokio::test]
1890 #[traced_test]
1891 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
1892 let tmp_dir = testutils::setup_test_dir().await?;
1893 let test_path = tmp_dir.as_path();
1894 let mut filter = FilterSettings::new();
1901 filter.add_exclude("bar/").unwrap();
1902 let summary = link(
1903 &PROGRESS,
1904 test_path,
1905 &test_path.join("foo"),
1906 &test_path.join("dst"),
1907 &None,
1908 &Settings {
1909 copy_settings: CopySettings {
1910 dereference: false,
1911 fail_early: false,
1912 overwrite: false,
1913 overwrite_compare: Default::default(),
1914 overwrite_filter: None,
1915 ignore_existing: false,
1916 chunk_size: 0,
1917 skip_specials: false,
1918 remote_copy_buffer_size: 0,
1919 filter: None,
1920 dry_run: None,
1921 },
1922 update_compare: Default::default(),
1923 update_exclusive: false,
1924 filter: Some(filter),
1925 dry_run: None,
1926 preserve: preserve::preserve_all(),
1927 },
1928 false,
1929 )
1930 .await?;
1931 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1935 assert_eq!(
1936 summary.copy_summary.symlinks_created, 2,
1937 "should copy 2 symlinks"
1938 );
1939 assert_eq!(
1940 summary.copy_summary.directories_skipped, 1,
1941 "should skip 1 directory (bar)"
1942 );
1943 assert!(
1945 !test_path.join("dst/bar").exists(),
1946 "bar directory should not be linked"
1947 );
1948 Ok(())
1949 }
1950 #[tokio::test]
1953 #[traced_test]
1954 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
1955 let test_path = testutils::create_temp_dir().await?;
1956 let src_path = test_path.join("src");
1962 tokio::fs::create_dir(&src_path).await?;
1963 tokio::fs::write(src_path.join("foo"), "content").await?;
1964 tokio::fs::write(src_path.join("bar"), "content").await?;
1965 tokio::fs::create_dir(src_path.join("baz")).await?;
1966 let mut filter = FilterSettings::new();
1968 filter.add_include("foo").unwrap();
1969 let summary = link(
1970 &PROGRESS,
1971 &test_path,
1972 &src_path,
1973 &test_path.join("dst"),
1974 &None,
1975 &Settings {
1976 copy_settings: copy::Settings {
1977 dereference: false,
1978 fail_early: false,
1979 overwrite: false,
1980 overwrite_compare: Default::default(),
1981 overwrite_filter: None,
1982 ignore_existing: false,
1983 chunk_size: 0,
1984 skip_specials: false,
1985 remote_copy_buffer_size: 0,
1986 filter: None,
1987 dry_run: None,
1988 },
1989 update_compare: Default::default(),
1990 update_exclusive: false,
1991 filter: Some(filter),
1992 dry_run: None,
1993 preserve: preserve::preserve_all(),
1994 },
1995 false,
1996 )
1997 .await?;
1998 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2000 assert_eq!(
2001 summary.copy_summary.directories_created, 1,
2002 "should create only root directory (not empty 'baz')"
2003 );
2004 assert!(
2006 test_path.join("dst").join("foo").exists(),
2007 "foo should be linked"
2008 );
2009 assert!(
2011 !test_path.join("dst").join("bar").exists(),
2012 "bar should not be linked"
2013 );
2014 assert!(
2016 !test_path.join("dst").join("baz").exists(),
2017 "empty baz directory should NOT be created"
2018 );
2019 Ok(())
2020 }
2021 #[tokio::test]
2024 #[traced_test]
2025 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
2026 let test_path = testutils::create_temp_dir().await?;
2027 let src_path = test_path.join("src");
2034 tokio::fs::create_dir(&src_path).await?;
2035 tokio::fs::write(src_path.join("foo"), "content").await?;
2036 tokio::fs::create_dir(src_path.join("baz")).await?;
2037 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
2038 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
2039 let mut filter = FilterSettings::new();
2041 filter.add_include("foo").unwrap();
2042 let summary = link(
2043 &PROGRESS,
2044 &test_path,
2045 &src_path,
2046 &test_path.join("dst"),
2047 &None,
2048 &Settings {
2049 copy_settings: copy::Settings {
2050 dereference: false,
2051 fail_early: false,
2052 overwrite: false,
2053 overwrite_compare: Default::default(),
2054 overwrite_filter: None,
2055 ignore_existing: false,
2056 chunk_size: 0,
2057 skip_specials: false,
2058 remote_copy_buffer_size: 0,
2059 filter: None,
2060 dry_run: None,
2061 },
2062 update_compare: Default::default(),
2063 update_exclusive: false,
2064 filter: Some(filter),
2065 dry_run: None,
2066 preserve: preserve::preserve_all(),
2067 },
2068 false,
2069 )
2070 .await?;
2071 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2073 assert_eq!(
2074 summary.copy_summary.files_skipped, 2,
2075 "should skip 2 files (qux and quux)"
2076 );
2077 assert_eq!(
2078 summary.copy_summary.directories_created, 1,
2079 "should create only root directory (not 'baz' with non-matching content)"
2080 );
2081 assert!(
2083 test_path.join("dst").join("foo").exists(),
2084 "foo should be linked"
2085 );
2086 assert!(
2088 !test_path.join("dst").join("baz").exists(),
2089 "baz directory should NOT be created (no matching content inside)"
2090 );
2091 Ok(())
2092 }
2093 #[tokio::test]
2096 #[traced_test]
2097 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
2098 let test_path = testutils::create_temp_dir().await?;
2099 let src_path = test_path.join("src");
2105 tokio::fs::create_dir(&src_path).await?;
2106 tokio::fs::write(src_path.join("foo"), "content").await?;
2107 tokio::fs::write(src_path.join("bar"), "content").await?;
2108 tokio::fs::create_dir(src_path.join("baz")).await?;
2109 let mut filter = FilterSettings::new();
2111 filter.add_include("foo").unwrap();
2112 let summary = link(
2113 &PROGRESS,
2114 &test_path,
2115 &src_path,
2116 &test_path.join("dst"),
2117 &None,
2118 &Settings {
2119 copy_settings: copy::Settings {
2120 dereference: false,
2121 fail_early: false,
2122 overwrite: false,
2123 overwrite_compare: Default::default(),
2124 overwrite_filter: None,
2125 ignore_existing: false,
2126 chunk_size: 0,
2127 skip_specials: false,
2128 remote_copy_buffer_size: 0,
2129 filter: None,
2130 dry_run: None,
2131 },
2132 update_compare: Default::default(),
2133 update_exclusive: false,
2134 filter: Some(filter),
2135 dry_run: Some(crate::config::DryRunMode::Explain),
2136 preserve: preserve::preserve_all(),
2137 },
2138 false,
2139 )
2140 .await?;
2141 assert_eq!(
2143 summary.hard_links_created, 1,
2144 "should report only 'foo' would be linked"
2145 );
2146 assert_eq!(
2147 summary.copy_summary.directories_created, 1,
2148 "should report only root directory would be created (not empty 'baz')"
2149 );
2150 assert!(
2152 !test_path.join("dst").exists(),
2153 "dst should not exist in dry-run"
2154 );
2155 Ok(())
2156 }
2157 #[tokio::test]
2160 #[traced_test]
2161 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
2162 let test_path = testutils::create_temp_dir().await?;
2163 let src_path = test_path.join("src");
2169 tokio::fs::create_dir(&src_path).await?;
2170 tokio::fs::write(src_path.join("foo"), "content").await?;
2171 tokio::fs::write(src_path.join("bar"), "content").await?;
2172 tokio::fs::create_dir(src_path.join("baz")).await?;
2173 let dst_path = test_path.join("dst");
2175 tokio::fs::create_dir(&dst_path).await?;
2176 tokio::fs::create_dir(dst_path.join("baz")).await?;
2177 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
2179 let mut filter = FilterSettings::new();
2181 filter.add_include("foo").unwrap();
2182 let summary = link(
2183 &PROGRESS,
2184 &test_path,
2185 &src_path,
2186 &dst_path,
2187 &None,
2188 &Settings {
2189 copy_settings: copy::Settings {
2190 dereference: false,
2191 fail_early: false,
2192 overwrite: true, overwrite_compare: Default::default(),
2194 overwrite_filter: None,
2195 ignore_existing: false,
2196 chunk_size: 0,
2197 skip_specials: false,
2198 remote_copy_buffer_size: 0,
2199 filter: None,
2200 dry_run: None,
2201 },
2202 update_compare: Default::default(),
2203 update_exclusive: false,
2204 filter: Some(filter),
2205 dry_run: None,
2206 preserve: preserve::preserve_all(),
2207 },
2208 false,
2209 )
2210 .await?;
2211 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2213 assert_eq!(
2215 summary.copy_summary.directories_unchanged, 2,
2216 "root dst and baz directories should be unchanged"
2217 );
2218 assert_eq!(
2219 summary.copy_summary.directories_created, 0,
2220 "should not create any directories"
2221 );
2222 assert!(dst_path.join("foo").exists(), "foo should be linked");
2224 assert!(!dst_path.join("bar").exists(), "bar should not be linked");
2226 assert!(
2228 dst_path.join("baz").exists(),
2229 "existing baz directory should still exist"
2230 );
2231 assert!(
2232 dst_path.join("baz").join("marker.txt").exists(),
2233 "existing content in baz should still exist"
2234 );
2235 Ok(())
2236 }
2237 }
2238 mod dry_run_tests {
2239 use super::*;
2240 #[tokio::test]
2242 #[traced_test]
2243 async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
2244 let tmp_dir = testutils::setup_test_dir().await?;
2245 let test_path = tmp_dir.as_path();
2246 let src_file = test_path.join("foo/0.txt");
2247 let dst_file = test_path.join("dst_link.txt");
2248 assert!(
2250 !dst_file.exists(),
2251 "destination should not exist before dry-run"
2252 );
2253 let summary = link(
2254 &PROGRESS,
2255 test_path,
2256 &src_file,
2257 &dst_file,
2258 &None,
2259 &Settings {
2260 copy_settings: CopySettings {
2261 dereference: false,
2262 fail_early: false,
2263 overwrite: false,
2264 overwrite_compare: Default::default(),
2265 overwrite_filter: None,
2266 ignore_existing: false,
2267 chunk_size: 0,
2268 skip_specials: false,
2269 remote_copy_buffer_size: 0,
2270 filter: None,
2271 dry_run: None,
2272 },
2273 update_compare: Default::default(),
2274 update_exclusive: false,
2275 filter: None,
2276 dry_run: Some(crate::config::DryRunMode::Brief),
2277 preserve: preserve::preserve_all(),
2278 },
2279 false,
2280 )
2281 .await?;
2282 assert!(!dst_file.exists(), "dry-run should not create hard link");
2284 assert_eq!(
2286 summary.hard_links_created, 1,
2287 "dry-run should report 1 hard link that would be created"
2288 );
2289 Ok(())
2290 }
2291 #[tokio::test]
2293 #[traced_test]
2294 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
2295 let tmp_dir = testutils::setup_test_dir().await?;
2296 let test_path = tmp_dir.as_path();
2297 let dst_path = test_path.join("nonexistent_dst");
2298 assert!(
2300 !dst_path.exists(),
2301 "destination should not exist before dry-run"
2302 );
2303 let summary = link(
2304 &PROGRESS,
2305 test_path,
2306 &test_path.join("foo"),
2307 &dst_path,
2308 &None,
2309 &Settings {
2310 copy_settings: CopySettings {
2311 dereference: false,
2312 fail_early: false,
2313 overwrite: false,
2314 overwrite_compare: Default::default(),
2315 overwrite_filter: None,
2316 ignore_existing: false,
2317 chunk_size: 0,
2318 skip_specials: false,
2319 remote_copy_buffer_size: 0,
2320 filter: None,
2321 dry_run: None,
2322 },
2323 update_compare: Default::default(),
2324 update_exclusive: false,
2325 filter: None,
2326 dry_run: Some(crate::config::DryRunMode::Brief),
2327 preserve: preserve::preserve_all(),
2328 },
2329 false,
2330 )
2331 .await?;
2332 assert!(
2334 !dst_path.exists(),
2335 "dry-run should not create destination directory"
2336 );
2337 assert!(
2339 summary.hard_links_created > 0,
2340 "dry-run should report hard links that would be created"
2341 );
2342 Ok(())
2343 }
2344 #[tokio::test]
2346 #[traced_test]
2347 async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
2348 let tmp_dir = testutils::setup_test_dir().await?;
2349 let test_path = tmp_dir.as_path();
2350 let src_path = test_path.join("foo/baz");
2352 let dst_path = test_path.join("dst_baz");
2353 assert!(
2355 !dst_path.exists(),
2356 "destination should not exist before dry-run"
2357 );
2358 let summary = link(
2359 &PROGRESS,
2360 test_path,
2361 &src_path,
2362 &dst_path,
2363 &None,
2364 &Settings {
2365 copy_settings: CopySettings {
2366 dereference: false,
2367 fail_early: false,
2368 overwrite: false,
2369 overwrite_compare: Default::default(),
2370 overwrite_filter: None,
2371 ignore_existing: false,
2372 chunk_size: 0,
2373 skip_specials: false,
2374 remote_copy_buffer_size: 0,
2375 filter: None,
2376 dry_run: None,
2377 },
2378 update_compare: Default::default(),
2379 update_exclusive: false,
2380 filter: None,
2381 dry_run: Some(crate::config::DryRunMode::Brief),
2382 preserve: preserve::preserve_all(),
2383 },
2384 false,
2385 )
2386 .await?;
2387 assert!(!dst_path.exists(), "dry-run should not create destination");
2389 assert_eq!(
2391 summary.hard_links_created, 1,
2392 "dry-run should report 1 hard link (for 4.txt)"
2393 );
2394 assert_eq!(
2395 summary.copy_summary.symlinks_created, 2,
2396 "dry-run should report 2 symlinks (5.txt and 6.txt)"
2397 );
2398 Ok(())
2399 }
2400 }
2401
2402 #[tokio::test]
2409 #[traced_test]
2410 async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
2411 let tmp_dir = testutils::create_temp_dir().await?;
2412 let test_path = tmp_dir.as_path();
2413 let src_dir = test_path.join("src");
2418 let sub_dir = src_dir.join("sub");
2419 let bad_dir = sub_dir.join("unreadable_dir");
2420 tokio::fs::create_dir_all(&bad_dir).await?;
2421 tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
2422 tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
2423 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
2424 let dst_dir = test_path.join("dst");
2425 let result = link(
2426 &PROGRESS,
2427 test_path,
2428 &src_dir,
2429 &dst_dir,
2430 &None,
2431 &Settings {
2432 copy_settings: CopySettings {
2433 fail_early: true,
2434 ..common_settings(false, false).copy_settings
2435 },
2436 ..common_settings(false, false)
2437 },
2438 false,
2439 )
2440 .await;
2441 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
2443 let error = result.expect_err("link should fail due to unreadable directory");
2444 assert!(
2449 error.summary.copy_summary.directories_created >= 2,
2450 "fail-early summary should include directories from the failing subtree, \
2451 got directories_created={} (expected >= 2: dst/ and dst/sub/)",
2452 error.summary.copy_summary.directories_created
2453 );
2454 Ok(())
2455 }
2456
2457 #[tokio::test]
2458 #[traced_test]
2459 async fn skip_specials_skips_socket_in_link() -> Result<(), anyhow::Error> {
2460 let tmp_dir = testutils::setup_test_dir().await?;
2461 let test_path = tmp_dir.as_path();
2462 let src = test_path.join("src_dir");
2463 let dst = test_path.join("dst_dir");
2464 tokio::fs::create_dir(&src).await?;
2465 tokio::fs::write(src.join("file.txt"), "hello").await?;
2466 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
2467 let mut settings = common_settings(false, false);
2468 settings.copy_settings.skip_specials = true;
2469 let summary = link(&PROGRESS, test_path, &src, &dst, &None, &settings, false).await?;
2470 assert_eq!(summary.hard_links_created, 1);
2471 assert_eq!(summary.copy_summary.specials_skipped, 1);
2472 assert!(dst.join("file.txt").exists());
2473 assert!(!dst.join("test.sock").exists());
2474 Ok(())
2475 }
2476
2477 #[tokio::test]
2478 #[traced_test]
2479 async fn skip_specials_top_level_socket_in_link() -> Result<(), anyhow::Error> {
2480 let tmp_dir = testutils::setup_test_dir().await?;
2481 let test_path = tmp_dir.as_path();
2482 let src_socket = test_path.join("test.sock");
2483 let dst = test_path.join("dst.sock");
2484 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
2485 let mut settings = common_settings(false, false);
2486 settings.copy_settings.skip_specials = true;
2487 let summary = link(
2488 &PROGRESS,
2489 test_path,
2490 &src_socket,
2491 &dst,
2492 &None,
2493 &settings,
2494 false,
2495 )
2496 .await?;
2497 assert_eq!(summary.copy_summary.specials_skipped, 1);
2498 assert_eq!(summary.hard_links_created, 0);
2499 assert!(!dst.exists());
2500 Ok(())
2501 }
2502
2503 mod max_open_files_tests {
2505 use super::*;
2506
2507 #[tokio::test]
2510 #[traced_test]
2511 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
2512 let tmp_dir = testutils::create_temp_dir().await?;
2513 let src = tmp_dir.join("src");
2514 let dst = tmp_dir.join("dst");
2515 let depth = 20;
2516 let files_per_level = 5;
2517 let limit = 4;
2518 let mut dir = src.clone();
2520 for level in 0..depth {
2521 tokio::fs::create_dir_all(&dir).await?;
2522 for f in 0..files_per_level {
2523 tokio::fs::write(
2524 dir.join(format!("f{}_{}.txt", level, f)),
2525 format!("L{}F{}", level, f),
2526 )
2527 .await?;
2528 }
2529 dir = dir.join(format!("d{}", level));
2530 }
2531 throttle::set_max_open_files(limit);
2532 let summary = tokio::time::timeout(
2533 std::time::Duration::from_secs(30),
2534 link(
2535 &PROGRESS,
2536 tmp_dir.as_path(),
2537 &src,
2538 &dst,
2539 &None,
2540 &common_settings(false, false),
2541 false,
2542 ),
2543 )
2544 .await
2545 .context("link timed out — possible deadlock")?
2546 .context("link failed")?;
2547 assert_eq!(summary.hard_links_created, depth * files_per_level);
2548 assert_eq!(summary.copy_summary.directories_created, depth);
2549 let mut check_dir = dst.clone();
2551 for level in 0..depth {
2552 let content =
2553 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
2554 assert_eq!(content, format!("L{}F0", level));
2555 check_dir = check_dir.join(format!("d{}", level));
2556 }
2557 Ok(())
2558 }
2559
2560 #[tokio::test]
2571 #[traced_test]
2572 async fn parallel_update_filetype_change_no_deadlock() -> Result<(), anyhow::Error> {
2573 let tmp_dir = testutils::create_temp_dir().await?;
2574 let src = tmp_dir.join("src");
2575 let update = tmp_dir.join("update");
2576 let dst = tmp_dir.join("dst");
2577 tokio::fs::create_dir(&src).await?;
2578 tokio::fs::create_dir(&update).await?;
2579 let n = 8;
2580 for i in 0..n {
2584 tokio::fs::write(src.join(format!("e{}", i)), format!("src-{}", i)).await?;
2585 let upd_subdir = update.join(format!("e{}", i));
2586 tokio::fs::create_dir(&upd_subdir).await?;
2587 for j in 0..3 {
2588 tokio::fs::write(
2589 upd_subdir.join(format!("inner_{}.txt", j)),
2590 format!("upd-{}-{}", i, j),
2591 )
2592 .await?;
2593 }
2594 }
2595 throttle::set_max_open_files(2);
2598 let summary = tokio::time::timeout(
2599 std::time::Duration::from_secs(30),
2600 link(
2601 &PROGRESS,
2602 tmp_dir.as_path(),
2603 &src,
2604 &dst,
2605 &Some(update.clone()),
2606 &common_settings(false, false),
2607 false,
2608 ),
2609 )
2610 .await
2611 .context(
2612 "link timed out — caller-supplied open-files guard not released before copy::copy",
2613 )?
2614 .context("link failed")?;
2615 assert_eq!(summary.copy_summary.directories_created, n + 1); assert_eq!(summary.copy_summary.files_copied, n * 3);
2619 for i in 0..n {
2621 for j in 0..3 {
2622 let content =
2623 tokio::fs::read_to_string(dst.join(format!("e{}/inner_{}.txt", i, j)))
2624 .await?;
2625 assert_eq!(content, format!("upd-{}-{}", i, j));
2626 }
2627 }
2628 Ok(())
2629 }
2630
2631 #[tokio::test]
2639 #[traced_test]
2640 async fn update_only_entries_bounded_no_deadlock() -> Result<(), anyhow::Error> {
2641 let tmp_dir = testutils::create_temp_dir().await?;
2642 let src = tmp_dir.join("src");
2643 let update = tmp_dir.join("update");
2644 let dst = tmp_dir.join("dst");
2645 tokio::fs::create_dir(&src).await?;
2646 tokio::fs::create_dir(&update).await?;
2647 let n = 50;
2650 for i in 0..n {
2651 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
2652 }
2653 throttle::set_max_open_files(2);
2654 let summary = tokio::time::timeout(
2655 std::time::Duration::from_secs(30),
2656 link(
2657 &PROGRESS,
2658 tmp_dir.as_path(),
2659 &src,
2660 &dst,
2661 &Some(update.clone()),
2662 &common_settings(false, false),
2663 false,
2664 ),
2665 )
2666 .await
2667 .context("link timed out — site-3 spawn loop deadlock")?
2668 .context("link failed")?;
2669 assert_eq!(summary.copy_summary.directories_created, 1);
2671 assert_eq!(summary.copy_summary.files_copied, n);
2672 for i in 0..n {
2673 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
2674 assert_eq!(content, format!("upd-{}", i));
2675 }
2676 Ok(())
2677 }
2678
2679 #[tokio::test]
2690 #[traced_test]
2691 async fn update_only_overwrite_preexisting_dirs_no_deadlock() -> Result<(), anyhow::Error> {
2692 let tmp_dir = testutils::create_temp_dir().await?;
2693 let src = tmp_dir.join("src");
2694 let update = tmp_dir.join("update");
2695 let dst = tmp_dir.join("dst");
2696 tokio::fs::create_dir(&src).await?;
2697 tokio::fs::create_dir(&update).await?;
2698 tokio::fs::create_dir(&dst).await?;
2699 let n = 12;
2700 for i in 0..n {
2701 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
2703 let dst_subdir = dst.join(format!("u{}", i));
2707 tokio::fs::create_dir(&dst_subdir).await?;
2708 for j in 0..3 {
2709 tokio::fs::write(
2710 dst_subdir.join(format!("inner_{}.txt", j)),
2711 format!("old-{}-{}", i, j),
2712 )
2713 .await?;
2714 }
2715 }
2716 throttle::set_max_open_files(2);
2718 let summary = tokio::time::timeout(
2719 std::time::Duration::from_secs(30),
2720 link(
2721 &PROGRESS,
2722 tmp_dir.as_path(),
2723 &src,
2724 &dst,
2725 &Some(update.clone()),
2726 &common_settings(false, true), false,
2728 ),
2729 )
2730 .await
2731 .context("link timed out — pending-meta self-deadlock between site 3 and inner rm")?
2732 .context("link failed")?;
2733 assert_eq!(summary.copy_summary.files_copied, n);
2736 assert_eq!(summary.copy_summary.rm_summary.files_removed, n * 3);
2737 assert_eq!(summary.copy_summary.rm_summary.directories_removed, n);
2738 for i in 0..n {
2740 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
2741 assert_eq!(content, format!("upd-{}", i));
2742 }
2743 Ok(())
2744 }
2745 }
2746}