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