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