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}