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