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