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