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 overwrite_filter: None,
847 ignore_existing: false,
848 chunk_size: 0,
849 remote_copy_buffer_size: 0,
850 filter: None,
851 dry_run: None,
852 },
853 update_compare: filecmp::MetadataCmpSettings {
854 size: true,
855 mtime: true,
856 ..Default::default()
857 },
858 update_exclusive: false,
859 filter: None,
860 dry_run: None,
861 preserve: preserve::preserve_all(),
862 }
863 }
864
865 #[tokio::test]
866 #[traced_test]
867 async fn test_basic_link() -> Result<(), anyhow::Error> {
868 let tmp_dir = testutils::setup_test_dir().await?;
869 let test_path = tmp_dir.as_path();
870 let summary = link(
871 &PROGRESS,
872 test_path,
873 &test_path.join("foo"),
874 &test_path.join("bar"),
875 &None,
876 &common_settings(false, false),
877 false,
878 )
879 .await?;
880 assert_eq!(summary.hard_links_created, 5);
881 assert_eq!(summary.copy_summary.files_copied, 0);
882 assert_eq!(summary.copy_summary.symlinks_created, 2);
883 assert_eq!(summary.copy_summary.directories_created, 3);
884 testutils::check_dirs_identical(
885 &test_path.join("foo"),
886 &test_path.join("bar"),
887 testutils::FileEqualityCheck::Timestamp,
888 )
889 .await?;
890 Ok(())
891 }
892
893 #[tokio::test]
894 #[traced_test]
895 async fn test_basic_link_update() -> Result<(), anyhow::Error> {
896 let tmp_dir = testutils::setup_test_dir().await?;
897 let test_path = tmp_dir.as_path();
898 let summary = link(
899 &PROGRESS,
900 test_path,
901 &test_path.join("foo"),
902 &test_path.join("bar"),
903 &Some(test_path.join("foo")),
904 &common_settings(false, false),
905 false,
906 )
907 .await?;
908 assert_eq!(summary.hard_links_created, 5);
909 assert_eq!(summary.copy_summary.files_copied, 0);
910 assert_eq!(summary.copy_summary.symlinks_created, 2);
911 assert_eq!(summary.copy_summary.directories_created, 3);
912 testutils::check_dirs_identical(
913 &test_path.join("foo"),
914 &test_path.join("bar"),
915 testutils::FileEqualityCheck::Timestamp,
916 )
917 .await?;
918 Ok(())
919 }
920
921 #[tokio::test]
922 #[traced_test]
923 async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
924 let tmp_dir = testutils::setup_test_dir().await?;
925 tokio::fs::create_dir(tmp_dir.join("baz")).await?;
926 let test_path = tmp_dir.as_path();
927 let summary = link(
928 &PROGRESS,
929 test_path,
930 &test_path.join("baz"), &test_path.join("bar"),
932 &Some(test_path.join("foo")),
933 &common_settings(false, false),
934 false,
935 )
936 .await?;
937 assert_eq!(summary.hard_links_created, 0);
938 assert_eq!(summary.copy_summary.files_copied, 5);
939 assert_eq!(summary.copy_summary.symlinks_created, 2);
940 assert_eq!(summary.copy_summary.directories_created, 3);
941 testutils::check_dirs_identical(
942 &test_path.join("foo"),
943 &test_path.join("bar"),
944 testutils::FileEqualityCheck::Timestamp,
945 )
946 .await?;
947 Ok(())
948 }
949
950 #[tokio::test]
951 #[traced_test]
952 async fn test_link_destination_permission_error_includes_root_cause(
953 ) -> Result<(), anyhow::Error> {
954 let tmp_dir = testutils::setup_test_dir().await?;
955 let test_path = tmp_dir.as_path();
956 let readonly_parent = test_path.join("readonly_dest");
957 tokio::fs::create_dir(&readonly_parent).await?;
958 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
959 .await?;
960
961 let mut settings = common_settings(false, false);
962 settings.copy_settings.fail_early = true;
963
964 let result = link(
965 &PROGRESS,
966 test_path,
967 &test_path.join("foo"),
968 &readonly_parent.join("bar"),
969 &None,
970 &settings,
971 false,
972 )
973 .await;
974
975 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
977 .await?;
978
979 assert!(result.is_err(), "link into read-only parent should fail");
980 let err = result.unwrap_err();
981 let err_msg = format!("{:#}", err.source);
982 assert!(
983 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
984 "Error message must include permission denied text. Got: {}",
985 err_msg
986 );
987 Ok(())
988 }
989
990 pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
991 let foo_path = tmp_dir.join("update");
997 tokio::fs::create_dir(&foo_path).await.unwrap();
998 tokio::fs::write(foo_path.join("0.txt"), "0-new")
999 .await
1000 .unwrap();
1001 let bar_path = foo_path.join("bar");
1002 tokio::fs::create_dir(&bar_path).await.unwrap();
1003 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1004 .await
1005 .unwrap();
1006 tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
1007 .await
1008 .unwrap();
1009 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1010 Ok(())
1011 }
1012
1013 #[tokio::test]
1014 #[traced_test]
1015 async fn test_link_update() -> Result<(), anyhow::Error> {
1016 let tmp_dir = testutils::setup_test_dir().await?;
1017 setup_update_dir(&tmp_dir).await?;
1018 let test_path = tmp_dir.as_path();
1019 let summary = link(
1020 &PROGRESS,
1021 test_path,
1022 &test_path.join("foo"),
1023 &test_path.join("bar"),
1024 &Some(test_path.join("update")),
1025 &common_settings(false, false),
1026 false,
1027 )
1028 .await?;
1029 assert_eq!(summary.hard_links_created, 2);
1030 assert_eq!(summary.copy_summary.files_copied, 2);
1031 assert_eq!(summary.copy_summary.symlinks_created, 3);
1032 assert_eq!(summary.copy_summary.directories_created, 3);
1033 testutils::check_dirs_identical(
1035 &test_path.join("foo").join("baz"),
1036 &test_path.join("bar").join("baz"),
1037 testutils::FileEqualityCheck::HardLink,
1038 )
1039 .await?;
1040 testutils::check_dirs_identical(
1042 &test_path.join("update"),
1043 &test_path.join("bar"),
1044 testutils::FileEqualityCheck::Timestamp,
1045 )
1046 .await?;
1047 Ok(())
1048 }
1049
1050 #[tokio::test]
1051 #[traced_test]
1052 async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
1053 let tmp_dir = testutils::setup_test_dir().await?;
1054 setup_update_dir(&tmp_dir).await?;
1055 let test_path = tmp_dir.as_path();
1056 let mut settings = common_settings(false, false);
1057 settings.update_exclusive = true;
1058 let summary = link(
1059 &PROGRESS,
1060 test_path,
1061 &test_path.join("foo"),
1062 &test_path.join("bar"),
1063 &Some(test_path.join("update")),
1064 &settings,
1065 false,
1066 )
1067 .await?;
1068 assert_eq!(summary.hard_links_created, 0);
1074 assert_eq!(summary.copy_summary.files_copied, 2);
1075 assert_eq!(summary.copy_summary.symlinks_created, 1);
1076 assert_eq!(summary.copy_summary.directories_created, 2);
1077 testutils::check_dirs_identical(
1079 &test_path.join("update"),
1080 &test_path.join("bar"),
1081 testutils::FileEqualityCheck::Timestamp,
1082 )
1083 .await?;
1084 Ok(())
1085 }
1086
1087 async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
1088 let tmp_dir = testutils::setup_test_dir().await?;
1089 let test_path = tmp_dir.as_path();
1090 let summary = link(
1091 &PROGRESS,
1092 test_path,
1093 &test_path.join("foo"),
1094 &test_path.join("bar"),
1095 &None,
1096 &common_settings(false, false),
1097 false,
1098 )
1099 .await?;
1100 assert_eq!(summary.hard_links_created, 5);
1101 assert_eq!(summary.copy_summary.symlinks_created, 2);
1102 assert_eq!(summary.copy_summary.directories_created, 3);
1103 Ok(tmp_dir)
1104 }
1105
1106 #[tokio::test]
1107 #[traced_test]
1108 async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
1109 let tmp_dir = setup_test_dir_and_link().await?;
1110 let output_path = &tmp_dir.join("bar");
1111 {
1112 let summary = rm::rm(
1123 &PROGRESS,
1124 &output_path.join("bar"),
1125 &rm::Settings {
1126 fail_early: false,
1127 filter: None,
1128 dry_run: None,
1129 },
1130 )
1131 .await?
1132 + rm::rm(
1133 &PROGRESS,
1134 &output_path.join("baz").join("5.txt"),
1135 &rm::Settings {
1136 fail_early: false,
1137 filter: None,
1138 dry_run: None,
1139 },
1140 )
1141 .await?;
1142 assert_eq!(summary.files_removed, 3);
1143 assert_eq!(summary.symlinks_removed, 1);
1144 assert_eq!(summary.directories_removed, 1);
1145 }
1146 let summary = link(
1147 &PROGRESS,
1148 &tmp_dir,
1149 &tmp_dir.join("foo"),
1150 output_path,
1151 &None,
1152 &common_settings(false, true), false,
1154 )
1155 .await?;
1156 assert_eq!(summary.hard_links_created, 3);
1157 assert_eq!(summary.copy_summary.symlinks_created, 1);
1158 assert_eq!(summary.copy_summary.directories_created, 1);
1159 testutils::check_dirs_identical(
1160 &tmp_dir.join("foo"),
1161 output_path,
1162 testutils::FileEqualityCheck::Timestamp,
1163 )
1164 .await?;
1165 Ok(())
1166 }
1167
1168 #[tokio::test]
1169 #[traced_test]
1170 async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
1171 let tmp_dir = setup_test_dir_and_link().await?;
1172 let output_path = &tmp_dir.join("bar");
1173 {
1174 let summary = rm::rm(
1185 &PROGRESS,
1186 &output_path.join("bar"),
1187 &rm::Settings {
1188 fail_early: false,
1189 filter: None,
1190 dry_run: None,
1191 },
1192 )
1193 .await?
1194 + rm::rm(
1195 &PROGRESS,
1196 &output_path.join("baz").join("5.txt"),
1197 &rm::Settings {
1198 fail_early: false,
1199 filter: None,
1200 dry_run: None,
1201 },
1202 )
1203 .await?;
1204 assert_eq!(summary.files_removed, 3);
1205 assert_eq!(summary.symlinks_removed, 1);
1206 assert_eq!(summary.directories_removed, 1);
1207 }
1208 setup_update_dir(&tmp_dir).await?;
1209 let summary = link(
1215 &PROGRESS,
1216 &tmp_dir,
1217 &tmp_dir.join("foo"),
1218 output_path,
1219 &Some(tmp_dir.join("update")),
1220 &common_settings(false, true), false,
1222 )
1223 .await?;
1224 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);
1228 testutils::check_dirs_identical(
1230 &tmp_dir.join("foo").join("baz"),
1231 &tmp_dir.join("bar").join("baz"),
1232 testutils::FileEqualityCheck::HardLink,
1233 )
1234 .await?;
1235 testutils::check_dirs_identical(
1237 &tmp_dir.join("update"),
1238 &tmp_dir.join("bar"),
1239 testutils::FileEqualityCheck::Timestamp,
1240 )
1241 .await?;
1242 Ok(())
1243 }
1244
1245 #[tokio::test]
1246 #[traced_test]
1247 async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
1248 let tmp_dir = setup_test_dir_and_link().await?;
1249 let output_path = &tmp_dir.join("bar");
1250 {
1251 let bar_path = output_path.join("bar");
1260 let summary = rm::rm(
1261 &PROGRESS,
1262 &bar_path.join("1.txt"),
1263 &rm::Settings {
1264 fail_early: false,
1265 filter: None,
1266 dry_run: None,
1267 },
1268 )
1269 .await?
1270 + rm::rm(
1271 &PROGRESS,
1272 &bar_path.join("2.txt"),
1273 &rm::Settings {
1274 fail_early: false,
1275 filter: None,
1276 dry_run: None,
1277 },
1278 )
1279 .await?
1280 + rm::rm(
1281 &PROGRESS,
1282 &bar_path.join("3.txt"),
1283 &rm::Settings {
1284 fail_early: false,
1285 filter: None,
1286 dry_run: None,
1287 },
1288 )
1289 .await?
1290 + rm::rm(
1291 &PROGRESS,
1292 &output_path.join("baz"),
1293 &rm::Settings {
1294 fail_early: false,
1295 filter: None,
1296 dry_run: None,
1297 },
1298 )
1299 .await?;
1300 assert_eq!(summary.files_removed, 4);
1301 assert_eq!(summary.symlinks_removed, 2);
1302 assert_eq!(summary.directories_removed, 1);
1303 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1305 .await
1306 .unwrap();
1307 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1308 .await
1309 .unwrap();
1310 tokio::fs::create_dir(&bar_path.join("3.txt"))
1311 .await
1312 .unwrap();
1313 tokio::fs::write(&output_path.join("baz"), "baz")
1314 .await
1315 .unwrap();
1316 }
1317 let summary = link(
1318 &PROGRESS,
1319 &tmp_dir,
1320 &tmp_dir.join("foo"),
1321 output_path,
1322 &None,
1323 &common_settings(false, true), false,
1325 )
1326 .await?;
1327 assert_eq!(summary.hard_links_created, 4);
1328 assert_eq!(summary.copy_summary.files_copied, 0);
1329 assert_eq!(summary.copy_summary.symlinks_created, 2);
1330 assert_eq!(summary.copy_summary.directories_created, 1);
1331 testutils::check_dirs_identical(
1332 &tmp_dir.join("foo"),
1333 &tmp_dir.join("bar"),
1334 testutils::FileEqualityCheck::HardLink,
1335 )
1336 .await?;
1337 Ok(())
1338 }
1339
1340 #[tokio::test]
1341 #[traced_test]
1342 async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
1343 let tmp_dir = setup_test_dir_and_link().await?;
1344 let output_path = &tmp_dir.join("bar");
1345 {
1346 let bar_path = output_path.join("bar");
1355 let summary = rm::rm(
1356 &PROGRESS,
1357 &bar_path.join("1.txt"),
1358 &rm::Settings {
1359 fail_early: false,
1360 filter: None,
1361 dry_run: None,
1362 },
1363 )
1364 .await?
1365 + rm::rm(
1366 &PROGRESS,
1367 &bar_path.join("2.txt"),
1368 &rm::Settings {
1369 fail_early: false,
1370 filter: None,
1371 dry_run: None,
1372 },
1373 )
1374 .await?
1375 + rm::rm(
1376 &PROGRESS,
1377 &bar_path.join("3.txt"),
1378 &rm::Settings {
1379 fail_early: false,
1380 filter: None,
1381 dry_run: None,
1382 },
1383 )
1384 .await?
1385 + rm::rm(
1386 &PROGRESS,
1387 &output_path.join("baz"),
1388 &rm::Settings {
1389 fail_early: false,
1390 filter: None,
1391 dry_run: None,
1392 },
1393 )
1394 .await?;
1395 assert_eq!(summary.files_removed, 4);
1396 assert_eq!(summary.symlinks_removed, 2);
1397 assert_eq!(summary.directories_removed, 1);
1398 tokio::fs::write(bar_path.join("1.txt"), "1-new")
1400 .await
1401 .unwrap();
1402 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1403 .await
1404 .unwrap();
1405 tokio::fs::create_dir(&bar_path.join("3.txt"))
1406 .await
1407 .unwrap();
1408 tokio::fs::write(&output_path.join("baz"), "baz")
1409 .await
1410 .unwrap();
1411 }
1412 let source_path = &tmp_dir.join("foo");
1413 tokio::fs::set_permissions(
1415 &source_path.join("baz"),
1416 std::fs::Permissions::from_mode(0o000),
1417 )
1418 .await?;
1419 match link(
1423 &PROGRESS,
1424 &tmp_dir,
1425 &tmp_dir.join("foo"),
1426 output_path,
1427 &None,
1428 &common_settings(false, true), false,
1430 )
1431 .await
1432 {
1433 Ok(_) => panic!("Expected the link to error!"),
1434 Err(error) => {
1435 tracing::info!("{}", &error);
1436 assert_eq!(error.summary.hard_links_created, 3);
1437 assert_eq!(error.summary.copy_summary.files_copied, 0);
1438 assert_eq!(error.summary.copy_summary.symlinks_created, 0);
1439 assert_eq!(error.summary.copy_summary.directories_created, 0);
1440 assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
1441 assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
1442 assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
1443 }
1444 }
1445 Ok(())
1446 }
1447
1448 #[tokio::test]
1452 #[traced_test]
1453 async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
1454 let tmp_dir = testutils::create_temp_dir().await?;
1455 let test_path = tmp_dir.as_path();
1456 let src_dir = test_path.join("src");
1458 tokio::fs::create_dir(&src_dir).await?;
1459 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
1460 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
1462 let unreadable_subdir = src_dir.join("unreadable_subdir");
1465 tokio::fs::create_dir(&unreadable_subdir).await?;
1466 tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
1467 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
1468 .await?;
1469 let dst_dir = test_path.join("dst");
1470 let result = link(
1472 &PROGRESS,
1473 test_path,
1474 &src_dir,
1475 &dst_dir,
1476 &None,
1477 &common_settings(false, false),
1478 false,
1479 )
1480 .await;
1481 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
1483 .await?;
1484 assert!(
1486 result.is_err(),
1487 "link should fail due to unreadable subdirectory"
1488 );
1489 let error = result.unwrap_err();
1490 assert_eq!(error.summary.hard_links_created, 1);
1492 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
1494 assert!(dst_metadata.is_dir());
1495 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
1496 assert_eq!(
1497 actual_mode, 0o750,
1498 "directory should have preserved source permissions (0o750), got {:o}",
1499 actual_mode
1500 );
1501 Ok(())
1502 }
1503 mod filter_tests {
1504 use super::*;
1505 use crate::filter::FilterSettings;
1506 #[tokio::test]
1508 #[traced_test]
1509 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
1510 let tmp_dir = testutils::setup_test_dir().await?;
1511 let test_path = tmp_dir.as_path();
1512 let mut filter = FilterSettings::new();
1514 filter.add_include("bar/*.txt").unwrap();
1515 let summary = link(
1516 &PROGRESS,
1517 test_path,
1518 &test_path.join("foo"),
1519 &test_path.join("dst"),
1520 &None,
1521 &Settings {
1522 copy_settings: CopySettings {
1523 dereference: false,
1524 fail_early: false,
1525 overwrite: false,
1526 overwrite_compare: Default::default(),
1527 overwrite_filter: None,
1528 ignore_existing: false,
1529 chunk_size: 0,
1530 remote_copy_buffer_size: 0,
1531 filter: None,
1532 dry_run: None,
1533 },
1534 update_compare: Default::default(),
1535 update_exclusive: false,
1536 filter: Some(filter),
1537 dry_run: None,
1538 preserve: preserve::preserve_all(),
1539 },
1540 false,
1541 )
1542 .await?;
1543 assert_eq!(
1545 summary.hard_links_created, 3,
1546 "should link 3 files matching bar/*.txt"
1547 );
1548 assert!(
1550 test_path.join("dst/bar/1.txt").exists(),
1551 "bar/1.txt should be linked"
1552 );
1553 assert!(
1554 test_path.join("dst/bar/2.txt").exists(),
1555 "bar/2.txt should be linked"
1556 );
1557 assert!(
1558 test_path.join("dst/bar/3.txt").exists(),
1559 "bar/3.txt should be linked"
1560 );
1561 assert!(
1563 !test_path.join("dst/0.txt").exists(),
1564 "0.txt should not be linked"
1565 );
1566 Ok(())
1567 }
1568 #[tokio::test]
1570 #[traced_test]
1571 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
1572 let tmp_dir = testutils::setup_test_dir().await?;
1573 let test_path = tmp_dir.as_path();
1574 let mut filter = FilterSettings::new();
1576 filter.add_exclude("*.txt").unwrap();
1577 let summary = link(
1578 &PROGRESS,
1579 test_path,
1580 &test_path.join("foo/0.txt"), &test_path.join("dst/0.txt"),
1582 &None,
1583 &Settings {
1584 copy_settings: CopySettings {
1585 dereference: false,
1586 fail_early: false,
1587 overwrite: false,
1588 overwrite_compare: Default::default(),
1589 overwrite_filter: None,
1590 ignore_existing: false,
1591 chunk_size: 0,
1592 remote_copy_buffer_size: 0,
1593 filter: None,
1594 dry_run: None,
1595 },
1596 update_compare: Default::default(),
1597 update_exclusive: false,
1598 filter: Some(filter),
1599 dry_run: None,
1600 preserve: preserve::preserve_all(),
1601 },
1602 false,
1603 )
1604 .await?;
1605 assert_eq!(
1607 summary.hard_links_created, 0,
1608 "file matching exclude pattern should not be linked"
1609 );
1610 assert!(
1611 !test_path.join("dst/0.txt").exists(),
1612 "excluded file should not exist at destination"
1613 );
1614 Ok(())
1615 }
1616 #[tokio::test]
1618 #[traced_test]
1619 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
1620 let test_path = testutils::create_temp_dir().await?;
1621 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
1623 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
1624 let mut filter = FilterSettings::new();
1626 filter.add_exclude("*_dir/").unwrap();
1627 let result = link(
1628 &PROGRESS,
1629 &test_path,
1630 &test_path.join("excluded_dir"),
1631 &test_path.join("dst"),
1632 &None,
1633 &Settings {
1634 copy_settings: CopySettings {
1635 dereference: false,
1636 fail_early: false,
1637 overwrite: false,
1638 overwrite_compare: Default::default(),
1639 overwrite_filter: None,
1640 ignore_existing: false,
1641 chunk_size: 0,
1642 remote_copy_buffer_size: 0,
1643 filter: None,
1644 dry_run: None,
1645 },
1646 update_compare: Default::default(),
1647 update_exclusive: false,
1648 filter: Some(filter),
1649 dry_run: None,
1650 preserve: preserve::preserve_all(),
1651 },
1652 false,
1653 )
1654 .await?;
1655 assert_eq!(
1657 result.copy_summary.directories_created, 0,
1658 "root directory matching exclude should not be created"
1659 );
1660 assert!(
1661 !test_path.join("dst").exists(),
1662 "excluded root directory should not exist at destination"
1663 );
1664 Ok(())
1665 }
1666 #[tokio::test]
1668 #[traced_test]
1669 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
1670 let test_path = testutils::create_temp_dir().await?;
1671 tokio::fs::write(test_path.join("target.txt"), "content").await?;
1673 tokio::fs::symlink(
1674 test_path.join("target.txt"),
1675 test_path.join("excluded_link"),
1676 )
1677 .await?;
1678 let mut filter = FilterSettings::new();
1680 filter.add_exclude("*_link").unwrap();
1681 let result = link(
1682 &PROGRESS,
1683 &test_path,
1684 &test_path.join("excluded_link"),
1685 &test_path.join("dst"),
1686 &None,
1687 &Settings {
1688 copy_settings: CopySettings {
1689 dereference: false,
1690 fail_early: false,
1691 overwrite: false,
1692 overwrite_compare: Default::default(),
1693 overwrite_filter: None,
1694 ignore_existing: false,
1695 chunk_size: 0,
1696 remote_copy_buffer_size: 0,
1697 filter: None,
1698 dry_run: None,
1699 },
1700 update_compare: Default::default(),
1701 update_exclusive: false,
1702 filter: Some(filter),
1703 dry_run: None,
1704 preserve: preserve::preserve_all(),
1705 },
1706 false,
1707 )
1708 .await?;
1709 assert_eq!(
1711 result.copy_summary.symlinks_created, 0,
1712 "root symlink matching exclude should not be created"
1713 );
1714 assert!(
1715 !test_path.join("dst").exists(),
1716 "excluded root symlink should not exist at destination"
1717 );
1718 Ok(())
1719 }
1720 #[tokio::test]
1722 #[traced_test]
1723 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
1724 let tmp_dir = testutils::setup_test_dir().await?;
1725 let test_path = tmp_dir.as_path();
1726 let mut filter = FilterSettings::new();
1733 filter.add_include("bar/*.txt").unwrap();
1734 filter.add_exclude("bar/2.txt").unwrap();
1735 let summary = link(
1736 &PROGRESS,
1737 test_path,
1738 &test_path.join("foo"),
1739 &test_path.join("dst"),
1740 &None,
1741 &Settings {
1742 copy_settings: CopySettings {
1743 dereference: false,
1744 fail_early: false,
1745 overwrite: false,
1746 overwrite_compare: Default::default(),
1747 overwrite_filter: None,
1748 ignore_existing: false,
1749 chunk_size: 0,
1750 remote_copy_buffer_size: 0,
1751 filter: None,
1752 dry_run: None,
1753 },
1754 update_compare: Default::default(),
1755 update_exclusive: false,
1756 filter: Some(filter),
1757 dry_run: None,
1758 preserve: preserve::preserve_all(),
1759 },
1760 false,
1761 )
1762 .await?;
1763 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1766 assert_eq!(
1767 summary.copy_summary.files_skipped, 2,
1768 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
1769 );
1770 assert!(
1772 test_path.join("dst/bar/1.txt").exists(),
1773 "bar/1.txt should be linked"
1774 );
1775 assert!(
1776 !test_path.join("dst/bar/2.txt").exists(),
1777 "bar/2.txt should be excluded"
1778 );
1779 assert!(
1780 test_path.join("dst/bar/3.txt").exists(),
1781 "bar/3.txt should be linked"
1782 );
1783 Ok(())
1784 }
1785 #[tokio::test]
1787 #[traced_test]
1788 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
1789 let tmp_dir = testutils::setup_test_dir().await?;
1790 let test_path = tmp_dir.as_path();
1791 let mut filter = FilterSettings::new();
1798 filter.add_exclude("bar/").unwrap();
1799 let summary = link(
1800 &PROGRESS,
1801 test_path,
1802 &test_path.join("foo"),
1803 &test_path.join("dst"),
1804 &None,
1805 &Settings {
1806 copy_settings: CopySettings {
1807 dereference: false,
1808 fail_early: false,
1809 overwrite: false,
1810 overwrite_compare: Default::default(),
1811 overwrite_filter: None,
1812 ignore_existing: false,
1813 chunk_size: 0,
1814 remote_copy_buffer_size: 0,
1815 filter: None,
1816 dry_run: None,
1817 },
1818 update_compare: Default::default(),
1819 update_exclusive: false,
1820 filter: Some(filter),
1821 dry_run: None,
1822 preserve: preserve::preserve_all(),
1823 },
1824 false,
1825 )
1826 .await?;
1827 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1831 assert_eq!(
1832 summary.copy_summary.symlinks_created, 2,
1833 "should copy 2 symlinks"
1834 );
1835 assert_eq!(
1836 summary.copy_summary.directories_skipped, 1,
1837 "should skip 1 directory (bar)"
1838 );
1839 assert!(
1841 !test_path.join("dst/bar").exists(),
1842 "bar directory should not be linked"
1843 );
1844 Ok(())
1845 }
1846 #[tokio::test]
1849 #[traced_test]
1850 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
1851 let test_path = testutils::create_temp_dir().await?;
1852 let src_path = test_path.join("src");
1858 tokio::fs::create_dir(&src_path).await?;
1859 tokio::fs::write(src_path.join("foo"), "content").await?;
1860 tokio::fs::write(src_path.join("bar"), "content").await?;
1861 tokio::fs::create_dir(src_path.join("baz")).await?;
1862 let mut filter = FilterSettings::new();
1864 filter.add_include("foo").unwrap();
1865 let summary = link(
1866 &PROGRESS,
1867 &test_path,
1868 &src_path,
1869 &test_path.join("dst"),
1870 &None,
1871 &Settings {
1872 copy_settings: copy::Settings {
1873 dereference: false,
1874 fail_early: false,
1875 overwrite: false,
1876 overwrite_compare: Default::default(),
1877 overwrite_filter: None,
1878 ignore_existing: false,
1879 chunk_size: 0,
1880 remote_copy_buffer_size: 0,
1881 filter: None,
1882 dry_run: None,
1883 },
1884 update_compare: Default::default(),
1885 update_exclusive: false,
1886 filter: Some(filter),
1887 dry_run: None,
1888 preserve: preserve::preserve_all(),
1889 },
1890 false,
1891 )
1892 .await?;
1893 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
1895 assert_eq!(
1896 summary.copy_summary.directories_created, 1,
1897 "should create only root directory (not empty 'baz')"
1898 );
1899 assert!(
1901 test_path.join("dst").join("foo").exists(),
1902 "foo should be linked"
1903 );
1904 assert!(
1906 !test_path.join("dst").join("bar").exists(),
1907 "bar should not be linked"
1908 );
1909 assert!(
1911 !test_path.join("dst").join("baz").exists(),
1912 "empty baz directory should NOT be created"
1913 );
1914 Ok(())
1915 }
1916 #[tokio::test]
1919 #[traced_test]
1920 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
1921 let test_path = testutils::create_temp_dir().await?;
1922 let src_path = test_path.join("src");
1929 tokio::fs::create_dir(&src_path).await?;
1930 tokio::fs::write(src_path.join("foo"), "content").await?;
1931 tokio::fs::create_dir(src_path.join("baz")).await?;
1932 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
1933 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
1934 let mut filter = FilterSettings::new();
1936 filter.add_include("foo").unwrap();
1937 let summary = link(
1938 &PROGRESS,
1939 &test_path,
1940 &src_path,
1941 &test_path.join("dst"),
1942 &None,
1943 &Settings {
1944 copy_settings: copy::Settings {
1945 dereference: false,
1946 fail_early: false,
1947 overwrite: false,
1948 overwrite_compare: Default::default(),
1949 overwrite_filter: None,
1950 ignore_existing: false,
1951 chunk_size: 0,
1952 remote_copy_buffer_size: 0,
1953 filter: None,
1954 dry_run: None,
1955 },
1956 update_compare: Default::default(),
1957 update_exclusive: false,
1958 filter: Some(filter),
1959 dry_run: None,
1960 preserve: preserve::preserve_all(),
1961 },
1962 false,
1963 )
1964 .await?;
1965 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
1967 assert_eq!(
1968 summary.copy_summary.files_skipped, 2,
1969 "should skip 2 files (qux and quux)"
1970 );
1971 assert_eq!(
1972 summary.copy_summary.directories_created, 1,
1973 "should create only root directory (not 'baz' with non-matching content)"
1974 );
1975 assert!(
1977 test_path.join("dst").join("foo").exists(),
1978 "foo should be linked"
1979 );
1980 assert!(
1982 !test_path.join("dst").join("baz").exists(),
1983 "baz directory should NOT be created (no matching content inside)"
1984 );
1985 Ok(())
1986 }
1987 #[tokio::test]
1990 #[traced_test]
1991 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
1992 let test_path = testutils::create_temp_dir().await?;
1993 let src_path = test_path.join("src");
1999 tokio::fs::create_dir(&src_path).await?;
2000 tokio::fs::write(src_path.join("foo"), "content").await?;
2001 tokio::fs::write(src_path.join("bar"), "content").await?;
2002 tokio::fs::create_dir(src_path.join("baz")).await?;
2003 let mut filter = FilterSettings::new();
2005 filter.add_include("foo").unwrap();
2006 let summary = link(
2007 &PROGRESS,
2008 &test_path,
2009 &src_path,
2010 &test_path.join("dst"),
2011 &None,
2012 &Settings {
2013 copy_settings: copy::Settings {
2014 dereference: false,
2015 fail_early: false,
2016 overwrite: false,
2017 overwrite_compare: Default::default(),
2018 overwrite_filter: None,
2019 ignore_existing: false,
2020 chunk_size: 0,
2021 remote_copy_buffer_size: 0,
2022 filter: None,
2023 dry_run: None,
2024 },
2025 update_compare: Default::default(),
2026 update_exclusive: false,
2027 filter: Some(filter),
2028 dry_run: Some(crate::config::DryRunMode::Explain),
2029 preserve: preserve::preserve_all(),
2030 },
2031 false,
2032 )
2033 .await?;
2034 assert_eq!(
2036 summary.hard_links_created, 1,
2037 "should report only 'foo' would be linked"
2038 );
2039 assert_eq!(
2040 summary.copy_summary.directories_created, 1,
2041 "should report only root directory would be created (not empty 'baz')"
2042 );
2043 assert!(
2045 !test_path.join("dst").exists(),
2046 "dst should not exist in dry-run"
2047 );
2048 Ok(())
2049 }
2050 #[tokio::test]
2053 #[traced_test]
2054 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
2055 let test_path = testutils::create_temp_dir().await?;
2056 let src_path = test_path.join("src");
2062 tokio::fs::create_dir(&src_path).await?;
2063 tokio::fs::write(src_path.join("foo"), "content").await?;
2064 tokio::fs::write(src_path.join("bar"), "content").await?;
2065 tokio::fs::create_dir(src_path.join("baz")).await?;
2066 let dst_path = test_path.join("dst");
2068 tokio::fs::create_dir(&dst_path).await?;
2069 tokio::fs::create_dir(dst_path.join("baz")).await?;
2070 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
2072 let mut filter = FilterSettings::new();
2074 filter.add_include("foo").unwrap();
2075 let summary = link(
2076 &PROGRESS,
2077 &test_path,
2078 &src_path,
2079 &dst_path,
2080 &None,
2081 &Settings {
2082 copy_settings: copy::Settings {
2083 dereference: false,
2084 fail_early: false,
2085 overwrite: true, overwrite_compare: Default::default(),
2087 overwrite_filter: None,
2088 ignore_existing: false,
2089 chunk_size: 0,
2090 remote_copy_buffer_size: 0,
2091 filter: None,
2092 dry_run: None,
2093 },
2094 update_compare: Default::default(),
2095 update_exclusive: false,
2096 filter: Some(filter),
2097 dry_run: None,
2098 preserve: preserve::preserve_all(),
2099 },
2100 false,
2101 )
2102 .await?;
2103 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2105 assert_eq!(
2107 summary.copy_summary.directories_unchanged, 2,
2108 "root dst and baz directories should be unchanged"
2109 );
2110 assert_eq!(
2111 summary.copy_summary.directories_created, 0,
2112 "should not create any directories"
2113 );
2114 assert!(dst_path.join("foo").exists(), "foo should be linked");
2116 assert!(!dst_path.join("bar").exists(), "bar should not be linked");
2118 assert!(
2120 dst_path.join("baz").exists(),
2121 "existing baz directory should still exist"
2122 );
2123 assert!(
2124 dst_path.join("baz").join("marker.txt").exists(),
2125 "existing content in baz should still exist"
2126 );
2127 Ok(())
2128 }
2129 }
2130 mod dry_run_tests {
2131 use super::*;
2132 #[tokio::test]
2134 #[traced_test]
2135 async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
2136 let tmp_dir = testutils::setup_test_dir().await?;
2137 let test_path = tmp_dir.as_path();
2138 let src_file = test_path.join("foo/0.txt");
2139 let dst_file = test_path.join("dst_link.txt");
2140 assert!(
2142 !dst_file.exists(),
2143 "destination should not exist before dry-run"
2144 );
2145 let summary = link(
2146 &PROGRESS,
2147 test_path,
2148 &src_file,
2149 &dst_file,
2150 &None,
2151 &Settings {
2152 copy_settings: CopySettings {
2153 dereference: false,
2154 fail_early: false,
2155 overwrite: false,
2156 overwrite_compare: Default::default(),
2157 overwrite_filter: None,
2158 ignore_existing: false,
2159 chunk_size: 0,
2160 remote_copy_buffer_size: 0,
2161 filter: None,
2162 dry_run: None,
2163 },
2164 update_compare: Default::default(),
2165 update_exclusive: false,
2166 filter: None,
2167 dry_run: Some(crate::config::DryRunMode::Brief),
2168 preserve: preserve::preserve_all(),
2169 },
2170 false,
2171 )
2172 .await?;
2173 assert!(!dst_file.exists(), "dry-run should not create hard link");
2175 assert_eq!(
2177 summary.hard_links_created, 1,
2178 "dry-run should report 1 hard link that would be created"
2179 );
2180 Ok(())
2181 }
2182 #[tokio::test]
2184 #[traced_test]
2185 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
2186 let tmp_dir = testutils::setup_test_dir().await?;
2187 let test_path = tmp_dir.as_path();
2188 let dst_path = test_path.join("nonexistent_dst");
2189 assert!(
2191 !dst_path.exists(),
2192 "destination should not exist before dry-run"
2193 );
2194 let summary = link(
2195 &PROGRESS,
2196 test_path,
2197 &test_path.join("foo"),
2198 &dst_path,
2199 &None,
2200 &Settings {
2201 copy_settings: CopySettings {
2202 dereference: false,
2203 fail_early: false,
2204 overwrite: false,
2205 overwrite_compare: Default::default(),
2206 overwrite_filter: None,
2207 ignore_existing: false,
2208 chunk_size: 0,
2209 remote_copy_buffer_size: 0,
2210 filter: None,
2211 dry_run: None,
2212 },
2213 update_compare: Default::default(),
2214 update_exclusive: false,
2215 filter: None,
2216 dry_run: Some(crate::config::DryRunMode::Brief),
2217 preserve: preserve::preserve_all(),
2218 },
2219 false,
2220 )
2221 .await?;
2222 assert!(
2224 !dst_path.exists(),
2225 "dry-run should not create destination directory"
2226 );
2227 assert!(
2229 summary.hard_links_created > 0,
2230 "dry-run should report hard links that would be created"
2231 );
2232 Ok(())
2233 }
2234 #[tokio::test]
2236 #[traced_test]
2237 async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
2238 let tmp_dir = testutils::setup_test_dir().await?;
2239 let test_path = tmp_dir.as_path();
2240 let src_path = test_path.join("foo/baz");
2242 let dst_path = test_path.join("dst_baz");
2243 assert!(
2245 !dst_path.exists(),
2246 "destination should not exist before dry-run"
2247 );
2248 let summary = link(
2249 &PROGRESS,
2250 test_path,
2251 &src_path,
2252 &dst_path,
2253 &None,
2254 &Settings {
2255 copy_settings: CopySettings {
2256 dereference: false,
2257 fail_early: false,
2258 overwrite: false,
2259 overwrite_compare: Default::default(),
2260 overwrite_filter: None,
2261 ignore_existing: false,
2262 chunk_size: 0,
2263 remote_copy_buffer_size: 0,
2264 filter: None,
2265 dry_run: None,
2266 },
2267 update_compare: Default::default(),
2268 update_exclusive: false,
2269 filter: None,
2270 dry_run: Some(crate::config::DryRunMode::Brief),
2271 preserve: preserve::preserve_all(),
2272 },
2273 false,
2274 )
2275 .await?;
2276 assert!(!dst_path.exists(), "dry-run should not create destination");
2278 assert_eq!(
2280 summary.hard_links_created, 1,
2281 "dry-run should report 1 hard link (for 4.txt)"
2282 );
2283 assert_eq!(
2284 summary.copy_summary.symlinks_created, 2,
2285 "dry-run should report 2 symlinks (5.txt and 6.txt)"
2286 );
2287 Ok(())
2288 }
2289 }
2290
2291 #[tokio::test]
2298 #[traced_test]
2299 async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
2300 let tmp_dir = testutils::create_temp_dir().await?;
2301 let test_path = tmp_dir.as_path();
2302 let src_dir = test_path.join("src");
2307 let sub_dir = src_dir.join("sub");
2308 let bad_dir = sub_dir.join("unreadable_dir");
2309 tokio::fs::create_dir_all(&bad_dir).await?;
2310 tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
2311 tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
2312 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
2313 let dst_dir = test_path.join("dst");
2314 let result = link(
2315 &PROGRESS,
2316 test_path,
2317 &src_dir,
2318 &dst_dir,
2319 &None,
2320 &Settings {
2321 copy_settings: CopySettings {
2322 fail_early: true,
2323 ..common_settings(false, false).copy_settings
2324 },
2325 ..common_settings(false, false)
2326 },
2327 false,
2328 )
2329 .await;
2330 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
2332 let error = result.expect_err("link should fail due to unreadable directory");
2333 assert!(
2338 error.summary.copy_summary.directories_created >= 2,
2339 "fail-early summary should include directories from the failing subtree, \
2340 got directories_created={} (expected >= 2: dst/ and dst/sub/)",
2341 error.summary.copy_summary.directories_created
2342 );
2343 Ok(())
2344 }
2345}