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//! - entry-admission lifecycle (`PermitKind` / `LeafPermit` / `EntryAdmission` / `AdmittedEntry` /
8//!   `AdmittedLeaf` / `classify_entry`) — carries enumeration hints into classification while
9//!   keeping classification handles structurally inside their admission lifetime.
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 walks that take no
94/// 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    /// copy/link leaf operations.
105    OpenFile,
106    /// Separate metadata-task backpressure ([`throttle::pending_meta_permit`]) —
107    /// for recursive chmod/rm walks. Keeping this pool distinct lets rm run
108    /// inside a copy overwrite while copy retains its open-file admission.
109    PendingMeta,
110    /// The tool takes no leaf permit (it doesn't gate at the leaf).
111    None,
112}
113
114/// A pre-acquired leaf permit, type-erased over the two distinct backpressure
115/// pools so a single caller (the traversal driver) can hold either uniformly
116/// and drop it in exactly one place before recursing into a directory.
117///
118/// The two pools must stay distinct: [`throttle::OpenFileGuard`] gates fd-bearing
119/// copy/link leaf operations while [`throttle::PendingMetaGuard`] gates in-flight
120/// metadata traversal tasks, some of which retain an `O_PATH` classification
121/// handle. They are separate semaphores, each configured to the same `N`, so a path that composes
122/// the two operations (for example, `copy_file_fd → remove_existing → rm_child` when overwriting a
123/// directory destination) cannot self-deadlock against a saturated OpenFile pool.
124/// This enum unifies only the *lifecycle*, never the pools themselves.
125///
126/// Neither guard is `Clone`; the only shared ownership they expose is an explicit
127/// [`throttle::FdAdmission`] weak reference for non-cancellable blocking work. In the ordinary
128/// path, dropping a `LeafPermit` releases exactly the underlying permit it wraps. The driver drops
129/// it before descending so a directory entry never holds a leaf permit across its recursive walk
130/// — the single home for the invariant that previously lived at 4/7/1/1 per-tool branch sites and
131/// shipped as a deadlock when a single-site tool forgot it.
132pub enum LeafPermit {
133    /// A permit from the open-files pool.
134    OpenFile(throttle::OpenFileGuard),
135    /// A permit from the pending-metadata pool.
136    PendingMeta(throttle::PendingMetaGuard),
137}
138
139/// Admission state carried from directory scheduling into entry classification.
140///
141/// A possible leaf either arrives with a held permit or acquires before its first authoritative
142/// classification. A positive `getdents` directory hint is the sole exception: it may classify
143/// without leaf admission, but a stale hint that resolves to a leaf must close that first handle,
144/// acquire, and classify again. Roots and delegated entries have no enumeration hint and acquire
145/// before classification.
146pub enum EntryAdmission {
147    /// The possible leaf already owns admission from its scheduling loop.
148    Held(LeafPermit),
149    /// A positive `getdents` directory hint permits one unadmitted classification.
150    HintedDirectory,
151    /// A root or delegated entry must acquire before classification.
152    RootOrDelegated,
153}
154
155impl EntryAdmission {
156    /// Construct the unacquired state described by a directory-entry type hint.
157    #[must_use]
158    pub fn from_hint(hint: Option<EntryKind>) -> Self {
159        if hint == Some(EntryKind::Dir) {
160            Self::HintedDirectory
161        } else {
162            Self::RootOrDelegated
163        }
164    }
165
166    /// Require ordinary admission when a decision cannot rely on the directory-hint exception.
167    ///
168    /// A dual-tree operation cannot use one entry's hint to exempt classification of its separate
169    /// counterpart. A destructive filter cannot trust the hinted type before authorizing an
170    /// action. Both discard the exception before classification; already-held admission remains.
171    #[must_use]
172    pub(crate) fn require_admission(self) -> Self {
173        match self {
174            Self::HintedDirectory => Self::RootOrDelegated,
175            Self::Held(_) | Self::RootOrDelegated => self,
176        }
177    }
178
179    /// Borrow the held admission lease, when this entry has acquired one.
180    #[must_use]
181    pub(crate) fn admission(&self) -> Option<throttle::FdAdmission> {
182        match self {
183            Self::Held(permit) => Some(permit.admission()),
184            Self::HintedDirectory | Self::RootOrDelegated => None,
185        }
186    }
187
188    /// Consume the state after classification and recover its leaf permit.
189    #[must_use]
190    pub(crate) fn into_permit(self) -> Option<LeafPermit> {
191        match self {
192            Self::Held(permit) => Some(permit),
193            Self::HintedDirectory | Self::RootOrDelegated => None,
194        }
195    }
196}
197
198impl From<Option<LeafPermit>> for EntryAdmission {
199    fn from(permit: Option<LeafPermit>) -> Self {
200        match permit {
201            Some(permit) => Self::Held(permit),
202            None => Self::RootOrDelegated,
203        }
204    }
205}
206
207impl LeafPermit {
208    /// Obtain a non-owning reference to this entry's admission for blocking work.
209    #[must_use]
210    pub(crate) fn admission(&self) -> throttle::FdAdmission {
211        match self {
212            Self::OpenFile(guard) => guard.admission(),
213            Self::PendingMeta(guard) => guard.admission(),
214        }
215    }
216
217    /// Consume an open-file admission wrapper and return its concrete guard.
218    ///
219    /// `rlink` schedules with the generic wrapper, then transfers the concrete guard into
220    /// `copy_child`; copy's `--dereference` path similarly transfers it into the target root walk.
221    /// A pending-metadata variant here is an internal policy error because neither path draws from
222    /// that pool.
223    pub(crate) fn into_open_file(self) -> throttle::OpenFileGuard {
224        match self {
225            Self::OpenFile(guard) => guard,
226            Self::PendingMeta(_) => {
227                unreachable!("pending-metadata permit used for open-file work")
228            }
229        }
230    }
231}
232
233/// A checked, authoritatively classified non-directory handle.
234#[derive(Debug)]
235struct NonDirectoryHandle(crate::safedir::Handle);
236
237impl TryFrom<crate::safedir::Handle> for NonDirectoryHandle {
238    type Error = crate::safedir::Handle;
239
240    fn try_from(handle: crate::safedir::Handle) -> Result<Self, Self::Error> {
241        if handle.kind() == EntryKind::Dir {
242            Err(handle)
243        } else {
244            Ok(Self(handle))
245        }
246    }
247}
248
249fn take_permit_after_closing_handle<H, P>(
250    handle: &mut Option<H>,
251    permit: &mut Option<P>,
252) -> Option<P> {
253    drop(handle.take());
254    permit.take()
255}
256
257/// One authoritatively classified entry and the admission held while deciding its action.
258///
259/// Destructive filters use this type to transfer the exact classification handle that informed
260/// the filter decision into the shared walk driver. Private handle-then-permit ownership plus the
261/// explicit [`Drop`] implementation close the handle before returning admission on every skipped,
262/// failed, or cancelled path. Only the driver's directory dispatch can deliberately release the
263/// provisional leaf permit while retaining a directory handle for unbudgeted recursion.
264pub(crate) struct AdmittedEntry {
265    handle: Option<crate::safedir::Handle>,
266    permit: Option<LeafPermit>,
267}
268
269impl AdmittedEntry {
270    pub(crate) fn new(handle: crate::safedir::Handle, permit: Option<LeafPermit>) -> Self {
271        Self {
272            handle: Some(handle),
273            permit,
274        }
275    }
276
277    /// Borrow the authoritative classification handle.
278    #[must_use]
279    pub(crate) fn handle(&self) -> &crate::safedir::Handle {
280        self.handle
281            .as_ref()
282            .expect("an admitted entry always owns its handle")
283    }
284
285    /// Return the authoritative entry kind.
286    #[must_use]
287    pub(crate) fn kind(&self) -> EntryKind {
288        self.handle().kind()
289    }
290
291    /// Borrow this entry's weak admission reference, when its visitor uses a pool.
292    #[must_use]
293    pub(crate) fn admission(&self) -> Option<throttle::FdAdmission> {
294        self.permit.as_ref().map(LeafPermit::admission)
295    }
296
297    /// Convert the classified entry into the driver's leaf-or-directory dispatch.
298    ///
299    /// The directory outcome is the sole explicit drop-before-recursion transition: it returns the
300    /// authoritative handle after releasing provisional leaf admission. The leaf outcome preserves
301    /// handle-before-permit ownership in [`AdmittedLeaf`].
302    #[allow(clippy::result_large_err)]
303    pub(crate) fn into_leaf(self) -> Result<AdmittedLeaf, crate::safedir::Handle> {
304        let mut this = self;
305        let handle = this
306            .handle
307            .take()
308            .expect("an admitted entry always owns its handle");
309        match NonDirectoryHandle::try_from(handle) {
310            Ok(handle) => Ok(AdmittedLeaf::new(handle, this.permit.take())),
311            Err(handle) => {
312                drop(this.permit.take());
313                Err(handle)
314            }
315        }
316    }
317}
318
319impl Drop for AdmittedEntry {
320    fn drop(&mut self) {
321        drop(take_permit_after_closing_handle(
322            &mut self.handle,
323            &mut self.permit,
324        ));
325    }
326}
327
328/// An authoritatively classified non-directory and its leaf admission.
329///
330/// The private handle-then-permit fields document the lifetime invariant, while the explicit drop
331/// implementation enforces it in one testable place on every destructing exit: ordinary return,
332/// error, panic unwind, and cancellation. Directory dispatch never constructs this type.
333pub struct AdmittedLeaf {
334    handle: Option<NonDirectoryHandle>,
335    permit: Option<LeafPermit>,
336}
337
338impl AdmittedLeaf {
339    fn new(handle: NonDirectoryHandle, permit: Option<LeafPermit>) -> Self {
340        Self {
341            handle: Some(handle),
342            permit,
343        }
344    }
345
346    /// Check and bundle an authoritative non-directory classification with its admission.
347    ///
348    /// A directory returns its original handle and permit so callers with a multi-entry decision
349    /// can close every related handle before releasing admission. The shared single-entry driver
350    /// instead consumes [`AdmittedEntry`], which centralizes its drop-before-recursion transition.
351    #[allow(clippy::result_large_err)]
352    pub(crate) fn try_new(
353        handle: crate::safedir::Handle,
354        permit: Option<LeafPermit>,
355    ) -> Result<Self, (crate::safedir::Handle, Option<LeafPermit>)> {
356        match NonDirectoryHandle::try_from(handle) {
357            Ok(handle) => Ok(Self::new(handle, permit)),
358            Err(handle) => Err((handle, permit)),
359        }
360    }
361
362    /// Borrow the authoritative classification handle.
363    #[must_use]
364    pub fn handle(&self) -> &crate::safedir::Handle {
365        &self
366            .handle
367            .as_ref()
368            .expect("an admitted leaf always owns its handle")
369            .0
370    }
371
372    /// Return the authoritative entry kind.
373    #[must_use]
374    pub fn kind(&self) -> EntryKind {
375        self.handle().kind()
376    }
377
378    /// Borrow this leaf's weak admission reference, when its visitor uses a pool.
379    #[must_use]
380    pub fn admission(&self) -> Option<throttle::FdAdmission> {
381        self.permit.as_ref().map(LeafPermit::admission)
382    }
383
384    /// Close the classification handle before transferring the admission permit.
385    #[must_use]
386    pub(crate) fn into_permit(mut self) -> Option<LeafPermit> {
387        take_permit_after_closing_handle(&mut self.handle, &mut self.permit)
388    }
389}
390
391impl Drop for AdmittedLeaf {
392    fn drop(&mut self) {
393        drop(take_permit_after_closing_handle(
394            &mut self.handle,
395            &mut self.permit,
396        ));
397    }
398}
399
400/// Pre-acquire a leaf permit for a child about to be spawned.
401///
402/// Returns `None` when `kind == PermitKind::None` or the cheap `getdents` hint positively identifies
403/// a directory. Every known non-directory and `DT_UNKNOWN` entry is admitted provisionally before
404/// spawn. The worker then classifies it authoritatively and drops the permit before recursion if it
405/// proves to be a directory.
406pub async fn preacquire_leaf_permit(
407    kind: PermitKind,
408    hint: Option<EntryKind>,
409) -> Option<LeafPermit> {
410    if kind == PermitKind::None || hint == Some(EntryKind::Dir) {
411        return None;
412    }
413    match kind {
414        PermitKind::OpenFile => Some(LeafPermit::OpenFile(throttle::open_file_permit().await)),
415        PermitKind::PendingMeta => Some(LeafPermit::PendingMeta(
416            throttle::pending_meta_permit().await,
417        )),
418        // unreachable: the early return above already handled `None`.
419        PermitKind::None => None,
420    }
421}
422
423/// Ensure an entry has admission before its fd-bearing authoritative classification.
424///
425/// Spawn loops pass their already-acquired permit here. Root and delegated callers that did not
426/// come through such a loop acquire from the visitor's pool now, before `Dir::child` opens the
427/// entry's `O_PATH` handle. A directory classification releases the provisional permit in the
428/// shared driver's drop-before-recursion branch.
429pub async fn ensure_leaf_permit(
430    kind: PermitKind,
431    permit: Option<LeafPermit>,
432) -> Option<LeafPermit> {
433    if permit.is_some() {
434        return permit;
435    }
436    match kind {
437        PermitKind::OpenFile => Some(LeafPermit::OpenFile(throttle::open_file_permit().await)),
438        PermitKind::PendingMeta => Some(LeafPermit::PendingMeta(
439            throttle::pending_meta_permit().await,
440        )),
441        PermitKind::None => None,
442    }
443}
444
445/// Ensure a root, delegated entry, or possible leaf holds the visitor's selected admission.
446///
447/// `HintedDirectory` deliberately remains unadmitted until classification proves the hint stale.
448/// `PermitKind::None` also remains `RootOrDelegated`, expressing that the visitor opted out of a
449/// leaf pool rather than pretending it holds a permit.
450pub async fn ensure_entry_admission(kind: PermitKind, admission: EntryAdmission) -> EntryAdmission {
451    match admission {
452        EntryAdmission::Held(_) | EntryAdmission::HintedDirectory => admission,
453        EntryAdmission::RootOrDelegated => match ensure_leaf_permit(kind, None).await {
454            Some(permit) => EntryAdmission::Held(permit),
455            None => EntryAdmission::RootOrDelegated,
456        },
457    }
458}
459
460/// Classify an entry within the admission scope selected by its caller.
461///
462/// The shared driver and rlink own the hinted-directory state machine because a stale positive
463/// hint must close its first handle, acquire admission, install a new scope, and keep that scope
464/// through final leaf work. This helper is deliberately only the authoritative fd-based open.
465pub async fn classify_entry(
466    dir: &crate::safedir::Dir,
467    name: &std::ffi::OsStr,
468) -> std::io::Result<crate::safedir::Handle> {
469    dir.child(name).await
470}
471
472/// Classify an entry while transferring its already-acquired admission into one owner.
473///
474/// Callers acquire according to their visitor's pool policy before entering here. The weak
475/// task-local scope lets every fd-bearing await in the classification use the same admission, and
476/// [`AdmittedEntry`] then keeps the handle inside that permit's lifetime until dispatch.
477pub(crate) async fn classify_admitted_entry(
478    dir: &crate::safedir::Dir,
479    name: &std::ffi::OsStr,
480    permit: Option<LeafPermit>,
481) -> std::io::Result<AdmittedEntry> {
482    let admission = permit.as_ref().map(LeafPermit::admission);
483    crate::safedir::with_optional_fd_admission(admission, async move {
484        let handle = classify_entry(dir, name).await?;
485        Ok(AdmittedEntry::new(handle, permit))
486    })
487    .await
488}
489
490/// Resolve the `throttle::Side` from the matching `congestion::Side`.
491///
492/// The two crates carry independent enum definitions to keep `throttle`
493/// free of any congestion dependency; this is the canonical bridge
494/// (paired with [`throttle_op`]) reused everywhere a congestion-side
495/// signal needs to address a throttle resource.
496pub(crate) fn throttle_side(side: congestion::Side) -> throttle::Side {
497    match side {
498        congestion::Side::Source => throttle::Side::Source,
499        congestion::Side::Destination => throttle::Side::Destination,
500    }
501}
502
503/// Resolve the `throttle::MetadataOp` from the matching `congestion::MetadataOp`.
504pub(crate) fn throttle_op(op: congestion::MetadataOp) -> throttle::MetadataOp {
505    match op {
506        congestion::MetadataOp::Stat => throttle::MetadataOp::Stat,
507        congestion::MetadataOp::ReadLink => throttle::MetadataOp::ReadLink,
508        congestion::MetadataOp::MkDir => throttle::MetadataOp::MkDir,
509        congestion::MetadataOp::RmDir => throttle::MetadataOp::RmDir,
510        congestion::MetadataOp::Unlink => throttle::MetadataOp::Unlink,
511        congestion::MetadataOp::HardLink => throttle::MetadataOp::HardLink,
512        congestion::MetadataOp::Symlink => throttle::MetadataOp::Symlink,
513        congestion::MetadataOp::Chmod => throttle::MetadataOp::Chmod,
514        congestion::MetadataOp::OpenCreate => throttle::MetadataOp::OpenCreate,
515    }
516}
517
518/// Resolve the [`throttle::Resource`] for a single per-file metadata
519/// syscall on the given side.
520pub(crate) fn meta_resource(
521    side: congestion::Side,
522    op: congestion::MetadataOp,
523) -> throttle::Resource {
524    throttle::Resource::meta(throttle_side(side), throttle_op(op))
525}
526
527/// Pull the next directory entry, gated only by the static ops rate
528/// gate.
529///
530/// Walks are deliberately not probed: `tokio::fs::ReadDir::next_entry`
531/// returns buffered entries from a prior `getdents` batch without
532/// entering the kernel, so most "walk probes" don't measure filesystem
533/// service time at all. The resulting bimodal latency distribution
534/// (cache hit vs. real `getdents`) collapses any baseline a controller
535/// could derive from it. The fix is to probe only the per-file
536/// metadata syscalls that follow the walk, where each sample reflects
537/// real filesystem work.
538///
539/// The prologue is therefore just:
540///
541/// 1. Await the static ops rate gate.
542/// 2. Call `next_entry()` and, on success, classify via `file_type()`.
543///
544/// `side` is currently unused at runtime but kept on the signature so
545/// callers stay self-documenting and future per-side gating can be
546/// reintroduced without touching every call site.
547///
548/// The error is left as `anyhow::Error` so each caller can wrap it in the
549/// site-specific error type (`copy::Error`, `link::Error`, `rm::Error`)
550/// without this helper needing to be generic over the summary payload.
551pub async fn next_entry_probed<F>(
552    entries: &mut tokio::fs::ReadDir,
553    _side: congestion::Side,
554    context: F,
555) -> anyhow::Result<Option<(tokio::fs::DirEntry, Option<std::fs::FileType>)>>
556where
557    F: FnOnce() -> String,
558{
559    throttle::get_ops_token().await;
560    let maybe_entry = entries.next_entry().await.with_context(context)?;
561    let Some(entry) = maybe_entry else {
562        return Ok(None);
563    };
564    let entry_file_type = entry.file_type().await.ok();
565    Ok(Some((entry, entry_file_type)))
566}
567
568/// Bracket a single metadata-producing future with the full per-op
569/// gating prologue: the static ops rate gate, the cwnd permit for the
570/// matching `(side, op_kind)` resource, and a congestion probe. The
571/// probe completes successfully when `fut` returns `Ok`, and is
572/// discarded on error so error paths don't skew the controller's
573/// latency baseline.
574///
575/// `op_kind` selects which per-syscall controller this call is
576/// reported to and gated by — `Stat`, `MkDir`, `Unlink`, etc. Pick the
577/// variant that matches the underlying syscall (`metadata` /
578/// `symlink_metadata` / `File::open(read)` / `canonicalize` all map to
579/// `Stat`; `create_dir` to `MkDir`; `remove_file` to `Unlink`; and so
580/// on — see [`congestion::MetadataOp`] for the full mapping).
581///
582/// `--ops-throttle` is the shared metadata rate gate, so this helper
583/// acquires it on every call — same as [`next_entry_probed`]. Callers
584/// that already rate-gate upstream (such as filegen, which gates at
585/// per-task spawn time so we don't fan out an unbounded task queue
586/// before any token is consumed) must use
587/// [`run_metadata_probed_no_rate`] instead to avoid double-counting.
588pub async fn run_metadata_probed<F, T, E>(
589    side: congestion::Side,
590    op_kind: congestion::MetadataOp,
591    fut: F,
592) -> Result<T, E>
593where
594    F: std::future::Future<Output = Result<T, E>>,
595{
596    throttle::get_ops_token().await;
597    run_metadata_probed_no_rate(side, op_kind, fut).await
598}
599
600/// Variant of [`run_metadata_probed`] that skips the static ops rate
601/// gate — for callers that already rate-limit at a coarser granularity
602/// upstream and would otherwise consume two tokens per metadata op.
603///
604/// Concretely: `filegen` gates the rate at task-spawn time so an enabled
605/// ops throttle paces creation of `write_file` futures. Their concurrent
606/// backlog is controlled separately by descriptor admission when that is
607/// enabled. The `OpenOptions::open(O_CREAT)` inside the spawned task is the
608/// only metadata syscall in that path; rate-gating it again would halve the
609/// effective rate.
610pub async fn run_metadata_probed_no_rate<F, T, E>(
611    side: congestion::Side,
612    op_kind: congestion::MetadataOp,
613    fut: F,
614) -> Result<T, E>
615where
616    F: std::future::Future<Output = Result<T, E>>,
617{
618    let ops_permit = throttle::ops_in_flight_permit(meta_resource(side, op_kind)).await;
619    let probe = congestion::Probe::start_metadata(side, op_kind);
620    let result = fut.await;
621    match &result {
622        Ok(_) => probe.complete_ok(0),
623        Err(_) => probe.discard(),
624    }
625    drop(ops_permit);
626    result
627}
628
629/// Determine the `is_dir` value to feed an include/exclude FILTER decision for a
630/// directory entry, using the authoritative `fstat` type when the cheap
631/// `getdents` `d_type` hint is unavailable.
632///
633/// `read_entries` returns each entry's `d_type` as a best-effort hint; on
634/// filesystems that don't populate it (NFS, some FUSE mounts) the hint is `None`
635/// (`DT_UNKNOWN`). Treating `None` as "not a directory" for an `is_dir`-dependent
636/// filter (e.g. `--include '/sub/**'`) would wrongly EXCLUDE a real directory and
637/// omit its entire subtree. To match the old path-based walk (which fell back to
638/// an `lstat` via `DirEntry::file_type()`), this resolves the type
639/// AUTHORITATIVELY via `dir.child(name)`'s `fstat` — but ONLY when the hint is
640/// `None` AND the type is actually needed: either a filter is active, or the
641/// caller passes `force_authoritative` because its own control flow depends on
642/// the result (e.g. a dry-run recurse-vs-leaf decision). When the hint is
643/// reliable, or the type is unneeded, the cheap hint is used directly (no extra
644/// syscall), preserving the optimization.
645///
646/// `child(name)` uses `O_PATH|O_NOFOLLOW`, so this never follows a symlink (a
647/// symlink entry classifies as `Symlink`, not `Dir`) or blocks opening a FIFO or
648/// device payload; the metadata lookup itself can still wait on slow storage.
649/// The hardening of the walk below the named root is unaffected. On a `child`
650/// error (e.g. the entry vanished mid-walk), the error is returned. A failed authoritative probe
651/// cannot safely be interpreted as a non-directory because that could defeat directory-only
652/// filtering or delete protection.
653pub async fn filter_is_dir(
654    filter: Option<&FilterSettings>,
655    dir: &crate::safedir::Dir,
656    name: &std::ffi::OsStr,
657    hint: Option<EntryKind>,
658    force_authoritative: bool,
659) -> std::io::Result<bool> {
660    match hint {
661        Some(kind) => Ok(kind == EntryKind::Dir),
662        // DT_UNKNOWN: only pay for the authoritative fstat when the type is actually
663        // needed — a filter needs it, or the caller's control flow depends on it
664        // (`force_authoritative`, e.g. a dry-run recurse-vs-leaf decision). Otherwise
665        // the value is unused, so default cheaply.
666        None if filter.is_some() || force_authoritative => {
667            Ok(dir.child(name).await?.kind() == EntryKind::Dir)
668        }
669        None => Ok(false),
670    }
671}
672
673/// Decide whether an entry should be skipped by the filter, returning the
674/// `FilterResult` that caused the skip. Returns `None` if there is no filter
675/// or the entry is included.
676#[must_use]
677pub fn should_skip_entry(
678    filter: &Option<FilterSettings>,
679    relative_path: &std::path::Path,
680    is_dir: bool,
681) -> Option<FilterResult> {
682    should_skip_entry_ref(filter.as_ref(), relative_path, is_dir)
683}
684
685/// [`should_skip_entry`] taking the filter by `Option<&_>` rather than
686/// `&Option<_>`, so callers that already hold an `Option<&FilterSettings>` (the
687/// traversal driver) don't have to clone it. Identical semantics otherwise.
688#[must_use]
689pub fn should_skip_entry_ref(
690    filter: Option<&FilterSettings>,
691    relative_path: &std::path::Path,
692    is_dir: bool,
693) -> Option<FilterResult> {
694    if let Some(f) = filter {
695        let result = f.should_include(relative_path, is_dir);
696        match result {
697            FilterResult::Included => None,
698            _ => Some(result),
699        }
700    } else {
701        None
702    }
703}
704
705/// Path of `entry` relative to `root` (typically `source_root` or `dest_root` at the call
706/// site), with the `unwrap_or(entry)` defensive fallback rcp uses when `entry` isn't
707/// actually under `root`. Naming the pattern lets call sites read "the entry's path inside
708/// the tree" instead of `entry.strip_prefix(root).unwrap_or(entry)` — and removes a class
709/// of "did I get strip_prefix the right way round?" regressions.
710///
711/// Use with `filter_base.join(...)` for a logical filter path inside a delegated subtree
712/// (`copy_with_filter_base`'s non-empty `filter_base` case), or on its own when filter_base
713/// is empty.
714#[must_use]
715pub fn relative_to_root<'a>(
716    entry: &'a std::path::Path,
717    root: &std::path::Path,
718) -> &'a std::path::Path {
719    entry.strip_prefix(root).unwrap_or(entry)
720}
721
722/// Strip trailing path separators from a root operand. A trailing slash forces the OS to resolve
723/// the final component as a directory, which would dereference a symlink root like `link/`
724/// (following it to its target). Stripping makes `link/` behave like `link` (the symlink itself),
725/// which is then classified/operated on `O_NOFOLLOW` relative to its parent fd.
726#[must_use]
727pub fn without_trailing_separators(path: &std::path::Path) -> std::path::PathBuf {
728    use std::os::unix::ffi::OsStrExt;
729    let bytes = path.as_os_str().as_bytes();
730    let mut end = bytes.len();
731    while end > 1 && bytes[end - 1] == b'/' {
732        end -= 1;
733    }
734    std::path::PathBuf::from(std::ffi::OsStr::from_bytes(&bytes[..end]))
735}
736
737/// A root operand decomposed for the fd-relative walk entry point.
738pub struct RootOperand {
739    /// The operand's parent directory — opened TRUSTED (follows symlinks) via
740    /// [`crate::safedir::Dir::open_parent_dir`].
741    pub parent: std::path::PathBuf,
742    /// The operand's final component — classified `O_NOFOLLOW` via `child(name)` below the parent.
743    pub name: std::ffi::OsString,
744    /// The operand path for diagnostics / `real_path` reconstruction: the operand as typed (with
745    /// trailing slashes stripped) for a normal operand, or the canonicalized path for a `.`/`..`
746    /// operand that had to be resolved.
747    pub display: std::path::PathBuf,
748}
749
750/// Decompose a root operand into the `(parent, final_component)` the fd-relative walk needs (see
751/// [`RootOperand`]): the parent prefix is opened with [`crate::safedir::Dir::open_parent_dir`]
752/// (trusted, follows symlinks) and the final component is then classified `O_NOFOLLOW` via
753/// `child(name)` below it.
754///
755/// Most operands split directly via `parent()` / `file_name()` (an empty parent meaning the current
756/// directory). An operand whose final component is `.` or `..` (e.g. `.`, `tree/..`) has no
757/// `file_name()`, so it is first canonicalized to a concrete path — `.` becomes the current
758/// directory, `tree/..` its grandparent — restoring the behavior of the pre-fd-walk path code,
759/// where `rrm .` / `rchm -R … .` operated on the current tree. (Canonicalizing only this branch
760/// never touches a normal operand, so a symlinked final component on the normal path is still
761/// opened `O_NOFOLLOW`; `.`/`..` are themselves never symlinks.) The filesystem root `/` has no
762/// parent and cannot be expressed as parent + component, so it is rejected with a clear error.
763pub async fn split_root_operand(path: &std::path::Path) -> anyhow::Result<RootOperand> {
764    let stripped = without_trailing_separators(path);
765    if let Some(name) = stripped.file_name() {
766        let parent = match stripped.parent() {
767            Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
768            // empty parent (a single-component relative path) means the current directory.
769            _ => std::path::PathBuf::from("."),
770        };
771        let name = name.to_owned();
772        return Ok(RootOperand {
773            parent,
774            name,
775            display: stripped,
776        });
777    }
778    // final component is `.`/`..` (or the operand is `/`): canonicalize to a concrete path so it can
779    // be split into parent + component.
780    let canonical = tokio::fs::canonicalize(&stripped)
781        .await
782        .with_context(|| format!("cannot resolve operand {stripped:?}"))?;
783    let name = canonical.file_name().map(std::ffi::OsStr::to_owned);
784    let parent = canonical.parent().map(std::path::Path::to_path_buf);
785    let (Some(parent), Some(name)) = (parent, name) else {
786        anyhow::bail!("cannot operate on the filesystem root {canonical:?}");
787    };
788    Ok(RootOperand {
789        parent,
790        name,
791        display: canonical,
792    })
793}
794
795/// The basename a root operand contributes to the trailing-slash "copy INTO directory" rule: the
796/// name of the entry the walk will create for `path`, so a CLI forming `dst/<name>` matches it
797/// exactly (`rcp . out/` / `rlink . out/` -> `out/<cwd-name>`; `… tree/.. out/` ->
798/// `out/<parent-name>`).
799///
800/// This is the synchronous twin of [`split_root_operand`]'s `name`, for the CLI path-resolution
801/// layer (which is not async): a normal operand uses [`std::path::Path::file_name`]; an operand
802/// with no `file_name()` (`.`/`..`/`dir/..`) is canonicalized first, exactly as
803/// [`split_root_operand`] does. The filesystem root has no basename and is rejected. The
804/// `root_operand_basename_matches_split_root_operand` test keeps the two in lock-step.
805pub fn root_operand_basename(path: &std::path::Path) -> anyhow::Result<std::ffi::OsString> {
806    let stripped = without_trailing_separators(path);
807    if let Some(name) = stripped.file_name() {
808        return Ok(name.to_owned());
809    }
810    // final component is `.`/`..` (or the operand is `/`): canonicalize to a concrete path so it
811    // has a real basename — the same resolution `split_root_operand` performs for the walk.
812    let canonical = std::fs::canonicalize(&stripped)
813        .with_context(|| format!("cannot resolve operand {stripped:?}"))?;
814    canonical
815        .file_name()
816        .map(std::ffi::OsStr::to_owned)
817        .ok_or_else(|| anyhow::anyhow!("cannot operate on the filesystem root {canonical:?}"))
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use crate::safedir::Dir;
824    use crate::testutils;
825    use std::ffi::OsStr;
826    use std::os::fd::AsRawFd;
827
828    fn include_filter(pattern: &str) -> Option<FilterSettings> {
829        let mut f = FilterSettings::new();
830        f.add_include(pattern).unwrap();
831        Some(f)
832    }
833
834    // FIX B (PR #247 review): when the getdents `d_type` hint is unavailable (DT_UNKNOWN -> None)
835    // AND a filter is active, the filter `is_dir` decision must come from the AUTHORITATIVE fstat,
836    // not default to non-dir. Otherwise a real directory reported as DT_UNKNOWN would be wrongly
837    // excluded by an is_dir-dependent include filter, omitting its whole subtree. We can't force
838    // DT_UNKNOWN on a normal local fs, so we drive `filter_is_dir` with `hint = None` directly —
839    // exactly the value `read_entries` would yield on NFS/FUSE — to exercise the authoritative path.
840    #[tokio::test]
841    async fn filter_is_dir_authoritatively_classifies_dt_unknown_directory() -> anyhow::Result<()> {
842        let tmp = testutils::setup_test_dir().await?;
843        // fixture: tmp/foo holds `bar` (a real directory) and `0.txt` (a real file).
844        let dir = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
845        let filter = include_filter("/bar/**");
846        // DT_UNKNOWN + active filter on a real DIRECTORY -> must resolve to `true` via fstat, so an
847        // include filter does NOT omit its subtree (the regression this fix closes).
848        assert!(
849            filter_is_dir(filter.as_ref(), &dir, OsStr::new("bar"), None, false).await?,
850            "a real directory reported as DT_UNKNOWN must classify as a directory for the filter"
851        );
852        // DT_UNKNOWN + active filter on a real FILE -> resolves to `false` authoritatively.
853        assert!(
854            !filter_is_dir(filter.as_ref(), &dir, OsStr::new("0.txt"), None, false).await?,
855            "a real file reported as DT_UNKNOWN must classify as a non-directory"
856        );
857        Ok(())
858    }
859
860    // FIX B: a reliable hint is used directly (no fstat), and with no filter active the value is
861    // the cheap default — the optimization is preserved (we never pay an fstat we don't need).
862    #[tokio::test]
863    async fn filter_is_dir_uses_hint_when_available_and_skips_when_no_filter() -> anyhow::Result<()>
864    {
865        let tmp = testutils::setup_test_dir().await?;
866        let dir = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
867        let filter = include_filter("/bar/**");
868        // reliable Dir hint -> true regardless of fstat.
869        assert!(
870            filter_is_dir(
871                filter.as_ref(),
872                &dir,
873                OsStr::new("bar"),
874                Some(EntryKind::Dir),
875                false
876            )
877            .await?
878        );
879        // reliable File hint -> false.
880        assert!(
881            !filter_is_dir(
882                filter.as_ref(),
883                &dir,
884                OsStr::new("0.txt"),
885                Some(EntryKind::File),
886                false
887            )
888            .await?
889        );
890        // DT_UNKNOWN, NO filter, and not forced -> cheap non-dir default (no authoritative fstat
891        // needed); the `name` is never resolved, so it need not even exist.
892        assert!(!filter_is_dir(None, &dir, OsStr::new("does_not_exist"), None, false).await?);
893        Ok(())
894    }
895
896    // force_authoritative (e.g. rlink --dry-run, which uses is_dir for its recurse-vs-leaf
897    // branch): a DT_UNKNOWN hint with NO filter must still classify AUTHORITATIVELY when the
898    // caller's control flow depends on the result, so a dry-run on NFS/FUSE doesn't preview a
899    // real directory as a leaf and skip its subtree.
900    #[tokio::test]
901    async fn filter_is_dir_forces_authoritative_classification_when_requested() -> anyhow::Result<()>
902    {
903        let tmp = testutils::setup_test_dir().await?;
904        let dir = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
905        // DT_UNKNOWN + NO filter, but force_authoritative -> a real directory must classify as dir.
906        assert!(
907            filter_is_dir(None, &dir, OsStr::new("bar"), None, true).await?,
908            "force_authoritative must fstat a DT_UNKNOWN directory even with no filter"
909        );
910        // ...and a real file as non-dir.
911        assert!(
912            !filter_is_dir(None, &dir, OsStr::new("0.txt"), None, true).await?,
913            "force_authoritative must fstat a DT_UNKNOWN file even with no filter"
914        );
915        Ok(())
916    }
917
918    // the leaf-permit lifecycle: `PermitKind::None` and a known directory opt out;
919    // every possible leaf acquires from the requested pool. The pools are
920    // process-global; configure a small cap so the OpenFile case exercises a real
921    // acquire rather than the disabled-pool no-op.
922    #[tokio::test]
923    async fn preacquire_leaf_permit_respects_max_files_in_flight_kind_and_hint() {
924        let admission = crate::testutils::AdmissionLimit::new().await;
925        admission.set_files_in_flight(4);
926        // `None` kind never takes a permit, regardless of hint.
927        assert!(
928            preacquire_leaf_permit(PermitKind::None, Some(EntryKind::File))
929                .await
930                .is_none()
931        );
932        // a possible leaf on the OpenFile pool yields an OpenFile permit.
933        let permit = preacquire_leaf_permit(PermitKind::OpenFile, Some(EntryKind::File)).await;
934        assert!(matches!(permit, Some(LeafPermit::OpenFile(_))));
935        drop(permit);
936        // a known directory opts out even though the pool is configured.
937        assert!(
938            preacquire_leaf_permit(PermitKind::OpenFile, Some(EntryKind::Dir))
939                .await
940                .is_none()
941        );
942        // treat DT_UNKNOWN as a provisional leaf until authoritative classification.
943        let permit = preacquire_leaf_permit(PermitKind::OpenFile, None).await;
944        assert!(matches!(permit, Some(LeafPermit::OpenFile(_))));
945        drop(permit);
946    }
947
948    #[test]
949    fn leaf_drop_primitive_closes_handle_before_permit() {
950        let events = testutils::DropEvents::default();
951        let mut handle = Some(events.probe("handle"));
952        let mut permit = Some(events.probe("permit"));
953
954        let permit = take_permit_after_closing_handle(&mut handle, &mut permit);
955        assert_eq!(
956            events.snapshot(),
957            ["handle"],
958            "the handle must close before the permit can be returned"
959        );
960        drop(permit);
961        assert_eq!(events.snapshot(), ["handle", "permit"]);
962    }
963
964    #[tokio::test]
965    async fn directory_handle_conversion_returns_the_original_handle() -> anyhow::Result<()> {
966        let root = testutils::create_temp_dir().await?;
967        tokio::fs::create_dir(root.join("directory")).await?;
968        let parent = Dir::open_parent_dir(&root, congestion::Side::Source)
969            .await?
970            .into_tree();
971        let handle = parent.child(OsStr::new("directory")).await?;
972        let raw_fd = handle.as_fd().as_raw_fd();
973        let probe = match testutils::FdIdentityProbe::capture(raw_fd) {
974            Ok(probe) => probe,
975            Err(error) => {
976                drop(handle);
977                drop(parent);
978                let cleanup_result = tokio::fs::remove_dir_all(root).await;
979                return match cleanup_result {
980                    Ok(()) => Err(error.into()),
981                    Err(cleanup_error) => Err(anyhow::Error::new(error).context(format!(
982                        "directory conversion fixture did not clean up: {cleanup_error:#}"
983                    ))),
984                };
985            }
986        };
987
988        let directory = match NonDirectoryHandle::try_from(handle) {
989            Ok(_) => anyhow::bail!("a directory was accepted as a non-directory handle"),
990            Err(directory) => directory,
991        };
992        assert_eq!(directory.kind(), EntryKind::Dir);
993        assert_eq!(directory.as_fd().as_raw_fd(), raw_fd);
994        let fd_was_open_before_drop = probe.original_is_closed().map(|closed| !closed);
995        drop(directory);
996        let fd_was_closed_after_drop = probe.original_is_closed();
997        drop(parent);
998        let cleanup_result = tokio::fs::remove_dir_all(root).await;
999        let fd_was_open_before_drop = fd_was_open_before_drop?;
1000        let fd_was_closed_after_drop = fd_was_closed_after_drop?;
1001        cleanup_result?;
1002        assert!(fd_was_open_before_drop);
1003        assert!(fd_was_closed_after_drop);
1004        Ok(())
1005    }
1006
1007    #[tokio::test]
1008    async fn into_permit_closes_handle_before_transfer() -> anyhow::Result<()> {
1009        let root = testutils::create_temp_dir().await?;
1010        tokio::fs::write(root.join("leaf"), b"x").await?;
1011        let admission = testutils::AdmissionLimit::new().await;
1012        admission.set_files_in_flight(1);
1013        let parent = Dir::open_parent_dir(&root, congestion::Side::Source)
1014            .await?
1015            .into_tree();
1016        let handle = parent.child(OsStr::new("leaf")).await?;
1017        let raw_fd = handle.as_fd().as_raw_fd();
1018        let probe = match testutils::FdIdentityProbe::capture(raw_fd) {
1019            Ok(probe) => probe,
1020            Err(error) => {
1021                drop(handle);
1022                drop(parent);
1023                drop(admission);
1024                let cleanup_result = tokio::fs::remove_dir_all(root).await;
1025                return match cleanup_result {
1026                    Ok(()) => Err(error.into()),
1027                    Err(cleanup_error) => Err(anyhow::Error::new(error).context(format!(
1028                        "leaf permit fixture did not clean up: {cleanup_error:#}"
1029                    ))),
1030                };
1031            }
1032        };
1033        let leaf = AdmittedLeaf::try_new(
1034            handle,
1035            Some(LeafPermit::OpenFile(throttle::open_file_permit().await)),
1036        )
1037        .map_err(|_| anyhow::anyhow!("file classification returned a directory outcome"))?;
1038        assert_eq!(leaf.kind(), EntryKind::File);
1039        assert_eq!(leaf.handle().as_fd().as_raw_fd(), raw_fd);
1040        assert!(leaf.admission().is_some());
1041        let permit = leaf.into_permit();
1042        let fd_was_closed_after_transfer = probe.original_is_closed();
1043        let mut next_permit = Box::pin(throttle::open_file_permit());
1044        assert!(
1045            futures::poll!(next_permit.as_mut()).is_pending(),
1046            "transfer returned admission instead of preserving it"
1047        );
1048        drop(permit);
1049        drop(next_permit.await);
1050        drop(parent);
1051        drop(admission);
1052        let cleanup_result = tokio::fs::remove_dir_all(root).await;
1053        let fd_was_closed_after_transfer = fd_was_closed_after_transfer?;
1054        cleanup_result?;
1055        assert!(
1056            fd_was_closed_after_transfer,
1057            "transfer retained the classification fd"
1058        );
1059        Ok(())
1060    }
1061
1062    // split_root_operand: a normal operand splits via parent()/file_name() (single component ->
1063    // parent "."), a trailing slash is stripped, a trailing `/.` names the directory itself, and —
1064    // the regression fix — a bare `.` or an operand ending in `..` (no file_name()) is canonicalized
1065    // so it still names a directory instead of being rejected. The filesystem root `/` is rejected.
1066    #[tokio::test]
1067    async fn split_root_operand_handles_dot_and_normal_operands() -> anyhow::Result<()> {
1068        use std::ffi::OsStr;
1069        use std::path::Path;
1070        // normal nested operand: split verbatim.
1071        let op = split_root_operand(Path::new("a/b")).await?;
1072        assert_eq!(op.parent, Path::new("a"));
1073        assert_eq!(op.name, OsStr::new("b"));
1074        assert_eq!(op.display, Path::new("a/b"));
1075        // single component: parent defaults to the current directory.
1076        let op = split_root_operand(Path::new("foo")).await?;
1077        assert_eq!(op.parent, Path::new("."));
1078        assert_eq!(op.name, OsStr::new("foo"));
1079        // trailing slash stripped (so a symlink root is classified O_NOFOLLOW, not dereferenced).
1080        let op = split_root_operand(Path::new("foo/")).await?;
1081        assert_eq!(op.name, OsStr::new("foo"));
1082        // trailing `/.` names the directory itself: `file_name()` normalizes the `.` away, so it
1083        // splits verbatim (no canonicalize) — `dir/.` -> parent ".", name "dir"; `a/b/.` -> "a","b".
1084        let op = split_root_operand(Path::new("dir/.")).await?;
1085        assert_eq!(op.parent, Path::new("."));
1086        assert_eq!(op.name, OsStr::new("dir"));
1087        let op = split_root_operand(Path::new("a/b/.")).await?;
1088        assert_eq!(op.parent, Path::new("a"));
1089        assert_eq!(op.name, OsStr::new("b"));
1090        // bare `.` (no file_name): canonicalized to the current directory.
1091        let cwd = tokio::fs::canonicalize(".").await?;
1092        let op = split_root_operand(Path::new(".")).await?;
1093        assert_eq!(op.parent, cwd.parent().unwrap());
1094        assert_eq!(op.name, cwd.file_name().unwrap());
1095        assert_eq!(op.display, cwd);
1096        // operand ending in `..` (no file_name): canonicalized so it still names a directory.
1097        // tmp/sub/.. resolves to tmp — the `rrm .` / `rchm -R … .` regression this fix closes.
1098        let tmp = testutils::create_temp_dir().await?;
1099        let sub = tmp.join("sub");
1100        tokio::fs::create_dir(&sub).await?;
1101        let canonical_tmp = tokio::fs::canonicalize(&tmp).await?;
1102        let op = split_root_operand(&sub.join("..")).await?;
1103        assert_eq!(op.parent, canonical_tmp.parent().unwrap());
1104        assert_eq!(op.name, canonical_tmp.file_name().unwrap());
1105        assert_eq!(op.display, canonical_tmp);
1106        // the filesystem root has no parent and is rejected with a clear error.
1107        assert!(split_root_operand(Path::new("/")).await.is_err());
1108        Ok(())
1109    }
1110
1111    // `root_operand_basename` (the sync CLI helper) must return exactly `split_root_operand`'s
1112    // `name` for every operand shape, so a `dst/<name>` the CLI builds always matches the entry the
1113    // walk creates. This lock-step is what makes the trailing-slash result deterministic.
1114    #[tokio::test]
1115    async fn root_operand_basename_matches_split_root_operand() -> anyhow::Result<()> {
1116        use std::path::Path;
1117        // operands with a real `file_name()` (no canonicalize): both take the lexical basename.
1118        for p in ["a/b", "foo", "foo/", "dir/.", "a/b/."] {
1119            assert_eq!(
1120                root_operand_basename(Path::new(p))?,
1121                split_root_operand(Path::new(p)).await?.name,
1122                "operand {p:?}"
1123            );
1124        }
1125        // `.`/`..`/`dir/..` (no `file_name()`): both canonicalize to a real path first. Use real
1126        // dirs so canonicalize succeeds; assert only that the two helpers agree (the concrete
1127        // basename depends on the cwd / temp path).
1128        let tmp = testutils::create_temp_dir().await?;
1129        let sub = tmp.join("sub");
1130        tokio::fs::create_dir(&sub).await?;
1131        let dot_operands = [
1132            std::path::PathBuf::from("."),
1133            std::path::PathBuf::from(".."),
1134            sub.join(".."),
1135            sub.join("../.."),
1136        ];
1137        for p in &dot_operands {
1138            assert_eq!(
1139                root_operand_basename(p)?,
1140                split_root_operand(p).await?.name,
1141                "operand {p:?}"
1142            );
1143        }
1144        // the filesystem root has no basename: both reject it.
1145        assert!(root_operand_basename(Path::new("/")).is_err());
1146        assert!(split_root_operand(Path::new("/")).await.is_err());
1147        Ok(())
1148    }
1149}