Skip to main content

common/
safedir.rs

1//! Safe, race-resistant directory traversal primitives.
2//!
3//! This module provides `O_NOFOLLOW`-based directory and file handle types that
4//! prevent TOCTOU races by using file-descriptor-relative syscalls (`openat`,
5//! `fstatat`) rather than path-based lookups. Every `open_dir`/`child` call
6//! refuses to follow symlinks, so an attacker who races a directory walk cannot
7//! redirect operations outside the intended tree.
8
9use std::ffi::{CStr, OsStr};
10use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
11use std::os::unix::ffi::OsStrExt;
12use std::path::Path;
13use std::sync::Arc;
14
15use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
16
17use nix::fcntl::{AT_FDCWD, AtFlags, OFlag, openat, readlinkat};
18use nix::sys::stat::{FileStat, Mode, fchmod, fstat, fstatat, futimens, mkdirat};
19use nix::sys::time::TimeSpec;
20use nix::unistd::{Gid, Uid, UnlinkatFlags, fchown, fchownat, linkat, symlinkat, unlinkat};
21
22use crate::walk::EntryKind;
23
24// ── Destination creation modes ───────────────────────────────────────────────
25
26/// The mode a destination FILE is created with, before it has any contents.
27///
28/// Owner-only. The source mode is applied by [`set_file_metadata_fd`] once the last byte has been
29/// written, so the file is never visible to anyone but the copier while it is being filled in —
30/// the file counterpart of the directory split-chmod (see [`DST_DIR_CREATE_MODE`]).
31///
32/// Creating at the *final* mode instead would publish the destination before its contents exist,
33/// giving a half-written file the audience its finished form was meant to have — at the default
34/// `0o0777` mask, a world-readable source yields a world-readable destination from creation onward.
35/// The sharper case needs the special bits preserved: a root copier holds `CAP_FSETID`, so writing
36/// does not clear `S_ISUID`, and the destination carries `setuid` root while the source's owner is
37/// still authoring its contents. That is not an exec window while the copy *runs* — our own write
38/// descriptor is open, and `execve` refuses a file any process holds open for writing with
39/// `ETXTBSY`. If the copier dies, closing that descriptor drops the protection; creation at the
40/// final mode would leave a finished-looking setuid binary (see `docs/tocttou.md`). Withholding the
41/// owner execute bit is deliberate too — a half-written executable should not be executable.
42///
43/// This is a constant rather than a [`Dir::create_file`] parameter so that no call site, present or
44/// future, can create a destination file at a wide mode. Like any creation mode it is subject to
45/// the umask, which can only narrow it further.
46pub const DST_FILE_CREATE_MODE: u32 = 0o600;
47
48/// The mode a destination DIRECTORY is created with, before it has any children.
49///
50/// Owner-only, plus the execute bit the copier needs to populate it. The source mode is applied
51/// after every child has been written — by `CopyVisitor::dir_post` locally, and on directory
52/// completion remotely.
53pub const DST_DIR_CREATE_MODE: u32 = 0o700;
54
55// ── Strict operand resolution (--require-toctou-safe) ────────────────────────
56//
57// A process-global, one-way switch armed by the TOCTOU linter (see
58// `crate::toctou_check::run_linter`) when `--require-toctou-safe` proceeds. When
59// armed, the two multi-component path resolutions in this module —
60// `Dir::open_root_dir` and `Dir::open_parent_dir`, the only places an operand
61// path is resolved — use `openat2(2)` with `RESOLVE_NO_SYMLINKS` instead of a
62// plain `openat`, so a symlink in ANY component of an operand path fails closed
63// with `ELOOP` at the open itself (not in a racy pre-check). This is a global
64// rather than a threaded setting because it is per-process security policy that
65// must cover every operand open in every engine (copy/link/rm/chmod, local and
66// rcpd) without widening each Settings struct and the rcpd spawn contract; it is
67// armed once before the async runtime starts and never unset.
68
69static STRICT_OPERAND_RESOLUTION: std::sync::atomic::AtomicBool =
70    std::sync::atomic::AtomicBool::new(false);
71
72tokio::task_local! {
73    /// The weak admission reference inherited by fd-owning blocking metadata work in this task.
74    static FD_ADMISSION: throttle::FdAdmission;
75}
76
77/// Run `future` with a weak admission reference available to safedir blocking work.
78///
79/// Tokio cannot cancel a `spawn_blocking` closure after it starts. Without this scope, cancelling
80/// the async waiter drops its operation guard even though the detached closure may still own a
81/// duplicated directory or entry fd. [`run_metadata_probed_blocking`] upgrades the reference for
82/// each such closure, keeping the original admission slot occupied until that fd-owning owner
83/// finishes. The ambient reference is deliberately weak: dropping the concrete guard before
84/// directory recursion still releases capacity even while this scope remains on the stack.
85/// Tokio task locals are not inherited by `tokio::spawn`; every spawned entry worker must install
86/// its own scope after it receives admission.
87pub async fn with_fd_admission<F>(admission: throttle::FdAdmission, future: F) -> F::Output
88where
89    F: std::future::Future,
90{
91    FD_ADMISSION.scope(admission, future).await
92}
93
94/// Run `future` under `admission` when this traversal uses an fd-admission pool.
95pub(crate) async fn with_optional_fd_admission<F>(
96    admission: Option<throttle::FdAdmission>,
97    future: F,
98) -> F::Output
99where
100    F: std::future::Future,
101{
102    match admission {
103        Some(admission) => with_fd_admission(admission, future).await,
104        None => future.await,
105    }
106}
107
108/// Arm strict operand resolution for the rest of this process (one-way).
109///
110/// Called by the TOCTOU linter when `--require-toctou-safe` proceeds; not
111/// intended to be called from anywhere else.
112pub fn enable_strict_operand_resolution() {
113    STRICT_OPERAND_RESOLUTION.store(true, std::sync::atomic::Ordering::Release);
114}
115
116/// Whether strict operand resolution is armed for this process.
117#[must_use]
118pub fn strict_operand_resolution() -> bool {
119    STRICT_OPERAND_RESOLUTION.load(std::sync::atomic::Ordering::Acquire)
120}
121
122/// Open `path` with `openat2(2)`, refusing to resolve ANY symlink component
123/// (`RESOLVE_NO_SYMLINKS`, which also implies `RESOLVE_NO_MAGICLINKS`). A
124/// symlink anywhere in the path fails with `ELOOP`.
125#[cfg(target_os = "linux")]
126fn openat2_no_symlinks(path: &Path, flags: OFlag) -> nix::Result<OwnedFd> {
127    use nix::fcntl::{OpenHow, ResolveFlag, openat2};
128    let how = OpenHow::new()
129        .flags(flags)
130        .resolve(ResolveFlag::RESOLVE_NO_SYMLINKS);
131    // bounded retry: with resolve restrictions openat2 can return EAGAIN when the
132    // kernel detects a rename race during resolution (see openat2(2)); retrying a
133    // handful of times resolves transient races without risking an unbounded loop.
134    let mut attempts = 0;
135    loop {
136        match openat2(AT_FDCWD, path, how) {
137            Err(nix::errno::Errno::EAGAIN) if attempts < 4 => attempts += 1,
138            other => return other,
139        }
140    }
141}
142
143/// Whether this kernel supports `openat2(2)` (Linux 5.6+), probed once.
144///
145/// Strict operand resolution is impossible without it, so
146/// `--require-toctou-safe` refuses to run when this returns `false`.
147#[cfg(target_os = "linux")]
148#[must_use]
149pub fn openat2_available() -> bool {
150    static PROBE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
151    *PROBE.get_or_init(|| {
152        // "/" always exists and is a directory; ENOSYS is the only expected failure
153        !matches!(
154            openat2_no_symlinks(Path::new("/"), OFlag::O_PATH | OFlag::O_CLOEXEC),
155            Err(nix::errno::Errno::ENOSYS)
156        )
157    })
158}
159
160/// Non-Linux builds have no `openat2`; `--require-toctou-safe` already refuses
161/// to run there (the hardened walk is Linux-only), so this is only consulted to
162/// render an accurate `--toctou-check` note.
163#[cfg(not(target_os = "linux"))]
164#[must_use]
165pub fn openat2_available() -> bool {
166    false
167}
168
169// ── FileMeta ──────────────────────────────────────────────────────────────────
170
171/// A snapshot of filesystem metadata obtained via `fstat`/`fstatat`.
172///
173/// Implements [`crate::preserve::Metadata`] so callers can apply these fields
174/// to another entry with the existing `set_*_metadata` helpers.
175#[derive(Clone, Debug)]
176pub struct FileMeta {
177    uid: u32,
178    gid: u32,
179    atime: i64,
180    atime_nsec: i64,
181    mtime: i64,
182    mtime_nsec: i64,
183    ctime: i64,
184    ctime_nsec: i64,
185    mode: u32,
186    size: u64,
187    dev: u64,
188    ino: u64,
189}
190
191impl FileMeta {
192    fn from_stat(st: &FileStat) -> Self {
193        Self {
194            uid: st.st_uid,
195            gid: st.st_gid,
196            atime: st.st_atime,
197            atime_nsec: st.st_atime_nsec,
198            mtime: st.st_mtime,
199            mtime_nsec: st.st_mtime_nsec,
200            ctime: st.st_ctime,
201            ctime_nsec: st.st_ctime_nsec,
202            mode: st.st_mode,
203            size: st.st_size as u64,
204            dev: st.st_dev,
205            ino: st.st_ino,
206        }
207    }
208
209    /// The device number of the filesystem this entry lives on.
210    #[must_use]
211    pub fn dev(&self) -> u64 {
212        self.dev
213    }
214
215    /// The entry's inode number.
216    #[must_use]
217    pub fn ino(&self) -> u64 {
218        self.ino
219    }
220}
221
222impl crate::preserve::Metadata for FileMeta {
223    fn uid(&self) -> u32 {
224        self.uid
225    }
226    fn gid(&self) -> u32 {
227        self.gid
228    }
229    fn atime(&self) -> i64 {
230        self.atime
231    }
232    fn atime_nsec(&self) -> i64 {
233        self.atime_nsec
234    }
235    fn mtime(&self) -> i64 {
236        self.mtime
237    }
238    fn mtime_nsec(&self) -> i64 {
239        self.mtime_nsec
240    }
241    fn permissions(&self) -> std::fs::Permissions {
242        use std::os::unix::fs::PermissionsExt;
243        std::fs::Permissions::from_mode(self.mode)
244    }
245    fn ctime(&self) -> i64 {
246        self.ctime
247    }
248    fn ctime_nsec(&self) -> i64 {
249        self.ctime_nsec
250    }
251    fn size(&self) -> u64 {
252        self.size
253    }
254}
255
256// ── EntryKind classification ───────────────────────────────────────────────────
257
258fn kind_from_stat(st: &FileStat) -> EntryKind {
259    let mode = st.st_mode;
260    // use libc mode-classification macros via their bit patterns (POSIX S_IFMT)
261    // S_IFREG = 0o0100000, S_IFDIR = 0o0040000, S_IFLNK = 0o0120000
262    let ifmt = mode & libc::S_IFMT;
263    match ifmt {
264        libc::S_IFREG => EntryKind::File,
265        libc::S_IFDIR => EntryKind::Dir,
266        libc::S_IFLNK => EntryKind::Symlink,
267        _ => EntryKind::Special,
268    }
269}
270
271// ── Handle ────────────────────────────────────────────────────────────────────
272
273/// An open, classified handle to a filesystem entry obtained via `O_PATH|O_NOFOLLOW`.
274///
275/// The fd is opened with `O_PATH`, so it cannot be used for reading; it exists
276/// solely to identify the entry (for further `openat` calls relative to it) and
277/// to carry the stat snapshot. A symlink entry is never followed: it yields a
278/// `Handle` with `kind() == EntryKind::Symlink`.
279#[derive(Debug)]
280pub struct Handle {
281    fd: OwnedFd,
282    kind: EntryKind,
283    dev: u64,
284    ino: u64,
285    meta: FileMeta,
286}
287
288/// The classification and size needed to remove an occupied destination slot.
289///
290/// Unlike [`Handle`], this snapshot does not keep an inode pinned. Overwrite removal operates by
291/// name through a pinned parent directory, so retaining the entry fd cannot bind the later by-name
292/// removal to that inode. Converting to this snapshot closes the classification fd while preserving
293/// the accounting data the removal path needs.
294#[derive(Clone, Copy, Debug)]
295pub struct RemovalSnapshot {
296    kind: EntryKind,
297    size: u64,
298}
299
300impl RemovalSnapshot {
301    /// The entry classification captured during overwrite planning.
302    #[must_use]
303    pub fn kind(self) -> EntryKind {
304        self.kind
305    }
306
307    /// The entry size captured during overwrite planning.
308    #[must_use]
309    pub fn size(self) -> u64 {
310        self.size
311    }
312}
313
314impl Handle {
315    /// The entry's classification (File / Dir / Symlink / Special).
316    #[must_use]
317    pub fn kind(&self) -> EntryKind {
318        self.kind
319    }
320
321    /// The device number of the entry.
322    #[must_use]
323    pub fn dev(&self) -> u64 {
324        self.dev
325    }
326
327    /// The inode number of the entry.
328    #[must_use]
329    pub fn ino(&self) -> u64 {
330        self.ino
331    }
332
333    /// A snapshot of the entry's metadata at the time the handle was opened.
334    #[must_use]
335    pub fn meta(&self) -> &FileMeta {
336        &self.meta
337    }
338
339    /// Consume this handle and retain only the data needed for overwrite removal.
340    #[must_use]
341    pub fn into_removal_snapshot(self) -> RemovalSnapshot {
342        RemovalSnapshot {
343            kind: self.kind,
344            size: self.meta.size,
345        }
346    }
347
348    /// Borrow the underlying file descriptor.
349    #[must_use]
350    pub fn as_fd(&self) -> BorrowedFd<'_> {
351        self.fd.as_fd()
352    }
353
354    /// Duplicate this handle, sharing the same pinned inode via a `dup`'d
355    /// (`F_DUPFD_CLOEXEC`) `O_PATH` file descriptor and copying the cached
356    /// classification + stat snapshot.
357    ///
358    /// This is a pure fd dup — it opens nothing, follows nothing, and stats
359    /// nothing on the filesystem, so it preserves every TOCTOU property of the
360    /// original `O_PATH|O_NOFOLLOW` handle (the clone pins the exact same inode and
361    /// cannot be redirected by a concurrent rename/symlink swap). It lets a walk
362    /// that classifies an entry once hand an owned handle to a deferred (post-order)
363    /// step without a second `openat`/`fstatat` on the entry.
364    pub fn try_clone(&self) -> std::io::Result<Handle> {
365        Ok(Handle {
366            fd: self.fd.try_clone()?,
367            kind: self.kind,
368            dev: self.dev,
369            ino: self.ino,
370            meta: self.meta.clone(),
371        })
372    }
373
374    /// Read this symlink's target and metadata from the one pinned `O_PATH` fd: the target via the
375    /// empty-path `readlinkat` ([`read_link_handle`]) and the metadata from this handle's `fstat`
376    /// snapshot. Both describe the same pinned inode, so they are a faithful pair (the symlink
377    /// analogue of [`Dir::open_file_read`]'s `(File, FileMeta)`). Errors if the handle is not a
378    /// symlink (the empty-path read requires a symlink fd).
379    pub async fn read_symlink(
380        &self,
381        side: congestion::Side,
382    ) -> std::io::Result<(std::path::PathBuf, FileMeta)> {
383        let target = read_link_handle(self, side).await?;
384        Ok((target, self.meta.clone()))
385    }
386}
387
388// ── Dir ───────────────────────────────────────────────────────────────────────
389
390/// A directory file descriptor opened `O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC`.
391///
392/// All entry-level operations are relative to this fd, preventing TOCTOU races
393/// that path-based lookups are vulnerable to.
394///
395/// The fd is held behind an `Arc` so per-entry operations can move an owned
396/// reference into their `spawn_blocking` closure. Once a closure starts it is
397/// not cancellable: if the surrounding future is then dropped (timeout,
398/// `fail_early` abort, Ctrl-C) the closure keeps running detached. User work
399/// still queued at `run_fd_admitted_blocking`'s handoff is instead reclaimed
400/// synchronously with its captures; the Tokio wrapper does nothing if scheduled
401/// later. Cloning the `Arc` (a refcount bump, no syscall) keeps the open file
402/// description alive for a started closure's full duration even if the
403/// originating `Dir` is dropped mid-flight, preserving the `openat` TOCTOU
404/// guarantee. Later fd-relative methods (`open_file_read`, `create_file`,
405/// `make_dir`, `read_entries`, …) must follow this same clone-Arc-into-closure
406/// shape.
407#[derive(Debug)]
408pub struct Dir {
409    fd: Arc<OwnedFd>,
410    /// Which filesystem side this directory lives on, for congestion gating.
411    side: congestion::Side,
412    /// Whether a file created in THIS directory may inherit an access ACL that rcp has not
413    /// sanitized — i.e. whether the directory may still carry a default ACL rcp took no
414    /// responsibility for.
415    ///
416    /// `true` for every directory rcp merely OPENED: the ambient parent above a named operand
417    /// (`open_parent_dir` / `open_root_dir`) and any reused directory before its lockdown. `false`
418    /// only once rcp has made inheritance impossible: a `make_dir` (which strips both ACLs at
419    /// creation under strict operand resolution) or a [`lockdown_reused_dir`] (which removes the
420    /// default ACL for the copy's duration) — the ONLY two sites that clear it, via
421    /// `Self::mark_children_cannot_inherit`.
422    ///
423    /// Consulted ONLY by [`Self::create_file`], ONLY under `--require-toctou-safe`. The polarity is
424    /// deliberately fail-safe: a stale `true` on an already-sanitized parent costs one wasted
425    /// `fremovexattr` per created file; a wrong `false` would silently reopen the containment hole
426    /// (docs/acls.md, "containment"), so nothing but the two sanitizing sites may clear it — and
427    /// the ONE re-arming site may set it back: [`ReusedDirLock`]'s rollback restores the
428    /// directory's original default ACL, so it stores `true` (before the restore syscall) or a
429    /// file created after the rollback would inherit that ACL with the strip skipped. `Arc`d so
430    /// `create_file`'s blocking closure can consult it AFTER its `openat` (a pre-closure snapshot
431    /// races the rollback: the closure can run after the restore with a stale `false`).
432    children_may_inherit: Arc<std::sync::atomic::AtomicBool>,
433}
434
435impl Dir {
436    /// Wrap a directory fd rcp merely OPENED (as opposed to created): its inherited-ACL state is
437    /// unknown, so files created in it must assume the worst — see `children_may_inherit`.
438    fn opened(fd: OwnedFd, side: congestion::Side) -> Dir {
439        Dir {
440            fd: Arc::new(fd),
441            side,
442            children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(true)),
443        }
444    }
445
446    /// Record that nothing created in this directory can inherit an ACL anymore — called by the
447    /// two sanitizing sites (`make_dir`'s creation strip and [`lockdown_reused_dir`]'s default-ACL
448    /// removal) and nothing else; see `children_may_inherit`.
449    fn mark_children_cannot_inherit(&self) {
450        self.children_may_inherit
451            .store(false, std::sync::atomic::Ordering::Relaxed);
452    }
453
454    /// Which filesystem side this directory lives on (for congestion gating).
455    #[must_use]
456    pub fn side(&self) -> congestion::Side {
457        self.side
458    }
459
460    /// Open `path` as a directory fd.
461    ///
462    /// The final component is always opened with `O_NOFOLLOW`. If `dereference`
463    /// is `false` and the final component is a symlink, the call fails with
464    /// `ELOOP`. If `dereference` is `true` and the final component is a symlink,
465    /// the call is retried without `O_NOFOLLOW` so the symlink is followed.
466    ///
467    /// The parent prefix is resolved normally (it is trusted) — unless strict
468    /// operand resolution is armed (`--require-toctou-safe`), in which case the
469    /// whole path is resolved `RESOLVE_NO_SYMLINKS` and a symlink in ANY
470    /// component fails closed with `ELOOP`.
471    pub async fn open_root_dir(
472        path: &Path,
473        dereference: bool,
474        side: congestion::Side,
475    ) -> std::io::Result<Dir> {
476        let path = path.to_owned();
477        // run the blocking openat inside spawn_blocking, gated by the congestion
478        // controller, matching the per-metadata-syscall pattern used across the crate.
479        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
480            let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
481            let mode = Mode::empty();
482            #[cfg(target_os = "linux")]
483            {
484                if strict_operand_resolution() {
485                    // --require-toctou-safe: `-L` is refused by the linter before strict
486                    // mode can arm, so a dereference request here is an internal
487                    // inconsistency; fail closed rather than follow a symlink.
488                    if dereference {
489                        return Err(std::io::Error::new(
490                            std::io::ErrorKind::InvalidInput,
491                            "--dereference cannot be combined with strict operand resolution",
492                        ));
493                    }
494                    return openat2_no_symlinks(&path, flags)
495                        .map(|fd| Dir::opened(fd, side))
496                        .map_err(nix_to_io);
497                }
498            }
499            match openat(AT_FDCWD, &path, flags, mode) {
500                Ok(fd) => Ok(Dir::opened(fd, side)),
501                Err(nix::errno::Errno::ELOOP) if dereference => {
502                    // final component is a symlink; follow it only when dereference=true
503                    let follow_flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
504                    openat(AT_FDCWD, &path, follow_flags, mode)
505                        .map(|fd| Dir::opened(fd, side))
506                        .map_err(nix_to_io)
507                }
508                Err(e) => Err(nix_to_io(e)),
509            }
510        })
511        .await
512    }
513
514    /// Open a TRUSTED command-line parent-prefix directory, resolving symlinks
515    /// normally (the final component IS followed if it is a symlink).
516    ///
517    /// The trusted-boundary model (docs/tocttou.md, "Trusted boundary") trusts the directory named on
518    /// the command line up to and including itself; only entries strictly BELOW
519    /// it are hardened with `O_NOFOLLOW`. The parent prefix that CONTAINS the
520    /// operand is therefore resolved like a normal path open — a symlinked parent
521    /// (e.g. `rcp file symlink_to_dir/out`, where `symlink_to_dir` is a symlink to
522    /// a real directory) must be followed into the real directory, not rejected
523    /// with `ELOOP`/`ENOTDIR`.
524    ///
525    /// This differs from [`Self::open_root_dir`], which `O_NOFOLLOW`s the final
526    /// component (the named operand itself) and only follows it when
527    /// `dereference` is set. Use `open_parent_dir` for the operand's CONTAINER
528    /// directory; use `open_root_dir` for the operand entry. Every descendant
529    /// `openat` during the walk still uses `O_NOFOLLOW`, so the hardening below
530    /// the named root is unaffected.
531    ///
532    /// Returns a [`TrustedDir`], the only retained/exposed symlink-following transition used by
533    /// ordinary callers. The private dry-run preview parent opener follows the trusted prefix
534    /// internally before beginning its nofollow descent. Crossing into the hardened tree below the
535    /// named root is the explicit [`TrustedDir::into_tree`] step.
536    ///
537    /// Under strict operand resolution (`--require-toctou-safe`) the prefix must
538    /// already be symlink-free: it is resolved `RESOLVE_NO_SYMLINKS`, and a
539    /// symlink in any component fails closed with `ELOOP` instead of being
540    /// followed. Pass fully-resolved operands (`realpath` output) in that mode.
541    pub async fn open_parent_dir(
542        path: &Path,
543        side: congestion::Side,
544    ) -> std::io::Result<TrustedDir> {
545        let path = path.to_owned();
546        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
547            let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
548            #[cfg(target_os = "linux")]
549            {
550                if strict_operand_resolution() {
551                    return openat2_no_symlinks(&path, flags)
552                        .map(|fd| TrustedDir(Dir::opened(fd, side)))
553                        .map_err(nix_to_io);
554                }
555            }
556            // a normal directory open: the kernel resolves the whole path following
557            // symlinks, including the final (trusted parent) component. No O_NOFOLLOW.
558            openat(AT_FDCWD, &path, flags, Mode::empty())
559                .map(|fd| TrustedDir(Dir::opened(fd, side)))
560                .map_err(nix_to_io)
561        })
562        .await
563    }
564
565    /// Open a child directory entry by name, refusing to follow symlinks.
566    ///
567    /// Fails with `ELOOP` if `name` refers to a symlink, or `ENOTDIR` if it
568    /// refers to a non-directory entry. The returned `Dir` carries the same
569    /// congestion side as `self`.
570    pub async fn open_dir(&self, name: &OsStr) -> std::io::Result<Dir> {
571        // `O_NOFOLLOW`/`O_PATH` only guard the final path component, so a `name`
572        // containing `/` could let openat traverse an intermediate symlink. Reject
573        // multi-component names at runtime (debug_assert is compiled out in release).
574        if !is_single_component(name) {
575            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
576        }
577        // clone the Arc (refcount bump, no syscall) and move it into the blocking
578        // closure so the open file description stays alive for the closure's full
579        // duration even if this Dir is dropped after the closure starts (started
580        // spawn_blocking work is not cancellable). see the Dir doc comment.
581        let dir = self.fd.clone();
582        let side = self.side;
583        let name = name.to_owned();
584        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
585            let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
586            openat(dir.as_fd(), name.as_bytes(), flags, Mode::empty())
587                .map(|fd| Dir::opened(fd, side))
588                .map_err(nix_to_io)
589        })
590        .await
591    }
592
593    /// Open a child regular file for reading, refusing to follow symlinks and never
594    /// blocking on a FIFO. Returns the open file plus its metadata snapshot.
595    ///
596    /// `O_NONBLOCK` is included so that if an attacker races the directory entry to
597    /// a FIFO between `getdents` and this `open`, the open returns immediately
598    /// (`O_RDONLY|O_NONBLOCK` on a FIFO never blocks on Linux) rather than blocking
599    /// forever waiting for a writer. `O_NOFOLLOW` prevents symlink following but
600    /// does not catch FIFOs (they are not symlinks); the subsequent `fstat` +
601    /// `S_ISREG` check rejects any non-regular file (FIFO, device, directory) with
602    /// `EINVAL`. `O_NONBLOCK` persists on the returned `File`, which is harmless for
603    /// regular-file I/O on a local fs.
604    ///
605    /// Fails with `EINVAL` if `name` is not a single path component, `ELOOP` if
606    /// `name` is a symlink, or `EINVAL` (after open, via the `fstat`+`S_ISREG`
607    /// check) if the entry is any non-regular type such as a FIFO, device, or
608    /// directory.
609    ///
610    /// This is the canonical regular-file payload+metadata read: the returned `FileMeta` (not the
611    /// classify [`Handle`]'s metadata) is what callers must apply/send, so bytes and metadata come
612    /// from the same fd (read-side fidelity, see docs/tocttou.md).
613    pub async fn open_file_read(&self, name: &OsStr) -> std::io::Result<(std::fs::File, FileMeta)> {
614        if !is_single_component(name) {
615            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
616        }
617        let dir = self.fd.clone();
618        let side = self.side;
619        let name = name.to_owned();
620        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
621            let flags = OFlag::O_RDONLY | OFlag::O_NOFOLLOW | OFlag::O_NONBLOCK | OFlag::O_CLOEXEC;
622            let fd =
623                openat(dir.as_fd(), name.as_bytes(), flags, Mode::empty()).map_err(nix_to_io)?;
624            // fstat the open fd to confirm the entry is a regular file; this is the
625            // safety check — O_NOFOLLOW does not catch FIFOs or other special files.
626            let st = fstat(&fd).map_err(nix_to_io)?;
627            if kind_from_stat(&st) != EntryKind::File {
628                // fd is dropped here, closing it
629                return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
630            }
631            let meta = FileMeta::from_stat(&st);
632            let file = std::fs::File::from(fd);
633            Ok((file, meta))
634        })
635        .await
636    }
637
638    /// `fstat` this directory's own held fd, returning its metadata snapshot.
639    ///
640    /// Lets a caller apply/send a directory's metadata from the SAME fd whose `read_entries`
641    /// produced its contents (read-side fidelity, see docs/tocttou.md), rather than from a
642    /// separately-opened classify [`Handle`] that a concurrent swap could desync from the
643    /// enumerated contents. Gated as `Stat`.
644    pub async fn meta(&self) -> std::io::Result<FileMeta> {
645        let dir = self.fd.clone();
646        let side = self.side;
647        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
648            let st = fstat(dir.as_fd()).map_err(nix_to_io)?;
649            Ok(FileMeta::from_stat(&st))
650        })
651        .await
652    }
653
654    /// Read this directory's own access and default POSIX ACLs from its held fd.
655    ///
656    /// The directory counterpart of [`Self::meta`], and paired with it for the same reason: the
657    /// ACLs a caller applies to the destination come from the SAME fd whose `read_entries` produced
658    /// the contents being copied. Callers gate this on `d:acl` — see [`read_acls_fd`] for what the
659    /// probe costs.
660    pub async fn read_acls(&self) -> std::io::Result<Acls> {
661        read_acls_fd(self.fd.as_fd(), self.side, true).await
662    }
663
664    /// Open a child entry by name, classifying it without following symlinks.
665    ///
666    /// Uses `O_PATH|O_NOFOLLOW`, which yields a valid fd even for symlinks. The
667    /// stat is then obtained via `fstatat` with `AT_EMPTY_PATH` on the resulting
668    /// fd so the classification is always consistent with the opened entry.
669    pub async fn child(&self, name: &OsStr) -> std::io::Result<Handle> {
670        // see open_dir: `O_NOFOLLOW`/`O_PATH` only guard the final component, so a
671        // `name` containing `/` could traverse an intermediate symlink. Reject
672        // multi-component names at runtime (debug_assert is compiled out in release).
673        if !is_single_component(name) {
674            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
675        }
676        // clone the Arc (refcount bump, no syscall) and move it into the blocking
677        // closure so the open file description stays alive for the closure's full
678        // duration even if this Dir is dropped after the closure starts (started
679        // spawn_blocking work is not cancellable). see the Dir doc comment.
680        let dir = self.fd.clone();
681        let side = self.side;
682        let name = name.to_owned();
683        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
684            let flags = OFlag::O_PATH | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
685            let fd =
686                openat(dir.as_fd(), name.as_bytes(), flags, Mode::empty()).map_err(nix_to_io)?;
687            // stat the fd itself (empty path + AT_EMPTY_PATH): works for symlinks too
688            let st = fstatat(&fd, "", AtFlags::AT_EMPTY_PATH).map_err(nix_to_io)?;
689            let kind = kind_from_stat(&st);
690            let dev = st.st_dev;
691            let ino = st.st_ino;
692            let meta = FileMeta::from_stat(&st);
693            Ok(Handle {
694                fd,
695                kind,
696                dev,
697                ino,
698                meta,
699            })
700        })
701        .await
702    }
703
704    /// Confirm this directory's own held fd still refers to the same inode as
705    /// `handle` (same `dev` + `ino`), returning `ESTALE` on mismatch.
706    ///
707    /// This stats this [`Dir`]'s already-pinned fd (via [`Self::meta`]) against a classification
708    /// [`Handle`] taken for the same entry. It closes the classify-to-`open_dir` window in the
709    /// reused-destination-directory lockdown: `open_dir` re-resolves the name, so a concurrent
710    /// rename could substitute a different directory between classification and the open.
711    /// Comparing the opened fd's identity to the pinned classification handle catches that swap.
712    /// The handle keeps the classified inode number from being recycled during the comparison.
713    pub async fn verify_same_inode(&self, handle: &Handle) -> std::io::Result<()> {
714        let meta = self.meta().await?;
715        if meta.dev() == handle.dev() && meta.ino() == handle.ino() {
716            Ok(())
717        } else {
718            Err(std::io::Error::from_raw_os_error(libc::ESTALE))
719        }
720    }
721
722    /// Take uid ownership of this directory as the copier and restrict it to `mode`,
723    /// atomically and verified. Used by the reused-destination-directory lockdown under
724    /// [`strict_operand_resolution`]: taking ownership first means the directory's PRIOR
725    /// owner can no longer `chmod` it back open while children are written.
726    ///
727    /// The sequence — inside ONE `spawn_blocking` closure (which runs to completion once it
728    /// starts) — is ordered so that **every failure exit leaves the directory no wider than a
729    /// successful copy would**:
730    ///
731    /// 1. `fchown` the owner to the effective uid (lock the prior owner out). Failing here
732    ///    leaves the directory untouched — transparent, fail-safe.
733    /// 2. `fchmod` to a plain `0o700` IMMEDIATELY. From here on any failure leaves the
734    ///    directory owner-only, i.e. no wider than success. If this `fchmod` fails, roll the
735    ///    ownership back to `orig_uid` and CHECK the rollback: a clean rollback fully restores
736    ///    the pre-lockdown state (the failed `chmod` changed nothing); a rollback that ALSO
737    ///    fails is a genuinely stuck state (a read-only/failing backend) reported with both
738    ///    errnos — the caller fails closed, so no child is ever written there.
739    /// 3. Reset the gid to `orig_gid` **only if it differs** (a prior owner raced a `chgrp`
740    ///    between classification and step 1). This matters for a **setgid** directory:
741    ///    children inherit the directory's gid, and finalization cannot repair children
742    ///    already created. Writing only-when-different keeps the common case a no-op — never
743    ///    an EPERM for a non-root copier that owns the directory but is not in its group — and
744    ///    a difference implies a foreign-owned directory, so the copier took ownership in step
745    ///    1 as root and can set any gid.
746    /// 4. Re-add the setgid bit (`fchmod` to `mode`) when wanted, now that the gid is
747    ///    `orig_gid`. Doing this AFTER the gid reset stops a non-privileged copier's `chmod`
748    ///    from silently dropping `S_ISGID`.
749    /// 5. Final `fstat` verifies the directory is owned by the effective uid, carries gid
750    ///    `orig_gid`, and carries exactly `mode`. A filesystem that reports a successful
751    ///    `chown`/`chmod` without honoring it (e.g. CIFS without unix extensions), or a
752    ///    dropped `S_ISGID`, is caught here rather than descending into a still-exposed
753    ///    directory. A verify failure still leaves the directory at the restrictive interim
754    ///    mode from step 2/4.
755    ///
756    /// `orig_uid`/`orig_gid` are the owner/group captured at classification. Fails (typically
757    /// `EPERM`) when the copier neither owns the directory nor is privileged — caller fails closed.
758    pub async fn secure_as_copier(
759        &self,
760        mode: u32,
761        orig_uid: u32,
762        orig_gid: u32,
763    ) -> std::io::Result<()> {
764        let fd = self.fd.clone();
765        let euid = nix::unistd::geteuid().as_raw();
766        run_metadata_probed_blocking(self.side, congestion::MetadataOp::Chmod, move || {
767            let raw = fd.as_fd();
768            // Take uid ownership — this locks the prior owner out of any further chown/chmod. If it
769            // fails the directory is left untouched (transparent), which is fail-safe.
770            fchown(raw, Some(Uid::from_raw(euid)), None).map_err(nix_to_io)?;
771            // Restrict to owner-only IMMEDIATELY, so EVERY later failure leaves the directory no wider
772            // than a successful copy would. Drop to a plain 0o700 first — there is no special bit to
773            // lose yet; the setgid bit (if wanted) is re-added below, once the gid is correct. If the
774            // chmod fails, roll the ownership back AND CHECK the rollback, so we never silently leave
775            // the directory copier-owned at its original (possibly permissive) mode.
776            if let Err(chmod_err) = fchmod(raw, Mode::from_bits_truncate(0o700)).map_err(nix_to_io)
777            {
778                // roll the ownership back AND VERIFY it landed with an fstat: a non-honoring backend
779                // can report a false chown success, so we must never claim a restore that did not
780                // happen.
781                match fchown(raw, Some(Uid::from_raw(orig_uid)), None)
782                    .map_err(nix_to_io)
783                    .and_then(|()| fstat(raw).map_err(nix_to_io))
784                {
785                    // rollback landed: back to the prior owner at its original mode (the failed chmod
786                    // changed nothing). Report the chmod cause.
787                    Ok(st) if st.st_uid == orig_uid => return Err(chmod_err),
788                    // rollback reported success but did not take effect (non-honoring backend).
789                    Ok(st) => {
790                        return Err(std::io::Error::new(
791                            std::io::ErrorKind::PermissionDenied,
792                            format!(
793                                "reused-directory lockdown could not restrict the directory (chmod: \
794                                 {chmod_err}); the ownership rollback did not take effect (directory \
795                                 shows uid={}, expected {orig_uid}) — it is left copier-owned at its \
796                                 original mode, refusing to descend (the filesystem may not honor \
797                                 chown/chmod)",
798                                st.st_uid,
799                            ),
800                        ));
801                    }
802                    Err(rb) => {
803                        return Err(std::io::Error::new(
804                            std::io::ErrorKind::PermissionDenied,
805                            format!(
806                                "reused-directory lockdown could not restrict the directory (chmod: \
807                                 {chmod_err}) and could not roll ownership back (chown/stat: {rb}); \
808                                 it is left owned by the copier at its original mode — refusing to \
809                                 descend (the destination filesystem may be read-only or failing)"
810                            ),
811                        ));
812                    }
813                }
814            }
815            // VERIFY the takeover landed NOW — uid == copier and mode == exactly 0o700 — BEFORE the
816            // fallible gid/setgid steps below run on a possibly-unverified directory. A backend that
817            // reports a false chown/chmod success (e.g. CIFS without unix extensions) is thus caught
818            // here rather than after more operations, and the directory is never descended into while
819            // it might still be permissive.
820            let st = fstat(raw).map_err(nix_to_io)?;
821            if st.st_uid != euid || (st.st_mode & 0o7777) != 0o700 {
822                return Err(std::io::Error::new(
823                    std::io::ErrorKind::PermissionDenied,
824                    format!(
825                        "reused-directory lockdown could not be verified: after chown+chmod the \
826                         directory shows uid={} mode={:#o} (expected uid={} mode={:#o}); the \
827                         filesystem may not honor chown/chmod (e.g. CIFS without unix extensions) — \
828                         refusing to descend",
829                        st.st_uid,
830                        st.st_mode & 0o7777,
831                        euid,
832                        0o700,
833                    ),
834                ));
835            }
836            // The directory is now VERIFIED copier-owned at 0o700 (restrictive); every exit below
837            // leaves it at least that restrictive. Reset the gid if a prior owner raced a `chgrp`
838            // before the take — only when it differs, so the unchanged common case issues no gid write
839            // (never an EPERM for a non-root copier that owns the directory but is not in its group).
840            // A setgid directory's children must inherit the captured gid, not the raced one.
841            if st.st_gid != orig_gid {
842                fchown(raw, None, Some(Gid::from_raw(orig_gid))).map_err(nix_to_io)?;
843            }
844            // Re-add the setgid bit now that the gid is orig_gid (skipped when the interim mode is a
845            // plain 0o700). Doing this AFTER the gid reset means a non-privileged copier's chmod
846            // cannot silently drop S_ISGID — the kernel keeps it because the directory's gid is now
847            // one the copier is in (or the copier is root, with CAP_FSETID).
848            if mode != 0o700 {
849                fchmod(raw, Mode::from_bits_truncate(mode)).map_err(nix_to_io)?;
850            }
851            // verify the whole takeover actually landed (uid, gid, mode); fail closed otherwise.
852            let st = fstat(raw).map_err(nix_to_io)?;
853            if st.st_uid != euid || st.st_gid != orig_gid || (st.st_mode & 0o7777) != mode {
854                return Err(std::io::Error::new(
855                    std::io::ErrorKind::PermissionDenied,
856                    format!(
857                        "reused-directory lockdown could not be verified: after takeover the \
858                         directory shows uid={} gid={} mode={:#o} (expected uid={} gid={} \
859                         mode={:#o}); the filesystem may not honor chown/chmod (e.g. CIFS without \
860                         unix extensions), or the copier is not in the directory's group and lacks \
861                         CAP_FSETID to keep setgid — refusing to descend",
862                        st.st_uid,
863                        st.st_gid,
864                        st.st_mode & 0o7777,
865                        euid,
866                        orig_gid,
867                        mode,
868                    ),
869                ));
870            }
871            Ok(())
872        })
873        .await
874    }
875
876    /// Create a child directory and return an open `Dir` handle to it (same side as self).
877    ///
878    /// Fails with `EINVAL` if `name` is not a single path component, or `EEXIST` if
879    /// a directory (or any other entry) at `name` already exists.
880    ///
881    /// The `mkdirat`, the re-open, and (under strict operand resolution) the inherited-ACL strip
882    /// all run inside ONE blocking closure, gated once as `MkDir`. That is a cancellation-safety
883    /// requirement, not packaging: a queued job can be reclaimed before doing anything, while a
884    /// closure that takes it runs every step to completion, so there is NO await point at which the
885    /// directory exists but its sanitization has not happened. Splitting the steps across separate
886    /// gated calls (as an earlier version did) leaves windows a `--fail-early` sibling abort can
887    /// land in, abandoning an rcp-created directory that still carries the destination's inherited
888    /// default ACL — inert at that moment (created owner-only, `ACL_MASK` empty), but poisoning the
889    /// containment invariant for anything created under it later, including a rerun that cannot
890    /// tell such an orphan from a user's own directory. For the same reason, a directory whose open
891    /// or strip FAILS is removed (best-effort; it is empty and just created) inside the same closure
892    /// rather than left behind unsanitized.
893    ///
894    /// # Inherited ACLs, and why the strip lives here
895    ///
896    /// A directory created under a parent that carries a DEFAULT ACL inherits both an access and a
897    /// default ACL from it, and its own children then inherit in turn — so a destination tree's
898    /// inheritance policy reaches straight through the directories rcp creates itself. Under
899    /// [`strict_operand_resolution`] (`--require-toctou-safe`) that is contained by removing both
900    /// ACLs from the new directory, which stops the chain for the directory's ENTIRE subtree:
901    /// nothing created inside a stripped directory inherits anything. This is the containment half
902    /// of the `--require-toctou-safe` invariant — *no destination entry that rcp CREATES carries an
903    /// ACL entry that did not come from its source* — and stripping per DIRECTORY is what makes it
904    /// 2 syscalls per directory and none per file created beneath one ([`Self::create_file`] pays
905    /// its own single strip only in a parent rcp did NOT sanitize — the ambient parent of a direct
906    /// file operand; see `children_may_inherit`).
907    ///
908    /// The strip belongs HERE rather than at each caller because this is the only `mkdirat` in the
909    /// crate, and therefore the single creation site for both the local (`copy`/`link`) and remote
910    /// (`rcpd`) destinations — a caller-side strip is one forgotten exit path away from leaving the
911    /// hole open. The default path is untouched: it pays nothing, which is the whole point of making
912    /// containment a strict-mode concern.
913    ///
914    /// `ENODATA` and `EOPNOTSUPP` are success for the strip (see `apply_one_acl`) — a directory
915    /// with no inherited ACL, or on a filesystem that cannot hold one, already satisfies the
916    /// post-condition. Any other errno fails the create (after the best-effort removal above), and
917    /// the caller must fail closed rather than descend: an un-strippable default ACL is one every
918    /// child written afterwards would inherit.
919    ///
920    /// **A consequence, deliberately taken.** Under `--require-toctou-safe` WITHOUT `d:acl` a fresh
921    /// destination directory ends with NO ACL, even where the parent's default ACL would ordinarily
922    /// have given it one. An administrator who set a default ACL on a destination tree expecting new
923    /// subdirectories to inherit it does not get that under this flag. The flag's contract is that
924    /// the destination reflects its source and nothing else, so containment beats inheritance;
925    /// `d:acl` is the escape for a caller who wants the SOURCE's ACLs instead of none, and a caller
926    /// who wants the destination tree's inheritance policy honored should not be using this flag on
927    /// that tree.
928    pub async fn make_dir(&self, name: &OsStr, mode: u32) -> std::io::Result<Dir> {
929        if !is_single_component(name) {
930            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
931        }
932        let dir = self.fd.clone();
933        let side = self.side;
934        let name_owned = name.to_owned();
935        let strict = strict_operand_resolution();
936        let created =
937            run_metadata_probed_blocking(side, congestion::MetadataOp::MkDir, move || {
938                mkdirat(
939                    dir.as_fd(),
940                    name_owned.as_bytes(),
941                    Mode::from_bits_truncate(mode),
942                )
943                .map_err(nix_to_io)?;
944                let open_flags =
945                    OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
946                let fd = match openat(
947                    dir.as_fd(),
948                    name_owned.as_bytes(),
949                    open_flags,
950                    Mode::empty(),
951                )
952                .map_err(nix_to_io)
953                {
954                    Ok(fd) => fd,
955                    Err(err) => {
956                        // could not open what was just created: remove it best-effort. No fd
957                        // exists to anchor an inode recheck on, but the exposure is bounded —
958                        // `RemoveDir` refuses anything but an empty directory, so a swapped-in
959                        // file or symlink fails it and a swapped-in populated directory survives
960                        let _ =
961                            unlinkat(dir.as_fd(), name_owned.as_bytes(), UnlinkatFlags::RemoveDir);
962                        return Err(err);
963                    }
964                };
965                if strict {
966                    // both ACLs, the default (inheritance-carrying) one first — see the doc
967                    // comment for why this must happen inside the creation closure
968                    if let Err(err) = apply_one_acl(fd.as_raw_fd(), ACL_DEFAULT_XATTR, None)
969                        .and_then(|()| apply_one_acl(fd.as_raw_fd(), ACL_ACCESS_XATTR, None))
970                    {
971                        // clean up the destination slot rather than leave inherited ACLs behind.
972                        // rmdir can remove only an empty directory and never follows a symlink; a
973                        // prior identity observation would not bind this by-name removal
974                        let _ =
975                            unlinkat(dir.as_fd(), name_owned.as_bytes(), UnlinkatFlags::RemoveDir);
976                        return Err(err);
977                    }
978                }
979                Ok(fd)
980            })
981            .await?;
982        Ok(Dir {
983            fd: Arc::new(created),
984            side,
985            // under strict mode the closure above stripped both ACLs, so nothing created in this
986            // directory can inherit; outside strict mode inheritance is the documented default
987            // behavior and the flag is never consulted
988            children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(!strict)),
989        })
990    }
991
992    /// Enumerate the directory's entries (excluding `.` and `..`).
993    ///
994    /// Returns each entry's name and its `getdents` `d_type` as a best-effort
995    /// `EntryKind` hint (`None` when the filesystem reports `DT_UNKNOWN`). The
996    /// hint is advisory only — callers MUST confirm type via `child`/`fstat`
997    /// before acting (TOCTOU safety).
998    ///
999    /// This method acquires only the static ops rate gate (not the congestion
1000    /// probe). Directory enumeration is deliberately not probed because buffered
1001    /// `getdents` produces bimodal latency (cache hit vs. real kernel call) that
1002    /// would pollute the congestion controller's baseline — see
1003    /// `walk::next_entry_probed` for the full rationale.
1004    pub async fn read_entries(
1005        &self,
1006    ) -> std::io::Result<Vec<(std::ffi::OsString, Option<EntryKind>)>> {
1007        throttle::get_ops_token().await;
1008        let dir = self.fd.clone();
1009        run_fd_admitted_blocking(move || {
1010            // Dup the fd with FD_CLOEXEC so nix::dir::Dir can consume (and close)
1011            // it on drop without touching self's Arc<OwnedFd>. A bare dup(2)
1012            // would clear FD_CLOEXEC; F_DUPFD_CLOEXEC atomically sets it.
1013            //
1014            // Re-entrancy: the dup shares the original's open file description,
1015            // and therefore its directory read offset. Reading to EOF advances
1016            // that shared offset, so a naive `fdopendir` loop would leave self's
1017            // fd at EOF and make a *second* read_entries() on the same Dir
1018            // return an empty listing. nix's borrowing `Iter` (from
1019            // `nix_dir.iter()`) rewinds the shared description in its `Drop`
1020            // (rewinddir(3) → offset 0), and that `Drop` runs on BOTH normal
1021            // completion AND the early `?`-return taken on a mid-iteration error
1022            // — so the dup is always rewound before it is closed, leaving self's
1023            // fd at offset 0 either way. This re-entrancy is load-bearing: the
1024            // hardened remote source enumerates a directory in Pass 1 and again
1025            // in Pass 2 on the *same* `Arc<Dir>`. (Additionally every caller
1026            // treats an enumeration error as terminal and never re-enumerates the
1027            // directory, so a partially-advanced offset is never observed
1028            // regardless.)
1029            let dup_raw: RawFd =
1030                nix::fcntl::fcntl(dir.as_fd(), nix::fcntl::FcntlArg::F_DUPFD_CLOEXEC(0))
1031                    .map_err(nix_to_io)?;
1032            // SAFETY: dup_raw is a freshly-dup'd fd that we own exclusively; no
1033            // other reference to it exists.
1034            let dup_owned = unsafe { OwnedFd::from_raw_fd(dup_raw) };
1035            let mut nix_dir = nix::dir::Dir::from_fd(dup_owned).map_err(nix_to_io)?;
1036
1037            let mut entries = Vec::new();
1038            for entry_result in nix_dir.iter() {
1039                let entry = entry_result.map_err(nix_to_io)?;
1040                let name_cstr = entry.file_name();
1041                // skip "." and ".."
1042                if name_cstr == c"." || name_cstr == c".." {
1043                    continue;
1044                }
1045                let name = std::ffi::OsStr::from_bytes(name_cstr.to_bytes()).to_owned();
1046                let kind = entry.file_type().map(|t| match t {
1047                    nix::dir::Type::Directory => EntryKind::Dir,
1048                    nix::dir::Type::Symlink => EntryKind::Symlink,
1049                    nix::dir::Type::File => EntryKind::File,
1050                    _ => EntryKind::Special,
1051                });
1052                entries.push((name, kind));
1053            }
1054            // nix_dir drops here, closing the dup'd fd; self's fd is unaffected
1055            Ok(entries)
1056        })
1057        .await
1058    }
1059
1060    /// Enumerate at most `cap` entries (excluding `.` and `..`), or report the directory as
1061    /// over-cap. Returns `Ok(None)` as soon as a `cap + 1`-th entry is seen — the whole point:
1062    /// a caller that will DISCARD an over-cap listing (the overwrite-manifest build against its
1063    /// `--overwrite-manifest-max-entries` bound) must not pay for, or sit uncancellably in, a
1064    /// full enumeration of a directory arbitrarily larger than its bound. The early break leaves
1065    /// the shared read offset mid-directory; the borrowing iterator's `Drop` rewinds it exactly
1066    /// as on the full-read and error paths of [`Self::read_entries`], so re-enumeration on the
1067    /// same `Dir` stays sound.
1068    pub async fn read_entries_capped(
1069        &self,
1070        cap: usize,
1071    ) -> std::io::Result<Option<Vec<(std::ffi::OsString, Option<EntryKind>)>>> {
1072        throttle::get_ops_token().await;
1073        let dir = self.fd.clone();
1074        run_fd_admitted_blocking(move || {
1075            // dup + rewind contract: see read_entries
1076            let dup_raw: RawFd =
1077                nix::fcntl::fcntl(dir.as_fd(), nix::fcntl::FcntlArg::F_DUPFD_CLOEXEC(0))
1078                    .map_err(nix_to_io)?;
1079            // SAFETY: dup_raw is a freshly-dup'd fd that we own exclusively; no
1080            // other reference to it exists.
1081            let dup_owned = unsafe { OwnedFd::from_raw_fd(dup_raw) };
1082            let mut nix_dir = nix::dir::Dir::from_fd(dup_owned).map_err(nix_to_io)?;
1083            let mut entries = Vec::new();
1084            for entry_result in nix_dir.iter() {
1085                let entry = entry_result.map_err(nix_to_io)?;
1086                let name_cstr = entry.file_name();
1087                if name_cstr == c"." || name_cstr == c".." {
1088                    continue;
1089                }
1090                if entries.len() >= cap {
1091                    return Ok(None);
1092                }
1093                let name = std::ffi::OsStr::from_bytes(name_cstr.to_bytes()).to_owned();
1094                let kind = entry.file_type().map(|t| match t {
1095                    nix::dir::Type::Directory => EntryKind::Dir,
1096                    nix::dir::Type::Symlink => EntryKind::Symlink,
1097                    nix::dir::Type::File => EntryKind::File,
1098                    _ => EntryKind::Special,
1099                });
1100                entries.push((name, kind));
1101            }
1102            Ok(Some(entries))
1103        })
1104        .await
1105    }
1106
1107    /// Remove a child non-directory entry by name, gated on this directory's own congestion side.
1108    ///
1109    /// For a symlink, this unlinks the link itself — never its target.
1110    ///
1111    /// Fails with `EINVAL` if `name` is not a single path component, or `EISDIR`
1112    /// if `name` refers to a directory.
1113    pub async fn unlink_at(&self, name: &OsStr) -> std::io::Result<()> {
1114        self.unlink_at_on(name, self.side).await
1115    }
1116
1117    /// Like [`Self::unlink_at`], but gates the `unlinkat` on an explicitly chosen congestion
1118    /// `side` rather than the directory's own side.
1119    ///
1120    /// `rm` reads its tree on the `Source` side because its `Dir` handles are source-sided, but the
1121    /// destructive `unlinkat` is bucketed on `Destination` so it competes for the same metadata cwnd
1122    /// as other destructive work. The fd-relative TOCTOU guarantee is unaffected: the syscall is
1123    /// still resolved against this directory's pinned fd.
1124    pub(crate) async fn unlink_at_on(
1125        &self,
1126        name: &OsStr,
1127        side: congestion::Side,
1128    ) -> std::io::Result<()> {
1129        if !is_single_component(name) {
1130            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1131        }
1132        let dir = self.fd.clone();
1133        let name = name.to_owned();
1134        run_metadata_probed_blocking(side, congestion::MetadataOp::Unlink, move || {
1135            unlinkat(dir.as_fd(), name.as_bytes(), UnlinkatFlags::NoRemoveDir).map_err(nix_to_io)
1136        })
1137        .await
1138    }
1139
1140    /// Remove a child empty directory by name, gated on this directory's own congestion side.
1141    ///
1142    /// Fails with `EINVAL` if `name` is not a single path component, `ENOTEMPTY`
1143    /// if the directory is not empty, or `ENOTDIR` if `name` is not a directory.
1144    pub async fn rmdir_at(&self, name: &OsStr) -> std::io::Result<()> {
1145        self.rmdir_at_on(name, self.side).await
1146    }
1147
1148    /// Like [`Self::rmdir_at`], but gates the `rmdir` on an explicitly chosen congestion `side`
1149    /// rather than the directory's own side. See [`Self::unlink_at_on`] for why `rm` needs this
1150    /// (`Destination`-sided removal from a `Source`-sided read walk).
1151    pub(crate) async fn rmdir_at_on(
1152        &self,
1153        name: &OsStr,
1154        side: congestion::Side,
1155    ) -> std::io::Result<()> {
1156        if !is_single_component(name) {
1157            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1158        }
1159        let dir = self.fd.clone();
1160        let name = name.to_owned();
1161        run_metadata_probed_blocking(side, congestion::MetadataOp::RmDir, move || {
1162            unlinkat(dir.as_fd(), name.as_bytes(), UnlinkatFlags::RemoveDir).map_err(nix_to_io)
1163        })
1164        .await
1165    }
1166
1167    /// Create a symlink `name` → `target` in this directory, returning a
1168    /// fd-pinned `Handle` to the just-created link.
1169    ///
1170    /// The returned handle has `kind() == EntryKind::Symlink` and can be used to
1171    /// apply metadata to the link race-free. `target` is the link contents — it
1172    /// is an arbitrary path and is not restricted to a single component.
1173    ///
1174    /// Fails with `EINVAL` if `name` is not a single path component, or `EEXIST`
1175    /// if an entry at `name` already exists.
1176    pub async fn symlink_at(&self, name: &OsStr, target: &Path) -> std::io::Result<Handle> {
1177        if !is_single_component(name) {
1178            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1179        }
1180        let dir = self.fd.clone();
1181        let side = self.side;
1182        let name = name.to_owned();
1183        let target = target.to_owned();
1184        // clone `name` so we can call self.child() after the closure consumes it
1185        let name_for_child = name.clone();
1186        run_metadata_probed_blocking(side, congestion::MetadataOp::Symlink, move || {
1187            // symlinkat(target, dirfd, name): creates `name` → `target`
1188            symlinkat(target.as_os_str().as_bytes(), dir.as_fd(), name.as_bytes())
1189                .map_err(nix_to_io)
1190        })
1191        .await?;
1192        // open the just-created link with O_PATH|O_NOFOLLOW so we get a Handle
1193        // that is pinned to the symlink inode itself (not its target).
1194        let handle = self.child(&name_for_child).await?;
1195        if handle.kind() != EntryKind::Symlink {
1196            // should never happen — we just created a symlink; if it somehow
1197            // changed underneath us, report ENOENT to signal the caller.
1198            return Err(std::io::Error::from_raw_os_error(libc::ENOENT));
1199        }
1200        Ok(handle)
1201    }
1202
1203    /// Read the target of a child symlink.
1204    ///
1205    /// Fails with `EINVAL` if `name` is not a single path component, or `EINVAL`
1206    /// (from `readlinkat`) if `name` is not a symlink.
1207    pub async fn read_link_at(&self, name: &OsStr) -> std::io::Result<std::path::PathBuf> {
1208        if !is_single_component(name) {
1209            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1210        }
1211        let dir = self.fd.clone();
1212        let side = self.side;
1213        let name = name.to_owned();
1214        run_metadata_probed_blocking(side, congestion::MetadataOp::ReadLink, move || {
1215            readlinkat(dir.as_fd(), name.as_bytes())
1216                .map(std::path::PathBuf::from)
1217                .map_err(nix_to_io)
1218        })
1219        .await
1220    }
1221
1222    /// Create a hard link at `dst`/`dst_name` pointing to this directory's `name`.
1223    ///
1224    /// Uses `AtFlags::empty()` (flags=0, no `AT_SYMLINK_FOLLOW`), so if `name` is a
1225    /// symlink, the link target is the symlink inode itself — the target file
1226    /// gains no new hard link.
1227    ///
1228    /// Fails with `EINVAL` if either `name` or `dst_name` is not a single path
1229    /// component.
1230    pub async fn hard_link_at(
1231        &self,
1232        name: &OsStr,
1233        dst: &Dir,
1234        dst_name: &OsStr,
1235    ) -> std::io::Result<()> {
1236        if !is_single_component(name) {
1237            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1238        }
1239        if !is_single_component(dst_name) {
1240            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1241        }
1242        let src_dir = self.fd.clone();
1243        let dst_dir = dst.fd.clone();
1244        let side = dst.side;
1245        let name = name.to_owned();
1246        let dst_name = dst_name.to_owned();
1247        run_metadata_probed_blocking(side, congestion::MetadataOp::HardLink, move || {
1248            linkat(
1249                src_dir.as_fd(),
1250                name.as_bytes(),
1251                dst_dir.as_fd(),
1252                dst_name.as_bytes(),
1253                AtFlags::empty(),
1254            )
1255            .map_err(nix_to_io)
1256        })
1257        .await
1258    }
1259
1260    /// Create a hard link at `self`/`dst_name` pointing to the EXACT inode that
1261    /// `src_handle` pins — never re-resolving the source by name.
1262    ///
1263    /// `self` is the DESTINATION directory. The source is identified solely by
1264    /// `src_handle`'s `O_PATH` file descriptor: the link is made via
1265    /// `linkat(AT_FDCWD, "/proc/self/fd/N", dst_fd, dst_name, AT_SYMLINK_FOLLOW)`,
1266    /// where `N` is the handle's fd. `AT_SYMLINK_FOLLOW` makes `linkat` follow the
1267    /// `/proc` magic symlink to the handle's pinned inode, so the new hard link
1268    /// targets that exact inode regardless of any concurrent rename / symlink swap
1269    /// of the original directory entry.
1270    ///
1271    /// # Why /proc and not the source-name `linkat` or `AT_EMPTY_PATH`
1272    ///
1273    /// `Dir::hard_link_at` re-resolves the source by `name`, which is a TOCTOU
1274    /// window: an attacker who controls the source tree can replace `name` with a
1275    /// different inode (symlink, FIFO, another file) between classification and the
1276    /// `linkat`, so the link would target the replacement. Linking the pinned fd
1277    /// closes that window. `linkat(fd, "", .., AT_EMPTY_PATH)` would also be
1278    /// inode-exact but requires `CAP_DAC_READ_SEARCH`; the `/proc/self/fd` form does
1279    /// not, mirroring `chmod_via_proc_fd`.
1280    ///
1281    /// # Behavior
1282    ///
1283    /// - Inode-exact happy path: a stable regular-file handle links exactly as the
1284    ///   by-name path did (same inode, same content).
1285    /// - Fail-closed under attack: if the pinned inode's last directory entry was
1286    ///   removed (link count 0, e.g. the attacker renamed `name` away), the kernel
1287    ///   refuses to resurrect it and `linkat` fails with `ENOENT`. It never links a
1288    ///   swapped-in replacement.
1289    /// - Directories: `linkat` refuses to hard-link a directory (`EPERM`), exactly
1290    ///   as the by-name path did. Callers must only pass a regular-file handle.
1291    ///
1292    /// # Errors
1293    ///
1294    /// `EINVAL` if `dst_name` is not a single path component; `ENOENT` if the pinned
1295    /// inode has no remaining links (fail-closed); `EEXIST` if an entry at
1296    /// `dst_name` already exists; `EPERM` if the handle refers to a directory.
1297    /// Requires `/proc` mounted (same precondition as `chmod_via_proc_fd`).
1298    pub async fn hard_link_handle_at(
1299        &self,
1300        src_handle: &Handle,
1301        dst_name: &OsStr,
1302    ) -> std::io::Result<()> {
1303        if !is_single_component(dst_name) {
1304            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1305        }
1306        // clone the source O_PATH fd into an owned fd the blocking closure can hold,
1307        // keeping the pinned inode alive for the syscall's full duration even if the
1308        // originating Handle is dropped after the closure starts. started spawn_blocking work is
1309        // not cancellable.
1310        let src_owned = src_handle.as_fd().try_clone_to_owned()?;
1311        let dst_dir = self.fd.clone();
1312        let side = self.side;
1313        let dst_name = dst_name.to_owned();
1314        run_metadata_probed_blocking(side, congestion::MetadataOp::HardLink, move || {
1315            let proc_path = format!("/proc/self/fd/{}", src_owned.as_raw_fd());
1316            // AT_SYMLINK_FOLLOW: the /proc entry is a magic symlink that must be
1317            // dereferenced to reach the pinned inode (without the flag, linkat would
1318            // try to hard-link the magic symlink itself, which is not permitted).
1319            linkat(
1320                AT_FDCWD,
1321                proc_path.as_str(),
1322                dst_dir.as_fd(),
1323                dst_name.as_bytes(),
1324                AtFlags::AT_SYMLINK_FOLLOW,
1325            )
1326            .map_err(nix_to_io)
1327        })
1328        .await
1329    }
1330
1331    /// Create a new child file, failing if it already exists and never following a symlink.
1332    ///
1333    /// The file is ALWAYS created at [`DST_FILE_CREATE_MODE`] — there is deliberately no mode
1334    /// parameter, so a caller cannot publish a destination file at its final (possibly setuid)
1335    /// mode before its contents exist. [`set_file_metadata_fd`] widens it to the source mode after
1336    /// the last byte. Returns the open writable `File` on success; the returned fd is writable
1337    /// whatever the mode says, having been opened `O_WRONLY` at creation.
1338    ///
1339    /// `O_EXCL` is the primary guard: combined with `O_CREAT`, it fails with
1340    /// `EEXIST` on any pre-existing entry — including a symlink — without
1341    /// following it. `O_NOFOLLOW` is the fallback that would still refuse to
1342    /// follow a symlink (with `ELOOP`) should `O_EXCL` ever be bypassed.
1343    ///
1344    /// # The ambient-parent ACL strip
1345    ///
1346    /// Under [`strict_operand_resolution`] a file created in a parent rcp did NOT sanitize — the
1347    /// ambient parent of a direct file operand, the one directory kind rcp neither creates nor
1348    /// locks down (`children_may_inherit`) — has its inherited access ACL removed in the SAME
1349    /// blocking closure as the create. Without it, a parent's default ACL hands the fresh file
1350    /// named `user:`/`group:` entries; they are inert at [`DST_FILE_CREATE_MODE`] (the create mode
1351    /// leaves `ACL_MASK` empty), but the final `fchmod` to the source mode re-derives the mask
1352    /// from the group bits and ACTIVATES them — a strict copy of a plain `0640` file granting a
1353    /// named user read access its source never did. Files created beneath a sanitized directory
1354    /// pay nothing: the parent-side strip already broke the inheritance chain, which is the flag's
1355    /// whole point. A strip failure fails the create (after a best-effort unlink of the empty
1356    /// file), because the caller's later chmod would otherwise publish the inherited entries.
1357    ///
1358    /// Fails with `EINVAL` if `name` is not a single path component, or `EEXIST`
1359    /// if a file or symlink at `name` already exists.
1360    pub async fn create_file(&self, name: &OsStr) -> std::io::Result<std::fs::File> {
1361        if !is_single_component(name) {
1362            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1363        }
1364        let dir = self.fd.clone();
1365        let side = self.side;
1366        let name = name.to_owned();
1367        // the flag travels INTO the closure and is read AFTER the openat: a pre-submit snapshot
1368        // races the reused-dir rollback — a fail-early abort can restore the parent's default ACL
1369        // between this submit and the queued openat, and the file created then DOES inherit. The
1370        // rollback stores `true` before its restore syscall and this load runs after the openat;
1371        // syscalls order as full fences, so an openat that observed the restored ACL cannot then
1372        // load a stale `false`. A wasted strip on a not-yet-restored parent remains harmless.
1373        let strict = strict_operand_resolution();
1374        let may_inherit = Arc::clone(&self.children_may_inherit);
1375        run_metadata_probed_blocking(side, congestion::MetadataOp::OpenCreate, move || {
1376            let flags = OFlag::O_CREAT
1377                | OFlag::O_EXCL
1378                | OFlag::O_WRONLY
1379                | OFlag::O_NOFOLLOW
1380                | OFlag::O_CLOEXEC;
1381            let file_mode = Mode::from_bits_truncate(DST_FILE_CREATE_MODE);
1382            let file = openat(dir.as_fd(), name.as_bytes(), flags, file_mode)
1383                .map(std::fs::File::from)
1384                .map_err(nix_to_io)?;
1385            if strict && may_inherit.load(std::sync::atomic::Ordering::SeqCst) {
1386                // same-closure for the same reason as `make_dir`: queued work creates nothing,
1387                // while work that starts runs to completion, so cancellation cannot abandon a
1388                // created-but-unsanitized file. cleanup is a single unlink relative to the ambient
1389                // operand parent; it never follows a symlink or removes a directory, and a prior
1390                // identity observation would not bind the by-name removal
1391                if let Err(err) = apply_one_acl(file.as_raw_fd(), ACL_ACCESS_XATTR, None) {
1392                    let _ = unlinkat(dir.as_fd(), name.as_bytes(), UnlinkatFlags::NoRemoveDir);
1393                    return Err(err);
1394                }
1395            }
1396            Ok(file)
1397        })
1398        .await
1399    }
1400}
1401
1402// ── TrustedDir ──────────────────────────────────────────────────────────────────
1403
1404/// A directory opened by FOLLOWING symlinks normally — the command-line-named
1405/// path's trusted parent prefix.
1406///
1407/// The trusted-boundary model (docs/tocttou.md, "Trusted boundary") trusts the path named on the
1408/// command line up to and including its container directory; only entries
1409/// strictly BELOW the named root are hardened with `O_NOFOLLOW`. A `TrustedDir`
1410/// is that trusted container, and it is the only retained/exposed follow-open transition used by
1411/// ordinary callers. The private dry-run preview parent opener follows the trusted prefix
1412/// internally before its nofollow descent. Every other ordinary directory open ([`Dir::open_dir`],
1413/// [`Dir::child`], [`Dir::open_file_read`], [`Dir::create_file`],
1414/// [`Dir::make_dir`], …) is `O_NOFOLLOW`.
1415///
1416/// Because the trusted/hardened distinction is a type rather than a convention,
1417/// the compiler enforces it: a parent-prefix slot typed `TrustedDir` can only be
1418/// filled by the follow-open, and a hardened `Dir` cannot be used where a trusted
1419/// parent is required. Crossing from the trusted prefix into the hardened tree is
1420/// the single explicit [`Self::into_tree`] step.
1421///
1422/// Under strict operand resolution (`--require-toctou-safe`) the "trusted"
1423/// prefix is additionally required to be symlink-free: the open resolves it
1424/// `RESOLVE_NO_SYMLINKS`, so a symlink component fails closed with `ELOOP`
1425/// rather than being followed (see [`enable_strict_operand_resolution`]).
1426#[derive(Debug)]
1427pub struct TrustedDir(Dir);
1428
1429impl TrustedDir {
1430    /// Cross from the trusted parent prefix into the hardened tree, consuming the `TrustedDir` and
1431    /// handing back the owned hardened `Dir` (e.g. to wrap it in an `Arc` for the walk). Every open
1432    /// below the returned `Dir` is `O_NOFOLLOW`, so nothing below the named root can be redirected
1433    /// by a symlink swap. This is the one explicit trusted→hardened transition.
1434    #[must_use]
1435    pub fn into_tree(self) -> Dir {
1436        self.0
1437    }
1438}
1439
1440// ── Operand probes and contained preview opens ───────────────────────────────
1441//
1442// dry-run preview scans resolve the trusted operand parent with the active default/strict policy,
1443// then open the named operand and every scan-relative component fd-relative without following
1444// symlinks. The strict existence/kind probes below similarly decompose an operand into parent +
1445// final component: strict mode resolves the parent through `openat2(RESOLVE_NO_SYMLINKS)` and
1446// touches only the final component fd-relative, while default-mode callers retain their existing
1447// path-based probes.
1448
1449/// Split a lexically-normal absolute operand into `(parent, final_component)` for
1450/// an fd-relative probe. Strict operands are already absolute + normal (the linter
1451/// enforced it), so a plain `parent()`/`file_name()` split is correct. Returns
1452/// `None` when the path has no distinct parent+name (e.g. `/`), where there is
1453/// nothing to probe fd-relative.
1454fn split_parent_and_name(path: &Path) -> Option<(&Path, &OsStr)> {
1455    let name = path.file_name()?;
1456    let parent = match path.parent() {
1457        Some(parent) if !parent.as_os_str().is_empty() => parent,
1458        // a single-component relative path means the current directory
1459        _ => Path::new("."),
1460    };
1461    Some((parent, name))
1462}
1463
1464/// Open the trusted parent of a destination operand for a contained preview scan.
1465///
1466/// `O_PATH` is load-bearing in default mode: a searchable-but-unreadable parent may contain an
1467/// operand that is itself readable, and preview containment must not add a parent read-permission
1468/// requirement. Strict mode applies the same flag through `openat2(RESOLVE_NO_SYMLINKS)`.
1469async fn open_preview_operand_parent(path: &Path, side: congestion::Side) -> std::io::Result<Dir> {
1470    let path = path.to_owned();
1471    run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
1472        let flags = OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
1473        #[cfg(target_os = "linux")]
1474        if strict_operand_resolution() {
1475            return openat2_no_symlinks(&path, flags)
1476                .map(|fd| Dir::opened(fd, side))
1477                .map_err(nix_to_io);
1478        }
1479        openat(AT_FDCWD, &path, flags, Mode::empty())
1480            .map(|fd| Dir::opened(fd, side))
1481            .map_err(nix_to_io)
1482    })
1483    .await
1484}
1485
1486fn preview_scan_absence(error: &std::io::Error) -> bool {
1487    error.kind() == std::io::ErrorKind::NotFound
1488        || error.kind() == std::io::ErrorKind::NotADirectory
1489        || matches!(
1490            error.raw_os_error(),
1491            Some(libc::ENOTDIR) | Some(libc::ELOOP)
1492        )
1493}
1494
1495fn preview_relative_components(relative: &Path) -> std::io::Result<Vec<std::ffi::OsString>> {
1496    if relative.as_os_str().is_empty() {
1497        return Ok(Vec::new());
1498    }
1499    relative
1500        .as_os_str()
1501        .as_bytes()
1502        .split(|byte| *byte == b'/')
1503        .map(|component| {
1504            let component = OsStr::from_bytes(component);
1505            is_single_component(component)
1506                .then(|| component.to_owned())
1507                .ok_or_else(|| std::io::Error::from_raw_os_error(libc::EINVAL))
1508        })
1509        .collect()
1510}
1511
1512/// Open an existing directory below the exact destination operand named by the user.
1513///
1514/// The operand's trusted parent is resolved with the normal strict/default policy, but the named
1515/// root itself and every `relative` component are opened one at a time with `O_NOFOLLOW`. This keeps
1516/// dry-run delete scans beneath the operand even when that final operand component is a symlink.
1517/// Missing, non-directory, and symlink components mean there is no real directory to preview and
1518/// return `None`; every other error remains observable to the caller.
1519pub(crate) async fn open_existing_dir_beneath_operand(
1520    named_root: &Path,
1521    relative: &Path,
1522    side: congestion::Side,
1523) -> std::io::Result<Option<Dir>> {
1524    let relative = preview_relative_components(relative)?;
1525    let Some((parent_path, root_name)) = split_parent_and_name(named_root) else {
1526        return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1527    };
1528    if !is_single_component(root_name) {
1529        return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
1530    }
1531    let parent = match open_preview_operand_parent(parent_path, side).await {
1532        Ok(parent) => parent,
1533        Err(error) if preview_scan_absence(&error) => return Ok(None),
1534        Err(error) => return Err(error),
1535    };
1536    let mut current = match parent.open_dir(root_name).await {
1537        Ok(dir) => dir,
1538        Err(error) if preview_scan_absence(&error) => return Ok(None),
1539        Err(error) => return Err(error),
1540    };
1541    drop(parent);
1542    for component in relative {
1543        current = match current.open_dir(&component).await {
1544            Ok(dir) => dir,
1545            Err(error) if preview_scan_absence(&error) => return Ok(None),
1546            Err(error) => return Err(error),
1547        };
1548    }
1549    Ok(Some(current))
1550}
1551
1552/// Probe an operand's existence and kind fd-relative under strict operand
1553/// resolution: open its parent with `open_parent_dir` (`RESOLVE_NO_SYMLINKS`
1554/// while armed) and classify the final component via `child` (`O_NOFOLLOW`).
1555///
1556/// - `Ok(Some(kind))` — the entry exists (a final-component symlink counts as
1557///   existing, classified `Symlink`, and is never followed, matching
1558///   `symlink_metadata`).
1559/// - `Ok(None)` — the entry, or its parent, does not exist (`ENOENT`/`ENOTDIR`).
1560/// - `Err(ELOOP)` — a directory component of the operand path is a symlink; the
1561///   caller must fail closed.
1562pub async fn strict_probe_dst_kind(
1563    path: &Path,
1564    side: congestion::Side,
1565) -> std::io::Result<Option<EntryKind>> {
1566    let Some((parent, name)) = split_parent_and_name(path) else {
1567        return Ok(None);
1568    };
1569    match Dir::open_parent_dir(parent, side).await {
1570        Ok(parent) => match parent.into_tree().child(name).await {
1571            Ok(handle) => Ok(Some(handle.kind())),
1572            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1573            Err(err) => Err(err),
1574        },
1575        Err(err)
1576            if err.kind() == std::io::ErrorKind::NotFound
1577                || err.raw_os_error() == Some(libc::ENOTDIR) =>
1578        {
1579            Ok(None)
1580        }
1581        Err(err) => Err(err),
1582    }
1583}
1584
1585/// What a strict-mode reused-directory lockdown changed and finalize must put back.
1586///
1587/// # This type is an RAII guard, and that is the point
1588///
1589/// The snapshotted default ACL is the ONLY copy of those bytes in existence — the lockdown removed
1590/// them from the filesystem. If this value were ever dropped without the restore running, the
1591/// destination directory would permanently lose an ACL it had before the copy: unrecoverable, and
1592/// invisible, since nothing else reports it.
1593///
1594/// There are many ways to drop it. A `--fail-early` copy returns `Err` from the walk driver without
1595/// ever reaching `dir_post`, and aborts its in-flight siblings by dropping their `JoinSet` — so one
1596/// failed leaf can end a dozen directories' lockdowns at once. `link_dir_contents` returns early on
1597/// a failed `read_entries` with no flag needed at all. The remote destination can fail between
1598/// locking a directory and registering it, or shut down with directories still pending. Restoring at
1599/// each of those sites is the shape `CLAUDE.md` warns about — a missed exit path is the recurring
1600/// finding here, and there were six-plus of them.
1601///
1602/// So the restore lives in [`Drop`] instead, and holds on every exit path that drops the value: an
1603/// early return, a `?`, a `break`, and — the case the abort paths above actually need — a cancelled
1604/// task, since dropping a future drops its locals. [`set_reused_dir_metadata_fd`] disarms the guard
1605/// once the default ACL has been dealt with deliberately AND nothing after it can fail, which is
1606/// why the state is private: nothing outside this module can arm, disarm, or duplicate it. The
1607/// armed/disarmed distinction is an explicit enum (`DefaultAclGuard`) rather than the emptiness
1608/// of the snapshot, because a directory that HAD no default ACL still needs an armed rollback: a
1609/// partial finalize may have installed the source's, and only an armed guard removes it. `Clone`
1610/// is deliberately NOT derived — exactly one of these exists per lockdown, and a copy would
1611/// restore twice.
1612///
1613/// **The guard must exist before anything is destroyed, not after.** "Dropped on every exit path"
1614/// says nothing about the window in which the value does not exist. [`lockdown_reused_dir`] arms
1615/// the guard before removing the ACL synchronously, with no cancellation point in between.
1616///
1617/// # Exits it does NOT cover
1618///
1619/// A panic (the workspace builds `panic = "abort"`, so nothing unwinds and no destructor runs), a
1620/// signal, and `std::process::exit` — which, unlike the first two, is ordinary control flow in
1621/// shipped code. Every `process::exit` call site runs either before a copy starts (the TOCTOU
1622/// linter, config validation) or after `common::run` has dropped the tokio runtime, which drops every
1623/// task and therefore every guard; `rcpd`'s stdin watchdog follows the same lifetime rule. A call
1624/// that exits mid-copy would reintroduce the hole. Closing it for panic and signal would need the
1625/// snapshot on disk rather than in memory — out of proportion to the risk, but worth knowing rather
1626/// than assuming this is total.
1627///
1628/// # The `Drop` restore is best-effort recovery, not a guarantee
1629///
1630/// A destructor cannot await, so it issues a synchronous `fsetxattr` that bypasses the congestion
1631/// throttle every other metadata write here goes through, and it cannot return an error. A failure
1632/// is logged at `warn!` naming the directory — resolved from the held fd through `/proc/self/fd`,
1633/// one cheap syscall, because on a mass abort an operator otherwise gets N indistinguishable
1634/// warnings and no target — along with the ACL bytes to rebuild it from. A SUCCESSFUL restore is
1635/// logged at `debug!` so that "did the guard fire?" is observable rather than inferred. This is the
1636/// backstop for an aborted copy; the finalize path is where a successful one restores.
1637///
1638/// # What is deliberately NOT restored here
1639///
1640/// Only the default ACL. An aborted lockdown still leaves the directory owned by the copier at
1641/// `0o700` — that outcome is already documented in `docs/tocttou.md`, and unlike ACL loss it is
1642/// narrow, visible, and repairable: an operator sees the mode and fixes it. Nothing is destroyed.
1643/// The armed/disarmed state of a [`ReusedDirLock`]'s default-ACL rollback, shared (behind one
1644/// mutex) between the guard's `Drop` restore and every finalize write to the guarded attribute.
1645///
1646/// Two conflations this enum exists to prevent:
1647///
1648/// - **"Had no original ACL" is not "nothing to undo".** With `d:acl` on, finalize INSTALLS the
1649///   source's default ACL before later steps that can still fail; a directory that originally had
1650///   none must then get that installation REMOVED on rollback, or a failed copy leaves it carrying
1651///   an ACL the destination never had. A bare `Option<Vec<u8>>` cannot express that arm because its
1652///   `None` would have to mean both "no original ACL" and "disarmed".
1653/// - **A detached write must not undo the restore.** A finalize ACL write still queued when its
1654///   waiter is dropped is reclaimed before it starts, but one already taken by a blocking worker
1655///   cannot be cancelled and may land AFTER the guard's `Drop` has put the original back.
1656///   Serializing every guarded write and the restore through this one mutex, with a write that
1657///   observes [`Self::Disarmed`] SKIPPING, closes the race in both directions: a write mid-syscall
1658///   holds the mutex, so the restore waits and then lands over it (correct — the copy is
1659///   aborting); a write not yet started finds the guard disarmed and never lands.
1660#[derive(Debug)]
1661enum DefaultAclGuard {
1662    /// The lockdown is live. On abort, put the directory's default ACL back to `original`:
1663    /// `Some` bytes are restored; `None` means the directory had no default ACL, so whatever a
1664    /// partial finalize may have installed is REMOVED.
1665    Armed { original: Option<Vec<u8>> },
1666    /// Finalize resolved the default ACL deliberately (or the restore already ran). Nothing is
1667    /// left to undo, and any still-queued guarded write must NOT land.
1668    Disarmed,
1669}
1670
1671#[derive(Debug)]
1672pub struct ReusedDirLock {
1673    /// The directory's original `(uid, gid)`, taken from the classify handle before the takeover.
1674    pub restore_owner: (u32, u32),
1675    /// The rollback state plus the original default ACL, shared (`Arc`) with in-flight guarded
1676    /// writes so the `Drop` restore serializes against them — see `DefaultAclGuard`.
1677    state: Arc<std::sync::Mutex<DefaultAclGuard>>,
1678    /// The locked directory's fd, shared with the `Dir` it came from. An `Arc` rather than a `dup`:
1679    /// it costs no descriptor and no syscall, and it keeps the fd open for exactly as long as the
1680    /// guard can still need it, so `Drop` writes to the pinned inode rather than re-resolving a path
1681    /// an attacker may have swapped.
1682    fd: Arc<OwnedFd>,
1683    /// The locked `Dir`'s inherit flag, shared so the rollback can RE-ARM it (`store(true)` before
1684    /// the restore syscall): restoring the original default ACL makes inheritance possible again,
1685    /// and a file created in the window after the rollback must strip what it inherited — see
1686    /// `Dir::children_may_inherit` and `Dir::create_file`.
1687    children_may_inherit: Arc<std::sync::atomic::AtomicBool>,
1688}
1689
1690impl ReusedDirLock {
1691    /// The original default ACL the lockdown snapshotted (`None` when the directory had none).
1692    /// Peeks without disarming; a caller restores it via [`Self::write_default_acl_guarded`].
1693    fn original_default_acl(&self) -> Option<Vec<u8>> {
1694        match &*self.state.lock().unwrap() {
1695            DefaultAclGuard::Armed { original } => original.clone(),
1696            DefaultAclGuard::Disarmed => None,
1697        }
1698    }
1699
1700    /// Declare the default ACL deliberately resolved: the `Drop` restore becomes a no-op and any
1701    /// still-queued guarded write is refused. Only called once every finalize step that could
1702    /// still fail has succeeded.
1703    fn disarm(&self) {
1704        *self.state.lock().unwrap() = DefaultAclGuard::Disarmed;
1705    }
1706
1707    /// Install `blob` as the locked directory's DEFAULT ACL, serialized against the guard's
1708    /// rollback (see `DefaultAclGuard`): the state mutex is held across the syscall, and a write
1709    /// that finds the guard already disarmed is SKIPPED — landing it would silently undo a restore
1710    /// that already ran. Every finalize-path write to this attribute MUST go through here; an
1711    /// unguarded `set_or_remove_acl_fd` on the blocking pool is exactly the detached-write hazard
1712    /// the guard exists to close. Only ever needs to SET: after the lockdown the directory
1713    /// provably carries no default ACL, so "remove" cases have nothing to do.
1714    ///
1715    /// Gated as `MetadataOp::Chmod`, like every other single-inode permission write.
1716    async fn write_default_acl_guarded(
1717        &self,
1718        side: congestion::Side,
1719        blob: Vec<u8>,
1720    ) -> std::io::Result<()> {
1721        let state = Arc::clone(&self.state);
1722        let fd = Arc::clone(&self.fd);
1723        run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
1724            let guard = state.lock().unwrap();
1725            match &*guard {
1726                DefaultAclGuard::Armed { .. } => {
1727                    apply_one_acl(fd.as_raw_fd(), ACL_DEFAULT_XATTR, Some(&blob))
1728                }
1729                DefaultAclGuard::Disarmed => {
1730                    // this write's owner was cancelled and the dropped guard already restored;
1731                    // landing the write now would silently overwrite that restore
1732                    tracing::debug!(
1733                        "skipped a default-ACL write that lost its race against the lockdown \
1734                         guard's restore ({} bytes)",
1735                        blob.len(),
1736                    );
1737                    Ok(())
1738                }
1739            }
1740        })
1741        .await
1742    }
1743}
1744
1745impl Drop for ReusedDirLock {
1746    fn drop(&mut self) {
1747        // re-arm the inherit flag BEFORE the restore syscall (which may run detached below): once
1748        // the original default ACL is back, a file created in this directory inherits it and must
1749        // strip — `create_file` loads the flag after its openat, so this ordering (store, then
1750        // restore; openat, then load) leaves no interleaving where the openat sees the restored
1751        // ACL but the load sees `false`
1752        self.children_may_inherit
1753            .store(true, std::sync::atomic::Ordering::SeqCst);
1754        // Taking the mutex is what serializes this restore against an in-flight guarded write: a
1755        // write mid-syscall holds it, so the restore must wait and then deliberately land OVER
1756        // it. But WAITING here would block whatever thread runs this destructor — on the
1757        // cancellation paths that is an EXECUTOR thread (`--fail-early` dropping tracker state, a
1758        // watchdog dropping the operation), and the wait is bounded only by the write's
1759        // `fsetxattr`, which on dead network storage is unbounded. So: uncontended (the ordinary
1760        // case — no write in flight) restores inline; contended detaches the SAME serialized
1761        // wait-then-restore to its own thread. The `Arc`s keep the state and fd alive for it, and
1762        // process exit may abandon the thread exactly as it abandons the stalled write itself.
1763        let state = match self.state.try_lock() {
1764            Ok(guard) => guard,
1765            Err(std::sync::TryLockError::WouldBlock) => {
1766                let state = Arc::clone(&self.state);
1767                let fd = Arc::clone(&self.fd);
1768                let flag_note = "detached (a guarded write held the rollback mutex)";
1769                std::thread::spawn(move || {
1770                    // a poisoned mutex is unreachable under panic = "abort"; fail toward doing
1771                    // the restore anyway rather than silently dropping it
1772                    let guard = state
1773                        .lock()
1774                        .unwrap_or_else(|poisoned| poisoned.into_inner());
1775                    rollback_default_acl(guard, &fd, flag_note);
1776                });
1777                return;
1778            }
1779            // unreachable under panic = "abort"; restore anyway rather than skip
1780            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
1781        };
1782        rollback_default_acl(state, &self.fd, "inline");
1783    }
1784}
1785
1786/// The rollback itself, run with the guard's mutex HELD (see `DefaultAclGuard`): flips the state
1787/// to `Disarmed` and puts the directory's original default ACL back (or removes what a partial
1788/// finalize installed, when there was none). Split from `ReusedDirLock::drop` so the contended
1789/// path can run it on a detached thread with the same serialization.
1790fn rollback_default_acl(
1791    mut state: std::sync::MutexGuard<'_, DefaultAclGuard>,
1792    fd: &OwnedFd,
1793    how: &str,
1794) {
1795    {
1796        let DefaultAclGuard::Armed { original } =
1797            std::mem::replace(&mut *state, DefaultAclGuard::Disarmed)
1798        else {
1799            return;
1800        };
1801        let raw = fd.as_raw_fd();
1802        let _ = how;
1803        // name the directory rather than the descriptor: a mass abort restores many at once, and
1804        // "fd 47" tells an operator nothing about which tree to repair. Best-effort — a fd whose
1805        // target is gone falls back to the raw number rather than failing the restore.
1806        let path = std::fs::read_link(format!("/proc/self/fd/{raw}"))
1807            .map_or_else(|_| format!("<fd {raw}>"), |p| p.display().to_string());
1808        let restore = apply_one_acl(raw, ACL_DEFAULT_XATTR, original.as_deref());
1809        match (restore, &original) {
1810            (Ok(()), Some(blob)) => tracing::debug!(
1811                "restored the default ACL of reused destination directory {} after an aborted \
1812                 copy ({} bytes)",
1813                path,
1814                blob.len(),
1815            ),
1816            (Ok(()), None) => tracing::debug!(
1817                "ensured reused destination directory {} carries no default ACL after an aborted \
1818                 copy (it had none before it)",
1819                path,
1820            ),
1821            (Err(error), Some(blob)) => tracing::warn!(
1822                "could not restore the default ACL of reused destination directory {} after an \
1823                 aborted copy: {:#}. It is left with NO default ACL; it had {} bytes ({:02x?}), \
1824                 which must be re-applied by hand to restore what entries created beneath it \
1825                 inherit.",
1826                path,
1827                &error,
1828                blob.len(),
1829                blob,
1830            ),
1831            (Err(error), None) => tracing::warn!(
1832                "could not remove a partially-applied default ACL from reused destination \
1833                 directory {} after an aborted copy: {:#}. The directory had NO default ACL \
1834                 before the copy; whatever the aborted finalize installed remains and must be \
1835                 removed by hand.",
1836                path,
1837                &error,
1838            ),
1839        }
1840    }
1841}
1842
1843/// Lock down a REUSED destination directory under strict operand resolution, returning what finalize
1844/// must restore — `Some` when locked, `None` (a pure no-op) when strict mode is off.
1845///
1846/// `dir` is the freshly `open_dir`'d handle to the reused directory; `handle` is the `O_PATH`
1847/// classify [`Handle`] taken for the same entry BEFORE the open (still held, pinning the classified
1848/// inode). While strict operand resolution is armed the lockdown:
1849///
1850/// 1. captures `handle`'s original `(uid, gid)` as the restore owner;
1851/// 2. [`Dir::verify_same_inode`] — confirms `dir` is still that exact inode, failing closed
1852///    (`ESTALE`) on a classify→open swap;
1853/// 3. [`Dir::secure_as_copier`] — takes ownership as the copier then restricts to `0o700`, so no
1854///    child is written while the directory is world-readable/writable and the prior owner cannot
1855///    re-widen it mid-copy;
1856/// 4. [`read_acls_fd`] — snapshots the DEFAULT ACL, and
1857/// 5. removes it, so nothing created during the copy inherits it.
1858///
1859/// Steps 4 and 5 both run AFTER the takeover, and both for the same reason: only an owner (or a
1860/// `CAP_FOWNER` copier) may write these attributes, so a strip issued before step 3 would leave the
1861/// prior owner free to put the default ACL straight back, and a snapshot read before it could be
1862/// raced by that same owner. `chmod` does not touch a default ACL (verified), so nothing about the
1863/// recorded value depends on reading it first.
1864///
1865/// # Why only the DEFAULT ACL
1866///
1867/// The *access* ACL is deliberately left alone, and this is a narrowing of what an earlier version
1868/// did. It buys no containment: step 3's `chmod` rewrites `ACL_MASK` from the new group bits, so at
1869/// `0o700` the mask is `---` and every named `user:`/`group:` entry — and the owning group — grants
1870/// nothing, while `OTHER` is `---` too. Only the copier, who now owns the directory, has any access
1871/// at all (verified). And it needs no restoring, because finalize resolves it either way: with
1872/// `d:acl` the source's ACL is set or cleared over it, and without `d:acl` the finalize `chmod`
1873/// re-derives its mask from the source's mode — which is exactly what an unhardened copy's single
1874/// `chmod` does to a pre-existing ACL, so the end state is identical either way.
1875///
1876/// Leaving it alone therefore costs nothing and means an aborted copy can destroy at most the
1877/// default ACL rather than both. (A freshly CREATED directory is the opposite case and still strips
1878/// both — see [`Dir::make_dir`]: what it inherited is not its own, and with `d:acl` off nothing at
1879/// finalize would remove an inherited access ACL, so the finalize `chmod` would make it effective.)
1880///
1881/// KNOWN, PRE-EXISTING: two source operands whose destinations alias the same directory lock it
1882/// twice, and the remote tracker's `pending_directories.insert` would drop the first guard mid-copy
1883/// (restoring the ACL while the second lockdown is still live). `--require-toctou-safe` refuses
1884/// byte-equal duplicate destinations up front and serializes strict multi-source copies, which
1885/// covers the reachable cases; filesystem-level aliasing (casefold, bind mount) is not detected.
1886///
1887/// Everything the lockdown changed is put back at finalize by [`set_reused_dir_metadata_fd`] (the
1888/// owner component-wise, so no transient window hands the directory back to a hostile prior owner)
1889/// along with the source mode, so a successful copy leaves the directory byte-identical to a copy
1890/// without this hardening. A recheck mismatch, an `fchown`/`fchmod` `EPERM` (non-owner,
1891/// non-privileged copier), or an ACL snapshot/strip that fails propagates as `Err`; callers fail
1892/// closed and skip descent, so no child is written into an unsecured directory. Failing closed on
1893/// the snapshot is why it is read before the strip: removing an ACL we could not record would
1894/// permanently destroy the destination directory's own. This is the single lockdown used by BOTH the
1895/// local (`copy`/`link`) and remote (`rcpd`) reuse sites.
1896pub async fn lockdown_reused_dir(
1897    dir: &Dir,
1898    handle: &Handle,
1899) -> std::io::Result<Option<ReusedDirLock>> {
1900    if !strict_operand_resolution() {
1901        return Ok(None);
1902    }
1903    use crate::preserve::Metadata as _;
1904    use std::os::unix::fs::PermissionsExt as _;
1905    let orig_meta = handle.meta();
1906    let restore_owner = (orig_meta.uid(), orig_meta.gid());
1907    dir.verify_same_inode(handle).await?;
1908    // Restrict to 0o700 (owner-only) for the copy's duration, PRESERVING the setgid bit if the reused
1909    // directory had it (0o700 already denies all group/other access, so keeping setgid costs nothing
1910    // security-wise). Unlike the source mode applied at finalize, this INTERIM setgid bit governs the
1911    // group that every child created during the copy inherits — and finalization cannot repair those
1912    // children's GIDs under `preserve_none`. So a setgid directory must be locked down with BOTH its
1913    // gid value and its S_ISGID bit intact: `secure_as_copier` (given the classified gid) resets the
1914    // gid if a prior owner raced a `chgrp`, and re-stats to fail closed if `chmod` cleared S_ISGID (a
1915    // non-privileged copier not in the directory's group, lacking `CAP_FSETID`) or if the filesystem
1916    // did not honor the takeover at all. Root has `CAP_FSETID`, keeps the bit, and never trips this.
1917    let want_setgid = orig_meta.permissions().mode() & 0o2000 != 0;
1918    let interim_mode = if want_setgid { 0o2700 } else { 0o700 };
1919    dir.secure_as_copier(interim_mode, orig_meta.uid(), orig_meta.gid())
1920        .await?;
1921    // snapshot the default ACL, now that the directory is ours and the prior owner can no longer
1922    // race the read (step 4). Mode-neutral. `read_acls_fd` also reads the access ACL when one is
1923    // present, which is one wasted `fgetxattr` on a reused directory that has one — not worth a
1924    // second read path for. Nothing is destroyed yet, so a cancellation here loses nothing.
1925    let original = read_acls_fd(dir.fd.as_fd(), dir.side, true).await?.default;
1926    let needs_strip = original.is_some();
1927    // ARM THE GUARD BEFORE DESTROYING ANYTHING (step 5). The snapshot is the only copy of these
1928    // bytes once the removal lands, so it must already be inside a value whose `Drop` puts it back
1929    // before the removal is issued — otherwise a task cancelled between the two loses it as a bare
1930    // `Option<Vec<u8>>` with no destructor. Armed even when the directory has no default ACL: the
1931    // rollback then means "keep it that way" — removing whatever a partial finalize installs.
1932    let lock = ReusedDirLock {
1933        restore_owner,
1934        state: Arc::new(std::sync::Mutex::new(DefaultAclGuard::Armed { original })),
1935        fd: Arc::clone(&dir.fd),
1936        children_may_inherit: Arc::clone(&dir.children_may_inherit),
1937    };
1938    if needs_strip {
1939        let raw = dir.fd.as_raw_fd();
1940        // The removal is issued SYNCHRONOUSLY, inside the gate rather than on the blocking pool,
1941        // and this is load-bearing rather than an optimization. the shared blocking boundary can
1942        // reclaim user work before a worker takes it, but a closure cannot be stopped after that
1943        // handoff, so an `.await` on it is a cancellation point at which the `fremovexattr` may
1944        // still run — the exact hazard the arming above closes. Running it in-line means there is
1945        // no cancellation point between arming and destroying at all: polling this future runs the
1946        // syscall to completion.
1947        //
1948        // Arming first while KEEPING `spawn_blocking` would be strictly worse than either: a pool
1949        // thread's `fremovexattr` could land after this task's `Drop` restore and silently undo it.
1950        //
1951        // The cost is one `fremovexattr` (microseconds) on an async worker, the same call `Drop`
1952        // already makes, and only for a reused directory that actually has a default ACL. The gate
1953        // still applies — `run_metadata_probed` takes the ops token and cwnd permit and feeds the
1954        // latency probe exactly as `set_or_remove_acl_fd` would.
1955        crate::walk::run_metadata_probed(dir.side, congestion::MetadataOp::Chmod, async {
1956            apply_one_acl(raw, ACL_DEFAULT_XATTR, None)
1957        })
1958        .await?;
1959        // NB: on `Err` the guard is still armed and `Drop` re-writes the snapshot. `fremovexattr`
1960        // is atomic, so the ACL is either gone (the restore puts it back) or never left (the
1961        // restore is a no-op) — never a partial state.
1962    }
1963    // the directory now provably has no default ACL (either it never had one, or the strip above
1964    // succeeded), so files created in it during the copy cannot inherit — `create_file` need not
1965    // pay the ambient-parent strip here
1966    dir.mark_children_cannot_inherit();
1967    Ok(Some(lock))
1968}
1969
1970// ── POSIX ACLs ──────────────────────────────────────────────────────────────────
1971//
1972// POSIX.1e ACLs live in two extended attributes and are read and written here as
1973// OPAQUE kernel blobs: `system.posix_acl_access` (on any inode) and
1974// `system.posix_acl_default` (directories only, governing what children inherit).
1975// Using the raw xattrs rather than libacl keeps this to plain fd syscalls with no
1976// new dependency, and the on-disk format is defined little-endian, so the bytes
1977// are portable verbatim. The blobs are never interpreted: rcp carries what the
1978// source kernel produced and lets the destination kernel validate it on
1979// `fsetxattr`.
1980
1981/// The access ACL: the entry's own permissions. Present on any inode kind.
1982const ACL_ACCESS_XATTR: &CStr = c"system.posix_acl_access";
1983
1984/// The default ACL: what a directory's freshly created CHILDREN inherit. Directories only, and
1985/// mode-neutral — setting or clearing it never moves the directory's own mode.
1986const ACL_DEFAULT_XATTR: &CStr = c"system.posix_acl_default";
1987
1988/// A source entry's POSIX.1e ACLs, as opaque kernel bytes.
1989///
1990/// `None` means the source has no such ACL — which a destination applier reproduces by REMOVING
1991/// the attribute, not by leaving it alone. A destination directory's default ACL is inherited by
1992/// every child created beneath it, so "do nothing when the source has no ACL" hands the copy
1993/// permissions the source never granted. See [`apply_acls_fd`].
1994#[derive(Clone, Debug, Default, PartialEq, Eq)]
1995pub struct Acls {
1996    /// `system.posix_acl_access`.
1997    pub access: Option<Vec<u8>>,
1998    /// `system.posix_acl_default`; only ever read or applied for a directory.
1999    pub default: Option<Vec<u8>>,
2000}
2001
2002/// Read an entry's POSIX ACLs through an fd it is already open on.
2003///
2004/// `want_default` is `true` for directories only — files have no default ACL, so asking for one
2005/// would spend a syscall that can only miss.
2006///
2007/// `flistxattr` runs FIRST and an `fgetxattr` is issued only for a name actually present. There is
2008/// no bit in `stat` saying an entry has an ACL, so this probe is what ACL preservation costs per
2009/// entry, and the shape is chosen to make the common case cheap: measured on ext4, a `getxattr`
2010/// that misses costs ~1057ns against ~591ns for the whole `listxattr`, and missing is the
2011/// overwhelming majority. Each syscall is gated through the module's `run_metadata_probed_blocking`
2012/// as `MetadataOp::Stat`, like every other metadata read here — an ACL probe that bypassed the
2013/// throttle would be the one metadata operation in rcp that does.
2014///
2015/// A filesystem that cannot hold ACLs (`EOPNOTSUPP`) and an entry that simply has none (`ENODATA`)
2016/// both yield `None`: neither has an ACL to preserve.
2017pub async fn read_acls_fd(
2018    fd: BorrowedFd<'_>,
2019    side: congestion::Side,
2020    want_default: bool,
2021) -> std::io::Result<Acls> {
2022    // dup once and share the owned fd across the (up to three) gated syscalls: each runs in its own
2023    // `spawn_blocking` closure, which must own what it touches to be 'static.
2024    let owned = Arc::new(fd.try_clone_to_owned()?);
2025    let names = {
2026        let owned = Arc::clone(&owned);
2027        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
2028            flistxattr_names(owned.as_raw_fd())
2029        })
2030        .await?
2031    };
2032    let mut acls = Acls::default();
2033    if names_contain(&names, ACL_ACCESS_XATTR) {
2034        let owned = Arc::clone(&owned);
2035        acls.access = run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
2036            fgetxattr_blob(owned.as_raw_fd(), ACL_ACCESS_XATTR)
2037        })
2038        .await?;
2039    }
2040    if want_default && names_contain(&names, ACL_DEFAULT_XATTR) {
2041        acls.default =
2042            run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
2043                fgetxattr_blob(owned.as_raw_fd(), ACL_DEFAULT_XATTR)
2044            })
2045            .await?;
2046    }
2047    Ok(acls)
2048}
2049
2050/// Apply `acls` to a destination entry through an fd it is already open on, SETTING each attribute
2051/// the source had and REMOVING each one it did not.
2052///
2053/// Clearing is not an optimization to skip: a destination directory's default ACL is inherited by
2054/// every child created beneath it, including children rcp itself creates, so a source with no ACL
2055/// needs an explicit removal or the copy silently ends up more permissive than what it copied.
2056///
2057/// The access ACL is applied LAST because it is the step that WIDENS the destination — see
2058/// [`set_file_metadata_fd`]. The default ACL is mode-neutral, so it goes first and constrains
2059/// nothing.
2060///
2061/// Gated as `MetadataOp::Chmod`, the bucket for single-inode permission writes.
2062pub async fn apply_acls_fd(
2063    fd: BorrowedFd<'_>,
2064    side: congestion::Side,
2065    acls: &Acls,
2066    want_default: bool,
2067) -> std::io::Result<()> {
2068    if want_default {
2069        set_or_remove_acl_fd(fd, side, ACL_DEFAULT_XATTR, acls.default.clone()).await?;
2070    }
2071    set_or_remove_acl_fd(fd, side, ACL_ACCESS_XATTR, acls.access.clone()).await
2072}
2073
2074/// Install `blob` as `name` on `fd`, or REMOVE `name` when `blob` is `None`, through the module's
2075/// metadata gate.
2076///
2077/// The async half of `apply_one_acl`, at single-attribute granularity: [`apply_acls_fd`] (its
2078/// one caller) resolves the two ACLs one at a time through it. A LOCKED reused directory's
2079/// default ACL must NOT come through here — that write has to serialize against the lockdown
2080/// guard's rollback; `ReusedDirLock::write_default_acl_guarded` is the only correct writer.
2081///
2082/// Gated as `MetadataOp::Chmod`, the bucket for single-inode permission writes.
2083async fn set_or_remove_acl_fd(
2084    fd: BorrowedFd<'_>,
2085    side: congestion::Side,
2086    name: &'static CStr,
2087    blob: Option<Vec<u8>>,
2088) -> std::io::Result<()> {
2089    let owned = fd.try_clone_to_owned()?;
2090    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
2091        apply_one_acl(owned.as_raw_fd(), name, blob.as_deref())
2092    })
2093    .await
2094}
2095
2096/// Resolve the ACL payload an applier should install: `None` when ACL preservation is off for this
2097/// entry kind, `Some(acls)` when it is on and the caller carried the source's ACLs.
2098///
2099/// The third combination — preservation on, but the call site did not read the source's ACLs — is
2100/// an ERROR rather than either silent alternative. Treating it as "leave the destination alone"
2101/// keeps whatever the destination inherited, and treating it as an empty [`Acls`] CLEARS what the
2102/// source had; both produce a destination whose ACLs did not come from its source, which is exactly
2103/// the quiet lie ACL preservation exists to remove. Consolidated here so a reader verifies it once
2104/// rather than at each applier.
2105///
2106/// No caller reaches that arm today: the local copy reads the source's ACLs from the fd it is about
2107/// to copy, and the remote destination gets them from the wire header. It is kept as a fail-closed
2108/// backstop for a future applier that forgets to — which would otherwise be silent.
2109fn acls_to_apply(want: bool, acls: Option<&Acls>) -> std::io::Result<Option<&Acls>> {
2110    match (want, acls) {
2111        (false, _) => Ok(None),
2112        (true, Some(acls)) => Ok(Some(acls)),
2113        (true, None) => Err(std::io::Error::new(
2114            std::io::ErrorKind::Unsupported,
2115            "ACL preservation was requested but this copy path does not carry the source's ACLs",
2116        )),
2117    }
2118}
2119
2120/// The `listxattr` family's two-step protocol, shared by the fd and path forms: try a stack
2121/// buffer, and only on `ERANGE` ask the kernel for the current size and retry on the heap.
2122///
2123/// `call(buf, len)` issues one syscall; `call(null, 0)` asks for the size without writing.
2124///
2125/// An `EOPNOTSUPP` filesystem yields an empty list: it holds no xattrs, so it holds no ACL either.
2126fn listxattr_retry(
2127    mut call: impl FnMut(*mut libc::c_char, usize) -> isize,
2128) -> std::io::Result<Vec<u8>> {
2129    // a name list is well under 256 bytes even with `security.selinux` and a couple of user
2130    // attributes present; the ERANGE path below covers anything larger.
2131    let mut buf = [0u8; 256];
2132    let n = call(buf.as_mut_ptr().cast(), buf.len());
2133    if n >= 0 {
2134        return Ok(buf[..n as usize].to_vec());
2135    }
2136    let err = std::io::Error::last_os_error();
2137    match err.raw_os_error() {
2138        Some(libc::EOPNOTSUPP) => Ok(Vec::new()),
2139        Some(libc::ERANGE) => {
2140            let size = call(std::ptr::null_mut(), 0);
2141            if size < 0 {
2142                return Err(std::io::Error::last_os_error());
2143            }
2144            let mut heap = vec![0u8; size as usize];
2145            if heap.is_empty() {
2146                return Ok(heap);
2147            }
2148            let n = call(heap.as_mut_ptr().cast(), heap.len());
2149            if n < 0 {
2150                // a concurrent writer grew the list between the two calls; report it rather than
2151                // looping against a live competing writer.
2152                return Err(std::io::Error::last_os_error());
2153            }
2154            heap.truncate(n as usize);
2155            Ok(heap)
2156        }
2157        _ => Err(err),
2158    }
2159}
2160
2161/// `flistxattr`, returning the raw NUL-separated name list the kernel produced.
2162fn flistxattr_names(fd: RawFd) -> std::io::Result<Vec<u8>> {
2163    // SAFETY: `fd` is a valid open descriptor for the duration of the call and the kernel writes
2164    // at most `len` bytes into `buf` (or nothing at all when `buf` is null and `len` is 0).
2165    listxattr_retry(|buf, len| unsafe { libc::flistxattr(fd, buf, len) })
2166}
2167
2168/// `listxattr` on a PATH, following symlinks — used only for the `/proc/self/fd/N` magic symlink,
2169/// which must be dereferenced to reach the inode an `O_PATH` handle pins.
2170///
2171/// See [`has_acl_via_proc_fd`] for why the probe cannot go through the handle's fd directly.
2172fn listxattr_names_at_path(path: &CStr) -> std::io::Result<Vec<u8>> {
2173    // SAFETY: `path` is a NUL-terminated C string that outlives the call and the kernel writes at
2174    // most `len` bytes into `buf` (or nothing at all when `buf` is null and `len` is 0).
2175    // the only caller passes /proc/self/fd/N for an fd it already holds, so this resolves to that
2176    // PINNED inode: inode-exact, not a by-name lookup a swap could redirect.
2177    let probe = |buf, len| {
2178        unsafe { libc::listxattr(path.as_ptr(), buf, len) } // rcp-toctou-allow: /proc/self/fd magic symlink to a held fd
2179    };
2180    listxattr_retry(probe)
2181}
2182
2183/// Whether the NUL-separated `list` produced by [`flistxattr_names`] contains `name`.
2184fn names_contain(list: &[u8], name: &CStr) -> bool {
2185    list.split(|byte| *byte == 0).any(|n| n == name.to_bytes())
2186}
2187
2188/// `fgetxattr` one attribute, retrying on a larger buffer if the stack one did not fit.
2189///
2190/// `Ok(None)` for both "the entry has no such attribute" (`ENODATA`) and "this filesystem cannot
2191/// hold one" (`EOPNOTSUPP`) — neither has an ACL to preserve.
2192fn fgetxattr_blob(fd: RawFd, name: &CStr) -> std::io::Result<Option<Vec<u8>>> {
2193    // a POSIX.1e ACL blob is `4 + 8n` bytes, so 512 holds 63 entries — every realistic ACL in a
2194    // single syscall.
2195    let mut buf = [0u8; 512];
2196    // SAFETY: `fd` is a valid open descriptor for the duration of the call, `name` is a
2197    // NUL-terminated C string, and the kernel writes at most `buf.len()` bytes into `buf`.
2198    let n = unsafe { libc::fgetxattr(fd, name.as_ptr(), buf.as_mut_ptr().cast(), buf.len()) };
2199    if n >= 0 {
2200        return Ok(Some(buf[..n as usize].to_vec()));
2201    }
2202    let err = std::io::Error::last_os_error();
2203    match err.raw_os_error() {
2204        Some(libc::ENODATA | libc::EOPNOTSUPP) => Ok(None),
2205        Some(libc::ERANGE) => {
2206            // SAFETY: as above; a null buffer with size 0 asks the kernel for the current size
2207            // without writing anything.
2208            let size = unsafe { libc::fgetxattr(fd, name.as_ptr(), std::ptr::null_mut(), 0) };
2209            if size < 0 {
2210                return Err(std::io::Error::last_os_error());
2211            }
2212            let mut heap = vec![0u8; size as usize];
2213            if heap.is_empty() {
2214                return Ok(Some(heap));
2215            }
2216            // SAFETY: as above; `heap` has `len()` writable bytes.
2217            let n =
2218                unsafe { libc::fgetxattr(fd, name.as_ptr(), heap.as_mut_ptr().cast(), heap.len()) };
2219            if n < 0 {
2220                // a concurrent writer grew the attribute between the two calls; report it rather
2221                // than looping against a live competing writer.
2222                return Err(std::io::Error::last_os_error());
2223            }
2224            heap.truncate(n as usize);
2225            Ok(Some(heap))
2226        }
2227        _ => Err(err),
2228    }
2229}
2230
2231/// Install `blob` as `name`, or REMOVE `name` when `blob` is `None`.
2232///
2233/// The error handling is asymmetric, and the asymmetry is the point:
2234///
2235/// - A failing SET fails the entry, `EOPNOTSUPP` included. A destination that cannot hold the
2236///   source's ACL ends up MORE PERMISSIVE than its source — a named entry narrower than `other`
2237///   acts as a deny in effect, so dropping it grants exactly what the source withheld. Dropping it
2238///   quietly is the bug this feature exists to remove; a destination filesystem without ACL support
2239///   is a failure, not a shrug.
2240/// - A failing REMOVE with `ENODATA` or `EOPNOTSUPP` is SUCCESS. Both say there is no ACL on the
2241///   destination to clear, so nothing can have widened — the post-condition the caller asked for
2242///   already holds.
2243fn apply_one_acl(fd: RawFd, name: &CStr, blob: Option<&[u8]>) -> std::io::Result<()> {
2244    let Some(blob) = blob else {
2245        // SAFETY: `fd` is a valid open descriptor for the duration of the call and `name` is a
2246        // NUL-terminated C string.
2247        if unsafe { libc::fremovexattr(fd, name.as_ptr()) } == 0 {
2248            return Ok(());
2249        }
2250        let err = std::io::Error::last_os_error();
2251        return match err.raw_os_error() {
2252            Some(libc::ENODATA | libc::EOPNOTSUPP) => Ok(()),
2253            _ => Err(err),
2254        };
2255    };
2256    // SAFETY: as above; `blob` points at `blob.len()` readable bytes. Flags 0 means "create or
2257    // replace", which is what preserving a source's ACL onto a fresh destination needs.
2258    let rc = unsafe {
2259        libc::fsetxattr(
2260            fd,
2261            name.as_ptr(),
2262            blob.as_ptr().cast(),
2263            blob.len(),
2264            0, // neither XATTR_CREATE nor XATTR_REPLACE: set it whatever the destination holds
2265        )
2266    };
2267    if rc == 0 {
2268        Ok(())
2269    } else {
2270        Err(std::io::Error::last_os_error())
2271    }
2272}
2273
2274// ── The source-root ACL warning ─────────────────────────────────────────────────
2275//
2276// `all` does not preserve ACLs, and a user who asked for it and does not know
2277// that gets a silently wider destination. Telling them costs one `listxattr` on
2278// the SOURCE ROOT per run — a constant, not a per-entry probe, so it is
2279// affordable on the very path whose whole point is that it pays nothing per
2280// entry.
2281//
2282// armed by ASKING (`RootAclNotice`), not by an ACL merely existing: a run left at
2283// the shipped default already drops uid, gid, timestamps and the special mode bits
2284// without a word, so it is not the place to be loud about one more attribute it
2285// also does not carry — and it pays nothing to find that out.
2286//
2287// It is a HEURISTIC and must be read as one: a root without an ACL says nothing
2288// about its children. The alternative — probing enough entries to be sure — IS
2289// the per-entry cost that made `acl` opt-in in the first place.
2290
2291/// Guards the probe so it runs at most once per process, no matter how many source operands (or
2292/// `--dereference` recursions into `copy`) reach a call site.
2293static ROOT_ACL_PROBED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2294
2295/// What a run's settings say about the source-root ACL notice: whether it is wanted at all, and
2296/// which kinds are already covered.
2297///
2298/// The single home of the rule, so the two entry points below, the cheap pre-check a caller uses to
2299/// skip work, and the remote source all decide the same way.
2300#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2301pub struct RootAclNotice {
2302    /// Whether this run asked for the kind of fidelity a dropped ACL would undermine, normally
2303    /// [`crate::preserve::Settings::requests_preservation`].
2304    ///
2305    /// `false` silences the notice outright — probe included, so a run that did not ask pays
2306    /// nothing at all. A copy left at the shipped default already drops uid, gid, timestamps and
2307    /// the setuid/setgid/sticky bits without a word; singling ACLs out there would be advice about
2308    /// permission completeness nobody claimed. It is also independent of
2309    /// [`strict_operand_resolution`], which arms the notice on its own: `--require-toctou-safe` is
2310    /// a request about permissions whatever the preserve settings say, and it gets its own wording.
2311    pub wanted: bool,
2312    /// Whether a FILE root's ACL is already safe — normally the `acl` preserve setting for files,
2313    /// but `true` also covers a kind whose ACL cannot be lost by construction (`rlink` passes it
2314    /// for files without `--update`, which share the source inode through the hard link).
2315    pub file_acl_preserved: bool,
2316    /// Whether a DIRECTORY root's ACLs (access and default) are already safe.
2317    pub dir_acl_preserved: bool,
2318}
2319
2320impl RootAclNotice {
2321    /// The notice a copy governed by `preserve` wants.
2322    #[must_use]
2323    pub fn for_preserve(preserve: &crate::preserve::Settings) -> Self {
2324        Self {
2325            wanted: preserve.requests_preservation(),
2326            file_acl_preserved: preserve.file.acl,
2327            dir_acl_preserved: preserve.dir.acl,
2328        }
2329    }
2330
2331    /// Whether a root of `kind` could produce a notice: the run wants one and that kind's ACLs are
2332    /// not already being carried across.
2333    ///
2334    /// Symlinks and special files answer `false` without consulting anything — the kernel has no
2335    /// symlink ACL (the settings parser rejects `l:acl`), and a special file is not copied as an
2336    /// object whose permissions rcp reproduces.
2337    ///
2338    /// Reads the process-global strict flag, so it must not be called before
2339    /// [`enable_strict_operand_resolution`] has run — every call site is well past the linter that
2340    /// sets it.
2341    fn could_warn_for(self, kind: EntryKind) -> bool {
2342        self.could_warn_for_strict(kind, strict_operand_resolution())
2343    }
2344
2345    /// [`could_warn_for`](Self::could_warn_for) with the strict flag passed in rather than read, so
2346    /// the rule itself can be pinned without a process-global to arrange.
2347    fn could_warn_for_strict(self, kind: EntryKind, strict: bool) -> bool {
2348        (self.wanted || strict)
2349            && match kind {
2350                EntryKind::File => !self.file_acl_preserved,
2351                EntryKind::Dir => !self.dir_acl_preserved,
2352                EntryKind::Symlink | EntryKind::Special => false,
2353            }
2354    }
2355
2356    /// Whether ANY root could produce a notice — [`could_warn_for`](Self::could_warn_for) with the
2357    /// root's kind not yet known, which is the only thing a caller holding just a path can ask.
2358    fn could_warn(self) -> bool {
2359        self.could_warn_for(EntryKind::File) || self.could_warn_for(EntryKind::Dir)
2360    }
2361}
2362
2363/// Whether the inode an `O_PATH` [`Handle`] pins carries either POSIX ACL, probed through its
2364/// `/proc/self/fd/N` magic symlink.
2365///
2366/// The `/proc` detour is not decoration: `flistxattr` on an `O_PATH` descriptor fails with `EBADF`,
2367/// so the classify handle — the one fd every engine already holds for its root, whatever the entry
2368/// kind — cannot answer the probe directly. Resolving the magic symlink reaches the pinned inode
2369/// while staying inode-exact, exactly as [`chmod_via_proc_fd`] and [`stat_meta_via_proc_fd`] do,
2370/// and it needs neither read permission on the entry nor a second `openat`. Requires `/proc`
2371/// mounted (same precondition as [`chmod_via_proc_fd`]); a caller treats failure as "don't know"
2372/// rather than as an error, since this only ever produces a warning.
2373///
2374/// Gated as `MetadataOp::Stat` like every other metadata read here.
2375async fn has_acl_via_proc_fd(handle: &Handle, side: congestion::Side) -> std::io::Result<bool> {
2376    // clone the O_PATH fd into an owned fd the blocking closure can hold, keeping the pinned inode
2377    // alive for the syscall's full duration even if the originating Handle is dropped.
2378    let owned = handle.as_fd().try_clone_to_owned()?;
2379    let names = run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
2380        let proc_path = std::ffi::CString::new(format!("/proc/self/fd/{}", owned.as_raw_fd()))
2381            .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
2382        listxattr_names_at_path(&proc_path)
2383    })
2384    .await?;
2385    Ok(names_contain(&names, ACL_ACCESS_XATTR) || names_contain(&names, ACL_DEFAULT_XATTR))
2386}
2387
2388/// Whether it is worth *reaching* for the probe: something could still be warned about, and the
2389/// one per-process run has not been taken yet.
2390///
2391/// Advisory only — it claims nothing, so the probe itself still goes through
2392/// [`warn_if_root_acl_unpreserved`] / [`warn_if_root_acl_unpreserved_at`], which claim internally.
2393/// Its job is to let a caller skip work it would otherwise do just to reach the probe: the two
2394/// entry points below use it to skip an `openat`, and `rcp`'s remote `-L` source uses it to skip
2395/// resolving and opening the root's parent, which it does not otherwise need.
2396#[must_use]
2397pub fn root_acl_probe_worth_reaching(notice: RootAclNotice) -> bool {
2398    if !notice.could_warn() {
2399        return false;
2400    }
2401    !ROOT_ACL_PROBED.load(std::sync::atomic::Ordering::Relaxed)
2402}
2403
2404/// Claim the single per-process probe run, returning `false` if someone already has it.
2405///
2406/// Called as LATE as possible — after the root's kind is known and after the per-kind settings
2407/// check — so that a root which cannot produce a warning does not consume the budget and silence
2408/// a later operand that could have. Two operands racing here can both pass
2409/// [`root_acl_probe_worth_reaching`] and both open their root; only one wins the swap, and the
2410/// loser has wasted one `openat`. Which one wins is therefore unspecified for concurrent operands;
2411/// see the note on [`warn_if_root_acl_unpreserved`].
2412fn claim_root_acl_probe() -> bool {
2413    !ROOT_ACL_PROBED.swap(true, std::sync::atomic::Ordering::Relaxed)
2414}
2415
2416/// Probe `handle` and warn if it carries an ACL this copy will not carry across. The shared body
2417/// of the two entry points below, so the message and the kind rule live in one place.
2418///
2419/// The root's kind is the LAST thing the rule needs, and the only one a caller cannot supply, so
2420/// this is where [`RootAclNotice::could_warn_for`] is asked authoritatively — a symlink or special
2421/// root, and a kind whose ACLs are already carried across, are turned away without a syscall.
2422/// Failure to probe is logged at `debug!` and nothing else: this produces advice, so a missing
2423/// `/proc` or an exotic filesystem must not turn into a copy error.
2424async fn warn_root_acl(handle: &Handle, src_root: &Path, notice: RootAclNotice) {
2425    if !notice.could_warn_for(handle.kind()) {
2426        return;
2427    }
2428    // claim LAST. A root that could not have warned returns above without spending the one
2429    // per-process run, so it cannot silence a later operand that had something to say.
2430    if !claim_root_acl_probe() {
2431        return;
2432    }
2433    // Both arms go to `crate::NOTICE_TARGET`, which the verbose filter renders at every verbosity
2434    // INCLUDING the default: a notice about `all` silently dropping ACLs is useless if only a user
2435    // who already passed `-v` — and therefore already suspects something — can see it.
2436    match has_acl_via_proc_fd(handle, congestion::Side::Source).await {
2437        Ok(false) => {}
2438        Ok(true) if strict_operand_resolution() => tracing::warn!(
2439            target: crate::NOTICE_TARGET,
2440            "Source root {src_root:?} carries a POSIX ACL that this copy will NOT preserve \
2441             (`--preserve-settings` does not request `acl`). --require-toctou-safe stops the \
2442             DESTINATION tree's ACLs from being inherited, but it does not carry the SOURCE's \
2443             across, so the copy can still end up more permissive than what it copied. Use \
2444             `--preserve-settings=all+acl`. Only the root was checked - entries beneath it may \
2445             carry ACLs this says nothing about."
2446        ),
2447        Ok(true) => tracing::warn!(
2448            target: crate::NOTICE_TARGET,
2449            "Source root {src_root:?} carries a POSIX ACL that this copy will NOT preserve \
2450             (`--preserve-settings` does not request `acl`), so the destination may end up more \
2451             permissive than its source. Use `--preserve-settings=all+acl`. Only the root was \
2452             checked - entries beneath it may carry ACLs this says nothing about."
2453        ),
2454        Err(err) => {
2455            tracing::debug!("cannot probe {src_root:?} for a POSIX ACL: {err:#}");
2456        }
2457    }
2458}
2459
2460/// Warn once per run when the source ROOT carries a POSIX ACL that this copy will not carry across.
2461///
2462/// `handle` is the root's `O_PATH` classify handle and `src_root` its path, for the message;
2463/// `notice` is what the run's settings say (see [`RootAclNotice`]). The probe consults whichever of
2464/// its per-kind flags matches the root's own kind, so `f:acl` does not silence a directory root.
2465///
2466/// "Once" is per PROCESS, which is what makes this affordable: a `--dereference` walk recurses into
2467/// `copy` per resolved symlink, and a multi-operand run has several roots. The first root that can
2468/// carry an ACL spends the probe; the rest are silent even if they would have warned. That is the
2469/// heuristic being cheap on purpose — the alternative is the per-entry cost that made `acl` opt-in.
2470pub async fn warn_if_root_acl_unpreserved(handle: &Handle, src_root: &Path, notice: RootAclNotice) {
2471    if !root_acl_probe_worth_reaching(notice) {
2472        return;
2473    }
2474    warn_root_acl(handle, src_root, notice).await;
2475}
2476
2477/// [`warn_if_root_acl_unpreserved`] for a caller that holds the root's PARENT rather than the root
2478/// itself — the shape of the local `rcp`/`rlink` entry points, which open the root only inside the
2479/// walk.
2480///
2481/// The `openat` is skipped when nothing could be warned about and when the per-process run is
2482/// already spent, so a copy that did not ask for the notice or already preserves ACLs — and every
2483/// call after the first — costs nothing at all. Two operands racing can both pass that check and
2484/// both open their root; the loser of the claim has then spent one `openat` and nothing more. A
2485/// root that cannot be opened is left to the walk to diagnose.
2486pub async fn warn_if_root_acl_unpreserved_at(
2487    parent: &Dir,
2488    name: &OsStr,
2489    src_root: &Path,
2490    notice: RootAclNotice,
2491) {
2492    if !root_acl_probe_worth_reaching(notice) {
2493        return;
2494    }
2495    match parent.child(name).await {
2496        Ok(handle) => warn_root_acl(&handle, src_root, notice).await,
2497        Err(err) => {
2498            tracing::debug!("cannot open source root {src_root:?} for a POSIX ACL probe: {err:#}");
2499        }
2500    }
2501}
2502
2503// ── fd-based metadata application ───────────────────────────────────────────────
2504//
2505// These primitives apply ownership / mode / timestamps to an entry through a
2506// file descriptor we already hold, rather than re-resolving a path. That closes
2507// the TOCTOU window a path-based applier would have between opening/creating the
2508// entry and re-touching it by name (which is why the fd-based appliers replaced
2509// the path-based ones entirely).
2510//
2511// Every applier does chown BEFORE chmod: an unprivileged `fchown` clears
2512// setuid/setgid on a regular file, so the chmod has to come after to restore
2513// them. `set_file_metadata_fd` additionally puts the widening step LAST, after
2514// utimens — it is what takes a destination file from the owner-only mode it was
2515// created at (`DST_FILE_CREATE_MODE`) to the source mode, so it must not land
2516// until every other fallible step has succeeded. Ordering the two is free:
2517// `fchmod` touches ctime only, never atime/mtime, so the timestamps `futimens`
2518// installs are the same either way. All syscalls are gated through
2519// `run_metadata_probed_blocking` with `MetadataOp::Chmod`, bucketing
2520// chown/chmod/utimens/ACL together.
2521
2522/// `fchown` on a real (readable/writable) file descriptor.
2523///
2524/// No-op is the caller's responsibility: this always issues the syscall. Pass
2525/// `None` for a component that must not change.
2526async fn fchown_fd(
2527    fd: BorrowedFd<'_>,
2528    side: congestion::Side,
2529    uid: Option<u32>,
2530    gid: Option<u32>,
2531) -> std::io::Result<()> {
2532    // BorrowedFd is not 'static, so dup it into an owned fd the closure can hold.
2533    let owned = fd.try_clone_to_owned()?;
2534    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
2535        fchown(
2536            owned.as_fd(),
2537            uid.map(Uid::from_raw),
2538            gid.map(Gid::from_raw),
2539        )
2540        .map_err(nix_to_io)
2541    })
2542    .await
2543}
2544
2545/// `fchmod` on a real file descriptor. `mode` is masked to the permission bits
2546/// (`0o7777`); file-type bits, if present, are dropped by `from_bits_truncate`.
2547///
2548/// `fd` must be a real (not `O_PATH`) descriptor — `fchmod` returns `EBADF` on an
2549/// `O_PATH` fd. This is used by the copy path, which holds the destination's own
2550/// writable file / directory fd. For an `O_PATH` [`Handle`] (e.g. rchm's classified
2551/// entry), use [`chmod_via_proc_fd`] instead.
2552async fn fchmod_fd(fd: BorrowedFd<'_>, side: congestion::Side, mode: u32) -> std::io::Result<()> {
2553    let owned = fd.try_clone_to_owned()?;
2554    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
2555        fchmod(owned.as_fd(), Mode::from_bits_truncate(mode)).map_err(nix_to_io)
2556    })
2557    .await
2558}
2559
2560/// `futimens` on a real file descriptor.
2561async fn futimens_fd(
2562    fd: BorrowedFd<'_>,
2563    side: congestion::Side,
2564    atime: i64,
2565    atime_nsec: i64,
2566    mtime: i64,
2567    mtime_nsec: i64,
2568) -> std::io::Result<()> {
2569    let owned = fd.try_clone_to_owned()?;
2570    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
2571        let atime_spec = TimeSpec::new(atime, atime_nsec);
2572        let mtime_spec = TimeSpec::new(mtime, mtime_nsec);
2573        futimens(owned.as_fd(), &atime_spec, &mtime_spec).map_err(nix_to_io)
2574    })
2575    .await
2576}
2577
2578/// Inode-exact `fchownat` on any [`Handle`]'s `O_PATH` fd, operating on the entry
2579/// the fd points at — file, directory, or symlink — never following a symlink.
2580///
2581/// Uses `AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW` so the empty pathname resolves to
2582/// the fd's own pinned inode: no path re-resolution by `name` happens, so a
2583/// concurrent rename/symlink-swap of the directory entry cannot redirect the
2584/// chown to a different target. `AT_SYMLINK_NOFOLLOW` makes a symlink `Handle`
2585/// chown the link itself rather than its target. Pass `None` for a component
2586/// that must not change (the caller decides when to issue the syscall at all).
2587pub(crate) async fn fchown_handle(
2588    handle: &Handle,
2589    side: congestion::Side,
2590    uid: Option<u32>,
2591    gid: Option<u32>,
2592) -> std::io::Result<()> {
2593    let owned = handle.as_fd().try_clone_to_owned()?;
2594    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
2595        fchownat(
2596            owned.as_fd(),
2597            "",
2598            uid.map(Uid::from_raw),
2599            gid.map(Gid::from_raw),
2600            AtFlags::AT_EMPTY_PATH | AtFlags::AT_SYMLINK_NOFOLLOW,
2601        )
2602        .map_err(nix_to_io)
2603    })
2604    .await
2605}
2606
2607/// `chmod` any non-symlink entry (file, directory, or special) through an `O_PATH`
2608/// [`Handle`] by going via the `/proc/self/fd/N` magic symlink, changing the mode
2609/// of the EXACT inode the handle pins — never re-resolving the entry by name.
2610///
2611/// (Symlink mode bits are not settable on Linux, so callers never invoke this on a
2612/// symlink handle.)
2613///
2614/// # Why /proc and not `fchmod`/`fchmodat`
2615///
2616/// The `Handle` fd is `O_PATH`, which is the only way to pin an arbitrary entry's
2617/// inode without read/write/search rights on it. But `O_PATH` rules out the
2618/// obvious chmod paths:
2619///
2620/// - `fchmod(fd, mode)` returns `EBADF` on an `O_PATH` fd (it requires a real
2621///   open file description).
2622/// - `fchmodat(dirfd, name, mode, AT_SYMLINK_NOFOLLOW)` re-resolves `name`
2623///   relative to a directory fd — that re-resolution is exactly the TOCTOU window
2624///   we are closing, and the `AT_SYMLINK_NOFOLLOW` flag is only honored on Linux
2625///   6.6+ for `fchmodat` (older kernels reject it with `ENOTSUP`).
2626///
2627/// `chmod("/proc/self/fd/N", mode)` follows the kernel's per-fd magic symlink,
2628/// which resolves to the open file description's pinned inode regardless of what
2629/// the original `name` now refers to. Because the `O_PATH` handle keeps that
2630/// inode alive (the kernel cannot recycle an inode with an open reference), this
2631/// is inode-exact and immune to a concurrent rename/symlink swap. It also works
2632/// regardless of the file's own permission bits — e.g. a non-root owner's
2633/// `0000`-mode file — because the operation authorizes against the caller's
2634/// ownership, not the path's mode, and needs no traversal/read rights on the
2635/// target. (`fchmodat(.., FollowSymlink)` on the magic symlink is used because
2636/// the magic link must be dereferenced to reach the pinned inode.)
2637///
2638/// # Precondition
2639///
2640/// Requires `/proc` to be mounted (the standard Linux default). Without `/proc`
2641/// the call fails with `ENOENT`; this is a documented operational precondition of
2642/// the fd-based chmod path.
2643pub(crate) async fn chmod_via_proc_fd(
2644    handle: &Handle,
2645    side: congestion::Side,
2646    mode: u32,
2647) -> std::io::Result<()> {
2648    // clone the O_PATH fd into an owned fd the blocking closure can hold, keeping
2649    // the pinned inode alive for the syscall's full duration even if the
2650    // originating Handle is dropped after the closure starts. started spawn_blocking work is not
2651    // cancellable.
2652    let owned = handle.as_fd().try_clone_to_owned()?;
2653    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
2654        let proc_path = format!("/proc/self/fd/{}", owned.as_raw_fd());
2655        // FollowSymlink: the /proc entry is a magic symlink that must be
2656        // dereferenced to reach the pinned inode (NoFollowSymlink would chmod the
2657        // magic link itself, a silent no-op).
2658        nix::sys::stat::fchmodat(
2659            AT_FDCWD,
2660            proc_path.as_str(),
2661            Mode::from_bits_truncate(mode),
2662            nix::sys::stat::FchmodatFlags::FollowSymlink,
2663        )
2664        .map_err(nix_to_io)
2665    })
2666    .await
2667}
2668
2669/// Read full [`std::fs::Metadata`] for the exact inode an `O_PATH` [`Handle`] pins,
2670/// via the `/proc/self/fd/N` magic symlink.
2671///
2672/// The fd-pinned [`FileMeta`] snapshot ([`Handle::meta`]) covers uid/gid/mode and
2673/// the a/m/ctime timestamps, but NOT the birth time (`btime`) — `fstat` does not
2674/// return it. Callers that need `Metadata::created()` (the `--created-before`
2675/// time filter) get it here while staying inode-exact: the open `O_PATH` handle
2676/// keeps the inode alive, so resolving `/proc/self/fd/N` lands on that same inode
2677/// regardless of a concurrent rename/symlink swap of the original name. Gated as
2678/// `Stat`. Requires `/proc` mounted (same precondition as [`chmod_via_proc_fd`]).
2679pub(crate) async fn stat_meta_via_proc_fd(
2680    handle: &Handle,
2681    side: congestion::Side,
2682) -> std::io::Result<std::fs::Metadata> {
2683    let owned = handle.as_fd().try_clone_to_owned()?;
2684    run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
2685        let proc_path = format!("/proc/self/fd/{}", owned.as_raw_fd());
2686        std::fs::metadata(proc_path)
2687    })
2688    .await
2689}
2690
2691/// Read the target of a symlink [`Handle`] inode-exact, via `readlinkat(fd, "")` on the pinned
2692/// `O_PATH | O_NOFOLLOW` fd.
2693///
2694/// The empty-pathname form of `readlinkat` (Linux 2.6.39+) operates on the symlink the fd itself
2695/// refers to, so the target comes from the *same* pinned inode as [`Handle::meta`] — there is no
2696/// path re-resolution by name that a concurrent same-name swap could redirect. This is the symlink
2697/// analogue of reading a regular file's bytes and metadata from one [`Dir::open_file_read`] fd: it
2698/// lets a caller send/apply a symlink's target and metadata as a faithful pair. Fails if the handle
2699/// does not refer to a symlink (the empty-path form requires a symlink fd); callers only invoke it
2700/// on a `Symlink`-classified handle. Gated as `ReadLink`.
2701///
2702/// Raw `libc::readlinkat` is required: nix's wrapper rejects the empty pathname that selects the
2703/// fd's own link (the same reason `symlink_utimes_fd` uses raw `utimensat`).
2704pub async fn read_link_handle(
2705    handle: &Handle,
2706    side: congestion::Side,
2707) -> std::io::Result<std::path::PathBuf> {
2708    use std::os::unix::ffi::OsStringExt;
2709    let owned = handle.as_fd().try_clone_to_owned()?;
2710    run_metadata_probed_blocking(side, congestion::MetadataOp::ReadLink, move || {
2711        // a symlink target is bounded by PATH_MAX, so a single buffer of that size never truncates.
2712        let mut buf = vec![0u8; libc::PATH_MAX as usize];
2713        // SAFETY: `owned` is a valid open fd for the duration of this call; the empty C string
2714        // selects the fd's own symlink (it was opened O_PATH|O_NOFOLLOW); `buf` has `len()` bytes.
2715        let n = unsafe {
2716            libc::readlinkat(
2717                owned.as_raw_fd(),
2718                c"".as_ptr(),
2719                buf.as_mut_ptr().cast::<libc::c_char>(),
2720                buf.len(),
2721            )
2722        };
2723        if n < 0 {
2724            return Err(std::io::Error::last_os_error());
2725        }
2726        buf.truncate(n as usize);
2727        Ok(std::path::PathBuf::from(std::ffi::OsString::from_vec(buf)))
2728    })
2729    .await
2730}
2731
2732/// Set timestamps on a symlink `Handle`'s `O_PATH` fd, operating on the link
2733/// itself, via a raw `utimensat(fd, "", times, AT_EMPTY_PATH)`.
2734///
2735/// Raw libc is required here: nix's `utimensat` wrapper cannot pass
2736/// `AT_EMPTY_PATH`, and `futimens` on an `O_PATH` fd returns `EBADF`. The
2737/// `/proc/self/fd` form silently no-ops under `NOFOLLOW`, so it must not be used.
2738async fn symlink_utimes_fd(
2739    handle: &Handle,
2740    side: congestion::Side,
2741    atime: i64,
2742    atime_nsec: i64,
2743    mtime: i64,
2744    mtime_nsec: i64,
2745) -> std::io::Result<()> {
2746    let owned = handle.as_fd().try_clone_to_owned()?;
2747    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
2748        let times: [libc::timespec; 2] = [
2749            libc::timespec {
2750                tv_sec: atime,
2751                tv_nsec: atime_nsec,
2752            },
2753            libc::timespec {
2754                tv_sec: mtime,
2755                tv_nsec: mtime_nsec,
2756            },
2757        ];
2758        // SAFETY: `owned` is a valid open fd for the duration of this call; the
2759        // pathname is the empty C string and `times` points to a 2-element array.
2760        let res = unsafe {
2761            libc::utimensat(
2762                owned.as_raw_fd(),
2763                c"".as_ptr(),
2764                times.as_ptr(),
2765                libc::AT_EMPTY_PATH,
2766            )
2767        };
2768        if res == 0 {
2769            Ok(())
2770        } else {
2771            Err(std::io::Error::last_os_error())
2772        }
2773    })
2774    .await
2775}
2776
2777/// Apply file metadata (owner, timestamps, mode, ACL) to an already-open writable
2778/// file descriptor, in the chown → utimens → mode order.
2779///
2780/// `fd` must be the destination file's own fd (typically the write fd returned
2781/// by [`Dir::create_file`]); this avoids the redundant `File::open` re-open a
2782/// path-based applier would need, and closes the TOCTOU window in the process.
2783/// Gating on `settings.file`: chown only when uid or gid is requested, chmod
2784/// always (the masked mode honors `mode_mask`), timestamps only when requested, ACLs only when
2785/// `settings.file.acl` is on — in which case `acls` must carry what was read from the source, or
2786/// the call fails rather than guessing (see `acls_to_apply`).
2787///
2788/// # The widening invariant
2789///
2790/// > EXACTLY ONE step widens the destination from its owner-only create mode, and it is the LAST
2791/// > fallible step here. Without a source access ACL that step is the `fchmod`; with one it is the
2792/// > `fsetxattr`, and the `fchmod` before it is narrowed to `(mode & 0o7000) | DST_FILE_CREATE_MODE`
2793/// > so that it cannot widen.
2794///
2795/// This is what makes [`DST_FILE_CREATE_MODE`] safe to use for every destination file: a failure
2796/// part-way through metadata application must not publish the final mode on a file the copy is
2797/// about to report as failed. The mode is also decided UNCONDITIONALLY here — this call is the
2798/// single place it happens, so no `--preserve` setting can leave a successfully copied file
2799/// owner-only.
2800///
2801/// An access ACL has to take that slot because applying one IS a mode-changing operation: the ACL's
2802/// `MASK` entry becomes the mode's group bits, so `fsetxattr` widens, and a `chmod` AFTER it would
2803/// re-derive `MASK` from the mode and destroy the ACL's fidelity. The two want the same slot, and
2804/// the resolution is verified kernel behavior: `fsetxattr` sets the rwx bits exactly (`USER_OBJ`,
2805/// `MASK`, `OTHER`) and PRESERVES setuid/setgid/sticky, so the preceding `fchmod` can carry the
2806/// special bits alone. Its result — special bits over an owner-only, non-executable mode — grants
2807/// nothing on its own, so a failing `fsetxattr` leaves a file no one else can reach.
2808///
2809/// Clearing an ACL, by contrast, is mode-neutral and can only narrow, so when the source has none
2810/// the removal is safe to run BEFORE the widening chmod (verified: a `0o600` file stays `0o600`
2811/// across `fremovexattr`).
2812///
2813/// A file whose copy fails anywhere before that widening step therefore stays owner-only —
2814/// deliberately. It is reported as an error, and its size/mtime then *normally* differ from the
2815/// source so a later run re-copies it. Two cases where they do not, leaving the file owner-only
2816/// until something else re-copies it: a failure at the widening step ITSELF, which comes after the
2817/// timestamps have been applied, so the destination matches its source on both size and mtime; and
2818/// the nanosecond concession in [`metadata_equal`], which skips that comparison when either side's
2819/// `mtime_nsec` is zero, so even a file that never reached `futimens` compares equal when its write
2820/// and the source's mtime land in the same whole second. See `docs/tocttou.md`.
2821///
2822/// [`metadata_equal`]: crate::filecmp::metadata_equal
2823pub async fn set_file_metadata_fd<Meta: crate::preserve::Metadata>(
2824    settings: &crate::preserve::Settings,
2825    meta: &Meta,
2826    acls: Option<&Acls>,
2827    fd: BorrowedFd<'_>,
2828    side: congestion::Side,
2829) -> std::io::Result<()> {
2830    let ut = &settings.file.user_and_time;
2831    if ut.uid || ut.gid {
2832        let uid = if ut.uid { Some(meta.uid()) } else { None };
2833        let gid = if ut.gid { Some(meta.gid()) } else { None };
2834        fchown_fd(fd, side, uid, gid).await?;
2835    }
2836    if ut.time {
2837        futimens_fd(
2838            fd,
2839            side,
2840            meta.atime(),
2841            meta.atime_nsec(),
2842            meta.mtime(),
2843            meta.mtime_nsec(),
2844        )
2845        .await?;
2846    }
2847    let acls = acls_to_apply(settings.file.acl, acls)?;
2848    let mode = crate::preserve::masked_mode(settings.file.mode_mask, meta);
2849    if let Some(acls) = acls.filter(|acls| acls.access.is_some()) {
2850        // the ACL is the widening step; this chmod carries only the special bits
2851        fchmod_fd(fd, side, (mode & 0o7000) | DST_FILE_CREATE_MODE).await?;
2852        apply_acls_fd(fd, side, acls, false).await?;
2853    } else {
2854        // clearing an inherited ACL can only narrow, so it is safe before the widening chmod
2855        if let Some(acls) = acls {
2856            apply_acls_fd(fd, side, acls, false).await?;
2857        }
2858        fchmod_fd(fd, side, mode).await?;
2859    }
2860    Ok(())
2861}
2862
2863/// Apply directory metadata (owner, mode, ACLs, timestamps) to an open [`Dir`] fd,
2864/// following the chown → mode → utimens ordering. Gates on `settings.dir` and
2865/// uses the directory's own congestion side.
2866///
2867/// The mode step is the same two-branch shape as [`set_file_metadata_fd`] — see the widening
2868/// invariant there — with [`DST_DIR_CREATE_MODE`] as the create mode. It stays in the directory
2869/// applier's existing chmod slot rather than moving to the end: `futimens` is last for a directory
2870/// because a directory's mtime is bumped by every child created in it, and unlike a file's mode a
2871/// directory's is applied post-order, once its children exist. The DEFAULT ACL rides along with the
2872/// access one; being mode-neutral, it constrains nothing.
2873///
2874/// # One asymmetry a directory has and a file does not
2875///
2876/// A destination file is always freshly created, so the ACL branch's narrowed `fchmod` only ever
2877/// holds it at the mode it already had. A destination DIRECTORY may be REUSED — an existing `dst/`
2878/// that rcp is copying into — and there that same `fchmod` actively NARROWS it, to `0o700` plus its
2879/// special bits, for the moment between the chmod and the `fsetxattr`. If the `fsetxattr` then
2880/// fails, the copy is reported failed and a long-lived directory is left at `0o700`, which is not
2881/// the mode it started at.
2882///
2883/// That is fail-closed, and deliberately so: the alternative is publishing the source's mode on a
2884/// directory whose ACL never landed, which is the wider outcome. But it means an operator whose
2885/// destination directory comes back owner-only after a failed `d:acl` copy is looking at this step,
2886/// not at a bug. Branch 2 has no such window — it only ever chmods once, to the final mode.
2887pub async fn set_dir_metadata_fd<Meta: crate::preserve::Metadata>(
2888    settings: &crate::preserve::Settings,
2889    meta: &Meta,
2890    acls: Option<&Acls>,
2891    dir: &Dir,
2892) -> std::io::Result<()> {
2893    set_dir_metadata_fd_inner(settings, meta, acls, dir, true).await
2894}
2895
2896/// The body of [`set_dir_metadata_fd`], parameterized over whether the DEFAULT ACL is applied
2897/// here. `apply_default` is `false` for exactly one caller — [`set_reused_dir_metadata_fd`]'s
2898/// locked path, which owns the default ACL through the lockdown guard: that write must serialize
2899/// against the guard's rollback, and an unguarded write from here is precisely the detached-write
2900/// hazard the guard closes (see `DefaultAclGuard`). Everything else — including the access ACL's
2901/// claim on the widening slot — is identical in both modes.
2902async fn set_dir_metadata_fd_inner<Meta: crate::preserve::Metadata>(
2903    settings: &crate::preserve::Settings,
2904    meta: &Meta,
2905    acls: Option<&Acls>,
2906    dir: &Dir,
2907    apply_default: bool,
2908) -> std::io::Result<()> {
2909    let side = dir.side();
2910    let fd = dir.fd.as_fd();
2911    let ut = &settings.dir.user_and_time;
2912    if ut.uid || ut.gid {
2913        let uid = if ut.uid { Some(meta.uid()) } else { None };
2914        let gid = if ut.gid { Some(meta.gid()) } else { None };
2915        fchown_fd(fd, side, uid, gid).await?;
2916    }
2917    let acls = acls_to_apply(settings.dir.acl, acls)?;
2918    let mode = crate::preserve::masked_mode(settings.dir.mode_mask, meta);
2919    if let Some(acls) = acls.filter(|acls| acls.access.is_some()) {
2920        // the ACL is the widening step; this chmod carries only the special bits
2921        fchmod_fd(fd, side, (mode & 0o7000) | DST_DIR_CREATE_MODE).await?;
2922        apply_acls_fd(fd, side, acls, apply_default).await?;
2923    } else {
2924        // no source access ACL, so the chmod is the widening step and stays last. Both applies that
2925        // can run here are safe before it: clearing an inherited ACL can only narrow, and SETTING a
2926        // default ACL is mode-neutral — it governs what children inherit, never this directory's
2927        // own mode (a source can have a default ACL and no access one).
2928        if let Some(acls) = acls {
2929            apply_acls_fd(fd, side, acls, apply_default).await?;
2930        }
2931        fchmod_fd(fd, side, mode).await?;
2932    }
2933    if ut.time {
2934        futimens_fd(
2935            fd,
2936            side,
2937            meta.atime(),
2938            meta.atime_nsec(),
2939            meta.mtime(),
2940            meta.mtime_nsec(),
2941        )
2942        .await?;
2943    }
2944    Ok(())
2945}
2946
2947/// Apply directory metadata to a REUSED destination directory that may have been locked down under
2948/// [`strict_operand_resolution`], restoring the original owner correctly and WITHOUT a transient
2949/// window in which a hostile prior owner regains control.
2950///
2951/// `lock` is what [`lockdown_reused_dir`] recorded, or `None` for a freshly created directory
2952/// (nothing to restore). When present, this restores ONLY the owner components that are NOT being
2953/// preserved to the source — leaving each preserved component as the copier for
2954/// [`set_dir_metadata_fd`] to set to the source owner. So the directory's transient owner is always
2955/// the copier (for a preserved uid) or the final owner (a non-preserved uid equals the original) —
2956/// NEVER a prior owner who will not own the final directory. This closes the brief re-grant window
2957/// that a plain "chown to the original, then chown the preserved components to source" sequence
2958/// would open for a preserving copy, and removes the "left owned by the original if the second chown
2959/// fails" case. The non-preserved gid write, when issued, targets the directory's unchanged gid — a
2960/// kernel-permitted set-to-current no-op, safe even for a non-root copier not in that group.
2961///
2962/// # Resolving the two ACLs
2963///
2964/// The ACCESS ACL keeps its usual applier semantics (set or cleared by the inner applier when
2965/// `d:acl` is on, untouched otherwise). The DEFAULT ACL of a LOCKED directory is different: it is
2966/// resolved HERE, first, through the lockdown guard, and the inner applier is told to leave it
2967/// alone (`set_dir_metadata_fd_inner(…, apply_default: false)`):
2968///
2969/// 1. `settings.dir.acl` on → the SOURCE's default ACL is installed. When the source has none
2970///    there is nothing to write: the lockdown already left the directory without one, which is the
2971///    correct end state. The copy is preserving ACLs, so what the destination carried before is
2972///    not what it should end with.
2973/// 2. otherwise → the directory's ORIGINAL default ACL is restored (nothing to do when there was
2974///    none). rcp was not asked to change this directory's ACLs; the lockdown removed it only so
2975///    children created during the copy could not inherit it, and failing to put it back would
2976///    permanently destroy an ACL the destination had before the copy. That makes a failure here an
2977///    ERROR, unlike the lockdown strip, where `EOPNOTSUPP` is tolerated (a filesystem that cannot
2978///    hold an ACL cannot have had one).
2979///
2980/// Both writes go through `ReusedDirLock::write_default_acl_guarded`, which serializes them
2981/// against the guard's `Drop` rollback: this future can be cancelled with the write already
2982/// detached on the blocking pool, and an unserialized write could land AFTER the rollback and
2983/// silently undo it (see `DefaultAclGuard`). The guard stays armed through every remaining
2984/// fallible step — INCLUDING the final re-stat verification and every cancellation point up to
2985/// the successful return — and is disarmed only immediately before `Ok(())`, so a failure or a
2986/// cancellation anywhere in between rolls the directory back to ITS OWN original default ACL —
2987/// including the case where that original is "none" and the rollback must REMOVE a source ACL
2988/// that was just installed.
2989///
2990/// The guarded write runs FIRST, unwinding the lockdown in the reverse of the order it was applied
2991/// (ACL removed last, so resolved first; then the owner; then the mode, via the chmod inside the
2992/// inner applier). Two things make that placement deliberate:
2993///
2994/// - **It runs while the copier still owns the directory**, before the owner is handed back. Only
2995///   an owner or a `CAP_FOWNER` copier may write these attributes.
2996/// - **It fails toward *unchanged*.** If a later step fails, the armed guard leaves the directory
2997///   holding (or rolled back to) the default ACL it had before the copy. Deferring it past the
2998///   chmod would instead fail toward *damaged* — a directory permanently stripped of an ACL it
2999///   owned before rcp touched it, which is the one outcome this function is required to treat as
3000///   an error rather than produce.
3001///
3002/// The guarded write is mode-neutral, so unlike the SOURCE-ACL applies in the inner applier it
3003/// does not compete with the finalize chmod for the widening slot: only a DEFAULT ACL ever goes
3004/// through it, and a default ACL never moves its own directory's mode. That is a consequence of
3005/// the lockdown narrowing to the default ACL alone (see [`lockdown_reused_dir`]) and is worth
3006/// stating, because it was not always true: while the lockdown also stripped the ACCESS ACL, the
3007/// restore had to precede the chmod or it would install the DESTINATION's original rwx bits over
3008/// the source's mode — the snapshot's `USER_OBJ`/`MASK`/`OTHER` agree with the directory's
3009/// original mode, not the source's — and the re-stat verify below would then reject the copy.
3010/// Anything that reintroduces an access-ACL restore here inherits that constraint.
3011pub async fn set_reused_dir_metadata_fd<Meta: crate::preserve::Metadata>(
3012    settings: &crate::preserve::Settings,
3013    meta: &Meta,
3014    acls: Option<&Acls>,
3015    lock: Option<ReusedDirLock>,
3016    dir: &Dir,
3017) -> std::io::Result<()> {
3018    // A fresh / non-strict directory (lock == None) uses the shared best-effort applier UNVERIFIED —
3019    // verifying it would regress legitimate non-root `--preserve` copies (which the kernel silently
3020    // strips setgid from, best-effort). Only a strict-mode LOCKED reused directory (lock == Some)
3021    // gets the restore + final verify below.
3022    // NB: `ReusedDirLock` is an RAII guard, so it cannot be destructured by move — and must not be,
3023    // since dropping its parts separately is exactly the bug it exists to prevent. It stays whole
3024    // until it is deliberately disarmed below.
3025    let Some(lock) = lock else {
3026        return set_dir_metadata_fd(settings, meta, acls, dir).await;
3027    };
3028    let (orig_uid, orig_gid) = lock.restore_owner;
3029    // resolve the default ACL first, through the guard — see "Resolving the two ACLs" above for
3030    // the two cases, the serialization against the guard's rollback, and why a failure must leave
3031    // the guard armed (`Drop` then gets one synchronous rollback attempt rather than the bytes
3032    // dying with the error)
3033    let final_default = if settings.dir.acl {
3034        // fail closed exactly as the applier does when the call site carried no ACLs
3035        acls_to_apply(true, acls)?.and_then(|acls| acls.default.clone())
3036    } else {
3037        lock.original_default_acl()
3038    };
3039    if let Some(blob) = final_default {
3040        lock.write_default_acl_guarded(dir.side, blob).await?;
3041    }
3042    let ut = &settings.dir.user_and_time;
3043    {
3044        let uid = (!ut.uid).then_some(orig_uid);
3045        let gid = (!ut.gid).then_some(orig_gid);
3046        if uid.is_some() || gid.is_some() {
3047            fchown_fd(dir.fd.as_fd(), dir.side, uid, gid).await?;
3048        }
3049    }
3050    // the default ACL is excluded (`apply_default: false`): it was resolved through the guard
3051    // above, and only the guard's serialized writer may touch it while the lockdown is live
3052    set_dir_metadata_fd_inner(settings, meta, acls, dir, false).await?;
3053    // The guard stays ARMED through the verify below: a re-stat error, a verification failure, or
3054    // this future being cancelled at the `meta()` await all mean the copy of this directory FAILED,
3055    // and failure must leave the directory as it was — the armed guard's `Drop` rolls the default
3056    // ACL back to the destination's original (REMOVING a just-installed source ACL when the
3057    // original was "none") rather than leaving the directory half-converted to the source.
3058    // Disarming any earlier reopens exactly that window; see the disarm at the end.
3059    //
3060    // Re-stat and verify the finalize actually landed. A backend that reports chown/chmod success
3061    // without honoring it (e.g. CIFS without unix extensions) would otherwise leave a strict reused
3062    // directory silently owned by the copier or at the wrong mode. A lone dropped S_ISGID bit is a
3063    // WARNING, not a failure: it is narrower than the source, child safety was already ensured by the
3064    // INTERIM setgid held during the copy, and failing here would regress a legitimate non-root
3065    // `--preserve` of a setgid directory (the kernel strips setgid when the copier is not in the
3066    // group and lacks CAP_FSETID) — matching the best-effort behavior of a non-strict copy.
3067    //
3068    // `want_mode` stays `masked_mode` even when an access ACL was applied, because the two agree by
3069    // construction: the destination's rwx bits come from the source's ACL (`fsetxattr` sets them
3070    // from USER_OBJ/MASK/OTHER) and the source's own mode's rwx bits were derived from that same
3071    // ACL by the kernel. The special bits come from the chmod either way. So for a setgid source
3072    // directory with an ACL, `want_mode`'s S_ISGID is checked against the chmod's result and its
3073    // rwx against the ACL's, with no double-counting.
3074    use crate::preserve::Metadata as _;
3075    use std::os::unix::fs::PermissionsExt as _;
3076    let want_uid = if ut.uid { meta.uid() } else { orig_uid };
3077    let want_gid = if ut.gid { meta.gid() } else { orig_gid };
3078    let want_mode = crate::preserve::masked_mode(settings.dir.mode_mask, meta);
3079    let got = dir.meta().await?;
3080    let got_mode = got.permissions().mode() & 0o7777;
3081    if got.uid() != want_uid || got.gid() != want_gid {
3082        return Err(std::io::Error::new(
3083            std::io::ErrorKind::PermissionDenied,
3084            format!(
3085                "reused-directory finalize could not be verified: the directory shows uid={} gid={} \
3086                 (expected uid={} gid={}); the filesystem may not honor chown/chmod",
3087                got.uid(),
3088                got.gid(),
3089                want_uid,
3090                want_gid,
3091            ),
3092        ));
3093    }
3094    if got_mode != want_mode {
3095        if want_mode & 0o2000 != 0 && want_mode & !0o2000 == got_mode {
3096            // the ONLY difference is a dropped setgid bit — warn, do not fail.
3097            tracing::warn!(
3098                "setgid not applied to reused directory (final mode {:#o} vs source {:#o}): the \
3099                 copier is not in the directory's group and lacks CAP_FSETID",
3100                got_mode,
3101                want_mode,
3102            );
3103        } else {
3104            return Err(std::io::Error::new(
3105                std::io::ErrorKind::PermissionDenied,
3106                format!(
3107                    "reused-directory finalize could not be verified: the directory shows \
3108                     mode={:#o} (expected {:#o}); the filesystem may not honor chmod",
3109                    got_mode, want_mode,
3110                ),
3111            ));
3112        }
3113    }
3114    // Disarm only here, with the finalize verified and nothing fallible (or cancellable) left
3115    // before the successful return: every earlier exit — error or cancellation — leaves the guard
3116    // armed so its `Drop` restores the destination's original default ACL.
3117    lock.disarm();
3118    Ok(())
3119}
3120
3121/// Apply symlink metadata (owner and timestamps only — never mode) to a symlink
3122/// [`Handle`], operating on the link itself via `AT_EMPTY_PATH`.
3123///
3124/// Symlinks have no meaningful permission bits, so there is no chmod step;
3125/// ordering is chown → utimens. Gates on `settings.symlink`.
3126pub async fn set_symlink_metadata_fd<Meta: crate::preserve::Metadata>(
3127    settings: &crate::preserve::Settings,
3128    meta: &Meta,
3129    handle: &Handle,
3130    side: congestion::Side,
3131) -> std::io::Result<()> {
3132    let ut = &settings.symlink.user_and_time;
3133    if ut.uid || ut.gid {
3134        let uid = if ut.uid { Some(meta.uid()) } else { None };
3135        let gid = if ut.gid { Some(meta.gid()) } else { None };
3136        // chown the link itself: fchown_handle already operates inode-exact on the O_PATH handle
3137        // via AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW.
3138        fchown_handle(handle, side, uid, gid).await?;
3139    }
3140    if ut.time {
3141        symlink_utimes_fd(
3142            handle,
3143            side,
3144            meta.atime(),
3145            meta.atime_nsec(),
3146            meta.mtime(),
3147            meta.mtime_nsec(),
3148        )
3149        .await?;
3150    }
3151    Ok(())
3152}
3153
3154// ── helpers ───────────────────────────────────────────────────────────────────
3155
3156/// One blocking job whose queued resources have a defined destruction order.
3157///
3158/// Field order is load-bearing: queued user work may own fds, so it must be dropped before the
3159/// strong admission lease that accounts for them.
3160struct BlockingJob<F> {
3161    work: F,
3162    admission: Option<throttle::BlockingFdAdmissionLease>,
3163}
3164
3165type BlockingJobOutput<T> = (
3166    std::io::Result<T>,
3167    Option<throttle::BlockingFdAdmissionLease>,
3168);
3169
3170impl<F> BlockingJob<F> {
3171    fn run<T>(self) -> BlockingJobOutput<T>
3172    where
3173        F: FnOnce() -> std::io::Result<T>,
3174    {
3175        let Self { work, admission } = self;
3176        (work(), admission)
3177    }
3178}
3179
3180fn take_blocking_job<F>(
3181    shared_job: &std::sync::Mutex<Option<BlockingJob<F>>>,
3182) -> Option<BlockingJob<F>> {
3183    shared_job
3184        .lock()
3185        .unwrap_or_else(std::sync::PoisonError::into_inner)
3186        .take()
3187}
3188
3189/// Wait for a blocking job while retaining control of work that has not started.
3190struct BlockingJobWaiter<F, T> {
3191    shared_job: Arc<std::sync::Mutex<Option<BlockingJob<F>>>>,
3192    handle: tokio::task::JoinHandle<Option<BlockingJobOutput<T>>>,
3193}
3194
3195impl<F, T> std::future::Future for BlockingJobWaiter<F, T> {
3196    type Output = Result<Option<BlockingJobOutput<T>>, tokio::task::JoinError>;
3197
3198    fn poll(
3199        self: std::pin::Pin<&mut Self>,
3200        cx: &mut std::task::Context<'_>,
3201    ) -> std::task::Poll<Self::Output> {
3202        let this = self.get_mut();
3203        std::future::Future::poll(std::pin::Pin::new(&mut this.handle), cx)
3204    }
3205}
3206
3207impl<F, T> Drop for BlockingJobWaiter<F, T> {
3208    fn drop(&mut self) {
3209        let queued_job = take_blocking_job(&self.shared_job);
3210        drop(queued_job);
3211        self.handle.abort();
3212    }
3213}
3214
3215/// Run fd-owning blocking work while retaining any task-scoped admission through cancellation.
3216///
3217/// The strong lease lives in the blocking task's output, after `result`, so an abandoned returned
3218/// [`Dir`], [`Handle`], or file is dropped before the admission slot is released. This lower-level
3219/// boundary intentionally adds no rate or congestion gating; directory enumeration uses it after
3220/// consuming its static rate token, while metadata syscalls layer their congestion lifecycle on
3221/// top in [`run_metadata_probed_blocking_no_rate`].
3222///
3223/// Until the worker takes the shared job, dropping the waiter synchronously destroys its user work
3224/// before its lease. Once the worker takes it, the job is considered started and runs detached if
3225/// the waiter is cancelled.
3226pub(crate) async fn run_fd_admitted_blocking<F, T>(f: F) -> std::io::Result<T>
3227where
3228    F: FnOnce() -> std::io::Result<T> + Send + 'static,
3229    T: Send + 'static,
3230{
3231    // upgrade immediately before submission. if the ambient weak reference has expired (ordinary
3232    // directory recursion dropped its provisional guard), this remains an unadmitted directory
3233    // operation; an admitted outer leaf such as copy -> recursive rm stays represented.
3234    let blocking_admission = FD_ADMISSION
3235        .try_with(throttle::FdAdmission::blocking_lease)
3236        .ok();
3237    let shared_job = Arc::new(std::sync::Mutex::new(Some(BlockingJob {
3238        work: f,
3239        admission: blocking_admission,
3240    })));
3241    let worker_job = Arc::clone(&shared_job);
3242    let handle =
3243        tokio::task::spawn_blocking(move || take_blocking_job(&worker_job).map(BlockingJob::run));
3244    let (result, _blocking_admission) = BlockingJobWaiter { shared_job, handle }
3245        .await
3246        .map_err(std::io::Error::other)?
3247        .expect("blocking job disappeared while its waiter was alive");
3248    result
3249}
3250
3251/// Run a blocking metadata syscall closure on the blocking pool, gated by the
3252/// congestion controller for the given side and operation kind.
3253///
3254/// Each per-entry `openat`/`fstatat` is rate-gated, counted against the cwnd permit, and feeds the
3255/// latency probe — the same per-metadata-syscall gating shape used throughout this crate. The cwnd
3256/// guard and probe move into the blocking closure because cancelling the async waiter cannot stop
3257/// a syscall that has started; releasing either in the waiter would undercount the actual in-flight
3258/// work and its latency.
3259pub async fn run_metadata_probed_blocking<F, T>(
3260    side: congestion::Side,
3261    op: congestion::MetadataOp,
3262    f: F,
3263) -> std::io::Result<T>
3264where
3265    F: FnOnce() -> std::io::Result<T> + Send + 'static,
3266    T: Send + 'static,
3267{
3268    throttle::get_ops_token().await;
3269    run_metadata_probed_blocking_no_rate(side, op, f).await
3270}
3271
3272/// Variant of [`run_metadata_probed_blocking`] for a caller that already consumed the static
3273/// operations-rate token before spawning its task.
3274pub async fn run_metadata_probed_blocking_no_rate<F, T>(
3275    side: congestion::Side,
3276    op: congestion::MetadataOp,
3277    f: F,
3278) -> std::io::Result<T>
3279where
3280    F: FnOnce() -> std::io::Result<T> + Send + 'static,
3281    T: Send + 'static,
3282{
3283    let ops_permit = throttle::ops_in_flight_permit(crate::walk::meta_resource(side, op)).await;
3284    let probe = congestion::Probe::start_metadata(side, op);
3285    run_fd_admitted_blocking(move || {
3286        let result = f();
3287        match &result {
3288            Ok(_) => probe.complete_ok(0),
3289            Err(_) => probe.discard(),
3290        }
3291        // the cwnd models syscall concurrency, so release it when `f` finishes. The shared blocking
3292        // boundary keeps admission in the task output because `f` can return an fd that an
3293        // abandoned waiter never receives.
3294        drop(ops_permit);
3295        result
3296    })
3297    .await
3298}
3299
3300/// Convert a `nix::errno::Errno` to `std::io::Error`.
3301fn nix_to_io(e: nix::errno::Errno) -> std::io::Error {
3302    std::io::Error::from_raw_os_error(e as i32)
3303}
3304
3305/// Return `true` when `name` is a single non-empty path component (no `/`,
3306/// not `.` or `..`).
3307fn is_single_component(name: &OsStr) -> bool {
3308    if name.is_empty() || name == "." || name == ".." {
3309        return false;
3310    }
3311    !name.as_bytes().contains(&b'/')
3312}
3313
3314// ── tests ─────────────────────────────────────────────────────────────────────
3315
3316#[cfg(test)]
3317mod tests {
3318    use super::*;
3319    use crate::preserve::Metadata;
3320    use crate::testutils;
3321    use std::io::Read;
3322
3323    const EVERY_KIND: [EntryKind; 4] = [
3324        EntryKind::File,
3325        EntryKind::Dir,
3326        EntryKind::Symlink,
3327        EntryKind::Special,
3328    ];
3329
3330    #[tokio::test]
3331    async fn preview_operand_descent_rejects_a_symlink_in_the_relative_prefix() -> anyhow::Result<()>
3332    {
3333        let fixture = testutils::create_temp_dir().await?;
3334        let named_root = fixture.join("named-root");
3335        let outside = fixture.join("outside");
3336        tokio::fs::create_dir_all(&named_root).await?;
3337        tokio::fs::create_dir_all(outside.join("level-two")).await?;
3338        tokio::fs::symlink(&outside, named_root.join("level-one")).await?;
3339
3340        let opened = open_existing_dir_beneath_operand(
3341            &named_root,
3342            Path::new("level-one/level-two"),
3343            congestion::Side::Destination,
3344        )
3345        .await?;
3346
3347        assert!(opened.is_none());
3348        Ok(())
3349    }
3350
3351    #[tokio::test]
3352    async fn preview_operand_relative_path_accepts_empty_and_rejects_non_normal_components()
3353    -> anyhow::Result<()> {
3354        let fixture = testutils::create_temp_dir().await?;
3355        let named_root = fixture.join("named-root");
3356        tokio::fs::create_dir(&named_root).await?;
3357
3358        assert!(
3359            open_existing_dir_beneath_operand(
3360                &named_root,
3361                Path::new(""),
3362                congestion::Side::Destination,
3363            )
3364            .await?
3365            .is_some(),
3366            "an empty relative path must open the named root"
3367        );
3368        for invalid in ["/absolute", ".", "..", "one/./two", "one/../two"] {
3369            let error = open_existing_dir_beneath_operand(
3370                &named_root,
3371                Path::new(invalid),
3372                congestion::Side::Destination,
3373            )
3374            .await
3375            .expect_err("a non-normal relative path must be rejected");
3376            assert_eq!(error.raw_os_error(), Some(libc::EINVAL), "{invalid:?}");
3377        }
3378        Ok(())
3379    }
3380
3381    mod max_files_in_flight_tests {
3382        use super::*;
3383        use anyhow::Context as _;
3384        use futures::FutureExt as _;
3385
3386        struct BlockingDropFd {
3387            _file: std::fs::File,
3388            drop_started: Option<tokio::sync::oneshot::Sender<()>>,
3389            release_drop: std::sync::mpsc::Receiver<()>,
3390            _completion: crate::testutils::CompletionSignal,
3391        }
3392
3393        impl Drop for BlockingDropFd {
3394            fn drop(&mut self) {
3395                if let Some(drop_started) = self.drop_started.take() {
3396                    let _ = drop_started.send(());
3397                }
3398                self.release_drop
3399                    .recv()
3400                    .expect("drop release sender must stay alive");
3401            }
3402        }
3403
3404        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
3405        struct QueuedFdDropObservation {
3406            fd_was_closed: bool,
3407            admission_was_retained: bool,
3408        }
3409
3410        struct QueuedFdCapture {
3411            file: Option<std::fs::File>,
3412            probe: crate::testutils::FdIdentityProbe,
3413            observation: Arc<std::sync::Mutex<Option<std::io::Result<QueuedFdDropObservation>>>>,
3414        }
3415
3416        impl Drop for QueuedFdCapture {
3417            fn drop(&mut self) {
3418                drop(self.file.take());
3419                let admission_was_retained = throttle::open_file_permit().now_or_never().is_none();
3420                *self
3421                    .observation
3422                    .lock()
3423                    .expect("queued fd drop observation lock poisoned") =
3424                    Some(self.probe.original_is_closed().map(|fd_was_closed| {
3425                        QueuedFdDropObservation {
3426                            fd_was_closed,
3427                            admission_was_retained,
3428                        }
3429                    }));
3430            }
3431        }
3432
3433        /// Dropping a queued blocking waiter must synchronously close its captured fd and return
3434        /// admission before the occupied worker is released, without ever running user work.
3435        #[test]
3436        fn dropping_queued_blocking_work_closes_fd_before_returning_admission() -> anyhow::Result<()>
3437        {
3438            let runtime = tokio::runtime::Builder::new_multi_thread()
3439                .worker_threads(1)
3440                .max_blocking_threads(1)
3441                .enable_all()
3442                .build()?;
3443            runtime.block_on(async {
3444                let root = crate::testutils::create_temp_dir().await?;
3445                let file_path = root.join("queued-capture");
3446                tokio::fs::write(&file_path, b"x").await?;
3447                let admission = crate::testutils::AdmissionLimit::new().await;
3448                admission.set_files_in_flight(1);
3449                let open_file_guard = throttle::open_file_permit().await;
3450                let file = std::fs::File::open(file_path)?;
3451                let raw_fd = file.as_raw_fd();
3452                let probe = match crate::testutils::FdIdentityProbe::capture(raw_fd) {
3453                    Ok(probe) => probe,
3454                    Err(error) => {
3455                        drop(file);
3456                        drop(open_file_guard);
3457                        drop(admission);
3458                        let cleanup_result = tokio::fs::remove_dir_all(root).await;
3459                        return match cleanup_result {
3460                            Ok(()) => Err(error.into()),
3461                            Err(cleanup_error) => Err(anyhow::Error::new(error).context(format!(
3462                                "queued fd fixture did not clean up: {cleanup_error:#}"
3463                            ))),
3464                        };
3465                    }
3466                };
3467                let drop_observation = Arc::new(std::sync::Mutex::new(None));
3468
3469                let (blocker_started_tx, blocker_started_rx) = tokio::sync::oneshot::channel();
3470                let (release_blocker_tx, release_blocker_rx) = std::sync::mpsc::channel();
3471                let blocker = tokio::task::spawn_blocking(move || {
3472                    let _ = blocker_started_tx.send(());
3473                    release_blocker_rx
3474                        .recv()
3475                        .expect("blocking worker release sender must stay alive");
3476                });
3477                blocker_started_rx
3478                    .await
3479                    .context("the sole blocking worker did not start")?;
3480
3481                let (queued_started_tx, queued_started_rx) = tokio::sync::oneshot::channel();
3482                {
3483                    let admission = open_file_guard.admission();
3484                    let queued_capture = QueuedFdCapture {
3485                        file: Some(file),
3486                        probe,
3487                        observation: Arc::clone(&drop_observation),
3488                    };
3489                    let mut waiter = Box::pin(with_fd_admission(admission, async move {
3490                        run_fd_admitted_blocking(move || {
3491                            let _queued_capture = queued_capture;
3492                            let _ = queued_started_tx.send(());
3493                            Ok(())
3494                        })
3495                        .await
3496                    }));
3497                    assert!(
3498                        futures::poll!(waiter.as_mut()).is_pending(),
3499                        "the admitted blocking closure must queue behind the occupied worker"
3500                    );
3501                    // leave the blocking lease as the sole strong admission owner before
3502                    // exercising the queued-job drop order
3503                    drop(open_file_guard);
3504                }
3505
3506                // preserve probe failures until the occupied worker has been released and joined.
3507                let fd_was_closed_before_release = probe.original_is_closed();
3508                let drop_observation_before_release = drop_observation
3509                    .lock()
3510                    .expect("queued fd drop observation lock poisoned")
3511                    .take()
3512                    .transpose();
3513                let mut next_permit = Box::pin(throttle::open_file_permit());
3514                let (admission_returned_before_release, returned_admission) =
3515                    match futures::poll!(next_permit.as_mut()) {
3516                        std::task::Poll::Ready(permit) => (true, Some(permit)),
3517                        std::task::Poll::Pending => (false, None),
3518                    };
3519
3520                let release_result = release_blocker_tx.send(());
3521                let blocker_result = blocker.await;
3522                let queued_start_result =
3523                    tokio::time::timeout(std::time::Duration::from_secs(5), queued_started_rx)
3524                        .await;
3525                let capacity_error = match returned_admission {
3526                    Some(permit) => {
3527                        drop(permit);
3528                        None
3529                    }
3530                    None => tokio::time::timeout(
3531                        std::time::Duration::from_secs(5),
3532                        next_permit.as_mut(),
3533                    )
3534                    .await
3535                    .map(drop)
3536                    .map_err(anyhow::Error::new)
3537                    .map_err(|error| {
3538                        error.context("queued blocking work did not eventually return admission")
3539                    })
3540                    .err(),
3541                };
3542                drop(admission);
3543                let cleanup_result = tokio::fs::remove_dir_all(root).await;
3544
3545                let release_error = release_result.err().map(|error| {
3546                    anyhow::Error::new(error)
3547                        .context("the blocking worker ended before its release")
3548                });
3549                let blocker_error = blocker_result
3550                    .err()
3551                    .map(|error| anyhow::Error::new(error).context("the blocking worker panicked"));
3552                let fd_was_closed_before_release = fd_was_closed_before_release?;
3553                let drop_observation_before_release = drop_observation_before_release?;
3554                if let Some(error) = capacity_error {
3555                    return Err(error);
3556                }
3557                if let Some(error) = release_error {
3558                    return Err(error);
3559                }
3560                if let Some(error) = blocker_error {
3561                    return Err(error);
3562                }
3563                cleanup_result?;
3564
3565                assert!(
3566                    fd_was_closed_before_release,
3567                    "dropping the queued waiter left its captured fd open"
3568                );
3569                assert!(
3570                    admission_returned_before_release,
3571                    "dropping the queued waiter retained fd admission"
3572                );
3573                assert_eq!(
3574                    drop_observation_before_release,
3575                    Some(QueuedFdDropObservation {
3576                        fd_was_closed: true,
3577                        admission_was_retained: true,
3578                    }),
3579                    "the queued work must close its fd before its admission lease drops"
3580                );
3581                assert!(
3582                    matches!(queued_start_result, Ok(Err(_))),
3583                    "the dropped queued blocking work started"
3584                );
3585                Ok(())
3586            })
3587        }
3588
3589        /// Cancelling an unprobed blocking waiter must retain any ambient fd admission until its
3590        /// detached closure releases the descriptor it owns. Directory enumeration uses this
3591        /// lower-level boundary because `getdents` is deliberately excluded from congestion
3592        /// probing.
3593        #[tokio::test]
3594        async fn cancelled_unprobed_waiter_keeps_admission_until_fd_owner_finishes()
3595        -> anyhow::Result<()> {
3596            let root = crate::testutils::create_temp_dir().await?;
3597            let file_path = root.join("held-unprobed");
3598            tokio::fs::write(&file_path, b"x").await?;
3599            let admission = crate::testutils::AdmissionLimit::new().await;
3600            admission.set_files_in_flight(1);
3601            let open_file_guard = throttle::open_file_permit().await;
3602            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3603            let (release_tx, release_rx) = std::sync::mpsc::channel();
3604            let (completion, completion_rx) = crate::testutils::CompletionSignal::new();
3605            let task = tokio::spawn(async move {
3606                let admission = open_file_guard.admission();
3607                with_fd_admission(admission, async move {
3608                    let _open_file_guard = open_file_guard;
3609                    run_fd_admitted_blocking(move || {
3610                        let _completion = completion;
3611                        let _file = std::fs::File::open(file_path)?;
3612                        let _ = started_tx.send(());
3613                        release_rx.recv().map_err(std::io::Error::other)?;
3614                        Ok(())
3615                    })
3616                    .await
3617                })
3618                .await
3619            });
3620            let started_result = started_rx.await;
3621            task.abort();
3622            let waiter_was_cancelled = matches!(task.await, Err(error) if error.is_cancelled());
3623
3624            let mut second_permit = Box::pin(throttle::open_file_permit());
3625            let admission_was_retained =
3626                started_result.is_ok() && futures::poll!(second_permit.as_mut()).is_pending();
3627            let release_result = release_tx.send(());
3628            let (completion_result, permit) = crate::testutils::await_completion_and_capacity(
3629                completion_rx,
3630                second_permit.as_mut(),
3631            )
3632            .await;
3633            started_result.context("detached unprobed work did not start")?;
3634            release_result.context("detached unprobed work ended before its release")?;
3635            completion_result.context("detached unprobed work did not report completion")?;
3636            drop(permit);
3637            assert!(waiter_was_cancelled);
3638            assert!(
3639                admission_was_retained,
3640                "cancelling the waiter released admission while detached work held an fd"
3641            );
3642            Ok(())
3643        }
3644
3645        /// Cancelling a metadata waiter must not release admission while its detached blocking
3646        /// closure still owns a file descriptor.
3647        #[tokio::test]
3648        async fn cancelled_metadata_waiter_keeps_admission_until_fd_owner_finishes()
3649        -> anyhow::Result<()> {
3650            let root = crate::testutils::create_temp_dir().await?;
3651            let file_path = root.join("held");
3652            tokio::fs::write(&file_path, b"x").await?;
3653            let admission = crate::testutils::AdmissionLimit::new().await;
3654            admission.set_files_in_flight(1);
3655            let stat_resource =
3656                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
3657            admission.set_max_ops_in_flight(stat_resource, 1);
3658            let open_file_guard = throttle::open_file_permit().await;
3659            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3660            let (release_tx, release_rx) = std::sync::mpsc::channel();
3661            let (completion, completion_rx) = crate::testutils::CompletionSignal::new();
3662            let task = tokio::spawn(async move {
3663                let admission = open_file_guard.admission();
3664                with_fd_admission(admission, async move {
3665                    let _open_file_guard = open_file_guard;
3666                    run_metadata_probed_blocking(
3667                        congestion::Side::Source,
3668                        congestion::MetadataOp::Stat,
3669                        move || {
3670                            let _completion = completion;
3671                            let _file = std::fs::File::open(file_path)?;
3672                            let _ = started_tx.send(());
3673                            release_rx.recv().map_err(std::io::Error::other)?;
3674                            Ok(())
3675                        },
3676                    )
3677                    .await
3678                })
3679                .await
3680            });
3681            let started_result = started_rx.await;
3682            task.abort();
3683            let waiter_was_cancelled = matches!(task.await, Err(error) if error.is_cancelled());
3684
3685            let second_permit = throttle::open_file_permit();
3686            tokio::pin!(second_permit);
3687            let admission_was_retained =
3688                started_result.is_ok() && futures::poll!(second_permit.as_mut()).is_pending();
3689            let second_stat_permit = throttle::ops_in_flight_permit(stat_resource);
3690            tokio::pin!(second_stat_permit);
3691            let metadata_was_retained =
3692                started_result.is_ok() && futures::poll!(second_stat_permit.as_mut()).is_pending();
3693            let release_result = release_tx.send(());
3694            let (completion_result, (permit, stat_permit)) =
3695                crate::testutils::await_completion_and_capacity(
3696                    completion_rx,
3697                    futures::future::join(second_permit.as_mut(), second_stat_permit.as_mut()),
3698                )
3699                .await;
3700            started_result.context("detached metadata work did not start")?;
3701            release_result.context("detached metadata work ended before its release")?;
3702            completion_result.context("detached metadata work did not report completion")?;
3703            drop(permit);
3704            drop(stat_permit);
3705            assert!(waiter_was_cancelled);
3706            assert!(
3707                admission_was_retained,
3708                "cancelling the waiter released admission while detached work held an fd"
3709            );
3710            assert!(
3711                metadata_was_retained,
3712                "cancelling the waiter released metadata capacity while its syscall was live"
3713            );
3714            Ok(())
3715        }
3716
3717        /// A detached blocking task's returned fd must be dropped before its admission lease. The
3718        /// fd lives in the abandoned task output rather than inside the syscall closure itself.
3719        #[tokio::test]
3720        async fn abandoned_metadata_output_keeps_admission_until_returned_fd_drops()
3721        -> anyhow::Result<()> {
3722            let root = crate::testutils::create_temp_dir().await?;
3723            let file_path = root.join("returned");
3724            tokio::fs::write(&file_path, b"x").await?;
3725            let admission = crate::testutils::AdmissionLimit::new().await;
3726            admission.set_files_in_flight(1);
3727            let open_file_guard = throttle::open_file_permit().await;
3728            let (work_started_tx, work_started_rx) = tokio::sync::oneshot::channel();
3729            let (release_work_tx, release_work_rx) = std::sync::mpsc::channel();
3730            let (drop_started_tx, drop_started_rx) = tokio::sync::oneshot::channel();
3731            let (release_drop_tx, release_drop_rx) = std::sync::mpsc::channel();
3732            let (completion, completion_rx) = crate::testutils::CompletionSignal::new();
3733            let task = tokio::spawn(async move {
3734                let admission = open_file_guard.admission();
3735                with_fd_admission(admission, async move {
3736                    let _open_file_guard = open_file_guard;
3737                    run_metadata_probed_blocking(
3738                        congestion::Side::Source,
3739                        congestion::MetadataOp::Stat,
3740                        move || {
3741                            let file = std::fs::File::open(file_path)?;
3742                            let _ = work_started_tx.send(());
3743                            release_work_rx.recv().map_err(std::io::Error::other)?;
3744                            Ok(BlockingDropFd {
3745                                _file: file,
3746                                drop_started: Some(drop_started_tx),
3747                                release_drop: release_drop_rx,
3748                                _completion: completion,
3749                            })
3750                        },
3751                    )
3752                    .await
3753                })
3754                .await
3755            });
3756            let work_started_result = work_started_rx.await;
3757            task.abort();
3758            let waiter_was_cancelled = matches!(task.await, Err(error) if error.is_cancelled());
3759
3760            let release_work_result = release_work_tx.send(());
3761            let drop_started_result = drop_started_rx.await;
3762            let admission_was_retained = {
3763                let second_permit = throttle::open_file_permit();
3764                tokio::pin!(second_permit);
3765                drop_started_result.is_ok() && futures::poll!(second_permit.as_mut()).is_pending()
3766            };
3767            let release_drop_result = release_drop_tx.send(());
3768            let (completion_result, permit) = crate::testutils::await_completion_and_capacity(
3769                completion_rx,
3770                throttle::open_file_permit(),
3771            )
3772            .await;
3773            drop(permit);
3774            let cleanup_result = tokio::fs::remove_dir_all(root).await;
3775            work_started_result.context("detached metadata work did not start")?;
3776            release_work_result.context("detached metadata work ended before its release")?;
3777            drop_started_result.context("the abandoned returned fd did not begin dropping")?;
3778            release_drop_result.context("the abandoned returned fd ended before its release")?;
3779            completion_result.context("the abandoned returned fd did not report completion")?;
3780            cleanup_result?;
3781            assert!(
3782                waiter_was_cancelled,
3783                "the async metadata waiter must observe cancellation"
3784            );
3785            assert!(
3786                admission_was_retained,
3787                "admission was released before the abandoned returned fd was dropped"
3788            );
3789            Ok(())
3790        }
3791
3792        /// An abandoned unprobed blocking result must drop its fd owner before returning the
3793        /// strong admission lease. The data-copy path uses this lower-level runner directly.
3794        #[tokio::test]
3795        async fn abandoned_unprobed_output_keeps_admission_until_returned_fd_drops()
3796        -> anyhow::Result<()> {
3797            let root = crate::testutils::create_temp_dir().await?;
3798            let file_path = root.join("returned-unprobed");
3799            tokio::fs::write(&file_path, b"x").await?;
3800            let admission = crate::testutils::AdmissionLimit::new().await;
3801            admission.set_files_in_flight(1);
3802            let open_file_guard = throttle::open_file_permit().await;
3803            let (work_started_tx, work_started_rx) = tokio::sync::oneshot::channel();
3804            let (release_work_tx, release_work_rx) = std::sync::mpsc::channel();
3805            let (drop_started_tx, drop_started_rx) = tokio::sync::oneshot::channel();
3806            let (release_drop_tx, release_drop_rx) = std::sync::mpsc::channel();
3807            let (completion, completion_rx) = crate::testutils::CompletionSignal::new();
3808            let task = tokio::spawn(async move {
3809                let admission = open_file_guard.admission();
3810                with_fd_admission(admission, async move {
3811                    let _open_file_guard = open_file_guard;
3812                    run_fd_admitted_blocking(move || {
3813                        let file = std::fs::File::open(file_path)?;
3814                        let _ = work_started_tx.send(());
3815                        release_work_rx.recv().map_err(std::io::Error::other)?;
3816                        Ok(BlockingDropFd {
3817                            _file: file,
3818                            drop_started: Some(drop_started_tx),
3819                            release_drop: release_drop_rx,
3820                            _completion: completion,
3821                        })
3822                    })
3823                    .await
3824                })
3825                .await
3826            });
3827            let work_started_result = work_started_rx.await;
3828            task.abort();
3829            let waiter_was_cancelled = matches!(task.await, Err(error) if error.is_cancelled());
3830            let release_work_result = release_work_tx.send(());
3831            let drop_started_result = drop_started_rx.await;
3832            let admission_was_retained = {
3833                let second_permit = throttle::open_file_permit();
3834                tokio::pin!(second_permit);
3835                drop_started_result.is_ok() && futures::poll!(second_permit.as_mut()).is_pending()
3836            };
3837            let release_drop_result = release_drop_tx.send(());
3838            let (completion_result, permit) = crate::testutils::await_completion_and_capacity(
3839                completion_rx,
3840                throttle::open_file_permit(),
3841            )
3842            .await;
3843            drop(permit);
3844            let cleanup_result = tokio::fs::remove_dir_all(root).await;
3845            work_started_result.context("detached unprobed work did not start")?;
3846            release_work_result.context("detached unprobed work ended before its release")?;
3847            drop_started_result.context("the abandoned returned fd did not begin dropping")?;
3848            release_drop_result.context("the abandoned returned fd ended before its release")?;
3849            completion_result.context("the abandoned returned fd did not report completion")?;
3850            cleanup_result?;
3851            assert!(
3852                waiter_was_cancelled,
3853                "the async waiter must observe cancellation"
3854            );
3855            assert!(
3856                admission_was_retained,
3857                "admission was released before the abandoned returned fd was dropped"
3858            );
3859            Ok(())
3860        }
3861    }
3862
3863    #[test]
3864    fn a_run_that_asked_for_nothing_wants_no_root_acl_notice() {
3865        // the gate this whole type exists for: a copy at the shipped default already drops uid,
3866        // gid, timestamps and the special mode bits silently, so an ACL notice there is advice
3867        // about a completeness nobody claimed — and it must cost nothing, kind unknown included.
3868        let notice = RootAclNotice::for_preserve(&crate::preserve::preserve_none());
3869        assert!(!notice.wanted);
3870        for kind in EVERY_KIND {
3871            assert!(
3872                !notice.could_warn_for_strict(kind, false),
3873                "a {kind:?} root warned under settings that asked for no preservation"
3874            );
3875        }
3876    }
3877
3878    #[test]
3879    fn strict_resolution_arms_the_notice_on_its_own() {
3880        // `--require-toctou-safe` is a request ABOUT permissions whatever `--preserve-settings`
3881        // says, and the two flags deliberately do not imply each other, so it must arm the notice
3882        // by itself — with its own wording, which `warn_root_acl` picks the same way.
3883        let notice = RootAclNotice::for_preserve(&crate::preserve::preserve_none());
3884        assert!(notice.could_warn_for_strict(EntryKind::Dir, true));
3885        assert!(notice.could_warn_for_strict(EntryKind::File, true));
3886    }
3887
3888    #[test]
3889    fn the_notice_consults_the_setting_for_the_roots_own_kind() {
3890        // `f:acl` and `d:acl` are independent. Getting this wrong is silent in both directions:
3891        // one way it warns about an ACL the copy is carrying across, the other it stays quiet
3892        // about one being dropped.
3893        let file_only = RootAclNotice {
3894            wanted: true,
3895            file_acl_preserved: true,
3896            dir_acl_preserved: false,
3897        };
3898        assert!(!file_only.could_warn_for_strict(EntryKind::File, false));
3899        assert!(file_only.could_warn_for_strict(EntryKind::Dir, false));
3900        assert!(
3901            file_only.could_warn(),
3902            "a directory root can still warn, so the kind-unknown pre-check must not skip the probe"
3903        );
3904    }
3905
3906    #[test]
3907    fn a_root_that_cannot_carry_an_acl_never_warns() {
3908        // the kernel has no symlink ACL, and a special file is not copied as an object whose
3909        // permissions rcp reproduces — so neither reaches the probe, armed or not
3910        let armed = RootAclNotice {
3911            wanted: true,
3912            file_acl_preserved: false,
3913            dir_acl_preserved: false,
3914        };
3915        for kind in [EntryKind::Symlink, EntryKind::Special] {
3916            assert!(!armed.could_warn_for_strict(kind, true));
3917        }
3918    }
3919
3920    #[test]
3921    fn preserving_both_kinds_acls_leaves_nothing_to_warn_about() {
3922        let notice = RootAclNotice::for_preserve(&crate::preserve::preserve_all_with_acls());
3923        assert!(notice.wanted, "`all+acl` did ask for preservation");
3924        assert!(
3925            !notice.could_warn(),
3926            "both kinds' ACLs are carried across, so even an armed run has nothing to say — and \
3927             must not pay the probe to find that out"
3928        );
3929    }
3930
3931    #[tokio::test]
3932    async fn child_classifies_file_dir_symlink_and_rejects_nofollow() -> anyhow::Result<()> {
3933        let tmp = testutils::setup_test_dir().await?;
3934        // setup_test_dir() returns the temp dir; the fixture lives at tmp/foo/
3935        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
3936        assert_eq!(
3937            root.child(OsStr::new("0.txt")).await?.kind(),
3938            EntryKind::File
3939        );
3940        assert_eq!(root.child(OsStr::new("bar")).await?.kind(), EntryKind::Dir);
3941        tokio::fs::symlink("0.txt", tmp.join("foo/lnk")).await?;
3942        assert_eq!(
3943            root.child(OsStr::new("lnk")).await?.kind(),
3944            EntryKind::Symlink
3945        );
3946        // open_dir on a symlinked "dir" must fail closed (ELOOP/ENOTDIR), never follow
3947        tokio::fs::symlink("/etc", tmp.join("foo/evil")).await?;
3948        assert!(root.open_dir(OsStr::new("evil")).await.is_err());
3949        Ok(())
3950    }
3951
3952    #[tokio::test]
3953    async fn open_dir_succeeds_on_real_directory() -> anyhow::Result<()> {
3954        let tmp = testutils::setup_test_dir().await?;
3955        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
3956        // bar is a real directory; open_dir must succeed and yield a usable Dir
3957        let bar = root.open_dir(OsStr::new("bar")).await?;
3958        // and the resulting Dir is functional: it can classify its own children
3959        assert_eq!(
3960            bar.child(OsStr::new("1.txt")).await?.kind(),
3961            EntryKind::File
3962        );
3963        Ok(())
3964    }
3965
3966    #[cfg_attr(
3967        rcp_nix_sandbox,
3968        ignore = "Nix sandbox cannot change ownership or set-id modes"
3969    )]
3970    #[tokio::test]
3971    async fn secure_as_copier_takes_ownership_restricts_mode_and_preserves_gid()
3972    -> anyhow::Result<()> {
3973        use std::os::unix::fs::PermissionsExt as _;
3974        let tmp = testutils::setup_test_dir().await?;
3975        let root =
3976            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
3977
3978        // a plain reused subdir locked down to 0o700: ownership becomes the copier, the mode is
3979        // exactly 0o700, and the gid is unchanged (no race means no gid write at all).
3980        let plain = root.make_dir(OsStr::new("reused_plain"), 0o755).await?;
3981        let before = plain.meta().await?;
3982        plain
3983            .secure_as_copier(0o700, before.uid(), before.gid())
3984            .await?;
3985        let after = plain.meta().await?;
3986        assert_eq!(
3987            after.permissions().mode() & 0o7777,
3988            0o700,
3989            "mode restricted"
3990        );
3991        assert_eq!(
3992            after.uid(),
3993            nix::unistd::geteuid().as_raw(),
3994            "owned by copier"
3995        );
3996        assert_eq!(after.gid(), before.gid(), "gid unchanged when it matches");
3997
3998        // a setgid reused subdir: BOTH the S_ISGID bit and the gid value must survive the
3999        // lockdown, since children created during the copy inherit the directory's group.
4000        let sg = root.make_dir(OsStr::new("reused_setgid"), 0o755).await?;
4001        std::fs::set_permissions(
4002            tmp.join("foo/reused_setgid"),
4003            std::fs::Permissions::from_mode(0o2755),
4004        )?;
4005        let sg_before = sg.meta().await?;
4006        assert_eq!(
4007            sg_before.permissions().mode() & 0o2000,
4008            0o2000,
4009            "test setup: S_ISGID is set before lockdown"
4010        );
4011        sg.secure_as_copier(0o2700, sg_before.uid(), sg_before.gid())
4012            .await?;
4013        let sg_after = sg.meta().await?;
4014        assert_eq!(
4015            sg_after.permissions().mode() & 0o7777,
4016            0o2700,
4017            "S_ISGID + 0o700 both preserved"
4018        );
4019        assert_eq!(
4020            sg_after.gid(),
4021            sg_before.gid(),
4022            "gid preserved under setgid"
4023        );
4024        Ok(())
4025    }
4026
4027    // FIX A (PR #247 review): `open_parent_dir` resolves a TRUSTED command-line parent prefix
4028    // following symlinks (the final component IS followed), while `open_root_dir` keeps the operand
4029    // entry `O_NOFOLLOW` and descendants stay hardened. This pins the parent-prefix-vs-operand
4030    // distinction and proves the hardening below the followed prefix is unchanged.
4031    #[tokio::test]
4032    async fn open_parent_dir_follows_symlinked_prefix_but_descendants_stay_hardened()
4033    -> anyhow::Result<()> {
4034        let tmp = testutils::setup_test_dir().await?;
4035        // a symlink-to-dir standing in for a trusted parent prefix component.
4036        tokio::fs::symlink("foo", tmp.join("foo_link")).await?;
4037        // open_parent_dir FOLLOWS the symlinked final component into the real `foo` directory,
4038        // yielding a TrustedDir; into_tree() crosses into the hardened tree below it.
4039        let parent = Dir::open_parent_dir(&tmp.join("foo_link"), congestion::Side::Source).await?;
4040        let tree = parent.into_tree();
4041        // the followed dir is functional: it sees `foo`'s real children.
4042        assert_eq!(
4043            tree.child(OsStr::new("0.txt")).await?.kind(),
4044            EntryKind::File
4045        );
4046        // open_root_dir on the SAME symlinked path (dereference=false) must instead fail closed —
4047        // it `O_NOFOLLOW`s the final component (the operand-entry contract), proving the two entry
4048        // points differ exactly at the final-component follow decision.
4049        assert!(
4050            Dir::open_root_dir(&tmp.join("foo_link"), false, congestion::Side::Source)
4051                .await
4052                .is_err()
4053        );
4054        // hardening below the followed prefix is UNCHANGED: a symlinked child reached via the
4055        // followed parent still fails closed (O_NOFOLLOW) rather than being followed.
4056        tokio::fs::symlink("/etc", tmp.join("foo/evil_below")).await?;
4057        assert!(tree.open_dir(OsStr::new("evil_below")).await.is_err());
4058        Ok(())
4059    }
4060
4061    #[tokio::test]
4062    async fn rejects_multi_component_names() -> anyhow::Result<()> {
4063        let tmp = testutils::setup_test_dir().await?;
4064        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
4065        // names with a path separator could traverse an intermediate symlink, so
4066        // they are rejected with EINVAL before any syscall (release-safe check)
4067        for bad in ["bar/1.txt", "..", ".", ""] {
4068            let child_err = root.child(OsStr::new(bad)).await.unwrap_err();
4069            assert_eq!(child_err.raw_os_error(), Some(libc::EINVAL));
4070            let dir_err = root.open_dir(OsStr::new(bad)).await.unwrap_err();
4071            assert_eq!(dir_err.raw_os_error(), Some(libc::EINVAL));
4072            let file_err = root.open_file_read(OsStr::new(bad)).await.unwrap_err();
4073            assert_eq!(file_err.raw_os_error(), Some(libc::EINVAL));
4074            let create_err = root.create_file(OsStr::new(bad)).await.unwrap_err();
4075            assert_eq!(create_err.raw_os_error(), Some(libc::EINVAL));
4076        }
4077        Ok(())
4078    }
4079
4080    // Regression for the spawn_blocking cancellation soundness bug: the Dir's fd
4081    // lives behind an Arc that each operation clones into its closure, so an op
4082    // stays sound even after the originating Dir is dropped. We model the
4083    // detached-closure case by cloning a Dir, dropping the original, and
4084    // confirming the clone still opens children correctly.
4085    #[tokio::test]
4086    async fn operations_remain_valid_after_original_dir_dropped() -> anyhow::Result<()> {
4087        let tmp = testutils::setup_test_dir().await?;
4088        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
4089        // clone the underlying Arc-held fd into a second Dir, then drop the original
4090        let shared = Dir {
4091            fd: root.fd.clone(),
4092            side: root.side,
4093            children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(true)),
4094        };
4095        drop(root);
4096        // the shared handle's open file description is still alive; ops succeed
4097        assert_eq!(
4098            shared.child(OsStr::new("0.txt")).await?.kind(),
4099            EntryKind::File
4100        );
4101        let bar = shared.open_dir(OsStr::new("bar")).await?;
4102        assert_eq!(
4103            bar.child(OsStr::new("2.txt")).await?.kind(),
4104            EntryKind::File
4105        );
4106        Ok(())
4107    }
4108
4109    // open_file_read: verify that a regular file can be opened, metadata size is
4110    // correct, and the returned File is readable.
4111    #[tokio::test]
4112    async fn open_file_read_reads_regular_file() -> anyhow::Result<()> {
4113        let tmp = testutils::setup_test_dir().await?;
4114        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
4115        let (mut file, meta) = root.open_file_read(OsStr::new("0.txt")).await?;
4116        // "0.txt" contains the single byte "0"
4117        assert_eq!(meta.size(), 1);
4118        let mut buf = String::new();
4119        file.read_to_string(&mut buf)?;
4120        assert_eq!(buf, "0");
4121        Ok(())
4122    }
4123
4124    // open_file_read: a FIFO must not cause open to block (O_NONBLOCK) AND the
4125    // S_ISREG check must reject it, so the call returns Err without hanging.
4126    #[tokio::test]
4127    async fn open_file_read_rejects_fifo_without_blocking() -> anyhow::Result<()> {
4128        let tmp = testutils::setup_test_dir().await?;
4129        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
4130        let fifo_path = tmp.join("foo/test.fifo");
4131        nix::unistd::mkfifo(
4132            &fifo_path,
4133            nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
4134        )?;
4135        // the call must return (not block) within the timeout, and must be an Err
4136        let result = tokio::time::timeout(
4137            std::time::Duration::from_secs(5),
4138            root.open_file_read(OsStr::new("test.fifo")),
4139        )
4140        .await;
4141        assert!(result.is_ok(), "open_file_read blocked on FIFO (timed out)");
4142        assert!(
4143            result.unwrap().is_err(),
4144            "open_file_read must reject a FIFO"
4145        );
4146        Ok(())
4147    }
4148
4149    // open_file_read: a symlink must be rejected (ELOOP from O_NOFOLLOW).
4150    #[tokio::test]
4151    async fn open_file_read_rejects_symlink() -> anyhow::Result<()> {
4152        let tmp = testutils::setup_test_dir().await?;
4153        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
4154        // create a symlink pointing to a real file
4155        tokio::fs::symlink("0.txt", tmp.join("foo/link_to_0")).await?;
4156        let result = root.open_file_read(OsStr::new("link_to_0")).await;
4157        assert!(result.is_err(), "open_file_read must reject a symlink");
4158        Ok(())
4159    }
4160
4161    // create_file: successfully creates a new writable file.
4162    #[tokio::test]
4163    async fn create_file_creates_new_writable_file() -> anyhow::Result<()> {
4164        let tmp = testutils::setup_test_dir().await?;
4165        // use a dest-side dir for the write target
4166        let root =
4167            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4168        let mut file = root.create_file(OsStr::new("new.txt")).await?;
4169        use std::io::Write;
4170        file.write_all(b"hello safedir")?;
4171        drop(file);
4172        // re-open via std and verify the content
4173        let content = std::fs::read(tmp.join("foo/new.txt"))?;
4174        assert_eq!(content, b"hello safedir");
4175        Ok(())
4176    }
4177
4178    // create_file: fails with EEXIST when the file already exists.
4179    #[tokio::test]
4180    async fn create_file_fails_if_exists() -> anyhow::Result<()> {
4181        let tmp = testutils::setup_test_dir().await?;
4182        let root =
4183            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4184        // "0.txt" already exists in the fixture
4185        let err = root.create_file(OsStr::new("0.txt")).await.unwrap_err();
4186        assert_eq!(
4187            err.raw_os_error(),
4188            Some(libc::EEXIST),
4189            "expected EEXIST, got {err:#}"
4190        );
4191        Ok(())
4192    }
4193
4194    // make_dir: creates the directory and returns a usable Dir handle.
4195    #[tokio::test]
4196    async fn make_dir_creates_and_returns_usable_dir() -> anyhow::Result<()> {
4197        let tmp = testutils::setup_test_dir().await?;
4198        let root =
4199            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4200        let sub = root.make_dir(OsStr::new("sub"), 0o755).await?;
4201        // the returned Dir must be usable: create a file inside it
4202        sub.create_file(OsStr::new("child.txt")).await?;
4203        // and read_entries on the sub dir must show that file
4204        let entries = sub.read_entries().await?;
4205        let names: Vec<_> = entries
4206            .iter()
4207            .map(|(n, _)| n.to_string_lossy().into_owned())
4208            .collect();
4209        assert!(
4210            names.contains(&"child.txt".to_string()),
4211            "child.txt not found in {names:?}"
4212        );
4213        Ok(())
4214    }
4215
4216    // make_dir: multi-component names must be rejected with EINVAL.
4217    #[tokio::test]
4218    async fn make_dir_rejects_multi_component_names() -> anyhow::Result<()> {
4219        let tmp = testutils::setup_test_dir().await?;
4220        let root =
4221            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4222        for bad in ["a/b", "..", ".", ""] {
4223            let err = root.make_dir(OsStr::new(bad), 0o755).await.unwrap_err();
4224            assert_eq!(
4225                err.raw_os_error(),
4226                Some(libc::EINVAL),
4227                "expected EINVAL for {:?}, got {err:#}",
4228                bad
4229            );
4230        }
4231        Ok(())
4232    }
4233
4234    // read_entries: returns all entries with correct d_type hints.
4235    #[tokio::test]
4236    async fn read_entries_lists_children_with_dtype_hints() -> anyhow::Result<()> {
4237        use std::collections::HashMap;
4238        let tmp = testutils::setup_test_dir().await?;
4239        // baz contains: 4.txt (file), 5.txt (symlink), 6.txt (symlink)
4240        // use bar which has only files; instead build a custom fixture in foo
4241        let fixture = tmp.join("foo/fixture_dir");
4242        tokio::fs::create_dir(&fixture).await?;
4243        tokio::fs::write(fixture.join("afile.txt"), "x").await?;
4244        tokio::fs::create_dir(fixture.join("asubdir")).await?;
4245        tokio::fs::symlink("afile.txt", fixture.join("alink")).await?;
4246
4247        let root = Dir::open_root_dir(&fixture, false, congestion::Side::Source).await?;
4248        let entries = root.read_entries().await?;
4249        let map: HashMap<String, Option<EntryKind>> = entries
4250            .into_iter()
4251            .map(|(n, k)| (n.to_string_lossy().into_owned(), k))
4252            .collect();
4253
4254        assert_eq!(map.len(), 3, "expected 3 entries, got {map:?}");
4255        assert_eq!(
4256            map.get("afile.txt"),
4257            Some(&Some(EntryKind::File)),
4258            "afile.txt wrong"
4259        );
4260        assert_eq!(
4261            map.get("asubdir"),
4262            Some(&Some(EntryKind::Dir)),
4263            "asubdir wrong"
4264        );
4265        assert_eq!(
4266            map.get("alink"),
4267            Some(&Some(EntryKind::Symlink)),
4268            "alink wrong"
4269        );
4270        Ok(())
4271    }
4272
4273    // read_entries: calling it twice on the same Dir must succeed (fd not consumed).
4274    #[tokio::test]
4275    async fn read_entries_does_not_close_self_fd() -> anyhow::Result<()> {
4276        let tmp = testutils::setup_test_dir().await?;
4277        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
4278        // first call
4279        let first = root.read_entries().await?;
4280        assert!(!first.is_empty(), "first read_entries returned empty");
4281        // second call on the SAME Dir must yield the identical entry set, not
4282        // just an equal count. read_entries dups a fd that shares the directory
4283        // read offset, so absent nix's rewinddir-on-completion this second call
4284        // would see an empty (or partial) listing. The hardened remote source
4285        // depends on exactly this re-entrancy (Pass 1 then Pass 2 enumerate the
4286        // same Arc<Dir>).
4287        let second = root.read_entries().await?;
4288        let mut first_names: Vec<_> = first.iter().map(|(name, _)| name.clone()).collect();
4289        let mut second_names: Vec<_> = second.iter().map(|(name, _)| name.clone()).collect();
4290        first_names.sort();
4291        second_names.sort();
4292        assert_eq!(
4293            first_names, second_names,
4294            "second read_entries differs from first"
4295        );
4296        // also prove child() still works on the same Dir
4297        root.child(OsStr::new("0.txt")).await?;
4298        Ok(())
4299    }
4300
4301    // create_file: refuses to follow or clobber an existing symlink.
4302    #[tokio::test]
4303    async fn create_file_refuses_existing_symlink() -> anyhow::Result<()> {
4304        let tmp = testutils::setup_test_dir().await?;
4305        let root =
4306            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4307        // plant a symlink pointing at a non-existent target
4308        let link_path = tmp.join("foo/evil_link");
4309        let target_path = tmp.join("foo/should_not_be_created");
4310        tokio::fs::symlink(&target_path, &link_path).await?;
4311        // create_file must fail, not follow the symlink and create the target
4312        let err = root.create_file(OsStr::new("evil_link")).await.unwrap_err();
4313        // O_CREAT|O_EXCL returns EEXIST on an existing symlink without following it
4314        assert_eq!(
4315            err.raw_os_error(),
4316            Some(libc::EEXIST),
4317            "expected EEXIST, got {err:#}"
4318        );
4319        // the symlink target must NOT have been created
4320        assert!(
4321            !target_path.exists(),
4322            "symlink target was unexpectedly created"
4323        );
4324        Ok(())
4325    }
4326
4327    // unlink_at: removes a regular file and confirms it is gone.
4328    #[tokio::test]
4329    async fn unlink_at_removes_file() -> anyhow::Result<()> {
4330        let tmp = testutils::setup_test_dir().await?;
4331        let root =
4332            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4333        // "0.txt" exists in the fixture
4334        root.unlink_at(OsStr::new("0.txt")).await?;
4335        // afterwards child() must fail with ENOENT
4336        let err = root.child(OsStr::new("0.txt")).await.unwrap_err();
4337        assert_eq!(
4338            err.raw_os_error(),
4339            Some(libc::ENOENT),
4340            "expected ENOENT after unlink, got {err:#}"
4341        );
4342        Ok(())
4343    }
4344
4345    // unlink_at: removes the symlink itself, not its target.
4346    #[tokio::test]
4347    async fn unlink_at_on_symlink_removes_link_not_target() -> anyhow::Result<()> {
4348        let tmp = testutils::setup_test_dir().await?;
4349        let root =
4350            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4351        // create a sentinel file with content, then symlink to it
4352        tokio::fs::write(tmp.join("foo/sentinel.txt"), b"alive").await?;
4353        tokio::fs::symlink("sentinel.txt", tmp.join("foo/lnk")).await?;
4354        // unlink the link
4355        root.unlink_at(OsStr::new("lnk")).await?;
4356        // link is gone
4357        let err = root.child(OsStr::new("lnk")).await.unwrap_err();
4358        assert_eq!(
4359            err.raw_os_error(),
4360            Some(libc::ENOENT),
4361            "expected ENOENT for removed link, got {err:#}"
4362        );
4363        // sentinel target still exists with content
4364        let content = tokio::fs::read(tmp.join("foo/sentinel.txt")).await?;
4365        assert_eq!(content, b"alive", "sentinel.txt was unexpectedly removed");
4366        Ok(())
4367    }
4368
4369    // rmdir_at: removes an empty directory; rejects non-empty (ENOTEMPTY) and a
4370    // regular file (ENOTDIR).
4371    #[tokio::test]
4372    async fn rmdir_at_removes_empty_dir_and_rejects_nonempty() -> anyhow::Result<()> {
4373        let tmp = testutils::setup_test_dir().await?;
4374        let root =
4375            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4376        // create an empty subdirectory and remove it
4377        tokio::fs::create_dir(tmp.join("foo/empty_sub")).await?;
4378        root.rmdir_at(OsStr::new("empty_sub")).await?;
4379        let err = root.child(OsStr::new("empty_sub")).await.unwrap_err();
4380        assert_eq!(
4381            err.raw_os_error(),
4382            Some(libc::ENOENT),
4383            "expected ENOENT after rmdir, got {err:#}"
4384        );
4385        // "bar" is non-empty in the fixture → ENOTEMPTY
4386        let err = root.rmdir_at(OsStr::new("bar")).await.unwrap_err();
4387        assert_eq!(
4388            err.raw_os_error(),
4389            Some(libc::ENOTEMPTY),
4390            "expected ENOTEMPTY for non-empty dir, got {err:#}"
4391        );
4392        // "0.txt" is a regular file → ENOTDIR
4393        let err = root.rmdir_at(OsStr::new("0.txt")).await.unwrap_err();
4394        assert_eq!(
4395            err.raw_os_error(),
4396            Some(libc::ENOTDIR),
4397            "expected ENOTDIR for regular file, got {err:#}"
4398        );
4399        Ok(())
4400    }
4401
4402    // symlink_at: creates a symlink and returns a Handle with kind Symlink;
4403    // read_link_at then returns the original target path.
4404    #[tokio::test]
4405    async fn symlink_at_creates_link_and_returns_pinned_handle() -> anyhow::Result<()> {
4406        let tmp = testutils::setup_test_dir().await?;
4407        let root =
4408            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4409        let target = std::path::Path::new("some/arbitrary/target");
4410        let handle = root.symlink_at(OsStr::new("mylink"), target).await?;
4411        assert_eq!(
4412            handle.kind(),
4413            EntryKind::Symlink,
4414            "symlink_at must return a Symlink handle"
4415        );
4416        // read_link_at must return the same target
4417        let read_back = root.read_link_at(OsStr::new("mylink")).await?;
4418        assert_eq!(
4419            read_back, target,
4420            "read_link_at returned wrong target: {read_back:?}"
4421        );
4422        Ok(())
4423    }
4424
4425    // read_link_handle reads the target inode-exact from the pinned O_PATH symlink handle (the
4426    // empty-path readlinkat form), so the target pairs with `handle.meta()` from the SAME fd. A
4427    // non-symlink handle is rejected (EINVAL).
4428    #[tokio::test]
4429    async fn read_link_handle_reads_target_from_pinned_handle() -> anyhow::Result<()> {
4430        let tmp = testutils::setup_test_dir().await?;
4431        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
4432        let target = std::path::Path::new("some/arbitrary/target");
4433        tokio::fs::symlink(target, tmp.join("foo/mylink")).await?;
4434        // classify the link, then read its target through that same pinned handle.
4435        let handle = root.child(OsStr::new("mylink")).await?;
4436        assert_eq!(handle.kind(), EntryKind::Symlink);
4437        let read_back = read_link_handle(&handle, congestion::Side::Source).await?;
4438        assert_eq!(read_back, target, "wrong target: {read_back:?}");
4439        // a non-symlink handle (a regular file) is rejected (the empty-path readlinkat form requires
4440        // a symlink fd; the kernel returns an error rather than a target). Callers only ever invoke
4441        // this on a Symlink-classified handle, so this is the defensive path.
4442        let file_handle = root.child(OsStr::new("0.txt")).await?;
4443        assert!(
4444            read_link_handle(&file_handle, congestion::Side::Source)
4445                .await
4446                .is_err(),
4447            "read_link_handle on a non-symlink must fail"
4448        );
4449        Ok(())
4450    }
4451
4452    // Handle::read_symlink returns target AND metadata from the one pinned O_PATH fd, so they are a
4453    // faithful pair (the symlink analogue of open_file_read).
4454    #[tokio::test]
4455    async fn read_symlink_returns_target_and_meta_from_one_handle() -> anyhow::Result<()> {
4456        use crate::preserve::Metadata as _;
4457        let tmp = testutils::setup_test_dir().await?;
4458        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
4459        let target = std::path::Path::new("some/target");
4460        tokio::fs::symlink(target, tmp.join("foo/lnk")).await?;
4461        let handle = root.child(OsStr::new("lnk")).await?;
4462        let (read_target, meta) = handle.read_symlink(congestion::Side::Source).await?;
4463        assert_eq!(read_target, target);
4464        // metadata is the symlink's own, from the same handle.
4465        assert_eq!(meta.uid(), handle.meta().uid());
4466        assert_eq!(meta.mtime(), handle.meta().mtime());
4467        Ok(())
4468    }
4469
4470    // Dir::meta fstats the directory's own held fd (the fd whose contents we enumerate).
4471    #[tokio::test]
4472    async fn dir_meta_returns_opened_dir_fstat() -> anyhow::Result<()> {
4473        use crate::preserve::Metadata as _;
4474        let tmp = testutils::setup_test_dir().await?;
4475        let bar = Dir::open_root_dir(&tmp.join("foo/bar"), false, congestion::Side::Source).await?;
4476        let meta = bar.meta().await?;
4477        let std_meta = std::fs::metadata(tmp.join("foo/bar"))?;
4478        // `meta.uid()` resolves via preserve::Metadata (the only trait FileMeta implements);
4479        // fully-qualify the std::fs::Metadata side, which implements both that trait and MetadataExt.
4480        assert_eq!(meta.uid(), std::os::unix::fs::MetadataExt::uid(&std_meta));
4481        assert_eq!(meta.gid(), std::os::unix::fs::MetadataExt::gid(&std_meta));
4482        Ok(())
4483    }
4484
4485    // hard_link_at: creates a hard link sharing the same inode.
4486    #[tokio::test]
4487    async fn hard_link_at_creates_hardlink() -> anyhow::Result<()> {
4488        let tmp = testutils::setup_test_dir().await?;
4489        // use two subdirs as src and dst Dir handles
4490        tokio::fs::create_dir(tmp.join("foo/src_sub")).await?;
4491        tokio::fs::create_dir(tmp.join("foo/dst_sub")).await?;
4492        tokio::fs::write(tmp.join("foo/src_sub/orig.txt"), b"hardlink test").await?;
4493
4494        let src =
4495            Dir::open_root_dir(&tmp.join("foo/src_sub"), false, congestion::Side::Source).await?;
4496        let dst = Dir::open_root_dir(
4497            &tmp.join("foo/dst_sub"),
4498            false,
4499            congestion::Side::Destination,
4500        )
4501        .await?;
4502
4503        src.hard_link_at(OsStr::new("orig.txt"), &dst, OsStr::new("link.txt"))
4504            .await?;
4505
4506        // both handles must exist and share the same inode
4507        let orig_handle = src.child(OsStr::new("orig.txt")).await?;
4508        let link_handle = dst.child(OsStr::new("link.txt")).await?;
4509        assert_eq!(orig_handle.kind(), EntryKind::File, "orig must be a file");
4510        assert_eq!(link_handle.kind(), EntryKind::File, "link must be a file");
4511        assert_eq!(
4512            orig_handle.ino(),
4513            link_handle.ino(),
4514            "hard link must share the inode"
4515        );
4516        Ok(())
4517    }
4518
4519    // hard_link_at: when the source name is a symlink, linkat with flags=0 does
4520    // NOT follow it — it links the symlink inode itself, so the new entry is also
4521    // a symlink.
4522    #[tokio::test]
4523    async fn hard_link_at_does_not_follow_source_symlink() -> anyhow::Result<()> {
4524        let tmp = testutils::setup_test_dir().await?;
4525        tokio::fs::create_dir(tmp.join("foo/src_hl")).await?;
4526        tokio::fs::create_dir(tmp.join("foo/dst_hl")).await?;
4527        // create a real file and a symlink to it in src_hl
4528        tokio::fs::write(tmp.join("foo/src_hl/real.txt"), b"target").await?;
4529        tokio::fs::symlink("real.txt", tmp.join("foo/src_hl/sym.txt")).await?;
4530
4531        let src =
4532            Dir::open_root_dir(&tmp.join("foo/src_hl"), false, congestion::Side::Source).await?;
4533        let dst = Dir::open_root_dir(
4534            &tmp.join("foo/dst_hl"),
4535            false,
4536            congestion::Side::Destination,
4537        )
4538        .await?;
4539
4540        // Linux does not allow hard-linking a symlink without AT_EMPTY_PATH or
4541        // special capabilities; linkat with flags=0 on a symlink yields EPERM.
4542        // Verify that the call does NOT silently follow the symlink into real.txt.
4543        let result = src
4544            .hard_link_at(OsStr::new("sym.txt"), &dst, OsStr::new("new_link.txt"))
4545            .await;
4546        match result {
4547            Ok(()) => {
4548                // If it succeeded (some kernels/configs allow it), the new entry
4549                // must be a symlink — NOT a hard link to the underlying file.
4550                let new_handle = dst.child(OsStr::new("new_link.txt")).await?;
4551                assert_eq!(
4552                    new_handle.kind(),
4553                    EntryKind::Symlink,
4554                    "hard_link_at must link the symlink itself, not its target"
4555                );
4556                // and real.txt must still have link-count 1 (no new hard link)
4557                let real_meta = std::fs::metadata(tmp.join("foo/src_hl/real.txt"))?;
4558                use std::os::unix::fs::MetadataExt;
4559                assert_eq!(
4560                    real_meta.nlink(),
4561                    1,
4562                    "real.txt must not gain a new hard link"
4563                );
4564            }
4565            Err(ref e) if e.raw_os_error() == Some(libc::EPERM) => {
4566                // expected on most Linux configurations; the important thing is
4567                // that it did NOT follow the symlink and link real.txt.
4568                // real.txt must still have exactly 1 hard link.
4569                let real_meta = std::fs::metadata(tmp.join("foo/src_hl/real.txt"))?;
4570                use std::os::unix::fs::MetadataExt;
4571                assert_eq!(
4572                    real_meta.nlink(),
4573                    1,
4574                    "real.txt must not gain a new hard link"
4575                );
4576            }
4577            Err(e) => {
4578                return Err(anyhow::anyhow!(
4579                    "unexpected error from hard_link_at on symlink: {e:#}"
4580                ));
4581            }
4582        }
4583        Ok(())
4584    }
4585
4586    // hard_link_handle_at links the exact inode the classified Handle pins, immune to a concurrent
4587    // source-name swap. The deterministic swap occurs after classification but before the link.
4588    //
4589    // the decoy is a different regular file with different content placed at the same name. A
4590    // by-name link would re-resolve `name` and link the decoy; `hard_link_handle_at` links the pinned
4591    // original inode regardless.
4592    #[tokio::test]
4593    async fn hard_link_handle_at_links_pinned_inode_after_name_swap() -> anyhow::Result<()> {
4594        use std::os::unix::fs::MetadataExt;
4595        let tmp = testutils::create_temp_dir().await?;
4596        tokio::fs::create_dir(tmp.join("src")).await?;
4597        tokio::fs::create_dir(tmp.join("dst")).await?;
4598        tokio::fs::write(tmp.join("src/entry"), b"ORIGINAL").await?;
4599        let orig_ino = tokio::fs::metadata(tmp.join("src/entry")).await?.ino();
4600
4601        let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
4602        let dst =
4603            Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
4604        // classify `entry` — pins the ORIGINAL regular-file inode via O_PATH.
4605        let handle = src.child(OsStr::new("entry")).await?;
4606        assert_eq!(handle.kind(), EntryKind::File);
4607
4608        // SWAP `entry` to a DIFFERENT regular file (the decoy) before linking. We keep
4609        // the original inode alive only through `handle` (its directory entry is gone),
4610        // mimicking an attacker renaming a decoy over the source name.
4611        tokio::fs::write(tmp.join("src/decoy"), b"DECOY_SECRET").await?;
4612        tokio::fs::rename(tmp.join("src/decoy"), tmp.join("src/entry")).await?;
4613        let decoy_ino = tokio::fs::metadata(tmp.join("src/entry")).await?.ino();
4614        assert_ne!(orig_ino, decoy_ino, "decoy must be a different inode");
4615
4616        // inode-exact link: either links the ORIGINAL pinned inode, or fails closed.
4617        match dst.hard_link_handle_at(&handle, OsStr::new("linked")).await {
4618            Ok(()) => {
4619                let lm = tokio::fs::symlink_metadata(tmp.join("dst/linked")).await?;
4620                assert!(
4621                    lm.file_type().is_file(),
4622                    "linked entry must be a regular file"
4623                );
4624                assert_eq!(
4625                    lm.ino(),
4626                    orig_ino,
4627                    "hard_link_handle_at must link the PINNED original inode, never the \
4628                     swapped-in decoy (the by-name link would have linked the decoy here)"
4629                );
4630                let content = tokio::fs::read_to_string(tmp.join("dst/linked")).await?;
4631                assert_eq!(
4632                    content, "ORIGINAL",
4633                    "must reflect the original inode's content"
4634                );
4635                assert_ne!(content, "DECOY_SECRET");
4636            }
4637            Err(e) => {
4638                // fail-closed is acceptable (e.g. the pinned inode's last link was
4639                // already gone). It must NEVER have linked the decoy.
4640                assert!(
4641                    !tmp.join("dst/linked").exists(),
4642                    "no destination entry may exist when the link failed closed (got {e:#})"
4643                );
4644            }
4645        }
4646        Ok(())
4647    }
4648
4649    // hard_link_handle_at must refuse to hard-link a DIRECTORY (linkat returns EPERM),
4650    // matching the by-name path — a hard link to a directory is never created.
4651    #[tokio::test]
4652    async fn hard_link_handle_at_refuses_directory() -> anyhow::Result<()> {
4653        let tmp = testutils::create_temp_dir().await?;
4654        tokio::fs::create_dir(tmp.join("src")).await?;
4655        tokio::fs::create_dir(tmp.join("dst")).await?;
4656        tokio::fs::create_dir(tmp.join("src/adir")).await?;
4657        let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
4658        let dst =
4659            Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
4660        let dir_handle = src.child(OsStr::new("adir")).await?;
4661        assert_eq!(dir_handle.kind(), EntryKind::Dir);
4662        let result = dst
4663            .hard_link_handle_at(&dir_handle, OsStr::new("linked_dir"))
4664            .await;
4665        assert!(
4666            result.is_err(),
4667            "hard_link_handle_at must refuse to hard-link a directory"
4668        );
4669        assert!(
4670            !tmp.join("dst/linked_dir").exists(),
4671            "no destination entry may be created for a directory hard link"
4672        );
4673        Ok(())
4674    }
4675
4676    // classify a regular file, then swap the source name to a FIFO before linking. A by-name
4677    // `linkat(flags=0)` would re-resolve the name and hard-link the FIFO, surfacing a special at the
4678    // destination that rlink reports as a hard-linked file. The inode-exact link must instead link
4679    // the pinned regular file or fail closed, so the destination is never special.
4680    #[tokio::test]
4681    async fn hard_link_handle_at_never_links_swapped_in_fifo() -> anyhow::Result<()> {
4682        use std::os::unix::fs::FileTypeExt;
4683        let tmp = testutils::create_temp_dir().await?;
4684        tokio::fs::create_dir(tmp.join("src")).await?;
4685        tokio::fs::create_dir(tmp.join("dst")).await?;
4686        tokio::fs::write(tmp.join("src/entry"), b"REALFILE").await?;
4687        let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
4688        let dst =
4689            Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
4690        // classify `entry` — pins the regular-file inode.
4691        let handle = src.child(OsStr::new("entry")).await?;
4692        assert_eq!(handle.kind(), EntryKind::File);
4693        // swap `entry` to a FIFO (keep the regular inode alive only via the handle).
4694        tokio::fs::remove_file(tmp.join("src/entry")).await?;
4695        nix::unistd::mkfifo(
4696            &tmp.join("src/entry"),
4697            nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
4698        )?;
4699        match dst.hard_link_handle_at(&handle, OsStr::new("linked")).await {
4700            Ok(()) => {
4701                let lm = tokio::fs::symlink_metadata(tmp.join("dst/linked")).await?;
4702                assert!(
4703                    lm.file_type().is_file(),
4704                    "linked entry must be the pinned regular file, never the swapped-in FIFO"
4705                );
4706                assert!(
4707                    !lm.file_type().is_fifo(),
4708                    "the destination must never be a special (the by-name link would link the FIFO)"
4709                );
4710                let content = tokio::fs::read_to_string(tmp.join("dst/linked")).await?;
4711                assert_eq!(content, "REALFILE");
4712            }
4713            Err(_) => {
4714                // fail-closed is acceptable; nothing may be left at the destination.
4715                assert!(
4716                    !tmp.join("dst/linked").exists(),
4717                    "no destination entry may exist when the link failed closed"
4718                );
4719            }
4720        }
4721        Ok(())
4722    }
4723
4724    // hard_link_handle_at on a STABLE regular file links exactly like the by-name path
4725    // did (same inode, same content) — the happy path is unchanged.
4726    #[tokio::test]
4727    async fn hard_link_handle_at_stable_file_happy_path() -> anyhow::Result<()> {
4728        let tmp = testutils::create_temp_dir().await?;
4729        tokio::fs::create_dir(tmp.join("src")).await?;
4730        tokio::fs::create_dir(tmp.join("dst")).await?;
4731        tokio::fs::write(tmp.join("src/f"), b"STABLE").await?;
4732        let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
4733        let dst =
4734            Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
4735        let handle = src.child(OsStr::new("f")).await?;
4736        dst.hard_link_handle_at(&handle, OsStr::new("f_link"))
4737            .await?;
4738        let orig = src.child(OsStr::new("f")).await?;
4739        let linked = dst.child(OsStr::new("f_link")).await?;
4740        assert_eq!(linked.kind(), EntryKind::File);
4741        assert_eq!(orig.ino(), linked.ino(), "hard link must share the inode");
4742        let content = tokio::fs::read_to_string(tmp.join("dst/f_link")).await?;
4743        assert_eq!(content, "STABLE");
4744        Ok(())
4745    }
4746
4747    // ── fd-based metadata application ───────────────────────────────────────
4748
4749    // set_file_metadata_fd: applying owner/mode/time from a source FileMeta to an
4750    // already-open destination fd must reflect on the destination file: masked
4751    // mode, mtime, and (where testable) uid/gid all match the source.
4752    #[tokio::test]
4753    async fn set_file_metadata_fd_applies_owner_mode_time() -> anyhow::Result<()> {
4754        use std::io::Write;
4755        use std::os::unix::fs::PermissionsExt;
4756        let tmp = testutils::setup_test_dir().await?;
4757        // source file with a distinctive mode and a known, old mtime
4758        let src_path = tmp.join("foo/src_meta.txt");
4759        tokio::fs::write(&src_path, b"source").await?;
4760        std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o741))?;
4761        let src_mtime = filetime::FileTime::from_unix_time(1_000_000_000, 123_456_789);
4762        filetime::set_file_mtime(&src_path, src_mtime)?;
4763        filetime::set_file_atime(
4764            &src_path,
4765            filetime::FileTime::from_unix_time(1_000_000_500, 0),
4766        )?;
4767
4768        let root =
4769            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4770        // snapshot the source metadata via a Handle (the realistic source flow)
4771        let src_handle = root.child(OsStr::new("src_meta.txt")).await?;
4772        let src_meta = src_handle.meta().clone();
4773
4774        // create the destination file and write some content into it
4775        let mut dst_file = root.create_file(OsStr::new("dst_meta.txt")).await?;
4776        dst_file.write_all(b"destination")?;
4777        dst_file.flush()?;
4778
4779        // apply source metadata to the already-open dst fd; preserve everything
4780        let settings = crate::preserve::preserve_all();
4781        set_file_metadata_fd(
4782            &settings,
4783            &src_meta,
4784            None,
4785            dst_file.as_fd(),
4786            congestion::Side::Destination,
4787        )
4788        .await?;
4789        drop(dst_file);
4790
4791        // re-stat the destination and assert mode (masked to 0o7777), mtime
4792        let dst_md = std::fs::metadata(tmp.join("foo/dst_meta.txt"))?;
4793        assert_eq!(
4794            dst_md.permissions().mode() & 0o7777,
4795            0o741,
4796            "destination mode mismatch"
4797        );
4798        // disambiguate: both preserve::Metadata and std MetadataExt are in scope
4799        use std::os::unix::fs::MetadataExt;
4800        assert_eq!(
4801            MetadataExt::mtime(&dst_md),
4802            1_000_000_000,
4803            "mtime seconds mismatch"
4804        );
4805        assert_eq!(
4806            MetadataExt::mtime_nsec(&dst_md),
4807            123_456_789,
4808            "mtime nanos mismatch"
4809        );
4810        // uid/gid: chown to source's uid/gid (same as current user here) must hold
4811        assert_eq!(MetadataExt::uid(&dst_md), src_meta.uid(), "uid mismatch");
4812        assert_eq!(MetadataExt::gid(&dst_md), src_meta.gid(), "gid mismatch");
4813        Ok(())
4814    }
4815
4816    // set_file_metadata_fd: chown before chmod must preserve a setuid bit.
4817    // An unprivileged fchown (even to the current uid) clears setuid/setgid; doing
4818    // chown FIRST and chmod AFTER restores it. This test proves that ordering.
4819    #[cfg_attr(
4820        rcp_nix_sandbox,
4821        ignore = "Nix sandbox cannot change ownership or set-id modes"
4822    )]
4823    #[tokio::test]
4824    async fn set_file_metadata_fd_ordering_preserves_setuid() -> anyhow::Result<()> {
4825        use std::io::Write;
4826        use std::os::unix::fs::PermissionsExt;
4827        let tmp = testutils::setup_test_dir().await?;
4828        // source file with the setuid bit set (0o4755)
4829        let src_path = tmp.join("foo/setuid_src");
4830        tokio::fs::write(&src_path, b"x").await?;
4831        std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o4755))?;
4832
4833        let root =
4834            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4835        let src_handle = root.child(OsStr::new("setuid_src")).await?;
4836        let src_meta = src_handle.meta().clone();
4837        assert_eq!(
4838            src_meta.permissions().mode() & 0o7777,
4839            0o4755,
4840            "source setuid bit was not set up correctly"
4841        );
4842
4843        // destination starts without the setuid bit
4844        let mut dst_file = root.create_file(OsStr::new("setuid_dst")).await?;
4845        dst_file.write_all(b"x")?;
4846        dst_file.flush()?;
4847
4848        // preserve_all keeps the full mode (mask 0o7777) AND preserves uid/gid, so
4849        // the chown runs before the chmod; the setuid bit must survive.
4850        let settings = crate::preserve::preserve_all();
4851        set_file_metadata_fd(
4852            &settings,
4853            &src_meta,
4854            None,
4855            dst_file.as_fd(),
4856            congestion::Side::Destination,
4857        )
4858        .await?;
4859        drop(dst_file);
4860
4861        let dst_md = std::fs::metadata(tmp.join("foo/setuid_dst"))?;
4862        assert_eq!(
4863            dst_md.permissions().mode() & 0o7777,
4864            0o4755,
4865            "setuid bit was lost — chown must run before chmod"
4866        );
4867        Ok(())
4868    }
4869
4870    // set_file_metadata_fd: the widening chmod must be the LAST step. A destination file is created
4871    // owner-only and this call is the only thing that widens it to the source mode, so a fallible
4872    // step running AFTER the chmod would publish that final mode — here a setuid one — on a file the
4873    // copy is about to report as failed. `futimens` is that step. An out-of-range nanosecond field
4874    // makes it fail deterministically, with no privileged uid or hostile filesystem needed.
4875    #[tokio::test]
4876    async fn set_file_metadata_fd_keeps_the_file_owner_only_when_utimens_fails()
4877    -> anyhow::Result<()> {
4878        use std::os::unix::fs::PermissionsExt;
4879
4880        struct RejectedTimestamps;
4881        impl crate::preserve::Metadata for RejectedTimestamps {
4882            fn uid(&self) -> u32 {
4883                // SAFETY: `getuid` has no preconditions and cannot fail.
4884                unsafe { libc::getuid() }
4885            }
4886            fn gid(&self) -> u32 {
4887                // SAFETY: `getgid` has no preconditions and cannot fail.
4888                unsafe { libc::getgid() }
4889            }
4890            fn atime(&self) -> i64 {
4891                0
4892            }
4893            fn atime_nsec(&self) -> i64 {
4894                0
4895            }
4896            fn mtime(&self) -> i64 {
4897                0
4898            }
4899            // utimensat rejects a nanosecond field outside [0, 999999999] that is neither UTIME_NOW
4900            // nor UTIME_OMIT with EINVAL.
4901            fn mtime_nsec(&self) -> i64 {
4902                2_000_000_000
4903            }
4904            fn permissions(&self) -> std::fs::Permissions {
4905                std::fs::Permissions::from_mode(0o4755)
4906            }
4907        }
4908
4909        let tmp = testutils::setup_test_dir().await?;
4910        let root =
4911            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4912        let dst_file = root.create_file(OsStr::new("late_chmod")).await?;
4913        let dst_path = tmp.join("foo/late_chmod");
4914        assert_eq!(
4915            std::fs::metadata(&dst_path)?.permissions().mode() & 0o7777,
4916            DST_FILE_CREATE_MODE,
4917            "every destination file starts owner-only"
4918        );
4919
4920        // preserve_all: uid/gid so the chown runs first, time so the futimens is reached, and
4921        // mode_mask 0o7777 so the chmod that must NOT run would have set the setuid bit.
4922        let error = set_file_metadata_fd(
4923            &crate::preserve::preserve_all(),
4924            &RejectedTimestamps,
4925            None,
4926            dst_file.as_fd(),
4927            congestion::Side::Destination,
4928        )
4929        .await
4930        .expect_err("an out-of-range nanosecond field must fail futimens");
4931        assert_eq!(error.raw_os_error(), Some(libc::EINVAL));
4932        drop(dst_file);
4933
4934        assert_eq!(
4935            std::fs::metadata(&dst_path)?.permissions().mode() & 0o7777,
4936            DST_FILE_CREATE_MODE,
4937            "a failure during metadata application must not leave the final mode behind"
4938        );
4939        Ok(())
4940    }
4941
4942    // set_dir_metadata_fd: applying mode/time to a freshly made directory via its
4943    // Dir fd must reflect on the directory.
4944    #[cfg_attr(
4945        rcp_nix_sandbox,
4946        ignore = "Nix sandbox cannot change ownership or set-id modes"
4947    )]
4948    #[tokio::test]
4949    async fn set_dir_metadata_fd_applies() -> anyhow::Result<()> {
4950        use std::os::unix::fs::{MetadataExt, PermissionsExt};
4951        let tmp = testutils::setup_test_dir().await?;
4952        // source directory with a distinctive mode and known mtime
4953        let src_dir_path = tmp.join("foo/src_dir");
4954        tokio::fs::create_dir(&src_dir_path).await?;
4955        std::fs::set_permissions(&src_dir_path, std::fs::Permissions::from_mode(0o2750))?;
4956        filetime::set_file_mtime(
4957            &src_dir_path,
4958            filetime::FileTime::from_unix_time(1_111_111_111, 222_000_000),
4959        )?;
4960
4961        let root =
4962            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4963        let src_handle = root.child(OsStr::new("src_dir")).await?;
4964        let src_meta = src_handle.meta().clone();
4965
4966        // create the destination directory and apply metadata via its Dir fd
4967        let dst_dir = root.make_dir(OsStr::new("dst_dir"), 0o700).await?;
4968        let settings = crate::preserve::preserve_all();
4969        set_dir_metadata_fd(&settings, &src_meta, None, &dst_dir).await?;
4970
4971        let dst_md = std::fs::metadata(tmp.join("foo/dst_dir"))?;
4972        assert_eq!(
4973            dst_md.permissions().mode() & 0o7777,
4974            0o2750,
4975            "destination dir mode mismatch"
4976        );
4977        assert_eq!(
4978            MetadataExt::mtime(&dst_md),
4979            1_111_111_111,
4980            "dir mtime seconds mismatch"
4981        );
4982        assert_eq!(
4983            MetadataExt::mtime_nsec(&dst_md),
4984            222_000_000,
4985            "dir mtime nanos mismatch"
4986        );
4987        Ok(())
4988    }
4989
4990    // set_symlink_metadata_fd: applying time (and owner) to a symlink via its
4991    // O_PATH Handle must change the LINK's own atime/mtime — NOT the target's
4992    // mtime. This is the key proof that utimensat(AT_EMPTY_PATH) hit the link.
4993    #[tokio::test]
4994    async fn set_symlink_metadata_fd_changes_link_not_target() -> anyhow::Result<()> {
4995        use std::os::unix::fs::MetadataExt;
4996        let tmp = testutils::setup_test_dir().await?;
4997        let root =
4998            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
4999
5000        // sentinel target file with a known mtime we must NOT disturb
5001        let target_path = tmp.join("foo/sentinel_target.txt");
5002        tokio::fs::write(&target_path, b"keep my mtime").await?;
5003        let target_mtime = filetime::FileTime::from_unix_time(1_500_000_000, 0);
5004        filetime::set_file_mtime(&target_path, target_mtime)?;
5005        let target_before = std::fs::metadata(&target_path)?;
5006
5007        // the link to apply metadata to
5008        let link = root
5009            .symlink_at(
5010                OsStr::new("the_link"),
5011                std::path::Path::new("sentinel_target.txt"),
5012            )
5013            .await?;
5014
5015        // desired link timestamps come from a source FileMeta; build one by
5016        // stating a second symlink we set up with a distinctive mtime.
5017        let src_link_path = tmp.join("foo/src_link");
5018        tokio::fs::symlink("sentinel_target.txt", &src_link_path).await?;
5019        let src_link_mtime = filetime::FileTime::from_unix_time(1_234_567_890, 0);
5020        // set the LINK's own mtime (symlink=true) — not the target's
5021        filetime::set_symlink_file_times(
5022            &src_link_path,
5023            filetime::FileTime::from_unix_time(1_234_500_000, 0),
5024            src_link_mtime,
5025        )?;
5026        let src_meta = root.child(OsStr::new("src_link")).await?.meta().clone();
5027
5028        let settings = crate::preserve::preserve_all();
5029        set_symlink_metadata_fd(&settings, &src_meta, &link, congestion::Side::Destination).await?;
5030
5031        // the LINK's own mtime must now equal the source link's mtime
5032        let link_md = std::fs::symlink_metadata(tmp.join("foo/the_link"))?;
5033        assert_eq!(
5034            MetadataExt::mtime(&link_md),
5035            1_234_567_890,
5036            "link's own mtime was not applied"
5037        );
5038        // the TARGET file's mtime must be UNCHANGED
5039        let target_after = std::fs::metadata(&target_path)?;
5040        assert_eq!(
5041            MetadataExt::mtime(&target_after),
5042            MetadataExt::mtime(&target_before),
5043            "target mtime changed — utimensat followed the symlink!"
5044        );
5045        assert_eq!(
5046            MetadataExt::mtime_nsec(&target_after),
5047            MetadataExt::mtime_nsec(&target_before),
5048            "target mtime_nsec changed — utimensat followed the symlink!"
5049        );
5050        Ok(())
5051    }
5052
5053    // rejects_multi_component_names: cover every by-name helper added with fd-relative mutation.
5054    #[tokio::test]
5055    async fn new_methods_reject_multi_component_names() -> anyhow::Result<()> {
5056        let tmp = testutils::setup_test_dir().await?;
5057        let root =
5058            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5059        // a second Dir for hard_link_at's dst parameter
5060        let dst =
5061            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5062        for bad in ["a/b", "..", ".", ""] {
5063            let bad_os = OsStr::new(bad);
5064
5065            let err = root.unlink_at(bad_os).await.unwrap_err();
5066            assert_eq!(
5067                err.raw_os_error(),
5068                Some(libc::EINVAL),
5069                "unlink_at: expected EINVAL for {bad:?}, got {err:#}"
5070            );
5071
5072            let err = root.rmdir_at(bad_os).await.unwrap_err();
5073            assert_eq!(
5074                err.raw_os_error(),
5075                Some(libc::EINVAL),
5076                "rmdir_at: expected EINVAL for {bad:?}, got {err:#}"
5077            );
5078
5079            // symlink_at: only `name` is guarded; target is arbitrary
5080            let err = root
5081                .symlink_at(bad_os, std::path::Path::new("irrelevant"))
5082                .await
5083                .unwrap_err();
5084            assert_eq!(
5085                err.raw_os_error(),
5086                Some(libc::EINVAL),
5087                "symlink_at(name): expected EINVAL for {bad:?}, got {err:#}"
5088            );
5089
5090            let err = root.read_link_at(bad_os).await.unwrap_err();
5091            assert_eq!(
5092                err.raw_os_error(),
5093                Some(libc::EINVAL),
5094                "read_link_at: expected EINVAL for {bad:?}, got {err:#}"
5095            );
5096
5097            // hard_link_at: both `name` and `dst_name` are guarded
5098            let err = root
5099                .hard_link_at(bad_os, &dst, OsStr::new("good"))
5100                .await
5101                .unwrap_err();
5102            assert_eq!(
5103                err.raw_os_error(),
5104                Some(libc::EINVAL),
5105                "hard_link_at(name): expected EINVAL for {bad:?}, got {err:#}"
5106            );
5107
5108            let err = root
5109                .hard_link_at(OsStr::new("good"), &dst, bad_os)
5110                .await
5111                .unwrap_err();
5112            assert_eq!(
5113                err.raw_os_error(),
5114                Some(libc::EINVAL),
5115                "hard_link_at(dst_name): expected EINVAL for {bad:?}, got {err:#}"
5116            );
5117        }
5118        Ok(())
5119    }
5120
5121    // chmod_via_proc_fd: changing the mode of a 0000-mode file through its O_PATH
5122    // handle must succeed (the /proc magic-symlink path does not need any rights on
5123    // the target itself) and the new mode must be observable on disk. This is the
5124    // case `fchmod` (EBADF on O_PATH) and a bare path chmod under restrictive modes
5125    // would struggle with; the pinned inode makes it inode-exact and permission-free.
5126    #[tokio::test]
5127    async fn chmod_via_proc_fd_changes_mode_of_zero_mode_file() -> anyhow::Result<()> {
5128        use std::os::unix::fs::PermissionsExt;
5129        let tmp = testutils::setup_test_dir().await?;
5130        let root =
5131            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5132        // a file with no permission bits at all (0000).
5133        let path = tmp.join("foo/locked.txt");
5134        tokio::fs::write(&path, b"locked").await?;
5135        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))?;
5136        // O_PATH handle pins the inode even though the file is 0000.
5137        let handle = root.child(OsStr::new("locked.txt")).await?;
5138        assert_eq!(handle.kind(), EntryKind::File, "fixture must be a file");
5139        // chmod it to 0o640 via the /proc magic symlink.
5140        chmod_via_proc_fd(&handle, congestion::Side::Destination, 0o640).await?;
5141        // the mode change must be visible on disk.
5142        let md = std::fs::symlink_metadata(&path)?;
5143        assert_eq!(
5144            md.permissions().mode() & 0o7777,
5145            0o640,
5146            "chmod_via_proc_fd must change the mode of a 0000-mode file"
5147        );
5148        Ok(())
5149    }
5150
5151    // stat_meta_via_proc_fd: on a symlink Handle (opened O_PATH|O_NOFOLLOW), resolving
5152    // /proc/self/fd/N must land on the LINK's own inode — never the target's. This is
5153    // load-bearing for symlink time-filtering (rm/rrm reads a symlink's own mtime/btime to
5154    // decide removal). We give the link and its target DISTINCT mtimes and assert the metadata
5155    // returned is the link's (is_symlink + the link's mtime), proving the magic-symlink resolve
5156    // is pinned to the O_PATH inode and does not follow the link to the target.
5157    #[tokio::test]
5158    async fn stat_meta_via_proc_fd_on_symlink_resolves_link_not_target() -> anyhow::Result<()> {
5159        use std::os::unix::fs::MetadataExt;
5160        let tmp = testutils::setup_test_dir().await?;
5161        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
5162
5163        // target file with one mtime ...
5164        let target_path = tmp.join("foo/stat_target.txt");
5165        tokio::fs::write(&target_path, b"target body").await?;
5166        filetime::set_file_mtime(
5167            &target_path,
5168            filetime::FileTime::from_unix_time(1_700_000_000, 0),
5169        )?;
5170
5171        // ... and a symlink to it with a DISTINCT mtime set on the LINK itself (not the target).
5172        let link_path = tmp.join("foo/stat_link");
5173        tokio::fs::symlink("stat_target.txt", &link_path).await?;
5174        filetime::set_symlink_file_times(
5175            &link_path,
5176            filetime::FileTime::from_unix_time(1_600_000_000, 0),
5177            filetime::FileTime::from_unix_time(1_600_000_123, 0),
5178        )?;
5179
5180        // open the symlink via child() — an O_PATH|O_NOFOLLOW handle pinned to the link inode.
5181        let handle = root.child(OsStr::new("stat_link")).await?;
5182        assert_eq!(
5183            handle.kind(),
5184            EntryKind::Symlink,
5185            "fixture must classify as a symlink"
5186        );
5187
5188        let md = stat_meta_via_proc_fd(&handle, congestion::Side::Source).await?;
5189        // the returned metadata must be the LINK's, not the dereferenced target's.
5190        assert!(
5191            md.file_type().is_symlink(),
5192            "stat_meta_via_proc_fd followed the symlink to its target (got a non-symlink)"
5193        );
5194        assert_eq!(
5195            MetadataExt::mtime(&md),
5196            1_600_000_123,
5197            "expected the LINK's own mtime; a target-following stat would return 1_700_000_000"
5198        );
5199        Ok(())
5200    }
5201
5202    // NOTE: the test that ARMS strict operand resolution and exercises the strict
5203    // (openat2) open path lives in tests/strict_resolution.rs — its own integration
5204    // binary and therefore its own process. The switch is one-way, and under the
5205    // plain `cargo test` harness (used by the nix checkPhase) a lib's unit tests
5206    // share one process, so arming here would leak into the symlink-following
5207    // default-behavior tests above.
5208
5209    #[test]
5210    fn openat2_probe_is_stable() {
5211        // the probe is memoized; both calls must agree. On kernels without openat2 a
5212        // `false` result is the correct answer (the linter then refuses strict mode),
5213        // so no hard assertion on availability itself.
5214        let first = openat2_available();
5215        assert_eq!(first, openat2_available(), "probe must be stable");
5216        if !first {
5217            eprintln!("this kernel lacks openat2(2); strict-mode tests skip themselves");
5218        }
5219    }
5220
5221    // ── POSIX ACLs ──────────────────────────────────────────────────────────
5222
5223    // POSIX.1e ACL entry tags, from `<linux/posix_acl.h>`.
5224    const ACL_USER_OBJ: u16 = 0x01;
5225    const ACL_USER: u16 = 0x02;
5226    const ACL_GROUP_OBJ: u16 = 0x04;
5227    const ACL_MASK: u16 = 0x10;
5228    const ACL_OTHER: u16 = 0x20;
5229    const ACL_UNDEFINED_ID: u32 = 0xffff_ffff;
5230
5231    // Encode an ACL exactly the way the kernel stores it in `system.posix_acl_*`: a `__le32`
5232    // version followed by `{__le16 tag, __le16 perm, __le32 id}` entries. Written directly rather
5233    // than through `setfacl`, which the dev shell does not ship — and which would prove less
5234    // anyway, since these are the same bytes the code under test round-trips.
5235    fn encode_acl(entries: &[(u16, u16, u32)]) -> Vec<u8> {
5236        let mut out = 2u32.to_le_bytes().to_vec();
5237        for &(tag, perm, id) in entries {
5238            out.extend_from_slice(&tag.to_le_bytes());
5239            out.extend_from_slice(&perm.to_le_bytes());
5240            out.extend_from_slice(&id.to_le_bytes());
5241        }
5242        out
5243    }
5244
5245    // `u::rwx u:65534:--- g::--- m::r-x o::r-x`, i.e. mode 0o705 plus a named entry DENYING 65534
5246    // what `other` grants everyone else. No mode can express that, which is why dropping the ACL
5247    // hands 65534 exactly what the source withheld.
5248    fn restrictive_access_acl() -> Vec<u8> {
5249        encode_acl(&[
5250            (ACL_USER_OBJ, 7, ACL_UNDEFINED_ID),
5251            (ACL_USER, 0, 65534),
5252            (ACL_GROUP_OBJ, 0, ACL_UNDEFINED_ID),
5253            (ACL_MASK, 5, ACL_UNDEFINED_ID),
5254            (ACL_OTHER, 5, ACL_UNDEFINED_ID),
5255        ])
5256    }
5257
5258    // `u::rwx u:65534:rwx g::r-x m::rwx o::r-x` — permissive, and the shape an administrator sets
5259    // as a DEFAULT ACL on a destination tree so new children inherit it.
5260    fn permissive_acl() -> Vec<u8> {
5261        encode_acl(&[
5262            (ACL_USER_OBJ, 7, ACL_UNDEFINED_ID),
5263            (ACL_USER, 7, 65534),
5264            (ACL_GROUP_OBJ, 5, ACL_UNDEFINED_ID),
5265            (ACL_MASK, 7, ACL_UNDEFINED_ID),
5266            (ACL_OTHER, 5, ACL_UNDEFINED_ID),
5267        ])
5268    }
5269
5270    // An ACL the kernel refuses (EINVAL): a lone named entry, with none of the three required
5271    // `USER_OBJ`/`GROUP_OBJ`/`OTHER` ones. Gives the appliers a deterministic `fsetxattr` failure
5272    // with no privileged uid or hostile filesystem needed — the ACL counterpart of the
5273    // out-of-range nanosecond field the utimens ordering test uses.
5274    fn rejected_access_acl() -> Vec<u8> {
5275        encode_acl(&[(ACL_USER, 7, 65534)])
5276    }
5277
5278    fn set_xattr_at(path: &std::path::Path, name: &CStr, value: &[u8]) {
5279        let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
5280        // SAFETY: both pointers are NUL-terminated C strings that outlive the call, and `value`
5281        // points at `value.len()` readable bytes.
5282        let rc = unsafe {
5283            libc::setxattr(
5284                cpath.as_ptr(),
5285                name.as_ptr(),
5286                value.as_ptr().cast(),
5287                value.len(),
5288                0,
5289            )
5290        };
5291        assert_eq!(
5292            rc,
5293            0,
5294            "fixture setxattr({name:?}) on {path:?} failed: {} — this filesystem cannot hold \
5295             POSIX ACLs, so these tests cannot run here",
5296            std::io::Error::last_os_error()
5297        );
5298    }
5299
5300    /// Read `name` from `path`, or `None` if the entry genuinely has no such attribute.
5301    ///
5302    /// ONLY `ENODATA` yields `None`. Every other errno panics — a getter that answered "no ACL" for
5303    /// `ENOENT` would let a test assert "this entry has no ACL" about a path that does not exist,
5304    /// which is a passing test that checks nothing. The size is queried first so an ACL of any
5305    /// length round-trips (the ERANGE fixture below writes one far past a 512-byte buffer).
5306    fn get_xattr_at(path: &std::path::Path, name: &CStr) -> Option<Vec<u8>> {
5307        let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
5308        // SAFETY: `cpath` and `name` are NUL-terminated C strings that outlive the call; a null
5309        // buffer with size 0 asks for the size without writing.
5310        let size =
5311            unsafe { libc::getxattr(cpath.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0) };
5312        if size < 0 {
5313            let err = std::io::Error::last_os_error();
5314            assert_eq!(
5315                err.raw_os_error(),
5316                Some(libc::ENODATA),
5317                "getxattr({name:?}) on {path:?} failed with {err} — only ENODATA means \"no such \
5318                 attribute\"; anything else means the check never happened"
5319            );
5320            return None;
5321        }
5322        let mut buf = vec![0u8; size as usize];
5323        if buf.is_empty() {
5324            return Some(buf);
5325        }
5326        // SAFETY: as above; `buf` has `len()` writable bytes.
5327        let n = unsafe {
5328            libc::getxattr(
5329                cpath.as_ptr(),
5330                name.as_ptr(),
5331                buf.as_mut_ptr().cast(),
5332                buf.len(),
5333            )
5334        };
5335        assert!(
5336            n >= 0,
5337            "getxattr({name:?}) on {path:?} failed after its size was read: {}",
5338            std::io::Error::last_os_error()
5339        );
5340        buf.truncate(n as usize);
5341        Some(buf)
5342    }
5343
5344    fn mode_of(path: &std::path::Path) -> u32 {
5345        use std::os::unix::fs::PermissionsExt as _;
5346        std::fs::metadata(path).unwrap().permissions().mode() & 0o7777
5347    }
5348
5349    #[cfg_attr(rcp_nix_sandbox, ignore = "Nix sandbox cannot write POSIX ACL xattrs")]
5350    #[tokio::test]
5351    async fn read_acls_fd_round_trips_an_access_acl() -> anyhow::Result<()> {
5352        let tmp = testutils::setup_test_dir().await?;
5353        let src_path = tmp.join("foo/acl_src");
5354        tokio::fs::write(&src_path, b"x").await?;
5355        let blob = restrictive_access_acl();
5356        set_xattr_at(&src_path, ACL_ACCESS_XATTR, &blob);
5357        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
5358        let (file, _) = root.open_file_read(OsStr::new("acl_src")).await?;
5359        let acls = read_acls_fd(file.as_fd(), congestion::Side::Source, false).await?;
5360        // byte-identical: the blob is carried opaquely, never re-encoded
5361        assert_eq!(acls.access.as_deref(), Some(blob.as_slice()));
5362        assert_eq!(acls.default, None, "a file has no default ACL to read");
5363        Ok(())
5364    }
5365
5366    // An ACL far larger than the stack buffers, so BOTH `ERANGE` fallbacks run: the name list is
5367    // padded past 256 bytes with `user.*` attributes, and the blob past 512 by entry count (a blob
5368    // is `4 + 8n` bytes, so 64 entries fill the buffer exactly and 80 overflow it). Without the
5369    // retry paths this fails rather than silently truncating — `fgetxattr` refuses a short buffer.
5370    // tmpfs gained user.* xattr support in Linux 6.6, so older sandbox kernels cannot construct the
5371    // padded name list even apart from the POSIX ACL restriction
5372    #[cfg_attr(
5373        rcp_nix_sandbox,
5374        ignore = "Nix sandbox cannot provide both POSIX ACL and user xattrs this test requires"
5375    )]
5376    #[tokio::test]
5377    async fn read_acls_fd_reads_an_acl_larger_than_the_stack_buffers() -> anyhow::Result<()> {
5378        let tmp = testutils::setup_test_dir().await?;
5379        let src_path = tmp.join("foo/big_acl");
5380        tokio::fs::write(&src_path, b"x").await?;
5381        // canonical POSIX.1e order — USER_OBJ, named users by ascending uid, GROUP_OBJ, MASK,
5382        // OTHER. The kernel validates it and rejects anything else with EINVAL.
5383        let mut entries = vec![(ACL_USER_OBJ, 7, ACL_UNDEFINED_ID)];
5384        entries.extend((0..80).map(|i| (ACL_USER, 5, 100_000 + i)));
5385        entries.extend([
5386            (ACL_GROUP_OBJ, 0, ACL_UNDEFINED_ID),
5387            (ACL_MASK, 5, ACL_UNDEFINED_ID),
5388            (ACL_OTHER, 0, ACL_UNDEFINED_ID),
5389        ]);
5390        let blob = encode_acl(&entries);
5391        assert!(blob.len() > 512, "fixture must overflow the read buffer");
5392        set_xattr_at(&src_path, ACL_ACCESS_XATTR, &blob);
5393        // push the NAME list past its own 256-byte buffer too
5394        for i in 0..30 {
5395            let name = std::ffi::CString::new(format!("user.padding_attribute_number_{i:03}"))?;
5396            set_xattr_at(&src_path, &name, b"v");
5397        }
5398        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
5399        let (file, _) = root.open_file_read(OsStr::new("big_acl")).await?;
5400        let acls = read_acls_fd(file.as_fd(), congestion::Side::Source, false).await?;
5401        assert_eq!(acls.access.as_deref(), Some(blob.as_slice()));
5402        Ok(())
5403    }
5404
5405    #[tokio::test]
5406    async fn read_acls_fd_reports_no_acl_when_there_is_none() -> anyhow::Result<()> {
5407        let tmp = testutils::setup_test_dir().await?;
5408        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
5409        let (file, _) = root.open_file_read(OsStr::new("0.txt")).await?;
5410        let acls = read_acls_fd(file.as_fd(), congestion::Side::Source, false).await?;
5411        assert_eq!(acls, Acls::default(), "an entry with no xattrs has no ACL");
5412        Ok(())
5413    }
5414
5415    #[cfg_attr(rcp_nix_sandbox, ignore = "Nix sandbox cannot write POSIX ACL xattrs")]
5416    #[tokio::test]
5417    async fn read_acls_fd_reads_a_directorys_default_acl_only_when_asked() -> anyhow::Result<()> {
5418        let tmp = testutils::setup_test_dir().await?;
5419        let dir_path = tmp.join("foo/acl_dir");
5420        tokio::fs::create_dir(&dir_path).await?;
5421        let access = restrictive_access_acl();
5422        let default = permissive_acl();
5423        set_xattr_at(&dir_path, ACL_ACCESS_XATTR, &access);
5424        set_xattr_at(&dir_path, ACL_DEFAULT_XATTR, &default);
5425        let dir = Dir::open_root_dir(&dir_path, false, congestion::Side::Source).await?;
5426        let both = dir.read_acls().await?;
5427        assert_eq!(both.access.as_deref(), Some(access.as_slice()));
5428        assert_eq!(both.default.as_deref(), Some(default.as_slice()));
5429        // `want_default: false` is what the file path passes; it must not spend the extra syscall
5430        let access_only = read_acls_fd(dir.fd.as_fd(), congestion::Side::Source, false).await?;
5431        assert_eq!(access_only.access.as_deref(), Some(access.as_slice()));
5432        assert_eq!(access_only.default, None);
5433        Ok(())
5434    }
5435
5436    #[cfg_attr(rcp_nix_sandbox, ignore = "Nix sandbox cannot write POSIX ACL xattrs")]
5437    #[tokio::test]
5438    async fn apply_acls_fd_installs_the_source_acl_and_clears_what_the_source_lacked()
5439    -> anyhow::Result<()> {
5440        let tmp = testutils::setup_test_dir().await?;
5441        let dst_path = tmp.join("foo/applied");
5442        tokio::fs::write(&dst_path, b"x").await?;
5443        let root =
5444            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5445        let (file, _) = root.open_file_read(OsStr::new("applied")).await?;
5446        let blob = restrictive_access_acl();
5447        apply_acls_fd(
5448            file.as_fd(),
5449            congestion::Side::Destination,
5450            &Acls {
5451                access: Some(blob.clone()),
5452                default: None,
5453            },
5454            false,
5455        )
5456        .await?;
5457        assert_eq!(
5458            get_xattr_at(&dst_path, ACL_ACCESS_XATTR).as_deref(),
5459            Some(blob.as_slice())
5460        );
5461        // an all-`None` payload is a request to CLEAR, not to do nothing
5462        apply_acls_fd(
5463            file.as_fd(),
5464            congestion::Side::Destination,
5465            &Acls::default(),
5466            false,
5467        )
5468        .await?;
5469        assert_eq!(get_xattr_at(&dst_path, ACL_ACCESS_XATTR), None);
5470        // and clearing again, with nothing there, still succeeds (ENODATA is not a failure)
5471        apply_acls_fd(
5472            file.as_fd(),
5473            congestion::Side::Destination,
5474            &Acls::default(),
5475            false,
5476        )
5477        .await?;
5478        Ok(())
5479    }
5480
5481    #[tokio::test]
5482    async fn apply_acls_fd_fails_a_set_the_filesystem_cannot_hold_but_not_a_remove()
5483    -> anyhow::Result<()> {
5484        // `/proc` holds no xattrs at all, so both operations get EOPNOTSUPP — the asymmetry under
5485        // test is entirely in how each is interpreted.
5486        let probe = std::fs::File::open("/proc/self/environ")?;
5487        let error = apply_acls_fd(
5488            probe.as_fd(),
5489            congestion::Side::Destination,
5490            &Acls {
5491                access: Some(restrictive_access_acl()),
5492                default: None,
5493            },
5494            false,
5495        )
5496        .await
5497        .expect_err("a destination that cannot hold the source's ACL must fail the entry");
5498        assert_eq!(error.raw_os_error(), Some(libc::EOPNOTSUPP));
5499        // the same errno on a REMOVE says there is nothing to clear and nothing can have widened
5500        apply_acls_fd(
5501            probe.as_fd(),
5502            congestion::Side::Destination,
5503            &Acls::default(),
5504            false,
5505        )
5506        .await
5507        .expect("clearing an ACL a filesystem cannot hold is already satisfied");
5508        Ok(())
5509    }
5510
5511    // ORDERING, branch 1 (the source HAS an access ACL): the narrowed chmod carries the special
5512    // bits, the `fsetxattr` lands the rwx bits, and both survive together.
5513    #[cfg_attr(
5514        rcp_nix_sandbox,
5515        ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
5516    )]
5517    #[tokio::test]
5518    async fn set_file_metadata_fd_applies_a_setuid_source_mode_and_its_acl() -> anyhow::Result<()> {
5519        use std::io::Write;
5520        use std::os::unix::fs::PermissionsExt;
5521        let tmp = testutils::setup_test_dir().await?;
5522        let src_path = tmp.join("foo/acl_setuid_src");
5523        tokio::fs::write(&src_path, b"x").await?;
5524        std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o4700))?;
5525        let blob = restrictive_access_acl();
5526        set_xattr_at(&src_path, ACL_ACCESS_XATTR, &blob);
5527        // writing the ACL moved the source's own rwx bits to match it (USER_OBJ / MASK / OTHER);
5528        // that equality is exactly what the destination has to reproduce.
5529        assert_eq!(mode_of(&src_path), 0o4755, "fixture source mode");
5530        let root =
5531            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5532        let src_handle = root.child(OsStr::new("acl_setuid_src")).await?;
5533        let src_meta = src_handle.meta().clone();
5534        let mut dst_file = root.create_file(OsStr::new("acl_setuid_dst")).await?;
5535        dst_file.write_all(b"x")?;
5536        dst_file.flush()?;
5537        set_file_metadata_fd(
5538            &crate::preserve::preserve_all_with_acls(),
5539            &src_meta,
5540            Some(&Acls {
5541                access: Some(blob.clone()),
5542                default: None,
5543            }),
5544            dst_file.as_fd(),
5545            congestion::Side::Destination,
5546        )
5547        .await?;
5548        drop(dst_file);
5549        let dst_path = tmp.join("foo/acl_setuid_dst");
5550        assert_eq!(
5551            mode_of(&dst_path),
5552            mode_of(&src_path),
5553            "the special bits come from the chmod and the rwx bits from the ACL; both must land"
5554        );
5555        assert_eq!(mode_of(&dst_path), 0o4755);
5556        assert_eq!(
5557            get_xattr_at(&dst_path, ACL_ACCESS_XATTR).as_deref(),
5558            Some(blob.as_slice()),
5559            "the destination must not be more permissive than its source"
5560        );
5561        Ok(())
5562    }
5563
5564    // ORDERING, branch 1 under failure: the `fsetxattr` IS the widening step, so when it fails the
5565    // file must be left unreachable by anyone but the copier. This is what pins the preceding chmod
5566    // to `(mode & 0o7000) | DST_FILE_CREATE_MODE`: widening there and failing here would publish a
5567    // setuid-root file whose copy is about to be reported as failed (PR #287's regression).
5568    #[cfg_attr(
5569        rcp_nix_sandbox,
5570        ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
5571    )]
5572    #[tokio::test]
5573    async fn set_file_metadata_fd_keeps_the_file_owner_only_when_the_acl_fails()
5574    -> anyhow::Result<()> {
5575        use std::os::unix::fs::PermissionsExt;
5576        let tmp = testutils::setup_test_dir().await?;
5577        let src_path = tmp.join("foo/acl_fail_src");
5578        tokio::fs::write(&src_path, b"x").await?;
5579        std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o4755))?;
5580        let root =
5581            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5582        let src_meta = root.child(OsStr::new("acl_fail_src")).await?.meta().clone();
5583        let dst_file = root.create_file(OsStr::new("acl_fail_dst")).await?;
5584        let dst_path = tmp.join("foo/acl_fail_dst");
5585        let error = set_file_metadata_fd(
5586            &crate::preserve::preserve_all_with_acls(),
5587            &src_meta,
5588            Some(&Acls {
5589                access: Some(rejected_access_acl()),
5590                default: None,
5591            }),
5592            dst_file.as_fd(),
5593            congestion::Side::Destination,
5594        )
5595        .await
5596        .expect_err("an ACL the kernel rejects must fail the entry");
5597        assert_eq!(error.raw_os_error(), Some(libc::EINVAL));
5598        drop(dst_file);
5599        assert_eq!(
5600            mode_of(&dst_path),
5601            0o4000 | DST_FILE_CREATE_MODE,
5602            "the chmod preceding a source ACL must carry the special bits ALONE — a failed ACL \
5603             application left the file at a mode it had not earned"
5604        );
5605        Ok(())
5606    }
5607
5608    // ORDERING, branch 2 (the source has NO access ACL): the chmod is the widening step and stays
5609    // last, and the clear that precedes it is mode-neutral. Inverting the branch condition sends
5610    // this case down the narrowed-chmod path and leaves the file at 0o4600.
5611    #[cfg_attr(
5612        rcp_nix_sandbox,
5613        ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
5614    )]
5615    #[tokio::test]
5616    async fn set_file_metadata_fd_clears_an_inherited_acl_and_still_applies_the_full_mode()
5617    -> anyhow::Result<()> {
5618        use std::io::Write;
5619        use std::os::unix::fs::PermissionsExt;
5620        let tmp = testutils::setup_test_dir().await?;
5621        let src_path = tmp.join("foo/plain_setuid_src");
5622        tokio::fs::write(&src_path, b"x").await?;
5623        std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o4755))?;
5624        let root =
5625            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5626        let src_meta = root
5627            .child(OsStr::new("plain_setuid_src"))
5628            .await?
5629            .meta()
5630            .clone();
5631        let mut dst_file = root.create_file(OsStr::new("plain_setuid_dst")).await?;
5632        dst_file.write_all(b"x")?;
5633        dst_file.flush()?;
5634        let dst_path = tmp.join("foo/plain_setuid_dst");
5635        // stand in for what a permissive destination directory's default ACL would have left here
5636        set_xattr_at(&dst_path, ACL_ACCESS_XATTR, &permissive_acl());
5637        set_file_metadata_fd(
5638            &crate::preserve::preserve_all_with_acls(),
5639            &src_meta,
5640            Some(&Acls::default()),
5641            dst_file.as_fd(),
5642            congestion::Side::Destination,
5643        )
5644        .await?;
5645        drop(dst_file);
5646        assert_eq!(
5647            get_xattr_at(&dst_path, ACL_ACCESS_XATTR),
5648            None,
5649            "a source with no ACL must leave the destination with no ACL"
5650        );
5651        assert_eq!(
5652            mode_of(&dst_path),
5653            0o4755,
5654            "without a source ACL the chmod is the widening step and applies the FULL mode"
5655        );
5656        Ok(())
5657    }
5658
5659    #[cfg_attr(
5660        rcp_nix_sandbox,
5661        ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
5662    )]
5663    #[tokio::test]
5664    async fn set_dir_metadata_fd_installs_access_and_default_acls() -> anyhow::Result<()> {
5665        let tmp = testutils::setup_test_dir().await?;
5666        let root =
5667            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5668        let src_dir_path = tmp.join("foo/acl_src_dir");
5669        tokio::fs::create_dir(&src_dir_path).await?;
5670        let access = restrictive_access_acl();
5671        let default = permissive_acl();
5672        set_xattr_at(&src_dir_path, ACL_ACCESS_XATTR, &access);
5673        set_xattr_at(&src_dir_path, ACL_DEFAULT_XATTR, &default);
5674        let src_meta = root.child(OsStr::new("acl_src_dir")).await?.meta().clone();
5675        let dst_dir = root.make_dir(OsStr::new("acl_dst_dir"), 0o700).await?;
5676        let dst_path = tmp.join("foo/acl_dst_dir");
5677        set_dir_metadata_fd(
5678            &crate::preserve::preserve_all_with_acls(),
5679            &src_meta,
5680            Some(&Acls {
5681                access: Some(access.clone()),
5682                default: Some(default.clone()),
5683            }),
5684            &dst_dir,
5685        )
5686        .await?;
5687        assert_eq!(
5688            get_xattr_at(&dst_path, ACL_ACCESS_XATTR).as_deref(),
5689            Some(access.as_slice())
5690        );
5691        assert_eq!(
5692            get_xattr_at(&dst_path, ACL_DEFAULT_XATTR).as_deref(),
5693            Some(default.as_slice()),
5694            "the default ACL decides what CHILDREN inherit; dropping it changes the destination \
5695             tree's inheritance policy"
5696        );
5697        assert_eq!(mode_of(&dst_path), mode_of(&src_dir_path));
5698        assert_eq!(mode_of(&dst_path), 0o755);
5699        Ok(())
5700    }
5701
5702    // The access ACL is applied LAST inside `apply_acls_fd` for the same reason it is applied last
5703    // in the file applier: it is the step that WIDENS. A default ACL the kernel rejects must
5704    // therefore fail while the directory is still at its owner-only create mode.
5705    #[cfg_attr(
5706        rcp_nix_sandbox,
5707        ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
5708    )]
5709    #[tokio::test]
5710    async fn set_dir_metadata_fd_keeps_the_dir_owner_only_when_the_default_acl_fails()
5711    -> anyhow::Result<()> {
5712        use std::os::unix::fs::PermissionsExt;
5713        let tmp = testutils::setup_test_dir().await?;
5714        let root =
5715            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5716        let src_dir_path = tmp.join("foo/acl_dfail_src");
5717        tokio::fs::create_dir(&src_dir_path).await?;
5718        std::fs::set_permissions(&src_dir_path, std::fs::Permissions::from_mode(0o755))?;
5719        let src_meta = root
5720            .child(OsStr::new("acl_dfail_src"))
5721            .await?
5722            .meta()
5723            .clone();
5724        let dst_dir = root.make_dir(OsStr::new("acl_dfail_dst"), 0o700).await?;
5725        let error = set_dir_metadata_fd(
5726            &crate::preserve::preserve_all_with_acls(),
5727            &src_meta,
5728            Some(&Acls {
5729                access: Some(permissive_acl()),
5730                default: Some(rejected_access_acl()),
5731            }),
5732            &dst_dir,
5733        )
5734        .await
5735        .expect_err("a default ACL the kernel rejects must fail the entry");
5736        assert_eq!(error.raw_os_error(), Some(libc::EINVAL));
5737        assert_eq!(
5738            mode_of(&tmp.join("foo/acl_dfail_dst")),
5739            DST_DIR_CREATE_MODE,
5740            "the access ACL must be applied LAST: a failing default apply left the directory wider"
5741        );
5742        Ok(())
5743    }
5744
5745    // The reused-directory finalize re-stats and verifies the mode it applied. Its `want_mode` is
5746    // still `masked_mode`, which stays correct with an ACL in play only because the source's own
5747    // rwx bits and its ACL are the same fact — worth a test rather than a comment, and worth doing
5748    // it on a SETGID directory, where the special bit comes from the chmod and the rwx bits from
5749    // the ACL.
5750    #[cfg_attr(
5751        rcp_nix_sandbox,
5752        ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
5753    )]
5754    #[tokio::test]
5755    async fn set_reused_dir_metadata_fd_verifies_a_setgid_source_with_an_acl() -> anyhow::Result<()>
5756    {
5757        use std::os::unix::fs::PermissionsExt;
5758        let tmp = testutils::setup_test_dir().await?;
5759        let root =
5760            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5761        let src_dir_path = tmp.join("foo/setgid_acl_src");
5762        tokio::fs::create_dir(&src_dir_path).await?;
5763        std::fs::set_permissions(&src_dir_path, std::fs::Permissions::from_mode(0o2700))?;
5764        let access = restrictive_access_acl();
5765        set_xattr_at(&src_dir_path, ACL_ACCESS_XATTR, &access);
5766        let src_meta = root
5767            .child(OsStr::new("setgid_acl_src"))
5768            .await?
5769            .meta()
5770            .clone();
5771        assert_eq!(
5772            mode_of(&src_dir_path),
5773            0o2755,
5774            "fixture must keep setgid alongside the ACL (whose MASK set the group bits)"
5775        );
5776        let dst_dir = root.make_dir(OsStr::new("setgid_acl_dst"), 0o700).await?;
5777        let (uid, gid) = {
5778            let meta = dst_dir.meta().await?;
5779            use crate::preserve::Metadata as _;
5780            (meta.uid(), meta.gid())
5781        };
5782        // `Some(lock)` is the strict-mode locked path — the one that verifies. The snapshot is
5783        // empty and irrelevant here: `d:acl` is on, so the SOURCE's ACL wins and the snapshot is
5784        // discarded.
5785        set_reused_dir_metadata_fd(
5786            &crate::preserve::preserve_all_with_acls(),
5787            &src_meta,
5788            Some(&Acls {
5789                access: Some(access.clone()),
5790                default: None,
5791            }),
5792            Some(ReusedDirLock {
5793                restore_owner: (uid, gid),
5794                state: Arc::new(std::sync::Mutex::new(DefaultAclGuard::Armed {
5795                    original: None,
5796                })),
5797                fd: Arc::clone(&dst_dir.fd),
5798                children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(false)),
5799            }),
5800            &dst_dir,
5801        )
5802        .await?;
5803        let dst_path = tmp.join("foo/setgid_acl_dst");
5804        assert_eq!(
5805            mode_of(&dst_path),
5806            0o2755,
5807            "setgid from the chmod, rwx from the ACL"
5808        );
5809        assert_eq!(
5810            get_xattr_at(&dst_path, ACL_ACCESS_XATTR).as_deref(),
5811            Some(access.as_slice())
5812        );
5813        Ok(())
5814    }
5815
5816    #[tokio::test]
5817    async fn appliers_refuse_a_call_site_that_did_not_carry_acls() -> anyhow::Result<()> {
5818        let tmp = testutils::setup_test_dir().await?;
5819        let root =
5820            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5821        let src_meta = root.child(OsStr::new("0.txt")).await?.meta().clone();
5822        let dst_file = root.create_file(OsStr::new("no_acls_carried")).await?;
5823        // `acl` on but no payload: neither "leave it alone" nor "clear it" reproduces the source,
5824        // so the applier must refuse rather than silently pick one.
5825        let error = set_file_metadata_fd(
5826            &crate::preserve::preserve_all_with_acls(),
5827            &src_meta,
5828            None,
5829            dst_file.as_fd(),
5830            congestion::Side::Destination,
5831        )
5832        .await
5833        .expect_err("requesting ACL preservation on a path that carries no ACLs must fail");
5834        assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
5835        // with `acl` off the same call site is correct and must stay silent
5836        set_file_metadata_fd(
5837            &crate::preserve::preserve_all(),
5838            &src_meta,
5839            None,
5840            dst_file.as_fd(),
5841            congestion::Side::Destination,
5842        )
5843        .await?;
5844        Ok(())
5845    }
5846
5847    #[cfg_attr(rcp_nix_sandbox, ignore = "Nix sandbox cannot write POSIX ACL xattrs")]
5848    #[tokio::test]
5849    async fn guarded_default_acl_write_is_skipped_once_disarmed() -> anyhow::Result<()> {
5850        let tmp = testutils::setup_test_dir().await?;
5851        let root =
5852            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
5853        let dir = root.make_dir(OsStr::new("locked"), 0o700).await?;
5854        // a guard whose restore has already run (the owning future was cancelled and the guard
5855        // dropped): a finalize write that already started on the blocking pool must observe that
5856        // and SKIP — landing it would silently overwrite the restore
5857        let lock = ReusedDirLock {
5858            restore_owner: (0, 0),
5859            state: Arc::new(std::sync::Mutex::new(DefaultAclGuard::Disarmed)),
5860            fd: Arc::clone(&dir.fd),
5861            children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(false)),
5862        };
5863        lock.write_default_acl_guarded(congestion::Side::Destination, restrictive_access_acl())
5864            .await?;
5865        assert_eq!(
5866            get_xattr_at(&tmp.join("foo/locked"), ACL_DEFAULT_XATTR),
5867            None,
5868            "a disarmed guard must refuse the write — landing it would undo the restore"
5869        );
5870        Ok(())
5871    }
5872}