Skip to main content

common/
delete.rs

1//! rsync-style `--delete` (mirror) support: remove destination entries that
2//! have no counterpart in the source directory.
3
4use std::ffi::OsString;
5use std::sync::Arc;
6
7use anyhow::Context;
8
9use crate::copy::DeleteSettings;
10use crate::progress;
11use crate::safedir::Dir;
12
13/// Remove entries in the already-open destination directory `dst_dir` whose names are not in
14/// `keep` (the source entry names that passed the filter for this directory).
15///
16/// The destination is enumerated and pruned entirely through `dst_dir`'s pinned file descriptor:
17/// entries come from `dst_dir.read_entries()` and each extraneous entry is removed via
18/// `rm::rm_child` (fd-relative, `O_NOFOLLOW` descent). The destination path is never
19/// re-resolved, so a privileged prune cannot be redirected by a concurrent symlink swap into
20/// deleting a tree outside the destination — the classic mirror-delete symlink race fails closed.
21/// The caller is responsible for opening `dst_dir` `O_NOFOLLOW`: in a real copy it is the held
22/// destination directory; in `--dry-run` (where the create-or-overwrite step is skipped and the
23/// path could still be a symlink-to-directory) the caller opens it `O_NOFOLLOW|O_DIRECTORY`, which
24/// fails closed on a symlink or non-directory before prune is ever invoked.
25///
26/// `relative_dir` is this directory's path relative to the source root, used to match destination
27/// entries against `filter` for exclude-protection. Excluded destination entries are protected
28/// (kept) unless `delete_settings.delete_excluded` is set. Honors `dry_run` (reports without
29/// removing, via `rm::rm_child`).
30#[allow(clippy::too_many_arguments)]
31pub async fn prune_extraneous(
32    prog_track: &'static progress::Progress,
33    dst_dir: &Arc<Dir>,
34    relative_dir: &std::path::Path,
35    keep: &std::collections::HashSet<OsString>,
36    filter: Option<&crate::filter::FilterSettings>,
37    delete_settings: &DeleteSettings,
38    fail_early: bool,
39    dry_run: Option<crate::config::DryRunMode>,
40) -> Result<crate::rm::Summary, crate::rm::Error> {
41    let mut summary = crate::rm::Summary::default();
42    // Enumerate the destination through its pinned fd (no path re-resolution). The returned
43    // `d_type` is a best-effort hint passed to `filter_is_dir`, which resolves authoritatively via
44    // fstat on DT_UNKNOWN. `rm_child` re-classifies each entry authoritatively via `child()`.
45    let entries = dst_dir
46        .read_entries()
47        .await
48        .with_context(|| "failed scanning destination directory for deletion".to_string())
49        .map_err(|err| crate::rm::Error::new(err, summary))?;
50    let errors = crate::error_collector::ErrorCollector::default();
51    for (name, hint) in entries {
52        if keep.contains(&name) {
53            continue;
54        }
55        // the exclude-protection decision must use the AUTHORITATIVE is_dir: on filesystems that
56        // report DT_UNKNOWN (NFS, some FUSE mounts) the hint is None, so defaulting to non-dir
57        // would fail to protect a real directory that matches a dir-only exclude pattern like
58        // `cache/`. `filter_is_dir` does one authoritative fstat only in the DT_UNKNOWN+filter
59        // case, preserving the no-cost path when the hint is reliable or no filter is active.
60        let is_dir = crate::walk::filter_is_dir(filter, dst_dir, &name, hint, false).await;
61        // the entry's path relative to the destination (mirror) root: anchors filter matching and
62        // reconstructs the display path inside `rm_child`. Computed once and reused below.
63        let rel = relative_dir.join(&name);
64        // exclude-protection: keep destination entries the filter would exclude,
65        // unless --delete-excluded was requested.
66        if !delete_settings.delete_excluded
67            && let Some(filter) = filter
68            && !matches!(
69                filter.should_include(&rel, is_dir),
70                crate::filter::FilterResult::Included
71            )
72        {
73            tracing::debug!("protecting excluded destination entry {:?}", rel);
74            continue;
75        }
76        // Protect excluded descendants when removing an extraneous directory: rm::rm_child applies
77        // the filter recursively (skipping excluded entries), so an extra dir containing e.g.
78        // `*.log` files keeps them and survives non-empty — upholding the documented
79        // "excluded files are protected by default" guarantee (and matching rsync). With
80        // --delete-excluded we pass no filter so the whole subtree is removed. The filter is
81        // anchored at the destination (mirror) root, so the entry's destination-root-relative path
82        // `relative_dir.join(name)` matches path/anchored patterns like `cache/*.log` correctly.
83        let rm_settings = crate::rm::Settings {
84            fail_early,
85            filter: if delete_settings.delete_excluded {
86                None
87            } else {
88                filter.cloned()
89            },
90            time_filter: None,
91            dry_run,
92        };
93        match crate::rm::rm_child(prog_track, dst_dir, &name, &rel, &rm_settings).await {
94            Ok(rm_summary) => {
95                summary = summary + rm_summary;
96            }
97            Err(err) => {
98                summary = summary + err.summary;
99                if fail_early {
100                    return Err(crate::rm::Error::new(err.source, summary));
101                }
102                errors.push(err.source);
103            }
104        }
105    }
106    if let Some(err) = errors.into_error() {
107        return Err(crate::rm::Error::new(err, summary));
108    }
109    Ok(summary)
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::collections::HashSet;
116    use tracing_test::traced_test;
117
118    static PROGRESS: std::sync::LazyLock<progress::Progress> =
119        std::sync::LazyLock::new(progress::Progress::new);
120
121    fn delete_settings(delete_excluded: bool) -> DeleteSettings {
122        DeleteSettings { delete_excluded }
123    }
124
125    /// Open `dst` as the destination directory `Dir` the (now fd-relative) prune operates through,
126    /// mirroring what the copy/link call sites do (`O_NOFOLLOW|O_DIRECTORY`, Destination side).
127    async fn open_dst(dst: &std::path::Path) -> anyhow::Result<Arc<Dir>> {
128        Ok(Arc::new(
129            Dir::open_root_dir(dst, false, congestion::Side::Destination).await?,
130        ))
131    }
132
133    #[tokio::test]
134    #[traced_test]
135    async fn removes_entries_not_in_keep_set() -> anyhow::Result<()> {
136        let tmp = tempfile::tempdir()?;
137        let dst = tmp.path().join("dst");
138        tokio::fs::create_dir(&dst).await?;
139        tokio::fs::write(dst.join("keep.txt"), b"x").await?;
140        tokio::fs::write(dst.join("extra.txt"), b"x").await?;
141        tokio::fs::create_dir(dst.join("extra_dir")).await?;
142        tokio::fs::write(dst.join("extra_dir").join("nested.txt"), b"x").await?;
143
144        let mut keep = HashSet::new();
145        keep.insert(std::ffi::OsString::from("keep.txt"));
146
147        let dst_dir = open_dst(&dst).await?;
148        let summary = prune_extraneous(
149            &PROGRESS,
150            &dst_dir,
151            std::path::Path::new(""),
152            &keep,
153            None,
154            &delete_settings(false),
155            false,
156            None,
157        )
158        .await
159        .map_err(|e| e.source)?;
160
161        assert_eq!(summary.files_removed, 2); // extra.txt + extra_dir/nested.txt
162        assert_eq!(summary.directories_removed, 1); // extra_dir
163        assert!(dst.join("keep.txt").exists());
164        assert!(!dst.join("extra.txt").exists());
165        assert!(!dst.join("extra_dir").exists());
166        Ok(())
167    }
168
169    #[tokio::test]
170    #[traced_test]
171    async fn protects_excluded_entries_unless_delete_excluded() -> anyhow::Result<()> {
172        let tmp = tempfile::tempdir()?;
173        let dst = tmp.path().join("dst");
174        tokio::fs::create_dir(&dst).await?;
175        tokio::fs::write(dst.join("data.bin"), b"x").await?; // extra, not excluded
176        tokio::fs::write(dst.join("note.log"), b"x").await?; // extra, excluded by *.log
177
178        let mut filter = crate::filter::FilterSettings::new();
179        filter.add_exclude("*.log")?;
180        let keep = HashSet::new(); // both are extraneous
181
182        // default: *.log is protected, data.bin is removed
183        let dst_dir = open_dst(&dst).await?;
184        let summary = prune_extraneous(
185            &PROGRESS,
186            &dst_dir,
187            std::path::Path::new(""),
188            &keep,
189            Some(&filter),
190            &delete_settings(false),
191            false,
192            None,
193        )
194        .await
195        .map_err(|e| e.source)?;
196        assert_eq!(summary.files_removed, 1);
197        assert!(!dst.join("data.bin").exists());
198        assert!(
199            dst.join("note.log").exists(),
200            "*.log must be protected by default"
201        );
202
203        // with delete_excluded: note.log is also removed
204        let dst_dir = open_dst(&dst).await?;
205        let summary = prune_extraneous(
206            &PROGRESS,
207            &dst_dir,
208            std::path::Path::new(""),
209            &keep,
210            Some(&filter),
211            &delete_settings(true),
212            false,
213            None,
214        )
215        .await
216        .map_err(|e| e.source)?;
217        assert_eq!(summary.files_removed, 1);
218        assert!(!dst.join("note.log").exists());
219        Ok(())
220    }
221
222    #[tokio::test]
223    #[traced_test]
224    async fn protects_excluded_descendants_of_extraneous_dir() -> anyhow::Result<()> {
225        let tmp = tempfile::tempdir()?;
226        let dst = tmp.path().join("dst");
227        tokio::fs::create_dir(&dst).await?;
228        // an extraneous directory (no source counterpart) with an excluded and a normal file
229        tokio::fs::create_dir(dst.join("extra_dir")).await?;
230        tokio::fs::write(dst.join("extra_dir").join("keep.log"), b"x").await?; // excluded by *.log
231        tokio::fs::write(dst.join("extra_dir").join("gone.txt"), b"x").await?; // not excluded
232
233        let mut filter = crate::filter::FilterSettings::new();
234        filter.add_exclude("*.log")?;
235        let keep = HashSet::new(); // extra_dir is extraneous
236
237        // default --delete: the excluded descendant is protected, so the dir survives non-empty
238        let dst_dir = open_dst(&dst).await?;
239        let summary = prune_extraneous(
240            &PROGRESS,
241            &dst_dir,
242            std::path::Path::new(""),
243            &keep,
244            Some(&filter),
245            &delete_settings(false),
246            false,
247            None,
248        )
249        .await
250        .map_err(|e| e.source)?;
251        assert_eq!(summary.files_removed, 1); // gone.txt
252        assert!(!dst.join("extra_dir").join("gone.txt").exists());
253        assert!(
254            dst.join("extra_dir").join("keep.log").exists(),
255            "excluded descendant of an extraneous dir must be protected"
256        );
257
258        // --delete-excluded: the whole extraneous directory is removed
259        let dst_dir = open_dst(&dst).await?;
260        let summary = prune_extraneous(
261            &PROGRESS,
262            &dst_dir,
263            std::path::Path::new(""),
264            &keep,
265            Some(&filter),
266            &delete_settings(true),
267            false,
268            None,
269        )
270        .await
271        .map_err(|e| e.source)?;
272        assert_eq!(summary.files_removed, 1); // keep.log
273        assert!(!dst.join("extra_dir").exists());
274        Ok(())
275    }
276
277    #[tokio::test]
278    #[traced_test]
279    async fn protects_path_excluded_descendants_of_extraneous_dir() -> anyhow::Result<()> {
280        let tmp = tempfile::tempdir()?;
281        let dst = tmp.path().join("dst");
282        tokio::fs::create_dir(&dst).await?;
283        // an extraneous directory whose descendants are targeted by a PATH-based exclude
284        tokio::fs::create_dir(dst.join("cache")).await?;
285        tokio::fs::write(dst.join("cache").join("foo.log"), b"x").await?; // matches cache/*.log -> protected
286        tokio::fs::write(dst.join("cache").join("data.txt"), b"x").await?; // not matched -> removed
287
288        let mut filter = crate::filter::FilterSettings::new();
289        filter.add_exclude("cache/*.log")?;
290        let keep = HashSet::new();
291
292        let dst_dir = open_dst(&dst).await?;
293        let summary = prune_extraneous(
294            &PROGRESS,
295            &dst_dir,
296            std::path::Path::new(""),
297            &keep,
298            Some(&filter),
299            &delete_settings(false),
300            false,
301            None,
302        )
303        .await
304        .map_err(|e| e.source)?;
305
306        assert_eq!(summary.files_removed, 1); // data.txt
307        assert!(!dst.join("cache").join("data.txt").exists());
308        assert!(
309            dst.join("cache").join("foo.log").exists(),
310            "path-based exclude must protect the descendant of an extraneous dir"
311        );
312        Ok(())
313    }
314
315    /// Regression test for the DT_UNKNOWN + dir-only exclude protection bug (PR #247 review).
316    ///
317    /// On NFS/FUSE filesystems `read_entries` returns `None` for `d_type` (the `DT_UNKNOWN` case).
318    /// The old hint-only `is_dir` computation (`hint.is_some_and(|k| k == Dir)`) produced `false`
319    /// for `None`, so a real destination directory with a `None` hint appeared to be a non-dir.
320    /// A dir-only exclude pattern like `cache/` therefore did NOT protect it, and prune would
321    /// delete the directory when it should have kept it.
322    ///
323    /// The fix replaces the hint-only computation with `filter_is_dir(filter, dst_dir, name, hint)`,
324    /// which performs an authoritative `fstat` when the hint is `None` AND a filter is active.
325    ///
326    /// Since we cannot force `DT_UNKNOWN` on a local tmpfs, we verify the fix indirectly by driving
327    /// `filter_is_dir` with `hint = None` in isolation (exactly as the authoritative fstat path is
328    /// exercised in `walk.rs`'s unit tests), and by confirming that `prune_extraneous` with a
329    /// dir-only exclude retains the matching destination directory end-to-end. On a local fs the hint
330    /// is always `Some(Dir)`, so `filter_is_dir` uses it directly — but the test would fail with the
331    /// old code if the hint were forced to `None`, which is exactly what happens on NFS/FUSE.
332    #[tokio::test]
333    #[traced_test]
334    async fn protects_dir_only_excluded_directory_dt_unknown() -> anyhow::Result<()> {
335        let tmp = tempfile::tempdir()?;
336        let dst = tmp.path().join("dst");
337        tokio::fs::create_dir(&dst).await?;
338        // extraneous destination directory `cache/` containing a file; it should be RETAINED by a
339        // `cache/` dir-only exclude even though it has no source counterpart.
340        tokio::fs::create_dir(dst.join("cache")).await?;
341        tokio::fs::write(dst.join("cache").join("item.dat"), b"x").await?;
342        // an unrelated extra file that IS extraneous and not excluded, so it should be removed.
343        tokio::fs::write(dst.join("unrelated.txt"), b"y").await?;
344
345        let mut filter = crate::filter::FilterSettings::new();
346        filter.add_exclude("cache/")?; // dir-only exclude: only protects directories, not files
347
348        let keep = HashSet::new(); // both `cache/` and `unrelated.txt` are extraneous
349
350        // verify the authoritative fstat path via `filter_is_dir` with hint=None (DT_UNKNOWN
351        // simulation): a real directory must classify as `is_dir = true`, so the dir-only exclude
352        // protects it. The old hint-only code returned `false` here, causing the directory to be
353        // pruned instead.
354        let dst_dir = open_dst(&dst).await?;
355        let authoritative_is_dir = crate::walk::filter_is_dir(
356            Some(&filter),
357            &dst_dir,
358            std::ffi::OsStr::new("cache"),
359            None, // DT_UNKNOWN: no hint available (NFS/FUSE case)
360            false,
361        )
362        .await;
363        assert!(
364            authoritative_is_dir,
365            "filter_is_dir with hint=None on a real directory must return true via authoritative fstat"
366        );
367
368        // end-to-end: `prune_extraneous` must retain `cache/` (protected by the dir-only exclude)
369        // and remove `unrelated.txt` (not excluded).
370        let dst_dir = open_dst(&dst).await?;
371        let summary = prune_extraneous(
372            &PROGRESS,
373            &dst_dir,
374            std::path::Path::new(""),
375            &keep,
376            Some(&filter),
377            &delete_settings(false),
378            false,
379            None,
380        )
381        .await
382        .map_err(|e| e.source)?;
383
384        assert_eq!(summary.files_removed, 1); // unrelated.txt
385        assert!(!dst.join("unrelated.txt").exists());
386        assert!(
387            dst.join("cache").exists(),
388            "dir-only exclude `cache/` must protect the destination directory from --delete"
389        );
390        assert!(
391            dst.join("cache").join("item.dat").exists(),
392            "contents of the excluded directory must be preserved"
393        );
394        Ok(())
395    }
396
397    /// Repeatedly swap `dst/extra` between a real directory (holding a real file) and a symlink to
398    /// an OUT-OF-TREE sentinel directory, using rename so each individual state is atomic. Two
399    /// staging names live alongside `extra` and are renamed over it in a tight loop until `stop` is
400    /// set. Runs on a dedicated OS thread so it makes progress regardless of the tokio runtime's
401    /// scheduling. Mirrors rm's `spawn_dir_symlink_swapper`.
402    fn spawn_extra_swapper(
403        dst: std::path::PathBuf,
404        sentinel: std::path::PathBuf,
405        stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
406    ) -> std::thread::JoinHandle<()> {
407        std::thread::spawn(move || {
408            let extra = dst.join("extra");
409            let staged_dir = dst.join("__staged_extra_dir");
410            let staged_link = dst.join("__staged_extra_link");
411            while !stop.load(std::sync::atomic::Ordering::Relaxed) {
412                // stage a real directory (with a real file) then swap it in over `extra`.
413                let _ = std::fs::remove_dir_all(&staged_dir);
414                if std::fs::create_dir(&staged_dir).is_ok() {
415                    let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL");
416                    // RENAME_EXCHANGE isn't portable here; remove-then-rename. The window where
417                    // `extra` is briefly absent is fine — prune may error or no-op, an accepted
418                    // failed-closed outcome. (prune must still never touch the sentinel.)
419                    let _ = std::fs::remove_dir_all(&extra);
420                    let _ = std::fs::remove_file(&extra);
421                    let _ = std::fs::rename(&staged_dir, &extra);
422                }
423                // stage a symlink to the out-of-tree sentinel dir, then swap it in over `extra`.
424                let _ = std::fs::remove_file(&staged_link);
425                if std::os::unix::fs::symlink(&sentinel, &staged_link).is_ok() {
426                    let _ = std::fs::remove_dir_all(&extra);
427                    let _ = std::fs::remove_file(&extra);
428                    let _ = std::fs::rename(&staged_link, &extra);
429                }
430            }
431        })
432    }
433
434    /// While `prune_extraneous` prunes an extraneous destination SUBDIRECTORY (`dst/extra`, with no
435    /// source counterpart so the empty keep-set marks it for deletion), a background thread rapidly
436    /// flips `dst/extra` between a real directory and a symlink to a SENTINEL directory tree that
437    /// lives OUTSIDE the destination, holding files that must never be deleted.
438    ///
439    /// Prune is fd-relative: it enumerates and removes children through the destination's own
440    /// pinned `Dir` fd (`rm_child` → `child()` classify + `open_dir` descent). If `extra` is a
441    /// symlink at the moment of descent, `open_dir`'s `O_NOFOLLOW|O_DIRECTORY` fails closed
442    /// (ELOOP/ENOTDIR) and prune never follows it into the sentinel. If `extra` is a symlink at the
443    /// moment of classification it is treated as a leaf and `unlink_at` removes the LINK, never its
444    /// target. Either way the out-of-tree sentinel files survive — the core safety assertion,
445    /// checked on every iteration regardless of timing. Also confirms the run terminates (per-op
446    /// timeout) rather than hanging or following the link.
447    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
448    async fn prune_extra_dir_swap_never_deletes_out_of_tree_sentinel() -> anyhow::Result<()> {
449        let tmp = tempfile::tempdir()?;
450        let root = tmp.path();
451        // the sentinel tree lives OUTSIDE the destination, reachable only via the swapped symlink.
452        let sentinel = root.join("sentinel_tree");
453        tokio::fs::create_dir(&sentinel).await?;
454        tokio::fs::write(sentinel.join("secret1.txt"), b"SECRET-1").await?;
455        tokio::fs::create_dir(sentinel.join("subdir")).await?;
456        tokio::fs::write(sentinel.join("subdir").join("secret2.txt"), b"SECRET-2").await?;
457
458        let dst = root.join("dst");
459        tokio::fs::create_dir(&dst).await?;
460
461        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
462        let swapper = spawn_extra_swapper(dst.clone(), sentinel.clone(), stop.clone());
463
464        // empty keep-set: `extra` (and any other entry) is extraneous and marked for pruning.
465        let keep = HashSet::new();
466        let mut pruned = 0usize;
467        let mut errored = 0usize;
468        for i in 0..400 {
469            // give `dst` many sibling extraneous subdirectories with files so prune spends real
470            // time enumerating/removing them concurrently with the swapper's flips, widening the
471            // window in which `extra` is classified/descended mid-swap.
472            for d in 0..16 {
473                let sib = dst.join(format!("sib_{d}"));
474                let _ = tokio::fs::create_dir(&sib).await;
475                for f in 0..4 {
476                    let _ = tokio::fs::write(sib.join(format!("f{f}.txt")), b"x").await;
477                }
478            }
479            let extra = dst.join("extra");
480            if i % 2 == 0 {
481                // deterministically place a symlink-to-sentinel at `extra` (best-effort; the
482                // swapper may immediately flip it — both states are safe).
483                let _ = tokio::fs::remove_dir_all(&extra).await;
484                let _ = tokio::fs::remove_file(&extra).await;
485                let _ = tokio::fs::symlink(&sentinel, &extra).await;
486            } else if tokio::fs::symlink_metadata(&extra).await.is_err() {
487                let _ = tokio::fs::create_dir(&extra).await;
488                let _ = tokio::fs::write(extra.join("real.txt"), b"REAL").await;
489            }
490            // open the destination O_NOFOLLOW (as the real call sites do) and prune it.
491            let dst_dir = open_dst(&dst).await?;
492            let result = tokio::time::timeout(
493                std::time::Duration::from_secs(30),
494                prune_extraneous(
495                    &PROGRESS,
496                    &dst_dir,
497                    std::path::Path::new(""),
498                    &keep,
499                    None,
500                    &delete_settings(false),
501                    false,
502                    None,
503                ),
504            )
505            .await
506            .expect("prune must not hang under concurrent dir swapping");
507            match result {
508                Ok(_) => pruned += 1,
509                Err(_) => errored += 1, // a swap was caught mid-walk (failed closed) — accepted
510            }
511            // CORE SAFETY ASSERTION (holds on every iteration regardless of timing): the
512            // out-of-tree sentinel tree and its files are NEVER deleted — neither by following a
513            // symlinked `extra` (unlink removes the link, not the target) nor by descending it
514            // (open_dir's O_NOFOLLOW fails closed).
515            assert!(
516                sentinel.exists(),
517                "iteration {i}: sentinel directory was deleted — prune followed the symlink"
518            );
519            let s1 = tokio::fs::read(sentinel.join("secret1.txt")).await;
520            assert!(
521                matches!(&s1, Ok(b) if b == b"SECRET-1"),
522                "iteration {i}: sentinel/secret1.txt was deleted or altered — prune followed the symlink"
523            );
524            let s2 = tokio::fs::read(sentinel.join("subdir").join("secret2.txt")).await;
525            assert!(
526                matches!(&s2, Ok(b) if b == b"SECRET-2"),
527                "iteration {i}: sentinel/subdir/secret2.txt was deleted — prune recursed through the symlink"
528            );
529        }
530
531        stop.store(true, std::sync::atomic::Ordering::Relaxed);
532        swapper.join().expect("extra swapper thread panicked");
533        // sanity (not the safety assertion): the run did observable work across the iterations.
534        tracing::info!("prune extra-dir swap: pruned={pruned}, errored={errored}");
535        assert!(
536            pruned + errored > 0,
537            "expected at least one observable outcome across the iterations"
538        );
539        Ok(())
540    }
541
542    #[tokio::test]
543    #[traced_test]
544    async fn opening_dst_symlink_to_directory_fails_closed() -> anyhow::Result<()> {
545        // prune is now fd-relative: it operates through a `Dir` the caller opens `O_NOFOLLOW`. In a
546        // real --delete run the create-or-overwrite step replaces any non-directory destination
547        // (including a symlink) before prune runs; in --dry-run that overwrite is skipped, so the
548        // destination path could still be a symlink-to-directory. Opening it `O_NOFOLLOW|O_DIRECTORY`
549        // (dereference=false) — exactly what the copy/link prune call sites do — must FAIL CLOSED on
550        // that symlink rather than follow it. This is the guarantee that replaces the old
551        // `symlink_metadata` pre-check: prune can never be handed a Dir that followed a dst symlink,
552        // so it can never preview/perform deletions OUTSIDE the destination tree.
553        let tmp = tempfile::tempdir()?;
554        let dst_parent = tmp.path().join("dst_parent");
555        let outside = tmp.path().join("outside"); // outside the destination tree
556        tokio::fs::create_dir(&dst_parent).await?;
557        tokio::fs::create_dir(&outside).await?;
558        tokio::fs::write(outside.join("precious.txt"), b"keep me").await?;
559        // dst is a symlink-to-directory living under the parent we'd prune.
560        let dst = dst_parent.join("link_dir");
561        std::os::unix::fs::symlink(&outside, &dst)?;
562
563        // opening the symlinked dst O_NOFOLLOW must fail (ELOOP), so the call site skips prune.
564        let result = Dir::open_root_dir(&dst, false, congestion::Side::Destination).await;
565        assert!(
566            result.is_err(),
567            "opening a dst symlink-to-directory O_NOFOLLOW must fail closed, not follow it"
568        );
569        // the out-of-tree tree is untouched.
570        assert!(outside.join("precious.txt").exists());
571        Ok(())
572    }
573}