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