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}