common/
link.rs

1use anyhow::{anyhow, Context};
2use async_recursion::async_recursion;
3use std::os::linux::fs::MetadataExt as LinuxMetadataExt;
4use tracing::instrument;
5
6use crate::copy;
7use crate::copy::{Settings as CopySettings, Summary as CopySummary};
8use crate::filecmp;
9use crate::preserve;
10use crate::progress;
11use crate::rm;
12
13lazy_static! {
14    static ref RLINK_PRESERVE_SETTINGS: preserve::Settings = preserve::preserve_all();
15}
16
17/// Error type for link operations that preserves operation summary even on failure.
18///
19/// # Logging Convention
20/// When logging this error, use `{:#}` or `{:?}` format to preserve the error chain:
21/// ```ignore
22/// tracing::error!("operation failed: {:#}", &error); // ✅ Shows full chain
23/// tracing::error!("operation failed: {:?}", &error); // ✅ Shows full chain
24/// ```
25/// The Display implementation also shows the full chain, but workspace linting enforces `{:#}`
26/// for consistency.
27#[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, Copy, Clone)]
43pub struct Settings {
44    pub copy_settings: CopySettings,
45    pub update_compare: filecmp::MetadataCmpSettings,
46    pub update_exclusive: bool,
47}
48
49#[derive(Copy, Clone, Debug, Default)]
50pub struct Summary {
51    pub hard_links_created: usize,
52    pub hard_links_unchanged: usize,
53    pub copy_summary: CopySummary,
54}
55
56impl std::ops::Add for Summary {
57    type Output = Self;
58    fn add(self, other: Self) -> Self {
59        Self {
60            hard_links_created: self.hard_links_created + other.hard_links_created,
61            hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
62            copy_summary: self.copy_summary + other.copy_summary,
63        }
64    }
65}
66
67impl std::fmt::Display for Summary {
68    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
69        write!(
70            f,
71            "{}hard-links created: {}\nhard links unchanged: {}\n",
72            &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
73        )
74    }
75}
76
77fn is_hard_link(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
78    copy::is_file_type_same(md1, md2)
79        && md2.st_dev() == md1.st_dev()
80        && md2.st_ino() == md1.st_ino()
81}
82
83#[instrument(skip(prog_track))]
84async fn hard_link_helper(
85    prog_track: &'static progress::Progress,
86    src: &std::path::Path,
87    src_metadata: &std::fs::Metadata,
88    dst: &std::path::Path,
89    settings: &Settings,
90) -> Result<Summary, Error> {
91    let mut link_summary = Summary::default();
92    if let Err(error) = tokio::fs::hard_link(src, dst).await {
93        if settings.copy_settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
94            tracing::debug!("'dst' already exists, check if we need to update");
95            let dst_metadata = tokio::fs::symlink_metadata(dst)
96                .await
97                .with_context(|| format!("cannot read {dst:?} metadata"))
98                .map_err(|err| Error::new(err, Default::default()))?;
99            if is_hard_link(src_metadata, &dst_metadata) {
100                tracing::debug!("no change, leaving file as is");
101                prog_track.hard_links_unchanged.inc();
102                return Ok(Summary {
103                    hard_links_unchanged: 1,
104                    ..Default::default()
105                });
106            }
107            tracing::info!("'dst' file type changed, removing and hard-linking");
108            let rm_summary = rm::rm(
109                prog_track,
110                dst,
111                &rm::Settings {
112                    fail_early: settings.copy_settings.fail_early,
113                },
114            )
115            .await
116            .map_err(|err| {
117                let rm_summary = err.summary;
118                link_summary.copy_summary.rm_summary = rm_summary;
119                Error::new(err.source, link_summary)
120            })?;
121            link_summary.copy_summary.rm_summary = rm_summary;
122            tokio::fs::hard_link(src, dst)
123                .await
124                .with_context(|| format!("failed to hard link {:?} to {:?}", src, dst))
125                .map_err(|err| Error::new(err, link_summary))?;
126        }
127    }
128    prog_track.hard_links_created.inc();
129    link_summary.hard_links_created = 1;
130    Ok(link_summary)
131}
132
133#[instrument(skip(prog_track))]
134#[async_recursion]
135pub async fn link(
136    prog_track: &'static progress::Progress,
137    cwd: &std::path::Path,
138    src: &std::path::Path,
139    dst: &std::path::Path,
140    update: &Option<std::path::PathBuf>,
141    settings: &Settings,
142    mut is_fresh: bool,
143) -> Result<Summary, Error> {
144    let _prog_guard = prog_track.ops.guard();
145    tracing::debug!("reading source metadata");
146    let src_metadata = tokio::fs::symlink_metadata(src)
147        .await
148        .with_context(|| format!("failed reading metadata from {:?}", &src))
149        .map_err(|err| Error::new(err, Default::default()))?;
150    let update_metadata_opt = match update {
151        Some(update) => {
152            tracing::debug!("reading 'update' metadata");
153            let update_metadata_res = tokio::fs::symlink_metadata(update).await;
154            match update_metadata_res {
155                Ok(update_metadata) => Some(update_metadata),
156                Err(error) => {
157                    if error.kind() == std::io::ErrorKind::NotFound {
158                        if settings.update_exclusive {
159                            // the path is missing from update, we're done
160                            return Ok(Default::default());
161                        }
162                        None
163                    } else {
164                        return Err(Error::new(
165                            anyhow!("failed reading metadata from {:?}", &update),
166                            Default::default(),
167                        ));
168                    }
169                }
170            }
171        }
172        None => None,
173    };
174    if let Some(update_metadata) = update_metadata_opt.as_ref() {
175        let update = update.as_ref().unwrap();
176        if !copy::is_file_type_same(&src_metadata, update_metadata) {
177            // file type changed, just copy the updated one
178            tracing::debug!(
179                "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
180                src,
181                src_metadata.file_type(),
182                update,
183                update_metadata.file_type()
184            );
185            let copy_summary = copy::copy(
186                prog_track,
187                update,
188                dst,
189                &settings.copy_settings,
190                &RLINK_PRESERVE_SETTINGS,
191                is_fresh,
192            )
193            .await
194            .map_err(|err| {
195                let copy_summary = err.summary;
196                let link_summary = Summary {
197                    copy_summary,
198                    ..Default::default()
199                };
200                Error::new(err.source, link_summary)
201            })?;
202            return Ok(Summary {
203                copy_summary,
204                ..Default::default()
205            });
206        }
207        if update_metadata.is_file() {
208            // check if the file is unchanged and if so hard-link, otherwise copy from the updated one
209            if filecmp::metadata_equal(&settings.update_compare, &src_metadata, update_metadata) {
210                tracing::debug!("no change, hard link 'src'");
211                return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
212            }
213            tracing::debug!(
214                "link: {:?} metadata has changed, copying from {:?}",
215                src,
216                update
217            );
218            return Ok(Summary {
219                copy_summary: copy::copy_file(
220                    prog_track,
221                    update,
222                    dst,
223                    &settings.copy_settings,
224                    &RLINK_PRESERVE_SETTINGS,
225                    is_fresh,
226                )
227                .await
228                .map_err(|err| {
229                    let copy_summary = err.summary;
230                    let link_summary = Summary {
231                        copy_summary,
232                        ..Default::default()
233                    };
234                    Error::new(err.source, link_summary)
235                })?,
236                ..Default::default()
237            });
238        }
239        if update_metadata.is_symlink() {
240            tracing::debug!("'update' is a symlink so just symlink that");
241            // use "copy" function to handle the overwrite logic
242            let copy_summary = copy::copy(
243                prog_track,
244                update,
245                dst,
246                &settings.copy_settings,
247                &RLINK_PRESERVE_SETTINGS,
248                is_fresh,
249            )
250            .await
251            .map_err(|err| {
252                let copy_summary = err.summary;
253                let link_summary = Summary {
254                    copy_summary,
255                    ..Default::default()
256                };
257                Error::new(err.source, link_summary)
258            })?;
259            return Ok(Summary {
260                copy_summary,
261                ..Default::default()
262            });
263        }
264    } else {
265        // update hasn't been specified, if this is a file just hard-link the source or symlink if it's a symlink
266        tracing::debug!("no 'update' specified");
267        if src_metadata.is_file() {
268            return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
269        }
270        if src_metadata.is_symlink() {
271            tracing::debug!("'src' is a symlink so just symlink that");
272            // use "copy" function to handle the overwrite logic
273            let copy_summary = copy::copy(
274                prog_track,
275                src,
276                dst,
277                &settings.copy_settings,
278                &RLINK_PRESERVE_SETTINGS,
279                is_fresh,
280            )
281            .await
282            .map_err(|err| {
283                let copy_summary = err.summary;
284                let link_summary = Summary {
285                    copy_summary,
286                    ..Default::default()
287                };
288                Error::new(err.source, link_summary)
289            })?;
290            return Ok(Summary {
291                copy_summary,
292                ..Default::default()
293            });
294        }
295    }
296    if !src_metadata.is_dir() {
297        return Err(Error::new(
298            anyhow!(
299                "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
300                src,
301                dst,
302                src_metadata.file_type()
303            ),
304            Default::default(),
305        ));
306    }
307    assert!(update_metadata_opt.is_none() || update_metadata_opt.as_ref().unwrap().is_dir());
308    tracing::debug!("process contents of 'src' directory");
309    let mut src_entries = tokio::fs::read_dir(src)
310        .await
311        .with_context(|| format!("cannot open directory {src:?} for reading"))
312        .map_err(|err| Error::new(err, Default::default()))?;
313    let copy_summary = {
314        if let Err(error) = tokio::fs::create_dir(dst).await {
315            assert!(!is_fresh, "unexpected error creating directory: {:?}", &dst);
316            if settings.copy_settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists
317            {
318                // check if the destination is a directory - if so, leave it
319                //
320                // N.B. the permissions may prevent us from writing to it but the alternative is to open up the directory
321                // while we're writing to it which isn't safe
322                let dst_metadata = tokio::fs::metadata(dst)
323                    .await
324                    .with_context(|| format!("failed reading metadata from {:?}", &dst))
325                    .map_err(|err| Error::new(err, Default::default()))?;
326                if dst_metadata.is_dir() {
327                    tracing::debug!("'dst' is a directory, leaving it as is");
328                    CopySummary {
329                        directories_unchanged: 1,
330                        ..Default::default()
331                    }
332                } else {
333                    tracing::info!("'dst' is not a directory, removing and creating a new one");
334                    let mut copy_summary = CopySummary::default();
335                    let rm_summary = rm::rm(
336                        prog_track,
337                        dst,
338                        &rm::Settings {
339                            fail_early: settings.copy_settings.fail_early,
340                        },
341                    )
342                    .await
343                    .map_err(|err| {
344                        let rm_summary = err.summary;
345                        copy_summary.rm_summary = rm_summary;
346                        Error::new(
347                            err.source,
348                            Summary {
349                                copy_summary,
350                                ..Default::default()
351                            },
352                        )
353                    })?;
354                    tokio::fs::create_dir(dst)
355                        .await
356                        .with_context(|| format!("cannot create directory {dst:?}"))
357                        .map_err(|err| {
358                            copy_summary.rm_summary = rm_summary;
359                            Error::new(
360                                err,
361                                Summary {
362                                    copy_summary,
363                                    ..Default::default()
364                                },
365                            )
366                        })?;
367                    // anything copied into dst may assume they don't need to check for conflicts
368                    is_fresh = true;
369                    CopySummary {
370                        rm_summary,
371                        directories_created: 1,
372                        ..Default::default()
373                    }
374                }
375            } else {
376                return Err(error)
377                    .with_context(|| format!("cannot create directory {dst:?}"))
378                    .map_err(|err| Error::new(err, Default::default()))?;
379            }
380        } else {
381            // new directory created, anything copied into dst may assume they don't need to check for conflicts
382            is_fresh = true;
383            CopySummary {
384                directories_created: 1,
385                ..Default::default()
386            }
387        }
388    };
389    let mut link_summary = Summary {
390        copy_summary,
391        ..Default::default()
392    };
393    let mut join_set = tokio::task::JoinSet::new();
394    let mut all_children_succeeded = true;
395    // create a set of all the files we already processed
396    let mut processed_files = std::collections::HashSet::new();
397    // iterate through src entries and recursively call "link" on each one
398    while let Some(src_entry) = src_entries
399        .next_entry()
400        .await
401        .with_context(|| format!("failed traversing directory {:?}", &src))
402        .map_err(|err| Error::new(err, link_summary))?
403    {
404        // it's better to await the token here so that we throttle the syscalls generated by the
405        // DirEntry call. the ops-throttle will never cause a deadlock (unlike max-open-files limit)
406        // so it's safe to do here.
407        throttle::get_ops_token().await;
408        let cwd_path = cwd.to_owned();
409        let entry_path = src_entry.path();
410        let entry_name = entry_path.file_name().unwrap();
411        processed_files.insert(entry_name.to_owned());
412        let dst_path = dst.join(entry_name);
413        let update_path = update.as_ref().map(|s| s.join(entry_name));
414        let settings = *settings;
415        let do_link = || async move {
416            link(
417                prog_track,
418                &cwd_path,
419                &entry_path,
420                &dst_path,
421                &update_path,
422                &settings,
423                is_fresh,
424            )
425            .await
426        };
427        join_set.spawn(do_link());
428    }
429    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
430    // one thing we CAN do however is to drop it as soon as we're done with it
431    drop(src_entries);
432    // only process update if the path was provided and the directory is present
433    if update_metadata_opt.is_some() {
434        let update = update.as_ref().unwrap();
435        tracing::debug!("process contents of 'update' directory");
436        let mut update_entries = tokio::fs::read_dir(update)
437            .await
438            .with_context(|| format!("cannot open directory {:?} for reading", &update))
439            .map_err(|err| Error::new(err, link_summary))?;
440        // iterate through update entries and for each one that's not present in src call "copy"
441        while let Some(update_entry) = update_entries
442            .next_entry()
443            .await
444            .with_context(|| format!("failed traversing directory {:?}", &update))
445            .map_err(|err| Error::new(err, link_summary))?
446        {
447            let entry_path = update_entry.path();
448            let entry_name = entry_path.file_name().unwrap();
449            if processed_files.contains(entry_name) {
450                // we already must have considered this file, skip it
451                continue;
452            }
453            tracing::debug!("found a new entry in the 'update' directory");
454            let dst_path = dst.join(entry_name);
455            let update_path = update.join(entry_name);
456            let settings = *settings;
457            let do_copy = || async move {
458                let copy_summary = copy::copy(
459                    prog_track,
460                    &update_path,
461                    &dst_path,
462                    &settings.copy_settings,
463                    &RLINK_PRESERVE_SETTINGS,
464                    is_fresh,
465                )
466                .await
467                .map_err(|err| {
468                    link_summary.copy_summary = link_summary.copy_summary + err.summary;
469                    Error::new(err.source, link_summary)
470                })?;
471                Ok(Summary {
472                    copy_summary,
473                    ..Default::default()
474                })
475            };
476            join_set.spawn(do_copy());
477        }
478        // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
479        // one thing we CAN do however is to drop it as soon as we're done with it
480        drop(update_entries);
481    }
482    while let Some(res) = join_set.join_next().await {
483        match res {
484            Ok(result) => match result {
485                Ok(summary) => link_summary = link_summary + summary,
486                Err(error) => {
487                    tracing::error!(
488                        "link: {:?} {:?} -> {:?} failed with: {:#}",
489                        src,
490                        update,
491                        dst,
492                        &error
493                    );
494                    if settings.copy_settings.fail_early {
495                        return Err(error);
496                    }
497                    all_children_succeeded = false;
498                }
499            },
500            Err(error) => {
501                if settings.copy_settings.fail_early {
502                    return Err(Error::new(error.into(), link_summary));
503                }
504            }
505        }
506    }
507    // apply directory metadata regardless of whether all children linked successfully.
508    // the directory itself was created earlier in this function (we would have returned
509    // early if create_dir failed), so we should preserve the source metadata.
510    tracing::debug!("set 'dst' directory metadata");
511    let preserve_metadata = if let Some(update_metadata) = update_metadata_opt.as_ref() {
512        update_metadata
513    } else {
514        &src_metadata
515    };
516    let metadata_result =
517        preserve::set_dir_metadata(&RLINK_PRESERVE_SETTINGS, preserve_metadata, dst).await;
518    if !all_children_succeeded {
519        // child failures take precedence - log metadata error if it also failed
520        if let Err(metadata_err) = metadata_result {
521            tracing::error!(
522                "link: {:?} {:?} -> {:?} failed to set directory metadata: {:#}",
523                src,
524                update,
525                dst,
526                &metadata_err
527            );
528        }
529        return Err(Error::new(
530            anyhow!("link: {:?} {:?} -> {:?} failed!", src, update, dst),
531            link_summary,
532        ))?;
533    }
534    // no child failures, so metadata error is the primary error
535    metadata_result.map_err(|err| Error::new(err, link_summary))?;
536    Ok(link_summary)
537}
538
539#[cfg(test)]
540mod link_tests {
541    use crate::testutils;
542    use std::os::unix::fs::PermissionsExt;
543    use tracing_test::traced_test;
544
545    use super::*;
546
547    lazy_static! {
548        static ref PROGRESS: progress::Progress = progress::Progress::new();
549    }
550
551    fn common_settings(dereference: bool, overwrite: bool) -> Settings {
552        Settings {
553            copy_settings: CopySettings {
554                dereference,
555                fail_early: false,
556                overwrite,
557                overwrite_compare: filecmp::MetadataCmpSettings {
558                    size: true,
559                    mtime: true,
560                    ..Default::default()
561                },
562                chunk_size: 0,
563                remote_copy_buffer_size: 0,
564            },
565            update_compare: filecmp::MetadataCmpSettings {
566                size: true,
567                mtime: true,
568                ..Default::default()
569            },
570            update_exclusive: false,
571        }
572    }
573
574    #[tokio::test]
575    #[traced_test]
576    async fn test_basic_link() -> Result<(), anyhow::Error> {
577        let tmp_dir = testutils::setup_test_dir().await?;
578        let test_path = tmp_dir.as_path();
579        let summary = link(
580            &PROGRESS,
581            test_path,
582            &test_path.join("foo"),
583            &test_path.join("bar"),
584            &None,
585            &common_settings(false, false),
586            false,
587        )
588        .await?;
589        assert_eq!(summary.hard_links_created, 5);
590        assert_eq!(summary.copy_summary.files_copied, 0);
591        assert_eq!(summary.copy_summary.symlinks_created, 2);
592        assert_eq!(summary.copy_summary.directories_created, 3);
593        testutils::check_dirs_identical(
594            &test_path.join("foo"),
595            &test_path.join("bar"),
596            testutils::FileEqualityCheck::Timestamp,
597        )
598        .await?;
599        Ok(())
600    }
601
602    #[tokio::test]
603    #[traced_test]
604    async fn test_basic_link_update() -> Result<(), anyhow::Error> {
605        let tmp_dir = testutils::setup_test_dir().await?;
606        let test_path = tmp_dir.as_path();
607        let summary = link(
608            &PROGRESS,
609            test_path,
610            &test_path.join("foo"),
611            &test_path.join("bar"),
612            &Some(test_path.join("foo")),
613            &common_settings(false, false),
614            false,
615        )
616        .await?;
617        assert_eq!(summary.hard_links_created, 5);
618        assert_eq!(summary.copy_summary.files_copied, 0);
619        assert_eq!(summary.copy_summary.symlinks_created, 2);
620        assert_eq!(summary.copy_summary.directories_created, 3);
621        testutils::check_dirs_identical(
622            &test_path.join("foo"),
623            &test_path.join("bar"),
624            testutils::FileEqualityCheck::Timestamp,
625        )
626        .await?;
627        Ok(())
628    }
629
630    #[tokio::test]
631    #[traced_test]
632    async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
633        let tmp_dir = testutils::setup_test_dir().await?;
634        tokio::fs::create_dir(tmp_dir.join("baz")).await?;
635        let test_path = tmp_dir.as_path();
636        let summary = link(
637            &PROGRESS,
638            test_path,
639            &test_path.join("baz"), // empty source
640            &test_path.join("bar"),
641            &Some(test_path.join("foo")),
642            &common_settings(false, false),
643            false,
644        )
645        .await?;
646        assert_eq!(summary.hard_links_created, 0);
647        assert_eq!(summary.copy_summary.files_copied, 5);
648        assert_eq!(summary.copy_summary.symlinks_created, 2);
649        assert_eq!(summary.copy_summary.directories_created, 3);
650        testutils::check_dirs_identical(
651            &test_path.join("foo"),
652            &test_path.join("bar"),
653            testutils::FileEqualityCheck::Timestamp,
654        )
655        .await?;
656        Ok(())
657    }
658
659    #[tokio::test]
660    #[traced_test]
661    async fn test_link_destination_permission_error_includes_root_cause(
662    ) -> Result<(), anyhow::Error> {
663        let tmp_dir = testutils::setup_test_dir().await?;
664        let test_path = tmp_dir.as_path();
665        let readonly_parent = test_path.join("readonly_dest");
666        tokio::fs::create_dir(&readonly_parent).await?;
667        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
668            .await?;
669
670        let mut settings = common_settings(false, false);
671        settings.copy_settings.fail_early = true;
672
673        let result = link(
674            &PROGRESS,
675            test_path,
676            &test_path.join("foo"),
677            &readonly_parent.join("bar"),
678            &None,
679            &settings,
680            false,
681        )
682        .await;
683
684        // restore permissions to allow temporary directory cleanup
685        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
686            .await?;
687
688        assert!(result.is_err(), "link into read-only parent should fail");
689        let err = result.unwrap_err();
690        let err_msg = format!("{:#}", err.source);
691        assert!(
692            err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
693            "Error message must include permission denied text. Got: {}",
694            err_msg
695        );
696        Ok(())
697    }
698
699    pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
700        // update
701        // |- 0.txt
702        // |- bar
703        //    |- 1.txt
704        //    |- 2.txt -> ../0.txt
705        let foo_path = tmp_dir.join("update");
706        tokio::fs::create_dir(&foo_path).await.unwrap();
707        tokio::fs::write(foo_path.join("0.txt"), "0-new")
708            .await
709            .unwrap();
710        let bar_path = foo_path.join("bar");
711        tokio::fs::create_dir(&bar_path).await.unwrap();
712        tokio::fs::write(bar_path.join("1.txt"), "1-new")
713            .await
714            .unwrap();
715        tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
716            .await
717            .unwrap();
718        tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
719        Ok(())
720    }
721
722    #[tokio::test]
723    #[traced_test]
724    async fn test_link_update() -> Result<(), anyhow::Error> {
725        let tmp_dir = testutils::setup_test_dir().await?;
726        setup_update_dir(&tmp_dir).await?;
727        let test_path = tmp_dir.as_path();
728        let summary = link(
729            &PROGRESS,
730            test_path,
731            &test_path.join("foo"),
732            &test_path.join("bar"),
733            &Some(test_path.join("update")),
734            &common_settings(false, false),
735            false,
736        )
737        .await?;
738        assert_eq!(summary.hard_links_created, 2);
739        assert_eq!(summary.copy_summary.files_copied, 2);
740        assert_eq!(summary.copy_summary.symlinks_created, 3);
741        assert_eq!(summary.copy_summary.directories_created, 3);
742        // compare subset of src and dst
743        testutils::check_dirs_identical(
744            &test_path.join("foo").join("baz"),
745            &test_path.join("bar").join("baz"),
746            testutils::FileEqualityCheck::HardLink,
747        )
748        .await?;
749        // compare update and dst
750        testutils::check_dirs_identical(
751            &test_path.join("update"),
752            &test_path.join("bar"),
753            testutils::FileEqualityCheck::Timestamp,
754        )
755        .await?;
756        Ok(())
757    }
758
759    #[tokio::test]
760    #[traced_test]
761    async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
762        let tmp_dir = testutils::setup_test_dir().await?;
763        setup_update_dir(&tmp_dir).await?;
764        let test_path = tmp_dir.as_path();
765        let mut settings = common_settings(false, false);
766        settings.update_exclusive = true;
767        let summary = link(
768            &PROGRESS,
769            test_path,
770            &test_path.join("foo"),
771            &test_path.join("bar"),
772            &Some(test_path.join("update")),
773            &settings,
774            false,
775        )
776        .await?;
777        // we should end up with same directory as the update
778        // |- 0.txt
779        // |- bar
780        //    |- 1.txt
781        //    |- 2.txt -> ../0.txt
782        assert_eq!(summary.hard_links_created, 0);
783        assert_eq!(summary.copy_summary.files_copied, 2);
784        assert_eq!(summary.copy_summary.symlinks_created, 1);
785        assert_eq!(summary.copy_summary.directories_created, 2);
786        // compare update and dst
787        testutils::check_dirs_identical(
788            &test_path.join("update"),
789            &test_path.join("bar"),
790            testutils::FileEqualityCheck::Timestamp,
791        )
792        .await?;
793        Ok(())
794    }
795
796    async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
797        let tmp_dir = testutils::setup_test_dir().await?;
798        let test_path = tmp_dir.as_path();
799        let summary = link(
800            &PROGRESS,
801            test_path,
802            &test_path.join("foo"),
803            &test_path.join("bar"),
804            &None,
805            &common_settings(false, false),
806            false,
807        )
808        .await?;
809        assert_eq!(summary.hard_links_created, 5);
810        assert_eq!(summary.copy_summary.symlinks_created, 2);
811        assert_eq!(summary.copy_summary.directories_created, 3);
812        Ok(tmp_dir)
813    }
814
815    #[tokio::test]
816    #[traced_test]
817    async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
818        let tmp_dir = setup_test_dir_and_link().await?;
819        let output_path = &tmp_dir.join("bar");
820        {
821            // bar
822            // |- 0.txt
823            // |- bar  <---------------------------------------- REMOVE
824            //    |- 1.txt  <----------------------------------- REMOVE
825            //    |- 2.txt  <----------------------------------- REMOVE
826            //    |- 3.txt  <----------------------------------- REMOVE
827            // |- baz
828            //    |- 4.txt
829            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
830            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
831            let summary = rm::rm(
832                &PROGRESS,
833                &output_path.join("bar"),
834                &rm::Settings { fail_early: false },
835            )
836            .await?
837                + rm::rm(
838                    &PROGRESS,
839                    &output_path.join("baz").join("5.txt"),
840                    &rm::Settings { fail_early: false },
841                )
842                .await?;
843            assert_eq!(summary.files_removed, 3);
844            assert_eq!(summary.symlinks_removed, 1);
845            assert_eq!(summary.directories_removed, 1);
846        }
847        let summary = link(
848            &PROGRESS,
849            &tmp_dir,
850            &tmp_dir.join("foo"),
851            output_path,
852            &None,
853            &common_settings(false, true), // overwrite!
854            false,
855        )
856        .await?;
857        assert_eq!(summary.hard_links_created, 3);
858        assert_eq!(summary.copy_summary.symlinks_created, 1);
859        assert_eq!(summary.copy_summary.directories_created, 1);
860        testutils::check_dirs_identical(
861            &tmp_dir.join("foo"),
862            output_path,
863            testutils::FileEqualityCheck::Timestamp,
864        )
865        .await?;
866        Ok(())
867    }
868
869    #[tokio::test]
870    #[traced_test]
871    async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
872        let tmp_dir = setup_test_dir_and_link().await?;
873        let output_path = &tmp_dir.join("bar");
874        {
875            // bar
876            // |- 0.txt
877            // |- bar  <---------------------------------------- REMOVE
878            //    |- 1.txt  <----------------------------------- REMOVE
879            //    |- 2.txt  <----------------------------------- REMOVE
880            //    |- 3.txt  <----------------------------------- REMOVE
881            // |- baz
882            //    |- 4.txt
883            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
884            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
885            let summary = rm::rm(
886                &PROGRESS,
887                &output_path.join("bar"),
888                &rm::Settings { fail_early: false },
889            )
890            .await?
891                + rm::rm(
892                    &PROGRESS,
893                    &output_path.join("baz").join("5.txt"),
894                    &rm::Settings { fail_early: false },
895                )
896                .await?;
897            assert_eq!(summary.files_removed, 3);
898            assert_eq!(summary.symlinks_removed, 1);
899            assert_eq!(summary.directories_removed, 1);
900        }
901        setup_update_dir(&tmp_dir).await?;
902        // update
903        // |- 0.txt
904        // |- bar
905        //    |- 1.txt
906        //    |- 2.txt -> ../0.txt
907        let summary = link(
908            &PROGRESS,
909            &tmp_dir,
910            &tmp_dir.join("foo"),
911            output_path,
912            &Some(tmp_dir.join("update")),
913            &common_settings(false, true), // overwrite!
914            false,
915        )
916        .await?;
917        assert_eq!(summary.hard_links_created, 1); // 3.txt
918        assert_eq!(summary.copy_summary.files_copied, 2); // 0.txt, 1.txt
919        assert_eq!(summary.copy_summary.symlinks_created, 2); // 2.txt, 5.txt
920        assert_eq!(summary.copy_summary.directories_created, 1);
921        // compare subset of src and dst
922        testutils::check_dirs_identical(
923            &tmp_dir.join("foo").join("baz"),
924            &tmp_dir.join("bar").join("baz"),
925            testutils::FileEqualityCheck::HardLink,
926        )
927        .await?;
928        // compare update and dst
929        testutils::check_dirs_identical(
930            &tmp_dir.join("update"),
931            &tmp_dir.join("bar"),
932            testutils::FileEqualityCheck::Timestamp,
933        )
934        .await?;
935        Ok(())
936    }
937
938    #[tokio::test]
939    #[traced_test]
940    async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
941        let tmp_dir = setup_test_dir_and_link().await?;
942        let output_path = &tmp_dir.join("bar");
943        {
944            // bar
945            // |- 0.txt
946            // |- bar
947            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
948            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
949            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
950            // |- baz    <-------------------------------------- REPLACE W/ FILE
951            //    |- ...
952            let bar_path = output_path.join("bar");
953            let summary = rm::rm(
954                &PROGRESS,
955                &bar_path.join("1.txt"),
956                &rm::Settings { fail_early: false },
957            )
958            .await?
959                + rm::rm(
960                    &PROGRESS,
961                    &bar_path.join("2.txt"),
962                    &rm::Settings { fail_early: false },
963                )
964                .await?
965                + rm::rm(
966                    &PROGRESS,
967                    &bar_path.join("3.txt"),
968                    &rm::Settings { fail_early: false },
969                )
970                .await?
971                + rm::rm(
972                    &PROGRESS,
973                    &output_path.join("baz"),
974                    &rm::Settings { fail_early: false },
975                )
976                .await?;
977            assert_eq!(summary.files_removed, 4);
978            assert_eq!(summary.symlinks_removed, 2);
979            assert_eq!(summary.directories_removed, 1);
980            // REPLACE with a file, a symlink, a directory and a file
981            tokio::fs::write(bar_path.join("1.txt"), "1-new")
982                .await
983                .unwrap();
984            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
985                .await
986                .unwrap();
987            tokio::fs::create_dir(&bar_path.join("3.txt"))
988                .await
989                .unwrap();
990            tokio::fs::write(&output_path.join("baz"), "baz")
991                .await
992                .unwrap();
993        }
994        let summary = link(
995            &PROGRESS,
996            &tmp_dir,
997            &tmp_dir.join("foo"),
998            output_path,
999            &None,
1000            &common_settings(false, true), // overwrite!
1001            false,
1002        )
1003        .await?;
1004        assert_eq!(summary.hard_links_created, 4);
1005        assert_eq!(summary.copy_summary.files_copied, 0);
1006        assert_eq!(summary.copy_summary.symlinks_created, 2);
1007        assert_eq!(summary.copy_summary.directories_created, 1);
1008        testutils::check_dirs_identical(
1009            &tmp_dir.join("foo"),
1010            &tmp_dir.join("bar"),
1011            testutils::FileEqualityCheck::HardLink,
1012        )
1013        .await?;
1014        Ok(())
1015    }
1016
1017    #[tokio::test]
1018    #[traced_test]
1019    async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
1020        let tmp_dir = setup_test_dir_and_link().await?;
1021        let output_path = &tmp_dir.join("bar");
1022        {
1023            // bar
1024            // |- 0.txt
1025            // |- bar
1026            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
1027            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
1028            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
1029            // |- baz    <-------------------------------------- REPLACE W/ FILE
1030            //    |- ...
1031            let bar_path = output_path.join("bar");
1032            let summary = rm::rm(
1033                &PROGRESS,
1034                &bar_path.join("1.txt"),
1035                &rm::Settings { fail_early: false },
1036            )
1037            .await?
1038                + rm::rm(
1039                    &PROGRESS,
1040                    &bar_path.join("2.txt"),
1041                    &rm::Settings { fail_early: false },
1042                )
1043                .await?
1044                + rm::rm(
1045                    &PROGRESS,
1046                    &bar_path.join("3.txt"),
1047                    &rm::Settings { fail_early: false },
1048                )
1049                .await?
1050                + rm::rm(
1051                    &PROGRESS,
1052                    &output_path.join("baz"),
1053                    &rm::Settings { fail_early: false },
1054                )
1055                .await?;
1056            assert_eq!(summary.files_removed, 4);
1057            assert_eq!(summary.symlinks_removed, 2);
1058            assert_eq!(summary.directories_removed, 1);
1059            // REPLACE with a file, a symlink, a directory and a file
1060            tokio::fs::write(bar_path.join("1.txt"), "1-new")
1061                .await
1062                .unwrap();
1063            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1064                .await
1065                .unwrap();
1066            tokio::fs::create_dir(&bar_path.join("3.txt"))
1067                .await
1068                .unwrap();
1069            tokio::fs::write(&output_path.join("baz"), "baz")
1070                .await
1071                .unwrap();
1072        }
1073        let source_path = &tmp_dir.join("foo");
1074        // unreadable
1075        tokio::fs::set_permissions(
1076            &source_path.join("baz"),
1077            std::fs::Permissions::from_mode(0o000),
1078        )
1079        .await?;
1080        // bar
1081        // |- ...
1082        // |- baz <- NON READABLE
1083        match link(
1084            &PROGRESS,
1085            &tmp_dir,
1086            &tmp_dir.join("foo"),
1087            output_path,
1088            &None,
1089            &common_settings(false, true), // overwrite!
1090            false,
1091        )
1092        .await
1093        {
1094            Ok(_) => panic!("Expected the link to error!"),
1095            Err(error) => {
1096                tracing::info!("{}", &error);
1097                assert_eq!(error.summary.hard_links_created, 3);
1098                assert_eq!(error.summary.copy_summary.files_copied, 0);
1099                assert_eq!(error.summary.copy_summary.symlinks_created, 0);
1100                assert_eq!(error.summary.copy_summary.directories_created, 0);
1101                assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
1102                assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
1103                assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
1104            }
1105        }
1106        Ok(())
1107    }
1108
1109    /// Verify that directory metadata is applied even when child link operations fail.
1110    /// This is a regression test for a bug where directory permissions were not preserved
1111    /// when linking with fail_early=false and some children failed to link.
1112    #[tokio::test]
1113    #[traced_test]
1114    async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
1115        let tmp_dir = testutils::create_temp_dir().await?;
1116        let test_path = tmp_dir.as_path();
1117        // create source directory with specific permissions
1118        let src_dir = test_path.join("src");
1119        tokio::fs::create_dir(&src_dir).await?;
1120        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
1121        // create a readable file (will be linked successfully)
1122        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
1123        // create a subdirectory with a file, then make the subdirectory unreadable
1124        // this will cause the recursive walk to fail when trying to read subdirectory contents
1125        let unreadable_subdir = src_dir.join("unreadable_subdir");
1126        tokio::fs::create_dir(&unreadable_subdir).await?;
1127        tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
1128        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
1129            .await?;
1130        let dst_dir = test_path.join("dst");
1131        // link with fail_early=false
1132        let result = link(
1133            &PROGRESS,
1134            test_path,
1135            &src_dir,
1136            &dst_dir,
1137            &None,
1138            &common_settings(false, false),
1139            false,
1140        )
1141        .await;
1142        // restore permissions so cleanup can succeed
1143        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
1144            .await?;
1145        // verify the operation returned an error (unreadable subdirectory should fail)
1146        assert!(
1147            result.is_err(),
1148            "link should fail due to unreadable subdirectory"
1149        );
1150        let error = result.unwrap_err();
1151        // verify the readable file was linked successfully
1152        assert_eq!(error.summary.hard_links_created, 1);
1153        // verify the destination directory exists and has the correct permissions
1154        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
1155        assert!(dst_metadata.is_dir());
1156        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
1157        assert_eq!(
1158            actual_mode, 0o750,
1159            "directory should have preserved source permissions (0o750), got {:o}",
1160            actual_mode
1161        );
1162        Ok(())
1163    }
1164}