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::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// ── Strict operand resolution (--require-toctou-safe) ────────────────────────
25//
26// A process-global, one-way switch armed by the TOCTOU linter (see
27// `crate::toctou_check::run_linter`) when `--require-toctou-safe` proceeds. When
28// armed, the two multi-component path resolutions in this module —
29// `Dir::open_root_dir` and `Dir::open_parent_dir`, the only places an operand
30// path is resolved — use `openat2(2)` with `RESOLVE_NO_SYMLINKS` instead of a
31// plain `openat`, so a symlink in ANY component of an operand path fails closed
32// with `ELOOP` at the open itself (not in a racy pre-check). This is a global
33// rather than a threaded setting because it is per-process security policy that
34// must cover every operand open in every engine (copy/link/rm/chmod, local and
35// rcpd) without widening each Settings struct and the rcpd spawn contract; it is
36// armed once before the async runtime starts and never unset.
37
38static STRICT_OPERAND_RESOLUTION: std::sync::atomic::AtomicBool =
39    std::sync::atomic::AtomicBool::new(false);
40
41/// Arm strict operand resolution for the rest of this process (one-way).
42///
43/// Called by the TOCTOU linter when `--require-toctou-safe` proceeds; not
44/// intended to be called from anywhere else.
45pub fn enable_strict_operand_resolution() {
46    STRICT_OPERAND_RESOLUTION.store(true, std::sync::atomic::Ordering::Release);
47}
48
49/// Whether strict operand resolution is armed for this process.
50#[must_use]
51pub fn strict_operand_resolution() -> bool {
52    STRICT_OPERAND_RESOLUTION.load(std::sync::atomic::Ordering::Acquire)
53}
54
55/// Open `path` with `openat2(2)`, refusing to resolve ANY symlink component
56/// (`RESOLVE_NO_SYMLINKS`, which also implies `RESOLVE_NO_MAGICLINKS`). A
57/// symlink anywhere in the path fails with `ELOOP`.
58#[cfg(target_os = "linux")]
59fn openat2_no_symlinks(path: &Path, flags: OFlag) -> nix::Result<OwnedFd> {
60    use nix::fcntl::{OpenHow, ResolveFlag, openat2};
61    let how = OpenHow::new()
62        .flags(flags)
63        .resolve(ResolveFlag::RESOLVE_NO_SYMLINKS);
64    // bounded retry: with resolve restrictions openat2 can return EAGAIN when the
65    // kernel detects a rename race during resolution (see openat2(2)); retrying a
66    // handful of times resolves transient races without risking an unbounded loop.
67    let mut attempts = 0;
68    loop {
69        match openat2(AT_FDCWD, path, how) {
70            Err(nix::errno::Errno::EAGAIN) if attempts < 4 => attempts += 1,
71            other => return other,
72        }
73    }
74}
75
76/// Whether this kernel supports `openat2(2)` (Linux 5.6+), probed once.
77///
78/// Strict operand resolution is impossible without it, so
79/// `--require-toctou-safe` refuses to run when this returns `false`.
80#[cfg(target_os = "linux")]
81#[must_use]
82pub fn openat2_available() -> bool {
83    static PROBE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
84    *PROBE.get_or_init(|| {
85        // "/" always exists and is a directory; ENOSYS is the only expected failure
86        !matches!(
87            openat2_no_symlinks(Path::new("/"), OFlag::O_PATH | OFlag::O_CLOEXEC),
88            Err(nix::errno::Errno::ENOSYS)
89        )
90    })
91}
92
93/// Non-Linux builds have no `openat2`; `--require-toctou-safe` already refuses
94/// to run there (the hardened walk is Linux-only), so this is only consulted to
95/// render an accurate `--toctou-check` note.
96#[cfg(not(target_os = "linux"))]
97#[must_use]
98pub fn openat2_available() -> bool {
99    false
100}
101
102// ── FileMeta ──────────────────────────────────────────────────────────────────
103
104/// A snapshot of filesystem metadata obtained via `fstat`/`fstatat`.
105///
106/// Implements [`crate::preserve::Metadata`] so callers can apply these fields
107/// to another entry with the existing `set_*_metadata` helpers.
108#[derive(Clone, Debug)]
109pub struct FileMeta {
110    uid: u32,
111    gid: u32,
112    atime: i64,
113    atime_nsec: i64,
114    mtime: i64,
115    mtime_nsec: i64,
116    ctime: i64,
117    ctime_nsec: i64,
118    mode: u32,
119    size: u64,
120}
121
122impl FileMeta {
123    fn from_stat(st: &FileStat) -> Self {
124        Self {
125            uid: st.st_uid,
126            gid: st.st_gid,
127            atime: st.st_atime,
128            atime_nsec: st.st_atime_nsec,
129            mtime: st.st_mtime,
130            mtime_nsec: st.st_mtime_nsec,
131            ctime: st.st_ctime,
132            ctime_nsec: st.st_ctime_nsec,
133            mode: st.st_mode,
134            size: st.st_size as u64,
135        }
136    }
137}
138
139impl crate::preserve::Metadata for FileMeta {
140    fn uid(&self) -> u32 {
141        self.uid
142    }
143    fn gid(&self) -> u32 {
144        self.gid
145    }
146    fn atime(&self) -> i64 {
147        self.atime
148    }
149    fn atime_nsec(&self) -> i64 {
150        self.atime_nsec
151    }
152    fn mtime(&self) -> i64 {
153        self.mtime
154    }
155    fn mtime_nsec(&self) -> i64 {
156        self.mtime_nsec
157    }
158    fn permissions(&self) -> std::fs::Permissions {
159        use std::os::unix::fs::PermissionsExt;
160        std::fs::Permissions::from_mode(self.mode)
161    }
162    fn ctime(&self) -> i64 {
163        self.ctime
164    }
165    fn ctime_nsec(&self) -> i64 {
166        self.ctime_nsec
167    }
168    fn size(&self) -> u64 {
169        self.size
170    }
171}
172
173// ── EntryKind classification ───────────────────────────────────────────────────
174
175fn kind_from_stat(st: &FileStat) -> EntryKind {
176    let mode = st.st_mode;
177    // use libc mode-classification macros via their bit patterns (POSIX S_IFMT)
178    // S_IFREG = 0o0100000, S_IFDIR = 0o0040000, S_IFLNK = 0o0120000
179    let ifmt = mode & libc::S_IFMT;
180    match ifmt {
181        libc::S_IFREG => EntryKind::File,
182        libc::S_IFDIR => EntryKind::Dir,
183        libc::S_IFLNK => EntryKind::Symlink,
184        _ => EntryKind::Special,
185    }
186}
187
188// ── Handle ────────────────────────────────────────────────────────────────────
189
190/// An open, classified handle to a filesystem entry obtained via `O_PATH|O_NOFOLLOW`.
191///
192/// The fd is opened with `O_PATH`, so it cannot be used for reading; it exists
193/// solely to identify the entry (for further `openat` calls relative to it) and
194/// to carry the stat snapshot. A symlink entry is never followed: it yields a
195/// `Handle` with `kind() == EntryKind::Symlink`.
196#[derive(Debug)]
197pub struct Handle {
198    fd: OwnedFd,
199    kind: EntryKind,
200    dev: u64,
201    ino: u64,
202    meta: FileMeta,
203}
204
205impl Handle {
206    /// The entry's classification (File / Dir / Symlink / Special).
207    #[must_use]
208    pub fn kind(&self) -> EntryKind {
209        self.kind
210    }
211
212    /// The device number of the entry.
213    #[must_use]
214    pub fn dev(&self) -> u64 {
215        self.dev
216    }
217
218    /// The inode number of the entry.
219    #[must_use]
220    pub fn ino(&self) -> u64 {
221        self.ino
222    }
223
224    /// A snapshot of the entry's metadata at the time the handle was opened.
225    #[must_use]
226    pub fn meta(&self) -> &FileMeta {
227        &self.meta
228    }
229
230    /// Borrow the underlying file descriptor.
231    #[must_use]
232    pub fn as_fd(&self) -> BorrowedFd<'_> {
233        self.fd.as_fd()
234    }
235
236    /// Duplicate this handle, sharing the same pinned inode via a `dup`'d
237    /// (`F_DUPFD_CLOEXEC`) `O_PATH` file descriptor and copying the cached
238    /// classification + stat snapshot.
239    ///
240    /// This is a pure fd dup — it opens nothing, follows nothing, and stats
241    /// nothing on the filesystem, so it preserves every TOCTOU property of the
242    /// original `O_PATH|O_NOFOLLOW` handle (the clone pins the exact same inode and
243    /// cannot be redirected by a concurrent rename/symlink swap). It lets a walk
244    /// that classifies an entry once hand an owned handle to a deferred (post-order)
245    /// step without a second `openat`/`fstatat` on the entry.
246    pub fn try_clone(&self) -> std::io::Result<Handle> {
247        Ok(Handle {
248            fd: self.fd.try_clone()?,
249            kind: self.kind,
250            dev: self.dev,
251            ino: self.ino,
252            meta: self.meta.clone(),
253        })
254    }
255
256    /// Read this symlink's target and metadata from the one pinned `O_PATH` fd: the target via the
257    /// empty-path `readlinkat` ([`read_link_handle`]) and the metadata from this handle's `fstat`
258    /// snapshot. Both describe the same pinned inode, so they are a faithful pair (the symlink
259    /// analogue of [`Dir::open_file_read`]'s `(File, FileMeta)`). Errors if the handle is not a
260    /// symlink (the empty-path read requires a symlink fd).
261    pub async fn read_symlink(
262        &self,
263        side: congestion::Side,
264    ) -> std::io::Result<(std::path::PathBuf, FileMeta)> {
265        let target = read_link_handle(self, side).await?;
266        Ok((target, self.meta.clone()))
267    }
268}
269
270// ── Dir ───────────────────────────────────────────────────────────────────────
271
272/// A directory file descriptor opened `O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC`.
273///
274/// All entry-level operations are relative to this fd, preventing TOCTOU races
275/// that path-based lookups are vulnerable to.
276///
277/// The fd is held behind an `Arc` so per-entry operations can move an owned
278/// reference into their `spawn_blocking` closure. `spawn_blocking` tasks are
279/// not cancellable: if the surrounding future is dropped (timeout, `fail_early`
280/// abort, Ctrl-C) the closure keeps running detached. Cloning the `Arc` (a
281/// refcount bump, no syscall) keeps the open file description alive for the
282/// closure's full duration even if the originating `Dir` is dropped mid-flight,
283/// preserving the `openat` TOCTOU guarantee. Later fd-relative methods
284/// (`open_file_read`, `create_file`, `make_dir`, `read_entries`, …) must follow
285/// this same clone-Arc-into-closure shape.
286#[derive(Debug)]
287pub struct Dir {
288    fd: Arc<OwnedFd>,
289    /// Which filesystem side this directory lives on, for congestion gating.
290    side: congestion::Side,
291}
292
293impl Dir {
294    /// Which filesystem side this directory lives on (for congestion gating).
295    #[must_use]
296    pub fn side(&self) -> congestion::Side {
297        self.side
298    }
299
300    /// Open `path` as a directory fd.
301    ///
302    /// The final component is always opened with `O_NOFOLLOW`. If `dereference`
303    /// is `false` and the final component is a symlink, the call fails with
304    /// `ELOOP`. If `dereference` is `true` and the final component is a symlink,
305    /// the call is retried without `O_NOFOLLOW` so the symlink is followed.
306    ///
307    /// The parent prefix is resolved normally (it is trusted) — unless strict
308    /// operand resolution is armed (`--require-toctou-safe`), in which case the
309    /// whole path is resolved `RESOLVE_NO_SYMLINKS` and a symlink in ANY
310    /// component fails closed with `ELOOP`.
311    pub async fn open_root_dir(
312        path: &Path,
313        dereference: bool,
314        side: congestion::Side,
315    ) -> std::io::Result<Dir> {
316        let path = path.to_owned();
317        // run the blocking openat inside spawn_blocking, gated by the congestion
318        // controller, matching the per-metadata-syscall pattern used across the crate.
319        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
320            let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
321            let mode = Mode::empty();
322            #[cfg(target_os = "linux")]
323            {
324                if strict_operand_resolution() {
325                    // --require-toctou-safe: `-L` is refused by the linter before strict
326                    // mode can arm, so a dereference request here is an internal
327                    // inconsistency; fail closed rather than follow a symlink.
328                    if dereference {
329                        return Err(std::io::Error::new(
330                            std::io::ErrorKind::InvalidInput,
331                            "--dereference cannot be combined with strict operand resolution",
332                        ));
333                    }
334                    return openat2_no_symlinks(&path, flags)
335                        .map(|fd| Dir {
336                            fd: Arc::new(fd),
337                            side,
338                        })
339                        .map_err(nix_to_io);
340                }
341            }
342            match openat(AT_FDCWD, &path, flags, mode) {
343                Ok(fd) => Ok(Dir {
344                    fd: Arc::new(fd),
345                    side,
346                }),
347                Err(nix::errno::Errno::ELOOP) if dereference => {
348                    // final component is a symlink; follow it only when dereference=true
349                    let follow_flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
350                    openat(AT_FDCWD, &path, follow_flags, mode)
351                        .map(|fd| Dir {
352                            fd: Arc::new(fd),
353                            side,
354                        })
355                        .map_err(nix_to_io)
356                }
357                Err(e) => Err(nix_to_io(e)),
358            }
359        })
360        .await
361    }
362
363    /// Open a TRUSTED command-line parent-prefix directory, resolving symlinks
364    /// normally (the final component IS followed if it is a symlink).
365    ///
366    /// The trusted-boundary model (docs/tocttou.md, "Trusted boundary") trusts the directory named on
367    /// the command line up to and including itself; only entries strictly BELOW
368    /// it are hardened with `O_NOFOLLOW`. The parent prefix that CONTAINS the
369    /// operand is therefore resolved like a normal path open — a symlinked parent
370    /// (e.g. `rcp file symlink_to_dir/out`, where `symlink_to_dir` is a symlink to
371    /// a real directory) must be followed into the real directory, not rejected
372    /// with `ELOOP`/`ENOTDIR`.
373    ///
374    /// This differs from [`Self::open_root_dir`], which `O_NOFOLLOW`s the final
375    /// component (the named operand itself) and only follows it when
376    /// `dereference` is set. Use `open_parent_dir` for the operand's CONTAINER
377    /// directory; use `open_root_dir` for the operand entry. Every descendant
378    /// `openat` during the walk still uses `O_NOFOLLOW`, so the hardening below
379    /// the named root is unaffected.
380    ///
381    /// Returns a [`TrustedDir`]: this is the ONLY constructor of that type, so a
382    /// symlink-following open can be obtained nowhere else. Crossing into the
383    /// hardened tree below the named root is the explicit [`TrustedDir::into_tree`]
384    /// step.
385    ///
386    /// Under strict operand resolution (`--require-toctou-safe`) the prefix must
387    /// already be symlink-free: it is resolved `RESOLVE_NO_SYMLINKS`, and a
388    /// symlink in any component fails closed with `ELOOP` instead of being
389    /// followed. Pass fully-resolved operands (`realpath` output) in that mode.
390    pub async fn open_parent_dir(
391        path: &Path,
392        side: congestion::Side,
393    ) -> std::io::Result<TrustedDir> {
394        let path = path.to_owned();
395        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
396            let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
397            #[cfg(target_os = "linux")]
398            {
399                if strict_operand_resolution() {
400                    return openat2_no_symlinks(&path, flags)
401                        .map(|fd| {
402                            TrustedDir(Dir {
403                                fd: Arc::new(fd),
404                                side,
405                            })
406                        })
407                        .map_err(nix_to_io);
408                }
409            }
410            // a normal directory open: the kernel resolves the whole path following
411            // symlinks, including the final (trusted parent) component. No O_NOFOLLOW.
412            openat(AT_FDCWD, &path, flags, Mode::empty())
413                .map(|fd| {
414                    TrustedDir(Dir {
415                        fd: Arc::new(fd),
416                        side,
417                    })
418                })
419                .map_err(nix_to_io)
420        })
421        .await
422    }
423
424    /// Open a child directory entry by name, refusing to follow symlinks.
425    ///
426    /// Fails with `ELOOP` if `name` refers to a symlink, or `ENOTDIR` if it
427    /// refers to a non-directory entry. The returned `Dir` carries the same
428    /// congestion side as `self`.
429    pub async fn open_dir(&self, name: &OsStr) -> std::io::Result<Dir> {
430        // `O_NOFOLLOW`/`O_PATH` only guard the final path component, so a `name`
431        // containing `/` could let openat traverse an intermediate symlink. Reject
432        // multi-component names at runtime (debug_assert is compiled out in release).
433        if !is_single_component(name) {
434            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
435        }
436        // clone the Arc (refcount bump, no syscall) and move it into the blocking
437        // closure so the open file description stays alive for the closure's full
438        // duration even if this Dir is dropped mid-flight (spawn_blocking is not
439        // cancellable). see the Dir doc comment.
440        let dir = self.fd.clone();
441        let side = self.side;
442        let name = name.to_owned();
443        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
444            let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
445            openat(dir.as_fd(), name.as_bytes(), flags, Mode::empty())
446                .map(|fd| Dir {
447                    fd: Arc::new(fd),
448                    side,
449                })
450                .map_err(nix_to_io)
451        })
452        .await
453    }
454
455    /// Open a child regular file for reading, refusing to follow symlinks and never
456    /// blocking on a FIFO. Returns the open file plus its metadata snapshot.
457    ///
458    /// `O_NONBLOCK` is included so that if an attacker races the directory entry to
459    /// a FIFO between `getdents` and this `open`, the open returns immediately
460    /// (`O_RDONLY|O_NONBLOCK` on a FIFO never blocks on Linux) rather than blocking
461    /// forever waiting for a writer. `O_NOFOLLOW` prevents symlink following but
462    /// does not catch FIFOs (they are not symlinks); the subsequent `fstat` +
463    /// `S_ISREG` check rejects any non-regular file (FIFO, device, directory) with
464    /// `EINVAL`. `O_NONBLOCK` persists on the returned `File`, which is harmless for
465    /// regular-file I/O on a local fs.
466    ///
467    /// Fails with `EINVAL` if `name` is not a single path component, `ELOOP` if
468    /// `name` is a symlink, or `EINVAL` (after open, via the `fstat`+`S_ISREG`
469    /// check) if the entry is any non-regular type such as a FIFO, device, or
470    /// directory.
471    ///
472    /// This is the canonical regular-file payload+metadata read: the returned `FileMeta` (not the
473    /// classify [`Handle`]'s metadata) is what callers must apply/send, so bytes and metadata come
474    /// from the same fd (read-side fidelity, see docs/tocttou.md).
475    pub async fn open_file_read(&self, name: &OsStr) -> std::io::Result<(std::fs::File, FileMeta)> {
476        if !is_single_component(name) {
477            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
478        }
479        let dir = self.fd.clone();
480        let side = self.side;
481        let name = name.to_owned();
482        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
483            let flags = OFlag::O_RDONLY | OFlag::O_NOFOLLOW | OFlag::O_NONBLOCK | OFlag::O_CLOEXEC;
484            let fd =
485                openat(dir.as_fd(), name.as_bytes(), flags, Mode::empty()).map_err(nix_to_io)?;
486            // fstat the open fd to confirm the entry is a regular file; this is the
487            // safety check — O_NOFOLLOW does not catch FIFOs or other special files.
488            let st = fstat(&fd).map_err(nix_to_io)?;
489            if kind_from_stat(&st) != EntryKind::File {
490                // fd is dropped here, closing it
491                return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
492            }
493            let meta = FileMeta::from_stat(&st);
494            let file = std::fs::File::from(fd);
495            Ok((file, meta))
496        })
497        .await
498    }
499
500    /// `fstat` this directory's own held fd, returning its metadata snapshot.
501    ///
502    /// Lets a caller apply/send a directory's metadata from the SAME fd whose `read_entries`
503    /// produced its contents (read-side fidelity, see docs/tocttou.md), rather than from a
504    /// separately-opened classify [`Handle`] that a concurrent swap could desync from the
505    /// enumerated contents. Gated as `Stat`.
506    pub async fn meta(&self) -> std::io::Result<FileMeta> {
507        let dir = self.fd.clone();
508        let side = self.side;
509        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
510            let st = fstat(dir.as_fd()).map_err(nix_to_io)?;
511            Ok(FileMeta::from_stat(&st))
512        })
513        .await
514    }
515
516    /// Open a child entry by name, classifying it without following symlinks.
517    ///
518    /// Uses `O_PATH|O_NOFOLLOW`, which yields a valid fd even for symlinks. The
519    /// stat is then obtained via `fstatat` with `AT_EMPTY_PATH` on the resulting
520    /// fd so the classification is always consistent with the opened entry.
521    pub async fn child(&self, name: &OsStr) -> std::io::Result<Handle> {
522        // see open_dir: `O_NOFOLLOW`/`O_PATH` only guard the final component, so a
523        // `name` containing `/` could traverse an intermediate symlink. Reject
524        // multi-component names at runtime (debug_assert is compiled out in release).
525        if !is_single_component(name) {
526            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
527        }
528        // clone the Arc (refcount bump, no syscall) and move it into the blocking
529        // closure so the open file description stays alive for the closure's full
530        // duration even if this Dir is dropped mid-flight (spawn_blocking is not
531        // cancellable). see the Dir doc comment.
532        let dir = self.fd.clone();
533        let side = self.side;
534        let name = name.to_owned();
535        run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
536            let flags = OFlag::O_PATH | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
537            let fd =
538                openat(dir.as_fd(), name.as_bytes(), flags, Mode::empty()).map_err(nix_to_io)?;
539            // stat the fd itself (empty path + AT_EMPTY_PATH): works for symlinks too
540            let st = fstatat(&fd, "", AtFlags::AT_EMPTY_PATH).map_err(nix_to_io)?;
541            let kind = kind_from_stat(&st);
542            let dev = st.st_dev;
543            let ino = st.st_ino;
544            let meta = FileMeta::from_stat(&st);
545            Ok(Handle {
546                fd,
547                kind,
548                dev,
549                ino,
550                meta,
551            })
552        })
553        .await
554    }
555
556    /// Re-open `name` and confirm it still refers to the same inode as `expected`
557    /// (same `dev` + `ino`). Returns the fresh [`Handle`] on match.
558    ///
559    /// On mismatch — the directory entry was swapped to a different inode between
560    /// when `expected` was obtained and this call — returns `ESTALE`. Callers fail
561    /// closed: they must not proceed with an operation that assumed a specific identity
562    /// for the entry.
563    ///
564    /// # Soundness
565    ///
566    /// `expected`'s `O_PATH` fd pins the old inode alive for the duration of the
567    /// call: as long as any fd referencing an inode is open, the kernel cannot
568    /// recycle that inode number. A matching `(dev, ino)` therefore genuinely
569    /// proves the two fds refer to the same inode — there is no window in which
570    /// the number could have been reused.
571    pub async fn recheck(&self, name: &OsStr, expected: &Handle) -> std::io::Result<Handle> {
572        if !is_single_component(name) {
573            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
574        }
575        let fresh = self.child(name).await?;
576        if fresh.dev() == expected.dev() && fresh.ino() == expected.ino() {
577            Ok(fresh)
578        } else {
579            Err(std::io::Error::from_raw_os_error(libc::ESTALE))
580        }
581    }
582
583    /// Create a child directory and return an open `Dir` handle to it (same side as self).
584    ///
585    /// Fails with `EINVAL` if `name` is not a single path component, or `EEXIST` if
586    /// a directory (or any other entry) at `name` already exists.
587    ///
588    /// This is a two-step operation: `mkdirat` (gated as `MkDir`) to create the
589    /// directory, followed by `open_dir` (gated as `Stat`) to open and return it.
590    pub async fn make_dir(&self, name: &OsStr, mode: u32) -> std::io::Result<Dir> {
591        if !is_single_component(name) {
592            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
593        }
594        let dir = self.fd.clone();
595        let side = self.side;
596        let name_owned = name.to_owned();
597        run_metadata_probed_blocking(side, congestion::MetadataOp::MkDir, move || {
598            mkdirat(
599                dir.as_fd(),
600                name_owned.as_bytes(),
601                Mode::from_bits_truncate(mode),
602            )
603            .map_err(nix_to_io)
604        })
605        .await?;
606        self.open_dir(name).await
607    }
608
609    /// Enumerate the directory's entries (excluding `.` and `..`).
610    ///
611    /// Returns each entry's name and its `getdents` `d_type` as a best-effort
612    /// `EntryKind` hint (`None` when the filesystem reports `DT_UNKNOWN`). The
613    /// hint is advisory only — callers MUST confirm type via `child`/`fstat`
614    /// before acting (TOCTOU safety).
615    ///
616    /// This method acquires only the static ops rate gate (not the congestion
617    /// probe). Directory enumeration is deliberately not probed because buffered
618    /// `getdents` produces bimodal latency (cache hit vs. real kernel call) that
619    /// would pollute the congestion controller's baseline — see
620    /// `walk::next_entry_probed` for the full rationale.
621    pub async fn read_entries(
622        &self,
623    ) -> std::io::Result<Vec<(std::ffi::OsString, Option<EntryKind>)>> {
624        throttle::get_ops_token().await;
625        let dir = self.fd.clone();
626        tokio::task::spawn_blocking(move || {
627            // Dup the fd with FD_CLOEXEC so nix::dir::Dir can consume (and close)
628            // it on drop without touching self's Arc<OwnedFd>. A bare dup(2)
629            // would clear FD_CLOEXEC; F_DUPFD_CLOEXEC atomically sets it.
630            //
631            // Re-entrancy: the dup shares the original's open file description,
632            // and therefore its directory read offset. Reading to EOF advances
633            // that shared offset, so a naive `fdopendir` loop would leave self's
634            // fd at EOF and make a *second* read_entries() on the same Dir
635            // return an empty listing. nix's borrowing `Iter` (from
636            // `nix_dir.iter()`) rewinds the shared description in its `Drop`
637            // (rewinddir(3) → offset 0), and that `Drop` runs on BOTH normal
638            // completion AND the early `?`-return taken on a mid-iteration error
639            // — so the dup is always rewound before it is closed, leaving self's
640            // fd at offset 0 either way. This re-entrancy is load-bearing: the
641            // hardened remote source enumerates a directory in Pass 1 and again
642            // in Pass 2 on the *same* `Arc<Dir>`. (Additionally every caller
643            // treats an enumeration error as terminal and never re-enumerates the
644            // directory, so a partially-advanced offset is never observed
645            // regardless.)
646            let dup_raw: RawFd =
647                nix::fcntl::fcntl(dir.as_fd(), nix::fcntl::FcntlArg::F_DUPFD_CLOEXEC(0))
648                    .map_err(nix_to_io)?;
649            // SAFETY: dup_raw is a freshly-dup'd fd that we own exclusively; no
650            // other reference to it exists.
651            let dup_owned = unsafe { OwnedFd::from_raw_fd(dup_raw) };
652            let mut nix_dir = nix::dir::Dir::from_fd(dup_owned).map_err(nix_to_io)?;
653
654            let mut entries = Vec::new();
655            for entry_result in nix_dir.iter() {
656                let entry = entry_result.map_err(nix_to_io)?;
657                let name_cstr = entry.file_name();
658                // skip "." and ".."
659                if name_cstr == c"." || name_cstr == c".." {
660                    continue;
661                }
662                let name = std::ffi::OsStr::from_bytes(name_cstr.to_bytes()).to_owned();
663                let kind = entry.file_type().map(|t| match t {
664                    nix::dir::Type::Directory => EntryKind::Dir,
665                    nix::dir::Type::Symlink => EntryKind::Symlink,
666                    nix::dir::Type::File => EntryKind::File,
667                    _ => EntryKind::Special,
668                });
669                entries.push((name, kind));
670            }
671            // nix_dir drops here, closing the dup'd fd; self's fd is unaffected
672            Ok(entries)
673        })
674        .await
675        .map_err(std::io::Error::other)?
676    }
677
678    /// Remove a child non-directory entry by name, gated on this directory's own congestion side.
679    ///
680    /// For a symlink, this unlinks the link itself — never its target.
681    ///
682    /// Fails with `EINVAL` if `name` is not a single path component, or `EISDIR`
683    /// if `name` refers to a directory.
684    pub async fn unlink_at(&self, name: &OsStr) -> std::io::Result<()> {
685        self.unlink_at_on(name, self.side).await
686    }
687
688    /// Like [`Self::unlink_at`], but gates the `unlinkat` on an explicitly chosen congestion
689    /// `side` rather than the directory's own side.
690    ///
691    /// `rm` reads its tree on the `Source` side (its `Dir` handles are `Source`-sided, matching
692    /// the old path-based `symlink_metadata`/`read_dir`), but the destructive `unlinkat` must be
693    /// bucketed on `Destination` to match the side the path-based rm used for `remove_file` — so
694    /// it competes for the same metadata cwnd as other destructive work. The fd-relative TOCTOU
695    /// guarantee is unaffected: the syscall is still resolved against this directory's pinned fd.
696    pub(crate) async fn unlink_at_on(
697        &self,
698        name: &OsStr,
699        side: congestion::Side,
700    ) -> std::io::Result<()> {
701        if !is_single_component(name) {
702            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
703        }
704        let dir = self.fd.clone();
705        let name = name.to_owned();
706        run_metadata_probed_blocking(side, congestion::MetadataOp::Unlink, move || {
707            unlinkat(dir.as_fd(), name.as_bytes(), UnlinkatFlags::NoRemoveDir).map_err(nix_to_io)
708        })
709        .await
710    }
711
712    /// Remove a child empty directory by name, gated on this directory's own congestion side.
713    ///
714    /// Fails with `EINVAL` if `name` is not a single path component, `ENOTEMPTY`
715    /// if the directory is not empty, or `ENOTDIR` if `name` is not a directory.
716    pub async fn rmdir_at(&self, name: &OsStr) -> std::io::Result<()> {
717        self.rmdir_at_on(name, self.side).await
718    }
719
720    /// Like [`Self::rmdir_at`], but gates the `rmdir` on an explicitly chosen congestion `side`
721    /// rather than the directory's own side. See [`Self::unlink_at_on`] for why `rm` needs this
722    /// (`Destination`-sided removal from a `Source`-sided read walk).
723    pub(crate) async fn rmdir_at_on(
724        &self,
725        name: &OsStr,
726        side: congestion::Side,
727    ) -> std::io::Result<()> {
728        if !is_single_component(name) {
729            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
730        }
731        let dir = self.fd.clone();
732        let name = name.to_owned();
733        run_metadata_probed_blocking(side, congestion::MetadataOp::RmDir, move || {
734            unlinkat(dir.as_fd(), name.as_bytes(), UnlinkatFlags::RemoveDir).map_err(nix_to_io)
735        })
736        .await
737    }
738
739    /// Create a symlink `name` → `target` in this directory, returning a
740    /// fd-pinned `Handle` to the just-created link.
741    ///
742    /// The returned handle has `kind() == EntryKind::Symlink` and can be used to
743    /// apply metadata to the link race-free. `target` is the link contents — it
744    /// is an arbitrary path and is not restricted to a single component.
745    ///
746    /// Fails with `EINVAL` if `name` is not a single path component, or `EEXIST`
747    /// if an entry at `name` already exists.
748    pub async fn symlink_at(&self, name: &OsStr, target: &Path) -> std::io::Result<Handle> {
749        if !is_single_component(name) {
750            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
751        }
752        let dir = self.fd.clone();
753        let side = self.side;
754        let name = name.to_owned();
755        let target = target.to_owned();
756        // clone `name` so we can call self.child() after the closure consumes it
757        let name_for_child = name.clone();
758        run_metadata_probed_blocking(side, congestion::MetadataOp::Symlink, move || {
759            // symlinkat(target, dirfd, name): creates `name` → `target`
760            symlinkat(target.as_os_str().as_bytes(), dir.as_fd(), name.as_bytes())
761                .map_err(nix_to_io)
762        })
763        .await?;
764        // open the just-created link with O_PATH|O_NOFOLLOW so we get a Handle
765        // that is pinned to the symlink inode itself (not its target).
766        let handle = self.child(&name_for_child).await?;
767        if handle.kind() != EntryKind::Symlink {
768            // should never happen — we just created a symlink; if it somehow
769            // changed underneath us, report ENOENT to signal the caller.
770            return Err(std::io::Error::from_raw_os_error(libc::ENOENT));
771        }
772        Ok(handle)
773    }
774
775    /// Read the target of a child symlink.
776    ///
777    /// Fails with `EINVAL` if `name` is not a single path component, or `EINVAL`
778    /// (from `readlinkat`) if `name` is not a symlink.
779    pub async fn read_link_at(&self, name: &OsStr) -> std::io::Result<std::path::PathBuf> {
780        if !is_single_component(name) {
781            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
782        }
783        let dir = self.fd.clone();
784        let side = self.side;
785        let name = name.to_owned();
786        run_metadata_probed_blocking(side, congestion::MetadataOp::ReadLink, move || {
787            readlinkat(dir.as_fd(), name.as_bytes())
788                .map(std::path::PathBuf::from)
789                .map_err(nix_to_io)
790        })
791        .await
792    }
793
794    /// Create a hard link at `dst`/`dst_name` pointing to this directory's `name`.
795    ///
796    /// Uses `AtFlags::empty()` (flags=0, no `AT_SYMLINK_FOLLOW`), so if `name` is a
797    /// symlink, the link target is the symlink inode itself — the target file
798    /// gains no new hard link.
799    ///
800    /// Fails with `EINVAL` if either `name` or `dst_name` is not a single path
801    /// component.
802    pub async fn hard_link_at(
803        &self,
804        name: &OsStr,
805        dst: &Dir,
806        dst_name: &OsStr,
807    ) -> std::io::Result<()> {
808        if !is_single_component(name) {
809            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
810        }
811        if !is_single_component(dst_name) {
812            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
813        }
814        let src_dir = self.fd.clone();
815        let dst_dir = dst.fd.clone();
816        let side = dst.side;
817        let name = name.to_owned();
818        let dst_name = dst_name.to_owned();
819        run_metadata_probed_blocking(side, congestion::MetadataOp::HardLink, move || {
820            linkat(
821                src_dir.as_fd(),
822                name.as_bytes(),
823                dst_dir.as_fd(),
824                dst_name.as_bytes(),
825                AtFlags::empty(),
826            )
827            .map_err(nix_to_io)
828        })
829        .await
830    }
831
832    /// Create a hard link at `self`/`dst_name` pointing to the EXACT inode that
833    /// `src_handle` pins — never re-resolving the source by name.
834    ///
835    /// `self` is the DESTINATION directory. The source is identified solely by
836    /// `src_handle`'s `O_PATH` file descriptor: the link is made via
837    /// `linkat(AT_FDCWD, "/proc/self/fd/N", dst_fd, dst_name, AT_SYMLINK_FOLLOW)`,
838    /// where `N` is the handle's fd. `AT_SYMLINK_FOLLOW` makes `linkat` follow the
839    /// `/proc` magic symlink to the handle's pinned inode, so the new hard link
840    /// targets that exact inode regardless of any concurrent rename / symlink swap
841    /// of the original directory entry.
842    ///
843    /// # Why /proc and not the source-name `linkat` or `AT_EMPTY_PATH`
844    ///
845    /// `Dir::hard_link_at` re-resolves the source by `name`, which is a TOCTOU
846    /// window: an attacker who controls the source tree can replace `name` with a
847    /// different inode (symlink, FIFO, another file) between classification and the
848    /// `linkat`, so the link would target the replacement. Linking the pinned fd
849    /// closes that window. `linkat(fd, "", .., AT_EMPTY_PATH)` would also be
850    /// inode-exact but requires `CAP_DAC_READ_SEARCH`; the `/proc/self/fd` form does
851    /// not, mirroring `chmod_via_proc_fd`.
852    ///
853    /// # Behavior
854    ///
855    /// - Inode-exact happy path: a stable regular-file handle links exactly as the
856    ///   by-name path did (same inode, same content).
857    /// - Fail-closed under attack: if the pinned inode's last directory entry was
858    ///   removed (link count 0, e.g. the attacker renamed `name` away), the kernel
859    ///   refuses to resurrect it and `linkat` fails with `ENOENT`. It never links a
860    ///   swapped-in replacement.
861    /// - Directories: `linkat` refuses to hard-link a directory (`EPERM`), exactly
862    ///   as the by-name path did. Callers must only pass a regular-file handle.
863    ///
864    /// # Errors
865    ///
866    /// `EINVAL` if `dst_name` is not a single path component; `ENOENT` if the pinned
867    /// inode has no remaining links (fail-closed); `EEXIST` if an entry at
868    /// `dst_name` already exists; `EPERM` if the handle refers to a directory.
869    /// Requires `/proc` mounted (same precondition as `chmod_via_proc_fd`).
870    pub async fn hard_link_handle_at(
871        &self,
872        src_handle: &Handle,
873        dst_name: &OsStr,
874    ) -> std::io::Result<()> {
875        if !is_single_component(dst_name) {
876            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
877        }
878        // clone the source O_PATH fd into an owned fd the blocking closure can hold,
879        // keeping the pinned inode alive for the syscall's full duration even if the
880        // originating Handle is dropped (spawn_blocking is not cancellable).
881        let src_owned = src_handle.as_fd().try_clone_to_owned()?;
882        let dst_dir = self.fd.clone();
883        let side = self.side;
884        let dst_name = dst_name.to_owned();
885        run_metadata_probed_blocking(side, congestion::MetadataOp::HardLink, move || {
886            let proc_path = format!("/proc/self/fd/{}", src_owned.as_raw_fd());
887            // AT_SYMLINK_FOLLOW: the /proc entry is a magic symlink that must be
888            // dereferenced to reach the pinned inode (without the flag, linkat would
889            // try to hard-link the magic symlink itself, which is not permitted).
890            linkat(
891                AT_FDCWD,
892                proc_path.as_str(),
893                dst_dir.as_fd(),
894                dst_name.as_bytes(),
895                AtFlags::AT_SYMLINK_FOLLOW,
896            )
897            .map_err(nix_to_io)
898        })
899        .await
900    }
901
902    /// Create a new child file, failing if it already exists and never following a symlink.
903    ///
904    /// `mode` is the creation mode (subject to umask); exact permissions are set
905    /// later via fchmod. Returns the open writable `File` on success.
906    ///
907    /// `O_EXCL` is the primary guard: combined with `O_CREAT`, it fails with
908    /// `EEXIST` on any pre-existing entry — including a symlink — without
909    /// following it. `O_NOFOLLOW` is the fallback that would still refuse to
910    /// follow a symlink (with `ELOOP`) should `O_EXCL` ever be bypassed.
911    ///
912    /// Fails with `EINVAL` if `name` is not a single path component, or `EEXIST`
913    /// if a file or symlink at `name` already exists.
914    pub async fn create_file(&self, name: &OsStr, mode: u32) -> std::io::Result<std::fs::File> {
915        if !is_single_component(name) {
916            return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
917        }
918        let dir = self.fd.clone();
919        let side = self.side;
920        let name = name.to_owned();
921        run_metadata_probed_blocking(side, congestion::MetadataOp::OpenCreate, move || {
922            let flags = OFlag::O_CREAT
923                | OFlag::O_EXCL
924                | OFlag::O_WRONLY
925                | OFlag::O_NOFOLLOW
926                | OFlag::O_CLOEXEC;
927            let file_mode = Mode::from_bits_truncate(mode);
928            openat(dir.as_fd(), name.as_bytes(), flags, file_mode)
929                .map(std::fs::File::from)
930                .map_err(nix_to_io)
931        })
932        .await
933    }
934}
935
936// ── TrustedDir ──────────────────────────────────────────────────────────────────
937
938/// A directory opened by FOLLOWING symlinks normally — the command-line-named
939/// path's trusted parent prefix.
940///
941/// The trusted-boundary model (docs/tocttou.md, "Trusted boundary") trusts the path named on the
942/// command line up to and including its container directory; only entries
943/// strictly BELOW the named root are hardened with `O_NOFOLLOW`. A `TrustedDir`
944/// is that trusted container, and it is the ONLY way in this crate to obtain a
945/// directory fd that was opened following symlinks — its sole constructor is
946/// [`Dir::open_parent_dir`]. Every other directory open ([`Dir::open_dir`],
947/// [`Dir::child`], [`Dir::open_file_read`], [`Dir::create_file`],
948/// [`Dir::make_dir`], …) is `O_NOFOLLOW`.
949///
950/// Because the trusted/hardened distinction is a type rather than a convention,
951/// the compiler enforces it: a parent-prefix slot typed `TrustedDir` can only be
952/// filled by the follow-open, and a hardened `Dir` cannot be used where a trusted
953/// parent is required. Crossing from the trusted prefix into the hardened tree is
954/// the single explicit [`Self::into_tree`] step.
955///
956/// Under strict operand resolution (`--require-toctou-safe`) the "trusted"
957/// prefix is additionally required to be symlink-free: the open resolves it
958/// `RESOLVE_NO_SYMLINKS`, so a symlink component fails closed with `ELOOP`
959/// rather than being followed (see [`enable_strict_operand_resolution`]).
960#[derive(Debug)]
961pub struct TrustedDir(Dir);
962
963impl TrustedDir {
964    /// Cross from the trusted parent prefix into the hardened tree, consuming the `TrustedDir` and
965    /// handing back the owned hardened `Dir` (e.g. to wrap it in an `Arc` for the walk). Every open
966    /// below the returned `Dir` is `O_NOFOLLOW`, so nothing below the named root can be redirected
967    /// by a symlink swap. This is the one explicit trusted→hardened transition.
968    #[must_use]
969    pub fn into_tree(self) -> Dir {
970        self.0
971    }
972}
973
974// ── Strict operand probes ────────────────────────────────────────────────────
975//
976// Existence/kind and directory-open probes on an operand path that stay faithful
977// to strict operand resolution (`--require-toctou-safe`): they resolve the
978// operand's parent prefix with `open_parent_dir` (which is
979// `openat2(RESOLVE_NO_SYMLINKS)` while armed) and touch the final component only
980// fd-relative, so a symlink in a directory component of the operand path fails
981// closed with `ELOOP` instead of being followed by a path-based probe
982// (`Path::exists`, `symlink_metadata`, `open_root_dir` on the full path). These
983// decompose the path into parent + final component so an INTERMEDIATE-prefix
984// symlink (a strict violation → `Err(ELOOP)`) is never conflated with a final
985// component that is merely a symlink / non-directory (→ `Ok(None)`). Callers use
986// these under `strict_operand_resolution()`; the default path keeps its
987// path-based probes unchanged.
988
989/// Split a lexically-normal absolute operand into `(parent, final_component)` for
990/// an fd-relative probe. Strict operands are already absolute + normal (the linter
991/// enforced it), so a plain `parent()`/`file_name()` split is correct. Returns
992/// `None` when the path has no distinct parent+name (e.g. `/`), where there is
993/// nothing to probe fd-relative.
994fn split_parent_and_name(path: &Path) -> Option<(&Path, &OsStr)> {
995    let name = path.file_name()?;
996    let parent = match path.parent() {
997        Some(parent) if !parent.as_os_str().is_empty() => parent,
998        // a single-component relative path means the current directory
999        _ => Path::new("."),
1000    };
1001    Some((parent, name))
1002}
1003
1004/// Probe an operand's existence and kind fd-relative under strict operand
1005/// resolution: open its parent with `open_parent_dir` (`RESOLVE_NO_SYMLINKS`
1006/// while armed) and classify the final component via `child` (`O_NOFOLLOW`).
1007///
1008/// - `Ok(Some(kind))` — the entry exists (a final-component symlink counts as
1009///   existing, classified `Symlink`, and is never followed, matching
1010///   `symlink_metadata`).
1011/// - `Ok(None)` — the entry, or its parent, does not exist (`ENOENT`/`ENOTDIR`).
1012/// - `Err(ELOOP)` — a directory component of the operand path is a symlink; the
1013///   caller must fail closed.
1014pub async fn strict_probe_dst_kind(
1015    path: &Path,
1016    side: congestion::Side,
1017) -> std::io::Result<Option<EntryKind>> {
1018    let Some((parent, name)) = split_parent_and_name(path) else {
1019        return Ok(None);
1020    };
1021    match Dir::open_parent_dir(parent, side).await {
1022        Ok(parent) => match parent.into_tree().child(name).await {
1023            Ok(handle) => Ok(Some(handle.kind())),
1024            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1025            Err(err) => Err(err),
1026        },
1027        Err(err)
1028            if err.kind() == std::io::ErrorKind::NotFound
1029                || err.raw_os_error() == Some(libc::ENOTDIR) =>
1030        {
1031            Ok(None)
1032        }
1033        Err(err) => Err(err),
1034    }
1035}
1036
1037// ── fd-based metadata application ───────────────────────────────────────────────
1038//
1039// These primitives apply ownership / mode / timestamps to an entry through a
1040// file descriptor we already hold, rather than re-resolving a path. That closes
1041// the TOCTOU window a path-based applier would have between opening/creating the
1042// entry and re-touching it by name (which is why the fd-based appliers replaced
1043// the path-based ones entirely).
1044//
1045// Every applier follows the chown → chmod → utimens ordering: chown first (it
1046// clears setuid/setgid on regular files), chmod second (restores them), utimens
1047// last (chown and chmod both touch ctime/mtime). All syscalls are gated through
1048// `run_metadata_probed_blocking` with `MetadataOp::Chmod`, bucketing
1049// chown/chmod/utimens together.
1050
1051/// `fchown` on a real (readable/writable) file descriptor.
1052///
1053/// No-op is the caller's responsibility: this always issues the syscall. Pass
1054/// `None` for a component that must not change.
1055async fn fchown_fd(
1056    fd: BorrowedFd<'_>,
1057    side: congestion::Side,
1058    uid: Option<u32>,
1059    gid: Option<u32>,
1060) -> std::io::Result<()> {
1061    // BorrowedFd is not 'static, so dup it into an owned fd the closure can hold.
1062    let owned = fd.try_clone_to_owned()?;
1063    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
1064        fchown(
1065            owned.as_fd(),
1066            uid.map(Uid::from_raw),
1067            gid.map(Gid::from_raw),
1068        )
1069        .map_err(nix_to_io)
1070    })
1071    .await
1072}
1073
1074/// `fchmod` on a real file descriptor. `mode` is masked to the permission bits
1075/// (`0o7777`); file-type bits, if present, are dropped by `from_bits_truncate`.
1076///
1077/// `fd` must be a real (not `O_PATH`) descriptor — `fchmod` returns `EBADF` on an
1078/// `O_PATH` fd. This is used by the copy path, which holds the destination's own
1079/// writable file / directory fd. For an `O_PATH` [`Handle`] (e.g. rchm's classified
1080/// entry), use [`chmod_via_proc_fd`] instead.
1081async fn fchmod_fd(fd: BorrowedFd<'_>, side: congestion::Side, mode: u32) -> std::io::Result<()> {
1082    let owned = fd.try_clone_to_owned()?;
1083    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
1084        fchmod(owned.as_fd(), Mode::from_bits_truncate(mode)).map_err(nix_to_io)
1085    })
1086    .await
1087}
1088
1089/// `futimens` on a real file descriptor.
1090async fn futimens_fd(
1091    fd: BorrowedFd<'_>,
1092    side: congestion::Side,
1093    atime: i64,
1094    atime_nsec: i64,
1095    mtime: i64,
1096    mtime_nsec: i64,
1097) -> std::io::Result<()> {
1098    let owned = fd.try_clone_to_owned()?;
1099    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
1100        let atime_spec = TimeSpec::new(atime, atime_nsec);
1101        let mtime_spec = TimeSpec::new(mtime, mtime_nsec);
1102        futimens(owned.as_fd(), &atime_spec, &mtime_spec).map_err(nix_to_io)
1103    })
1104    .await
1105}
1106
1107/// Inode-exact `fchownat` on any [`Handle`]'s `O_PATH` fd, operating on the entry
1108/// the fd points at — file, directory, or symlink — never following a symlink.
1109///
1110/// Uses `AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW` so the empty pathname resolves to
1111/// the fd's own pinned inode: no path re-resolution by `name` happens, so a
1112/// concurrent rename/symlink-swap of the directory entry cannot redirect the
1113/// chown to a different target. `AT_SYMLINK_NOFOLLOW` makes a symlink `Handle`
1114/// chown the link itself rather than its target. Pass `None` for a component
1115/// that must not change (the caller decides when to issue the syscall at all).
1116pub(crate) async fn fchown_handle(
1117    handle: &Handle,
1118    side: congestion::Side,
1119    uid: Option<u32>,
1120    gid: Option<u32>,
1121) -> std::io::Result<()> {
1122    let owned = handle.as_fd().try_clone_to_owned()?;
1123    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
1124        fchownat(
1125            owned.as_fd(),
1126            "",
1127            uid.map(Uid::from_raw),
1128            gid.map(Gid::from_raw),
1129            AtFlags::AT_EMPTY_PATH | AtFlags::AT_SYMLINK_NOFOLLOW,
1130        )
1131        .map_err(nix_to_io)
1132    })
1133    .await
1134}
1135
1136/// `chmod` any non-symlink entry (file, directory, or special) through an `O_PATH`
1137/// [`Handle`] by going via the `/proc/self/fd/N` magic symlink, changing the mode
1138/// of the EXACT inode the handle pins — never re-resolving the entry by name.
1139///
1140/// (Symlink mode bits are not settable on Linux, so callers never invoke this on a
1141/// symlink handle.)
1142///
1143/// # Why /proc and not `fchmod`/`fchmodat`
1144///
1145/// The `Handle` fd is `O_PATH`, which is the only way to pin an arbitrary entry's
1146/// inode without read/write/search rights on it. But `O_PATH` rules out the
1147/// obvious chmod paths:
1148///
1149/// - `fchmod(fd, mode)` returns `EBADF` on an `O_PATH` fd (it requires a real
1150///   open file description).
1151/// - `fchmodat(dirfd, name, mode, AT_SYMLINK_NOFOLLOW)` re-resolves `name`
1152///   relative to a directory fd — that re-resolution is exactly the TOCTOU window
1153///   we are closing, and the `AT_SYMLINK_NOFOLLOW` flag is only honored on Linux
1154///   6.6+ for `fchmodat` (older kernels reject it with `ENOTSUP`).
1155///
1156/// `chmod("/proc/self/fd/N", mode)` follows the kernel's per-fd magic symlink,
1157/// which resolves to the open file description's pinned inode regardless of what
1158/// the original `name` now refers to. Because the `O_PATH` handle keeps that
1159/// inode alive (the kernel cannot recycle an inode with an open reference), this
1160/// is inode-exact and immune to a concurrent rename/symlink swap. It also works
1161/// regardless of the file's own permission bits — e.g. a non-root owner's
1162/// `0000`-mode file — because the operation authorizes against the caller's
1163/// ownership, not the path's mode, and needs no traversal/read rights on the
1164/// target. (`fchmodat(.., FollowSymlink)` on the magic symlink is used because
1165/// the magic link must be dereferenced to reach the pinned inode.)
1166///
1167/// # Precondition
1168///
1169/// Requires `/proc` to be mounted (the standard Linux default). Without `/proc`
1170/// the call fails with `ENOENT`; this is a documented operational precondition of
1171/// the fd-based chmod path.
1172pub(crate) async fn chmod_via_proc_fd(
1173    handle: &Handle,
1174    side: congestion::Side,
1175    mode: u32,
1176) -> std::io::Result<()> {
1177    // clone the O_PATH fd into an owned fd the blocking closure can hold, keeping
1178    // the pinned inode alive for the syscall's full duration even if the
1179    // originating Handle is dropped (spawn_blocking is not cancellable).
1180    let owned = handle.as_fd().try_clone_to_owned()?;
1181    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
1182        let proc_path = format!("/proc/self/fd/{}", owned.as_raw_fd());
1183        // FollowSymlink: the /proc entry is a magic symlink that must be
1184        // dereferenced to reach the pinned inode (NoFollowSymlink would chmod the
1185        // magic link itself, a silent no-op).
1186        nix::sys::stat::fchmodat(
1187            AT_FDCWD,
1188            proc_path.as_str(),
1189            Mode::from_bits_truncate(mode),
1190            nix::sys::stat::FchmodatFlags::FollowSymlink,
1191        )
1192        .map_err(nix_to_io)
1193    })
1194    .await
1195}
1196
1197/// Synchronous, ungated chmod of the EXACT inode an `O_PATH` `fd` pins, via its
1198/// `/proc/self/fd/N` magic symlink — the blocking-`Drop` counterpart of
1199/// [`chmod_via_proc_fd`].
1200///
1201/// `fd` must reference an `O_PATH` handle (the only fd kind `rm`'s relax path
1202/// holds). The same inode-exactness argument applies: the open fd keeps the
1203/// pinned inode alive, so `/proc/self/fd/N` resolves to that inode regardless of
1204/// any concurrent rename/symlink swap of the original name — there is no path
1205/// re-resolution to redirect, so this is race-safe even on a directory whose own
1206/// mode is being restored. Works on a `0000`-mode directory we own (it authorizes
1207/// against ownership, not the path's mode).
1208///
1209/// This is deliberately not gated through the congestion controller or the
1210/// blocking pool: it runs from a synchronous `Drop` (which cannot `.await`) as a
1211/// one-shot best-effort cleanup — a single `fchmodat` whose cost is negligible.
1212/// Requires `/proc` mounted (same precondition as [`chmod_via_proc_fd`]).
1213pub(crate) fn chmod_via_proc_fd_sync(fd: BorrowedFd<'_>, mode: u32) -> std::io::Result<()> {
1214    let proc_path = format!("/proc/self/fd/{}", fd.as_raw_fd());
1215    // FollowSymlink: the /proc entry is a magic symlink that must be dereferenced
1216    // to reach the pinned inode (NoFollowSymlink would chmod the magic link
1217    // itself, a silent no-op).
1218    nix::sys::stat::fchmodat(
1219        AT_FDCWD,
1220        proc_path.as_str(),
1221        Mode::from_bits_truncate(mode),
1222        nix::sys::stat::FchmodatFlags::FollowSymlink,
1223    )
1224    .map_err(nix_to_io)
1225}
1226
1227/// Read full [`std::fs::Metadata`] for the exact inode an `O_PATH` [`Handle`] pins,
1228/// via the `/proc/self/fd/N` magic symlink.
1229///
1230/// The fd-pinned [`FileMeta`] snapshot ([`Handle::meta`]) covers uid/gid/mode and
1231/// the a/m/ctime timestamps, but NOT the birth time (`btime`) — `fstat` does not
1232/// return it. Callers that need `Metadata::created()` (the `--created-before`
1233/// time filter) get it here while staying inode-exact: the open `O_PATH` handle
1234/// keeps the inode alive, so resolving `/proc/self/fd/N` lands on that same inode
1235/// regardless of a concurrent rename/symlink swap of the original name. Gated as
1236/// `Stat`. Requires `/proc` mounted (same precondition as [`chmod_via_proc_fd`]).
1237pub(crate) async fn stat_meta_via_proc_fd(
1238    handle: &Handle,
1239    side: congestion::Side,
1240) -> std::io::Result<std::fs::Metadata> {
1241    let owned = handle.as_fd().try_clone_to_owned()?;
1242    run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
1243        let proc_path = format!("/proc/self/fd/{}", owned.as_raw_fd());
1244        std::fs::metadata(proc_path)
1245    })
1246    .await
1247}
1248
1249/// Read the target of a symlink [`Handle`] inode-exact, via `readlinkat(fd, "")` on the pinned
1250/// `O_PATH | O_NOFOLLOW` fd.
1251///
1252/// The empty-pathname form of `readlinkat` (Linux 2.6.39+) operates on the symlink the fd itself
1253/// refers to, so the target comes from the *same* pinned inode as [`Handle::meta`] — there is no
1254/// path re-resolution by name that a concurrent same-name swap could redirect. This is the symlink
1255/// analogue of reading a regular file's bytes and metadata from one [`Dir::open_file_read`] fd: it
1256/// lets a caller send/apply a symlink's target and metadata as a faithful pair. Fails if the handle
1257/// does not refer to a symlink (the empty-path form requires a symlink fd); callers only invoke it
1258/// on a `Symlink`-classified handle. Gated as `ReadLink`.
1259///
1260/// Raw `libc::readlinkat` is required: nix's wrapper rejects the empty pathname that selects the
1261/// fd's own link (the same reason `symlink_utimes_fd` uses raw `utimensat`).
1262pub async fn read_link_handle(
1263    handle: &Handle,
1264    side: congestion::Side,
1265) -> std::io::Result<std::path::PathBuf> {
1266    use std::os::unix::ffi::OsStringExt;
1267    let owned = handle.as_fd().try_clone_to_owned()?;
1268    run_metadata_probed_blocking(side, congestion::MetadataOp::ReadLink, move || {
1269        // a symlink target is bounded by PATH_MAX, so a single buffer of that size never truncates.
1270        let mut buf = vec![0u8; libc::PATH_MAX as usize];
1271        // SAFETY: `owned` is a valid open fd for the duration of this call; the empty C string
1272        // selects the fd's own symlink (it was opened O_PATH|O_NOFOLLOW); `buf` has `len()` bytes.
1273        let n = unsafe {
1274            libc::readlinkat(
1275                owned.as_raw_fd(),
1276                c"".as_ptr(),
1277                buf.as_mut_ptr().cast::<libc::c_char>(),
1278                buf.len(),
1279            )
1280        };
1281        if n < 0 {
1282            return Err(std::io::Error::last_os_error());
1283        }
1284        buf.truncate(n as usize);
1285        Ok(std::path::PathBuf::from(std::ffi::OsString::from_vec(buf)))
1286    })
1287    .await
1288}
1289
1290/// Set timestamps on a symlink `Handle`'s `O_PATH` fd, operating on the link
1291/// itself, via a raw `utimensat(fd, "", times, AT_EMPTY_PATH)`.
1292///
1293/// Raw libc is required here: nix's `utimensat` wrapper cannot pass
1294/// `AT_EMPTY_PATH`, and `futimens` on an `O_PATH` fd returns `EBADF`. The
1295/// `/proc/self/fd` form silently no-ops under `NOFOLLOW`, so it must not be used.
1296async fn symlink_utimes_fd(
1297    handle: &Handle,
1298    side: congestion::Side,
1299    atime: i64,
1300    atime_nsec: i64,
1301    mtime: i64,
1302    mtime_nsec: i64,
1303) -> std::io::Result<()> {
1304    let owned = handle.as_fd().try_clone_to_owned()?;
1305    run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
1306        let times: [libc::timespec; 2] = [
1307            libc::timespec {
1308                tv_sec: atime,
1309                tv_nsec: atime_nsec,
1310            },
1311            libc::timespec {
1312                tv_sec: mtime,
1313                tv_nsec: mtime_nsec,
1314            },
1315        ];
1316        // SAFETY: `owned` is a valid open fd for the duration of this call; the
1317        // pathname is the empty C string and `times` points to a 2-element array.
1318        let res = unsafe {
1319            libc::utimensat(
1320                owned.as_raw_fd(),
1321                c"".as_ptr(),
1322                times.as_ptr(),
1323                libc::AT_EMPTY_PATH,
1324            )
1325        };
1326        if res == 0 {
1327            Ok(())
1328        } else {
1329            Err(std::io::Error::last_os_error())
1330        }
1331    })
1332    .await
1333}
1334
1335/// Apply file metadata (owner, mode, timestamps) to an already-open writable
1336/// file descriptor, following the chown → chmod → utimens ordering.
1337///
1338/// `fd` must be the destination file's own fd (typically the write fd returned
1339/// by [`Dir::create_file`]); this avoids the redundant `File::open` re-open a
1340/// path-based applier would need, and closes the TOCTOU window in the process.
1341/// Gating on `settings.file`: chown only when uid or gid is requested, chmod
1342/// always (the masked
1343/// mode honors `mode_mask`), timestamps only when requested.
1344pub async fn set_file_metadata_fd<Meta: crate::preserve::Metadata>(
1345    settings: &crate::preserve::Settings,
1346    meta: &Meta,
1347    fd: BorrowedFd<'_>,
1348    side: congestion::Side,
1349) -> std::io::Result<()> {
1350    let ut = &settings.file.user_and_time;
1351    if ut.uid || ut.gid {
1352        let uid = if ut.uid { Some(meta.uid()) } else { None };
1353        let gid = if ut.gid { Some(meta.gid()) } else { None };
1354        fchown_fd(fd, side, uid, gid).await?;
1355    }
1356    let mode = crate::preserve::masked_mode(settings.file.mode_mask, meta);
1357    fchmod_fd(fd, side, mode).await?;
1358    if ut.time {
1359        futimens_fd(
1360            fd,
1361            side,
1362            meta.atime(),
1363            meta.atime_nsec(),
1364            meta.mtime(),
1365            meta.mtime_nsec(),
1366        )
1367        .await?;
1368    }
1369    Ok(())
1370}
1371
1372/// Apply directory metadata (owner, mode, timestamps) to an open [`Dir`] fd,
1373/// following the chown → chmod → utimens ordering. Gates on `settings.dir` and
1374/// uses the directory's own congestion side.
1375pub async fn set_dir_metadata_fd<Meta: crate::preserve::Metadata>(
1376    settings: &crate::preserve::Settings,
1377    meta: &Meta,
1378    dir: &Dir,
1379) -> std::io::Result<()> {
1380    let side = dir.side();
1381    let fd = dir.fd.as_fd();
1382    let ut = &settings.dir.user_and_time;
1383    if ut.uid || ut.gid {
1384        let uid = if ut.uid { Some(meta.uid()) } else { None };
1385        let gid = if ut.gid { Some(meta.gid()) } else { None };
1386        fchown_fd(fd, side, uid, gid).await?;
1387    }
1388    let mode = crate::preserve::masked_mode(settings.dir.mode_mask, meta);
1389    fchmod_fd(fd, side, mode).await?;
1390    if ut.time {
1391        futimens_fd(
1392            fd,
1393            side,
1394            meta.atime(),
1395            meta.atime_nsec(),
1396            meta.mtime(),
1397            meta.mtime_nsec(),
1398        )
1399        .await?;
1400    }
1401    Ok(())
1402}
1403
1404/// Apply symlink metadata (owner and timestamps only — never mode) to a symlink
1405/// [`Handle`], operating on the link itself via `AT_EMPTY_PATH`.
1406///
1407/// Symlinks have no meaningful permission bits, so there is no chmod step;
1408/// ordering is chown → utimens. Gates on `settings.symlink`.
1409pub async fn set_symlink_metadata_fd<Meta: crate::preserve::Metadata>(
1410    settings: &crate::preserve::Settings,
1411    meta: &Meta,
1412    handle: &Handle,
1413    side: congestion::Side,
1414) -> std::io::Result<()> {
1415    let ut = &settings.symlink.user_and_time;
1416    if ut.uid || ut.gid {
1417        let uid = if ut.uid { Some(meta.uid()) } else { None };
1418        let gid = if ut.gid { Some(meta.gid()) } else { None };
1419        // chown the link itself: fchown_handle already operates inode-exact on the O_PATH handle
1420        // via AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW.
1421        fchown_handle(handle, side, uid, gid).await?;
1422    }
1423    if ut.time {
1424        symlink_utimes_fd(
1425            handle,
1426            side,
1427            meta.atime(),
1428            meta.atime_nsec(),
1429            meta.mtime(),
1430            meta.mtime_nsec(),
1431        )
1432        .await?;
1433    }
1434    Ok(())
1435}
1436
1437// ── helpers ───────────────────────────────────────────────────────────────────
1438
1439/// Run a blocking metadata syscall closure on the blocking pool, gated by the
1440/// congestion controller for the given side and operation kind.
1441///
1442/// Wraps `spawn_blocking` inside [`crate::walk::run_metadata_probed`] so each
1443/// per-entry `openat`/`fstatat` is rate-gated, counted against the cwnd permit,
1444/// and feeds the latency probe — the same per-metadata-syscall gating shape used
1445/// throughout this crate.
1446async fn run_metadata_probed_blocking<F, T>(
1447    side: congestion::Side,
1448    op: congestion::MetadataOp,
1449    f: F,
1450) -> std::io::Result<T>
1451where
1452    F: FnOnce() -> std::io::Result<T> + Send + 'static,
1453    T: Send + 'static,
1454{
1455    crate::walk::run_metadata_probed(side, op, async {
1456        tokio::task::spawn_blocking(f)
1457            .await
1458            .map_err(std::io::Error::other)?
1459    })
1460    .await
1461}
1462
1463/// Convert a `nix::errno::Errno` to `std::io::Error`.
1464fn nix_to_io(e: nix::errno::Errno) -> std::io::Error {
1465    std::io::Error::from_raw_os_error(e as i32)
1466}
1467
1468/// Return `true` when `name` is a single non-empty path component (no `/`,
1469/// not `.` or `..`).
1470fn is_single_component(name: &OsStr) -> bool {
1471    if name.is_empty() || name == "." || name == ".." {
1472        return false;
1473    }
1474    !name.as_bytes().contains(&b'/')
1475}
1476
1477// ── tests ─────────────────────────────────────────────────────────────────────
1478
1479#[cfg(test)]
1480mod tests {
1481    use super::*;
1482    use crate::preserve::Metadata;
1483    use crate::testutils;
1484    use std::io::Read;
1485
1486    #[tokio::test]
1487    async fn child_classifies_file_dir_symlink_and_rejects_nofollow() -> anyhow::Result<()> {
1488        let tmp = testutils::setup_test_dir().await?;
1489        // setup_test_dir() returns the temp dir; the fixture lives at tmp/foo/
1490        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1491        assert_eq!(
1492            root.child(OsStr::new("0.txt")).await?.kind(),
1493            EntryKind::File
1494        );
1495        assert_eq!(root.child(OsStr::new("bar")).await?.kind(), EntryKind::Dir);
1496        tokio::fs::symlink("0.txt", tmp.join("foo/lnk")).await?;
1497        assert_eq!(
1498            root.child(OsStr::new("lnk")).await?.kind(),
1499            EntryKind::Symlink
1500        );
1501        // open_dir on a symlinked "dir" must fail closed (ELOOP/ENOTDIR), never follow
1502        tokio::fs::symlink("/etc", tmp.join("foo/evil")).await?;
1503        assert!(root.open_dir(OsStr::new("evil")).await.is_err());
1504        Ok(())
1505    }
1506
1507    #[tokio::test]
1508    async fn open_dir_succeeds_on_real_directory() -> anyhow::Result<()> {
1509        let tmp = testutils::setup_test_dir().await?;
1510        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1511        // bar is a real directory; open_dir must succeed and yield a usable Dir
1512        let bar = root.open_dir(OsStr::new("bar")).await?;
1513        // and the resulting Dir is functional: it can classify its own children
1514        assert_eq!(
1515            bar.child(OsStr::new("1.txt")).await?.kind(),
1516            EntryKind::File
1517        );
1518        Ok(())
1519    }
1520
1521    // FIX A (PR #247 review): `open_parent_dir` resolves a TRUSTED command-line parent prefix
1522    // following symlinks (the final component IS followed), while `open_root_dir` keeps the operand
1523    // entry `O_NOFOLLOW` and descendants stay hardened. This pins the parent-prefix-vs-operand
1524    // distinction and proves the hardening below the followed prefix is unchanged.
1525    #[tokio::test]
1526    async fn open_parent_dir_follows_symlinked_prefix_but_descendants_stay_hardened()
1527    -> anyhow::Result<()> {
1528        let tmp = testutils::setup_test_dir().await?;
1529        // a symlink-to-dir standing in for a trusted parent prefix component.
1530        tokio::fs::symlink("foo", tmp.join("foo_link")).await?;
1531        // open_parent_dir FOLLOWS the symlinked final component into the real `foo` directory,
1532        // yielding a TrustedDir; into_tree() crosses into the hardened tree below it.
1533        let parent = Dir::open_parent_dir(&tmp.join("foo_link"), congestion::Side::Source).await?;
1534        let tree = parent.into_tree();
1535        // the followed dir is functional: it sees `foo`'s real children.
1536        assert_eq!(
1537            tree.child(OsStr::new("0.txt")).await?.kind(),
1538            EntryKind::File
1539        );
1540        // open_root_dir on the SAME symlinked path (dereference=false) must instead fail closed —
1541        // it `O_NOFOLLOW`s the final component (the operand-entry contract), proving the two entry
1542        // points differ exactly at the final-component follow decision.
1543        assert!(
1544            Dir::open_root_dir(&tmp.join("foo_link"), false, congestion::Side::Source)
1545                .await
1546                .is_err()
1547        );
1548        // hardening below the followed prefix is UNCHANGED: a symlinked child reached via the
1549        // followed parent still fails closed (O_NOFOLLOW) rather than being followed.
1550        tokio::fs::symlink("/etc", tmp.join("foo/evil_below")).await?;
1551        assert!(tree.open_dir(OsStr::new("evil_below")).await.is_err());
1552        Ok(())
1553    }
1554
1555    #[tokio::test]
1556    async fn rejects_multi_component_names() -> anyhow::Result<()> {
1557        let tmp = testutils::setup_test_dir().await?;
1558        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1559        // names with a path separator could traverse an intermediate symlink, so
1560        // they are rejected with EINVAL before any syscall (release-safe check)
1561        for bad in ["bar/1.txt", "..", ".", ""] {
1562            let child_err = root.child(OsStr::new(bad)).await.unwrap_err();
1563            assert_eq!(child_err.raw_os_error(), Some(libc::EINVAL));
1564            let dir_err = root.open_dir(OsStr::new(bad)).await.unwrap_err();
1565            assert_eq!(dir_err.raw_os_error(), Some(libc::EINVAL));
1566            let file_err = root.open_file_read(OsStr::new(bad)).await.unwrap_err();
1567            assert_eq!(file_err.raw_os_error(), Some(libc::EINVAL));
1568            let create_err = root.create_file(OsStr::new(bad), 0o644).await.unwrap_err();
1569            assert_eq!(create_err.raw_os_error(), Some(libc::EINVAL));
1570        }
1571        Ok(())
1572    }
1573
1574    // Regression for the spawn_blocking cancellation soundness bug: the Dir's fd
1575    // lives behind an Arc that each operation clones into its closure, so an op
1576    // stays sound even after the originating Dir is dropped. We model the
1577    // detached-closure case by cloning a Dir, dropping the original, and
1578    // confirming the clone still opens children correctly.
1579    #[tokio::test]
1580    async fn operations_remain_valid_after_original_dir_dropped() -> anyhow::Result<()> {
1581        let tmp = testutils::setup_test_dir().await?;
1582        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1583        // clone the underlying Arc-held fd into a second Dir, then drop the original
1584        let shared = Dir {
1585            fd: root.fd.clone(),
1586            side: root.side,
1587        };
1588        drop(root);
1589        // the shared handle's open file description is still alive; ops succeed
1590        assert_eq!(
1591            shared.child(OsStr::new("0.txt")).await?.kind(),
1592            EntryKind::File
1593        );
1594        let bar = shared.open_dir(OsStr::new("bar")).await?;
1595        assert_eq!(
1596            bar.child(OsStr::new("2.txt")).await?.kind(),
1597            EntryKind::File
1598        );
1599        Ok(())
1600    }
1601
1602    // open_file_read: verify that a regular file can be opened, metadata size is
1603    // correct, and the returned File is readable.
1604    #[tokio::test]
1605    async fn open_file_read_reads_regular_file() -> anyhow::Result<()> {
1606        let tmp = testutils::setup_test_dir().await?;
1607        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1608        let (mut file, meta) = root.open_file_read(OsStr::new("0.txt")).await?;
1609        // "0.txt" contains the single byte "0"
1610        assert_eq!(meta.size(), 1);
1611        let mut buf = String::new();
1612        file.read_to_string(&mut buf)?;
1613        assert_eq!(buf, "0");
1614        Ok(())
1615    }
1616
1617    // open_file_read: a FIFO must not cause open to block (O_NONBLOCK) AND the
1618    // S_ISREG check must reject it, so the call returns Err without hanging.
1619    #[tokio::test]
1620    async fn open_file_read_rejects_fifo_without_blocking() -> anyhow::Result<()> {
1621        let tmp = testutils::setup_test_dir().await?;
1622        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1623        let fifo_path = tmp.join("foo/test.fifo");
1624        nix::unistd::mkfifo(
1625            &fifo_path,
1626            nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
1627        )?;
1628        // the call must return (not block) within the timeout, and must be an Err
1629        let result = tokio::time::timeout(
1630            std::time::Duration::from_secs(5),
1631            root.open_file_read(OsStr::new("test.fifo")),
1632        )
1633        .await;
1634        assert!(result.is_ok(), "open_file_read blocked on FIFO (timed out)");
1635        assert!(
1636            result.unwrap().is_err(),
1637            "open_file_read must reject a FIFO"
1638        );
1639        Ok(())
1640    }
1641
1642    // open_file_read: a symlink must be rejected (ELOOP from O_NOFOLLOW).
1643    #[tokio::test]
1644    async fn open_file_read_rejects_symlink() -> anyhow::Result<()> {
1645        let tmp = testutils::setup_test_dir().await?;
1646        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1647        // create a symlink pointing to a real file
1648        tokio::fs::symlink("0.txt", tmp.join("foo/link_to_0")).await?;
1649        let result = root.open_file_read(OsStr::new("link_to_0")).await;
1650        assert!(result.is_err(), "open_file_read must reject a symlink");
1651        Ok(())
1652    }
1653
1654    // create_file: successfully creates a new writable file.
1655    #[tokio::test]
1656    async fn create_file_creates_new_writable_file() -> anyhow::Result<()> {
1657        let tmp = testutils::setup_test_dir().await?;
1658        // use a dest-side dir for the write target
1659        let root =
1660            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
1661        let mut file = root.create_file(OsStr::new("new.txt"), 0o644).await?;
1662        use std::io::Write;
1663        file.write_all(b"hello safedir")?;
1664        drop(file);
1665        // re-open via std and verify the content
1666        let content = std::fs::read(tmp.join("foo/new.txt"))?;
1667        assert_eq!(content, b"hello safedir");
1668        Ok(())
1669    }
1670
1671    // create_file: fails with EEXIST when the file already exists.
1672    #[tokio::test]
1673    async fn create_file_fails_if_exists() -> anyhow::Result<()> {
1674        let tmp = testutils::setup_test_dir().await?;
1675        let root =
1676            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
1677        // "0.txt" already exists in the fixture
1678        let err = root
1679            .create_file(OsStr::new("0.txt"), 0o644)
1680            .await
1681            .unwrap_err();
1682        assert_eq!(
1683            err.raw_os_error(),
1684            Some(libc::EEXIST),
1685            "expected EEXIST, got {err:#}"
1686        );
1687        Ok(())
1688    }
1689
1690    // make_dir: creates the directory and returns a usable Dir handle.
1691    #[tokio::test]
1692    async fn make_dir_creates_and_returns_usable_dir() -> anyhow::Result<()> {
1693        let tmp = testutils::setup_test_dir().await?;
1694        let root =
1695            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
1696        let sub = root.make_dir(OsStr::new("sub"), 0o755).await?;
1697        // the returned Dir must be usable: create a file inside it
1698        sub.create_file(OsStr::new("child.txt"), 0o644).await?;
1699        // and read_entries on the sub dir must show that file
1700        let entries = sub.read_entries().await?;
1701        let names: Vec<_> = entries
1702            .iter()
1703            .map(|(n, _)| n.to_string_lossy().into_owned())
1704            .collect();
1705        assert!(
1706            names.contains(&"child.txt".to_string()),
1707            "child.txt not found in {names:?}"
1708        );
1709        Ok(())
1710    }
1711
1712    // make_dir: multi-component names must be rejected with EINVAL.
1713    #[tokio::test]
1714    async fn make_dir_rejects_multi_component_names() -> anyhow::Result<()> {
1715        let tmp = testutils::setup_test_dir().await?;
1716        let root =
1717            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
1718        for bad in ["a/b", "..", ".", ""] {
1719            let err = root.make_dir(OsStr::new(bad), 0o755).await.unwrap_err();
1720            assert_eq!(
1721                err.raw_os_error(),
1722                Some(libc::EINVAL),
1723                "expected EINVAL for {:?}, got {err:#}",
1724                bad
1725            );
1726        }
1727        Ok(())
1728    }
1729
1730    // read_entries: returns all entries with correct d_type hints.
1731    #[tokio::test]
1732    async fn read_entries_lists_children_with_dtype_hints() -> anyhow::Result<()> {
1733        use std::collections::HashMap;
1734        let tmp = testutils::setup_test_dir().await?;
1735        // baz contains: 4.txt (file), 5.txt (symlink), 6.txt (symlink)
1736        // use bar which has only files; instead build a custom fixture in foo
1737        let fixture = tmp.join("foo/fixture_dir");
1738        tokio::fs::create_dir(&fixture).await?;
1739        tokio::fs::write(fixture.join("afile.txt"), "x").await?;
1740        tokio::fs::create_dir(fixture.join("asubdir")).await?;
1741        tokio::fs::symlink("afile.txt", fixture.join("alink")).await?;
1742
1743        let root = Dir::open_root_dir(&fixture, false, congestion::Side::Source).await?;
1744        let entries = root.read_entries().await?;
1745        let map: HashMap<String, Option<EntryKind>> = entries
1746            .into_iter()
1747            .map(|(n, k)| (n.to_string_lossy().into_owned(), k))
1748            .collect();
1749
1750        assert_eq!(map.len(), 3, "expected 3 entries, got {map:?}");
1751        assert_eq!(
1752            map.get("afile.txt"),
1753            Some(&Some(EntryKind::File)),
1754            "afile.txt wrong"
1755        );
1756        assert_eq!(
1757            map.get("asubdir"),
1758            Some(&Some(EntryKind::Dir)),
1759            "asubdir wrong"
1760        );
1761        assert_eq!(
1762            map.get("alink"),
1763            Some(&Some(EntryKind::Symlink)),
1764            "alink wrong"
1765        );
1766        Ok(())
1767    }
1768
1769    // read_entries: calling it twice on the same Dir must succeed (fd not consumed).
1770    #[tokio::test]
1771    async fn read_entries_does_not_close_self_fd() -> anyhow::Result<()> {
1772        let tmp = testutils::setup_test_dir().await?;
1773        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1774        // first call
1775        let first = root.read_entries().await?;
1776        assert!(!first.is_empty(), "first read_entries returned empty");
1777        // second call on the SAME Dir must yield the identical entry set, not
1778        // just an equal count. read_entries dups a fd that shares the directory
1779        // read offset, so absent nix's rewinddir-on-completion this second call
1780        // would see an empty (or partial) listing. The hardened remote source
1781        // depends on exactly this re-entrancy (Pass 1 then Pass 2 enumerate the
1782        // same Arc<Dir>).
1783        let second = root.read_entries().await?;
1784        let mut first_names: Vec<_> = first.iter().map(|(name, _)| name.clone()).collect();
1785        let mut second_names: Vec<_> = second.iter().map(|(name, _)| name.clone()).collect();
1786        first_names.sort();
1787        second_names.sort();
1788        assert_eq!(
1789            first_names, second_names,
1790            "second read_entries differs from first"
1791        );
1792        // also prove child() still works on the same Dir
1793        root.child(OsStr::new("0.txt")).await?;
1794        Ok(())
1795    }
1796
1797    // create_file: refuses to follow or clobber an existing symlink.
1798    #[tokio::test]
1799    async fn create_file_refuses_existing_symlink() -> anyhow::Result<()> {
1800        let tmp = testutils::setup_test_dir().await?;
1801        let root =
1802            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
1803        // plant a symlink pointing at a non-existent target
1804        let link_path = tmp.join("foo/evil_link");
1805        let target_path = tmp.join("foo/should_not_be_created");
1806        tokio::fs::symlink(&target_path, &link_path).await?;
1807        // create_file must fail, not follow the symlink and create the target
1808        let err = root
1809            .create_file(OsStr::new("evil_link"), 0o644)
1810            .await
1811            .unwrap_err();
1812        // O_CREAT|O_EXCL returns EEXIST on an existing symlink without following it
1813        assert_eq!(
1814            err.raw_os_error(),
1815            Some(libc::EEXIST),
1816            "expected EEXIST, got {err:#}"
1817        );
1818        // the symlink target must NOT have been created
1819        assert!(
1820            !target_path.exists(),
1821            "symlink target was unexpectedly created"
1822        );
1823        Ok(())
1824    }
1825
1826    // unlink_at: removes a regular file and confirms it is gone.
1827    #[tokio::test]
1828    async fn unlink_at_removes_file() -> anyhow::Result<()> {
1829        let tmp = testutils::setup_test_dir().await?;
1830        let root =
1831            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
1832        // "0.txt" exists in the fixture
1833        root.unlink_at(OsStr::new("0.txt")).await?;
1834        // afterwards child() must fail with ENOENT
1835        let err = root.child(OsStr::new("0.txt")).await.unwrap_err();
1836        assert_eq!(
1837            err.raw_os_error(),
1838            Some(libc::ENOENT),
1839            "expected ENOENT after unlink, got {err:#}"
1840        );
1841        Ok(())
1842    }
1843
1844    // unlink_at: removes the symlink itself, not its target.
1845    #[tokio::test]
1846    async fn unlink_at_on_symlink_removes_link_not_target() -> anyhow::Result<()> {
1847        let tmp = testutils::setup_test_dir().await?;
1848        let root =
1849            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
1850        // create a sentinel file with content, then symlink to it
1851        tokio::fs::write(tmp.join("foo/sentinel.txt"), b"alive").await?;
1852        tokio::fs::symlink("sentinel.txt", tmp.join("foo/lnk")).await?;
1853        // unlink the link
1854        root.unlink_at(OsStr::new("lnk")).await?;
1855        // link is gone
1856        let err = root.child(OsStr::new("lnk")).await.unwrap_err();
1857        assert_eq!(
1858            err.raw_os_error(),
1859            Some(libc::ENOENT),
1860            "expected ENOENT for removed link, got {err:#}"
1861        );
1862        // sentinel target still exists with content
1863        let content = tokio::fs::read(tmp.join("foo/sentinel.txt")).await?;
1864        assert_eq!(content, b"alive", "sentinel.txt was unexpectedly removed");
1865        Ok(())
1866    }
1867
1868    // rmdir_at: removes an empty directory; rejects non-empty (ENOTEMPTY) and a
1869    // regular file (ENOTDIR).
1870    #[tokio::test]
1871    async fn rmdir_at_removes_empty_dir_and_rejects_nonempty() -> anyhow::Result<()> {
1872        let tmp = testutils::setup_test_dir().await?;
1873        let root =
1874            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
1875        // create an empty subdirectory and remove it
1876        tokio::fs::create_dir(tmp.join("foo/empty_sub")).await?;
1877        root.rmdir_at(OsStr::new("empty_sub")).await?;
1878        let err = root.child(OsStr::new("empty_sub")).await.unwrap_err();
1879        assert_eq!(
1880            err.raw_os_error(),
1881            Some(libc::ENOENT),
1882            "expected ENOENT after rmdir, got {err:#}"
1883        );
1884        // "bar" is non-empty in the fixture → ENOTEMPTY
1885        let err = root.rmdir_at(OsStr::new("bar")).await.unwrap_err();
1886        assert_eq!(
1887            err.raw_os_error(),
1888            Some(libc::ENOTEMPTY),
1889            "expected ENOTEMPTY for non-empty dir, got {err:#}"
1890        );
1891        // "0.txt" is a regular file → ENOTDIR
1892        let err = root.rmdir_at(OsStr::new("0.txt")).await.unwrap_err();
1893        assert_eq!(
1894            err.raw_os_error(),
1895            Some(libc::ENOTDIR),
1896            "expected ENOTDIR for regular file, got {err:#}"
1897        );
1898        Ok(())
1899    }
1900
1901    // symlink_at: creates a symlink and returns a Handle with kind Symlink;
1902    // read_link_at then returns the original target path.
1903    #[tokio::test]
1904    async fn symlink_at_creates_link_and_returns_pinned_handle() -> anyhow::Result<()> {
1905        let tmp = testutils::setup_test_dir().await?;
1906        let root =
1907            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
1908        let target = std::path::Path::new("some/arbitrary/target");
1909        let handle = root.symlink_at(OsStr::new("mylink"), target).await?;
1910        assert_eq!(
1911            handle.kind(),
1912            EntryKind::Symlink,
1913            "symlink_at must return a Symlink handle"
1914        );
1915        // read_link_at must return the same target
1916        let read_back = root.read_link_at(OsStr::new("mylink")).await?;
1917        assert_eq!(
1918            read_back, target,
1919            "read_link_at returned wrong target: {read_back:?}"
1920        );
1921        Ok(())
1922    }
1923
1924    // read_link_handle reads the target inode-exact from the pinned O_PATH symlink handle (the
1925    // empty-path readlinkat form), so the target pairs with `handle.meta()` from the SAME fd. A
1926    // non-symlink handle is rejected (EINVAL).
1927    #[tokio::test]
1928    async fn read_link_handle_reads_target_from_pinned_handle() -> anyhow::Result<()> {
1929        let tmp = testutils::setup_test_dir().await?;
1930        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1931        let target = std::path::Path::new("some/arbitrary/target");
1932        tokio::fs::symlink(target, tmp.join("foo/mylink")).await?;
1933        // classify the link, then read its target through that same pinned handle.
1934        let handle = root.child(OsStr::new("mylink")).await?;
1935        assert_eq!(handle.kind(), EntryKind::Symlink);
1936        let read_back = read_link_handle(&handle, congestion::Side::Source).await?;
1937        assert_eq!(read_back, target, "wrong target: {read_back:?}");
1938        // a non-symlink handle (a regular file) is rejected (the empty-path readlinkat form requires
1939        // a symlink fd; the kernel returns an error rather than a target). Callers only ever invoke
1940        // this on a Symlink-classified handle, so this is the defensive path.
1941        let file_handle = root.child(OsStr::new("0.txt")).await?;
1942        assert!(
1943            read_link_handle(&file_handle, congestion::Side::Source)
1944                .await
1945                .is_err(),
1946            "read_link_handle on a non-symlink must fail"
1947        );
1948        Ok(())
1949    }
1950
1951    // Handle::read_symlink returns target AND metadata from the one pinned O_PATH fd, so they are a
1952    // faithful pair (the symlink analogue of open_file_read).
1953    #[tokio::test]
1954    async fn read_symlink_returns_target_and_meta_from_one_handle() -> anyhow::Result<()> {
1955        use crate::preserve::Metadata as _;
1956        let tmp = testutils::setup_test_dir().await?;
1957        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
1958        let target = std::path::Path::new("some/target");
1959        tokio::fs::symlink(target, tmp.join("foo/lnk")).await?;
1960        let handle = root.child(OsStr::new("lnk")).await?;
1961        let (read_target, meta) = handle.read_symlink(congestion::Side::Source).await?;
1962        assert_eq!(read_target, target);
1963        // metadata is the symlink's own, from the same handle.
1964        assert_eq!(meta.uid(), handle.meta().uid());
1965        assert_eq!(meta.mtime(), handle.meta().mtime());
1966        Ok(())
1967    }
1968
1969    // Dir::meta fstats the directory's own held fd (the fd whose contents we enumerate).
1970    #[tokio::test]
1971    async fn dir_meta_returns_opened_dir_fstat() -> anyhow::Result<()> {
1972        use crate::preserve::Metadata as _;
1973        let tmp = testutils::setup_test_dir().await?;
1974        let bar = Dir::open_root_dir(&tmp.join("foo/bar"), false, congestion::Side::Source).await?;
1975        let meta = bar.meta().await?;
1976        let std_meta = std::fs::metadata(tmp.join("foo/bar"))?;
1977        // `meta.uid()` resolves via preserve::Metadata (the only trait FileMeta implements);
1978        // fully-qualify the std::fs::Metadata side, which implements both that trait and MetadataExt.
1979        assert_eq!(meta.uid(), std::os::unix::fs::MetadataExt::uid(&std_meta));
1980        assert_eq!(meta.gid(), std::os::unix::fs::MetadataExt::gid(&std_meta));
1981        Ok(())
1982    }
1983
1984    // hard_link_at: creates a hard link sharing the same inode.
1985    #[tokio::test]
1986    async fn hard_link_at_creates_hardlink() -> anyhow::Result<()> {
1987        let tmp = testutils::setup_test_dir().await?;
1988        // use two subdirs as src and dst Dir handles
1989        tokio::fs::create_dir(tmp.join("foo/src_sub")).await?;
1990        tokio::fs::create_dir(tmp.join("foo/dst_sub")).await?;
1991        tokio::fs::write(tmp.join("foo/src_sub/orig.txt"), b"hardlink test").await?;
1992
1993        let src =
1994            Dir::open_root_dir(&tmp.join("foo/src_sub"), false, congestion::Side::Source).await?;
1995        let dst = Dir::open_root_dir(
1996            &tmp.join("foo/dst_sub"),
1997            false,
1998            congestion::Side::Destination,
1999        )
2000        .await?;
2001
2002        src.hard_link_at(OsStr::new("orig.txt"), &dst, OsStr::new("link.txt"))
2003            .await?;
2004
2005        // both handles must exist and share the same inode
2006        let orig_handle = src.child(OsStr::new("orig.txt")).await?;
2007        let link_handle = dst.child(OsStr::new("link.txt")).await?;
2008        assert_eq!(orig_handle.kind(), EntryKind::File, "orig must be a file");
2009        assert_eq!(link_handle.kind(), EntryKind::File, "link must be a file");
2010        assert_eq!(
2011            orig_handle.ino(),
2012            link_handle.ino(),
2013            "hard link must share the inode"
2014        );
2015        Ok(())
2016    }
2017
2018    // hard_link_at: when the source name is a symlink, linkat with flags=0 does
2019    // NOT follow it — it links the symlink inode itself, so the new entry is also
2020    // a symlink.
2021    #[tokio::test]
2022    async fn hard_link_at_does_not_follow_source_symlink() -> anyhow::Result<()> {
2023        let tmp = testutils::setup_test_dir().await?;
2024        tokio::fs::create_dir(tmp.join("foo/src_hl")).await?;
2025        tokio::fs::create_dir(tmp.join("foo/dst_hl")).await?;
2026        // create a real file and a symlink to it in src_hl
2027        tokio::fs::write(tmp.join("foo/src_hl/real.txt"), b"target").await?;
2028        tokio::fs::symlink("real.txt", tmp.join("foo/src_hl/sym.txt")).await?;
2029
2030        let src =
2031            Dir::open_root_dir(&tmp.join("foo/src_hl"), false, congestion::Side::Source).await?;
2032        let dst = Dir::open_root_dir(
2033            &tmp.join("foo/dst_hl"),
2034            false,
2035            congestion::Side::Destination,
2036        )
2037        .await?;
2038
2039        // Linux does not allow hard-linking a symlink without AT_EMPTY_PATH or
2040        // special capabilities; linkat with flags=0 on a symlink yields EPERM.
2041        // Verify that the call does NOT silently follow the symlink into real.txt.
2042        let result = src
2043            .hard_link_at(OsStr::new("sym.txt"), &dst, OsStr::new("new_link.txt"))
2044            .await;
2045        match result {
2046            Ok(()) => {
2047                // If it succeeded (some kernels/configs allow it), the new entry
2048                // must be a symlink — NOT a hard link to the underlying file.
2049                let new_handle = dst.child(OsStr::new("new_link.txt")).await?;
2050                assert_eq!(
2051                    new_handle.kind(),
2052                    EntryKind::Symlink,
2053                    "hard_link_at must link the symlink itself, not its target"
2054                );
2055                // and real.txt must still have link-count 1 (no new hard link)
2056                let real_meta = std::fs::metadata(tmp.join("foo/src_hl/real.txt"))?;
2057                use std::os::unix::fs::MetadataExt;
2058                assert_eq!(
2059                    real_meta.nlink(),
2060                    1,
2061                    "real.txt must not gain a new hard link"
2062                );
2063            }
2064            Err(ref e) if e.raw_os_error() == Some(libc::EPERM) => {
2065                // expected on most Linux configurations; the important thing is
2066                // that it did NOT follow the symlink and link real.txt.
2067                // real.txt must still have exactly 1 hard link.
2068                let real_meta = std::fs::metadata(tmp.join("foo/src_hl/real.txt"))?;
2069                use std::os::unix::fs::MetadataExt;
2070                assert_eq!(
2071                    real_meta.nlink(),
2072                    1,
2073                    "real.txt must not gain a new hard link"
2074                );
2075            }
2076            Err(e) => {
2077                return Err(anyhow::anyhow!(
2078                    "unexpected error from hard_link_at on symlink: {e:#}"
2079                ));
2080            }
2081        }
2082        Ok(())
2083    }
2084
2085    // hard_link_handle_at (FIX 2, PR #247 review): links the EXACT inode the
2086    // classified Handle pins, immune to a concurrent swap of the source name. This is
2087    // deterministic — the swap happens (in fixed order) AFTER classification but
2088    // BEFORE the link — so it directly demonstrates the TOCTOU fix.
2089    //
2090    // The decoy is a DIFFERENT regular file with different content placed at the same
2091    // name. The old by-name `hard_link_at(name, ..)` re-resolves `name` and would link
2092    // the decoy inode (this test would fail against it). `hard_link_handle_at` links
2093    // the pinned original inode regardless.
2094    #[tokio::test]
2095    async fn hard_link_handle_at_links_pinned_inode_after_name_swap() -> anyhow::Result<()> {
2096        use std::os::unix::fs::MetadataExt;
2097        let tmp = testutils::create_temp_dir().await?;
2098        tokio::fs::create_dir(tmp.join("src")).await?;
2099        tokio::fs::create_dir(tmp.join("dst")).await?;
2100        tokio::fs::write(tmp.join("src/entry"), b"ORIGINAL").await?;
2101        let orig_ino = tokio::fs::metadata(tmp.join("src/entry")).await?.ino();
2102
2103        let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
2104        let dst =
2105            Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
2106        // classify `entry` — pins the ORIGINAL regular-file inode via O_PATH.
2107        let handle = src.child(OsStr::new("entry")).await?;
2108        assert_eq!(handle.kind(), EntryKind::File);
2109
2110        // SWAP `entry` to a DIFFERENT regular file (the decoy) before linking. We keep
2111        // the original inode alive only through `handle` (its directory entry is gone),
2112        // mimicking an attacker renaming a decoy over the source name.
2113        tokio::fs::write(tmp.join("src/decoy"), b"DECOY_SECRET").await?;
2114        tokio::fs::rename(tmp.join("src/decoy"), tmp.join("src/entry")).await?;
2115        let decoy_ino = tokio::fs::metadata(tmp.join("src/entry")).await?.ino();
2116        assert_ne!(orig_ino, decoy_ino, "decoy must be a different inode");
2117
2118        // inode-exact link: either links the ORIGINAL pinned inode, or fails closed.
2119        match dst.hard_link_handle_at(&handle, OsStr::new("linked")).await {
2120            Ok(()) => {
2121                let lm = tokio::fs::symlink_metadata(tmp.join("dst/linked")).await?;
2122                assert!(
2123                    lm.file_type().is_file(),
2124                    "linked entry must be a regular file"
2125                );
2126                assert_eq!(
2127                    lm.ino(),
2128                    orig_ino,
2129                    "hard_link_handle_at must link the PINNED original inode, never the \
2130                     swapped-in decoy (the by-name link would have linked the decoy here)"
2131                );
2132                let content = tokio::fs::read_to_string(tmp.join("dst/linked")).await?;
2133                assert_eq!(
2134                    content, "ORIGINAL",
2135                    "must reflect the original inode's content"
2136                );
2137                assert_ne!(content, "DECOY_SECRET");
2138            }
2139            Err(e) => {
2140                // fail-closed is acceptable (e.g. the pinned inode's last link was
2141                // already gone). It must NEVER have linked the decoy.
2142                assert!(
2143                    !tmp.join("dst/linked").exists(),
2144                    "no destination entry may exist when the link failed closed (got {e:#})"
2145                );
2146            }
2147        }
2148        Ok(())
2149    }
2150
2151    // hard_link_handle_at must refuse to hard-link a DIRECTORY (linkat returns EPERM),
2152    // matching the by-name path — a hard link to a directory is never created.
2153    #[tokio::test]
2154    async fn hard_link_handle_at_refuses_directory() -> anyhow::Result<()> {
2155        let tmp = testutils::create_temp_dir().await?;
2156        tokio::fs::create_dir(tmp.join("src")).await?;
2157        tokio::fs::create_dir(tmp.join("dst")).await?;
2158        tokio::fs::create_dir(tmp.join("src/adir")).await?;
2159        let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
2160        let dst =
2161            Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
2162        let dir_handle = src.child(OsStr::new("adir")).await?;
2163        assert_eq!(dir_handle.kind(), EntryKind::Dir);
2164        let result = dst
2165            .hard_link_handle_at(&dir_handle, OsStr::new("linked_dir"))
2166            .await;
2167        assert!(
2168            result.is_err(),
2169            "hard_link_handle_at must refuse to hard-link a directory"
2170        );
2171        assert!(
2172            !tmp.join("dst/linked_dir").exists(),
2173            "no destination entry may be created for a directory hard link"
2174        );
2175        Ok(())
2176    }
2177
2178    // hard_link_handle_at (FIX 2, PR #247 review): classify a regular File, then swap the
2179    // source name to a FIFO (a special, a different kind AND inode) before linking. The
2180    // old by-name `linkat(flags=0)` re-resolves the name and would hard-link the FIFO —
2181    // surfacing a special at the destination that rlink would report as a hard-linked
2182    // file (specials CAN be hard-linked, unlike directories). The inode-exact link must
2183    // instead link the pinned regular file or fail closed: the destination must NEVER be
2184    // a special. Deterministic (swap happens between classify and link).
2185    #[tokio::test]
2186    async fn hard_link_handle_at_never_links_swapped_in_fifo() -> anyhow::Result<()> {
2187        use std::os::unix::fs::FileTypeExt;
2188        let tmp = testutils::create_temp_dir().await?;
2189        tokio::fs::create_dir(tmp.join("src")).await?;
2190        tokio::fs::create_dir(tmp.join("dst")).await?;
2191        tokio::fs::write(tmp.join("src/entry"), b"REALFILE").await?;
2192        let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
2193        let dst =
2194            Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
2195        // classify `entry` — pins the regular-file inode.
2196        let handle = src.child(OsStr::new("entry")).await?;
2197        assert_eq!(handle.kind(), EntryKind::File);
2198        // swap `entry` to a FIFO (keep the regular inode alive only via the handle).
2199        tokio::fs::remove_file(tmp.join("src/entry")).await?;
2200        nix::unistd::mkfifo(
2201            &tmp.join("src/entry"),
2202            nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
2203        )?;
2204        match dst.hard_link_handle_at(&handle, OsStr::new("linked")).await {
2205            Ok(()) => {
2206                let lm = tokio::fs::symlink_metadata(tmp.join("dst/linked")).await?;
2207                assert!(
2208                    lm.file_type().is_file(),
2209                    "linked entry must be the pinned regular file, never the swapped-in FIFO"
2210                );
2211                assert!(
2212                    !lm.file_type().is_fifo(),
2213                    "the destination must never be a special (the by-name link would link the FIFO)"
2214                );
2215                let content = tokio::fs::read_to_string(tmp.join("dst/linked")).await?;
2216                assert_eq!(content, "REALFILE");
2217            }
2218            Err(_) => {
2219                // fail-closed is acceptable; nothing may be left at the destination.
2220                assert!(
2221                    !tmp.join("dst/linked").exists(),
2222                    "no destination entry may exist when the link failed closed"
2223                );
2224            }
2225        }
2226        Ok(())
2227    }
2228
2229    // hard_link_handle_at on a STABLE regular file links exactly like the by-name path
2230    // did (same inode, same content) — the happy path is unchanged.
2231    #[tokio::test]
2232    async fn hard_link_handle_at_stable_file_happy_path() -> anyhow::Result<()> {
2233        let tmp = testutils::create_temp_dir().await?;
2234        tokio::fs::create_dir(tmp.join("src")).await?;
2235        tokio::fs::create_dir(tmp.join("dst")).await?;
2236        tokio::fs::write(tmp.join("src/f"), b"STABLE").await?;
2237        let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
2238        let dst =
2239            Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
2240        let handle = src.child(OsStr::new("f")).await?;
2241        dst.hard_link_handle_at(&handle, OsStr::new("f_link"))
2242            .await?;
2243        let orig = src.child(OsStr::new("f")).await?;
2244        let linked = dst.child(OsStr::new("f_link")).await?;
2245        assert_eq!(linked.kind(), EntryKind::File);
2246        assert_eq!(orig.ino(), linked.ino(), "hard link must share the inode");
2247        let content = tokio::fs::read_to_string(tmp.join("dst/f_link")).await?;
2248        assert_eq!(content, "STABLE");
2249        Ok(())
2250    }
2251
2252    // ── fd-based metadata application ───────────────────────────────────────
2253
2254    // set_file_metadata_fd: applying owner/mode/time from a source FileMeta to an
2255    // already-open destination fd must reflect on the destination file: masked
2256    // mode, mtime, and (where testable) uid/gid all match the source.
2257    #[tokio::test]
2258    async fn set_file_metadata_fd_applies_owner_mode_time() -> anyhow::Result<()> {
2259        use std::io::Write;
2260        use std::os::unix::fs::PermissionsExt;
2261        let tmp = testutils::setup_test_dir().await?;
2262        // source file with a distinctive mode and a known, old mtime
2263        let src_path = tmp.join("foo/src_meta.txt");
2264        tokio::fs::write(&src_path, b"source").await?;
2265        std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o741))?;
2266        let src_mtime = filetime::FileTime::from_unix_time(1_000_000_000, 123_456_789);
2267        filetime::set_file_mtime(&src_path, src_mtime)?;
2268        filetime::set_file_atime(
2269            &src_path,
2270            filetime::FileTime::from_unix_time(1_000_000_500, 0),
2271        )?;
2272
2273        let root =
2274            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
2275        // snapshot the source metadata via a Handle (the realistic source flow)
2276        let src_handle = root.child(OsStr::new("src_meta.txt")).await?;
2277        let src_meta = src_handle.meta().clone();
2278
2279        // create the destination file and write some content into it
2280        let mut dst_file = root.create_file(OsStr::new("dst_meta.txt"), 0o600).await?;
2281        dst_file.write_all(b"destination")?;
2282        dst_file.flush()?;
2283
2284        // apply source metadata to the already-open dst fd; preserve everything
2285        let settings = crate::preserve::preserve_all();
2286        set_file_metadata_fd(
2287            &settings,
2288            &src_meta,
2289            dst_file.as_fd(),
2290            congestion::Side::Destination,
2291        )
2292        .await?;
2293        drop(dst_file);
2294
2295        // re-stat the destination and assert mode (masked to 0o7777), mtime
2296        let dst_md = std::fs::metadata(tmp.join("foo/dst_meta.txt"))?;
2297        assert_eq!(
2298            dst_md.permissions().mode() & 0o7777,
2299            0o741,
2300            "destination mode mismatch"
2301        );
2302        // disambiguate: both preserve::Metadata and std MetadataExt are in scope
2303        use std::os::unix::fs::MetadataExt;
2304        assert_eq!(
2305            MetadataExt::mtime(&dst_md),
2306            1_000_000_000,
2307            "mtime seconds mismatch"
2308        );
2309        assert_eq!(
2310            MetadataExt::mtime_nsec(&dst_md),
2311            123_456_789,
2312            "mtime nanos mismatch"
2313        );
2314        // uid/gid: chown to source's uid/gid (same as current user here) must hold
2315        assert_eq!(MetadataExt::uid(&dst_md), src_meta.uid(), "uid mismatch");
2316        assert_eq!(MetadataExt::gid(&dst_md), src_meta.gid(), "gid mismatch");
2317        Ok(())
2318    }
2319
2320    // set_file_metadata_fd: the chown → chmod ordering must preserve a setuid bit.
2321    // An unprivileged fchown (even to the current uid) clears setuid/setgid; doing
2322    // chown FIRST and chmod AFTER restores it. This test proves that ordering.
2323    #[tokio::test]
2324    async fn set_file_metadata_fd_ordering_preserves_setuid() -> anyhow::Result<()> {
2325        use std::io::Write;
2326        use std::os::unix::fs::PermissionsExt;
2327        let tmp = testutils::setup_test_dir().await?;
2328        // source file with the setuid bit set (0o4755)
2329        let src_path = tmp.join("foo/setuid_src");
2330        tokio::fs::write(&src_path, b"x").await?;
2331        std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o4755))?;
2332
2333        let root =
2334            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
2335        let src_handle = root.child(OsStr::new("setuid_src")).await?;
2336        let src_meta = src_handle.meta().clone();
2337        assert_eq!(
2338            src_meta.permissions().mode() & 0o7777,
2339            0o4755,
2340            "source setuid bit was not set up correctly"
2341        );
2342
2343        // destination starts without the setuid bit
2344        let mut dst_file = root.create_file(OsStr::new("setuid_dst"), 0o600).await?;
2345        dst_file.write_all(b"x")?;
2346        dst_file.flush()?;
2347
2348        // preserve_all keeps the full mode (mask 0o7777) AND preserves uid/gid, so
2349        // the chown runs before the chmod; the setuid bit must survive.
2350        let settings = crate::preserve::preserve_all();
2351        set_file_metadata_fd(
2352            &settings,
2353            &src_meta,
2354            dst_file.as_fd(),
2355            congestion::Side::Destination,
2356        )
2357        .await?;
2358        drop(dst_file);
2359
2360        let dst_md = std::fs::metadata(tmp.join("foo/setuid_dst"))?;
2361        assert_eq!(
2362            dst_md.permissions().mode() & 0o7777,
2363            0o4755,
2364            "setuid bit was lost — chown must run before chmod"
2365        );
2366        Ok(())
2367    }
2368
2369    // set_dir_metadata_fd: applying mode/time to a freshly made directory via its
2370    // Dir fd must reflect on the directory.
2371    #[tokio::test]
2372    async fn set_dir_metadata_fd_applies() -> anyhow::Result<()> {
2373        use std::os::unix::fs::{MetadataExt, PermissionsExt};
2374        let tmp = testutils::setup_test_dir().await?;
2375        // source directory with a distinctive mode and known mtime
2376        let src_dir_path = tmp.join("foo/src_dir");
2377        tokio::fs::create_dir(&src_dir_path).await?;
2378        std::fs::set_permissions(&src_dir_path, std::fs::Permissions::from_mode(0o2750))?;
2379        filetime::set_file_mtime(
2380            &src_dir_path,
2381            filetime::FileTime::from_unix_time(1_111_111_111, 222_000_000),
2382        )?;
2383
2384        let root =
2385            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
2386        let src_handle = root.child(OsStr::new("src_dir")).await?;
2387        let src_meta = src_handle.meta().clone();
2388
2389        // create the destination directory and apply metadata via its Dir fd
2390        let dst_dir = root.make_dir(OsStr::new("dst_dir"), 0o700).await?;
2391        let settings = crate::preserve::preserve_all();
2392        set_dir_metadata_fd(&settings, &src_meta, &dst_dir).await?;
2393
2394        let dst_md = std::fs::metadata(tmp.join("foo/dst_dir"))?;
2395        assert_eq!(
2396            dst_md.permissions().mode() & 0o7777,
2397            0o2750,
2398            "destination dir mode mismatch"
2399        );
2400        assert_eq!(
2401            MetadataExt::mtime(&dst_md),
2402            1_111_111_111,
2403            "dir mtime seconds mismatch"
2404        );
2405        assert_eq!(
2406            MetadataExt::mtime_nsec(&dst_md),
2407            222_000_000,
2408            "dir mtime nanos mismatch"
2409        );
2410        Ok(())
2411    }
2412
2413    // set_symlink_metadata_fd: applying time (and owner) to a symlink via its
2414    // O_PATH Handle must change the LINK's own atime/mtime — NOT the target's
2415    // mtime. This is the key proof that utimensat(AT_EMPTY_PATH) hit the link.
2416    #[tokio::test]
2417    async fn set_symlink_metadata_fd_changes_link_not_target() -> anyhow::Result<()> {
2418        use std::os::unix::fs::MetadataExt;
2419        let tmp = testutils::setup_test_dir().await?;
2420        let root =
2421            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
2422
2423        // sentinel target file with a known mtime we must NOT disturb
2424        let target_path = tmp.join("foo/sentinel_target.txt");
2425        tokio::fs::write(&target_path, b"keep my mtime").await?;
2426        let target_mtime = filetime::FileTime::from_unix_time(1_500_000_000, 0);
2427        filetime::set_file_mtime(&target_path, target_mtime)?;
2428        let target_before = std::fs::metadata(&target_path)?;
2429
2430        // the link to apply metadata to
2431        let link = root
2432            .symlink_at(
2433                OsStr::new("the_link"),
2434                std::path::Path::new("sentinel_target.txt"),
2435            )
2436            .await?;
2437
2438        // desired link timestamps come from a source FileMeta; build one by
2439        // stating a second symlink we set up with a distinctive mtime.
2440        let src_link_path = tmp.join("foo/src_link");
2441        tokio::fs::symlink("sentinel_target.txt", &src_link_path).await?;
2442        let src_link_mtime = filetime::FileTime::from_unix_time(1_234_567_890, 0);
2443        // set the LINK's own mtime (symlink=true) — not the target's
2444        filetime::set_symlink_file_times(
2445            &src_link_path,
2446            filetime::FileTime::from_unix_time(1_234_500_000, 0),
2447            src_link_mtime,
2448        )?;
2449        let src_meta = root.child(OsStr::new("src_link")).await?.meta().clone();
2450
2451        let settings = crate::preserve::preserve_all();
2452        set_symlink_metadata_fd(&settings, &src_meta, &link, congestion::Side::Destination).await?;
2453
2454        // the LINK's own mtime must now equal the source link's mtime
2455        let link_md = std::fs::symlink_metadata(tmp.join("foo/the_link"))?;
2456        assert_eq!(
2457            MetadataExt::mtime(&link_md),
2458            1_234_567_890,
2459            "link's own mtime was not applied"
2460        );
2461        // the TARGET file's mtime must be UNCHANGED
2462        let target_after = std::fs::metadata(&target_path)?;
2463        assert_eq!(
2464            MetadataExt::mtime(&target_after),
2465            MetadataExt::mtime(&target_before),
2466            "target mtime changed — utimensat followed the symlink!"
2467        );
2468        assert_eq!(
2469            MetadataExt::mtime_nsec(&target_after),
2470            MetadataExt::mtime_nsec(&target_before),
2471            "target mtime_nsec changed — utimensat followed the symlink!"
2472        );
2473        Ok(())
2474    }
2475
2476    // recheck: returns a fresh Handle with the same dev/ino when the entry is unchanged.
2477    #[tokio::test]
2478    async fn recheck_succeeds_when_unchanged() -> anyhow::Result<()> {
2479        let tmp = testutils::setup_test_dir().await?;
2480        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
2481        let h = root.child(OsStr::new("0.txt")).await?;
2482        let fresh = root.recheck(OsStr::new("0.txt"), &h).await?;
2483        assert_eq!(
2484            fresh.dev(),
2485            h.dev(),
2486            "recheck: dev mismatch on unchanged entry"
2487        );
2488        assert_eq!(
2489            fresh.ino(),
2490            h.ino(),
2491            "recheck: ino mismatch on unchanged entry"
2492        );
2493        Ok(())
2494    }
2495
2496    // recheck: returns ESTALE when the entry's inode has been replaced.
2497    #[tokio::test]
2498    async fn recheck_fails_when_swapped_to_different_inode() -> anyhow::Result<()> {
2499        let tmp = testutils::setup_test_dir().await?;
2500        let root =
2501            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
2502        // create a file whose Handle we will hold
2503        tokio::fs::write(tmp.join("foo/f"), b"original").await?;
2504        let h = root.child(OsStr::new("f")).await?;
2505        let original_ino = h.ino();
2506        // replace f with a completely new file (different inode)
2507        root.unlink_at(OsStr::new("f")).await?;
2508        root.create_file(OsStr::new("f"), 0o644).await?;
2509        // verify the replacement has a different inode
2510        let fresh_via_child = root.child(OsStr::new("f")).await?;
2511        assert_ne!(
2512            fresh_via_child.ino(),
2513            original_ino,
2514            "test setup error: new file has same inode as old one"
2515        );
2516        // recheck must detect the swap and return ESTALE
2517        let err = root.recheck(OsStr::new("f"), &h).await.unwrap_err();
2518        assert_eq!(
2519            err.raw_os_error(),
2520            Some(libc::ESTALE),
2521            "expected ESTALE on inode swap, got {err:#}"
2522        );
2523        Ok(())
2524    }
2525
2526    // recheck: returns ESTALE when the entry has been swapped to a symlink.
2527    #[tokio::test]
2528    async fn recheck_fails_when_swapped_to_symlink() -> anyhow::Result<()> {
2529        let tmp = testutils::setup_test_dir().await?;
2530        let root =
2531            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
2532        // create a regular file g
2533        tokio::fs::write(tmp.join("foo/g"), b"regular").await?;
2534        let h = root.child(OsStr::new("g")).await?;
2535        // replace g with a symlink (different inode and kind)
2536        root.unlink_at(OsStr::new("g")).await?;
2537        root.symlink_at(OsStr::new("g"), std::path::Path::new("0.txt"))
2538            .await?;
2539        // recheck must detect the mismatch (different inode) and return ESTALE
2540        let err = root.recheck(OsStr::new("g"), &h).await.unwrap_err();
2541        assert_eq!(
2542            err.raw_os_error(),
2543            Some(libc::ESTALE),
2544            "expected ESTALE on symlink swap, got {err:#}"
2545        );
2546        Ok(())
2547    }
2548
2549    // rejects_multi_component_names: extend to cover the five new methods.
2550    #[tokio::test]
2551    async fn new_methods_reject_multi_component_names() -> anyhow::Result<()> {
2552        let tmp = testutils::setup_test_dir().await?;
2553        let root =
2554            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
2555        // a second Dir for hard_link_at's dst parameter
2556        let dst =
2557            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
2558        // a valid Handle to satisfy recheck's `expected` parameter; the bad `name`
2559        // must be rejected before any dev/ino comparison is attempted.
2560        let any_handle = root.child(OsStr::new("0.txt")).await?;
2561
2562        for bad in ["a/b", "..", ".", ""] {
2563            let bad_os = OsStr::new(bad);
2564
2565            let err = root.unlink_at(bad_os).await.unwrap_err();
2566            assert_eq!(
2567                err.raw_os_error(),
2568                Some(libc::EINVAL),
2569                "unlink_at: expected EINVAL for {bad:?}, got {err:#}"
2570            );
2571
2572            let err = root.rmdir_at(bad_os).await.unwrap_err();
2573            assert_eq!(
2574                err.raw_os_error(),
2575                Some(libc::EINVAL),
2576                "rmdir_at: expected EINVAL for {bad:?}, got {err:#}"
2577            );
2578
2579            // symlink_at: only `name` is guarded; target is arbitrary
2580            let err = root
2581                .symlink_at(bad_os, std::path::Path::new("irrelevant"))
2582                .await
2583                .unwrap_err();
2584            assert_eq!(
2585                err.raw_os_error(),
2586                Some(libc::EINVAL),
2587                "symlink_at(name): expected EINVAL for {bad:?}, got {err:#}"
2588            );
2589
2590            let err = root.read_link_at(bad_os).await.unwrap_err();
2591            assert_eq!(
2592                err.raw_os_error(),
2593                Some(libc::EINVAL),
2594                "read_link_at: expected EINVAL for {bad:?}, got {err:#}"
2595            );
2596
2597            // hard_link_at: both `name` and `dst_name` are guarded
2598            let err = root
2599                .hard_link_at(bad_os, &dst, OsStr::new("good"))
2600                .await
2601                .unwrap_err();
2602            assert_eq!(
2603                err.raw_os_error(),
2604                Some(libc::EINVAL),
2605                "hard_link_at(name): expected EINVAL for {bad:?}, got {err:#}"
2606            );
2607
2608            let err = root
2609                .hard_link_at(OsStr::new("good"), &dst, bad_os)
2610                .await
2611                .unwrap_err();
2612            assert_eq!(
2613                err.raw_os_error(),
2614                Some(libc::EINVAL),
2615                "hard_link_at(dst_name): expected EINVAL for {bad:?}, got {err:#}"
2616            );
2617
2618            // recheck: bad `name` must be rejected before dev/ino comparison
2619            let err = root.recheck(bad_os, &any_handle).await.unwrap_err();
2620            assert_eq!(
2621                err.raw_os_error(),
2622                Some(libc::EINVAL),
2623                "recheck(name): expected EINVAL for {bad:?}, got {err:#}"
2624            );
2625        }
2626        Ok(())
2627    }
2628
2629    // chmod_via_proc_fd: changing the mode of a 0000-mode file through its O_PATH
2630    // handle must succeed (the /proc magic-symlink path does not need any rights on
2631    // the target itself) and the new mode must be observable on disk. This is the
2632    // case `fchmod` (EBADF on O_PATH) and a bare path chmod under restrictive modes
2633    // would struggle with; the pinned inode makes it inode-exact and permission-free.
2634    #[tokio::test]
2635    async fn chmod_via_proc_fd_changes_mode_of_zero_mode_file() -> anyhow::Result<()> {
2636        use std::os::unix::fs::PermissionsExt;
2637        let tmp = testutils::setup_test_dir().await?;
2638        let root =
2639            Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
2640        // a file with no permission bits at all (0000).
2641        let path = tmp.join("foo/locked.txt");
2642        tokio::fs::write(&path, b"locked").await?;
2643        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))?;
2644        // O_PATH handle pins the inode even though the file is 0000.
2645        let handle = root.child(OsStr::new("locked.txt")).await?;
2646        assert_eq!(handle.kind(), EntryKind::File, "fixture must be a file");
2647        // chmod it to 0o640 via the /proc magic symlink.
2648        chmod_via_proc_fd(&handle, congestion::Side::Destination, 0o640).await?;
2649        // the mode change must be visible on disk.
2650        let md = std::fs::symlink_metadata(&path)?;
2651        assert_eq!(
2652            md.permissions().mode() & 0o7777,
2653            0o640,
2654            "chmod_via_proc_fd must change the mode of a 0000-mode file"
2655        );
2656        Ok(())
2657    }
2658
2659    // stat_meta_via_proc_fd: on a symlink Handle (opened O_PATH|O_NOFOLLOW), resolving
2660    // /proc/self/fd/N must land on the LINK's own inode — never the target's. This is
2661    // load-bearing for symlink time-filtering (rm/rrm reads a symlink's own mtime/btime to
2662    // decide removal). We give the link and its target DISTINCT mtimes and assert the metadata
2663    // returned is the link's (is_symlink + the link's mtime), proving the magic-symlink resolve
2664    // is pinned to the O_PATH inode and does not follow the link to the target.
2665    #[tokio::test]
2666    async fn stat_meta_via_proc_fd_on_symlink_resolves_link_not_target() -> anyhow::Result<()> {
2667        use std::os::unix::fs::MetadataExt;
2668        let tmp = testutils::setup_test_dir().await?;
2669        let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
2670
2671        // target file with one mtime ...
2672        let target_path = tmp.join("foo/stat_target.txt");
2673        tokio::fs::write(&target_path, b"target body").await?;
2674        filetime::set_file_mtime(
2675            &target_path,
2676            filetime::FileTime::from_unix_time(1_700_000_000, 0),
2677        )?;
2678
2679        // ... and a symlink to it with a DISTINCT mtime set on the LINK itself (not the target).
2680        let link_path = tmp.join("foo/stat_link");
2681        tokio::fs::symlink("stat_target.txt", &link_path).await?;
2682        filetime::set_symlink_file_times(
2683            &link_path,
2684            filetime::FileTime::from_unix_time(1_600_000_000, 0),
2685            filetime::FileTime::from_unix_time(1_600_000_123, 0),
2686        )?;
2687
2688        // open the symlink via child() — an O_PATH|O_NOFOLLOW handle pinned to the link inode.
2689        let handle = root.child(OsStr::new("stat_link")).await?;
2690        assert_eq!(
2691            handle.kind(),
2692            EntryKind::Symlink,
2693            "fixture must classify as a symlink"
2694        );
2695
2696        let md = stat_meta_via_proc_fd(&handle, congestion::Side::Source).await?;
2697        // the returned metadata must be the LINK's, not the dereferenced target's.
2698        assert!(
2699            md.file_type().is_symlink(),
2700            "stat_meta_via_proc_fd followed the symlink to its target (got a non-symlink)"
2701        );
2702        assert_eq!(
2703            MetadataExt::mtime(&md),
2704            1_600_000_123,
2705            "expected the LINK's own mtime; a target-following stat would return 1_700_000_000"
2706        );
2707        Ok(())
2708    }
2709
2710    // NOTE: the test that ARMS strict operand resolution and exercises the strict
2711    // (openat2) open path lives in tests/strict_resolution.rs — its own integration
2712    // binary and therefore its own process. The switch is one-way, and under the
2713    // plain `cargo test` harness (used by the nix checkPhase) a lib's unit tests
2714    // share one process, so arming here would leak into the symlink-following
2715    // default-behavior tests above.
2716
2717    #[test]
2718    fn openat2_probe_is_stable() {
2719        // the probe is memoized; both calls must agree. On kernels without openat2 a
2720        // `false` result is the correct answer (the linter then refuses strict mode),
2721        // so no hard assertion on availability itself.
2722        let first = openat2_available();
2723        assert_eq!(first, openat2_available(), "probe must be stable");
2724        if !first {
2725            eprintln!("this kernel lacks openat2(2); strict-mode tests skip themselves");
2726        }
2727    }
2728}