From 19f34bd6de23cf3f7d28ba021275bdb5c125914b Mon Sep 17 00:00:00 2001 From: abcxff <79597906+abcxff@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:24:03 -0400 Subject: [PATCH 1/3] chore: remove accidentally-committed node_modules symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were absolute symlinks pointing at /home/nathan/secure-exec/... — a local checkout — so they dangle on every other machine and break `pnpm install` (it cannot create a real node_modules over a stale symlink). Co-Authored-By: Claude Opus 4.8 (1M context) --- node_modules | 1 - packages/build-tools/node_modules | 1 - packages/core/node_modules | 1 - website/node_modules | 1 - 4 files changed, 4 deletions(-) delete mode 120000 node_modules delete mode 120000 packages/build-tools/node_modules delete mode 120000 packages/core/node_modules delete mode 120000 website/node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index 93042b57a..000000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/nathan/secure-exec/node_modules \ No newline at end of file diff --git a/packages/build-tools/node_modules b/packages/build-tools/node_modules deleted file mode 120000 index 35c651809..000000000 --- a/packages/build-tools/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/nathan/secure-exec/packages/build-tools/node_modules \ No newline at end of file diff --git a/packages/core/node_modules b/packages/core/node_modules deleted file mode 120000 index 47beda28b..000000000 --- a/packages/core/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/nathan/secure-exec/packages/core/node_modules \ No newline at end of file diff --git a/website/node_modules b/website/node_modules deleted file mode 120000 index a33f908d9..000000000 --- a/website/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/nathan/secure-exec/website/node_modules \ No newline at end of file From bfb16b183796a62a9fa7b84a26a082c1af5acddd Mon Sep 17 00:00:00 2001 From: abcxff <79597906+abcxff@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:24:21 -0400 Subject: [PATCH 2/3] feat(macos): port the native runtime + sidecar to macOS The native stack previously compiled only on Linux. This adds macOS (aarch64/x86_64) support, gated entirely behind cfg(target_os) so the Linux paths are untouched. v8-runtime (per-thread CPU accounting): - timeout.rs: pthread_getcpuclockid has no macOS equivalent; the CpuBudgetGuard watchdog now reads the execution thread's CPU time via the Mach thread port (pthread_mach_thread_np + thread_info(THREAD_BASIC_INFO)), readable cross-thread like the Linux clockid. - bridge.rs: RUSAGE_THREAD is Linux-only; process.cpuUsage/resourceUsage get per-thread CPU time from thread_info, the rest best-effort from RUSAGE_SELF. sidecar (host-mount filesystem confinement): - macos_fs.rs: macOS has no openat2(RESOLVE_BENEATH); resolve-beneath path resolution now goes through cap-std (audited userspace walk; openat2 on Linux, fd-relative O_NOFOLLOW walk elsewhere). F_GETPATH replaces readlink on /proc/self/fd; /dev/fd/N replaces /proc/self/fd/N. - host_dir.rs + filesystem.rs: open_beneath / mapped-runtime helpers resolve via cap-std on macOS; O_PATH -> read-only anchor, O_TMPFILE -> empty. All nine mapped-runtime child ops (mkdir/rmdir/unlink/symlink/rename/readlink/lstat/ lutimes) converted from the Linux /proc/self/fd-join idiom to fd-relative *at calls, since /dev/fd/N cannot have children appended. lstat goes through a platform-neutral HostStat translator; rename uses renameat with a real-path EXDEV fallback. Also fixes a latent dangling-fd bug in the mapped utimes path (the resolved handle is now held across the operation on both platforms). - execution.rs: waitid/WNOWAIT is unavailable in nix on macOS; the child-status poll uses waitpid(WNOHANG) there. - Plus latent macOS type-width fixes (mode_t/dev_t/nlink_t). All sidecar lib tests pass on macOS, including every host-mount escape test. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 90 +++++ crates/sidecar/Cargo.toml | 5 + crates/sidecar/src/execution.rs | 31 +- crates/sidecar/src/filesystem.rs | 522 +++++++++++++++++++++---- crates/sidecar/src/lib.rs | 2 + crates/sidecar/src/macos_fs.rs | 106 +++++ crates/sidecar/src/plugins/host_dir.rs | 110 ++++-- crates/v8-runtime/src/bridge.rs | 79 ++++ crates/v8-runtime/src/timeout.rs | 68 +++- 9 files changed, 915 insertions(+), 98 deletions(-) create mode 100644 crates/sidecar/src/macos_fs.rs diff --git a/Cargo.lock b/Cargo.lock index 84e10090f..3c4829809 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,12 @@ 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 = "anyhow" version = "1.0.102" @@ -635,6 +641,36 @@ 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.52.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.58" @@ -1110,6 +1146,17 @@ 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.52.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1780,6 +1827,22 @@ 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.52.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" @@ -1993,6 +2056,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[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" @@ -2625,6 +2694,16 @@ 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" @@ -2864,6 +2943,7 @@ dependencies = [ "aws-sdk-s3", "base64 0.22.1", "bytes", + "cap-std", "filetime", "h2 0.4.13", "hickory-resolver", @@ -4045,6 +4125,16 @@ 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.52.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/crates/sidecar/Cargo.toml b/crates/sidecar/Cargo.toml index 8fb7a2e3a..42a70942f 100644 --- a/crates/sidecar/Cargo.toml +++ b/crates/sidecar/Cargo.toml @@ -56,6 +56,11 @@ url = "2" vbare = { workspace = true } 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" + [build-dependencies] vbare-compiler = { workspace = true } diff --git a/crates/sidecar/src/execution.rs b/crates/sidecar/src/execution.rs index f1492e26e..00f73a79c 100644 --- a/crates/sidecar/src/execution.rs +++ b/crates/sidecar/src/execution.rs @@ -62,7 +62,11 @@ use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, Response, Uri}; use md5::Md5; use nix::libc; use nix::sys::signal::{kill as send_signal, Signal}; -use nix::sys::wait::{waitid as wait_on_child, Id as WaitId, WaitPidFlag, WaitStatus}; +use nix::sys::wait::WaitStatus; +#[cfg(not(target_os = "macos"))] +use nix::sys::wait::{waitid as wait_on_child, Id as WaitId, WaitPidFlag}; +#[cfg(target_os = "macos")] +use nix::sys::wait::{waitpid, WaitPidFlag}; use nix::unistd::Pid; use openssl::bn::{BigNum, BigNumContext}; use openssl::derive::Deriver; @@ -20991,6 +20995,7 @@ pub(crate) fn runtime_child_is_alive(child_pid: u32) -> Result Result, SidecarError> { if child_pid == 0 { return Ok(Some(0)); @@ -21016,6 +21021,30 @@ fn runtime_child_exit_status(child_pid: u32) -> Result, SidecarError } } +// macOS nix exposes no `waitid`/`WNOWAIT`, so we poll with `waitpid(WNOHANG)`. +// NOTE: unlike Linux's `waitid(WNOWAIT)`, `waitpid` REAPS an exited child rather +// than leaving it waitable. That is correct for this poll (the sidecar is the +// reaping parent), but a second status query after exit returns ECHILD → treated +// as "exited(0)" below. +#[cfg(target_os = "macos")] +fn runtime_child_exit_status(child_pid: u32) -> Result, SidecarError> { + if child_pid == 0 { + return Ok(Some(0)); + } + + match waitpid(Pid::from_raw(child_pid as i32), Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::StillAlive) + | Ok(WaitStatus::Stopped(_, _)) + | Ok(WaitStatus::Continued(_)) => Ok(None), + Ok(WaitStatus::Exited(_, status)) => Ok(Some(status)), + Ok(WaitStatus::Signaled(_, signal, _)) => Ok(Some(128 + signal as i32)), + Err(nix::errno::Errno::ECHILD) => Ok(Some(0)), + Err(error) => Err(SidecarError::Execution(format!( + "failed to inspect guest runtime process {child_pid}: {error}" + ))), + } +} + pub(crate) fn signal_runtime_process(child_pid: u32, signal: i32) -> Result<(), SidecarError> { if child_pid == 0 { return Ok(()); diff --git a/crates/sidecar/src/filesystem.rs b/crates/sidecar/src/filesystem.rs index 795dc74ac..983d86be9 100644 --- a/crates/sidecar/src/filesystem.rs +++ b/crates/sidecar/src/filesystem.rs @@ -23,8 +23,23 @@ use crate::{DispatchResult, NativeSidecar, NativeSidecarBridge, SidecarError}; use base64::Engine; use nix::errno::Errno; -use nix::fcntl::{open, openat2, OFlag, OpenHow, ResolveFlag}; +#[cfg(not(target_os = "macos"))] +use nix::fcntl::{openat2, OpenHow, ResolveFlag}; +use nix::fcntl::{open, 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")] +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 nix::sys::stat::{utimensat, Mode, UtimensatFlags}; use nix::sys::time::TimeSpec; use secure_exec_execution::{ @@ -83,9 +98,17 @@ struct AnchoredFd { } impl AnchoredFd { + #[cfg(not(target_os = "macos"))] fn proc_path(&self) -> PathBuf { PathBuf::from(format!("/proc/self/fd/{}", self.fd)) } + + // macOS `/dev/fd/N` mirrors Linux `/proc/self/fd/N`: a path that re-opens + // the already-resolved fd without re-resolving through the untrusted tree. + #[cfg(target_os = "macos")] + fn proc_path(&self) -> PathBuf { + PathBuf::from(format!("/dev/fd/{}", self.fd)) + } } impl AsRawFd for AnchoredFd { @@ -951,7 +974,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( &mapped_host, "fs.open", OFlag::from_bits_truncate(flags as i32), - Mode::from_bits_truncate(mode.unwrap_or(0o666)), + Mode::from_bits_truncate(mode.unwrap_or(0o666) as _), )?; let host_path = opened.host_path.clone(); return open_mapped_host_fd( @@ -1103,7 +1126,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC, Mode::from_bits_truncate( javascript_sync_rpc_option_u32(&request.args, 2, "mode")? - .unwrap_or(0o666), + .unwrap_or(0o666) as _, ), )?; fs::write(opened.handle.proc_path(), contents).map_err(|error| { @@ -1138,7 +1161,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( let opened = open_mapped_runtime_beneath( &mapped_host, "fs.stat", - OFlag::O_PATH, + O_PATH_ANCHOR, Mode::empty(), )?; let metadata = fs::metadata(opened.handle.proc_path()).map_err(|error| { @@ -1160,7 +1183,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( if let Some(mapped_host) = mapped_runtime_host_path_for_read(process, path) { materialize_mapped_host_path_from_kernel(kernel, kernel_pid, path, &mapped_host)?; let metadata = mapped_runtime_symlink_metadata(&mapped_host, "fs.lstat")?; - return Ok(javascript_sync_rpc_host_stat_value(&metadata)); + return Ok(metadata.to_value()); } kernel .lstat_for_process(EXECUTION_DRIVER_NAME, kernel_pid, path) @@ -1208,7 +1231,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( let Ok(opened) = open_mapped_runtime_beneath( &child, "fs.readdir entry", - OFlag::O_PATH, + O_PATH_ANCHOR, Mode::empty(), ) else { continue; @@ -1273,7 +1296,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( let opened = open_mapped_runtime_beneath( &mapped_host, "fs.access", - OFlag::O_PATH, + O_PATH_ANCHOR, Mode::empty(), )?; fs::metadata(opened.handle.proc_path()).map_err(|error| { @@ -1389,7 +1412,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( let exists = match open_mapped_runtime_beneath( &mapped_host, "fs.exists", - OFlag::O_PATH, + O_PATH_ANCHOR, Mode::empty(), ) { Ok(opened) => fs::metadata(opened.handle.proc_path()).is_ok(), @@ -1424,7 +1447,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( let parent = open_mapped_runtime_parent_beneath(&mapped_host, "fs.symlink")?; let host_path = parent.host_path.join(&parent.child_name); remove_shadow_path_if_exists(&host_path, link_path)?; - symlink(target, mapped_runtime_parent_child_path(&parent)).map_err( + mapped_child_symlink(&parent, target).map_err( |error| { SidecarError::Io(format!( "failed to create mapped guest symlink {} -> {} ({target}): {error}", @@ -1480,7 +1503,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( Some(MappedRuntimeHostAccess::Writable(mapped_host)) => { let parent = open_mapped_runtime_parent_beneath(&mapped_host, "fs.rmdir")?; let host_path = parent.host_path.join(&parent.child_name); - return fs::remove_dir(mapped_runtime_parent_child_path(&parent)) + return mapped_child_remove_dir(&parent) .map(|()| Value::Null) .map_err(|error| { SidecarError::Io(format!( @@ -1506,7 +1529,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( Some(MappedRuntimeHostAccess::Writable(mapped_host)) => { let parent = open_mapped_runtime_parent_beneath(&mapped_host, "fs.unlink")?; let host_path = parent.host_path.join(&parent.child_name); - return fs::remove_file(mapped_runtime_parent_child_path(&parent)) + return mapped_child_remove_file(&parent) .map(|()| Value::Null) .map_err(|error| { SidecarError::Io(format!( @@ -1540,7 +1563,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( let opened = open_mapped_runtime_beneath( &mapped_host, "fs.chmod", - OFlag::O_PATH, + O_PATH_ANCHOR, Mode::empty(), )?; if kernel @@ -1633,18 +1656,26 @@ pub(crate) fn service_javascript_fs_sync_rpc( } }; if mapped_host_exists { - let proc_path = if follow_symlinks { - let opened = open_mapped_runtime_beneath( + let context = format!("failed to update mapped guest path times {path}"); + // Resolve the host target up front and hold the handle across + // the kernel update so the apply below operates on the verified + // fd. (The handle must stay alive: a `/proc/self/fd` path is + // only valid while its fd is open, and the macOS fd-relative + // path needs the live parent fd.) + let follow_handle = if follow_symlinks { + Some(open_mapped_runtime_beneath( &mapped_host, "fs.utimes", - OFlag::O_PATH, + O_PATH_ANCHOR, Mode::empty(), - )?; - opened.handle.proc_path() + )?) } else { - let parent = - open_mapped_runtime_parent_beneath(&mapped_host, "fs.lutimes")?; - mapped_runtime_parent_child_path(&parent) + None + }; + let parent_handle = if follow_symlinks { + None + } else { + Some(open_mapped_runtime_parent_beneath(&mapped_host, "fs.lutimes")?) }; if kernel .exists_for_process(EXECUTION_DRIVER_NAME, kernel_pid, path) @@ -1661,13 +1692,17 @@ pub(crate) fn service_javascript_fs_sync_rpc( } } } - apply_host_path_utimens( - &proc_path, - atime, - mtime, - follow_symlinks, - &format!("failed to update mapped guest path times {path}"), - )?; + if let Some(opened) = &follow_handle { + apply_host_path_utimens( + &opened.handle.proc_path(), + atime, + mtime, + true, + &context, + )?; + } else if let Some(parent) = &parent_handle { + apply_mapped_child_utimens(parent, atime, mtime, &context)?; + } return Ok(Value::Null); } } @@ -2026,10 +2061,44 @@ 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"))] +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) +} + fn mapped_runtime_relative_path(mapped: &MappedRuntimeHostPath) -> Result { let normalized_root = normalize_host_path(&mapped.host_root); let normalized_path = normalize_host_path(&mapped.host_path); @@ -2084,20 +2153,13 @@ fn open_mapped_runtime_beneath( ) -> 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 | OFlag::O_TMPFILE) { + let open_mode = if flags.intersects(OFlag::O_CREAT | O_TMPFILE_FLAG) { mode } else { Mode::empty() }; - let fd = openat2( - root_dir.as_raw_fd(), - &relative, - OpenHow::new() - .flags(flags | OFlag::O_CLOEXEC) - .mode(open_mode) - .resolve(mapped_runtime_resolve_flags()), - ) - .map_err(|error| mapped_runtime_open_error(operation, mapped, error))?; + let fd = mapped_runtime_open_fd(&root_dir, &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 }) @@ -2109,13 +2171,12 @@ fn open_mapped_runtime_directory_beneath( relative: &Path, ) -> Result { let root_dir = open_mapped_runtime_root_dir(mapped, operation)?; - let fd = openat2( - root_dir.as_raw_fd(), + let fd = mapped_runtime_open_fd( + &root_dir, + &mapped.host_root, relative, - OpenHow::new() - .flags(OFlag::O_CLOEXEC | OFlag::O_DIRECTORY | OFlag::O_RDONLY) - .mode(Mode::empty()) - .resolve(mapped_runtime_resolve_flags()), + OFlag::O_DIRECTORY | OFlag::O_RDONLY, + Mode::empty(), ) .map_err(|error| mapped_runtime_open_error(operation, mapped, error))?; let handle = AnchoredFd { fd }; @@ -2146,24 +2207,137 @@ fn open_mapped_runtime_parent_beneath( }) } +/// Platform-neutral lstat result. Lets the mapped-runtime lstat path produce the +/// same guest-facing stat value from either a `std::fs::Metadata` (Linux, and +/// the macOS root case) or a raw `fstatat` result (macOS fd-relative child +/// lstat), so the operation stays fd-relative on macOS without a `std::fs` +/// metadata handle. +struct HostStat { + mode: u32, + size: u64, + blocks: u64, + dev: u64, + rdev: u64, + is_directory: bool, + is_symbolic_link: bool, + atime_ms: i64, + mtime_ms: i64, + ctime_ms: i64, + ino: u64, + nlink: u64, + uid: u32, + gid: u32, +} + +impl HostStat { + #[cfg_attr(not(test), allow(dead_code))] + fn is_dir(&self) -> bool { + self.is_directory + } + + fn to_value(&self) -> Value { + json!({ + "mode": self.mode, + "size": self.size, + "blocks": self.blocks, + "dev": self.dev, + "rdev": self.rdev, + "isDirectory": self.is_directory, + "isSymbolicLink": self.is_symbolic_link, + "atimeMs": self.atime_ms, + "mtimeMs": self.mtime_ms, + "ctimeMs": self.ctime_ms, + "birthtimeMs": self.ctime_ms, + "ino": self.ino, + "nlink": self.nlink, + "uid": self.uid, + "gid": self.gid, + }) + } +} + +impl From<&fs::Metadata> for HostStat { + fn from(metadata: &fs::Metadata) -> Self { + Self { + mode: metadata.mode(), + size: metadata.size(), + blocks: metadata.blocks(), + dev: metadata.dev(), + rdev: metadata.rdev(), + is_directory: metadata.is_dir(), + is_symbolic_link: metadata.file_type().is_symlink(), + atime_ms: metadata.atime() * 1000 + (metadata.atime_nsec() / 1_000_000), + mtime_ms: metadata.mtime() * 1000 + (metadata.mtime_nsec() / 1_000_000), + ctime_ms: metadata.ctime() * 1000 + (metadata.ctime_nsec() / 1_000_000), + ino: metadata.ino(), + nlink: metadata.nlink(), + uid: metadata.uid(), + gid: metadata.gid(), + } + } +} + +#[cfg(target_os = "macos")] +impl HostStat { + fn from_filestat(stat: &nix::sys::stat::FileStat) -> Self { + use nix::sys::stat::SFlag; + let fmt = stat.st_mode & SFlag::S_IFMT.bits(); + Self { + mode: stat.st_mode as u32, + size: stat.st_size as u64, + blocks: stat.st_blocks as u64, + dev: stat.st_dev as u64, + rdev: stat.st_rdev as u64, + is_directory: fmt == SFlag::S_IFDIR.bits(), + is_symbolic_link: fmt == SFlag::S_IFLNK.bits(), + atime_ms: stat.st_atime * 1000 + (stat.st_atime_nsec / 1_000_000), + mtime_ms: stat.st_mtime * 1000 + (stat.st_mtime_nsec / 1_000_000), + ctime_ms: stat.st_ctime * 1000 + (stat.st_ctime_nsec / 1_000_000), + ino: stat.st_ino, + nlink: stat.st_nlink as u64, + uid: stat.st_uid, + gid: stat.st_gid, + } + } +} + +#[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()), + parent.child_name.as_os_str(), + nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW, + ) + .map_err(errno_to_io)?; + Ok(HostStat::from_filestat(&stat)) +} + fn mapped_runtime_symlink_metadata( mapped: &MappedRuntimeHostPath, operation: &str, -) -> Result { +) -> Result { let relative = mapped_runtime_relative_path(mapped)?; if relative == Path::new(".") { - return fs::symlink_metadata(&mapped.host_path).map_err(|error| { - SidecarError::Io(format!( - "failed to lstat mapped guest path {} -> {}: {error}", - mapped.guest_path, - mapped.host_path.display() - )) - }); + return fs::symlink_metadata(&mapped.host_path) + .map(|metadata| HostStat::from(&metadata)) + .map_err(|error| { + SidecarError::Io(format!( + "failed to lstat mapped guest path {} -> {}: {error}", + mapped.guest_path, + mapped.host_path.display() + )) + }); } let parent = open_mapped_runtime_parent_beneath(mapped, operation)?; let host_path = parent.host_path.join(&parent.child_name); - fs::symlink_metadata(mapped_runtime_parent_child_path(&parent)).map_err(|error| { + mapped_child_lstat(&parent).map_err(|error| { SidecarError::Io(format!( "failed to lstat mapped guest path {} -> {}: {error}", mapped.guest_path, @@ -2189,7 +2363,7 @@ fn read_mapped_runtime_link( let parent = open_mapped_runtime_parent_beneath(mapped, operation)?; let host_path = parent.host_path.join(&parent.child_name); - fs::read_link(mapped_runtime_parent_child_path(&parent)).map_err(|error| { + mapped_child_read_link(&parent).map_err(|error| { SidecarError::Io(format!( "failed to read mapped guest symlink {} -> {}: {error}", guest_path, @@ -2203,7 +2377,13 @@ fn mapped_runtime_host_path_from_fd( operation: &str, fd: &AnchoredFd, ) -> Result { - fs::read_link(fd.proc_path()).map_err(|error| { + // 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 @@ -2211,22 +2391,220 @@ fn mapped_runtime_host_path_from_fd( }) } +#[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. +// --------------------------------------------------------------------------- + +#[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) +} + +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( + Some(parent.directory.as_raw_fd()), + parent.child_name.as_os_str(), + nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW, + ) + .map_err(errno_to_io)?; + Ok(stat.st_mode & SFlag::S_IFMT.bits() == SFlag::S_IFDIR.bits()) +} + +#[cfg(not(target_os = "macos"))] +fn mapped_child_remove_dir(parent: &MappedRuntimeParentPath) -> 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()), + parent.child_name.as_os_str(), + nix::unistd::UnlinkatFlags::RemoveDir, + ) + .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()), + parent.child_name.as_os_str(), + nix::unistd::UnlinkatFlags::NoRemoveDir, + ) + .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, + Some(parent.directory.as_raw_fd()), + parent.child_name.as_os_str(), + ) + .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()), parent.child_name.as_os_str()) + .map(PathBuf::from) + .map_err(errno_to_io) +} + +/// Set access/modification times on a mapped child without following symlinks +/// (lutimes). Linux operates on `/proc/self/fd/N/child`; macOS uses fd-relative +/// `utimensat` anchored on the resolved parent fd. +#[cfg(not(target_os = "macos"))] +fn apply_mapped_child_utimens( + parent: &MappedRuntimeParentPath, + atime: VirtualUtimeSpec, + mtime: VirtualUtimeSpec, + context: &str, +) -> Result<(), SidecarError> { + apply_host_path_utimens( + &mapped_runtime_parent_child_path(parent), + atime, + mtime, + false, + context, + ) +} +#[cfg(target_os = "macos")] +fn apply_mapped_child_utimens( + parent: &MappedRuntimeParentPath, + atime: VirtualUtimeSpec, + mtime: VirtualUtimeSpec, + context: &str, +) -> Result<(), SidecarError> { + let existing = match (atime, mtime) { + (VirtualUtimeSpec::Omit, _) | (_, VirtualUtimeSpec::Omit) => { + let stat = nix::sys::stat::fstatat( + Some(parent.directory.as_raw_fd()), + parent.child_name.as_os_str(), + nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW, + ) + .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), + ]; + utimensat( + Some(parent.directory.as_raw_fd()), + parent.child_name.as_os_str(), + ×[0], + ×[1], + UtimensatFlags::NoFollowSymlink, + ) + .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), + ) +} +#[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. + match nix::fcntl::renameat( + Some(source.directory.as_raw_fd()), + source.child_name.as_os_str(), + Some(destination.directory.as_raw_fd()), + 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(error) => Err(errno_to_io(error)), + } +} + fn create_mapped_runtime_directory( parent: &MappedRuntimeParentPath, guest_path: &str, recursive: bool, ) -> Result<(), SidecarError> { - let child_path = mapped_runtime_parent_child_path(parent); - match fs::create_dir(&child_path) { + match mapped_child_create_dir(parent) { Ok(()) => Ok(()), Err(error) if recursive && error.kind() == std::io::ErrorKind::AlreadyExists => { - match fs::symlink_metadata(&child_path) { - Ok(metadata) if metadata.is_dir() => Ok(()), - Ok(_) => Err(SidecarError::Io(format!( + match mapped_child_is_dir(parent) { + Ok(true) => Ok(()), + Ok(false) => Err(SidecarError::Io(format!( "failed to create mapped guest directory {} -> {}: file exists and is not a directory", guest_path, parent.host_path.join(&parent.child_name).display() @@ -2314,7 +2692,7 @@ fn ensure_mapped_runtime_parent_dirs( )) })?; let parent_dir = open_mapped_runtime_directory_beneath(mapped, operation, prefix_parent)?; - fs::create_dir(parent_dir.handle.proc_path().join(prefix_name)).map_err(|error| { + create_dir_at(&parent_dir.handle, prefix_name).map_err(|error| { SidecarError::Io(format!( "{operation}: failed to create mapped guest parent {} under {}: {error}", mapped.guest_path, @@ -2399,7 +2777,7 @@ fn materialize_mapped_host_path_from_kernel( .map_err(kernel_error)?; ensure_mapped_runtime_parent_dirs(mapped, "fs.materialize")?; let parent = open_mapped_runtime_parent_beneath(mapped, "fs.materialize")?; - symlink(&target, mapped_runtime_parent_child_path(&parent)).map_err(|error| { + mapped_child_symlink(&parent, &target).map_err(|error| { SidecarError::Io(format!( "failed to materialize mapped guest symlink {} -> {} ({target}): {error}", guest_path, @@ -2424,7 +2802,7 @@ fn materialize_mapped_host_path_from_kernel( mapped, "fs.materialize", OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_WRONLY, - Mode::from_bits_truncate(stat.mode & 0o7777), + Mode::from_bits_truncate((stat.mode & 0o7777) as _), )?; fs::write(opened.handle.proc_path(), bytes).map_err(|error| { SidecarError::Io(format!( @@ -2436,7 +2814,7 @@ fn materialize_mapped_host_path_from_kernel( } let opened = - open_mapped_runtime_beneath(mapped, "fs.materialize", OFlag::O_PATH, Mode::empty())?; + 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), @@ -2561,11 +2939,8 @@ fn rename_mapped_host_path( let destination_host_path = destination_parent .host_path .join(&destination_parent.child_name); - rename_mapped_host_path_with_fallback( - &mapped_runtime_parent_child_path(&source_parent), - &mapped_runtime_parent_child_path(&destination_parent), - ) - .map(|()| Value::Null) + mapped_child_rename(&source_parent, &destination_parent) + .map(|()| Value::Null) .map_err(|error| { SidecarError::Io(format!( "failed to rename mapped guest path {} -> {} ({} -> {}): {error}", @@ -2588,6 +2963,9 @@ 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)?; @@ -3346,7 +3724,13 @@ mod tests { let parent = open_mapped_runtime_parent_beneath(&mapped, "test") .expect("open mapped parent for root child"); - assert_eq!(parent.host_path, host_root); + // `host_path` is the resolved fd's real path, which is canonical (on + // macOS the temp dir resolves through the `/private` firmlink), so + // compare against the canonicalized root rather than the raw value. + assert_eq!( + parent.host_path, + fs::canonicalize(&host_root).expect("canonicalize host root") + ); assert_eq!(parent.child_name.to_string_lossy(), "workspace"); } diff --git a/crates/sidecar/src/lib.rs b/crates/sidecar/src/lib.rs index 5b7113906..b603298e1 100644 --- a/crates/sidecar/src/lib.rs +++ b/crates/sidecar/src/lib.rs @@ -11,6 +11,8 @@ pub mod generated_protocol; #[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(crate) mod plugins; pub mod protocol; diff --git a/crates/sidecar/src/macos_fs.rs b/crates/sidecar/src/macos_fs.rs new file mode 100644 index 000000000..90591f450 --- /dev/null +++ b/crates/sidecar/src/macos_fs.rs @@ -0,0 +1,106 @@ +//! 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/sidecar/src/plugins/host_dir.rs b/crates/sidecar/src/plugins/host_dir.rs index 6b139df52..5693ae9dd 100644 --- a/crates/sidecar/src/plugins/host_dir.rs +++ b/crates/sidecar/src/plugins/host_dir.rs @@ -1,6 +1,16 @@ use nix::errno::Errno; -use nix::fcntl::{openat2, readlinkat, renameat, AtFlags, OFlag, OpenHow, ResolveFlag}; +#[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")] +const O_PATH_ANCHOR: OFlag = OFlag::O_RDONLY; use nix::sys::stat::{fstatat, mkdirat, utimensat, Mode, SFlag, UtimensatFlags}; use nix::sys::time::TimeSpec; use nix::unistd::{chown, linkat, symlinkat, unlinkat, Gid, Uid, UnlinkatFlags}; @@ -32,9 +42,18 @@ struct AnchoredFd { } impl AnchoredFd { + #[cfg(not(target_os = "macos"))] fn proc_path(&self) -> PathBuf { PathBuf::from(format!("/proc/self/fd/{}", self.fd)) } + + // 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`: operate on the + // already-resolved fd without re-resolving through the untrusted tree. + #[cfg(target_os = "macos")] + fn proc_path(&self) -> PathBuf { + PathBuf::from(format!("/dev/fd/{}", self.fd)) + } } impl AsRawFd for AnchoredFd { @@ -49,6 +68,19 @@ impl Drop for AnchoredFd { } } +/// 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()) +} + +#[cfg(target_os = "macos")] +fn anchored_fd_real_path(fd: &AnchoredFd) -> io::Result { + crate::macos_fs::fd_real_path(fd.as_raw_fd()) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct HostDirMountConfig { @@ -180,13 +212,38 @@ impl HostDirFilesystem { (normalized, relative) } + #[cfg(not(target_os = "macos"))] fn resolve_flags() -> ResolveFlag { ResolveFlag::RESOLVE_BENEATH | ResolveFlag::RESOLVE_NO_MAGICLINKS } fn open_beneath(&self, relative: &Path, flags: OFlag, mode: Mode) -> VfsResult { let relative_display = relative.display().to_string(); - let fd = openat2( + 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)), + })?; + 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() @@ -194,15 +251,16 @@ impl HostDirFilesystem { .mode(mode) .resolve(Self::resolve_flags()), ) - .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 }) + } + + #[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) } fn open_directory_beneath(&self, relative: &Path) -> VfsResult { @@ -214,8 +272,8 @@ impl HostDirFilesystem { } fn host_path_for_fd(&self, fd: &AnchoredFd, virtual_path: &str) -> VfsResult { - let host_path = fs::read_link(fd.proc_path()) - .map_err(|error| io_error_to_vfs("open", virtual_path, error))?; + 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) } @@ -223,7 +281,7 @@ impl HostDirFilesystem { fn open_metadata_beneath(&self, path: &str, op: &'static str) -> VfsResult { let (_, relative) = self.relative_virtual_path(path); let handle = - self.open_beneath(&relative, OFlag::O_PATH | OFlag::O_NOFOLLOW, Mode::empty())?; + 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() { @@ -273,7 +331,7 @@ impl HostDirFilesystem { match mkdirat( Some(parent_dir.as_raw_fd()), name, - Mode::from_bits_truncate(mode), + Mode::from_bits_truncate(mode as _), ) { Ok(()) => {} Err(Errno::EEXIST) => {} @@ -457,11 +515,13 @@ impl HostDirFilesystem { let ctime_nsec = stat.st_ctime_nsec.clamp(0, 999_999_999) as u32; VirtualStat { - mode: stat.st_mode, + // Widen for platform differences: mode_t/dev_t/nlink_t are narrower + // on macOS (u16/i32/u16) than on Linux. + mode: stat.st_mode as u32, size: stat.st_size as u64, blocks: stat.st_blocks as u64, - dev: stat.st_dev, - rdev: stat.st_rdev, + dev: stat.st_dev as u64, + rdev: stat.st_rdev as u64, is_directory: file_type == SFlag::S_IFDIR, is_symbolic_link: file_type == SFlag::S_IFLNK, atime_ms, @@ -472,8 +532,8 @@ impl HostDirFilesystem { ctime_nsec, birthtime_ms: ctime_ms, ino: stat.st_ino, - // st_nlink is u64 on x86_64 but u32 on aarch64; widen for both. - nlink: stat.st_nlink, + // st_nlink is u64 on x86_64 but u32 on aarch64 / u16 on macOS; widen. + nlink: stat.st_nlink as u64, uid: stat.st_uid, gid: stat.st_gid, } @@ -569,7 +629,7 @@ impl HostDirFilesystem { let handle = self.open_beneath( &relative, OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC, - Mode::from_bits_truncate(file_mode), + Mode::from_bits_truncate(file_mode as _), )?; let mut file = File::options() .write(true) @@ -585,7 +645,7 @@ impl HostDirFilesystem { mkdirat( Some(parent_dir.as_raw_fd()), name.as_os_str(), - Mode::from_bits_truncate(mode), + Mode::from_bits_truncate(mode as _), ) .map_err(|error| io_error_to_vfs("mkdir", &normalized, nix_to_io(error))) } @@ -687,13 +747,13 @@ impl VirtualFileSystem for HostDirFilesystem { fn exists(&self, path: &str) -> bool { let (_, relative) = self.relative_virtual_path(path); - self.open_beneath(&relative, OFlag::O_PATH, Mode::empty()) + self.open_beneath(&relative, O_PATH_ANCHOR, Mode::empty()) .is_ok() } fn stat(&mut self, path: &str) -> VfsResult { let (_, relative) = self.relative_virtual_path(path); - let handle = self.open_beneath(&relative, OFlag::O_PATH, Mode::empty())?; + 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)) @@ -733,7 +793,7 @@ impl VirtualFileSystem for HostDirFilesystem { fn realpath(&self, path: &str) -> VfsResult { let (_, relative) = self.relative_virtual_path(path); - let file = self.open_beneath(&relative, OFlag::O_PATH, Mode::empty())?; + let file = self.open_beneath(&relative, O_PATH_ANCHOR, Mode::empty())?; let resolved = self.host_path_for_fd(&file, path)?; self.host_to_virtual_path(&resolved, path) } diff --git a/crates/v8-runtime/src/bridge.rs b/crates/v8-runtime/src/bridge.rs index 6d5f93d43..e44174496 100644 --- a/crates/v8-runtime/src/bridge.rs +++ b/crates/v8-runtime/src/bridge.rs @@ -776,6 +776,9 @@ fn non_negative_c_long(value: libc::c_long) -> i64 { normalized.min(i128::from(i64::MAX)) as i64 } +// Used only by the non-macOS `getrusage(RUSAGE_THREAD)` path; macOS reads CPU +// time from Mach `time_value_t` instead. +#[cfg(not(target_os = "macos"))] fn timeval_to_micros(value: libc::timeval) -> u64 { let seconds = i128::from(value.tv_sec).max(0); let micros = i128::from(value.tv_usec).max(0); @@ -785,6 +788,7 @@ fn timeval_to_micros(value: libc::timeval) -> u64 { .min(i128::from(u64::MAX))) as u64 } +#[cfg(not(target_os = "macos"))] fn current_thread_resource_usage() -> Result { let mut usage = MaybeUninit::::uninit(); let result = unsafe { libc::getrusage(libc::RUSAGE_THREAD, usage.as_mut_ptr()) }; @@ -815,6 +819,81 @@ fn current_thread_resource_usage() -> Result Result<(u64, u64), String> { + // SAFETY: `pthread_mach_thread_np` yields the calling thread's Mach port; + // `thread_info` fully initialises `info` on KERN_SUCCESS. + unsafe { + let port = libc::pthread_mach_thread_np(libc::pthread_self()); + if port == 0 { + return Err("pthread_mach_thread_np returned MACH_PORT_NULL".to_string()); + } + let mut info = MaybeUninit::::zeroed(); + let mut count = (std::mem::size_of::() + / std::mem::size_of::()) + as libc::mach_msg_type_number_t; + let rc = libc::thread_info( + port, + libc::THREAD_BASIC_INFO as libc::thread_flavor_t, + info.as_mut_ptr() as libc::thread_info_t, + &mut count, + ); + if rc != libc::KERN_SUCCESS { + return Err(format!("thread_info(THREAD_BASIC_INFO) failed: {rc}")); + } + let info = info.assume_init(); + let to_micros = |t: libc::time_value_t| -> u64 { + let secs = i128::from(t.seconds).max(0); + let micros = i128::from(t.microseconds).max(0); + (secs + .saturating_mul(1_000_000) + .saturating_add(micros) + .min(i128::from(u64::MAX))) as u64 + }; + Ok((to_micros(info.user_time), to_micros(info.system_time))) + } +} + +#[cfg(target_os = "macos")] +fn current_thread_resource_usage() -> Result { + let (user_cpu_us, system_cpu_us) = macos_thread_cpu_micros()?; + + let mut usage = MaybeUninit::::uninit(); + let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + if result != 0 { + return Err(format!( + "getrusage(RUSAGE_SELF) failed: {}", + std::io::Error::last_os_error() + )); + } + let usage = unsafe { usage.assume_init() }; + Ok(ThreadResourceUsageSnapshot { + // Per-thread CPU time (accurate, from Mach thread_info). + user_cpu_us, + system_cpu_us, + // macOS reports ru_maxrss in bytes; normalise to KiB to match Linux. + max_rss_kib: non_negative_c_long(usage.ru_maxrss) / 1024, + // Process-wide best-effort: no per-thread source on macOS. + shared_memory_size: non_negative_c_long(usage.ru_ixrss), + unshared_data_size: non_negative_c_long(usage.ru_idrss), + unshared_stack_size: non_negative_c_long(usage.ru_isrss), + minor_page_faults: non_negative_c_long(usage.ru_minflt), + major_page_faults: non_negative_c_long(usage.ru_majflt), + swapped_out: non_negative_c_long(usage.ru_nswap), + fs_read: non_negative_c_long(usage.ru_inblock), + fs_write: non_negative_c_long(usage.ru_oublock), + ipc_sent: non_negative_c_long(usage.ru_msgsnd), + ipc_received: non_negative_c_long(usage.ru_msgrcv), + signals_count: non_negative_c_long(usage.ru_nsignals), + voluntary_context_switches: non_negative_c_long(usage.ru_nvcsw), + involuntary_context_switches: non_negative_c_long(usage.ru_nivcsw), + }) +} + fn normalize_openssl_version(raw: &str) -> String { raw.split_whitespace().nth(1).unwrap_or(raw).to_string() } diff --git a/crates/v8-runtime/src/timeout.rs b/crates/v8-runtime/src/timeout.rs index ed81bcfeb..5ce7b0be1 100644 --- a/crates/v8-runtime/src/timeout.rs +++ b/crates/v8-runtime/src/timeout.rs @@ -38,7 +38,11 @@ const CPU_BUDGET_POLL_INTERVAL: Duration = Duration::from_millis(50); /// The POSIX per-thread CPU clock id is derived from the thread's `pthread_t` /// and remains valid for the lifetime of that thread, so the watchdog can poll /// it via `clock_gettime` without running on the execution thread itself. -#[cfg(unix)] +/// +/// macOS has no `pthread_getcpuclockid`/per-thread POSIX clock, so it uses a +/// separate Mach-based implementation below (`pthread_mach_thread_np` + +/// `thread_info`) that exposes the same opaque `ThreadCpuClock` interface. +#[cfg(all(unix, not(target_os = "macos")))] #[cfg_attr(test, allow(dead_code))] #[derive(Clone, Copy)] pub(crate) struct ThreadCpuClock { @@ -50,7 +54,7 @@ pub(crate) struct ThreadCpuClock { /// /// Returns `None` if the platform refuses to expose a per-thread CPU clock, in /// which case no CPU budget can be enforced. -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "macos")))] #[cfg_attr(test, allow(dead_code))] pub(crate) fn current_thread_cpu_clock() -> Option { // SAFETY: `pthread_self` is always callable; `pthread_getcpuclockid` writes @@ -66,7 +70,7 @@ pub(crate) fn current_thread_cpu_clock() -> Option { } } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "macos")))] impl ThreadCpuClock { /// Read accumulated CPU time for the captured thread, in milliseconds. /// Returns `None` if the clock read fails. @@ -86,6 +90,64 @@ impl ThreadCpuClock { } } +/// macOS per-thread CPU clock. There is no `pthread_getcpuclockid` on Apple +/// platforms, so the thread's CPU time is read through the Mach +/// `thread_info(THREAD_BASIC_INFO)` call. The Mach thread port obtained via +/// `pthread_mach_thread_np` stays valid for the thread's lifetime and may be +/// inspected from another (watchdog) thread, matching the opaque-handle +/// contract above. +#[cfg(target_os = "macos")] +#[cfg_attr(test, allow(dead_code))] +#[derive(Clone, Copy)] +pub(crate) struct ThreadCpuClock { + port: libc::mach_port_t, +} + +#[cfg(target_os = "macos")] +#[cfg_attr(test, allow(dead_code))] +pub(crate) fn current_thread_cpu_clock() -> Option { + // SAFETY: `pthread_mach_thread_np` returns the Mach thread port for the + // calling pthread; the port is valid for the thread's lifetime. + let port = unsafe { libc::pthread_mach_thread_np(libc::pthread_self()) }; + // MACH_PORT_NULL is 0. + if port == 0 { + None + } else { + Some(ThreadCpuClock { port }) + } +} + +#[cfg(target_os = "macos")] +impl ThreadCpuClock { + /// Read accumulated CPU time (user + system) for the captured thread, in + /// milliseconds. Returns `None` if the Mach query fails. + #[cfg_attr(test, allow(dead_code))] + fn elapsed_ms(self) -> Option { + // SAFETY: `thread_info` fully initialises `info` when it returns + // KERN_SUCCESS; the count is the documented THREAD_BASIC_INFO length. + unsafe { + let mut info = std::mem::MaybeUninit::::zeroed(); + let mut count = (std::mem::size_of::() + / std::mem::size_of::()) + as libc::mach_msg_type_number_t; + let rc = libc::thread_info( + self.port, + libc::THREAD_BASIC_INFO as libc::thread_flavor_t, + info.as_mut_ptr() as libc::thread_info_t, + &mut count, + ); + if rc != libc::KERN_SUCCESS { + return None; + } + let info = info.assume_init(); + let ms = (info.user_time.seconds as i128 + info.system_time.seconds as i128) * 1_000 + + (info.user_time.microseconds as i128 + info.system_time.microseconds as i128) + / 1_000; + Some(ms.max(0) as u64) + } + } +} + /// Guard for per-execution TRUE CPU-time budget enforcement. /// /// Spawns a watchdog thread that polls the execution thread's CPU clock every From 0617da222223cef11a4ff62a3a4a477223c5b024 Mon Sep 17 00:00:00 2001 From: ABCxFF Date: Mon, 22 Jun 2026 21:12:08 +0000 Subject: [PATCH 3/3] [SLOP(claude-opus-4-8)] fix(core): restore Sidecar public export and sidecar-client subpath --- packages/core/package.json | 6 +++--- packages/core/src/index.ts | 2 +- packages/core/src/sidecar-process.ts | 4 ++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 1b12f1e7d..5cc1ba348 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -68,9 +68,9 @@ "default": "./dist/process.js" }, "./sidecar-client": { - "types": "./dist/sidecar-client.d.ts", - "import": "./dist/sidecar-client.js", - "default": "./dist/sidecar-client.js" + "types": "./dist/sidecar-process.d.ts", + "import": "./dist/sidecar-process.js", + "default": "./dist/sidecar-process.js" }, "./test-runtime": { "types": "./dist/test-runtime.d.ts", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 20cb7b4ce..4609a9ea6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,7 +19,7 @@ export * from "./protocol-client.js"; export * from "./protocol-frames.js"; export * from "./request-payloads.js"; export * from "./response-payloads.js"; -export { SidecarProcess } from "./sidecar-process.js"; +export { SidecarProcess, Sidecar } from "./sidecar-process.js"; export type { SidecarSpawnOptions } from "./sidecar-process.js"; export * from "./state.js"; export * as protocol from "./generated-protocol.js"; diff --git a/packages/core/src/sidecar-process.ts b/packages/core/src/sidecar-process.ts index fc2eb5bd8..a78efa900 100644 --- a/packages/core/src/sidecar-process.ts +++ b/packages/core/src/sidecar-process.ts @@ -54,6 +54,10 @@ export { SidecarProcessExited, } from "./process.js"; export { SidecarEventBufferOverflow } from "./event-buffer.js"; +// `Sidecar` is the public name for the native sidecar process client. The class +// is `SidecarProcess` internally; consumers import it as `Sidecar` via the +// `@secure-exec/core/sidecar-client` subpath and the package root. +export { SidecarProcess as Sidecar }; const BRIDGE_CONTRACT_VERSION = 1;