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}