Skip to main content

common/
walk.rs

1//! Shared primitives for directory-walking operations (copy, link, rm, chmod, cmp).
2//!
3//! This module collects several small, related helpers used by `walk_driver` and the per-tool
4//! visitors:
5//! - `EntryKind` — classifies a directory entry by file type and exposes the per-type bits
6//!   (dry-run label, skipped-counter increment) so callers don't re-implement the dispatch.
7//! - leaf-permit lifecycle (`PermitKind` / `LeafPermit` / `preacquire_leaf_permit`) — acquires a
8//!   leaf's open-files permit before spawning, co-located with the driver's drop-before-recurse
9//!   invariant.
10//! - congestion↔throttle bridges (`throttle_side` / `throttle_op` / `meta_resource`) — map the
11//!   walk's side/op enums onto the throttle and congestion resource enums.
12//! - metadata-probe wrappers (`next_entry_probed` / `run_metadata_probed`) — wrap the path-based
13//!   `ReadDir`/`metadata` calls with the static ops rate gate (deliberately not congestion-probed
14//!   — see the function docs). These are the iteration primitive for the PATH-based walks (rcmp and
15//!   the `-L`/dereference and remote-source paths); the TOCTOU-hardened copy/link/rm/chmod walks go
16//!   through the fd-based `walk_driver` instead.
17//! - filter helpers (`filter_is_dir` / `should_skip_entry`) — apply include/exclude filtering and
18//!   entry classification during enumeration.
19//! - root-operand parsing (`split_root_operand` / `root_operand_basename` /
20//!   `without_trailing_separators`) — resolve a user-specified source operand (trailing slashes,
21//!   `.`/`..`) into its components.
22
23use crate::filter::{FilterResult, FilterSettings};
24use crate::progress::Progress;
25use anyhow::Context;
26
27/// Classification of a filesystem entry by type.
28///
29/// `Special` covers sockets, FIFOs, block/character devices — anything that
30/// isn't a regular file, directory, or symlink. When a caller has only a best
31/// effort `Option<FileType>` (e.g. `entry.file_type().await.ok()`), an unknown
32/// type is treated as `File` to match historical behavior.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum EntryKind {
35    File,
36    Dir,
37    Symlink,
38    Special,
39}
40
41impl EntryKind {
42    /// Classify from a `Metadata` (root-level entries, where we always have full metadata).
43    #[must_use]
44    pub fn from_metadata(metadata: &std::fs::Metadata) -> Self {
45        if metadata.is_dir() {
46            Self::Dir
47        } else if metadata.is_symlink() {
48            Self::Symlink
49        } else if metadata.is_file() {
50            Self::File
51        } else {
52            Self::Special
53        }
54    }
55    /// Short dry-run label used during directory iteration (`"dir"`, `"symlink"`, `"file"`).
56    /// `Special` maps to `"file"` to match historical behavior — the old bool-triplet
57    /// dispatch in copy/link/rm fell through `is_dir`/`is_symlink` to "file" for any
58    /// other type. The explicit `--skip-specials` path uses its own literal "special"
59    /// string and does not call this helper.
60    #[must_use]
61    pub fn label(self) -> &'static str {
62        match self {
63            Self::Dir => "dir",
64            Self::Symlink => "symlink",
65            Self::File | Self::Special => "file",
66        }
67    }
68    /// Long dry-run label used at the root level (`"directory"` instead of `"dir"`).
69    /// `Special` maps to `"file"` for the same reason as [`Self::label`].
70    #[must_use]
71    pub fn label_long(self) -> &'static str {
72        match self {
73            Self::Dir => "directory",
74            Self::Symlink => "symlink",
75            Self::File | Self::Special => "file",
76        }
77    }
78    /// Increment the skipped counter that matches this entry kind. Special
79    /// files count as `files_skipped` — `specials_skipped` is reserved for
80    /// the explicit `--skip-specials` path, not filter skips.
81    pub fn inc_skipped(self, prog: &Progress) {
82        match self {
83            Self::Dir => prog.directories_skipped.inc(),
84            Self::Symlink => prog.symlinks_skipped.inc(),
85            Self::File | Self::Special => prog.files_skipped.inc(),
86        }
87    }
88}
89
90/// Which backpressure pool a leaf's pre-acquired permit comes from.
91///
92/// The two pools are deliberately distinct (see [`LeafPermit`]) — this enum
93/// selects between them per tool, plus a `None` variant for metadata-only
94/// walks (e.g. rcmp-style traversals) that take no leaf permit at all.
95///
96/// Unifying the *choice* of pool here — alongside [`LeafPermit`] and
97/// [`preacquire_leaf_permit`] — is what lets the traversal driver own the
98/// "drop the leaf permit before recursing into a directory" invariant in a
99/// single place. Hand-coding that drop at every per-tool branch is the root
100/// cause of the hold-and-wait deadlock class this lifecycle eliminates.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum PermitKind {
103    /// File-descriptor backpressure ([`throttle::open_file_permit`]) — for
104    /// tools that hold an open fd across leaf work (copy, link).
105    OpenFile,
106    /// Task-spawn backpressure ([`throttle::pending_meta_permit`]) — for
107    /// recursive metadata-only walks that don't hold an fd (chmod, rm).
108    PendingMeta,
109    /// The tool takes no leaf permit (it doesn't gate at the leaf).
110    None,
111}
112
113/// A pre-acquired leaf permit, type-erased over the two distinct backpressure
114/// pools so a single caller (the traversal driver) can hold either uniformly
115/// and drop it in exactly one place before recursing into a directory.
116///
117/// The two pools must stay distinct: [`throttle::OpenFileGuard`] gates open
118/// file descriptors while [`throttle::PendingMetaGuard`] gates in-flight
119/// metadata-only tasks. They are sized independently so a path that composes
120/// the two operations (e.g. `copy_file → rm` when overwriting a directory
121/// destination) cannot self-deadlock against a saturated open-files pool.
122/// This enum unifies only the *lifecycle*, never the pools themselves.
123///
124/// Neither guard is `Clone`; dropping a `LeafPermit` releases exactly the
125/// underlying permit it wraps. The driver drops it before descending so a
126/// directory entry never holds a leaf permit across its recursive walk —
127/// the single home for the invariant that previously lived at 4/7/1/1
128/// per-tool branch sites and shipped as a deadlock when a single-site tool
129/// forgot it.
130pub enum LeafPermit {
131    /// A permit from the open-files pool.
132    OpenFile(throttle::OpenFileGuard),
133    /// A permit from the pending-metadata pool.
134    PendingMeta(throttle::PendingMetaGuard),
135}
136
137/// Pre-acquire a leaf permit for a child about to be spawned, per the tool's
138/// policy.
139///
140/// Returns `None` when `kind == PermitKind::None` or when `!want(hint)` — the
141/// latter lets a tool opt out based on the cheap `getdents` `d_type` hint. The
142/// key case: when the hint says "directory", the tool passes a `want` that
143/// returns `false`, so no leaf permit is taken. That matters because a hinted
144/// directory must NOT hold a leaf permit across recursion (the hold-and-wait
145/// deadlock). Otherwise this acquires from the pool selected by `kind` and
146/// wraps it in [`LeafPermit`].
147///
148/// `want` receives the raw hint (`None` for `DT_UNKNOWN`); the authoritative
149/// type is only resolved later by the per-entry worker. A hint of `None`
150/// therefore takes a permit when the tool's `want` admits it (matching the
151/// historical "treat unknown as a leaf" behavior), and the worker re-classifies
152/// and drops it if the entry turns out to be a directory.
153pub async fn preacquire_leaf_permit(
154    kind: PermitKind,
155    hint: Option<EntryKind>,
156    want: impl Fn(Option<EntryKind>) -> bool,
157) -> Option<LeafPermit> {
158    if kind == PermitKind::None || !want(hint) {
159        return None;
160    }
161    match kind {
162        PermitKind::OpenFile => Some(LeafPermit::OpenFile(throttle::open_file_permit().await)),
163        PermitKind::PendingMeta => Some(LeafPermit::PendingMeta(
164            throttle::pending_meta_permit().await,
165        )),
166        // unreachable: the early return above already handled `None`.
167        PermitKind::None => None,
168    }
169}
170
171/// Resolve the `throttle::Side` from the matching `congestion::Side`.
172///
173/// The two crates carry independent enum definitions to keep `throttle`
174/// free of any congestion dependency; this is the canonical bridge
175/// (paired with [`throttle_op`]) reused everywhere a congestion-side
176/// signal needs to address a throttle resource.
177pub(crate) fn throttle_side(side: congestion::Side) -> throttle::Side {
178    match side {
179        congestion::Side::Source => throttle::Side::Source,
180        congestion::Side::Destination => throttle::Side::Destination,
181    }
182}
183
184/// Resolve the `throttle::MetadataOp` from the matching `congestion::MetadataOp`.
185pub(crate) fn throttle_op(op: congestion::MetadataOp) -> throttle::MetadataOp {
186    match op {
187        congestion::MetadataOp::Stat => throttle::MetadataOp::Stat,
188        congestion::MetadataOp::ReadLink => throttle::MetadataOp::ReadLink,
189        congestion::MetadataOp::MkDir => throttle::MetadataOp::MkDir,
190        congestion::MetadataOp::RmDir => throttle::MetadataOp::RmDir,
191        congestion::MetadataOp::Unlink => throttle::MetadataOp::Unlink,
192        congestion::MetadataOp::HardLink => throttle::MetadataOp::HardLink,
193        congestion::MetadataOp::Symlink => throttle::MetadataOp::Symlink,
194        congestion::MetadataOp::Chmod => throttle::MetadataOp::Chmod,
195        congestion::MetadataOp::OpenCreate => throttle::MetadataOp::OpenCreate,
196    }
197}
198
199/// Resolve the [`throttle::Resource`] for a single per-file metadata
200/// syscall on the given side.
201pub(crate) fn meta_resource(
202    side: congestion::Side,
203    op: congestion::MetadataOp,
204) -> throttle::Resource {
205    throttle::Resource::meta(throttle_side(side), throttle_op(op))
206}
207
208/// Pull the next directory entry, gated only by the static ops rate
209/// gate.
210///
211/// Walks are deliberately not probed: `tokio::fs::ReadDir::next_entry`
212/// returns buffered entries from a prior `getdents` batch without
213/// entering the kernel, so most "walk probes" don't measure filesystem
214/// service time at all. The resulting bimodal latency distribution
215/// (cache hit vs. real `getdents`) collapses any baseline a controller
216/// could derive from it. The fix is to probe only the per-file
217/// metadata syscalls that follow the walk, where each sample reflects
218/// real filesystem work.
219///
220/// The prologue is therefore just:
221///
222/// 1. Await the static ops rate gate.
223/// 2. Call `next_entry()` and, on success, classify via `file_type()`.
224///
225/// `side` is currently unused at runtime but kept on the signature so
226/// callers stay self-documenting and future per-side gating can be
227/// reintroduced without touching every call site.
228///
229/// The error is left as `anyhow::Error` so each caller can wrap it in the
230/// site-specific error type (`copy::Error`, `link::Error`, `rm::Error`)
231/// without this helper needing to be generic over the summary payload.
232pub async fn next_entry_probed<F>(
233    entries: &mut tokio::fs::ReadDir,
234    _side: congestion::Side,
235    context: F,
236) -> anyhow::Result<Option<(tokio::fs::DirEntry, Option<std::fs::FileType>)>>
237where
238    F: FnOnce() -> String,
239{
240    throttle::get_ops_token().await;
241    let maybe_entry = entries.next_entry().await.with_context(context)?;
242    let Some(entry) = maybe_entry else {
243        return Ok(None);
244    };
245    let entry_file_type = entry.file_type().await.ok();
246    Ok(Some((entry, entry_file_type)))
247}
248
249/// Bracket a single metadata-producing future with the full per-op
250/// gating prologue: the static ops rate gate, the cwnd permit for the
251/// matching `(side, op_kind)` resource, and a congestion probe. The
252/// probe completes successfully when `fut` returns `Ok`, and is
253/// discarded on error so error paths don't skew the controller's
254/// latency baseline.
255///
256/// `op_kind` selects which per-syscall controller this call is
257/// reported to and gated by — `Stat`, `MkDir`, `Unlink`, etc. Pick the
258/// variant that matches the underlying syscall (`metadata` /
259/// `symlink_metadata` / `File::open(read)` / `canonicalize` all map to
260/// `Stat`; `create_dir` to `MkDir`; `remove_file` to `Unlink`; and so
261/// on — see [`congestion::MetadataOp`] for the full mapping).
262///
263/// `--ops-throttle` is the shared metadata rate gate, so this helper
264/// acquires it on every call — same as [`next_entry_probed`]. Callers
265/// that already rate-gate upstream (such as filegen, which gates at
266/// per-task spawn time so we don't fan out an unbounded task queue
267/// before any token is consumed) must use
268/// [`run_metadata_probed_no_rate`] instead to avoid double-counting.
269pub async fn run_metadata_probed<F, T, E>(
270    side: congestion::Side,
271    op_kind: congestion::MetadataOp,
272    fut: F,
273) -> Result<T, E>
274where
275    F: std::future::Future<Output = Result<T, E>>,
276{
277    throttle::get_ops_token().await;
278    run_metadata_probed_no_rate(side, op_kind, fut).await
279}
280
281/// Variant of [`run_metadata_probed`] that skips the static ops rate
282/// gate — for callers that already rate-limit at a coarser granularity
283/// upstream and would otherwise consume two tokens per metadata op.
284///
285/// Concretely: `filegen` gates the rate at task-spawn time so the
286/// number of in-flight `write_file` futures stays bounded by the rate.
287/// The `OpenOptions::open(O_CREAT)` inside the spawned task is the only
288/// metadata syscall in that path; rate-gating it again would halve the
289/// effective rate.
290pub async fn run_metadata_probed_no_rate<F, T, E>(
291    side: congestion::Side,
292    op_kind: congestion::MetadataOp,
293    fut: F,
294) -> Result<T, E>
295where
296    F: std::future::Future<Output = Result<T, E>>,
297{
298    let ops_permit = throttle::ops_in_flight_permit(meta_resource(side, op_kind)).await;
299    let probe = congestion::Probe::start_metadata(side, op_kind);
300    let result = fut.await;
301    match &result {
302        Ok(_) => probe.complete_ok(0),
303        Err(_) => probe.discard(),
304    }
305    drop(ops_permit);
306    result
307}
308
309/// Determine the `is_dir` value to feed an include/exclude FILTER decision for a
310/// directory entry, using the authoritative `fstat` type when the cheap
311/// `getdents` `d_type` hint is unavailable.
312///
313/// `read_entries` returns each entry's `d_type` as a best-effort hint; on
314/// filesystems that don't populate it (NFS, some FUSE mounts) the hint is `None`
315/// (`DT_UNKNOWN`). Treating `None` as "not a directory" for an `is_dir`-dependent
316/// filter (e.g. `--include '/sub/**'`) would wrongly EXCLUDE a real directory and
317/// omit its entire subtree. To match the old path-based walk (which fell back to
318/// an `lstat` via `DirEntry::file_type()`), this resolves the type
319/// AUTHORITATIVELY via `dir.child(name)`'s `fstat` — but ONLY when the hint is
320/// `None` AND the type is actually needed: either a filter is active, or the
321/// caller passes `force_authoritative` because its own control flow depends on
322/// the result (e.g. a dry-run recurse-vs-leaf decision). When the hint is
323/// reliable, or the type is unneeded, the cheap hint is used directly (no extra
324/// syscall), preserving the optimization.
325///
326/// `child(name)` uses `O_PATH|O_NOFOLLOW`, so this never follows a symlink (a
327/// symlink entry classifies as `Symlink`, not `Dir`) and never blocks: the
328/// hardening of the walk below the named root is unaffected. On a `child` error
329/// (e.g. the entry vanished mid-walk) the hint-derived value is used as a
330/// fallback — the entry's own per-entry worker will then surface the error
331/// authoritatively.
332pub async fn filter_is_dir(
333    filter: Option<&FilterSettings>,
334    dir: &crate::safedir::Dir,
335    name: &std::ffi::OsStr,
336    hint: Option<EntryKind>,
337    force_authoritative: bool,
338) -> bool {
339    match hint {
340        Some(kind) => kind == EntryKind::Dir,
341        // DT_UNKNOWN: only pay for the authoritative fstat when the type is actually
342        // needed — a filter needs it, or the caller's control flow depends on it
343        // (`force_authoritative`, e.g. a dry-run recurse-vs-leaf decision). Otherwise
344        // the value is unused, so default cheaply.
345        None if filter.is_some() || force_authoritative => match dir.child(name).await {
346            Ok(handle) => handle.kind() == EntryKind::Dir,
347            // entry changed/vanished: fall back to the historical non-dir default;
348            // the per-entry worker re-classifies and reports any real error.
349            Err(_) => false,
350        },
351        None => false,
352    }
353}
354
355/// Decide whether an entry should be skipped by the filter, returning the
356/// `FilterResult` that caused the skip. Returns `None` if there is no filter
357/// or the entry is included.
358#[must_use]
359pub fn should_skip_entry(
360    filter: &Option<FilterSettings>,
361    relative_path: &std::path::Path,
362    is_dir: bool,
363) -> Option<FilterResult> {
364    should_skip_entry_ref(filter.as_ref(), relative_path, is_dir)
365}
366
367/// [`should_skip_entry`] taking the filter by `Option<&_>` rather than
368/// `&Option<_>`, so callers that already hold an `Option<&FilterSettings>` (the
369/// traversal driver) don't have to clone it. Identical semantics otherwise.
370#[must_use]
371pub fn should_skip_entry_ref(
372    filter: Option<&FilterSettings>,
373    relative_path: &std::path::Path,
374    is_dir: bool,
375) -> Option<FilterResult> {
376    if let Some(f) = filter {
377        let result = f.should_include(relative_path, is_dir);
378        match result {
379            FilterResult::Included => None,
380            _ => Some(result),
381        }
382    } else {
383        None
384    }
385}
386
387/// Path of `entry` relative to `root` (typically `source_root` or `dest_root` at the call
388/// site), with the `unwrap_or(entry)` defensive fallback rcp uses when `entry` isn't
389/// actually under `root`. Naming the pattern lets call sites read "the entry's path inside
390/// the tree" instead of `entry.strip_prefix(root).unwrap_or(entry)` — and removes a class
391/// of "did I get strip_prefix the right way round?" regressions.
392///
393/// Use with `filter_base.join(...)` for a logical filter path inside a delegated subtree
394/// (`copy_with_filter_base`'s non-empty `filter_base` case), or on its own when filter_base
395/// is empty.
396#[must_use]
397pub fn relative_to_root<'a>(
398    entry: &'a std::path::Path,
399    root: &std::path::Path,
400) -> &'a std::path::Path {
401    entry.strip_prefix(root).unwrap_or(entry)
402}
403
404/// Strip trailing path separators from a root operand. A trailing slash forces the OS to resolve
405/// the final component as a directory, which would dereference a symlink root like `link/`
406/// (following it to its target). Stripping makes `link/` behave like `link` (the symlink itself),
407/// which is then classified/operated on `O_NOFOLLOW` relative to its parent fd.
408#[must_use]
409pub fn without_trailing_separators(path: &std::path::Path) -> std::path::PathBuf {
410    use std::os::unix::ffi::OsStrExt;
411    let bytes = path.as_os_str().as_bytes();
412    let mut end = bytes.len();
413    while end > 1 && bytes[end - 1] == b'/' {
414        end -= 1;
415    }
416    std::path::PathBuf::from(std::ffi::OsStr::from_bytes(&bytes[..end]))
417}
418
419/// A root operand decomposed for the fd-relative walk entry point.
420pub struct RootOperand {
421    /// The operand's parent directory — opened TRUSTED (follows symlinks) via
422    /// [`crate::safedir::Dir::open_parent_dir`].
423    pub parent: std::path::PathBuf,
424    /// The operand's final component — classified `O_NOFOLLOW` via `child(name)` below the parent.
425    pub name: std::ffi::OsString,
426    /// The operand path for diagnostics / `real_path` reconstruction: the operand as typed (with
427    /// trailing slashes stripped) for a normal operand, or the canonicalized path for a `.`/`..`
428    /// operand that had to be resolved.
429    pub display: std::path::PathBuf,
430}
431
432/// Decompose a root operand into the `(parent, final_component)` the fd-relative walk needs (see
433/// [`RootOperand`]): the parent prefix is opened with [`crate::safedir::Dir::open_parent_dir`]
434/// (trusted, follows symlinks) and the final component is then classified `O_NOFOLLOW` via
435/// `child(name)` below it.
436///
437/// Most operands split directly via `parent()` / `file_name()` (an empty parent meaning the current
438/// directory). An operand whose final component is `.` or `..` (e.g. `.`, `tree/..`) has no
439/// `file_name()`, so it is first canonicalized to a concrete path — `.` becomes the current
440/// directory, `tree/..` its grandparent — restoring the behavior of the pre-fd-walk path code,
441/// where `rrm .` / `rchm -R … .` operated on the current tree. (Canonicalizing only this branch
442/// never touches a normal operand, so a symlinked final component on the normal path is still
443/// opened `O_NOFOLLOW`; `.`/`..` are themselves never symlinks.) The filesystem root `/` has no
444/// parent and cannot be expressed as parent + component, so it is rejected with a clear error.
445pub async fn split_root_operand(path: &std::path::Path) -> anyhow::Result<RootOperand> {
446    let stripped = without_trailing_separators(path);
447    if let Some(name) = stripped.file_name() {
448        let parent = match stripped.parent() {
449            Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
450            // empty parent (a single-component relative path) means the current directory.
451            _ => std::path::PathBuf::from("."),
452        };
453        let name = name.to_owned();
454        return Ok(RootOperand {
455            parent,
456            name,
457            display: stripped,
458        });
459    }
460    // final component is `.`/`..` (or the operand is `/`): canonicalize to a concrete path so it can
461    // be split into parent + component.
462    let canonical = tokio::fs::canonicalize(&stripped)
463        .await
464        .with_context(|| format!("cannot resolve operand {stripped:?}"))?;
465    let name = canonical.file_name().map(std::ffi::OsStr::to_owned);
466    let parent = canonical.parent().map(std::path::Path::to_path_buf);
467    let (Some(parent), Some(name)) = (parent, name) else {
468        anyhow::bail!("cannot operate on the filesystem root {canonical:?}");
469    };
470    Ok(RootOperand {
471        parent,
472        name,
473        display: canonical,
474    })
475}
476
477/// The basename a root operand contributes to the trailing-slash "copy INTO directory" rule: the
478/// name of the entry the walk will create for `path`, so a CLI forming `dst/<name>` matches it
479/// exactly (`rcp . out/` / `rlink . out/` -> `out/<cwd-name>`; `… tree/.. out/` ->
480/// `out/<parent-name>`).
481///
482/// This is the synchronous twin of [`split_root_operand`]'s `name`, for the CLI path-resolution
483/// layer (which is not async): a normal operand uses [`std::path::Path::file_name`]; an operand
484/// with no `file_name()` (`.`/`..`/`dir/..`) is canonicalized first, exactly as
485/// [`split_root_operand`] does. The filesystem root has no basename and is rejected. The
486/// `root_operand_basename_matches_split_root_operand` test keeps the two in lock-step.
487pub fn root_operand_basename(path: &std::path::Path) -> anyhow::Result<std::ffi::OsString> {
488    let stripped = without_trailing_separators(path);
489    if let Some(name) = stripped.file_name() {
490        return Ok(name.to_owned());
491    }
492    // final component is `.`/`..` (or the operand is `/`): canonicalize to a concrete path so it
493    // has a real basename — the same resolution `split_root_operand` performs for the walk.
494    let canonical = std::fs::canonicalize(&stripped)
495        .with_context(|| format!("cannot resolve operand {stripped:?}"))?;
496    canonical
497        .file_name()
498        .map(std::ffi::OsStr::to_owned)
499        .ok_or_else(|| anyhow::anyhow!("cannot operate on the filesystem root {canonical:?}"))
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::safedir::Dir;
506    use crate::testutils;
507    use std::ffi::OsStr;
508
509    fn include_filter(pattern: &str) -> Option<FilterSettings> {
510        let mut f = FilterSettings::new();
511        f.add_include(pattern).unwrap();
512        Some(f)
513    }
514
515    // FIX B (PR #247 review): when the getdents `d_type` hint is unavailable (DT_UNKNOWN -> None)
516    // AND a filter is active, the filter `is_dir` decision must come from the AUTHORITATIVE fstat,
517    // not default to non-dir. Otherwise a real directory reported as DT_UNKNOWN would be wrongly
518    // excluded by an is_dir-dependent include filter, omitting its whole subtree. We can't force
519    // DT_UNKNOWN on a normal local fs, so we drive `filter_is_dir` with `hint = None` directly —
520    // exactly the value `read_entries` would yield on NFS/FUSE — to exercise the authoritative path.
521    #[tokio::test]
522    async fn filter_is_dir_authoritatively_classifies_dt_unknown_directory() -> anyhow::Result<()> {
523        let tmp = testutils::setup_test_dir().await?;
524        // fixture: tmp/foo holds `bar` (a real directory) and `0.txt` (a real file).
525        let dir = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
526        let filter = include_filter("/bar/**");
527        // DT_UNKNOWN + active filter on a real DIRECTORY -> must resolve to `true` via fstat, so an
528        // include filter does NOT omit its subtree (the regression this fix closes).
529        assert!(
530            filter_is_dir(filter.as_ref(), &dir, OsStr::new("bar"), None, false).await,
531            "a real directory reported as DT_UNKNOWN must classify as a directory for the filter"
532        );
533        // DT_UNKNOWN + active filter on a real FILE -> resolves to `false` authoritatively.
534        assert!(
535            !filter_is_dir(filter.as_ref(), &dir, OsStr::new("0.txt"), None, false).await,
536            "a real file reported as DT_UNKNOWN must classify as a non-directory"
537        );
538        Ok(())
539    }
540
541    // FIX B: a reliable hint is used directly (no fstat), and with no filter active the value is
542    // the cheap default — the optimization is preserved (we never pay an fstat we don't need).
543    #[tokio::test]
544    async fn filter_is_dir_uses_hint_when_available_and_skips_when_no_filter() -> anyhow::Result<()>
545    {
546        let tmp = testutils::setup_test_dir().await?;
547        let dir = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
548        let filter = include_filter("/bar/**");
549        // reliable Dir hint -> true regardless of fstat.
550        assert!(
551            filter_is_dir(
552                filter.as_ref(),
553                &dir,
554                OsStr::new("bar"),
555                Some(EntryKind::Dir),
556                false
557            )
558            .await
559        );
560        // reliable File hint -> false.
561        assert!(
562            !filter_is_dir(
563                filter.as_ref(),
564                &dir,
565                OsStr::new("0.txt"),
566                Some(EntryKind::File),
567                false
568            )
569            .await
570        );
571        // DT_UNKNOWN, NO filter, and not forced -> cheap non-dir default (no authoritative fstat
572        // needed); the `name` is never resolved, so it need not even exist.
573        assert!(!filter_is_dir(None, &dir, OsStr::new("does_not_exist"), None, false).await);
574        Ok(())
575    }
576
577    // force_authoritative (e.g. rlink --dry-run, which uses is_dir for its recurse-vs-leaf
578    // branch): a DT_UNKNOWN hint with NO filter must still classify AUTHORITATIVELY when the
579    // caller's control flow depends on the result, so a dry-run on NFS/FUSE doesn't preview a
580    // real directory as a leaf and skip its subtree.
581    #[tokio::test]
582    async fn filter_is_dir_forces_authoritative_classification_when_requested() -> anyhow::Result<()>
583    {
584        let tmp = testutils::setup_test_dir().await?;
585        let dir = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
586        // DT_UNKNOWN + NO filter, but force_authoritative -> a real directory must classify as dir.
587        assert!(
588            filter_is_dir(None, &dir, OsStr::new("bar"), None, true).await,
589            "force_authoritative must fstat a DT_UNKNOWN directory even with no filter"
590        );
591        // ...and a real file as non-dir.
592        assert!(
593            !filter_is_dir(None, &dir, OsStr::new("0.txt"), None, true).await,
594            "force_authoritative must fstat a DT_UNKNOWN file even with no filter"
595        );
596        Ok(())
597    }
598
599    // The leaf-permit lifecycle: `PermitKind::None` and a rejecting `want` both opt
600    // out (the basis for "a hinted directory takes no permit, so it can't deadlock
601    // by holding one across recursion"); a matching `want` acquires from the
602    // requested pool. The pools are process-global; configure a small cap so the
603    // OpenFile case exercises a real acquire rather than the disabled-pool no-op.
604    #[tokio::test]
605    async fn preacquire_leaf_permit_respects_kind_and_want() {
606        throttle::set_max_open_files(4);
607        // `None` kind never takes a permit, regardless of hint/want.
608        assert!(
609            preacquire_leaf_permit(PermitKind::None, Some(EntryKind::File), |_| true)
610                .await
611                .is_none()
612        );
613        // matching want on the OpenFile pool yields an OpenFile permit.
614        let permit = preacquire_leaf_permit(PermitKind::OpenFile, Some(EntryKind::File), |h| {
615            h == Some(EntryKind::File)
616        })
617        .await;
618        assert!(matches!(permit, Some(LeafPermit::OpenFile(_))));
619        drop(permit);
620        // a rejecting want (e.g. the hint says "directory") opts out even though the
621        // pool is configured — the hinted-dir-takes-no-permit case.
622        assert!(
623            preacquire_leaf_permit(PermitKind::OpenFile, Some(EntryKind::Dir), |_| false)
624                .await
625                .is_none()
626        );
627    }
628
629    // split_root_operand: a normal operand splits via parent()/file_name() (single component ->
630    // parent "."), a trailing slash is stripped, a trailing `/.` names the directory itself, and —
631    // the regression fix — a bare `.` or an operand ending in `..` (no file_name()) is canonicalized
632    // so it still names a directory instead of being rejected. The filesystem root `/` is rejected.
633    #[tokio::test]
634    async fn split_root_operand_handles_dot_and_normal_operands() -> anyhow::Result<()> {
635        use std::ffi::OsStr;
636        use std::path::Path;
637        // normal nested operand: split verbatim.
638        let op = split_root_operand(Path::new("a/b")).await?;
639        assert_eq!(op.parent, Path::new("a"));
640        assert_eq!(op.name, OsStr::new("b"));
641        assert_eq!(op.display, Path::new("a/b"));
642        // single component: parent defaults to the current directory.
643        let op = split_root_operand(Path::new("foo")).await?;
644        assert_eq!(op.parent, Path::new("."));
645        assert_eq!(op.name, OsStr::new("foo"));
646        // trailing slash stripped (so a symlink root is classified O_NOFOLLOW, not dereferenced).
647        let op = split_root_operand(Path::new("foo/")).await?;
648        assert_eq!(op.name, OsStr::new("foo"));
649        // trailing `/.` names the directory itself: `file_name()` normalizes the `.` away, so it
650        // splits verbatim (no canonicalize) — `dir/.` -> parent ".", name "dir"; `a/b/.` -> "a","b".
651        let op = split_root_operand(Path::new("dir/.")).await?;
652        assert_eq!(op.parent, Path::new("."));
653        assert_eq!(op.name, OsStr::new("dir"));
654        let op = split_root_operand(Path::new("a/b/.")).await?;
655        assert_eq!(op.parent, Path::new("a"));
656        assert_eq!(op.name, OsStr::new("b"));
657        // bare `.` (no file_name): canonicalized to the current directory.
658        let cwd = tokio::fs::canonicalize(".").await?;
659        let op = split_root_operand(Path::new(".")).await?;
660        assert_eq!(op.parent, cwd.parent().unwrap());
661        assert_eq!(op.name, cwd.file_name().unwrap());
662        assert_eq!(op.display, cwd);
663        // operand ending in `..` (no file_name): canonicalized so it still names a directory.
664        // tmp/sub/.. resolves to tmp — the `rrm .` / `rchm -R … .` regression this fix closes.
665        let tmp = testutils::create_temp_dir().await?;
666        let sub = tmp.join("sub");
667        tokio::fs::create_dir(&sub).await?;
668        let canonical_tmp = tokio::fs::canonicalize(&tmp).await?;
669        let op = split_root_operand(&sub.join("..")).await?;
670        assert_eq!(op.parent, canonical_tmp.parent().unwrap());
671        assert_eq!(op.name, canonical_tmp.file_name().unwrap());
672        assert_eq!(op.display, canonical_tmp);
673        // the filesystem root has no parent and is rejected with a clear error.
674        assert!(split_root_operand(Path::new("/")).await.is_err());
675        Ok(())
676    }
677
678    // `root_operand_basename` (the sync CLI helper) must return exactly `split_root_operand`'s
679    // `name` for every operand shape, so a `dst/<name>` the CLI builds always matches the entry the
680    // walk creates. This lock-step is what makes the trailing-slash result deterministic.
681    #[tokio::test]
682    async fn root_operand_basename_matches_split_root_operand() -> anyhow::Result<()> {
683        use std::path::Path;
684        // operands with a real `file_name()` (no canonicalize): both take the lexical basename.
685        for p in ["a/b", "foo", "foo/", "dir/.", "a/b/."] {
686            assert_eq!(
687                root_operand_basename(Path::new(p))?,
688                split_root_operand(Path::new(p)).await?.name,
689                "operand {p:?}"
690            );
691        }
692        // `.`/`..`/`dir/..` (no `file_name()`): both canonicalize to a real path first. Use real
693        // dirs so canonicalize succeeds; assert only that the two helpers agree (the concrete
694        // basename depends on the cwd / temp path).
695        let tmp = testutils::create_temp_dir().await?;
696        let sub = tmp.join("sub");
697        tokio::fs::create_dir(&sub).await?;
698        let dot_operands = [
699            std::path::PathBuf::from("."),
700            std::path::PathBuf::from(".."),
701            sub.join(".."),
702            sub.join("../.."),
703        ];
704        for p in &dot_operands {
705            assert_eq!(
706                root_operand_basename(p)?,
707                split_root_operand(p).await?.name,
708                "operand {p:?}"
709            );
710        }
711        // the filesystem root has no basename: both reject it.
712        assert!(root_operand_basename(Path::new("/")).is_err());
713        assert!(split_root_operand(Path::new("/")).await.is_err());
714        Ok(())
715    }
716}