1use anyhow::{anyhow, Context};
2use async_recursion::async_recursion;
3use std::os::linux::fs::MetadataExt as LinuxMetadataExt;
4use tracing::instrument;
5
6use crate::config::DryRunMode;
7use crate::copy;
8use crate::copy::{
9 check_empty_dir_cleanup, EmptyDirAction, Settings as CopySettings, Summary as CopySummary,
10};
11use crate::filecmp;
12use crate::filter::{FilterResult, FilterSettings};
13use crate::preserve;
14use crate::progress;
15use crate::rm;
16
17#[derive(Debug, thiserror::Error)]
28#[error("{source:#}")]
29pub struct Error {
30 #[source]
31 pub source: anyhow::Error,
32 pub summary: Summary,
33}
34
35impl Error {
36 #[must_use]
37 pub fn new(source: anyhow::Error, summary: Summary) -> Self {
38 Error { source, summary }
39 }
40}
41
42#[derive(Debug, Clone)]
43pub struct Settings {
44 pub copy_settings: CopySettings,
45 pub update_compare: filecmp::MetadataCmpSettings,
46 pub update_exclusive: bool,
47 pub filter: Option<crate::filter::FilterSettings>,
49 pub dry_run: Option<crate::config::DryRunMode>,
51 pub preserve: preserve::Settings,
53}
54
55fn report_dry_run_link(src: &std::path::Path, dst: &std::path::Path, entry_type: &str) {
57 println!("would link {} {:?} -> {:?}", entry_type, src, dst);
58}
59
60fn report_dry_run_skip(
62 path: &std::path::Path,
63 result: &FilterResult,
64 mode: DryRunMode,
65 entry_type: &str,
66) {
67 match mode {
68 DryRunMode::Brief => { }
69 DryRunMode::All => {
70 println!("skip {} {:?}", entry_type, path);
71 }
72 DryRunMode::Explain => match result {
73 FilterResult::ExcludedByDefault => {
74 println!(
75 "skip {} {:?} (no include pattern matched)",
76 entry_type, path
77 );
78 }
79 FilterResult::ExcludedByPattern(pattern) => {
80 println!("skip {} {:?} (excluded by '{}')", entry_type, path, pattern);
81 }
82 FilterResult::Included => { }
83 },
84 }
85}
86
87fn should_skip_entry(
89 filter: &Option<FilterSettings>,
90 relative_path: &std::path::Path,
91 is_dir: bool,
92) -> Option<FilterResult> {
93 if let Some(ref f) = filter {
94 let result = f.should_include(relative_path, is_dir);
95 match result {
96 FilterResult::Included => None,
97 _ => Some(result),
98 }
99 } else {
100 None
101 }
102}
103
104#[derive(Copy, Clone, Debug, Default)]
105pub struct Summary {
106 pub hard_links_created: usize,
107 pub hard_links_unchanged: usize,
108 pub copy_summary: CopySummary,
109}
110
111impl std::ops::Add for Summary {
112 type Output = Self;
113 fn add(self, other: Self) -> Self {
114 Self {
115 hard_links_created: self.hard_links_created + other.hard_links_created,
116 hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
117 copy_summary: self.copy_summary + other.copy_summary,
118 }
119 }
120}
121
122impl std::fmt::Display for Summary {
123 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
124 write!(
125 f,
126 "{}\n\
127 link:\n\
128 -----\n\
129 hard-links created: {}\n\
130 hard links unchanged: {}\n",
131 &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
132 )
133 }
134}
135
136fn is_hard_link(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
137 copy::is_file_type_same(md1, md2)
138 && md2.st_dev() == md1.st_dev()
139 && md2.st_ino() == md1.st_ino()
140}
141
142#[instrument(skip(prog_track, settings))]
143async fn hard_link_helper(
144 prog_track: &'static progress::Progress,
145 src: &std::path::Path,
146 src_metadata: &std::fs::Metadata,
147 dst: &std::path::Path,
148 settings: &Settings,
149) -> Result<Summary, Error> {
150 let mut link_summary = Summary::default();
151 if let Err(error) = tokio::fs::hard_link(src, dst).await {
152 if settings.copy_settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
153 tracing::debug!("'dst' already exists, check if we need to update");
154 let dst_metadata = tokio::fs::symlink_metadata(dst)
155 .await
156 .with_context(|| format!("cannot read {dst:?} metadata"))
157 .map_err(|err| Error::new(err, Default::default()))?;
158 if is_hard_link(src_metadata, &dst_metadata) {
159 tracing::debug!("no change, leaving file as is");
160 prog_track.hard_links_unchanged.inc();
161 return Ok(Summary {
162 hard_links_unchanged: 1,
163 ..Default::default()
164 });
165 }
166 tracing::info!("'dst' file type changed, removing and hard-linking");
167 let rm_summary = rm::rm(
168 prog_track,
169 dst,
170 &rm::Settings {
171 fail_early: settings.copy_settings.fail_early,
172 filter: None,
173 dry_run: None,
174 },
175 )
176 .await
177 .map_err(|err| {
178 let rm_summary = err.summary;
179 link_summary.copy_summary.rm_summary = rm_summary;
180 Error::new(err.source, link_summary)
181 })?;
182 link_summary.copy_summary.rm_summary = rm_summary;
183 tokio::fs::hard_link(src, dst)
184 .await
185 .with_context(|| format!("failed to hard link {:?} to {:?}", src, dst))
186 .map_err(|err| Error::new(err, link_summary))?;
187 }
188 }
189 prog_track.hard_links_created.inc();
190 link_summary.hard_links_created = 1;
191 Ok(link_summary)
192}
193
194#[instrument(skip(prog_track, settings))]
197pub async fn link(
198 prog_track: &'static progress::Progress,
199 cwd: &std::path::Path,
200 src: &std::path::Path,
201 dst: &std::path::Path,
202 update: &Option<std::path::PathBuf>,
203 settings: &Settings,
204 is_fresh: bool,
205) -> Result<Summary, Error> {
206 if let Some(ref filter) = settings.filter {
208 let src_name = src.file_name().map(std::path::Path::new);
209 if let Some(name) = src_name {
210 let src_metadata = tokio::fs::symlink_metadata(src)
211 .await
212 .with_context(|| format!("failed reading metadata from {:?}", &src))
213 .map_err(|err| Error::new(err, Default::default()))?;
214 let is_dir = src_metadata.is_dir();
215 let result = filter.should_include_root_item(name, is_dir);
216 match result {
217 crate::filter::FilterResult::Included => {}
218 result => {
219 if let Some(mode) = settings.dry_run {
220 let entry_type = if src_metadata.is_dir() {
221 "directory"
222 } else if src_metadata.file_type().is_symlink() {
223 "symlink"
224 } else {
225 "file"
226 };
227 report_dry_run_skip(src, &result, mode, entry_type);
228 }
229 let skipped_summary = if src_metadata.is_dir() {
231 prog_track.directories_skipped.inc();
232 Summary {
233 copy_summary: CopySummary {
234 directories_skipped: 1,
235 ..Default::default()
236 },
237 ..Default::default()
238 }
239 } else if src_metadata.file_type().is_symlink() {
240 prog_track.symlinks_skipped.inc();
241 Summary {
242 copy_summary: CopySummary {
243 symlinks_skipped: 1,
244 ..Default::default()
245 },
246 ..Default::default()
247 }
248 } else {
249 prog_track.files_skipped.inc();
250 Summary {
251 copy_summary: CopySummary {
252 files_skipped: 1,
253 ..Default::default()
254 },
255 ..Default::default()
256 }
257 };
258 return Ok(skipped_summary);
259 }
260 }
261 }
262 }
263 link_internal(prog_track, cwd, src, dst, src, update, settings, is_fresh).await
264}
265#[instrument(skip(prog_track, settings))]
266#[async_recursion]
267#[allow(clippy::too_many_arguments)]
268async fn link_internal(
269 prog_track: &'static progress::Progress,
270 cwd: &std::path::Path,
271 src: &std::path::Path,
272 dst: &std::path::Path,
273 source_root: &std::path::Path,
274 update: &Option<std::path::PathBuf>,
275 settings: &Settings,
276 mut is_fresh: bool,
277) -> Result<Summary, Error> {
278 let _prog_guard = prog_track.ops.guard();
279 tracing::debug!("reading source metadata");
280 let src_metadata = tokio::fs::symlink_metadata(src)
281 .await
282 .with_context(|| format!("failed reading metadata from {:?}", &src))
283 .map_err(|err| Error::new(err, Default::default()))?;
284 let update_metadata_opt = match update {
285 Some(update) => {
286 tracing::debug!("reading 'update' metadata");
287 let update_metadata_res = tokio::fs::symlink_metadata(update).await;
288 match update_metadata_res {
289 Ok(update_metadata) => Some(update_metadata),
290 Err(error) => {
291 if error.kind() == std::io::ErrorKind::NotFound {
292 if settings.update_exclusive {
293 return Ok(Default::default());
295 }
296 None
297 } else {
298 return Err(Error::new(
299 anyhow!("failed reading metadata from {:?}", &update),
300 Default::default(),
301 ));
302 }
303 }
304 }
305 }
306 None => None,
307 };
308 if let Some(update_metadata) = update_metadata_opt.as_ref() {
309 let update = update.as_ref().unwrap();
310 if !copy::is_file_type_same(&src_metadata, update_metadata) {
311 tracing::debug!(
313 "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
314 src,
315 src_metadata.file_type(),
316 update,
317 update_metadata.file_type()
318 );
319 let copy_summary = copy::copy(
320 prog_track,
321 update,
322 dst,
323 &settings.copy_settings,
324 &settings.preserve,
325 is_fresh,
326 )
327 .await
328 .map_err(|err| {
329 let copy_summary = err.summary;
330 let link_summary = Summary {
331 copy_summary,
332 ..Default::default()
333 };
334 Error::new(err.source, link_summary)
335 })?;
336 return Ok(Summary {
337 copy_summary,
338 ..Default::default()
339 });
340 }
341 if update_metadata.is_file() {
342 if filecmp::metadata_equal(&settings.update_compare, &src_metadata, update_metadata) {
344 tracing::debug!("no change, hard link 'src'");
345 return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
346 }
347 tracing::debug!(
348 "link: {:?} metadata has changed, copying from {:?}",
349 src,
350 update
351 );
352 let _open_file_guard = throttle::open_file_permit().await;
353 return Ok(Summary {
354 copy_summary: copy::copy_file(
355 prog_track,
356 update,
357 dst,
358 update_metadata,
359 &settings.copy_settings,
360 &settings.preserve,
361 is_fresh,
362 )
363 .await
364 .map_err(|err| {
365 let copy_summary = err.summary;
366 let link_summary = Summary {
367 copy_summary,
368 ..Default::default()
369 };
370 Error::new(err.source, link_summary)
371 })?,
372 ..Default::default()
373 });
374 }
375 if update_metadata.is_symlink() {
376 tracing::debug!("'update' is a symlink so just symlink that");
377 let copy_summary = copy::copy(
379 prog_track,
380 update,
381 dst,
382 &settings.copy_settings,
383 &settings.preserve,
384 is_fresh,
385 )
386 .await
387 .map_err(|err| {
388 let copy_summary = err.summary;
389 let link_summary = Summary {
390 copy_summary,
391 ..Default::default()
392 };
393 Error::new(err.source, link_summary)
394 })?;
395 return Ok(Summary {
396 copy_summary,
397 ..Default::default()
398 });
399 }
400 } else {
401 tracing::debug!("no 'update' specified");
403 if src_metadata.is_file() {
404 if settings.dry_run.is_some() {
406 report_dry_run_link(src, dst, "file");
407 return Ok(Summary {
408 hard_links_created: 1,
409 ..Default::default()
410 });
411 }
412 return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
413 }
414 if src_metadata.is_symlink() {
415 tracing::debug!("'src' is a symlink so just symlink that");
416 let copy_summary = copy::copy(
418 prog_track,
419 src,
420 dst,
421 &settings.copy_settings,
422 &settings.preserve,
423 is_fresh,
424 )
425 .await
426 .map_err(|err| {
427 let copy_summary = err.summary;
428 let link_summary = Summary {
429 copy_summary,
430 ..Default::default()
431 };
432 Error::new(err.source, link_summary)
433 })?;
434 return Ok(Summary {
435 copy_summary,
436 ..Default::default()
437 });
438 }
439 }
440 if !src_metadata.is_dir() {
441 return Err(Error::new(
442 anyhow!(
443 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
444 src,
445 dst,
446 src_metadata.file_type()
447 ),
448 Default::default(),
449 ));
450 }
451 assert!(update_metadata_opt.is_none() || update_metadata_opt.as_ref().unwrap().is_dir());
452 tracing::debug!("process contents of 'src' directory");
453 let mut src_entries = tokio::fs::read_dir(src)
454 .await
455 .with_context(|| format!("cannot open directory {src:?} for reading"))
456 .map_err(|err| Error::new(err, Default::default()))?;
457 if settings.dry_run.is_some() {
459 report_dry_run_link(src, dst, "dir");
460 }
462 let copy_summary = if settings.dry_run.is_some() {
463 CopySummary {
465 directories_created: 1,
466 ..Default::default()
467 }
468 } else if let Err(error) = tokio::fs::create_dir(dst).await {
469 assert!(!is_fresh, "unexpected error creating directory: {:?}", &dst);
470 if settings.copy_settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
471 let dst_metadata = tokio::fs::metadata(dst)
476 .await
477 .with_context(|| format!("failed reading metadata from {:?}", &dst))
478 .map_err(|err| Error::new(err, Default::default()))?;
479 if dst_metadata.is_dir() {
480 tracing::debug!("'dst' is a directory, leaving it as is");
481 CopySummary {
482 directories_unchanged: 1,
483 ..Default::default()
484 }
485 } else {
486 tracing::info!("'dst' is not a directory, removing and creating a new one");
487 let mut copy_summary = CopySummary::default();
488 let rm_summary = rm::rm(
489 prog_track,
490 dst,
491 &rm::Settings {
492 fail_early: settings.copy_settings.fail_early,
493 filter: None,
494 dry_run: None,
495 },
496 )
497 .await
498 .map_err(|err| {
499 let rm_summary = err.summary;
500 copy_summary.rm_summary = rm_summary;
501 Error::new(
502 err.source,
503 Summary {
504 copy_summary,
505 ..Default::default()
506 },
507 )
508 })?;
509 tokio::fs::create_dir(dst)
510 .await
511 .with_context(|| format!("cannot create directory {dst:?}"))
512 .map_err(|err| {
513 copy_summary.rm_summary = rm_summary;
514 Error::new(
515 err,
516 Summary {
517 copy_summary,
518 ..Default::default()
519 },
520 )
521 })?;
522 is_fresh = true;
524 CopySummary {
525 rm_summary,
526 directories_created: 1,
527 ..Default::default()
528 }
529 }
530 } else {
531 return Err(error)
532 .with_context(|| format!("cannot create directory {dst:?}"))
533 .map_err(|err| Error::new(err, Default::default()))?;
534 }
535 } else {
536 is_fresh = true;
538 CopySummary {
539 directories_created: 1,
540 ..Default::default()
541 }
542 };
543 let we_created_this_dir = copy_summary.directories_created == 1;
546 let mut link_summary = Summary {
547 copy_summary,
548 ..Default::default()
549 };
550 let mut join_set = tokio::task::JoinSet::new();
551 let errors = crate::error_collector::ErrorCollector::default();
552 let mut processed_files = std::collections::HashSet::new();
554 while let Some(src_entry) = src_entries
556 .next_entry()
557 .await
558 .with_context(|| format!("failed traversing directory {:?}", &src))
559 .map_err(|err| Error::new(err, link_summary))?
560 {
561 throttle::get_ops_token().await;
565 let cwd_path = cwd.to_owned();
566 let entry_path = src_entry.path();
567 let entry_name = entry_path.file_name().unwrap();
568 let entry_file_type = src_entry.file_type().await.ok();
570 let entry_is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
571 let entry_is_symlink = entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false);
572 let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
574 if let Some(skip_result) = should_skip_entry(&settings.filter, relative_path, entry_is_dir)
576 {
577 if let Some(mode) = settings.dry_run {
578 let entry_type = if entry_is_dir {
579 "dir"
580 } else if entry_is_symlink {
581 "symlink"
582 } else {
583 "file"
584 };
585 report_dry_run_skip(&entry_path, &skip_result, mode, entry_type);
586 }
587 tracing::debug!("skipping {:?} due to filter", &entry_path);
588 if entry_is_dir {
590 link_summary.copy_summary.directories_skipped += 1;
591 prog_track.directories_skipped.inc();
592 } else if entry_is_symlink {
593 link_summary.copy_summary.symlinks_skipped += 1;
594 prog_track.symlinks_skipped.inc();
595 } else {
596 link_summary.copy_summary.files_skipped += 1;
597 prog_track.files_skipped.inc();
598 }
599 continue;
600 }
601 processed_files.insert(entry_name.to_owned());
602 let dst_path = dst.join(entry_name);
603 let update_path = update.as_ref().map(|s| s.join(entry_name));
604 if let Some(_mode) = settings.dry_run {
606 let entry_type = if entry_is_dir {
607 "dir"
608 } else if entry_is_symlink {
609 "symlink"
610 } else {
611 "file"
612 };
613 report_dry_run_link(&entry_path, &dst_path, entry_type);
614 if entry_is_dir {
616 let settings = settings.clone();
617 let source_root = source_root.to_owned();
618 let do_link = || async move {
619 link_internal(
620 prog_track,
621 &cwd_path,
622 &entry_path,
623 &dst_path,
624 &source_root,
625 &update_path,
626 &settings,
627 true,
628 )
629 .await
630 };
631 join_set.spawn(do_link());
632 } else if entry_is_symlink {
633 link_summary.copy_summary.symlinks_created += 1;
635 } else {
636 link_summary.hard_links_created += 1;
638 }
639 continue;
640 }
641 let settings = settings.clone();
642 let source_root = source_root.to_owned();
643 let do_link = || async move {
644 link_internal(
645 prog_track,
646 &cwd_path,
647 &entry_path,
648 &dst_path,
649 &source_root,
650 &update_path,
651 &settings,
652 is_fresh,
653 )
654 .await
655 };
656 join_set.spawn(do_link());
657 }
658 drop(src_entries);
661 if update_metadata_opt.is_some() {
663 let update = update.as_ref().unwrap();
664 tracing::debug!("process contents of 'update' directory");
665 let mut update_entries = tokio::fs::read_dir(update)
666 .await
667 .with_context(|| format!("cannot open directory {:?} for reading", &update))
668 .map_err(|err| Error::new(err, link_summary))?;
669 while let Some(update_entry) = update_entries
671 .next_entry()
672 .await
673 .with_context(|| format!("failed traversing directory {:?}", &update))
674 .map_err(|err| Error::new(err, link_summary))?
675 {
676 let entry_path = update_entry.path();
677 let entry_name = entry_path.file_name().unwrap();
678 if processed_files.contains(entry_name) {
679 continue;
681 }
682 tracing::debug!("found a new entry in the 'update' directory");
683 let dst_path = dst.join(entry_name);
684 let update_path = update.join(entry_name);
685 let settings = settings.clone();
686 let do_copy = || async move {
687 let copy_summary = copy::copy(
688 prog_track,
689 &update_path,
690 &dst_path,
691 &settings.copy_settings,
692 &settings.preserve,
693 is_fresh,
694 )
695 .await
696 .map_err(|err| {
697 link_summary.copy_summary = link_summary.copy_summary + err.summary;
698 Error::new(err.source, link_summary)
699 })?;
700 Ok(Summary {
701 copy_summary,
702 ..Default::default()
703 })
704 };
705 join_set.spawn(do_copy());
706 }
707 drop(update_entries);
710 }
711 while let Some(res) = join_set.join_next().await {
712 match res {
713 Ok(result) => match result {
714 Ok(summary) => link_summary = link_summary + summary,
715 Err(error) => {
716 tracing::error!(
717 "link: {:?} {:?} -> {:?} failed with: {:#}",
718 src,
719 update,
720 dst,
721 &error
722 );
723 link_summary = link_summary + error.summary;
724 if settings.copy_settings.fail_early {
725 return Err(Error::new(error.source, link_summary));
726 }
727 errors.push(error.source);
728 }
729 },
730 Err(error) => {
731 if settings.copy_settings.fail_early {
732 return Err(Error::new(error.into(), link_summary));
733 }
734 errors.push(error.into());
735 }
736 }
737 }
738 let this_dir_count = usize::from(we_created_this_dir);
741 let child_dirs_created = link_summary
742 .copy_summary
743 .directories_created
744 .saturating_sub(this_dir_count);
745 let anything_linked = link_summary.hard_links_created > 0
746 || link_summary.copy_summary.files_copied > 0
747 || link_summary.copy_summary.symlinks_created > 0
748 || child_dirs_created > 0;
749 let relative_path = src.strip_prefix(source_root).unwrap_or(src);
750 let is_root = src == source_root;
751 match check_empty_dir_cleanup(
752 settings.filter.as_ref(),
753 we_created_this_dir,
754 anything_linked,
755 relative_path,
756 is_root,
757 settings.dry_run.is_some(),
758 ) {
759 EmptyDirAction::Keep => { }
760 EmptyDirAction::DryRunSkip => {
761 tracing::debug!(
762 "dry-run: directory {:?} would not be created (nothing to link inside)",
763 &dst
764 );
765 link_summary.copy_summary.directories_created = 0;
766 return Ok(link_summary);
767 }
768 EmptyDirAction::Remove => {
769 tracing::debug!(
770 "directory {:?} has nothing to link inside, removing empty directory",
771 &dst
772 );
773 match tokio::fs::remove_dir(dst).await {
774 Ok(()) => {
775 link_summary.copy_summary.directories_created = 0;
776 return Ok(link_summary);
777 }
778 Err(err) => {
779 tracing::debug!(
781 "failed to remove empty directory {:?}: {:#}, keeping",
782 &dst,
783 &err
784 );
785 }
787 }
788 }
789 }
790 tracing::debug!("set 'dst' directory metadata");
795 let metadata_result = if settings.dry_run.is_some() {
796 Ok(()) } else {
798 let preserve_metadata = if let Some(update_metadata) = update_metadata_opt.as_ref() {
799 update_metadata
800 } else {
801 &src_metadata
802 };
803 preserve::set_dir_metadata(&settings.preserve, preserve_metadata, dst).await
804 };
805 if errors.has_errors() {
806 if let Err(metadata_err) = metadata_result {
808 tracing::error!(
809 "link: {:?} {:?} -> {:?} failed to set directory metadata: {:#}",
810 src,
811 update,
812 dst,
813 &metadata_err
814 );
815 }
816 return Err(Error::new(errors.into_error().unwrap(), link_summary));
818 }
819 metadata_result.map_err(|err| Error::new(err, link_summary))?;
821 Ok(link_summary)
822}
823
824#[cfg(test)]
825mod link_tests {
826 use crate::testutils;
827 use std::os::unix::fs::PermissionsExt;
828 use tracing_test::traced_test;
829
830 use super::*;
831
832 static PROGRESS: std::sync::LazyLock<progress::Progress> =
833 std::sync::LazyLock::new(progress::Progress::new);
834
835 fn common_settings(dereference: bool, overwrite: bool) -> Settings {
836 Settings {
837 copy_settings: CopySettings {
838 dereference,
839 fail_early: false,
840 overwrite,
841 overwrite_compare: filecmp::MetadataCmpSettings {
842 size: true,
843 mtime: true,
844 ..Default::default()
845 },
846 chunk_size: 0,
847 remote_copy_buffer_size: 0,
848 filter: None,
849 dry_run: None,
850 },
851 update_compare: filecmp::MetadataCmpSettings {
852 size: true,
853 mtime: true,
854 ..Default::default()
855 },
856 update_exclusive: false,
857 filter: None,
858 dry_run: None,
859 preserve: preserve::preserve_all(),
860 }
861 }
862
863 #[tokio::test]
864 #[traced_test]
865 async fn test_basic_link() -> Result<(), anyhow::Error> {
866 let tmp_dir = testutils::setup_test_dir().await?;
867 let test_path = tmp_dir.as_path();
868 let summary = link(
869 &PROGRESS,
870 test_path,
871 &test_path.join("foo"),
872 &test_path.join("bar"),
873 &None,
874 &common_settings(false, false),
875 false,
876 )
877 .await?;
878 assert_eq!(summary.hard_links_created, 5);
879 assert_eq!(summary.copy_summary.files_copied, 0);
880 assert_eq!(summary.copy_summary.symlinks_created, 2);
881 assert_eq!(summary.copy_summary.directories_created, 3);
882 testutils::check_dirs_identical(
883 &test_path.join("foo"),
884 &test_path.join("bar"),
885 testutils::FileEqualityCheck::Timestamp,
886 )
887 .await?;
888 Ok(())
889 }
890
891 #[tokio::test]
892 #[traced_test]
893 async fn test_basic_link_update() -> Result<(), anyhow::Error> {
894 let tmp_dir = testutils::setup_test_dir().await?;
895 let test_path = tmp_dir.as_path();
896 let summary = link(
897 &PROGRESS,
898 test_path,
899 &test_path.join("foo"),
900 &test_path.join("bar"),
901 &Some(test_path.join("foo")),
902 &common_settings(false, false),
903 false,
904 )
905 .await?;
906 assert_eq!(summary.hard_links_created, 5);
907 assert_eq!(summary.copy_summary.files_copied, 0);
908 assert_eq!(summary.copy_summary.symlinks_created, 2);
909 assert_eq!(summary.copy_summary.directories_created, 3);
910 testutils::check_dirs_identical(
911 &test_path.join("foo"),
912 &test_path.join("bar"),
913 testutils::FileEqualityCheck::Timestamp,
914 )
915 .await?;
916 Ok(())
917 }
918
919 #[tokio::test]
920 #[traced_test]
921 async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
922 let tmp_dir = testutils::setup_test_dir().await?;
923 tokio::fs::create_dir(tmp_dir.join("baz")).await?;
924 let test_path = tmp_dir.as_path();
925 let summary = link(
926 &PROGRESS,
927 test_path,
928 &test_path.join("baz"), &test_path.join("bar"),
930 &Some(test_path.join("foo")),
931 &common_settings(false, false),
932 false,
933 )
934 .await?;
935 assert_eq!(summary.hard_links_created, 0);
936 assert_eq!(summary.copy_summary.files_copied, 5);
937 assert_eq!(summary.copy_summary.symlinks_created, 2);
938 assert_eq!(summary.copy_summary.directories_created, 3);
939 testutils::check_dirs_identical(
940 &test_path.join("foo"),
941 &test_path.join("bar"),
942 testutils::FileEqualityCheck::Timestamp,
943 )
944 .await?;
945 Ok(())
946 }
947
948 #[tokio::test]
949 #[traced_test]
950 async fn test_link_destination_permission_error_includes_root_cause(
951 ) -> Result<(), anyhow::Error> {
952 let tmp_dir = testutils::setup_test_dir().await?;
953 let test_path = tmp_dir.as_path();
954 let readonly_parent = test_path.join("readonly_dest");
955 tokio::fs::create_dir(&readonly_parent).await?;
956 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
957 .await?;
958
959 let mut settings = common_settings(false, false);
960 settings.copy_settings.fail_early = true;
961
962 let result = link(
963 &PROGRESS,
964 test_path,
965 &test_path.join("foo"),
966 &readonly_parent.join("bar"),
967 &None,
968 &settings,
969 false,
970 )
971 .await;
972
973 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
975 .await?;
976
977 assert!(result.is_err(), "link into read-only parent should fail");
978 let err = result.unwrap_err();
979 let err_msg = format!("{:#}", err.source);
980 assert!(
981 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
982 "Error message must include permission denied text. Got: {}",
983 err_msg
984 );
985 Ok(())
986 }
987
988 pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
989 let foo_path = tmp_dir.join("update");
995 tokio::fs::create_dir(&foo_path).await.unwrap();
996 tokio::fs::write(foo_path.join("0.txt"), "0-new")
997 .await
998 .unwrap();
999 let bar_path = foo_path.join("bar");
1000 tokio::fs::create_dir(&bar_path).await.unwrap();
1001 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1002 .await
1003 .unwrap();
1004 tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
1005 .await
1006 .unwrap();
1007 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1008 Ok(())
1009 }
1010
1011 #[tokio::test]
1012 #[traced_test]
1013 async fn test_link_update() -> Result<(), anyhow::Error> {
1014 let tmp_dir = testutils::setup_test_dir().await?;
1015 setup_update_dir(&tmp_dir).await?;
1016 let test_path = tmp_dir.as_path();
1017 let summary = link(
1018 &PROGRESS,
1019 test_path,
1020 &test_path.join("foo"),
1021 &test_path.join("bar"),
1022 &Some(test_path.join("update")),
1023 &common_settings(false, false),
1024 false,
1025 )
1026 .await?;
1027 assert_eq!(summary.hard_links_created, 2);
1028 assert_eq!(summary.copy_summary.files_copied, 2);
1029 assert_eq!(summary.copy_summary.symlinks_created, 3);
1030 assert_eq!(summary.copy_summary.directories_created, 3);
1031 testutils::check_dirs_identical(
1033 &test_path.join("foo").join("baz"),
1034 &test_path.join("bar").join("baz"),
1035 testutils::FileEqualityCheck::HardLink,
1036 )
1037 .await?;
1038 testutils::check_dirs_identical(
1040 &test_path.join("update"),
1041 &test_path.join("bar"),
1042 testutils::FileEqualityCheck::Timestamp,
1043 )
1044 .await?;
1045 Ok(())
1046 }
1047
1048 #[tokio::test]
1049 #[traced_test]
1050 async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
1051 let tmp_dir = testutils::setup_test_dir().await?;
1052 setup_update_dir(&tmp_dir).await?;
1053 let test_path = tmp_dir.as_path();
1054 let mut settings = common_settings(false, false);
1055 settings.update_exclusive = true;
1056 let summary = link(
1057 &PROGRESS,
1058 test_path,
1059 &test_path.join("foo"),
1060 &test_path.join("bar"),
1061 &Some(test_path.join("update")),
1062 &settings,
1063 false,
1064 )
1065 .await?;
1066 assert_eq!(summary.hard_links_created, 0);
1072 assert_eq!(summary.copy_summary.files_copied, 2);
1073 assert_eq!(summary.copy_summary.symlinks_created, 1);
1074 assert_eq!(summary.copy_summary.directories_created, 2);
1075 testutils::check_dirs_identical(
1077 &test_path.join("update"),
1078 &test_path.join("bar"),
1079 testutils::FileEqualityCheck::Timestamp,
1080 )
1081 .await?;
1082 Ok(())
1083 }
1084
1085 async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
1086 let tmp_dir = testutils::setup_test_dir().await?;
1087 let test_path = tmp_dir.as_path();
1088 let summary = link(
1089 &PROGRESS,
1090 test_path,
1091 &test_path.join("foo"),
1092 &test_path.join("bar"),
1093 &None,
1094 &common_settings(false, false),
1095 false,
1096 )
1097 .await?;
1098 assert_eq!(summary.hard_links_created, 5);
1099 assert_eq!(summary.copy_summary.symlinks_created, 2);
1100 assert_eq!(summary.copy_summary.directories_created, 3);
1101 Ok(tmp_dir)
1102 }
1103
1104 #[tokio::test]
1105 #[traced_test]
1106 async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
1107 let tmp_dir = setup_test_dir_and_link().await?;
1108 let output_path = &tmp_dir.join("bar");
1109 {
1110 let summary = rm::rm(
1121 &PROGRESS,
1122 &output_path.join("bar"),
1123 &rm::Settings {
1124 fail_early: false,
1125 filter: None,
1126 dry_run: None,
1127 },
1128 )
1129 .await?
1130 + rm::rm(
1131 &PROGRESS,
1132 &output_path.join("baz").join("5.txt"),
1133 &rm::Settings {
1134 fail_early: false,
1135 filter: None,
1136 dry_run: None,
1137 },
1138 )
1139 .await?;
1140 assert_eq!(summary.files_removed, 3);
1141 assert_eq!(summary.symlinks_removed, 1);
1142 assert_eq!(summary.directories_removed, 1);
1143 }
1144 let summary = link(
1145 &PROGRESS,
1146 &tmp_dir,
1147 &tmp_dir.join("foo"),
1148 output_path,
1149 &None,
1150 &common_settings(false, true), false,
1152 )
1153 .await?;
1154 assert_eq!(summary.hard_links_created, 3);
1155 assert_eq!(summary.copy_summary.symlinks_created, 1);
1156 assert_eq!(summary.copy_summary.directories_created, 1);
1157 testutils::check_dirs_identical(
1158 &tmp_dir.join("foo"),
1159 output_path,
1160 testutils::FileEqualityCheck::Timestamp,
1161 )
1162 .await?;
1163 Ok(())
1164 }
1165
1166 #[tokio::test]
1167 #[traced_test]
1168 async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
1169 let tmp_dir = setup_test_dir_and_link().await?;
1170 let output_path = &tmp_dir.join("bar");
1171 {
1172 let summary = rm::rm(
1183 &PROGRESS,
1184 &output_path.join("bar"),
1185 &rm::Settings {
1186 fail_early: false,
1187 filter: None,
1188 dry_run: None,
1189 },
1190 )
1191 .await?
1192 + rm::rm(
1193 &PROGRESS,
1194 &output_path.join("baz").join("5.txt"),
1195 &rm::Settings {
1196 fail_early: false,
1197 filter: None,
1198 dry_run: None,
1199 },
1200 )
1201 .await?;
1202 assert_eq!(summary.files_removed, 3);
1203 assert_eq!(summary.symlinks_removed, 1);
1204 assert_eq!(summary.directories_removed, 1);
1205 }
1206 setup_update_dir(&tmp_dir).await?;
1207 let summary = link(
1213 &PROGRESS,
1214 &tmp_dir,
1215 &tmp_dir.join("foo"),
1216 output_path,
1217 &Some(tmp_dir.join("update")),
1218 &common_settings(false, true), false,
1220 )
1221 .await?;
1222 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);
1226 testutils::check_dirs_identical(
1228 &tmp_dir.join("foo").join("baz"),
1229 &tmp_dir.join("bar").join("baz"),
1230 testutils::FileEqualityCheck::HardLink,
1231 )
1232 .await?;
1233 testutils::check_dirs_identical(
1235 &tmp_dir.join("update"),
1236 &tmp_dir.join("bar"),
1237 testutils::FileEqualityCheck::Timestamp,
1238 )
1239 .await?;
1240 Ok(())
1241 }
1242
1243 #[tokio::test]
1244 #[traced_test]
1245 async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
1246 let tmp_dir = setup_test_dir_and_link().await?;
1247 let output_path = &tmp_dir.join("bar");
1248 {
1249 let bar_path = output_path.join("bar");
1258 let summary = rm::rm(
1259 &PROGRESS,
1260 &bar_path.join("1.txt"),
1261 &rm::Settings {
1262 fail_early: false,
1263 filter: None,
1264 dry_run: None,
1265 },
1266 )
1267 .await?
1268 + rm::rm(
1269 &PROGRESS,
1270 &bar_path.join("2.txt"),
1271 &rm::Settings {
1272 fail_early: false,
1273 filter: None,
1274 dry_run: None,
1275 },
1276 )
1277 .await?
1278 + rm::rm(
1279 &PROGRESS,
1280 &bar_path.join("3.txt"),
1281 &rm::Settings {
1282 fail_early: false,
1283 filter: None,
1284 dry_run: None,
1285 },
1286 )
1287 .await?
1288 + rm::rm(
1289 &PROGRESS,
1290 &output_path.join("baz"),
1291 &rm::Settings {
1292 fail_early: false,
1293 filter: None,
1294 dry_run: None,
1295 },
1296 )
1297 .await?;
1298 assert_eq!(summary.files_removed, 4);
1299 assert_eq!(summary.symlinks_removed, 2);
1300 assert_eq!(summary.directories_removed, 1);
1301 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1303 .await
1304 .unwrap();
1305 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1306 .await
1307 .unwrap();
1308 tokio::fs::create_dir(&bar_path.join("3.txt"))
1309 .await
1310 .unwrap();
1311 tokio::fs::write(&output_path.join("baz"), "baz")
1312 .await
1313 .unwrap();
1314 }
1315 let summary = link(
1316 &PROGRESS,
1317 &tmp_dir,
1318 &tmp_dir.join("foo"),
1319 output_path,
1320 &None,
1321 &common_settings(false, true), false,
1323 )
1324 .await?;
1325 assert_eq!(summary.hard_links_created, 4);
1326 assert_eq!(summary.copy_summary.files_copied, 0);
1327 assert_eq!(summary.copy_summary.symlinks_created, 2);
1328 assert_eq!(summary.copy_summary.directories_created, 1);
1329 testutils::check_dirs_identical(
1330 &tmp_dir.join("foo"),
1331 &tmp_dir.join("bar"),
1332 testutils::FileEqualityCheck::HardLink,
1333 )
1334 .await?;
1335 Ok(())
1336 }
1337
1338 #[tokio::test]
1339 #[traced_test]
1340 async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
1341 let tmp_dir = setup_test_dir_and_link().await?;
1342 let output_path = &tmp_dir.join("bar");
1343 {
1344 let bar_path = output_path.join("bar");
1353 let summary = rm::rm(
1354 &PROGRESS,
1355 &bar_path.join("1.txt"),
1356 &rm::Settings {
1357 fail_early: false,
1358 filter: None,
1359 dry_run: None,
1360 },
1361 )
1362 .await?
1363 + rm::rm(
1364 &PROGRESS,
1365 &bar_path.join("2.txt"),
1366 &rm::Settings {
1367 fail_early: false,
1368 filter: None,
1369 dry_run: None,
1370 },
1371 )
1372 .await?
1373 + rm::rm(
1374 &PROGRESS,
1375 &bar_path.join("3.txt"),
1376 &rm::Settings {
1377 fail_early: false,
1378 filter: None,
1379 dry_run: 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 },
1391 )
1392 .await?;
1393 assert_eq!(summary.files_removed, 4);
1394 assert_eq!(summary.symlinks_removed, 2);
1395 assert_eq!(summary.directories_removed, 1);
1396 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1398 .await
1399 .unwrap();
1400 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1401 .await
1402 .unwrap();
1403 tokio::fs::create_dir(&bar_path.join("3.txt"))
1404 .await
1405 .unwrap();
1406 tokio::fs::write(&output_path.join("baz"), "baz")
1407 .await
1408 .unwrap();
1409 }
1410 let source_path = &tmp_dir.join("foo");
1411 tokio::fs::set_permissions(
1413 &source_path.join("baz"),
1414 std::fs::Permissions::from_mode(0o000),
1415 )
1416 .await?;
1417 match link(
1421 &PROGRESS,
1422 &tmp_dir,
1423 &tmp_dir.join("foo"),
1424 output_path,
1425 &None,
1426 &common_settings(false, true), false,
1428 )
1429 .await
1430 {
1431 Ok(_) => panic!("Expected the link to error!"),
1432 Err(error) => {
1433 tracing::info!("{}", &error);
1434 assert_eq!(error.summary.hard_links_created, 3);
1435 assert_eq!(error.summary.copy_summary.files_copied, 0);
1436 assert_eq!(error.summary.copy_summary.symlinks_created, 0);
1437 assert_eq!(error.summary.copy_summary.directories_created, 0);
1438 assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
1439 assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
1440 assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
1441 }
1442 }
1443 Ok(())
1444 }
1445
1446 #[tokio::test]
1450 #[traced_test]
1451 async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
1452 let tmp_dir = testutils::create_temp_dir().await?;
1453 let test_path = tmp_dir.as_path();
1454 let src_dir = test_path.join("src");
1456 tokio::fs::create_dir(&src_dir).await?;
1457 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
1458 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
1460 let unreadable_subdir = src_dir.join("unreadable_subdir");
1463 tokio::fs::create_dir(&unreadable_subdir).await?;
1464 tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
1465 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
1466 .await?;
1467 let dst_dir = test_path.join("dst");
1468 let result = link(
1470 &PROGRESS,
1471 test_path,
1472 &src_dir,
1473 &dst_dir,
1474 &None,
1475 &common_settings(false, false),
1476 false,
1477 )
1478 .await;
1479 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
1481 .await?;
1482 assert!(
1484 result.is_err(),
1485 "link should fail due to unreadable subdirectory"
1486 );
1487 let error = result.unwrap_err();
1488 assert_eq!(error.summary.hard_links_created, 1);
1490 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
1492 assert!(dst_metadata.is_dir());
1493 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
1494 assert_eq!(
1495 actual_mode, 0o750,
1496 "directory should have preserved source permissions (0o750), got {:o}",
1497 actual_mode
1498 );
1499 Ok(())
1500 }
1501 mod filter_tests {
1502 use super::*;
1503 use crate::filter::FilterSettings;
1504 #[tokio::test]
1506 #[traced_test]
1507 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
1508 let tmp_dir = testutils::setup_test_dir().await?;
1509 let test_path = tmp_dir.as_path();
1510 let mut filter = FilterSettings::new();
1512 filter.add_include("bar/*.txt").unwrap();
1513 let summary = link(
1514 &PROGRESS,
1515 test_path,
1516 &test_path.join("foo"),
1517 &test_path.join("dst"),
1518 &None,
1519 &Settings {
1520 copy_settings: CopySettings {
1521 dereference: false,
1522 fail_early: false,
1523 overwrite: false,
1524 overwrite_compare: Default::default(),
1525 chunk_size: 0,
1526 remote_copy_buffer_size: 0,
1527 filter: None,
1528 dry_run: None,
1529 },
1530 update_compare: Default::default(),
1531 update_exclusive: false,
1532 filter: Some(filter),
1533 dry_run: None,
1534 preserve: preserve::preserve_all(),
1535 },
1536 false,
1537 )
1538 .await?;
1539 assert_eq!(
1541 summary.hard_links_created, 3,
1542 "should link 3 files matching bar/*.txt"
1543 );
1544 assert!(
1546 test_path.join("dst/bar/1.txt").exists(),
1547 "bar/1.txt should be linked"
1548 );
1549 assert!(
1550 test_path.join("dst/bar/2.txt").exists(),
1551 "bar/2.txt should be linked"
1552 );
1553 assert!(
1554 test_path.join("dst/bar/3.txt").exists(),
1555 "bar/3.txt should be linked"
1556 );
1557 assert!(
1559 !test_path.join("dst/0.txt").exists(),
1560 "0.txt should not be linked"
1561 );
1562 Ok(())
1563 }
1564 #[tokio::test]
1566 #[traced_test]
1567 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
1568 let tmp_dir = testutils::setup_test_dir().await?;
1569 let test_path = tmp_dir.as_path();
1570 let mut filter = FilterSettings::new();
1572 filter.add_exclude("*.txt").unwrap();
1573 let summary = link(
1574 &PROGRESS,
1575 test_path,
1576 &test_path.join("foo/0.txt"), &test_path.join("dst/0.txt"),
1578 &None,
1579 &Settings {
1580 copy_settings: CopySettings {
1581 dereference: false,
1582 fail_early: false,
1583 overwrite: false,
1584 overwrite_compare: Default::default(),
1585 chunk_size: 0,
1586 remote_copy_buffer_size: 0,
1587 filter: None,
1588 dry_run: None,
1589 },
1590 update_compare: Default::default(),
1591 update_exclusive: false,
1592 filter: Some(filter),
1593 dry_run: None,
1594 preserve: preserve::preserve_all(),
1595 },
1596 false,
1597 )
1598 .await?;
1599 assert_eq!(
1601 summary.hard_links_created, 0,
1602 "file matching exclude pattern should not be linked"
1603 );
1604 assert!(
1605 !test_path.join("dst/0.txt").exists(),
1606 "excluded file should not exist at destination"
1607 );
1608 Ok(())
1609 }
1610 #[tokio::test]
1612 #[traced_test]
1613 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
1614 let test_path = testutils::create_temp_dir().await?;
1615 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
1617 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
1618 let mut filter = FilterSettings::new();
1620 filter.add_exclude("*_dir/").unwrap();
1621 let result = link(
1622 &PROGRESS,
1623 &test_path,
1624 &test_path.join("excluded_dir"),
1625 &test_path.join("dst"),
1626 &None,
1627 &Settings {
1628 copy_settings: CopySettings {
1629 dereference: false,
1630 fail_early: false,
1631 overwrite: false,
1632 overwrite_compare: Default::default(),
1633 chunk_size: 0,
1634 remote_copy_buffer_size: 0,
1635 filter: None,
1636 dry_run: None,
1637 },
1638 update_compare: Default::default(),
1639 update_exclusive: false,
1640 filter: Some(filter),
1641 dry_run: None,
1642 preserve: preserve::preserve_all(),
1643 },
1644 false,
1645 )
1646 .await?;
1647 assert_eq!(
1649 result.copy_summary.directories_created, 0,
1650 "root directory matching exclude should not be created"
1651 );
1652 assert!(
1653 !test_path.join("dst").exists(),
1654 "excluded root directory should not exist at destination"
1655 );
1656 Ok(())
1657 }
1658 #[tokio::test]
1660 #[traced_test]
1661 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
1662 let test_path = testutils::create_temp_dir().await?;
1663 tokio::fs::write(test_path.join("target.txt"), "content").await?;
1665 tokio::fs::symlink(
1666 test_path.join("target.txt"),
1667 test_path.join("excluded_link"),
1668 )
1669 .await?;
1670 let mut filter = FilterSettings::new();
1672 filter.add_exclude("*_link").unwrap();
1673 let result = link(
1674 &PROGRESS,
1675 &test_path,
1676 &test_path.join("excluded_link"),
1677 &test_path.join("dst"),
1678 &None,
1679 &Settings {
1680 copy_settings: CopySettings {
1681 dereference: false,
1682 fail_early: false,
1683 overwrite: false,
1684 overwrite_compare: Default::default(),
1685 chunk_size: 0,
1686 remote_copy_buffer_size: 0,
1687 filter: None,
1688 dry_run: None,
1689 },
1690 update_compare: Default::default(),
1691 update_exclusive: false,
1692 filter: Some(filter),
1693 dry_run: None,
1694 preserve: preserve::preserve_all(),
1695 },
1696 false,
1697 )
1698 .await?;
1699 assert_eq!(
1701 result.copy_summary.symlinks_created, 0,
1702 "root symlink matching exclude should not be created"
1703 );
1704 assert!(
1705 !test_path.join("dst").exists(),
1706 "excluded root symlink should not exist at destination"
1707 );
1708 Ok(())
1709 }
1710 #[tokio::test]
1712 #[traced_test]
1713 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
1714 let tmp_dir = testutils::setup_test_dir().await?;
1715 let test_path = tmp_dir.as_path();
1716 let mut filter = FilterSettings::new();
1723 filter.add_include("bar/*.txt").unwrap();
1724 filter.add_exclude("bar/2.txt").unwrap();
1725 let summary = link(
1726 &PROGRESS,
1727 test_path,
1728 &test_path.join("foo"),
1729 &test_path.join("dst"),
1730 &None,
1731 &Settings {
1732 copy_settings: CopySettings {
1733 dereference: false,
1734 fail_early: false,
1735 overwrite: false,
1736 overwrite_compare: Default::default(),
1737 chunk_size: 0,
1738 remote_copy_buffer_size: 0,
1739 filter: None,
1740 dry_run: None,
1741 },
1742 update_compare: Default::default(),
1743 update_exclusive: false,
1744 filter: Some(filter),
1745 dry_run: None,
1746 preserve: preserve::preserve_all(),
1747 },
1748 false,
1749 )
1750 .await?;
1751 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1754 assert_eq!(
1755 summary.copy_summary.files_skipped, 2,
1756 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
1757 );
1758 assert!(
1760 test_path.join("dst/bar/1.txt").exists(),
1761 "bar/1.txt should be linked"
1762 );
1763 assert!(
1764 !test_path.join("dst/bar/2.txt").exists(),
1765 "bar/2.txt should be excluded"
1766 );
1767 assert!(
1768 test_path.join("dst/bar/3.txt").exists(),
1769 "bar/3.txt should be linked"
1770 );
1771 Ok(())
1772 }
1773 #[tokio::test]
1775 #[traced_test]
1776 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
1777 let tmp_dir = testutils::setup_test_dir().await?;
1778 let test_path = tmp_dir.as_path();
1779 let mut filter = FilterSettings::new();
1786 filter.add_exclude("bar/").unwrap();
1787 let summary = link(
1788 &PROGRESS,
1789 test_path,
1790 &test_path.join("foo"),
1791 &test_path.join("dst"),
1792 &None,
1793 &Settings {
1794 copy_settings: CopySettings {
1795 dereference: false,
1796 fail_early: false,
1797 overwrite: false,
1798 overwrite_compare: Default::default(),
1799 chunk_size: 0,
1800 remote_copy_buffer_size: 0,
1801 filter: None,
1802 dry_run: None,
1803 },
1804 update_compare: Default::default(),
1805 update_exclusive: false,
1806 filter: Some(filter),
1807 dry_run: None,
1808 preserve: preserve::preserve_all(),
1809 },
1810 false,
1811 )
1812 .await?;
1813 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1817 assert_eq!(
1818 summary.copy_summary.symlinks_created, 2,
1819 "should copy 2 symlinks"
1820 );
1821 assert_eq!(
1822 summary.copy_summary.directories_skipped, 1,
1823 "should skip 1 directory (bar)"
1824 );
1825 assert!(
1827 !test_path.join("dst/bar").exists(),
1828 "bar directory should not be linked"
1829 );
1830 Ok(())
1831 }
1832 #[tokio::test]
1835 #[traced_test]
1836 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
1837 let test_path = testutils::create_temp_dir().await?;
1838 let src_path = test_path.join("src");
1844 tokio::fs::create_dir(&src_path).await?;
1845 tokio::fs::write(src_path.join("foo"), "content").await?;
1846 tokio::fs::write(src_path.join("bar"), "content").await?;
1847 tokio::fs::create_dir(src_path.join("baz")).await?;
1848 let mut filter = FilterSettings::new();
1850 filter.add_include("foo").unwrap();
1851 let summary = link(
1852 &PROGRESS,
1853 &test_path,
1854 &src_path,
1855 &test_path.join("dst"),
1856 &None,
1857 &Settings {
1858 copy_settings: copy::Settings {
1859 dereference: false,
1860 fail_early: false,
1861 overwrite: false,
1862 overwrite_compare: Default::default(),
1863 chunk_size: 0,
1864 remote_copy_buffer_size: 0,
1865 filter: None,
1866 dry_run: None,
1867 },
1868 update_compare: Default::default(),
1869 update_exclusive: false,
1870 filter: Some(filter),
1871 dry_run: None,
1872 preserve: preserve::preserve_all(),
1873 },
1874 false,
1875 )
1876 .await?;
1877 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
1879 assert_eq!(
1880 summary.copy_summary.directories_created, 1,
1881 "should create only root directory (not empty 'baz')"
1882 );
1883 assert!(
1885 test_path.join("dst").join("foo").exists(),
1886 "foo should be linked"
1887 );
1888 assert!(
1890 !test_path.join("dst").join("bar").exists(),
1891 "bar should not be linked"
1892 );
1893 assert!(
1895 !test_path.join("dst").join("baz").exists(),
1896 "empty baz directory should NOT be created"
1897 );
1898 Ok(())
1899 }
1900 #[tokio::test]
1903 #[traced_test]
1904 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
1905 let test_path = testutils::create_temp_dir().await?;
1906 let src_path = test_path.join("src");
1913 tokio::fs::create_dir(&src_path).await?;
1914 tokio::fs::write(src_path.join("foo"), "content").await?;
1915 tokio::fs::create_dir(src_path.join("baz")).await?;
1916 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
1917 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
1918 let mut filter = FilterSettings::new();
1920 filter.add_include("foo").unwrap();
1921 let summary = link(
1922 &PROGRESS,
1923 &test_path,
1924 &src_path,
1925 &test_path.join("dst"),
1926 &None,
1927 &Settings {
1928 copy_settings: copy::Settings {
1929 dereference: false,
1930 fail_early: false,
1931 overwrite: false,
1932 overwrite_compare: Default::default(),
1933 chunk_size: 0,
1934 remote_copy_buffer_size: 0,
1935 filter: None,
1936 dry_run: None,
1937 },
1938 update_compare: Default::default(),
1939 update_exclusive: false,
1940 filter: Some(filter),
1941 dry_run: None,
1942 preserve: preserve::preserve_all(),
1943 },
1944 false,
1945 )
1946 .await?;
1947 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
1949 assert_eq!(
1950 summary.copy_summary.files_skipped, 2,
1951 "should skip 2 files (qux and quux)"
1952 );
1953 assert_eq!(
1954 summary.copy_summary.directories_created, 1,
1955 "should create only root directory (not 'baz' with non-matching content)"
1956 );
1957 assert!(
1959 test_path.join("dst").join("foo").exists(),
1960 "foo should be linked"
1961 );
1962 assert!(
1964 !test_path.join("dst").join("baz").exists(),
1965 "baz directory should NOT be created (no matching content inside)"
1966 );
1967 Ok(())
1968 }
1969 #[tokio::test]
1972 #[traced_test]
1973 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
1974 let test_path = testutils::create_temp_dir().await?;
1975 let src_path = test_path.join("src");
1981 tokio::fs::create_dir(&src_path).await?;
1982 tokio::fs::write(src_path.join("foo"), "content").await?;
1983 tokio::fs::write(src_path.join("bar"), "content").await?;
1984 tokio::fs::create_dir(src_path.join("baz")).await?;
1985 let mut filter = FilterSettings::new();
1987 filter.add_include("foo").unwrap();
1988 let summary = link(
1989 &PROGRESS,
1990 &test_path,
1991 &src_path,
1992 &test_path.join("dst"),
1993 &None,
1994 &Settings {
1995 copy_settings: copy::Settings {
1996 dereference: false,
1997 fail_early: false,
1998 overwrite: false,
1999 overwrite_compare: Default::default(),
2000 chunk_size: 0,
2001 remote_copy_buffer_size: 0,
2002 filter: None,
2003 dry_run: None,
2004 },
2005 update_compare: Default::default(),
2006 update_exclusive: false,
2007 filter: Some(filter),
2008 dry_run: Some(crate::config::DryRunMode::Explain),
2009 preserve: preserve::preserve_all(),
2010 },
2011 false,
2012 )
2013 .await?;
2014 assert_eq!(
2016 summary.hard_links_created, 1,
2017 "should report only 'foo' would be linked"
2018 );
2019 assert_eq!(
2020 summary.copy_summary.directories_created, 1,
2021 "should report only root directory would be created (not empty 'baz')"
2022 );
2023 assert!(
2025 !test_path.join("dst").exists(),
2026 "dst should not exist in dry-run"
2027 );
2028 Ok(())
2029 }
2030 #[tokio::test]
2033 #[traced_test]
2034 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
2035 let test_path = testutils::create_temp_dir().await?;
2036 let src_path = test_path.join("src");
2042 tokio::fs::create_dir(&src_path).await?;
2043 tokio::fs::write(src_path.join("foo"), "content").await?;
2044 tokio::fs::write(src_path.join("bar"), "content").await?;
2045 tokio::fs::create_dir(src_path.join("baz")).await?;
2046 let dst_path = test_path.join("dst");
2048 tokio::fs::create_dir(&dst_path).await?;
2049 tokio::fs::create_dir(dst_path.join("baz")).await?;
2050 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
2052 let mut filter = FilterSettings::new();
2054 filter.add_include("foo").unwrap();
2055 let summary = link(
2056 &PROGRESS,
2057 &test_path,
2058 &src_path,
2059 &dst_path,
2060 &None,
2061 &Settings {
2062 copy_settings: copy::Settings {
2063 dereference: false,
2064 fail_early: false,
2065 overwrite: true, overwrite_compare: Default::default(),
2067 chunk_size: 0,
2068 remote_copy_buffer_size: 0,
2069 filter: None,
2070 dry_run: None,
2071 },
2072 update_compare: Default::default(),
2073 update_exclusive: false,
2074 filter: Some(filter),
2075 dry_run: None,
2076 preserve: preserve::preserve_all(),
2077 },
2078 false,
2079 )
2080 .await?;
2081 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2083 assert_eq!(
2085 summary.copy_summary.directories_unchanged, 2,
2086 "root dst and baz directories should be unchanged"
2087 );
2088 assert_eq!(
2089 summary.copy_summary.directories_created, 0,
2090 "should not create any directories"
2091 );
2092 assert!(dst_path.join("foo").exists(), "foo should be linked");
2094 assert!(!dst_path.join("bar").exists(), "bar should not be linked");
2096 assert!(
2098 dst_path.join("baz").exists(),
2099 "existing baz directory should still exist"
2100 );
2101 assert!(
2102 dst_path.join("baz").join("marker.txt").exists(),
2103 "existing content in baz should still exist"
2104 );
2105 Ok(())
2106 }
2107 }
2108 mod dry_run_tests {
2109 use super::*;
2110 #[tokio::test]
2112 #[traced_test]
2113 async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
2114 let tmp_dir = testutils::setup_test_dir().await?;
2115 let test_path = tmp_dir.as_path();
2116 let src_file = test_path.join("foo/0.txt");
2117 let dst_file = test_path.join("dst_link.txt");
2118 assert!(
2120 !dst_file.exists(),
2121 "destination should not exist before dry-run"
2122 );
2123 let summary = link(
2124 &PROGRESS,
2125 test_path,
2126 &src_file,
2127 &dst_file,
2128 &None,
2129 &Settings {
2130 copy_settings: CopySettings {
2131 dereference: false,
2132 fail_early: false,
2133 overwrite: false,
2134 overwrite_compare: Default::default(),
2135 chunk_size: 0,
2136 remote_copy_buffer_size: 0,
2137 filter: None,
2138 dry_run: None,
2139 },
2140 update_compare: Default::default(),
2141 update_exclusive: false,
2142 filter: None,
2143 dry_run: Some(crate::config::DryRunMode::Brief),
2144 preserve: preserve::preserve_all(),
2145 },
2146 false,
2147 )
2148 .await?;
2149 assert!(!dst_file.exists(), "dry-run should not create hard link");
2151 assert_eq!(
2153 summary.hard_links_created, 1,
2154 "dry-run should report 1 hard link that would be created"
2155 );
2156 Ok(())
2157 }
2158 #[tokio::test]
2160 #[traced_test]
2161 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
2162 let tmp_dir = testutils::setup_test_dir().await?;
2163 let test_path = tmp_dir.as_path();
2164 let dst_path = test_path.join("nonexistent_dst");
2165 assert!(
2167 !dst_path.exists(),
2168 "destination should not exist before dry-run"
2169 );
2170 let summary = link(
2171 &PROGRESS,
2172 test_path,
2173 &test_path.join("foo"),
2174 &dst_path,
2175 &None,
2176 &Settings {
2177 copy_settings: CopySettings {
2178 dereference: false,
2179 fail_early: false,
2180 overwrite: false,
2181 overwrite_compare: Default::default(),
2182 chunk_size: 0,
2183 remote_copy_buffer_size: 0,
2184 filter: None,
2185 dry_run: None,
2186 },
2187 update_compare: Default::default(),
2188 update_exclusive: false,
2189 filter: None,
2190 dry_run: Some(crate::config::DryRunMode::Brief),
2191 preserve: preserve::preserve_all(),
2192 },
2193 false,
2194 )
2195 .await?;
2196 assert!(
2198 !dst_path.exists(),
2199 "dry-run should not create destination directory"
2200 );
2201 assert!(
2203 summary.hard_links_created > 0,
2204 "dry-run should report hard links that would be created"
2205 );
2206 Ok(())
2207 }
2208 #[tokio::test]
2210 #[traced_test]
2211 async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
2212 let tmp_dir = testutils::setup_test_dir().await?;
2213 let test_path = tmp_dir.as_path();
2214 let src_path = test_path.join("foo/baz");
2216 let dst_path = test_path.join("dst_baz");
2217 assert!(
2219 !dst_path.exists(),
2220 "destination should not exist before dry-run"
2221 );
2222 let summary = link(
2223 &PROGRESS,
2224 test_path,
2225 &src_path,
2226 &dst_path,
2227 &None,
2228 &Settings {
2229 copy_settings: CopySettings {
2230 dereference: false,
2231 fail_early: false,
2232 overwrite: false,
2233 overwrite_compare: Default::default(),
2234 chunk_size: 0,
2235 remote_copy_buffer_size: 0,
2236 filter: None,
2237 dry_run: None,
2238 },
2239 update_compare: Default::default(),
2240 update_exclusive: false,
2241 filter: None,
2242 dry_run: Some(crate::config::DryRunMode::Brief),
2243 preserve: preserve::preserve_all(),
2244 },
2245 false,
2246 )
2247 .await?;
2248 assert!(!dst_path.exists(), "dry-run should not create destination");
2250 assert_eq!(
2252 summary.hard_links_created, 1,
2253 "dry-run should report 1 hard link (for 4.txt)"
2254 );
2255 assert_eq!(
2256 summary.copy_summary.symlinks_created, 2,
2257 "dry-run should report 2 symlinks (5.txt and 6.txt)"
2258 );
2259 Ok(())
2260 }
2261 }
2262
2263 #[tokio::test]
2270 #[traced_test]
2271 async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
2272 let tmp_dir = testutils::create_temp_dir().await?;
2273 let test_path = tmp_dir.as_path();
2274 let src_dir = test_path.join("src");
2279 let sub_dir = src_dir.join("sub");
2280 let bad_dir = sub_dir.join("unreadable_dir");
2281 tokio::fs::create_dir_all(&bad_dir).await?;
2282 tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
2283 tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
2284 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
2285 let dst_dir = test_path.join("dst");
2286 let result = link(
2287 &PROGRESS,
2288 test_path,
2289 &src_dir,
2290 &dst_dir,
2291 &None,
2292 &Settings {
2293 copy_settings: CopySettings {
2294 fail_early: true,
2295 ..common_settings(false, false).copy_settings
2296 },
2297 ..common_settings(false, false)
2298 },
2299 false,
2300 )
2301 .await;
2302 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
2304 let error = result.expect_err("link should fail due to unreadable directory");
2305 assert!(
2310 error.summary.copy_summary.directories_created >= 2,
2311 "fail-early summary should include directories from the failing subtree, \
2312 got directories_created={} (expected >= 2: dst/ and dst/sub/)",
2313 error.summary.copy_summary.directories_created
2314 );
2315 Ok(())
2316 }
2317}