Skip to main content

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}