From 1a4314e4b30d938275f80a8dcabb62ec55264940 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 8 Jul 2026 01:54:43 -0700 Subject: [PATCH] fix(native-sidecar): use one universal resolve-beneath walk for host-mount confinement Replace the Linux openat2(RESOLVE_BENEATH) path and the separate macOS cap-std fallback with a single portable resolve-beneath walker. gVisor/runsc (Rivet Compute) does not support openat2(RESOLVE_BENEATH), so a guest chdir/stat into a host mount returned ENOENT under runsc while passing under runc. The walker descends one component at a time with plain openat(2) (O_NOFOLLOW), keeps its own ancestor-fd stack for .., expands symlinks manually, and rejects absolute components/targets as escapes. - Delete src/macos_fs.rs and drop the cap-std dependency - Move the universal resolver into plugins/host_dir.rs as the confine module - Point filesystem.rs at crate::plugins::host_dir::confine - Document why openat2 is not used across the affected modules and AGENTS.md --- Cargo.lock | 91 +- crates/CLAUDE.md | 2 +- crates/native-sidecar/CLAUDE.md | 8 +- crates/native-sidecar/Cargo.toml | 11 +- crates/native-sidecar/src/execution.rs | 2 +- crates/native-sidecar/src/filesystem.rs | 1086 ++++++++++------- crates/native-sidecar/src/lib.rs | 2 - crates/native-sidecar/src/macos_fs.rs | 106 -- crates/native-sidecar/src/plugins/host_dir.rs | 915 ++++++++++---- .../tests/architecture_guards.rs | 11 +- crates/native-sidecar/tests/filesystem.rs | 8 - crates/native-sidecar/tests/host_dir.rs | 108 +- crates/native-sidecar/tests/service.rs | 7 - 13 files changed, 1494 insertions(+), 863 deletions(-) delete mode 100644 crates/native-sidecar/src/macos_fs.rs diff --git a/Cargo.lock b/Cargo.lock index 6b1bd94de7..0d848d8848 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,7 +174,6 @@ dependencies = [ "aws-sdk-s3", "base64 0.22.1", "bytes", - "cap-std", "ctr", "filetime", "h2 0.4.15", @@ -188,6 +187,7 @@ dependencies = [ "openssl", "pbkdf2", "rusqlite", + "rustix 1.1.4", "rustls 0.23.41", "rustls-native-certs", "rustls-pemfile", @@ -400,12 +400,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "ambient-authority" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -1059,36 +1053,6 @@ dependencies = [ "either", ] -[[package]] -name = "cap-primitives" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" -dependencies = [ - "ambient-authority", - "fs-set-times", - "io-extras", - "io-lifetimes", - "ipnet", - "maybe-owned", - "rustix 1.1.4", - "rustix-linux-procfs", - "windows-sys 0.59.0", - "winx", -] - -[[package]] -name = "cap-std" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" -dependencies = [ - "cap-primitives", - "io-extras", - "io-lifetimes", - "rustix 1.1.4", -] - [[package]] name = "cc" version = "1.2.66" @@ -1597,17 +1561,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs-set-times" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" -dependencies = [ - "io-lifetimes", - "rustix 1.1.4", - "windows-sys 0.59.0", -] - [[package]] name = "fs_extra" version = "1.3.0" @@ -2307,22 +2260,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "io-extras" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" -dependencies = [ - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "io-lifetimes" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" - [[package]] name = "ipconfig" version = "0.3.4" @@ -2534,12 +2471,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "maybe-owned" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" - [[package]] name = "md-5" version = "0.10.6" @@ -3226,16 +3157,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustix-linux-procfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" -dependencies = [ - "once_cell", - "rustix 1.1.4", -] - [[package]] name = "rustls" version = "0.21.12" @@ -4585,16 +4506,6 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" -[[package]] -name = "winx" -version = "0.36.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" -dependencies = [ - "bitflags", - "windows-sys 0.59.0", -] - [[package]] name = "writeable" version = "0.6.3" diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md index 0236cc6ca1..294464d791 100644 --- a/crates/CLAUDE.md +++ b/crates/CLAUDE.md @@ -106,7 +106,7 @@ These are hard rules with no exceptions: - **Filesystem-side teardown races must fail closed too.** In `crates/sidecar/src/filesystem.rs`, guest filesystem and Python VFS handlers should treat missing VMs or active processes as stale teardown races: log and return a rejection/`Ok(())` instead of `expect(...)`, because dispose can win after a request is queued but before the response is emitted. - **Mapped host paths in `crates/sidecar/src/filesystem.rs` must resolve symlinks against the mapping root before touching the real host.** For Python/Pyodide cache and other `AGENT_OS_GUEST_PATH_MAPPINGS` accesses, walk existing path components with `symlink_metadata`, reject targets that leave the mapped root, and filter `readdir` results the same way so guest `stat`/`listdir` calls cannot leak host metadata through escaped symlinks. - **Mapped host-path metadata and symlink ops must not require a pre-existing kernel mirror.** In `crates/sidecar/src/filesystem.rs`, guest writes under `AGENT_OS_GUEST_PATH_MAPPINGS` land on the host first, so `chmod`, `utimes`, `symlink`, and `readlink` need to operate on the mapped host path even when the kernel shadow entry has not been synced yet; only mirror metadata back into the kernel when the guest path already exists there. -- **`host_dir` metadata mutations must use anchored `openat2` handles and reject symlink leaves.** In `crates/sidecar/src/plugins/host_dir.rs`, route `chmod`, `chown`, and `utimes` through `open_beneath(... O_PATH | O_NOFOLLOW ...)` before mutating metadata, and fail closed on symlink leaves so a mounted host directory cannot retarget those ops onto escaped host paths. +- **`host_dir` metadata ops must resolve beneath the mount root, reject symlink leaves, and NOT require read on the target (non-root sidecar).** In `crates/native-sidecar/src/plugins/host_dir.rs`: `chown`/`utimes` resolve the parent via `split_parent`, `reject_symlink_leaf` (an `fstatat` `lstat` through the anchored parent fd → `EPERM` on a symlink), then mutate with `fchownat`/`utimensat(.., AT_SYMLINK_NOFOLLOW)` against `(parent_fd, leaf_name)` — no leaf open, so no read requirement, and `AT_SYMLINK_NOFOLLOW` closes the check→mutate swap race. `stat`/`exists` use `confine::resolve_parent_beneath` + `fstatat`. `chmod` is the deliberate exception: `fchmodat` has no `AT_SYMLINK_NOFOLLOW`, so it keeps `open_metadata_beneath` (leaf `O_RDONLY | O_NOFOLLOW`) + `fchmod` for TOCTOU safety, accepting the read requirement. Do NOT reintroduce `openat2`, an `O_PATH` *metadata/leaf* anchor, `/proc/self/fd` re-opens, or a macOS-specific path — the confinement boundary is the single universal `confine` module (see it and `crates/native-sidecar/CLAUDE.md` for why `openat2` was removed and why the non-root read constraint drives these choices). An `O_PATH` *directory traversal* anchor for search-only intermediate dirs (`confine::open_dir_anchor`, Linux-only, `EACCES` fallback) is allowed and is not a metadata anchor. - **`VirtualStat` schema changes must cross every filesystem boundary together.** When adding stat metadata for mount plugins such as `host_dir`, update the Rust `VirtualStat` constructors (`kernel`, `device_layer`, plugin adapters), sidecar filesystem JSON serialization, JS bridge conversions, and the V8 `Stats` shim in the same change or the extra metadata gets truncated before guest code can observe it. - **Stateful JS crypto sync RPCs in `crates/sidecar/src/execution.rs` are per-process state.** Keep `service_javascript_crypto_sync_rpc` threaded with `&mut ActiveProcess` for cipher/Diffie-Hellman session handlers, and for AES-GCM treat `authTagLength` as auth-tag output sizing instead of calling `Crypter::set_tag_len()` during encryption on the current OpenSSL provider. - **`net.poll` waits in `crates/sidecar/src/execution.rs` must stay explicitly bounded.** `service_javascript_net_sync_rpc(...)` runs on the sidecar's sync-RPC main thread, so clamp guest `wait_ms` values to the 50 ms ceiling in `clamp_javascript_net_poll_wait(...)`; longer waits should return the currently observed socket state after the ceiling expires instead of monopolizing dispose/shutdown or unrelated VM work. diff --git a/crates/native-sidecar/CLAUDE.md b/crates/native-sidecar/CLAUDE.md index 363268d14b..9937a66746 100644 --- a/crates/native-sidecar/CLAUDE.md +++ b/crates/native-sidecar/CLAUDE.md @@ -31,5 +31,11 @@ Migration status: **resource limits** (typed `*ExecutionLimits` on the execution - `plugins/host_dir.rs` metadata writes must keep the old symlink-leaf safety contract for plain ops (`chmod`/`chown`/`utimes` still reject symlink leaves), while the richer timestamp path should only mutate symlink metadata when the caller explicitly requests nofollow semantics (`lutimes` / `utimes_spec(..., false)`). - Mounted filesystem shutdown now happens explicitly during `src/vm.rs` disposal/reconfigure, not just in `MountTable::drop`. Bridge-backed mounts can therefore emit `SidecarRequestPayload::JsBridgeCall` during teardown, and host-visible flush failures should surface as the structured event `filesystem.mount.shutdown_failed` with the mount metadata plus the original error code/message. - Guest runtime env setup in `src/execution.rs` must add writable `host_dir` / `module_access` mount roots to `AGENTOS_EXTRA_FS_WRITE_PATHS`, not just the VM shadow cwd. Without those extra write roots, guest `fs.*` bridge calls misclassify writable mounts as read-only (`EROFS`) and cross-mount rename tests never reach the intended `EXDEV` path. -- Python mapped host-path access in `src/filesystem.rs` must stay on the anchored-fd path: open the mapped root once, resolve descendants with `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, and perform the actual syscall through `/proc/self/fd/` or an anchored parent dir. Do not reintroduce resolve-then-use `PathBuf` opens, and when filtering `read_dir` results from a proc-fd directory, rebuild child host paths from the resolved directory host path plus `file_name()` instead of reusing `DirEntry::path()`, which points back into procfs. +- The native sidecar must run UNPRIVILEGED (non-root uid) AND under gVisor/runsc (Rivet Compute), so host-mount confinement cannot assume either root DAC bypass or Linux-only syscalls. Two consequences bind the `confine` boundary: (1) `openat2(RESOLVE_BENEATH)` is unsupported under gVisor, so the resolve-beneath walk must use plain `openat(2)` (see the `confine` module); (2) because the sidecar uid is not root, DAC permission checks are real and are NOT masked away — so confinement must not require MORE access than POSIX does. Concretely, metadata ops must not require READ permission on the target file (POSIX only requires search on the parent). Rules: + - `stat`/`exists` use `confine::resolve_parent_beneath` + `fstatat`/`fstat` against `(parent_fd, leaf_name)` — never `open_beneath(O_RDONLY)` on the leaf, which would falsely report a statable-but-unreadable file (mode `0600` owned by another uid, or `0000`) as missing/`EACCES` under a non-root sidecar. + - `chown`/`utimes` use `split_parent` + `fchownat`/`utimensat(.., AT_SYMLINK_NOFOLLOW/NoFollowSymlink)` after `reject_symlink_leaf`. `AT_SYMLINK_NOFOLLOW` on the mutation both keeps the leaf read-free AND closes the check→mutate symlink-swap TOCTOU (a leaf swapped to a symlink after the reject-check is operated on in place, staying confined, never followed to an escaped host path). + - `chmod` is the ONE deliberate exception: `fchmodat` has no `AT_SYMLINK_NOFOLLOW`, so a leaf-free `chmod` cannot close that swap race. `chmod` therefore keeps the `open_metadata_beneath` leaf open (`O_RDONLY | O_NOFOLLOW`) + `fchmod`, accepting the non-root READ requirement in exchange for TOCTOU safety. Do NOT "fix" it by switching to `fchmodat(parent, name)` — that reintroduces a symlink-swap host-escape. + - A SEARCH-ONLY directory in the path (mode `0711`/`0111`: execute without read for the sidecar uid) must still be traversable, both mid-walk (intermediate dirs, via `confine::open_dir_anchor`) AND as the immediate *parent* opened for `*at` ops (`split_parent` → `open_dir_anchor_beneath` → `confine::resolve_dir_anchor_beneath`, NOT `open_directory_beneath`). Each tries `O_RDONLY` and on Linux falls back to `O_PATH` on `EACCES`. This `O_PATH` is a directory *traversal anchor only* (used solely as the `dirfd` for `statat`/`openat`/`readlinkat`/`fstatat`/`fchownat`/`utimensat`/…, never `read`/`readdir`/`fchmod`), so it does NOT violate the metadata-anchor prohibition below. Keep `readdir` on `open_directory_beneath` (`O_RDONLY`) so LISTING a search-only dir still fails `EACCES` as POSIX requires. The fallback is additive and cannot regress gVisor (it only runs after `O_RDONLY` already failed); full coverage of this case under gVisor still assumes gVisor honors `O_PATH` dir fds as `*at` anchors, which should be confirmed — but if it does not, only the already-failing search-only case is affected, nothing else. +- Host-mount confinement uses ONE universal resolve-beneath walk, the `confine` module in `src/plugins/host_dir.rs` (`crate::plugins::host_dir::confine::resolve_beneath` / `::resolve_parent_beneath` / `::read_dir`), for every platform. Do NOT split it back out into a separate `src/fs.rs` module, and do NOT reintroduce `openat2(RESOLVE_BENEATH)`, an `O_PATH` *metadata/leaf* anchor, `/proc/self/fd/` re-opens, a macOS/`cap-std` fallback, or any per-platform split of this boundary. (An `O_PATH` *directory traversal* anchor for search-only intermediate dirs — `confine::open_dir_anchor`, Linux-only `EACCES` fallback — is explicitly allowed; it is a `dirfd` for `*at` calls only, never a metadata/leaf anchor.) `openat2` was deleted because gVisor/runsc (Rivet Compute) does not support `openat2(RESOLVE_BENEATH)` — a guest `chdir`/`stat` into a host mount returned `ENOENT` under runsc while passing under runc — and because maintaining a second macOS implementation doubled the escape-bug surface. The walk descends one component at a time with plain `openat(2)` (`O_NOFOLLOW`), keeps its own ancestor-fd stack for `..` (never asking the kernel to resolve `..`), expands symlinks manually, and rejects absolute components/targets as escapes. +- Python mapped host-path access in `src/filesystem.rs` and all `host_dir` ops must stay on the anchored-fd path: resolve descendants via `confine::resolve_beneath` / `resolve_parent_beneath` (from `crate::plugins::host_dir`), then perform the actual syscall fd-relative against the returned `OwnedFd` (`fstat`/`fchmod`, fd `read`/`write`) or `*at` against the anchored parent dir + leaf name (`fstatat`/`fchownat`/`utimensat`). Metadata ops that do not need the file contents (`stat`/`exists`/`chown`/`utimes`) MUST use the parent-fd + `*at` form, never a leaf `O_RDONLY` open, so they do not require read permission the op does not need under a non-root sidecar (`chmod` is the exception — see the metadata-ops rule above). `confine::Resolved::real_path` is diagnostic/logical only — never re-open it as an authority handle. Do not reintroduce resolve-then-use `PathBuf` opens, and enumerate directories with `confine::read_dir(dir_fd)` (fd-based, classifying entries via the directory fd) instead of a path-based `fs::read_dir`. - In `tests/builtin_conformance.rs`, isolated extra tests that open a host listener should finish sidecar/session/VM setup before starting any accept deadline. Those tests run in subprocesses that can queue behind the shared sidecar-runtime lock, so a server timeout that starts before VM creation will flake under the normal parallel `cargo test` harness. diff --git a/crates/native-sidecar/Cargo.toml b/crates/native-sidecar/Cargo.toml index 43b69ca829..6178fdce70 100644 --- a/crates/native-sidecar/Cargo.toml +++ b/crates/native-sidecar/Cargo.toml @@ -49,6 +49,12 @@ rustls-native-certs = "0.8" rustls-pemfile = "2.2" tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } rusqlite = { version = "0.32", features = ["bundled"] } +# `rustix` provides the safe (owned-fd, no `unsafe`) `openat(2)`/`statat`/ +# `readlinkat`/`Dir` primitives used by the universal resolve-beneath +# implementation in the `confine` module of `src/plugins/host_dir.rs`. Plain +# `openat(2)` is portable across Linux, macOS, and gVisor; `openat2` is +# deliberately NOT used (see that module for why). +rustix = { version = "1", features = ["fs"] } scrypt = "0.11" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -62,11 +68,6 @@ ureq = { version = "2.10", features = ["json"] } url = "2" vfs = { workspace = true } -[target.'cfg(target_os = "macos")'.dependencies] -# macOS has no openat2/RESOLVE_BENEATH; cap-std provides the audited -# resolve-beneath path resolution used in place of openat2 on this platform. -cap-std = "3" - [dev-dependencies] serde_bare = "0.5" tar = "0.4" diff --git a/crates/native-sidecar/src/execution.rs b/crates/native-sidecar/src/execution.rs index 23254cafd4..ef336720d7 100644 --- a/crates/native-sidecar/src/execution.rs +++ b/crates/native-sidecar/src/execution.rs @@ -11099,7 +11099,7 @@ fn runtime_guest_path_mappings(vm: &VmState) -> Vec { /// inline against this reader — concurrently with the service loop — so a large /// cold-start module graph never serializes behind / starves an in-flight ACP /// `session/new` bootstrap on the single service-loop thread. The reader reads -/// the same mounted tree the guest sees (anchored `openat2`, escaping-symlink +/// the same mounted tree the guest sees (anchored resolve-beneath, escaping-symlink /// refusal), never the host-direct path translator. Returns `None` when the VM /// has no usable read-only mount, so resolution falls back to the service-loop /// kernel reader. diff --git a/crates/native-sidecar/src/filesystem.rs b/crates/native-sidecar/src/filesystem.rs index 077a4b1e7a..989f5b8651 100644 --- a/crates/native-sidecar/src/filesystem.rs +++ b/crates/native-sidecar/src/filesystem.rs @@ -23,22 +23,15 @@ use crate::{DispatchResult, NativeSidecar, NativeSidecarBridge, SidecarError}; use base64::Engine; use nix::errno::Errno; -use nix::fcntl::{open, OFlag}; -#[cfg(not(target_os = "macos"))] -use nix::fcntl::{openat2, OpenHow, ResolveFlag}; +use nix::fcntl::OFlag; use nix::libc; -// macOS has neither `O_PATH` (metadata-only anchor) nor `O_TMPFILE`. O_PATH -// anchors are re-opened via `/dev/fd/N`, so a read-only open stands in; no -// caller actually passes O_TMPFILE (it appears only in a defensive `intersects` -// check), so an empty flag is an exact behavioural match there. -#[cfg(not(target_os = "macos"))] -const O_PATH_ANCHOR: OFlag = OFlag::O_PATH; -#[cfg(target_os = "macos")] +// The universal resolver (`crate::plugins::host_dir::confine`) never returns a metadata-only `O_PATH` +// handle (macOS has no `O_PATH`); a read-only open stands in as the anchor and +// every operation is performed fd-relative, so `O_RDONLY` is the portable +// anchor open mode. `O_TMPFILE` is never passed by any caller (it appears only +// in a defensive `intersects` check), so an empty flag matches exactly. const O_PATH_ANCHOR: OFlag = OFlag::O_RDONLY; -#[cfg(not(target_os = "macos"))] -const O_TMPFILE_FLAG: OFlag = OFlag::O_TMPFILE; -#[cfg(target_os = "macos")] const O_TMPFILE_FLAG: OFlag = OFlag::empty(); use agentos_execution::{ JavascriptSyncRpcRequest, LocalResolvedModuleFormat, ModuleFsReader, ModuleResolveMode, @@ -59,8 +52,8 @@ use std::ffi::OsString; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{Read, Write}; -use std::os::fd::{AsRawFd, RawFd}; -use std::os::unix::fs::{symlink, FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd}; +use std::os::unix::fs::{symlink, FileExt, MetadataExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use std::time::Instant; @@ -128,52 +121,96 @@ enum MappedRuntimeHostAccess { ReadOnly(MappedRuntimeHostPath), } +/// An owned file descriptor resolved strictly beneath a mount root by +/// [`crate::plugins::host_dir::confine::resolve_beneath`]. All operations go through the fd (fd-relative +/// `*at` calls, `fstat`, fd `read`/`write`) — never a recovered path string — so +/// they stay confined to the resolved object and TOCTOU-safe. The `OwnedFd` +/// closes the descriptor on drop. #[derive(Debug)] struct AnchoredFd { - fd: RawFd, + fd: OwnedFd, } impl AnchoredFd { - #[cfg(not(target_os = "macos"))] - fn proc_path(&self) -> PathBuf { - PathBuf::from(format!("/proc/self/fd/{}", self.fd)) + /// `fstat` the resolved object. + fn metadata(&self) -> std::io::Result { + nix::sys::stat::fstat(self.as_raw_fd()) + .map(|stat| HostStat::from_filestat(&stat)) + .map_err(errno_to_io) } - // macOS `/dev/fd/N` re-opens a *file* fd (the kernel dups it), standing in - // for Linux's `/proc/self/fd/N` at the file read/write/metadata call sites. - // It is NOT, however, a drop-in for directory work: `/dev/fd/N` is not a - // `readdir`-able directory and child components cannot be appended - // (`/dev/fd/N/child` fails). Directory enumeration uses [`readdir_path`]; - // child mutations use fd-relative `*at` calls. - #[cfg(target_os = "macos")] - fn proc_path(&self) -> PathBuf { - PathBuf::from(format!("/dev/fd/{}", self.fd)) + /// Read the entire resolved file via the fd. + fn read_bytes(&self) -> std::io::Result> { + read_all_from_fd(self.fd.as_fd()) } - // Path to enumerate this fd's directory entries. Linux can `readdir` - // `/proc/self/fd/N` directly; macOS `/dev/fd/N` is not a readdir-able - // directory (it yields `ENOTDIR`), so recover the fd's real host path via - // `fcntl(F_GETPATH)` — the same fd→path recovery used elsewhere on macOS. - #[cfg(not(target_os = "macos"))] - fn readdir_path(&self) -> std::io::Result { - Ok(self.proc_path()) + /// Read the entire resolved file via the fd as UTF-8. + fn read_to_string(&self) -> std::io::Result { + let bytes = self.read_bytes()?; + String::from_utf8(bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) } - #[cfg(target_os = "macos")] - fn readdir_path(&self) -> std::io::Result { - crate::macos_fs::fd_real_path(self.fd) + + /// Write `data` to the resolved file via the fd (the fd must have been opened + /// writable). + fn write_bytes(&self, data: &[u8]) -> std::io::Result<()> { + write_all_to_fd(self.fd.as_fd(), data) + } + + /// `fchmod` the resolved object. + fn set_mode(&self, mode: u32) -> std::io::Result<()> { + nix::sys::stat::fchmod( + self.as_raw_fd(), + Mode::from_bits_truncate(mode as nix::libc::mode_t), + ) + .map_err(errno_to_io) + } + + /// `futimens` the resolved object. + fn set_times(&self, atime: &TimeSpec, mtime: &TimeSpec) -> std::io::Result<()> { + nix::sys::stat::futimens(self.as_raw_fd(), atime, mtime).map_err(errno_to_io) + } + + /// Consume the handle, yielding the owned fd (e.g. to build a persistent + /// [`std::fs::File`]). + fn into_owned_fd(self) -> OwnedFd { + self.fd } } impl AsRawFd for AnchoredFd { fn as_raw_fd(&self) -> RawFd { - self.fd + self.fd.as_raw_fd() } } -impl Drop for AnchoredFd { - fn drop(&mut self) { - let _ = nix::unistd::close(self.fd); +/// Read an entire file from `fd` into a `Vec`, using fd `read` (no path re-open). +fn read_all_from_fd(fd: BorrowedFd<'_>) -> std::io::Result> { + let mut out = Vec::new(); + let mut buf = [0_u8; 65536]; + loop { + let read = nix::unistd::read(fd.as_raw_fd(), &mut buf).map_err(errno_to_io)?; + if read == 0 { + break; + } + out.extend_from_slice(&buf[..read]); } + Ok(out) +} + +/// Write all of `data` to `fd`, using fd `write` (no path re-open). +fn write_all_to_fd(fd: BorrowedFd<'_>, mut data: &[u8]) -> std::io::Result<()> { + while !data.is_empty() { + let written = nix::unistd::write(fd, data).map_err(errno_to_io)?; + if written == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "failed to write whole buffer to mapped host fd", + )); + } + data = &data[written..]; + } + Ok(()) } #[derive(Debug)] @@ -838,7 +875,7 @@ pub(crate) fn normalize_python_vfs_rpc_path(path: &str) -> Result { let normalized = self.normalize_guest_path(guest_path); if let Some(opened) = self.open_mapped_path(&normalized, "module.readFile", OFlag::O_RDONLY) { - if let Ok(source) = fs::read_to_string(opened.handle.proc_path()) { + if let Ok(source) = opened.handle.read_to_string() { return Some(source); } } @@ -946,8 +983,8 @@ impl ModuleFsReader for ProcessModuleFsReader<'_> { fn path_is_dir(&mut self, guest_path: &str) -> Option { let normalized = self.normalize_guest_path(guest_path); if let Some(opened) = self.open_mapped_path(&normalized, "module.stat", O_PATH_ANCHOR) { - if let Ok(metadata) = fs::metadata(opened.handle.proc_path()) { - return Some(metadata.is_dir()); + if let Ok(metadata) = opened.handle.metadata() { + return Some(metadata.is_directory); } } @@ -1268,22 +1305,16 @@ pub(crate) fn service_javascript_fs_sync_rpc( "open_mapped_beneath", phase_start, ); - let host_path = opened.host_path.clone(); let phase_start = Instant::now(); - return open_mapped_host_fd( - process, - host_path, - Some(path.to_string()), - opened.handle.proc_path(), - flags, - ) - .inspect(|_| { - record_fs_sync_subphase( - request.method.as_str(), - "open_mapped_fd", - phase_start, - ); - }); + return open_mapped_host_fd(process, opened, Some(path.to_string())).inspect( + |_| { + record_fs_sync_subphase( + request.method.as_str(), + "open_mapped_fd", + phase_start, + ); + }, + ); } Some(MappedRuntimeHostAccess::ReadOnly(_)) => { return Err(read_only_mapped_runtime_host_path_error(path)); @@ -1548,7 +1579,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( OFlag::O_RDONLY, Mode::empty(), )?; - let content = fs::read(opened.handle.proc_path()).map_err(|error| { + let content = opened.handle.read_bytes().map_err(|error| { SidecarError::Io(format!( "failed to read mapped guest file {} -> {}: {error}", path, @@ -1596,7 +1627,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( .unwrap_or(0o666) as _, ), )?; - fs::write(opened.handle.proc_path(), contents).map_err(|error| { + opened.handle.write_bytes(&contents).map_err(|error| { SidecarError::Io(format!( "failed to write mapped guest file {} -> {}: {error}", path, @@ -1634,14 +1665,14 @@ pub(crate) fn service_javascript_fs_sync_rpc( O_PATH_ANCHOR, Mode::empty(), )?; - let metadata = fs::metadata(opened.handle.proc_path()).map_err(|error| { + let metadata = opened.handle.metadata().map_err(|error| { SidecarError::Io(format!( "failed to stat mapped guest path {} -> {}: {error}", path, opened.host_path.display() )) })?; - return Ok(javascript_sync_rpc_host_stat_value(&metadata)); + return Ok(metadata.to_value()); } kernel .stat_for_process(EXECUTION_DRIVER_NAME, kernel_pid, path) @@ -1730,14 +1761,14 @@ pub(crate) fn service_javascript_fs_sync_rpc( O_PATH_ANCHOR, Mode::empty(), )?; - let metadata = fs::metadata(opened.handle.proc_path()).map_err(|error| { + let metadata = opened.handle.metadata().map_err(|error| { SidecarError::Io(format!( "failed to access mapped guest path {} -> {}: {error}", path, opened.host_path.display() )) })?; - if !filesystem_access_mode_is_allowed(metadata.permissions().mode(), mode) { + if !filesystem_access_mode_is_allowed(metadata.mode, mode) { return Err(filesystem_access_denied(path, mode)); } return Ok(Value::Null); @@ -1779,7 +1810,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( OFlag::O_RDONLY, Mode::empty(), )?; - fs::read(opened.handle.proc_path()).map_err(|error| { + opened.handle.read_bytes().map_err(|error| { SidecarError::Io(format!( "failed to read mapped guest file {} -> {}: {error}", source, @@ -1794,7 +1825,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( OFlag::O_RDONLY, Mode::empty(), )?; - fs::read(opened.handle.proc_path()).map_err(|error| { + opened.handle.read_bytes().map_err(|error| { SidecarError::Io(format!( "failed to read mapped guest file {} -> {}: {error}", source, @@ -1814,7 +1845,9 @@ pub(crate) fn service_javascript_fs_sync_rpc( OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC, Mode::from_bits_truncate(0o666), )?; - fs::write(opened.handle.proc_path(), contents) + opened + .handle + .write_bytes(&contents) .map(|()| Value::Null) .map_err(|error| { SidecarError::Io(format!( @@ -1865,7 +1898,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( O_PATH_ANCHOR, Mode::empty(), ) { - Ok(opened) => fs::metadata(opened.handle.proc_path()).is_ok(), + Ok(opened) => opened.handle.metadata().is_ok(), Err(_) => false, }; return Ok(Value::Bool(exists)); @@ -2071,11 +2104,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( { kernel.chmod(path, mode).map_err(kernel_error)?; } - fs::set_permissions( - opened.handle.proc_path(), - fs::Permissions::from_mode(mode & 0o7777), - ) - .map_err(|error| { + opened.handle.set_mode(mode & 0o7777).map_err(|error| { SidecarError::Io(format!( "failed to chmod mapped guest path {} -> {}: {error}", path, @@ -2139,24 +2168,16 @@ pub(crate) fn service_javascript_fs_sync_rpc( } match mapped_runtime_host_path(process, path, true) { Some(MappedRuntimeHostAccess::Writable(mapped_host)) => { - let mapped_host_exists = match fs::symlink_metadata(&mapped_host.host_path) { - Ok(_) => true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - materialize_mapped_host_path_from_kernel( - kernel, - kernel_pid, - path, - &mapped_host, - )?; - fs::symlink_metadata(&mapped_host.host_path).is_ok() - } - Err(error) => { - return Err(SidecarError::Io(format!( - "failed to inspect mapped guest path {} -> {}: {error}", - path, - mapped_host.host_path.display() - ))); - } + let mapped_host_exists = if mapped_runtime_host_path_exists(&mapped_host)? { + true + } else { + materialize_mapped_host_path_from_kernel( + kernel, + kernel_pid, + path, + &mapped_host, + )?; + mapped_runtime_host_path_exists(&mapped_host)? }; if mapped_host_exists { let context = format!("failed to update mapped guest path times {path}"); @@ -2199,13 +2220,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( } } if let Some(opened) = &follow_handle { - apply_host_path_utimens( - &opened.handle.proc_path(), - atime, - mtime, - true, - &context, - )?; + apply_anchored_fd_utimens(&opened.handle, atime, mtime, &context)?; } else if let Some(parent) = &parent_handle { apply_mapped_child_utimens(parent, atime, mtime, &context)?; } @@ -2712,42 +2727,17 @@ fn read_only_mapped_runtime_host_path_error(guest_path: &str) -> SidecarError { SidecarError::Kernel(format!("EROFS: read-only filesystem: {guest_path}")) } -#[cfg(not(target_os = "macos"))] -fn mapped_runtime_resolve_flags() -> ResolveFlag { - ResolveFlag::RESOLVE_BENEATH | ResolveFlag::RESOLVE_NO_MAGICLINKS -} - -/// Open `relative` strictly beneath the mapped mount root, returning an owned -/// raw fd. Linux resolves with `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` -/// anchored on `root_dir`; macOS has no such syscall and resolves beneath -/// `host_root` with cap-std (see [`crate::macos_fs`]). -#[cfg(not(target_os = "macos"))] +/// Open `relative` strictly beneath the mapped mount root, returning the owned +/// fd and the resolved (diagnostic-only) host path via the universal +/// resolve-beneath walk in [`crate::plugins::host_dir::confine`]. See that +/// module for why `openat2` is not used. fn mapped_runtime_open_fd( - root_dir: &AnchoredFd, - _host_root: &Path, - relative: &Path, - flags: OFlag, - mode: Mode, -) -> Result { - openat2( - root_dir.as_raw_fd(), - relative, - OpenHow::new() - .flags(flags | OFlag::O_CLOEXEC) - .mode(mode) - .resolve(mapped_runtime_resolve_flags()), - ) -} - -#[cfg(target_os = "macos")] -fn mapped_runtime_open_fd( - _root_dir: &AnchoredFd, host_root: &Path, relative: &Path, flags: OFlag, mode: Mode, -) -> Result { - crate::macos_fs::resolve_beneath(host_root, relative, flags, mode) +) -> Result { + crate::plugins::host_dir::confine::resolve_beneath(host_root, relative, flags, mode) } fn mapped_runtime_relative_path(mapped: &MappedRuntimeHostPath) -> Result { @@ -2776,44 +2766,24 @@ fn mapped_runtime_relative_path(mapped: &MappedRuntimeHostPath) -> Result Result { - let fd = open( - &mapped.host_root, - OFlag::O_CLOEXEC | OFlag::O_DIRECTORY | OFlag::O_RDONLY, - Mode::empty(), - ) - .map_err(|error| { - SidecarError::Io(format!( - "{operation}: failed to open mapped host root {} for {}: {}", - mapped.host_root.display(), - mapped.guest_path, - std::io::Error::from_raw_os_error(error as i32) - )) - })?; - Ok(AnchoredFd { fd }) -} - fn open_mapped_runtime_beneath( mapped: &MappedRuntimeHostPath, operation: &str, flags: OFlag, mode: Mode, ) -> Result { - let root_dir = open_mapped_runtime_root_dir(mapped, operation)?; let relative = mapped_runtime_relative_path(mapped)?; let open_mode = if flags.intersects(OFlag::O_CREAT | O_TMPFILE_FLAG) { mode } else { Mode::empty() }; - let fd = mapped_runtime_open_fd(&root_dir, &mapped.host_root, &relative, flags, open_mode) + let resolved = mapped_runtime_open_fd(&mapped.host_root, &relative, flags, open_mode) .map_err(|error| mapped_runtime_open_error(operation, mapped, error))?; - let handle = AnchoredFd { fd }; - let host_path = mapped_runtime_host_path_from_fd(mapped, operation, &handle)?; - Ok(MappedRuntimeOpenedPath { handle, host_path }) + Ok(MappedRuntimeOpenedPath { + handle: AnchoredFd { fd: resolved.fd }, + host_path: resolved.real_path, + }) } fn open_mapped_runtime_directory_beneath( @@ -2821,18 +2791,17 @@ fn open_mapped_runtime_directory_beneath( operation: &str, relative: &Path, ) -> Result { - let root_dir = open_mapped_runtime_root_dir(mapped, operation)?; - let fd = mapped_runtime_open_fd( - &root_dir, + let resolved = mapped_runtime_open_fd( &mapped.host_root, relative, OFlag::O_DIRECTORY | OFlag::O_RDONLY, Mode::empty(), ) .map_err(|error| mapped_runtime_open_error(operation, mapped, error))?; - let handle = AnchoredFd { fd }; - let host_path = mapped_runtime_host_path_from_fd(mapped, operation, &handle)?; - Ok(MappedRuntimeOpenedPath { handle, host_path }) + Ok(MappedRuntimeOpenedPath { + handle: AnchoredFd { fd: resolved.fd }, + host_path: resolved.real_path, + }) } fn open_mapped_runtime_parent_beneath( @@ -2928,8 +2897,11 @@ impl From<&fs::Metadata> for HostStat { } } -#[cfg(target_os = "macos")] impl HostStat { + // `FileStat` field widths differ by platform (e.g. `st_dev`/`st_nlink` are + // narrower on macOS than on Linux), so these casts are load-bearing on macOS + // even though they are same-type on Linux. + #[allow(clippy::unnecessary_cast)] fn from_filestat(stat: &nix::sys::stat::FileStat) -> Self { use nix::sys::stat::SFlag; let fmt = stat.st_mode & SFlag::S_IFMT.bits(); @@ -2952,13 +2924,6 @@ impl HostStat { } } -#[cfg(not(target_os = "macos"))] -fn mapped_child_lstat(parent: &MappedRuntimeParentPath) -> std::io::Result { - Ok(HostStat::from(&fs::symlink_metadata( - mapped_runtime_parent_child_path(parent), - )?)) -} -#[cfg(target_os = "macos")] fn mapped_child_lstat(parent: &MappedRuntimeParentPath) -> std::io::Result { let stat = nix::sys::stat::fstatat( Some(parent.directory.as_raw_fd()), @@ -3023,49 +2988,19 @@ fn read_mapped_runtime_link( }) } -fn mapped_runtime_host_path_from_fd( - mapped: &MappedRuntimeHostPath, - operation: &str, - fd: &AnchoredFd, -) -> Result { - // Linux reads the magic symlink `/proc/self/fd/N`; macOS recovers the path - // with `fcntl(F_GETPATH)` (see [`crate::macos_fs::fd_real_path`]). - #[cfg(not(target_os = "macos"))] - let resolved = fs::read_link(fd.proc_path()); - #[cfg(target_os = "macos")] - let resolved = crate::macos_fs::fd_real_path(fd.as_raw_fd()); - resolved.map_err(|error| { - SidecarError::Io(format!( - "{operation}: failed to resolve anchored mapped guest path {}: {error}", - mapped.guest_path - )) - }) -} - -#[cfg(not(target_os = "macos"))] -fn mapped_runtime_parent_child_path(parent: &MappedRuntimeParentPath) -> PathBuf { - parent.directory.proc_path().join(&parent.child_name) -} - // --------------------------------------------------------------------------- // Mapped-runtime child operations. // -// On Linux these operate on the resolved parent fd by appending the child to -// `/proc/self/fd/N`. macOS cannot append path components to `/dev/fd/N`, so it -// performs the same operations with fd-relative `*at` calls anchored on the -// resolved parent fd — TOCTOU-safe, mirroring the host_dir plugin. +// Each operation is performed with an fd-relative `*at` call anchored on the +// resolved parent fd — TOCTOU-safe and portable across Linux, macOS, and +// gVisor. This is the single universal implementation (there is no longer a +// Linux `/proc/self/fd`-append variant). // --------------------------------------------------------------------------- -#[cfg(target_os = "macos")] fn errno_to_io(error: Errno) -> std::io::Error { std::io::Error::from_raw_os_error(error as i32) } -#[cfg(not(target_os = "macos"))] -fn create_dir_at(dir: &AnchoredFd, name: &std::ffi::OsStr) -> std::io::Result<()> { - fs::create_dir(dir.proc_path().join(name)) -} -#[cfg(target_os = "macos")] fn create_dir_at(dir: &AnchoredFd, name: &std::ffi::OsStr) -> std::io::Result<()> { nix::sys::stat::mkdirat(Some(dir.as_raw_fd()), name, Mode::from_bits_truncate(0o777)) .map_err(errno_to_io) @@ -3075,11 +3010,6 @@ fn mapped_child_create_dir(parent: &MappedRuntimeParentPath) -> std::io::Result< create_dir_at(&parent.directory, parent.child_name.as_os_str()) } -#[cfg(not(target_os = "macos"))] -fn mapped_child_is_dir(parent: &MappedRuntimeParentPath) -> std::io::Result { - Ok(fs::symlink_metadata(mapped_runtime_parent_child_path(parent))?.is_dir()) -} -#[cfg(target_os = "macos")] fn mapped_child_is_dir(parent: &MappedRuntimeParentPath) -> std::io::Result { use nix::sys::stat::SFlag; let stat = nix::sys::stat::fstatat( @@ -3091,11 +3021,6 @@ fn mapped_child_is_dir(parent: &MappedRuntimeParentPath) -> std::io::Result std::io::Result<()> { - fs::remove_dir(mapped_runtime_parent_child_path(parent)) -} -#[cfg(target_os = "macos")] fn mapped_child_remove_dir(parent: &MappedRuntimeParentPath) -> std::io::Result<()> { nix::unistd::unlinkat( Some(parent.directory.as_raw_fd()), @@ -3105,11 +3030,6 @@ fn mapped_child_remove_dir(parent: &MappedRuntimeParentPath) -> std::io::Result< .map_err(errno_to_io) } -#[cfg(not(target_os = "macos"))] -fn mapped_child_remove_file(parent: &MappedRuntimeParentPath) -> std::io::Result<()> { - fs::remove_file(mapped_runtime_parent_child_path(parent)) -} -#[cfg(target_os = "macos")] fn mapped_child_remove_file(parent: &MappedRuntimeParentPath) -> std::io::Result<()> { nix::unistd::unlinkat( Some(parent.directory.as_raw_fd()), @@ -3119,11 +3039,6 @@ fn mapped_child_remove_file(parent: &MappedRuntimeParentPath) -> std::io::Result .map_err(errno_to_io) } -#[cfg(not(target_os = "macos"))] -fn mapped_child_symlink(parent: &MappedRuntimeParentPath, target: &str) -> std::io::Result<()> { - std::os::unix::fs::symlink(target, mapped_runtime_parent_child_path(parent)) -} -#[cfg(target_os = "macos")] fn mapped_child_symlink(parent: &MappedRuntimeParentPath, target: &str) -> std::io::Result<()> { nix::unistd::symlinkat( target, @@ -3133,11 +3048,6 @@ fn mapped_child_symlink(parent: &MappedRuntimeParentPath, target: &str) -> std:: .map_err(errno_to_io) } -#[cfg(not(target_os = "macos"))] -fn mapped_child_read_link(parent: &MappedRuntimeParentPath) -> std::io::Result { - fs::read_link(mapped_runtime_parent_child_path(parent)) -} -#[cfg(target_os = "macos")] fn mapped_child_read_link(parent: &MappedRuntimeParentPath) -> std::io::Result { nix::fcntl::readlinkat( Some(parent.directory.as_raw_fd()), @@ -3148,24 +3058,8 @@ fn mapped_child_read_link(parent: &MappedRuntimeParentPath) -> std::io::Result

Result<(), SidecarError> { - apply_host_path_utimens( - &mapped_runtime_parent_child_path(parent), - atime, - mtime, - false, - context, - ) -} -#[cfg(target_os = "macos")] +/// (lutimes), using an fd-relative `utimensat` anchored on the resolved parent +/// fd. fn apply_mapped_child_utimens( parent: &MappedRuntimeParentPath, atime: VirtualUtimeSpec, @@ -3215,24 +3109,59 @@ fn apply_mapped_child_utimens( .map_err(|error| SidecarError::Io(format!("{context}: failed to set times: {error}"))) } -#[cfg(not(target_os = "macos"))] -fn mapped_child_rename( - source: &MappedRuntimeParentPath, - destination: &MappedRuntimeParentPath, -) -> std::io::Result<()> { - rename_mapped_host_path_with_fallback( - &mapped_runtime_parent_child_path(source), - &mapped_runtime_parent_child_path(destination), - ) +/// Set access/modification times on an already-resolved (symlink-followed) +/// handle via fd-relative `futimens`. Used for the follow-symlink `utimes` path; +/// `Omit` reads the existing time from the same fd (`fstat`), preserving +/// nanosecond precision. +fn apply_anchored_fd_utimens( + handle: &AnchoredFd, + atime: VirtualUtimeSpec, + mtime: VirtualUtimeSpec, + context: &str, +) -> Result<(), SidecarError> { + let existing = match (atime, mtime) { + (VirtualUtimeSpec::Omit, _) | (_, VirtualUtimeSpec::Omit) => { + let stat = nix::sys::stat::fstat(handle.as_raw_fd()) + .map_err(|error| SidecarError::Io(format!("{context}: failed to stat: {error}")))?; + Some(( + VirtualTimeSpec { + sec: stat.st_atime, + nsec: stat.st_atime_nsec.max(0) as u32, + }, + VirtualTimeSpec { + sec: stat.st_mtime, + nsec: stat.st_mtime_nsec.max(0) as u32, + }, + )) + } + _ => None, + }; + let existing_atime = existing + .as_ref() + .map(|(atime, _)| *atime) + .unwrap_or(VirtualTimeSpec { sec: 0, nsec: 0 }); + let existing_mtime = existing + .as_ref() + .map(|(_, mtime)| *mtime) + .unwrap_or(VirtualTimeSpec { sec: 0, nsec: 0 }); + let times = [ + resolve_host_utime(atime, existing_atime), + resolve_host_utime(mtime, existing_mtime), + ]; + handle + .set_times(×[0], ×[1]) + .map_err(|error| SidecarError::Io(format!("{context}: failed to set times: {error}"))) } -#[cfg(target_os = "macos")] + fn mapped_child_rename( source: &MappedRuntimeParentPath, destination: &MappedRuntimeParentPath, ) -> std::io::Result<()> { // Same-filesystem rename is fd-relative (TOCTOU-safe). A cross-device rename - // (EXDEV) cannot be done fd-relative, so fall back to the path-based copy on - // the resolved real paths, exactly as the Linux fallback does. + // (EXDEV) cannot be done with `renameat`, so fall back to a copy+unlink — but + // still fd-relative, anchored on the CONFINED parent dir fds, never on + // `host_path` (a `confine::Resolved::real_path`, which is diagnostic-only and + // whose ancestors a concurrent guest could swap for an escaping symlink). match nix::fcntl::renameat( Some(source.directory.as_raw_fd()), source.child_name.as_os_str(), @@ -3240,9 +3169,11 @@ fn mapped_child_rename( destination.child_name.as_os_str(), ) { Ok(()) => Ok(()), - Err(Errno::EXDEV) => move_mapped_host_path_across_devices( - &source.host_path.join(&source.child_name), - &destination.host_path.join(&destination.child_name), + Err(Errno::EXDEV) => move_across_devices_at( + source.directory.fd.as_fd(), + source.child_name.as_os_str(), + destination.directory.fd.as_fd(), + destination.child_name.as_os_str(), ), Err(error) => Err(errno_to_io(error)), } @@ -3395,23 +3326,72 @@ fn mapped_host_open_is_writable(flags: u32) -> bool { || flags & libc::O_TRUNC as u32 != 0 } +fn mapped_runtime_exists_error(mapped: &MappedRuntimeHostPath, error: Errno) -> SidecarError { + if error == Errno::EXDEV { + return mapped_runtime_host_path_escape_error(mapped, &mapped.host_path); + } + SidecarError::Io(format!( + "failed to inspect mapped guest path {} -> {}: {}", + mapped.guest_path, + mapped.host_path.display(), + std::io::Error::from_raw_os_error(error as i32) + )) +} + +/// Confined existence check (lstat semantics) for a mapped guest path. Resolves +/// the PARENT strictly beneath the mapped root via the universal `confine` walk +/// (which refuses ancestor `..`/symlink escapes) and `lstat`s the leaf through +/// the anchored parent fd — never a path-based `fs::symlink_metadata`, whose +/// ancestor resolution a guest could redirect out of the mapped root by swapping +/// an ancestor for a symlink, leaking an out-of-root existence bit. A missing +/// leaf OR a missing/non-directory ancestor yields `Ok(false)`; an escape yields +/// a typed error. +fn mapped_runtime_host_path_exists(mapped: &MappedRuntimeHostPath) -> Result { + use crate::plugins::host_dir::confine; + + let relative = mapped_runtime_relative_path(mapped)?; + let leaf = match relative.file_name() { + Some(name) => name.to_os_string(), + // `.` is the mapped root itself: open it directly to test existence. + None => { + return match confine::resolve_dir_anchor_beneath(&mapped.host_root, Path::new(".")) { + Ok(_) => Ok(true), + Err(Errno::ENOENT) | Err(Errno::ENOTDIR) => Ok(false), + Err(error) => Err(mapped_runtime_exists_error(mapped, error)), + }; + } + }; + let parent_relative = relative + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + + let parent = match confine::resolve_dir_anchor_beneath(&mapped.host_root, &parent_relative) { + Ok(resolved) => resolved, + // A missing (or non-directory) ancestor means the leaf cannot exist yet. + Err(Errno::ENOENT) | Err(Errno::ENOTDIR) => return Ok(false), + Err(error) => return Err(mapped_runtime_exists_error(mapped, error)), + }; + match nix::sys::stat::fstatat( + Some(parent.fd.as_raw_fd()), + leaf.as_os_str(), + nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW, + ) { + Ok(_) => Ok(true), + Err(Errno::ENOENT) => Ok(false), + Err(error) => Err(mapped_runtime_exists_error(mapped, error)), + } +} + fn materialize_mapped_host_path_from_kernel( kernel: &mut SidecarKernel, kernel_pid: u32, guest_path: &str, mapped: &MappedRuntimeHostPath, ) -> Result<(), SidecarError> { - let host_path = &mapped.host_path; - match fs::symlink_metadata(host_path) { - Ok(_) => return Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(SidecarError::Io(format!( - "failed to inspect mapped host path for {} -> {}: {error}", - guest_path, - host_path.display() - ))); - } + if mapped_runtime_host_path_exists(mapped)? { + return Ok(()); } if !kernel @@ -3458,7 +3438,7 @@ fn materialize_mapped_host_path_from_kernel( OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_WRONLY, Mode::from_bits_truncate((stat.mode & 0o7777) as _), )?; - fs::write(opened.handle.proc_path(), bytes).map_err(|error| { + opened.handle.write_bytes(&bytes).map_err(|error| { SidecarError::Io(format!( "failed to materialize mapped guest file {} -> {}: {error}", guest_path, @@ -3469,59 +3449,32 @@ fn materialize_mapped_host_path_from_kernel( let opened = open_mapped_runtime_beneath(mapped, "fs.materialize", O_PATH_ANCHOR, Mode::empty())?; - fs::set_permissions( - opened.handle.proc_path(), - fs::Permissions::from_mode(stat.mode & 0o7777), - ) - .map_err(|error| { - SidecarError::Io(format!( - "failed to set permissions for materialized mapped guest path {} -> {}: {error}", - guest_path, - opened.host_path.display() - )) - })?; + opened + .handle + .set_mode(stat.mode & 0o7777) + .map_err(|error| { + SidecarError::Io(format!( + "failed to set permissions for materialized mapped guest path {} -> {}: {error}", + guest_path, + opened.host_path.display() + )) + })?; Ok(()) } +/// Register a persistent guest file handle backed by an already-resolved +/// mapped-host fd. The resolve-beneath open already applied the guest's access +/// mode and creation flags, so the owned fd is turned directly into a +/// [`std::fs::File`] — no path re-open, so there is no TOCTOU window and no +/// `/proc/self/fd` dependency. fn open_mapped_host_fd( process: &mut ActiveProcess, - host_path: PathBuf, + opened: MappedRuntimeOpenedPath, guest_path: Option, - proc_path: PathBuf, - flags: u32, ) -> Result { - let access_mode = flags & libc::O_ACCMODE as u32; - let mut options = OpenOptions::new(); - match access_mode { - x if x == libc::O_WRONLY as u32 => { - options.write(true); - } - x if x == libc::O_RDWR as u32 => { - options.read(true).write(true); - } - _ => { - options.read(true); - } - } - if flags & libc::O_APPEND as u32 != 0 { - options.append(true); - } - - let masked_flags = flags - & !(libc::O_ACCMODE as u32 - | libc::O_APPEND as u32 - | libc::O_CREAT as u32 - | libc::O_EXCL as u32 - | libc::O_TRUNC as u32); - options.custom_flags(masked_flags as i32); - - let file = options.open(&proc_path).map_err(|error| { - SidecarError::Io(format!( - "failed to open mapped guest file {}: {error}", - host_path.display() - )) - })?; + let host_path = opened.host_path; + let file = std::fs::File::from(opened.handle.into_owned_fd()); let fd = process.allocate_mapped_host_fd(crate::state::ActiveMappedHostFd { file, path: host_path, @@ -3684,64 +3637,204 @@ fn rename_mapped_host_path( } } -// On macOS the mapped rename is fd-relative (`renameat`); this path-based -// fallback is only used by the Linux mapped-rename helper. -#[cfg_attr(target_os = "macos", allow(dead_code))] -fn rename_mapped_host_path_with_fallback(source: &Path, destination: &Path) -> std::io::Result<()> { - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; - } - match fs::rename(source, destination) { - Ok(()) => Ok(()), - Err(error) if error.raw_os_error() == Some(libc::EXDEV) => { - move_mapped_host_path_across_devices(source, destination) - } - Err(error) => Err(error), - } -} +/// Cross-device move of `(src_dir, src_name)` to `(dst_dir, dst_name)` performed +/// entirely fd-relative against the CONFINED parent directory fds — copy then +/// unlink, recursing into directories with `openat(O_NOFOLLOW)` subdir fds. +/// +/// This replaces a path-based `fs::copy`/`fs::rename` fallback that operated on +/// `confine::Resolved::real_path` strings: those re-traverse from `/` and follow +/// any ancestor symlink, so a guest racing `rmdir a; ln -s /etc a` could redirect +/// the copy outside the mapped root. Anchoring every syscall on the pinned parent +/// fds (and `O_NOFOLLOW` on every `openat`) keeps the move strictly confined: +/// a leaf swapped to a symlink fails closed (`ELOOP`) rather than being followed, +/// except a genuine symlink leaf, which is recreated verbatim (never dereferenced). +fn move_across_devices_at( + src_dir: BorrowedFd<'_>, + src_name: &std::ffi::OsStr, + dst_dir: BorrowedFd<'_>, + dst_name: &std::ffi::OsStr, +) -> std::io::Result<()> { + move_across_devices_at_depth(src_dir, src_name, dst_dir, dst_name, 0) +} + +/// Maximum directory nesting a single cross-device move will descend. A hostile +/// guest can nest directories arbitrarily deep (fd-relative `mkdirat` is not +/// `PATH_MAX`-bounded), and unbounded recursion here would overflow the sidecar +/// thread stack — a SIGSEGV that aborts every co-tenant VM — or exhaust file +/// descriptors (two held per level). Bounded by default per the runtime's +/// resource-safety invariant; deeper trees fail with the typed error below. +const MAX_CROSS_DEVICE_MOVE_DEPTH: u32 = 256; + +fn move_across_devices_at_depth( + src_dir: BorrowedFd<'_>, + src_name: &std::ffi::OsStr, + dst_dir: BorrowedFd<'_>, + dst_name: &std::ffi::OsStr, + depth: u32, +) -> std::io::Result<()> { + use nix::sys::stat::SFlag; -fn move_mapped_host_path_across_devices(source: &Path, destination: &Path) -> std::io::Result<()> { - let metadata = fs::symlink_metadata(source)?; - remove_existing_mapped_host_destination(destination)?; - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; + if depth > MAX_CROSS_DEVICE_MOVE_DEPTH { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "cross-device move exceeded max directory depth {MAX_CROSS_DEVICE_MOVE_DEPTH} \ + (raise MAX_CROSS_DEVICE_MOVE_DEPTH to allow deeper trees)" + ), + )); } - if metadata.file_type().is_symlink() { - let target = fs::read_link(source)?; - symlink(&target, destination)?; - fs::remove_file(source)?; + let stat = nix::sys::stat::fstatat( + Some(src_dir.as_raw_fd()), + src_name, + nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW, + ) + .map_err(errno_to_io)?; + remove_dest_at(dst_dir, dst_name)?; + + let fmt = stat.st_mode & SFlag::S_IFMT.bits(); + let perm = Mode::from_bits_truncate((stat.st_mode & 0o7777) as _); + + if fmt == SFlag::S_IFLNK.bits() { + let target = + nix::fcntl::readlinkat(Some(src_dir.as_raw_fd()), src_name).map_err(errno_to_io)?; + nix::unistd::symlinkat(target.as_os_str(), Some(dst_dir.as_raw_fd()), dst_name) + .map_err(errno_to_io)?; + nix::unistd::unlinkat( + Some(src_dir.as_raw_fd()), + src_name, + nix::unistd::UnlinkatFlags::NoRemoveDir, + ) + .map_err(errno_to_io)?; return Ok(()); } - if metadata.is_dir() { - fs::create_dir_all(destination)?; - for entry in fs::read_dir(source)? { - let entry = entry?; - let source_child = entry.path(); - let destination_child = destination.join(entry.file_name()); - move_mapped_host_path_across_devices(&source_child, &destination_child)?; + if fmt == SFlag::S_IFDIR.bits() { + // Create the destination owner-writable/searchable so the non-root + // sidecar can populate it even when the source mode lacks owner + // write/exec (e.g. `0o555`); the exact source mode is restored by the + // trailing `fchmod` after all children are copied. + nix::sys::stat::mkdirat(Some(dst_dir.as_raw_fd()), dst_name, perm | Mode::S_IRWXU) + .map_err(errno_to_io)?; + let src_sub = open_child_beneath(src_dir, src_name, true)?; + let dst_sub = open_child_beneath(dst_dir, dst_name, true)?; + for (name, _kind) in + crate::plugins::host_dir::confine::read_dir(src_sub.as_fd()).map_err(errno_to_io)? + { + move_across_devices_at_depth( + src_sub.as_fd(), + &name, + dst_sub.as_fd(), + &name, + depth + 1, + )?; } - fs::set_permissions(destination, metadata.permissions())?; - fs::remove_dir(source)?; + // Restore the source directory's exact mode (mkdirat used a temporary + // owner-writable mode above, and applied the umask). + nix::sys::stat::fchmod(dst_sub.as_raw_fd(), perm).map_err(errno_to_io)?; + nix::unistd::unlinkat( + Some(src_dir.as_raw_fd()), + src_name, + nix::unistd::UnlinkatFlags::RemoveDir, + ) + .map_err(errno_to_io)?; return Ok(()); } - fs::copy(source, destination)?; - fs::set_permissions(destination, metadata.permissions())?; - fs::remove_file(source)?; + if fmt != SFlag::S_IFREG.bits() { + // Only regular files, directories, and symlinks are movable. Special + // files (FIFO/socket/device) cannot be created through this VFS (no + // `mknod`), so a node here was placed by the host operator; refuse it + // rather than block indefinitely on an `O_RDONLY` open of a FIFO. + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "cross-device move: unsupported non-regular file in mapped root", + )); + } + + // Regular file: stream the bytes fd→fd. + let src_fd = open_child_beneath(src_dir, src_name, false)?; + let dst_fd = rustix::fs::openat( + dst_dir, + dst_name, + rustix::fs::OFlags::WRONLY + | rustix::fs::OFlags::CREATE + | rustix::fs::OFlags::EXCL + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::from_bits_truncate((stat.st_mode & 0o7777) as _), + ) + .map_err(|error| std::io::Error::from_raw_os_error(error.raw_os_error()))?; + if let Err(error) = copy_fd_to_fd(src_fd.as_fd(), dst_fd.as_fd()) { + // Never leave a truncated destination behind on a failed move. This + // cleanup is best-effort; the ORIGINAL copy error is what propagates. + drop(dst_fd); + let _ = nix::unistd::unlinkat( + Some(dst_dir.as_raw_fd()), + dst_name, + nix::unistd::UnlinkatFlags::NoRemoveDir, + ); + return Err(error); + } + nix::unistd::unlinkat( + Some(src_dir.as_raw_fd()), + src_name, + nix::unistd::UnlinkatFlags::NoRemoveDir, + ) + .map_err(errno_to_io) +} + +/// `openat` a single child of `dir` with `O_NOFOLLOW` (fails closed with `ELOOP` +/// if the child is a symlink), returning an owned fd. `directory` opens it +/// `O_DIRECTORY | O_RDONLY`; otherwise `O_RDONLY`. +fn open_child_beneath( + dir: BorrowedFd<'_>, + name: &std::ffi::OsStr, + directory: bool, +) -> std::io::Result { + let mut flags = + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC; + if directory { + flags |= rustix::fs::OFlags::DIRECTORY; + } + rustix::fs::openat(dir, name, flags, rustix::fs::Mode::empty()) + .map_err(|error| std::io::Error::from_raw_os_error(error.raw_os_error())) +} + +/// Copy all bytes from `src` to `dst`, streaming through a fixed buffer (no whole +/// -file allocation), using fd `read`/`write`. +fn copy_fd_to_fd(src: BorrowedFd<'_>, dst: BorrowedFd<'_>) -> std::io::Result<()> { + let mut buf = [0_u8; 65536]; + loop { + let read = nix::unistd::read(src.as_raw_fd(), &mut buf).map_err(errno_to_io)?; + if read == 0 { + break; + } + write_all_to_fd(dst, &buf[..read])?; + } Ok(()) } -fn remove_existing_mapped_host_destination(path: &Path) -> std::io::Result<()> { - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => { - fs::remove_file(path) +/// Remove an existing destination entry (fd-relative, nofollow): a file or +/// symlink is unlinked, a directory is `rmdir`ed (fails if non-empty, matching +/// rename-replace semantics), a missing entry is a no-op. +fn remove_dest_at(dst_dir: BorrowedFd<'_>, name: &std::ffi::OsStr) -> std::io::Result<()> { + use nix::sys::stat::SFlag; + match nix::sys::stat::fstatat( + Some(dst_dir.as_raw_fd()), + name, + nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW, + ) { + Ok(stat) => { + let flags = if stat.st_mode & SFlag::S_IFMT.bits() == SFlag::S_IFDIR.bits() { + nix::unistd::UnlinkatFlags::RemoveDir + } else { + nix::unistd::UnlinkatFlags::NoRemoveDir + }; + nix::unistd::unlinkat(Some(dst_dir.as_raw_fd()), name, flags).map_err(errno_to_io) } - Ok(metadata) if metadata.is_dir() => fs::remove_dir(path), - Ok(_) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), + Err(Errno::ENOENT) => Ok(()), + Err(error) => Err(errno_to_io(error)), } } @@ -3749,29 +3842,35 @@ fn mapped_readdir_entry_is_directory( mapped_host: &MappedRuntimeHostPath, directory: &MappedRuntimeOpenedPath, guest_dir_path: &str, - entry: &fs::DirEntry, - name: &str, + name: &std::ffi::OsStr, + kind: crate::plugins::host_dir::confine::EntryKind, ) -> Option { - let file_type = entry.file_type().ok()?; - if !file_type.is_symlink() { - return Some(file_type.is_dir()); - } - - let child = MappedRuntimeHostPath { - guest_path: normalize_path(&format!( - "{}/{}", - guest_dir_path.trim_end_matches('/'), - name - )), - host_root: mapped_host.host_root.clone(), - host_path: directory.host_path.join(entry.file_name()), - }; - let opened = - open_mapped_runtime_beneath(&child, "fs.readdir entry", O_PATH_ANCHOR, Mode::empty()) + match kind { + crate::plugins::host_dir::confine::EntryKind::Directory => Some(true), + crate::plugins::host_dir::confine::EntryKind::Other => Some(false), + // A symlink entry is followed by re-resolving it beneath the same root + // (fd-anchored), then classifying the target via `fstat`. + crate::plugins::host_dir::confine::EntryKind::Symlink => { + let name_str = name.to_str()?; + let child = MappedRuntimeHostPath { + guest_path: normalize_path(&format!( + "{}/{}", + guest_dir_path.trim_end_matches('/'), + name_str + )), + host_root: mapped_host.host_root.clone(), + host_path: directory.host_path.join(name), + }; + let opened = open_mapped_runtime_beneath( + &child, + "fs.readdir entry", + O_PATH_ANCHOR, + Mode::empty(), + ) .ok()?; - fs::metadata(opened.handle.proc_path()) - .map(|metadata| metadata.is_dir()) - .ok() + opened.handle.metadata().map(|stat| stat.is_directory).ok() + } + } } pub(crate) fn service_javascript_fs_readdir_entries( @@ -3791,41 +3890,40 @@ pub(crate) fn service_javascript_fs_readdir_entries( Mode::empty(), ) { Ok(directory) => { - let readdir_path = directory.handle.readdir_path().map_err(|error| { - SidecarError::Io(format!( - "failed to resolve mapped guest directory {} -> {}: {error}", - path, - directory.host_path.display() - )) - })?; - for entry in fs::read_dir(readdir_path).map_err(|error| { - SidecarError::Io(format!( - "failed to read mapped guest directory {} -> {}: {error}", - path, - directory.host_path.display() - )) - })? { - let Ok(entry) = entry else { continue }; - let Ok(name) = entry.file_name().into_string() else { - continue; - }; + let entries = + crate::plugins::host_dir::confine::read_dir(directory.handle.fd.as_fd()) + .map_err(|error| { + SidecarError::Io(format!( + "failed to read mapped guest directory {} -> {}: {}", + path, + directory.host_path.display(), + std::io::Error::from_raw_os_error(error as i32) + )) + })?; + for (name, kind) in entries { if let Some(is_dir) = mapped_readdir_entry_is_directory( &mapped_host, &directory, path, - &entry, &name, + kind, ) { + let Ok(name) = name.into_string() else { + continue; + }; typed.insert(name, is_dir); } } } + // The host dir simply not existing yet is fine — fall through to the + // kernel VFS. Test existence through the confined walk (not a + // path-based `symlink_metadata`, whose ancestors a guest could + // redirect out of the mapped root); on a resolve error, keep the + // original readdir error rather than swallowing it. Err(_) - if matches!( - fs::symlink_metadata(&mapped_host.host_path), - Err(ref metadata_error) - if metadata_error.kind() == std::io::ErrorKind::NotFound - ) => {} + if mapped_runtime_host_path_exists(&mapped_host) + .map(|exists| !exists) + .unwrap_or(false) => {} Err(error) => return Err(error), } match kernel.read_dir_with_types_for_process(EXECUTION_DRIVER_NAME, kernel_pid, path) { @@ -4524,10 +4622,10 @@ fn ensure_guest_parent_dir(vm: &mut VmState, guest_path: &str) -> Result<(), Sid mod tests { use super::{ create_mapped_runtime_directory, create_mapped_runtime_root_directory, - mapped_runtime_relative_path, mapped_runtime_symlink_metadata, - materialize_mapped_host_path_from_kernel, open_mapped_runtime_parent_beneath, - read_mapped_runtime_link, rename_mapped_host_path, MappedRuntimeHostAccess, - MappedRuntimeHostPath, SidecarError, + mapped_runtime_host_path_exists, mapped_runtime_relative_path, + mapped_runtime_symlink_metadata, materialize_mapped_host_path_from_kernel, + move_across_devices_at, open_mapped_runtime_parent_beneath, read_mapped_runtime_link, + rename_mapped_host_path, MappedRuntimeHostAccess, MappedRuntimeHostPath, SidecarError, }; use crate::execution::javascript_sync_rpc_error_code; use crate::state::{SidecarKernel, EXECUTION_DRIVER_NAME, JAVASCRIPT_COMMAND}; @@ -4537,6 +4635,7 @@ mod tests { use agentos_kernel::permissions::Permissions; use agentos_kernel::vfs::MemoryFileSystem; use std::fs; + use std::os::fd::AsFd; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -4562,6 +4661,163 @@ mod tests { path } + // Exercises the fd-relative cross-device move (the EXDEV rename fallback): + // a nested tree (file with a preserved non-default mode, a relative symlink, + // and a subdirectory) is moved anchored on the parent dir fds, and the source + // is removed. Directly drives `move_across_devices_at` (renameat would not + // return EXDEV within one filesystem). + #[test] + fn move_across_devices_copies_tree_fd_relative_and_removes_source() { + let root = temp_dir("mapped-xdev-move"); + let src_parent = root.join("src"); + let dst_parent = root.join("dst"); + fs::create_dir_all(&src_parent).expect("src parent"); + fs::create_dir_all(&dst_parent).expect("dst parent"); + + let item = src_parent.join("item"); + fs::create_dir(&item).expect("item dir"); + fs::write(item.join("a.txt"), b"hello").expect("a.txt"); + fs::set_permissions(item.join("a.txt"), fs::Permissions::from_mode(0o640)) + .expect("chmod a.txt"); + std::os::unix::fs::symlink("a.txt", item.join("link")).expect("relative symlink"); + fs::create_dir(item.join("sub")).expect("sub dir"); + fs::write(item.join("sub/b.txt"), b"world").expect("b.txt"); + // A subdirectory whose non-default mode must be restored exactly on the + // destination after it is populated (the dest is created owner-writable + // during population, then fchmod'd back). + fs::create_dir(item.join("mode")).expect("mode dir"); + fs::write(item.join("mode/c.txt"), b"c").expect("c.txt"); + fs::set_permissions(item.join("mode"), fs::Permissions::from_mode(0o700)) + .expect("chmod mode dir"); + + let src_dir = fs::File::open(&src_parent).expect("open src parent dir"); + let dst_dir = fs::File::open(&dst_parent).expect("open dst parent dir"); + move_across_devices_at( + src_dir.as_fd(), + std::ffi::OsStr::new("item"), + dst_dir.as_fd(), + std::ffi::OsStr::new("moved"), + ) + .expect("cross-device move"); + + let moved = dst_parent.join("moved"); + assert_eq!( + fs::read(moved.join("a.txt")).expect("moved a.txt"), + b"hello" + ); + assert_eq!( + fs::symlink_metadata(moved.join("a.txt")) + .expect("moved a.txt meta") + .permissions() + .mode() + & 0o777, + 0o640, + "file mode must be preserved" + ); + assert_eq!( + fs::read_link(moved.join("link")).expect("moved link"), + PathBuf::from("a.txt"), + "relative symlink recreated verbatim" + ); + assert_eq!( + fs::read(moved.join("sub/b.txt")).expect("moved b.txt"), + b"world" + ); + assert_eq!( + fs::read(moved.join("mode/c.txt")).expect("moved mode/c.txt"), + b"c" + ); + assert_eq!( + fs::symlink_metadata(moved.join("mode")) + .expect("moved mode dir") + .permissions() + .mode() + & 0o777, + 0o700, + "the exact dir mode must be restored after population" + ); + assert!(!item.exists(), "source tree must be removed after the move"); + + fs::remove_dir_all(&root).expect("cleanup"); + } + + // The cross-device move must be depth-bounded: a hostile guest can nest + // directories arbitrarily deep, and unbounded recursion would overflow the + // sidecar stack (SIGSEGV). A tree past the limit fails closed with a typed, + // limit-naming error instead of crashing. + #[test] + fn move_across_devices_rejects_excessive_directory_depth() { + let root = temp_dir("mapped-xdev-depth"); + let src_parent = root.join("src"); + let dst_parent = root.join("dst"); + fs::create_dir_all(&src_parent).expect("src parent"); + fs::create_dir_all(&dst_parent).expect("dst parent"); + + let mut deep = src_parent.join("item"); + fs::create_dir(&deep).expect("item"); + for level in 0..300 { + deep.push(format!("d{level}")); + fs::create_dir(&deep).expect("nested dir"); + } + + let src_dir = fs::File::open(&src_parent).expect("open src parent"); + let dst_dir = fs::File::open(&dst_parent).expect("open dst parent"); + let error = move_across_devices_at( + src_dir.as_fd(), + std::ffi::OsStr::new("item"), + dst_dir.as_fd(), + std::ffi::OsStr::new("moved"), + ) + .expect_err("a tree deeper than the limit must be rejected"); + assert!( + error.to_string().contains("max directory depth"), + "expected a depth-limit error, got: {error}" + ); + + fs::remove_dir_all(&root).expect("cleanup"); + } + + // S2: the mapped existence check must NOT follow an ancestor symlink out of + // the mapped root. A path-based `symlink_metadata` would follow `a -> outside` + // and report the out-of-root file as existing (an existence-bit leak); the + // confined walk refuses the escape instead. + #[test] + fn mapped_runtime_exists_refuses_ancestor_symlink_escape() { + let root = temp_dir("mapped-exists-escape"); + let mapped_root = root.join("mapped"); + let outside = root.join("outside"); + fs::create_dir_all(&mapped_root).expect("mapped root"); + fs::create_dir_all(&outside).expect("outside dir"); + fs::write(outside.join("secret"), b"x").expect("outside secret"); + std::os::unix::fs::symlink(&outside, mapped_root.join("a")).expect("ancestor symlink"); + + let mapped = MappedRuntimeHostPath { + guest_path: "/a/secret".to_string(), + host_path: mapped_root.join("a").join("secret"), + host_root: mapped_root.clone(), + }; + let result = mapped_runtime_host_path_exists(&mapped); + assert!( + result.is_err(), + "ancestor-symlink escape must be refused (not followed to report the \ + out-of-root file as existing), got {result:?}" + ); + + // A legitimate in-root path resolves without following anything outside. + fs::write(mapped_root.join("real.txt"), b"y").expect("in-root file"); + let in_root = MappedRuntimeHostPath { + guest_path: "/real.txt".to_string(), + host_path: mapped_root.join("real.txt"), + host_root: mapped_root.clone(), + }; + assert!( + mapped_runtime_host_path_exists(&in_root).expect("in-root exists check"), + "an in-root path must be reported as existing" + ); + + fs::remove_dir_all(&root).expect("cleanup"); + } + fn test_kernel_with_process() -> (SidecarKernel, u32) { let mut config = KernelVmConfig::new("vm-mapped-materialize"); config.permissions = Permissions::allow_all(); @@ -4952,8 +5208,8 @@ mod tests { // Companion to the kernel-VFS test above, but resolving through the // `HostDirModuleReader` — the bridge-thread reader the live VM uses so module // resolution runs concurrently with the service loop instead of serializing - // behind it. It reads the SAME read-only `host_dir` mount (anchored openat2, - // escaping-symlink refusal) and must resolve the identical pnpm layout to the + // behind it. It reads the SAME read-only `host_dir` mount (anchored + // resolve-beneath, escaping-symlink refusal) and must resolve the identical pnpm layout to the // identical guest path, with no `.pnpm` scanning and the symlink-pointed // version winning over the decoy. #[test] diff --git a/crates/native-sidecar/src/lib.rs b/crates/native-sidecar/src/lib.rs index 9c2f2131ab..9ec9638234 100644 --- a/crates/native-sidecar/src/lib.rs +++ b/crates/native-sidecar/src/lib.rs @@ -12,8 +12,6 @@ pub(crate) mod filesystem; #[allow(dead_code)] pub(crate) mod json_rpc; pub mod limits; -#[cfg(target_os = "macos")] -pub(crate) mod macos_fs; pub(crate) mod metadata; pub mod package_projection; pub(crate) mod plugins; diff --git a/crates/native-sidecar/src/macos_fs.rs b/crates/native-sidecar/src/macos_fs.rs deleted file mode 100644 index 90591f4506..0000000000 --- a/crates/native-sidecar/src/macos_fs.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! macOS host-mount confinement shims. -//! -//! The Linux host-mount filesystem confinement relies on three primitives that -//! do not exist on macOS: -//! * `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` — atomic resolve-beneath -//! path resolution (the escape boundary for host-backed mounts), -//! * `O_PATH` — a metadata-only anchor fd, -//! * `/proc/self/fd/N` — re-deriving a path/handle from an fd. -//! -//! This module provides the macOS equivalents: -//! * [`resolve_beneath`] resolves a guest-supplied relative path strictly -//! beneath the mount root using `cap-std`, whose audited userspace walk -//! (fd-relative, per-hop, symlink- and `..`-refusing) reproduces the -//! escape guarantee `openat2(RESOLVE_BENEATH)` gives atomically on Linux. -//! * [`fd_real_path`] uses `fcntl(F_GETPATH)` in place of -//! `readlink("/proc/self/fd/N")` to recover an fd's real host path. -//! -//! `O_PATH` is mapped to a read-only anchor (`O_RDONLY`) at the call sites, and -//! `/proc/self/fd/N` to `/dev/fd/N` in `AnchoredFd::proc_path`. - -use cap_std::ambient_authority; -use cap_std::fs::{Dir, OpenOptions, OpenOptionsExt}; -use nix::errno::Errno; -use nix::fcntl::{fcntl, FcntlArg, OFlag}; -use nix::sys::stat::Mode; -use std::io; -use std::os::fd::{IntoRawFd, RawFd}; -use std::path::{Path, PathBuf}; - -/// Resolve `relative` strictly beneath `root` and open it, returning an owned -/// raw fd. macOS counterpart to -/// `openat2(root, relative, RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`. -/// -/// `cap-std` guarantees the resolution never escapes `root` (via `..`, an -/// absolute symlink, or a symlink whose target leaves the tree), refusing such -/// attempts with an errno-less `PermissionDenied` that [`io_to_errno`] maps to -/// `EXDEV` — matching how callers already treat `openat2`'s escape error. -/// -/// `O_PATH` anchors arrive here as `O_RDONLY` (macOS has no metadata-only open); -/// the resulting fd is still only used as an anchor / re-opened via `/dev/fd/N`. -pub(crate) fn resolve_beneath( - root: &Path, - relative: &Path, - flags: OFlag, - mode: Mode, -) -> Result { - let dir = Dir::open_ambient_dir(root, ambient_authority()).map_err(io_to_errno)?; - - // Directory handles: `open_dir` is cap-std's resolve-beneath `O_DIRECTORY` - // open and returns a `Dir` we can hand back as a raw fd. - if flags.contains(OFlag::O_DIRECTORY) { - let sub = dir.open_dir(relative).map_err(io_to_errno)?; - return Ok(sub.into_raw_fd()); - } - - let acc = flags & OFlag::O_ACCMODE; - let write = acc == OFlag::O_WRONLY || acc == OFlag::O_RDWR; - - let mut opts = OpenOptions::new(); - // A pure anchor (O_PATH mapped to O_RDONLY) and any read/RDWR open needs - // read; ensure we never request neither read nor write (cap-std rejects it). - opts.read(!write) - .write(write) - .create(flags.contains(OFlag::O_CREAT)) - .create_new(flags.contains(OFlag::O_EXCL)) - .truncate(flags.contains(OFlag::O_TRUNC)); - if acc == OFlag::O_RDWR { - opts.read(true); - } - if flags.contains(OFlag::O_APPEND) { - opts.append(true); - } - opts.mode(u32::from(mode.bits())); - // Preserve a caller's request not to follow the final component. cap-std - // refuses *escaping* symlinks regardless; this additionally refuses a - // non-escaping final symlink, matching O_NOFOLLOW semantics. - if flags.contains(OFlag::O_NOFOLLOW) { - opts.custom_flags(OFlag::O_NOFOLLOW.bits()); - } - - let file = dir.open_with(relative, &opts).map_err(io_to_errno)?; - Ok(file.into_raw_fd()) -} - -/// Real filesystem path of an open fd via `fcntl(F_GETPATH)` — the macOS -/// counterpart to `readlink("/proc/self/fd/N")`. Uses nix's safe wrapper so the -/// sidecar crate's `#![forbid(unsafe_code)]` holds. -pub(crate) fn fd_real_path(fd: RawFd) -> io::Result { - let mut path = PathBuf::new(); - fcntl(fd, FcntlArg::F_GETPATH(&mut path)) - .map_err(|errno| io::Error::from_raw_os_error(errno as i32))?; - Ok(path) -} - -/// Map a `cap-std` filesystem error to an `Errno`. A resolve-beneath escape is -/// reported by cap-std as an errno-less `PermissionDenied`; translate that to -/// `EXDEV` so callers reuse their existing "path escapes mount" handling. -fn io_to_errno(error: io::Error) -> Errno { - if let Some(raw) = error.raw_os_error() { - Errno::from_raw(raw) - } else if error.kind() == io::ErrorKind::PermissionDenied { - Errno::EXDEV - } else { - Errno::EIO - } -} diff --git a/crates/native-sidecar/src/plugins/host_dir.rs b/crates/native-sidecar/src/plugins/host_dir.rs index 434f110831..ed3c2f7c07 100644 --- a/crates/native-sidecar/src/plugins/host_dir.rs +++ b/crates/native-sidecar/src/plugins/host_dir.rs @@ -1,15 +1,13 @@ use nix::errno::Errno; -#[cfg(not(target_os = "macos"))] -use nix::fcntl::{openat2, OpenHow, ResolveFlag}; use nix::fcntl::{readlinkat, renameat, AtFlags, OFlag}; use nix::libc; -// macOS has no `O_PATH` (metadata-only anchor fd). The host-mount code only uses -// O_PATH fds as anchors that are re-opened via `/dev/fd/N`, so a read-only open -// is an adequate stand-in there; the real access mode is applied on re-open. -#[cfg(not(target_os = "macos"))] -const O_PATH_ANCHOR: OFlag = OFlag::O_PATH; -#[cfg(target_os = "macos")] +// The universal resolver (the `confine` module in this file) never returns a +// metadata-only `O_PATH` +// handle (macOS has no `O_PATH`, and gVisor does not honor it as an anchor); a +// read-only open stands in as the metadata anchor and every operation is +// performed fd-relative (`fstat`/`fchmod`/`fchown`/`futimens`), so `O_RDONLY` is +// the portable anchor open mode. const O_PATH_ANCHOR: OFlag = OFlag::O_RDONLY; use agentos_execution::{ GuestModuleReader, LocalModuleResolutionCache, ModuleFsReader, ModuleResolveMode, @@ -26,77 +24,560 @@ use agentos_kernel::vfs::{ normalize_path, VfsError, VfsResult, VirtualDirEntry, VirtualFileSystem, VirtualStat, VirtualTimeSpec, VirtualUtimeSpec, }; -use nix::sys::stat::{fstatat, mkdirat, utimensat, Mode, SFlag, UtimensatFlags}; +use nix::sys::stat::{fchmod, fstat, fstatat, mkdirat, utimensat, Mode, SFlag, UtimensatFlags}; use nix::sys::time::TimeSpec; -use nix::unistd::{chown, linkat, symlinkat, unlinkat, Gid, Uid, UnlinkatFlags}; +use nix::unistd::{fchownat, linkat, symlinkat, unlinkat, Gid, Uid, UnlinkatFlags}; use serde::Deserialize; use std::fs::{self, File}; use std::io::{self, Read, Write}; -use std::os::fd::{AsRawFd, RawFd}; -use std::os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd}; +use std::os::unix::fs::{FileExt, MetadataExt}; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use vfs::posix::TarFileSystem; const MAX_HOST_DIR_READ_BYTES: usize = DEFAULT_MAX_PREAD_BYTES; -#[derive(Debug)] -struct AnchoredFd { - fd: RawFd, -} +/// Host-mount confinement: one universal resolve-beneath implementation. +/// +/// Host-backed mounts (both `host_dir` plugin mounts and the mapped-runtime +/// host paths in [`crate::filesystem`]) expose real host directories to guest +/// code. Every guest-supplied path must resolve *strictly beneath* its mount +/// root: `..` must never ascend above the root and symlinks (relative or +/// absolute) must never escape it. This module is that escape boundary, so it is +/// security-critical. +/// +/// # Why there is no `openat2(RESOLVE_BENEATH)` +/// +/// Linux offers `openat2(dirfd, path, RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, +/// which performs an atomic, kernel-enforced resolve-beneath open. We used to +/// rely on it on Linux and fall back to a cap-std walk on macOS (which has no +/// such syscall). That split is deleted. +/// +/// `openat2` is **not usable as our portable primitive**: +/// +/// * **gVisor / runsc (Rivet Compute) does not support it.** Under gVisor the +/// `openat2(RESOLVE_BENEATH)` resolve fails, so a guest `chdir`/`stat` into +/// a host mount returns `ENOENT` even though the path exists. This is the +/// bug that motivated deleting it: it passes under runc (a real kernel) and +/// fails under runsc (a foreign kernel). +/// * **macOS has no `openat2` at all**, which is why a second implementation +/// existed in the first place. +/// +/// Maintaining two implementations of a security boundary doubles the surface +/// for escape bugs. Instead this module implements a single manual +/// resolve-beneath walk that runs identically on Linux, macOS, and gVisor: +/// +/// * It descends one component at a time with plain `openat(2)` (via +/// [`rustix`], which returns an owned fd without `unsafe`, satisfying this +/// crate's `#![forbid(unsafe_code)]`), anchoring every hop on the parent's +/// file descriptor. `openat(2)` is universally supported, including gVisor. +/// * `..` is resolved by popping an ancestor-fd stack we keep ourselves — we +/// never ask the kernel to resolve `..` (that would be racy under directory +/// renames). This is what makes the walk escape-safe under concurrent +/// mutation of the mount contents by guest code. Intermediate directories +/// are opened `O_RDONLY` as traversal anchors; a search-only directory +/// (execute without read for a non-root sidecar uid) falls back to an +/// `O_PATH` anchor on Linux (see [`open_dir_anchor`]) — a *traversal* anchor +/// only, never a metadata/leaf anchor. +/// * Every real `openat` uses `O_NOFOLLOW`, so a component that is swapped for +/// a symlink after we `lstat` it fails closed with `ELOOP` instead of being +/// followed. Symlinks are instead expanded manually and re-resolved beneath +/// the root; absolute symlink targets and absolute components are rejected +/// as escapes (matching `RESOLVE_BENEATH`). +/// * Results are used as file descriptors, never as recovered path strings, so +/// there is no `/proc/self/fd/N`, `/dev/fd/N`, or `fcntl(F_GETPATH)` +/// dependency. The [`Resolved::real_path`] we return is *diagnostic / +/// logical only* (error messages, best-effort realpath) and must never be +/// re-opened as an authority handle. +/// +/// This is not byte-for-byte identical to `openat2` under pathological rename +/// races (the kernel may return `EAGAIN`/`EXDEV` where our fd-anchored walk sees +/// a valid sequence of states), but for our threat model — guest-controlled +/// mount contents, symlink swaps, concurrent guest mutation — it provides the +/// same confinement guarantee. +pub(crate) mod confine { + use nix::errno::Errno; + use nix::fcntl::OFlag; + use nix::sys::stat::Mode; + use rustix::fs::{self as rfs, AtFlags, FileType, OFlags}; + use std::ffi::{OsStr, OsString}; + use std::os::fd::{BorrowedFd, OwnedFd}; + use std::os::unix::ffi::OsStrExt; + use std::path::{Component, Path, PathBuf}; + + /// Maximum number of symlinks expanded during a single resolution, matching + /// the conventional `MAXSYMLINKS`/`ELOOP` budget. + const MAX_SYMLINK_EXPANSIONS: u32 = 40; + + /// A path resolved strictly beneath a mount root. + #[derive(Debug)] + pub(crate) struct Resolved { + /// Owned file descriptor for the resolved object. This is the authority + /// handle: operate on it (fd-relative `*at` calls, `fstat`, + /// `read`/`write`, `File::from`), never on [`Resolved::real_path`]. + pub(crate) fd: OwnedFd, + /// The real host path the walk arrived at. **Diagnostic / logical only** + /// — it is only accurate in a quiescent filesystem, so it must not be + /// re-opened as an authority handle. Safe to use for error messages and + /// as a seed for re-resolving children beneath the same root. + pub(crate) real_path: PathBuf, + } + + /// A path resolved strictly beneath a mount root down to its *parent + /// directory*, WITHOUT opening the final component. Returned by + /// [`resolve_parent_beneath`] so metadata syscalls run fd-relative against + /// `(parent, name)` (`fstatat`/`fchownat`/`utimensat`) instead of opening the + /// leaf `O_RDONLY` — a leaf open would require READ permission that POSIX does + /// not require for these ops, which regresses a non-root sidecar (see the + /// module guardrail). + #[derive(Debug)] + pub(crate) struct ResolvedParent { + /// Owned fd for the parent directory of the resolved object (or the + /// resolved directory itself when `name` is `None`). Confined beneath the + /// root and anchored per-hop with `O_NOFOLLOW`, so a `*at` call against it + /// cannot traverse outside the mount. + pub(crate) parent: OwnedFd, + /// Final component name to pass to the `*at` syscall. `None` when the path + /// resolved to the root/an ancestor directory itself (no final + /// component); operate on `parent` directly (e.g. `fstat`). + pub(crate) name: Option, + } + + /// What the walk does with the final resolved component. + #[derive(Clone, Copy)] + enum Leaf { + /// Open the final component with these flags/mode; yields [`Resolved`]. + Open(OFlags, rfs::Mode), + /// Open the final component as a DIRECTORY traversal anchor via + /// [`open_dir_anchor`] (`O_DIRECTORY | O_RDONLY`, with the Linux `O_PATH` + /// fallback for a search-only dir); yields [`Resolved`]. Used to open a + /// parent directory purely as an `*at` anchor (never to `readdir` it), so + /// a search-only parent does not spuriously fail `EACCES`. + OpenDirAnchor, + /// Do not open the final component; yield its parent dir fd + name + /// ([`ResolvedParent`]). The final symlink is followed (stat semantics), + /// so callers see the real target's parent. + Parent, + } + + enum Resolution { + Opened { + fd: OwnedFd, + real_path: PathBuf, + }, + Parent { + parent: OwnedFd, + name: Option, + }, + } -impl AnchoredFd { - #[cfg(not(target_os = "macos"))] - fn proc_path(&self) -> PathBuf { - PathBuf::from(format!("/proc/self/fd/{}", self.fd)) + /// Directory entry classification returned by [`read_dir`], derived from the + /// directory's own file descriptor. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum EntryKind { + Directory, + Symlink, + Other, } - // macOS exposes per-fd paths under `/dev/fd/N` (the kernel dups the fd), - // serving the same role as Linux's `/proc/self/fd/N` for re-opening a - // *file* fd. Unlike `/proc/self/fd/N` it is NOT a readdir-able directory, - // so directory enumeration goes through [`readdir_path`]; child mutations - // use the fd-relative `*at` syscalls below. - #[cfg(target_os = "macos")] - fn proc_path(&self) -> PathBuf { - PathBuf::from(format!("/dev/fd/{}", self.fd)) + /// An owned path segment used by the resolver work stack. Owning the + /// segments lets us splice expanded symlink targets in front of the + /// remaining path. + enum Segment { + Current, + Parent, + Name(OsString), } - // Path to enumerate this fd's directory entries. Linux can `readdir` - // `/proc/self/fd/N` directly; macOS `/dev/fd/N` yields `ENOTDIR`, so recover - // the fd's real host path via `fcntl(F_GETPATH)` (see [`anchored_fd_real_path`]). - #[cfg(not(target_os = "macos"))] - fn readdir_path(&self) -> io::Result { - Ok(self.proc_path()) + fn errno(err: rustix::io::Errno) -> Errno { + Errno::from_raw(err.raw_os_error()) } - #[cfg(target_os = "macos")] - fn readdir_path(&self) -> io::Result { - anchored_fd_real_path(self) + + fn nix_oflag_to_rustix(flags: OFlag) -> OFlags { + // Preserve every caller-set bit (no silent truncation); the resolver + // adds `NOFOLLOW`/`CLOEXEC` itself and inspects the well-known + // access/creation bits below. + OFlags::from_bits_retain(flags.bits() as u32) } -} -impl AsRawFd for AnchoredFd { - fn as_raw_fd(&self) -> RawFd { - self.fd + fn nix_mode_to_rustix(mode: Mode) -> rfs::Mode { + rfs::Mode::from_bits_retain(mode.bits()) } -} -impl Drop for AnchoredFd { - fn drop(&mut self) { - let _ = nix::unistd::close(self.fd); + /// Push the components of `path` onto `stack` so that the first component is + /// popped first. Returns `EXDEV` if the path is absolute (an escape). + fn push_path_segments(stack: &mut Vec, path: &Path) -> Result<(), Errno> { + let mut segments = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => segments.push(Segment::Current), + Component::ParentDir => segments.push(Segment::Parent), + Component::Normal(name) => segments.push(Segment::Name(name.to_os_string())), + // An absolute path or Windows-style prefix escapes the root; + // this is exactly what `RESOLVE_BENEATH` rejects. + Component::RootDir | Component::Prefix(_) => return Err(Errno::EXDEV), + } + } + // Reverse so `Vec::pop` yields the leftmost component first. + stack.extend(segments.into_iter().rev()); + Ok(()) + } + + fn open_root(root: &Path) -> Result { + // The mount root is a trusted, configured host path; follow it normally + // but require it to be a directory. + match rfs::open( + root, + OFlags::DIRECTORY | OFlags::RDONLY | OFlags::CLOEXEC, + rfs::Mode::empty(), + ) { + Ok(fd) => Ok(fd), + // A search-only root (execute without read for the sidecar uid) can + // still be traversed via an `O_PATH` anchor on Linux; see + // [`open_dir_anchor`]. + #[cfg(target_os = "linux")] + Err(rustix::io::Errno::ACCESS) => rfs::open( + root, + OFlags::DIRECTORY | OFlags::PATH | OFlags::CLOEXEC, + rfs::Mode::empty(), + ) + .map_err(errno), + Err(err) => Err(errno(err)), + } + } + + /// Open an INTERMEDIATE directory to use as a *traversal anchor* — never for + /// reading its entries. Prefers `O_RDONLY`; a search-only directory (mode + /// `0711`/`0111`: execute without read for the sidecar uid) cannot be opened + /// `O_RDONLY`, so on Linux fall back to `O_PATH`, which needs only search + /// access yet still works as an `openat`/`*at` dirfd anchor — restoring the + /// traversal `openat2`/kernel-walk gave a non-root sidecar. macOS has no + /// `O_PATH` and keeps `O_RDONLY`-only behaviour (its pre-existing limitation). + /// + /// This `O_PATH` is a directory *traversal* anchor ONLY: it is used solely as + /// the `dirfd` argument to `statat`/`openat`/`readlinkat`, never + /// `read`/`readdir`/`fchmod`, so the module's metadata-anchor prohibition + /// still holds. `O_NOFOLLOW` is added by [`open_child`] so a component swapped + /// to a symlink after we `lstat`ed it fails closed. + fn open_dir_anchor(parent: &OwnedFd, name: &OsStr) -> Result { + match open_child( + parent, + name, + OFlags::DIRECTORY | OFlags::RDONLY, + rfs::Mode::empty(), + ) { + #[cfg(target_os = "linux")] + Err(Errno::EACCES) => open_child( + parent, + name, + OFlags::DIRECTORY | OFlags::PATH, + rfs::Mode::empty(), + ), + other => other, + } + } + + /// Resolve `relative` strictly beneath `root` and open it with `flags`/`mode`. + /// + /// This is the universal replacement for `openat2(root, relative, + /// RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`. `flags`/`mode` are the flags + /// for the *final* component (intermediate directories are always opened + /// `O_DIRECTORY | O_RDONLY | O_NOFOLLOW`, falling back to `O_PATH` for a + /// search-only dir; see [`open_dir_anchor`]). An attempt to escape the root + /// (via `..`, an absolute component, or an escaping symlink) fails with + /// `EXDEV`, matching the kernel's `RESOLVE_BENEATH` behaviour. + pub(crate) fn resolve_beneath( + root: &Path, + relative: &Path, + flags: OFlag, + mode: Mode, + ) -> Result { + match resolve_impl( + root, + relative, + Leaf::Open(nix_oflag_to_rustix(flags), nix_mode_to_rustix(mode)), + )? { + Resolution::Opened { fd, real_path } => Ok(Resolved { fd, real_path }), + Resolution::Parent { .. } => { + unreachable!("Leaf::Open always yields Resolution::Opened") + } + } + } + + /// Resolve `relative` strictly beneath `root` to a DIRECTORY fd, opened as a + /// traversal anchor via [`open_dir_anchor`] (so a search-only final dir uses + /// the Linux `O_PATH` fallback instead of failing `EACCES`). The result is + /// only safe to use as an `*at` dirfd anchor, never to `readdir`. + pub(crate) fn resolve_dir_anchor_beneath( + root: &Path, + relative: &Path, + ) -> Result { + match resolve_impl(root, relative, Leaf::OpenDirAnchor)? { + Resolution::Opened { fd, real_path } => Ok(Resolved { fd, real_path }), + Resolution::Parent { .. } => { + unreachable!("Leaf::OpenDirAnchor always yields Resolution::Opened") + } + } + } + + /// Resolve `relative` strictly beneath `root`, following the final symlink + /// (stat semantics), and return the resolved object's *parent* directory fd + /// plus its final name — WITHOUT opening the leaf. Metadata callers then run + /// `fstatat`/`fchownat`/`utimensat` against `(parent, name)`, which needs only + /// search access on the parent, not read on the leaf. See [`ResolvedParent`]. + pub(crate) fn resolve_parent_beneath( + root: &Path, + relative: &Path, + ) -> Result { + match resolve_impl(root, relative, Leaf::Parent)? { + Resolution::Parent { parent, name } => Ok(ResolvedParent { parent, name }), + Resolution::Opened { .. } => { + unreachable!("Leaf::Parent always yields Resolution::Parent") + } + } + } + + /// The one universal resolve-beneath walk shared by [`resolve_beneath`] and + /// [`resolve_parent_beneath`] — a single security-critical implementation, per + /// the module guardrail. `leaf` selects whether the final component is opened + /// or returned as `(parent, name)`. + fn resolve_impl(root: &Path, relative: &Path, leaf: Leaf) -> Result { + // For `Leaf::Parent` the final component is followed like `stat`, so use + // empty flags (no `NOFOLLOW`/`O_EXCL`) for the follow decision. + let rflags = match leaf { + Leaf::Open(flags, _) => flags, + // Both follow the final symlink (no `NOFOLLOW`/`O_EXCL`); a parent + // dir path may legitimately end in a symlink-to-directory. + Leaf::OpenDirAnchor => OFlags::DIRECTORY | OFlags::RDONLY, + Leaf::Parent => OFlags::empty(), + }; + + // Ancestor fd stack: `dirs[0]` is the root, `dirs.last()` is the + // directory we are currently resolving relative to. `..` pops this stack + // instead of asking the kernel to resolve `..`, which would be unsafe + // under renames. + let mut dirs: Vec = vec![open_root(root)?]; + let mut real_path = root.to_path_buf(); + + let mut stack: Vec = Vec::new(); + push_path_segments(&mut stack, relative)?; + + let mut symlink_expansions: u32 = 0; + + while let Some(segment) = stack.pop() { + let name = match segment { + Segment::Current => continue, + Segment::Parent => { + if dirs.len() == 1 { + // `..` at the root escapes. + return Err(Errno::EXDEV); + } + dirs.pop(); + real_path.pop(); + continue; + } + Segment::Name(name) => name, + }; + + let is_last = stack.is_empty(); + // The final component does not follow a symlink when the caller + // asked for `O_NOFOLLOW`, or when `O_CREAT | O_EXCL` is set (POSIX + // requires `O_EXCL` to fail on an existing final symlink rather than + // follow it). + let final_no_follow = + rflags.contains(OFlags::NOFOLLOW) || rflags.contains(OFlags::EXCL | OFlags::CREATE); + let follow_symlink = !(is_last && final_no_follow); + + let parent = dirs.last().expect("dirs is never empty"); + + match rfs::statat(parent, name.as_os_str(), AtFlags::SYMLINK_NOFOLLOW) { + Ok(stat) => { + let is_symlink = FileType::from_raw_mode(stat.st_mode) == FileType::Symlink; + if is_symlink && follow_symlink { + symlink_expansions += 1; + if symlink_expansions > MAX_SYMLINK_EXPANSIONS { + return Err(Errno::ELOOP); + } + let target = read_link_at(parent, name.as_os_str())?; + if target.is_absolute() { + // Absolute symlink target escapes the root. + return Err(Errno::EXDEV); + } + // Splice the target's components in front of the + // remaining path, to be resolved from the symlink's own + // directory. + push_path_segments(&mut stack, &target)?; + continue; + } + + if is_last { + return match leaf { + Leaf::Open(flags, mode) => { + let fd = open_child(parent, name.as_os_str(), flags, mode)?; + real_path.push(&name); + Ok(Resolution::Opened { fd, real_path }) + } + Leaf::OpenDirAnchor => { + let fd = open_dir_anchor(parent, name.as_os_str())?; + real_path.push(&name); + Ok(Resolution::Opened { fd, real_path }) + } + Leaf::Parent => { + let parent = dirs.pop().expect("dirs is never empty"); + Ok(Resolution::Parent { + parent, + name: Some(name), + }) + } + }; + } + + let child = open_dir_anchor(parent, name.as_os_str())?; + dirs.push(child); + real_path.push(&name); + } + Err(rustix::io::Errno::NOENT) if is_last => { + if let Leaf::Open(flags, mode) = leaf { + if flags.contains(OFlags::CREATE) { + // Creating the final component. + let fd = open_child(parent, name.as_os_str(), flags, mode)?; + real_path.push(&name); + return Ok(Resolution::Opened { fd, real_path }); + } + } + return Err(Errno::ENOENT); + } + Err(err) => return Err(errno(err)), + } + } + + // The path was empty or `.`/`..`-only and resolved to a directory + // already on the stack (typically the root). Return that directory. + let fd = dirs.pop().expect("dirs is never empty"); + match leaf { + Leaf::Open(_, _) | Leaf::OpenDirAnchor => Ok(Resolution::Opened { fd, real_path }), + Leaf::Parent => Ok(Resolution::Parent { + parent: fd, + name: None, + }), + } + } + + /// Open a single already-validated child component. Always adds `O_NOFOLLOW` + /// (so a component swapped to a symlink after we `lstat`ed it fails closed) + /// and `O_CLOEXEC`. + fn open_child( + parent: &OwnedFd, + name: &OsStr, + flags: OFlags, + mode: rfs::Mode, + ) -> Result { + rfs::openat( + parent, + name, + flags | OFlags::NOFOLLOW | OFlags::CLOEXEC, + mode, + ) + .map_err(errno) + } + + fn read_link_at(dirfd: &OwnedFd, name: &OsStr) -> Result { + let target = rfs::readlinkat(dirfd, name, Vec::new()).map_err(errno)?; + Ok(PathBuf::from( + OsStr::from_bytes(target.as_bytes()).to_os_string(), + )) + } + + /// Enumerate the entries of an open directory fd, classifying each without a + /// path-based re-walk. Directory entries whose `d_type` is unknown are + /// resolved with an fd-anchored `fstatat`, so enumeration stays confined to + /// the directory file descriptor (never a recovered path string). `.` and + /// `..` are omitted. + pub(crate) fn read_dir(dir: BorrowedFd<'_>) -> Result, Errno> { + let iter = rfs::Dir::read_from(dir).map_err(errno)?; + let mut entries = Vec::new(); + for entry in iter { + let entry = entry.map_err(errno)?; + let name = entry.file_name(); + if name.to_bytes() == b"." || name.to_bytes() == b".." { + continue; + } + let name = OsStr::from_bytes(name.to_bytes()).to_os_string(); + let kind = match entry.file_type() { + FileType::Directory => EntryKind::Directory, + FileType::Symlink => EntryKind::Symlink, + FileType::Unknown => { + // Resolve via the directory fd (fd-anchored, no path re-walk). + match rfs::statat(dir, name.as_os_str(), AtFlags::SYMLINK_NOFOLLOW) { + Ok(stat) => match FileType::from_raw_mode(stat.st_mode) { + FileType::Directory => EntryKind::Directory, + FileType::Symlink => EntryKind::Symlink, + _ => EntryKind::Other, + }, + // A classification `statat` can fail for benign, + // non-fault reasons: the entry vanished after `readdir` + // yielded it (`ENOENT`), or the directory is + // readable-but-not-searchable so the type simply cannot be + // determined (`EACCES`/`EPERM` — common on `DT_UNKNOWN` + // filesystems like the gVisor gofer). Treat those as + // `Other` (unknown type) so a plain listing still returns + // the names. Any OTHER errno is a real fault and must + // propagate rather than be swallowed into a misclassification. + Err( + rustix::io::Errno::NOENT + | rustix::io::Errno::ACCESS + | rustix::io::Errno::PERM, + ) => EntryKind::Other, + Err(err) => return Err(errno(err)), + } + } + _ => EntryKind::Other, + }; + entries.push((name, kind)); + } + Ok(entries) } } -/// Recover the real host path an anchored fd points at. Linux reads the magic -/// symlink `/proc/self/fd/N`; macOS uses `fcntl(F_GETPATH)` (see -/// [`crate::macos_fs::fd_real_path`]). -#[cfg(not(target_os = "macos"))] -fn anchored_fd_real_path(fd: &AnchoredFd) -> io::Result { - fs::read_link(fd.proc_path()) +/// An owned file descriptor resolved strictly beneath a mount root by +/// [`confine::resolve_beneath`]. All operations go through the fd (fd-relative +/// `*at` calls, `fstat`, fd `read`/`write`) — never a recovered path string — so +/// they stay confined to the resolved object and TOCTOU-safe. The `OwnedFd` +/// closes the descriptor on drop. +#[derive(Debug)] +struct AnchoredFd { + fd: OwnedFd, + /// The real host path the resolve-beneath walk arrived at. **Diagnostic / + /// logical only** (see [`confine::Resolved::real_path`]): safe for error + /// messages and for re-expressing the resolved path as a guest path, but it + /// must never be re-opened as an authority handle. + real_path: PathBuf, +} + +impl AnchoredFd { + /// `fchmod` the resolved object. Used only by `chmod`, which still opens the + /// leaf (`O_RDONLY | O_NOFOLLOW`) rather than going through the parent-fd + + /// `*at` path the other metadata ops use: `fchmodat` has no + /// `AT_SYMLINK_NOFOLLOW`, so a leaf-free `chmod` cannot close the + /// check-then-mutate symlink-swap race. `chmod` therefore accepts the + /// non-root read requirement in exchange for TOCTOU safety (see the module + /// guardrail / `crates/native-sidecar/CLAUDE.md`). + fn set_mode(&self, mode: u32) -> Result<(), Errno> { + fchmod(self.as_raw_fd(), Mode::from_bits_truncate(mode as _)) + } + + /// Consume the handle, yielding a [`std::fs::File`] over the owned fd (no + /// path re-open). + fn into_file(self) -> File { + File::from(self.fd) + } } -#[cfg(target_os = "macos")] -fn anchored_fd_real_path(fd: &AnchoredFd) -> io::Result { - crate::macos_fs::fd_real_path(fd.as_raw_fd()) +impl AsRawFd for AnchoredFd { + fn as_raw_fd(&self) -> RawFd { + self.fd.as_raw_fd() + } } #[derive(Debug, Deserialize)] @@ -231,55 +712,26 @@ impl HostDirFilesystem { (normalized, relative) } - #[cfg(not(target_os = "macos"))] - fn resolve_flags() -> ResolveFlag { - ResolveFlag::RESOLVE_BENEATH | ResolveFlag::RESOLVE_NO_MAGICLINKS - } - + /// Open `relative` strictly beneath the mount root via the universal + /// resolve-beneath walk in [`confine`]. See that module for why `openat2` + /// is not used. fn open_beneath(&self, relative: &Path, flags: OFlag, mode: Mode) -> VfsResult { let relative_display = relative.display().to_string(); - let fd = self - .resolve_beneath_fd(relative, flags, mode) - .map_err(|error| match error { - Errno::EXDEV => VfsError::access_denied( - "open", - &relative_display, - Some("path escapes host directory"), - ), - other => io_error_to_vfs("open", &relative_display, nix_to_io(other)), + let resolved = + confine::resolve_beneath(&self.host_root, relative, flags, mode).map_err(|error| { + match error { + Errno::EXDEV => VfsError::access_denied( + "open", + &relative_display, + Some("path escapes host directory"), + ), + other => io_error_to_vfs("open", &relative_display, nix_to_io(other)), + } })?; - Ok(AnchoredFd { fd }) - } - - /// Open `relative` strictly beneath the mount root, returning an owned raw - /// fd. Linux uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`; macOS - /// has no such syscall and uses cap-std's audited resolve-beneath instead - /// (see [`crate::macos_fs`]). - #[cfg(not(target_os = "macos"))] - fn resolve_beneath_fd( - &self, - relative: &Path, - flags: OFlag, - mode: Mode, - ) -> Result { - openat2( - self.host_root_dir.as_raw_fd(), - relative, - OpenHow::new() - .flags(flags | OFlag::O_CLOEXEC) - .mode(mode) - .resolve(Self::resolve_flags()), - ) - } - - #[cfg(target_os = "macos")] - fn resolve_beneath_fd( - &self, - relative: &Path, - flags: OFlag, - mode: Mode, - ) -> Result { - crate::macos_fs::resolve_beneath(&self.host_root, relative, flags, mode) + Ok(AnchoredFd { + fd: resolved.fd, + real_path: resolved.real_path, + }) } fn open_directory_beneath(&self, relative: &Path) -> VfsResult { @@ -290,60 +742,87 @@ impl HostDirFilesystem { ) } - fn host_path_for_fd(&self, fd: &AnchoredFd, virtual_path: &str) -> VfsResult { - let host_path = anchored_fd_real_path(fd) - .map_err(|error| io_error_to_vfs("open", virtual_path, error))?; - self.ensure_within_root(&host_path, virtual_path)?; - Ok(host_path) + /// Open `relative` as a directory *anchor* for `*at` calls, using the + /// search-only-dir `O_PATH` fallback (see [`confine::open_dir_anchor`]). Use + /// this — not [`open_directory_beneath`] — when the fd is only an `*at` + /// anchor (parent for `fstatat`/`fchownat`/`unlinkat`/…), never `readdir`ed, + /// so a search-only parent does not spuriously fail `EACCES`. `readdir` paths + /// must keep [`open_directory_beneath`] so listing a search-only dir still + /// reports `EACCES`. + fn open_dir_anchor_beneath(&self, relative: &Path) -> VfsResult { + let relative_display = relative.display().to_string(); + let resolved = + confine::resolve_dir_anchor_beneath(&self.host_root, relative).map_err(|error| { + match error { + Errno::EXDEV => VfsError::access_denied( + "open", + &relative_display, + Some("path escapes host directory"), + ), + other => io_error_to_vfs("open", &relative_display, nix_to_io(other)), + } + })?; + Ok(AnchoredFd { + fd: resolved.fd, + real_path: resolved.real_path, + }) } - #[cfg(not(target_os = "macos"))] - fn open_metadata_beneath(&self, path: &str, op: &'static str) -> VfsResult { - let (_, relative) = self.relative_virtual_path(path); - let handle = - self.open_beneath(&relative, O_PATH_ANCHOR | OFlag::O_NOFOLLOW, Mode::empty())?; - let metadata = - fs::metadata(handle.proc_path()).map_err(|error| io_error_to_vfs(op, path, error))?; - if metadata.file_type().is_symlink() { + fn host_path_for_fd(&self, fd: &AnchoredFd, virtual_path: &str) -> VfsResult { + // The resolve-beneath walk already confined `fd` under the root and + // recorded the logical host path it arrived at; re-check it defensively + // before handing it back for path re-expression. + self.ensure_within_root(&fd.real_path, virtual_path)?; + Ok(fd.real_path.clone()) + } + + /// `lstat` the leaf through the confined parent fd and reject a symlink leaf + /// with `EPERM` — metadata mutations must not follow symlinks (a followed + /// symlink could retarget the op onto an escaped host path). Needs only search + /// permission on the parent, never read on the leaf. + fn reject_symlink_leaf( + &self, + parent_dir: &AnchoredFd, + name: &std::ffi::OsStr, + normalized: &str, + op: &'static str, + ) -> VfsResult<()> { + let stat = fstatat( + Some(parent_dir.as_raw_fd()), + name, + AtFlags::AT_SYMLINK_NOFOLLOW, + ) + .map_err(|error| io_error_to_vfs(op, normalized, nix_to_io(error)))?; + if stat.st_mode & SFlag::S_IFMT.bits() == SFlag::S_IFLNK.bits() { return Err(VfsError::new( "EPERM", - format!("{op} '{path}': metadata operations do not follow symlinks"), + format!("{op} '{normalized}': metadata operations do not follow symlinks"), )); } - Ok(handle) + Ok(()) } - // macOS has no `O_PATH`, and `open(O_NOFOLLOW)` on a symlink fails outright, - // so the Linux path above (open the link as an anchor, then inspect it) - // cannot run — the resolve-beneath refusal instead surfaces as `EACCES`, - // diverging from Linux's `EPERM`. Detect the symlink directly: `lstat` the - // final component through the resolved parent fd and reject it with `EPERM`; - // otherwise open the (non-symlink) target as the metadata anchor. - #[cfg(target_os = "macos")] + // `open(O_NOFOLLOW)` on a symlink fails outright, so we cannot open the link + // itself as an anchor and inspect it. Instead detect the symlink directly: + // `lstat` the final component through the resolved parent fd and reject it + // with `EPERM`; otherwise open the (non-symlink) target as the metadata + // anchor. `O_NOFOLLOW` on the anchor open closes the lstat→open race (a + // component swapped to a symlink after the check fails closed with `ELOOP`). + // + // This is used ONLY by `chmod` (see [`AnchoredFd::set_mode`]); `chown`/ + // `utimes`/`stat` avoid the leaf open entirely (parent-fd + `*at`). fn open_metadata_beneath(&self, path: &str, op: &'static str) -> VfsResult { // The mount root has no final component to lstat and is always a - // directory (never a symlink), so open it directly as the anchor — - // matching Linux, where the `O_PATH | O_NOFOLLOW` open of the root - // succeeds. (`split_parent` would otherwise reject `/` with EINVAL.) + // directory (never a symlink), so open it directly as the anchor. + // (`split_parent` would otherwise reject `/` with EINVAL.) let (_, root_relative) = self.relative_virtual_path(path); if root_relative.file_name().is_none() { return self.open_beneath(&root_relative, O_PATH_ANCHOR, Mode::empty()); } let (parent_dir, _, name, normalized) = self.split_parent(path, false)?; - let stat = fstatat( - Some(parent_dir.as_raw_fd()), - name.as_os_str(), - AtFlags::AT_SYMLINK_NOFOLLOW, - ) - .map_err(|error| io_error_to_vfs(op, &normalized, nix_to_io(error)))?; - if stat.st_mode & SFlag::S_IFMT.bits() == SFlag::S_IFLNK.bits() { - return Err(VfsError::new( - "EPERM", - format!("{op} '{path}': metadata operations do not follow symlinks"), - )); - } + self.reject_symlink_leaf(&parent_dir, name.as_os_str(), &normalized, op)?; let (_, relative) = self.relative_virtual_path(path); - self.open_beneath(&relative, O_PATH_ANCHOR, Mode::empty()) + self.open_beneath(&relative, O_PATH_ANCHOR | OFlag::O_NOFOLLOW, Mode::empty()) } fn ensure_directory_tree( @@ -416,7 +895,11 @@ impl HostDirFilesystem { if create_parent_dirs { self.ensure_directory_tree(&parent, &normalized, 0o755)?; } - let parent_dir = self.open_directory_beneath(&parent)?; + // The parent fd is used only as an `*at` anchor by every `split_parent` + // caller (never `readdir`ed), so use the search-only `O_PATH` fallback: + // `chown`/`utimes`/`remove`/`rename`/… must work through a search-only + // parent directory, matching POSIX (they need only search on the parent). + let parent_dir = self.open_dir_anchor_beneath(&parent)?; Ok((parent_dir, parent, name.to_os_string(), normalized)) } @@ -481,19 +964,23 @@ impl HostDirFilesystem { mtime: VirtualUtimeSpec, follow_symlinks: bool, ) -> VfsResult<()> { + let (parent_dir, _, name, normalized) = self.split_parent(path, false)?; if follow_symlinks { - let _ = self.open_metadata_beneath(path, "utimes")?; + // `utimes` (follow) rejects a symlink leaf, matching `chmod`/`chown`; + // the richer `lutimes` path (`follow_symlinks == false`) instead + // operates on the symlink itself via `NoFollowSymlink` below. + self.reject_symlink_leaf(&parent_dir, name.as_os_str(), &normalized, "utimes")?; } - let (parent_dir, _, name, normalized) = self.split_parent(path, false)?; + // Read existing times (for `Omit`) and apply the new times both with + // `NoFollowSymlink`. For `follow_symlinks == true` the leaf was just + // confirmed non-symlink, so nofollow is behaviourally identical while + // additionally closing the check→mutate symlink-swap race (a leaf swapped + // to a symlink afterwards is operated on as the symlink, staying confined, + // instead of following it to an escaped host path). let existing = match (atime, mtime) { - (VirtualUtimeSpec::Omit, _) | (_, VirtualUtimeSpec::Omit) => { - Some(self.existing_utime_specs( - &parent_dir, - name.as_os_str(), - &normalized, - follow_symlinks, - )?) - } + (VirtualUtimeSpec::Omit, _) | (_, VirtualUtimeSpec::Omit) => Some( + self.existing_utime_specs(&parent_dir, name.as_os_str(), &normalized, false)?, + ), _ => None, }; let existing_atime = existing @@ -508,17 +995,12 @@ impl HostDirFilesystem { Self::resolve_utime_timespec(atime, existing_atime), Self::resolve_utime_timespec(mtime, existing_mtime), ]; - let flags = if follow_symlinks { - UtimensatFlags::FollowSymlink - } else { - UtimensatFlags::NoFollowSymlink - }; utimensat( Some(parent_dir.as_raw_fd()), name.as_os_str(), ×[0], ×[1], - flags, + UtimensatFlags::NoFollowSymlink, ) .map_err(|error| io_error_to_vfs("utimes", &normalized, nix_to_io(error))) } @@ -685,11 +1167,7 @@ impl HostDirFilesystem { OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC, Mode::from_bits_truncate(file_mode as _), )?; - let mut file = File::options() - .write(true) - .custom_flags(libc::O_CLOEXEC) - .open(handle.proc_path()) - .map_err(|error| io_error_to_vfs("write", path, error))?; + let mut file = handle.into_file(); file.write_all(&content) .map_err(|error| io_error_to_vfs("write", path, error)) } @@ -723,8 +1201,7 @@ impl VirtualFileSystem for HostDirFilesystem { fn read_file(&mut self, path: &str) -> VfsResult> { let (_, relative) = self.relative_virtual_path(path); let handle = self.open_beneath(&relative, OFlag::O_RDONLY, Mode::empty())?; - let mut file = - File::open(handle.proc_path()).map_err(|error| io_error_to_vfs("open", path, error))?; + let mut file = handle.into_file(); self.check_full_read_metadata( path, file.metadata() @@ -737,17 +1214,11 @@ impl VirtualFileSystem for HostDirFilesystem { fn read_dir(&mut self, path: &str) -> VfsResult> { let (_, relative) = self.relative_virtual_path(path); let directory = self.open_directory_beneath(&relative)?; - let readdir_path = directory - .readdir_path() - .map_err(|error| io_error_to_vfs("readdir", path, error))?; - let mut entries = fs::read_dir(readdir_path) - .map_err(|error| io_error_to_vfs("readdir", path, error))? - .map(|entry| { - entry - .map_err(|error| io_error_to_vfs("readdir", path, error)) - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - }) - .collect::>>()?; + let mut entries = confine::read_dir(directory.fd.as_fd()) + .map_err(|error| io_error_to_vfs("readdir", path, nix_to_io(error)))? + .into_iter() + .map(|(name, _)| name.to_string_lossy().into_owned()) + .collect::>(); entries.sort(); Ok(entries) } @@ -755,23 +1226,15 @@ impl VirtualFileSystem for HostDirFilesystem { fn read_dir_with_types(&mut self, path: &str) -> VfsResult> { let (_, relative) = self.relative_virtual_path(path); let directory = self.open_directory_beneath(&relative)?; - let readdir_path = directory - .readdir_path() - .map_err(|error| io_error_to_vfs("readdir", path, error))?; - let mut entries = fs::read_dir(readdir_path) - .map_err(|error| io_error_to_vfs("readdir", path, error))? - .map(|entry| { - let entry = entry.map_err(|error| io_error_to_vfs("readdir", path, error))?; - let file_type = entry - .file_type() - .map_err(|error| io_error_to_vfs("readdir", path, error))?; - Ok(VirtualDirEntry { - name: entry.file_name().to_string_lossy().into_owned(), - is_directory: file_type.is_dir(), - is_symbolic_link: file_type.is_symlink(), - }) + let mut entries = confine::read_dir(directory.fd.as_fd()) + .map_err(|error| io_error_to_vfs("readdir", path, nix_to_io(error)))? + .into_iter() + .map(|(name, kind)| VirtualDirEntry { + name: name.to_string_lossy().into_owned(), + is_directory: kind == confine::EntryKind::Directory, + is_symbolic_link: kind == confine::EntryKind::Symlink, }) - .collect::>>()?; + .collect::>(); entries.sort_by(|left, right| left.name.cmp(&right.name)); Ok(entries) } @@ -806,17 +1269,37 @@ impl VirtualFileSystem for HostDirFilesystem { } fn exists(&self, path: &str) -> bool { + // Resolve to the parent + leaf (following symlinks), which needs only + // search access — `open_beneath(O_RDONLY)` would falsely report a + // statable-but-unreadable file as missing under a non-root sidecar. let (_, relative) = self.relative_virtual_path(path); - self.open_beneath(&relative, O_PATH_ANCHOR, Mode::empty()) - .is_ok() + confine::resolve_parent_beneath(&self.host_root, &relative).is_ok() } fn stat(&mut self, path: &str) -> VfsResult { + // `fstatat`/`fstat` against the confined parent fd — no leaf open, so no + // READ permission is required on the target (POSIX `stat` needs none). + // The walk already followed and confined the final symlink, so the leaf + // name is a real, non-symlink target of the resolved parent. let (_, relative) = self.relative_virtual_path(path); - let handle = self.open_beneath(&relative, O_PATH_ANCHOR, Mode::empty())?; - fs::metadata(handle.proc_path()) - .map(Self::stat_from_metadata) - .map_err(|error| io_error_to_vfs("stat", path, error)) + let resolved = confine::resolve_parent_beneath(&self.host_root, &relative).map_err( + |error| match error { + Errno::EXDEV => { + VfsError::access_denied("stat", path, Some("path escapes host directory")) + } + other => io_error_to_vfs("stat", path, nix_to_io(other)), + }, + )?; + let stat = match &resolved.name { + Some(name) => fstatat( + Some(resolved.parent.as_raw_fd()), + name.as_os_str(), + AtFlags::AT_SYMLINK_NOFOLLOW, + ), + None => fstat(resolved.parent.as_raw_fd()), + } + .map_err(|error| io_error_to_vfs("stat", path, nix_to_io(error)))?; + Ok(Self::stat_from_file_stat(stat)) } fn remove_file(&mut self, path: &str) -> VfsResult<()> { @@ -940,16 +1423,26 @@ impl VirtualFileSystem for HostDirFilesystem { fn chmod(&mut self, path: &str, mode: u32) -> VfsResult<()> { let handle = self.open_metadata_beneath(path, "chmod")?; - fs::set_permissions(handle.proc_path(), fs::Permissions::from_mode(mode)) - .map_err(|error| io_error_to_vfs("chmod", path, error)) + handle + .set_mode(mode) + .map_err(|error| io_error_to_vfs("chmod", path, nix_to_io(error))) } fn chown(&mut self, path: &str, uid: u32, gid: u32) -> VfsResult<()> { - let handle = self.open_metadata_beneath(path, "chown")?; - chown( - handle.proc_path().as_path(), + // Parent-fd + `fchownat` (no leaf open → no read requirement). Reject a + // symlink leaf, then use `AT_SYMLINK_NOFOLLOW`: for the confirmed + // non-symlink leaf this is behaviourally a plain `chown`, and it also + // closes the check→mutate symlink-swap race (a leaf swapped to a symlink + // afterwards is `lchown`ed in place, staying confined, never followed to + // an escaped host path). + let (parent_dir, _, name, normalized) = self.split_parent(path, false)?; + self.reject_symlink_leaf(&parent_dir, name.as_os_str(), &normalized, "chown")?; + fchownat( + Some(parent_dir.as_raw_fd()), + name.as_os_str(), Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)), + AtFlags::AT_SYMLINK_NOFOLLOW, ) .map_err(|error| VfsError::new(error_code(&error), error.to_string())) } @@ -976,11 +1469,9 @@ impl VirtualFileSystem for HostDirFilesystem { fn truncate(&mut self, path: &str, length: u64) -> VfsResult<()> { let (_, relative) = self.relative_virtual_path(path); let handle = self.open_beneath(&relative, OFlag::O_WRONLY, Mode::empty())?; - let file = File::options() - .write(true) - .open(handle.proc_path()) - .map_err(|error| io_error_to_vfs("truncate", path, error))?; - file.set_len(length) + handle + .into_file() + .set_len(length) .map_err(|error| io_error_to_vfs("truncate", path, error)) } @@ -988,8 +1479,7 @@ impl VirtualFileSystem for HostDirFilesystem { self.check_read_length(path, length)?; let (_, relative) = self.relative_virtual_path(path); let handle = self.open_beneath(&relative, OFlag::O_RDONLY, Mode::empty())?; - let file = - File::open(handle.proc_path()).map_err(|error| io_error_to_vfs("open", path, error))?; + let file = handle.into_file(); let mut buffer = vec![0; length]; let bytes_read = file .read_at(&mut buffer, offset) @@ -1001,10 +1491,7 @@ impl VirtualFileSystem for HostDirFilesystem { fn pwrite(&mut self, path: &str, content: impl Into>, offset: u64) -> VfsResult<()> { let (_, relative) = self.relative_virtual_path(path); let handle = self.open_beneath(&relative, OFlag::O_WRONLY, Mode::empty())?; - let file = File::options() - .write(true) - .open(handle.proc_path()) - .map_err(|error| io_error_to_vfs("open", path, error))?; + let file = handle.into_file(); let content = content.into(); self.write_all_at(&file, &content, offset, path) } @@ -1106,8 +1593,8 @@ impl HostDirModuleMount { /// `host_dir`/`module_access` filesystems. It lets module resolution run on the /// V8 bridge thread — concurrently with the sidecar service loop — while still /// reading exactly the mounted `node_modules` tree the guest sees (anchored -/// `openat2(RESOLVE_BENEATH)` with escaping-symlink refusal), instead of the -/// host-direct path translator. +/// resolve-beneath with escaping-symlink refusal; see [`confine`]), instead of +/// the host-direct path translator. /// /// It never touches the `&mut` kernel, so a large cold-start module graph cannot /// serialize behind / starve work on the service-loop thread (e.g. an ACP @@ -1249,7 +1736,7 @@ impl ModuleFsReader for HostDirModuleReader { /// Session-thread module reader: the mounted `HostDirModuleReader` plus a /// persistent resolution cache, so the V8 isolate thread can both resolve -/// specifiers and read source DIRECTLY (same mount + `openat2(RESOLVE_BENEATH)` +/// specifiers and read source DIRECTLY (same mount + resolve-beneath /// confinement, same `ModuleResolver` semantics as the bridge), skipping the /// per-module `_resolveModule`/`_loadFile` bridge round-trips. pub(crate) struct SessionModuleReader { diff --git a/crates/native-sidecar/tests/architecture_guards.rs b/crates/native-sidecar/tests/architecture_guards.rs index c847e43372..91818116cc 100644 --- a/crates/native-sidecar/tests/architecture_guards.rs +++ b/crates/native-sidecar/tests/architecture_guards.rs @@ -250,13 +250,14 @@ fn scan_class(root: &Path, files: &[PathBuf], class: &BannedClass) -> Vec