common/walk_driver.rs
1//! Generic single-tree safe-walk driver.
2//!
3//! This module owns the recursive directory-walk *skeleton* that copy, chmod, and
4//! rm previously each hand-coded. A tool supplies a [`WalkVisitor`]; the driver
5//! drives the traversal:
6//!
7//! 1. gated `read_entries` on the open hardened directory,
8//! 2. per child: authoritative-or-hinted [`walk::filter_is_dir`] +
9//! [`walk::should_skip_entry`] filter decision (against the child's
10//! [`EntryCx::filter_path`]), then [`walk::preacquire_leaf_permit`] per the
11//! visitor's policy,
12//! 3. acquire-then-spawn one task per non-skipped child, joining/folding via
13//! [`join_and_fold`] (NOT batched: see [`walk_dir_contents`] for why the permit
14//! is acquired and the task spawned in the same loop step),
15//! 4. in each task: authoritative [`Dir::child`] classification, then either
16//! [`WalkVisitor::visit_leaf`] (holding the permit) or — for a directory —
17//! **drop the permit**, [`WalkVisitor::dir_pre`], recurse, [`WalkVisitor::dir_post`].
18//!
19//! ## The single invariant home
20//!
21//! The "drop the leaf permit before recursing into a directory" invariant — the
22//! root cause of the hold-and-wait deadlock class (see [`walk::LeafPermit`]) —
23//! lives in **exactly one place**: the directory branch of [`process_entry`].
24//! Leaves hold their permit across [`WalkVisitor::visit_leaf`]; the directory
25//! branch `drop`s it before any further work. No visitor ever hand-drops a leaf
26//! permit, so the invariant cannot silently migrate back to N parallel sites.
27//!
28//! ## Cancellation safety
29//!
30//! Spawned tasks must be `'static`, and `spawn_blocking` work is not cancellable,
31//! so every per-entry context is **owned**: [`EntryCx`] clones `Arc<Dir>` plus
32//! owned `OsString`/`PathBuf` rather than borrowing, exactly as the existing
33//! per-tool walks do. A dropped surrounding future (timeout, `fail_early` abort,
34//! Ctrl-C) therefore can never leave a spawned task holding a dangling borrow.
35//!
36//! ## How copy maps onto this trait
37//!
38//! `copy` is the reference single-tree visitor (`rcp::copy::CopyVisitor`); its
39//! mapping shaped this trait.
40//! `CopyVisitor` holds the run-constant state (`dst_root`, `filter_base`,
41//! `Settings`, `preserve::Settings`, the opened top-level destination parent — the
42//! *source* root needs no field, since each entry's source path is its
43//! [`EntryCx::real_path`]) and:
44//!
45//! - **`type Summary`** = `copy::Summary`.
46//! - **`type DirContext`** = the *destination* parent for one level:
47//! `{ dst_dir: Option<Arc<Dir>>, is_fresh: bool }` (`None` dst = dry-run). This
48//! is how the single-tree driver carries copy's second tree — each child reads
49//! its destination parent from `parent_ctx` rather than the driver modeling two
50//! trees. [`WalkVisitor::root_dir_context`] returns the opened top-level
51//! destination (`Some(dst)`/`None`) with the initial `is_fresh`.
52//! - **`type DirState`** = `{ dst_dir, dst_parent, dst_name, we_created, src_meta,
53//! is_root, base }` — what `dir_post` needs to apply directory metadata
54//! (`src_meta` is taken from `dir_pre`'s classification `Handle`, no extra stat),
55//! run empty-dir cleanup (`dst_parent.rmdir_at(dst_name)`), and `--delete`-prune,
56//! plus the `base` create/unchanged contribution it folds with the children.
57//! - **`visit_leaf`** dispatches on `kind`: `File` → `copy_file_fd`, `Symlink` →
58//! `copy_symlink_fd`, `Special` → skip-or-error. The pre-acquired `permit` is
59//! the open-files guard `copy_file_fd` needs; it is dropped for symlink/special.
60//! `--dereference` of a symlink-to-dir stays inside `visit_leaf`: it drops the
61//! permit and calls the path-based `copy()` recursively (the one deliberately
62//! non-fd path), which the trait expresses fine — `visit_leaf` is a plain async
63//! fn that may itself recurse without going through the driver.
64//! - **`dir_pre`** runs `resolve_dst_dir`: `DirResolution::Skip` →
65//! [`DirAction::Skip`] (`--ignore-existing` hit a non-dir); `Proceed{dir,..}` →
66//! [`DirAction::Descend`] whose `dir` is the *source* dir (opened via
67//! `src_parent.open_dir(name)`), `child_ctx` carries the resolved `dst_dir` +
68//! child `is_fresh`, and `state` carries the `DirState`.
69//! - **`dir_post`** receives the children's folded `Result`: on `Ok` it runs the
70//! `--delete` prune (keep-set = `processed.names()`), empty-dir cleanup, and
71//! `set_dir_metadata_fd` (post-order); on `Err` (a non-fail-early child failure)
72//! it skips the destructive prune, still applies directory metadata, and returns
73//! the combined error — exactly as `copy_dir_contents`'s tail did.
74//! - **`on_skip`** mirrors copy's inline filter-skip: `report_skip` in dry-run +
75//! `skipped_summary_for(kind)`.
76//! - **`permit_kind`** = `OpenFile`; **`want_permit`** = "hint is `File`" (copy
77//! only pre-acquires for a regular-file hint — symlinks may deref to dirs, and
78//! DT_UNKNOWN might be a dir).
79//!
80//! The delegated-subtree case (rlink handing copy an update-only/type-changed
81//! subtree rooted below the original filter root) is carried by seeding the root
82//! [`EntryCx::filter_path`] with the subtree's logical base, so the filter still
83//! matches at the entry's true path while `rel_path`/`real_path` stay relative to
84//! the delegated root.
85//!
86//! The dry-run "directory" path (no destination dir, contents still traversed for
87//! reporting) is just `DirContext.dst_dir == None` threaded through — the same
88//! branch copy already has. No part of copy needs a trait shape this module does
89//! not provide, which is why the trait stops here (no second-tree concept leaks
90//! into the driver — that asymmetry is what keeps rlink on the substrate, not the
91//! visitor; see docs/tocttou.md, "One shared traversal driver").
92
93use std::ffi::{OsStr, OsString};
94use std::path::PathBuf;
95use std::sync::Arc;
96
97use async_recursion::async_recursion;
98
99use crate::error::OperationError;
100use crate::progress::Progress;
101use crate::safedir::{Dir, Handle};
102use crate::walk::{self, EntryKind, LeafPermit, PermitKind};
103
104/// A per-run summary accumulated by the walk.
105///
106/// Every tool's `Summary` (copy/chmod/rm/link) already satisfies these bounds
107/// (`Default + Add + Send + 'static`); requiring exactly them keeps the driver
108/// generic over the tool without depending on any tool's concrete counters.
109pub trait WalkSummary: Default + std::ops::Add<Output = Self> + Send + Sized + 'static {}
110
111impl<T> WalkSummary for T where T: Default + std::ops::Add<Output = T> + Send + Sized + 'static {}
112
113/// Owned per-entry context handed to every [`WalkVisitor`] method.
114///
115/// All fields are owned so the whole context can move into a spawned task (tasks
116/// are `'static`). It carries the hardened parent [`Dir`] (an `Arc`, cloned, never
117/// borrowed) and the entry's accumulated paths.
118#[derive(Clone)]
119pub struct EntryCx {
120 /// The hardened directory that contains this entry. Cloned into each child
121 /// task; every open below it is `O_NOFOLLOW`.
122 pub parent: Arc<Dir>,
123 /// This entry's name within `parent`.
124 pub name: OsString,
125 /// Path accumulated from the walk root to this entry (empty for the root
126 /// entry). Joined onto the tool's root it reconstructs the real path; used for
127 /// diagnostics and path reconstruction.
128 pub rel_path: PathBuf,
129 /// The path the driver feeds to the include/exclude filter for this entry: the
130 /// entry's **logical** path relative to the filter root. Usually equals
131 /// `rel_path`, but a tool processing a *delegated subtree* (rlink handing an
132 /// update-only or type-changed subtree to copy, which is rooted below the
133 /// original filter root) seeds the root entry's `filter_path` with that subtree's
134 /// logical base so the filter still matches at the entry's true path (e.g.
135 /// `cache/keep.txt`, not the bare `keep.txt` relative to the delegated root). The
136 /// driver extends it by one component per level alongside `rel_path`.
137 pub filter_path: PathBuf,
138 /// `root.join(rel_path)` — the reconstructed real filesystem path, for
139 /// diagnostics and the deliberately-path-based features (`-L`/`--delete`).
140 pub real_path: PathBuf,
141 /// Whether this is a dry run (no filesystem mutation).
142 pub dry_run: bool,
143 /// The process-global progress tracker.
144 pub prog_track: &'static Progress,
145}
146
147impl EntryCx {
148 /// Build the child context for `child_name` within `child_dir`, extending the
149 /// accumulated `rel_path`/`filter_path`/`real_path` by one component. `child_dir`
150 /// is the hardened directory the child lives in (for a directory entry's
151 /// contents, the opened directory itself; for the root, the root directory).
152 #[must_use]
153 pub fn child(&self, child_dir: Arc<Dir>, child_name: &OsStr) -> EntryCx {
154 EntryCx {
155 parent: child_dir,
156 name: child_name.to_owned(),
157 rel_path: self.rel_path.join(child_name),
158 filter_path: self.filter_path.join(child_name),
159 real_path: self.real_path.join(child_name),
160 dry_run: self.dry_run,
161 prog_track: self.prog_track,
162 }
163 }
164}
165
166/// What a [`WalkVisitor::dir_pre`] decided to do with a directory entry.
167///
168/// Generic over the tool's summary `Sum` (carried by [`Self::Skip`]), the
169/// per-directory inherited `Ctx` (carried by [`Self::Descend`] to this
170/// directory's children), and the per-directory `State` (carried by
171/// [`Self::Descend`] to [`WalkVisitor::dir_post`]).
172pub enum DirAction<Sum, Ctx, State> {
173 /// Do not descend; the whole subtree contributes `Sum` and nothing else
174 /// (e.g. `--ignore-existing` hit a non-directory destination, or a
175 /// filtered-out directory).
176 Skip(Sum),
177 /// Descend into `dir`; `child_ctx` is the inherited context handed to *this
178 /// directory's children* (copy's destination dir + freshness; chmod/rm: `()`),
179 /// and `state` is carried, in the same task, to [`WalkVisitor::dir_post`].
180 Descend {
181 /// The hardened directory whose contents to walk. For copy this is the
182 /// *source* directory being read; the destination travels in `child_ctx`.
183 dir: Arc<Dir>,
184 /// The inherited context the driver clones into each child task and hands
185 /// to the child's [`WalkVisitor::visit_leaf`] / [`WalkVisitor::dir_pre`].
186 /// This is how copy threads the destination parent directory down one
187 /// level without the driver knowing a second tree exists.
188 child_ctx: Ctx,
189 /// Tool state threaded from `dir_pre` to `dir_post` in the same task
190 /// (copy's `we_created` + dst handle for metadata; rm's `RelaxedDirGuard` +
191 /// snapshot; chmod's `()`).
192 state: State,
193 },
194}
195
196/// The names of the non-skipped children the driver actually spawned for a
197/// directory, in enumeration order.
198///
199/// Handed to [`WalkVisitor::dir_post`] so a visitor can build a `--delete`
200/// keep-set (the set of destination names with a source counterpart) without
201/// re-reading the directory.
202#[derive(Debug, Default)]
203pub struct ProcessedChildren {
204 names: Vec<OsString>,
205}
206
207impl ProcessedChildren {
208 /// The spawned children's names, in enumeration order.
209 #[must_use]
210 pub fn names(&self) -> &[OsString] {
211 &self.names
212 }
213
214 /// Move the names out (e.g. straight into a `--delete` keep-set).
215 #[must_use]
216 pub fn into_names(self) -> Vec<OsString> {
217 self.names
218 }
219}
220
221/// The `Result` a [`WalkVisitor::dir_pre`] produces: a [`DirAction`] over the
222/// visitor's three associated types, or its [`OperationError`]. A named alias so
223/// the trait method's signature stays readable.
224pub type DirPreResult<V> = Result<
225 DirAction<
226 <V as WalkVisitor>::Summary,
227 <V as WalkVisitor>::DirContext,
228 <V as WalkVisitor>::DirState,
229 >,
230 OperationError<<V as WalkVisitor>::Summary>,
231>;
232
233/// A tool's policy for a single-tree safe walk.
234///
235/// The driver calls these to make the per-entry decisions it cannot know itself;
236/// it owns everything else (enumeration, permit lifecycle, spawning, the
237/// drop-before-recurse invariant, error fold). All futures are `+ Send` (RPITIT)
238/// so the driver can spawn them; the visitor is shared as `Arc<V>`.
239pub trait WalkVisitor: Send + Sync + 'static {
240 /// Per-run summary type (the tool's `Summary`).
241 type Summary: WalkSummary;
242 /// Inherited per-directory context: what a directory's children need *from
243 /// that directory* (an "inherited attribute" of the tree walk). The driver
244 /// clones it into each child task and hands it to the child's
245 /// [`Self::visit_leaf`] / [`Self::dir_pre`].
246 ///
247 /// This is the single-tree driver's bridge to copy's second (destination)
248 /// tree: copy puts the open destination directory handle (plus its freshness)
249 /// here, so each child can create/overwrite its own destination entry without
250 /// the driver ever modeling a second tree. chmod and rm — which only ever
251 /// need the source parent the driver already provides — use `()`.
252 type DirContext: Clone + Send + Sync + 'static;
253 /// State threaded from [`Self::dir_pre`] to [`Self::dir_post`] within one
254 /// task (so it need not be `Send` across the per-child spawn boundary —
255 /// `dir_pre`/recurse/`dir_post` all run in the same task).
256 type DirState: Send;
257
258 /// The inherited context for the walk *root's* children — the seed of the
259 /// `DirContext` chain. copy returns its top-level destination directory here;
260 /// chmod/rm return `()`. (The root entry itself is processed with this same
261 /// context as its "parent" context.)
262 fn root_dir_context(&self) -> Self::DirContext;
263
264 /// Which backpressure pool a leaf permit comes from for this tool.
265 fn permit_kind(&self) -> PermitKind;
266
267 /// Whether to pre-acquire a leaf permit for a child with this `getdents`
268 /// `d_type` `hint`. Must return `false` for a hinted directory (it would
269 /// recurse and a held permit could deadlock); the canonical policy is
270 /// "known non-directory only". `hint == None` (DT_UNKNOWN) is a tool choice.
271 fn want_permit(&self, hint: Option<EntryKind>) -> bool;
272
273 /// Whether the walk stops at the first error (`--fail-early`).
274 fn fail_early(&self) -> bool;
275
276 /// The active filter, if any (drives [`walk::filter_is_dir`] /
277 /// [`walk::should_skip_entry_ref`]).
278 fn filter(&self) -> Option<&crate::filter::FilterSettings>;
279
280 /// Account for an entry the filter excluded, returning its summary
281 /// contribution. Called by the driver for each filtered-out child *instead of*
282 /// spawning it, so the tool's `*_skipped` counters and dry-run skip reporting
283 /// stay tool-owned (the driver is generic over the summary and dry-run mode).
284 ///
285 /// `kind` is the cheap `getdents`-hint classification (DT_UNKNOWN treated as a
286 /// file), matching the per-tool walks' skip dispatch; `skip_result` is the
287 /// `FilterResult` that caused the exclusion. The driver still increments the
288 /// shared progress counter via [`EntryKind::inc_skipped`] — override only to
289 /// add the summary counters and the `--dry-run` "skip …" line.
290 ///
291 /// The default does nothing (returns `Default`), which suits metadata-only
292 /// walks and the smoke tests; copy/chmod/rm override it to mirror their
293 /// existing `skipped_summary_for` + `report_skip` behavior.
294 fn on_skip(
295 &self,
296 _cx: &EntryCx,
297 _kind: EntryKind,
298 _skip_result: &crate::filter::FilterResult,
299 ) -> Self::Summary {
300 Self::Summary::default()
301 }
302
303 /// Process a non-directory entry (file / symlink / special). `parent_ctx` is
304 /// the inherited context of the directory containing this entry (copy's
305 /// destination parent + freshness). The pre-acquired `permit` (if any) is held
306 /// for the duration and dropped on return.
307 fn visit_leaf(
308 &self,
309 cx: &EntryCx,
310 parent_ctx: &Self::DirContext,
311 handle: Handle,
312 kind: EntryKind,
313 permit: Option<LeafPermit>,
314 ) -> impl std::future::Future<Output = Result<Self::Summary, OperationError<Self::Summary>>> + Send;
315
316 /// Pre-order step for a directory entry, run *after* the leaf permit has been
317 /// dropped and *before* the contents are walked. `parent_ctx` is the inherited
318 /// context of the *containing* directory. Returns [`DirAction::Skip`] to prune
319 /// the subtree or [`DirAction::Descend`] to walk it (supplying the child
320 /// context for this directory's own children, and the `dir_post` state).
321 ///
322 /// chmod applies the pre-order mode change here (unless deferred) and opens
323 /// the dir; copy resolves the destination directory (mkdir/overwrite/skip) and
324 /// puts it in the child context; rm snapshots metadata and arms its
325 /// `RelaxedDirGuard`.
326 fn dir_pre(
327 &self,
328 cx: &EntryCx,
329 parent_ctx: &Self::DirContext,
330 handle: &Handle,
331 ) -> impl std::future::Future<Output = DirPreResult<Self>> + Send;
332
333 /// Post-order step for a directory entry, run *after* its contents are walked,
334 /// in the same task as `dir_pre`. `state` is the [`DirAction::Descend`] state;
335 /// `processed` lists the spawned children; `child_result` is the contents' folded
336 /// outcome — `Ok(summary)` when every child succeeded, or `Err` carrying the
337 /// combined child error and the partial summary when one or more children failed
338 /// **without** `fail_early`. (Neither has `dir_pre`'s own contribution folded in
339 /// — the visitor carries that in `state` and folds it here.)
340 ///
341 /// `dir_post` is **not** called when `fail_early` is set and a child failed: that
342 /// case aborts the subtree immediately (the surrounding `JoinSet` drops, aborting
343 /// siblings) and the error is returned without any post-order work — so a
344 /// fail-early abort never applies post-order finalization (copy's directory
345 /// metadata / `--delete` prune). When `fail_early` is unset, `dir_post` IS called
346 /// with `Err(..)` so the visitor can still apply safe post-order finalization
347 /// (copy applies directory metadata even after a partial failure, but skips the
348 /// destructive `--delete` prune) and then return the combined error.
349 ///
350 /// copy applies directory metadata, empty-dir cleanup, and `--delete` prune;
351 /// chmod applies the deferred post-order change; rm runs the time filter, the
352 /// `rmdir`, and defuses its guard. A visitor that wants the historical
353 /// "finalize only on full success" behavior simply propagates the `Err`.
354 fn dir_post(
355 &self,
356 cx: &EntryCx,
357 state: Self::DirState,
358 processed: &ProcessedChildren,
359 child_result: Result<Self::Summary, OperationError<Self::Summary>>,
360 ) -> impl std::future::Future<Output = Result<Self::Summary, OperationError<Self::Summary>>> + Send;
361}
362
363/// Process one already-located entry: classify it authoritatively via
364/// [`Dir::child`], then dispatch.
365///
366/// - **Non-directory:** call [`WalkVisitor::visit_leaf`] holding `permit`.
367/// - **Directory:** `drop(permit)` — **the one and only drop-before-recurse
368/// site** — then [`WalkVisitor::dir_pre`]; on [`DirAction::Descend`], walk the
369/// contents via [`walk_dir_contents`] (threading the child context) and finish
370/// with [`WalkVisitor::dir_post`].
371///
372/// `parent_ctx` is the inherited context of the directory that contains this
373/// entry. `cx.parent` must be that (hardened) directory. On a classification
374/// error the entry's own error is surfaced — the same fail-closed behavior the
375/// per-tool walks have.
376#[async_recursion]
377pub async fn process_entry<V>(
378 visitor: Arc<V>,
379 cx: EntryCx,
380 parent_ctx: V::DirContext,
381 permit: Option<LeafPermit>,
382) -> Result<V::Summary, OperationError<V::Summary>>
383where
384 V: WalkVisitor,
385{
386 let _ops_guard = cx.prog_track.ops.guard();
387 // authoritative classification: one fstat, in one place. a symlink swap between
388 // the getdents hint and here is caught (O_NOFOLLOW) and classified as Symlink.
389 let handle = match cx.parent.child(&cx.name).await {
390 Ok(handle) => handle,
391 Err(err) => {
392 let err = anyhow::Error::new(err)
393 .context(format!("failed reading metadata from {:?}", &cx.real_path));
394 return Err(OperationError::new(err, Default::default()));
395 }
396 };
397 let kind = handle.kind();
398 if kind != EntryKind::Dir {
399 // leaf: the permit is held across the (non-recursive) leaf work and dropped
400 // when `visit_leaf` returns. nothing below this can recurse.
401 return visitor
402 .visit_leaf(&cx, &parent_ctx, handle, kind, permit)
403 .await;
404 }
405 // ── the single drop-before-recurse site ──────────────────────────────────
406 // an authoritative directory recurses; its children acquire their own permits.
407 // releasing the (only ever pre-acquired for a hinted leaf) permit now is what
408 // makes the hold-and-wait deadlock structurally impossible.
409 drop(permit);
410 match visitor.dir_pre(&cx, &parent_ctx, &handle).await? {
411 DirAction::Skip(summary) => Ok(summary),
412 DirAction::Descend {
413 dir,
414 child_ctx,
415 state,
416 } => {
417 match walk_dir_contents(Arc::clone(&visitor), dir, &cx, &child_ctx).await {
418 Ok((child_summary, processed)) => {
419 visitor
420 .dir_post(&cx, state, &processed, Ok(child_summary))
421 .await
422 }
423 // a child failed. with `fail_early` the subtree is aborted immediately and NO
424 // post-order work runs (siblings were aborted when the `JoinSet` dropped) — the
425 // error propagates as-is. without `fail_early`, `dir_post` IS still invoked, with
426 // the combined error, so the visitor can apply safe post-order finalization (copy's
427 // directory metadata) while skipping destructive work (copy's `--delete` prune) and
428 // then return the combined error. `processed` is not recoverable on the error path,
429 // so an empty list is passed — the only consumer (a `--delete` keep-set) is skipped
430 // on error anyway.
431 Err(walk_err) => {
432 if visitor.fail_early() {
433 Err(walk_err)
434 } else {
435 visitor
436 .dir_post(&cx, state, &ProcessedChildren::default(), Err(walk_err))
437 .await
438 }
439 }
440 }
441 }
442 }
443}
444
445/// Walk the contents of an open hardened directory.
446///
447/// Enumerates `dir` (gated `read_entries`), applies the visitor's filter to each
448/// child, pre-acquires a leaf permit per the visitor's policy, and spawns one
449/// [`process_entry`] task per non-skipped child. Joins them with a fold +
450/// fail-early via [`join_and_fold`].
451///
452/// Returns the folded child summary (filter-skip contributions included) and the
453/// [`ProcessedChildren`] list of the names that were spawned. `parent_cx`
454/// describes the directory entry itself (its `rel_path`/`real_path` are the base
455/// the children extend). `dir_ctx` is the inherited context of `dir` (the context
456/// its children receive) — for the root walk this is
457/// [`WalkVisitor::root_dir_context`].
458///
459/// ## Acquire-then-spawn ordering
460///
461/// Each leaf permit is acquired and the child task is spawned **in the same loop
462/// iteration**, before the next child's permit is acquired. This is load-bearing
463/// for backpressure correctness: a directory may have more permit-taking leaf
464/// children than the pool has permits. If permits for *every* child were acquired
465/// before *any* task was spawned (batch-acquire-then-spawn), the acquire loop would
466/// block on permit `N+1` while the first `N` permits are held by not-yet-running
467/// tasks — a self-deadlock against a saturated pool. Spawning each task as soon as
468/// its permit is taken lets running tasks release permits the loop is waiting on.
469#[async_recursion]
470pub async fn walk_dir_contents<V>(
471 visitor: Arc<V>,
472 dir: Arc<Dir>,
473 parent_cx: &EntryCx,
474 dir_ctx: &V::DirContext,
475) -> Result<(V::Summary, ProcessedChildren), OperationError<V::Summary>>
476where
477 V: WalkVisitor,
478{
479 let entries = match dir.read_entries().await {
480 Ok(entries) => entries,
481 Err(err) => {
482 let err = anyhow::Error::new(err)
483 .context(format!("cannot read directory {:?}", &parent_cx.real_path));
484 return Err(OperationError::new(err, Default::default()));
485 }
486 };
487 let mut skipped_summary = V::Summary::default();
488 let mut processed = ProcessedChildren::default();
489 let mut join_set = tokio::task::JoinSet::new();
490 for (entry_name, hint) in entries {
491 // the FILTER `is_dir` decision uses the AUTHORITATIVE type when the getdents
492 // hint is DT_UNKNOWN and a filter is active (one extra fstat only then, never
493 // follows a symlink) — the single classification path that closes the
494 // DT_UNKNOWN-omits-a-subtree bug class.
495 // used only for the FILTER decision; the recurse-vs-leaf choice is made later from the
496 // AUTHORITATIVE `child()` handle in `process_entry`, so there is no control-flow
497 // dependence here that would need `force_authoritative`.
498 let entry_is_dir =
499 walk::filter_is_dir(visitor.filter(), &dir, &entry_name, hint, false).await;
500 // build the child's owned context once; reused whether it is skipped or spawned.
501 let child_cx = parent_cx.child(Arc::clone(&dir), &entry_name);
502 if let Some(skip_result) =
503 walk::should_skip_entry_ref(visitor.filter(), &child_cx.filter_path, entry_is_dir)
504 {
505 // classification for the skipped-counter dispatch and the visitor's skip accounting
506 // uses the getdents hint, but for DT_UNKNOWN (`None`) falls back to the AUTHORITATIVE
507 // dir/non-dir decision already computed above for the filter. this branch only runs
508 // with an active filter, so `entry_is_dir` is the fstat-resolved value (no extra
509 // syscall) — matching the per-tool walks, which dispatched on the authoritative
510 // `file_type()`, so a real directory reported as DT_UNKNOWN is counted as
511 // `directories_skipped`, not `files_skipped`. (A DT_UNKNOWN symlink still counts as a
512 // file here; the subtree-scale dir mis-count is the one that matters.) The driver does
513 // the shared progress increment; the visitor's `on_skip` does the tool-specific summary
514 // + dry-run reporting.
515 let entry_kind = hint.unwrap_or(if entry_is_dir {
516 EntryKind::Dir
517 } else {
518 EntryKind::File
519 });
520 tracing::debug!("skipping {:?} due to filter", &child_cx.real_path);
521 entry_kind.inc_skipped(parent_cx.prog_track);
522 skipped_summary =
523 skipped_summary + visitor.on_skip(&child_cx, entry_kind, &skip_result);
524 continue;
525 }
526 // pre-acquire the leaf permit per the visitor's policy, then IMMEDIATELY spawn the task so
527 // the held permit can be released by the running task (see the acquire-then-spawn note
528 // above). a hinted directory takes none (it recurses); `process_entry` re-classifies and
529 // drops it for a hinted leaf that turns out to be a directory.
530 let permit =
531 walk::preacquire_leaf_permit(visitor.permit_kind(), hint, |h| visitor.want_permit(h))
532 .await;
533 // own everything moved into the task (cancellation safety): the child context
534 // (source parent Arc + owned name/paths), the visitor handle, and a clone of
535 // the inherited context (copy's destination parent dir).
536 let task_visitor = Arc::clone(&visitor);
537 let task_ctx = dir_ctx.clone();
538 processed.names.push(entry_name);
539 join_set
540 .spawn(async move { process_entry(task_visitor, child_cx, task_ctx, permit).await });
541 }
542 let folded =
543 join_and_fold::<V::Summary>(join_set, visitor.fail_early(), skipped_summary).await?;
544 Ok((folded, processed))
545}
546
547/// Join an already-populated `JoinSet` of per-child tasks and fold their summaries
548/// with fail-early / error-collection semantics — the shared join engine behind
549/// the directory walk.
550///
551/// The directory walk spawns into the `JoinSet` incrementally (acquire-then-spawn,
552/// see [`walk_dir_contents`]) and hands it here so the held leaf permits are
553/// released by running tasks rather than all held before the first task runs.
554/// `base` seeds the fold (the walk passes the filter-skip contributions). On
555/// `fail_early`, the first task error returns immediately, carrying the summary
556/// accumulated so far; the remaining tasks are aborted when the `JoinSet` is
557/// dropped. Otherwise all errors are collected and deduplicated, and the single
558/// combined error (if any) is returned with the full folded summary.
559pub async fn join_and_fold<S>(
560 mut join_set: tokio::task::JoinSet<Result<S, OperationError<S>>>,
561 fail_early: bool,
562 base: S,
563) -> Result<S, OperationError<S>>
564where
565 S: WalkSummary,
566{
567 let mut summary = base;
568 let errors = crate::error_collector::ErrorCollector::default();
569 while let Some(res) = join_set.join_next().await {
570 match res {
571 Ok(Ok(child_summary)) => summary = summary + child_summary,
572 Ok(Err(error)) => {
573 tracing::error!("walk child failed with: {:#}", &error);
574 summary = summary + error.summary;
575 if fail_early {
576 // dropping `join_set` here aborts the still-running children.
577 return Err(OperationError::new(error.source, summary));
578 }
579 errors.push(error.source);
580 }
581 Err(join_error) => {
582 if fail_early {
583 return Err(OperationError::new(join_error.into(), summary));
584 }
585 errors.push(join_error.into());
586 }
587 }
588 }
589 if let Some(error) = errors.into_error() {
590 return Err(OperationError::new(error, summary));
591 }
592 Ok(summary)
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use crate::filter::FilterSettings;
599 use crate::progress::Progress;
600 use std::sync::atomic::{AtomicUsize, Ordering};
601 use std::time::Duration;
602
603 static PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
604
605 /// A minimal `Summary` for the driver tests: counts files, dirs, and symlinks.
606 #[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
607 struct CountSummary {
608 files: usize,
609 dirs: usize,
610 symlinks: usize,
611 }
612
613 impl std::ops::Add for CountSummary {
614 type Output = Self;
615 fn add(self, other: Self) -> Self {
616 Self {
617 files: self.files + other.files,
618 dirs: self.dirs + other.dirs,
619 symlinks: self.symlinks + other.symlinks,
620 }
621 }
622 }
623
624 /// A trivial visitor that just counts entries by kind. Exercises RPITIT +
625 /// `Send` + recursion (compile and run). `DirState = ()`; leaf permit comes
626 /// from the pending-meta pool, taken for known non-directory hints only.
627 struct CountingVisitor {
628 /// counts every spawned leaf, to prove `visit_leaf` ran under backpressure.
629 leaves_seen: Arc<AtomicUsize>,
630 }
631
632 impl WalkVisitor for CountingVisitor {
633 type Summary = CountSummary;
634 type DirContext = ();
635 type DirState = ();
636
637 fn root_dir_context(&self) {}
638
639 fn permit_kind(&self) -> PermitKind {
640 PermitKind::PendingMeta
641 }
642 fn want_permit(&self, hint: Option<EntryKind>) -> bool {
643 // known non-directory only; a hinted dir (or DT_UNKNOWN) takes none.
644 hint.is_some_and(|k| k != EntryKind::Dir)
645 }
646 fn fail_early(&self) -> bool {
647 false
648 }
649 fn filter(&self) -> Option<&FilterSettings> {
650 None
651 }
652
653 async fn visit_leaf(
654 &self,
655 _cx: &EntryCx,
656 _parent_ctx: &(),
657 _handle: Handle,
658 kind: EntryKind,
659 permit: Option<LeafPermit>,
660 ) -> Result<CountSummary, OperationError<CountSummary>> {
661 self.leaves_seen.fetch_add(1, Ordering::SeqCst);
662 // hold the permit across the leaf work, then drop it (mirrors real tools).
663 drop(permit);
664 Ok(match kind {
665 EntryKind::Symlink => CountSummary {
666 symlinks: 1,
667 ..Default::default()
668 },
669 _ => CountSummary {
670 files: 1,
671 ..Default::default()
672 },
673 })
674 }
675
676 async fn dir_pre(
677 &self,
678 cx: &EntryCx,
679 _parent_ctx: &(),
680 _handle: &Handle,
681 ) -> Result<DirAction<CountSummary, (), ()>, OperationError<CountSummary>> {
682 // open the directory's contents fd (O_NOFOLLOW) and descend.
683 let dir = cx.parent.open_dir(&cx.name).await.map_err(|err| {
684 OperationError::new(
685 anyhow::Error::new(err)
686 .context(format!("cannot open directory {:?}", &cx.real_path)),
687 Default::default(),
688 )
689 })?;
690 Ok(DirAction::Descend {
691 dir: Arc::new(dir),
692 child_ctx: (),
693 state: (),
694 })
695 }
696
697 async fn dir_post(
698 &self,
699 _cx: &EntryCx,
700 _state: (),
701 _processed: &ProcessedChildren,
702 child_result: Result<CountSummary, OperationError<CountSummary>>,
703 ) -> Result<CountSummary, OperationError<CountSummary>> {
704 // count this directory itself, post-order. a child error propagates (this test visitor
705 // has `fail_early == false` but never errors, so the `Ok` arm is what runs).
706 let child_summary = child_result?;
707 Ok(child_summary
708 + CountSummary {
709 dirs: 1,
710 ..Default::default()
711 })
712 }
713 }
714
715 /// Build an `EntryCx` for the root directory `name` under `parent`.
716 fn root_cx(parent: Arc<Dir>, name: &OsStr, real_path: PathBuf) -> EntryCx {
717 EntryCx {
718 parent,
719 name: name.to_owned(),
720 rel_path: PathBuf::new(),
721 filter_path: PathBuf::new(),
722 real_path,
723 dry_run: false,
724 prog_track: &PROGRESS,
725 }
726 }
727
728 // The driver compiles and runs end-to-end (RPITIT + Send + recursion) and counts
729 // a real tree correctly. `setup_test_dir` builds foo/{0.txt, bar/{1,2,3.txt},
730 // baz/{4.txt, 5.txt->sym, 6.txt->sym}}: under `foo` there are 5 files, 2
731 // subdirectories, and 2 symlinks.
732 #[tokio::test]
733 async fn counts_entries_in_a_tree() -> anyhow::Result<()> {
734 let tmp = crate::testutils::setup_test_dir().await?;
735 let foo = tmp.join("foo");
736 // open the foo directory as the hardened root to walk its contents.
737 let root = Arc::new(Dir::open_root_dir(&foo, false, congestion::Side::Source).await?);
738 let leaves_seen = Arc::new(AtomicUsize::new(0));
739 let visitor = Arc::new(CountingVisitor {
740 leaves_seen: Arc::clone(&leaves_seen),
741 });
742 let cx = root_cx(Arc::clone(&root), std::ffi::OsStr::new("foo"), foo.clone());
743 let (summary, processed) = walk_dir_contents(visitor, root, &cx, &()).await?;
744 assert_eq!(
745 summary,
746 CountSummary {
747 files: 5,
748 dirs: 2,
749 symlinks: 2,
750 },
751 "the walk must count every entry once, by kind"
752 );
753 // the top-level processed list is foo's direct children: 0.txt, bar, baz.
754 assert_eq!(processed.names().len(), 3, "foo has three direct children");
755 assert_eq!(
756 leaves_seen.load(Ordering::SeqCst),
757 7,
758 "every non-directory leaf (5 files + 2 symlinks) was visited"
759 );
760 Ok(())
761 }
762
763 /// Driver-level deadlock regression. The module name carries the
764 /// `max_open_files` substring so nextest's serial test-group isolates this
765 /// process-wide throttle mutation (see `.config/nextest.toml`).
766 mod max_open_files_tests {
767 use super::*;
768
769 /// Driver-level regression for the drop-before-recurse invariant.
770 ///
771 /// With the pending-meta pool sized to a single permit, we pre-acquire that
772 /// one permit and hand it to `process_entry` for an entry that is
773 /// AUTHORITATIVELY a directory (mirroring the spawn loop's hinted-leaf
774 /// pre-acquire when getdents mis-hints, or a swap between getdents and
775 /// `child()`). The directory's child file then needs its own pending-meta
776 /// permit to be visited.
777 ///
778 /// WITHOUT the fix, `process_entry` would hold that one permit across the
779 /// recursion and the child's `preacquire_leaf_permit` would block forever
780 /// (pool size 1, already held) — the timeout fires. WITH the fix, the
781 /// directory branch drops the permit before recursing, the child acquires it,
782 /// and the walk completes well within the timeout.
783 #[tokio::test]
784 async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
785 let root = crate::testutils::create_temp_dir().await?;
786 // `d` is a real directory holding one child file `c`.
787 let dir_path = root.join("d");
788 tokio::fs::create_dir(&dir_path).await?;
789 tokio::fs::write(dir_path.join("c"), b"x").await?;
790 // size the pending-meta pool to a single permit (the `set_max_open_files`
791 // knob sizes both pools).
792 throttle::set_max_open_files(1);
793 // open the container of `d` and classify `d`: an authoritative directory.
794 let parent = Arc::new(
795 Dir::open_parent_dir(&root, congestion::Side::Source)
796 .await?
797 .into_tree(),
798 );
799 let name = std::ffi::OsStr::new("d");
800 let handle = parent.child(name).await?;
801 assert_eq!(
802 handle.kind(),
803 EntryKind::Dir,
804 "fixture `d` must be a directory"
805 );
806 drop(handle);
807 let leaves_seen = Arc::new(AtomicUsize::new(0));
808 let visitor = Arc::new(CountingVisitor {
809 leaves_seen: Arc::clone(&leaves_seen),
810 });
811 let cx = root_cx(Arc::clone(&parent), name, dir_path.clone());
812 // pre-acquire the single permit exactly as the spawn loop does for a
813 // hinted leaf, and hand it to `process_entry`. the fix drops it before
814 // recursing.
815 let permit = walk::preacquire_leaf_permit(
816 PermitKind::PendingMeta,
817 Some(EntryKind::File),
818 |_| true,
819 )
820 .await;
821 assert!(permit.is_some(), "the pre-acquire must take the one permit");
822 let result = tokio::time::timeout(
823 Duration::from_secs(20),
824 process_entry(visitor, cx, (), permit),
825 )
826 .await;
827 // restore the default (disabled) pool before asserting so a failure can't
828 // strand the tiny limit for a concurrent test.
829 throttle::set_max_open_files(0);
830 let summary = result
831 .map_err(|_| {
832 anyhow::anyhow!(
833 "process_entry hung — leaf permit held across directory recursion (deadlock)"
834 )
835 })?
836 .map_err(|e| e.source)?;
837 assert_eq!(
838 summary,
839 CountSummary {
840 files: 1,
841 dirs: 1,
842 symlinks: 0,
843 },
844 "the directory and its one child file are both counted"
845 );
846 assert_eq!(
847 leaves_seen.load(Ordering::SeqCst),
848 1,
849 "the child file was visited (its permit was acquired after the drop)"
850 );
851 Ok(())
852 }
853 }
854}