From 856e57ef60d178cd7ea41d054b7fc4f8a1f5ca50 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Fri, 3 Jul 2026 17:15:25 +0900 Subject: [PATCH 1/2] posix: open file descriptors non-inheritable (PEP 446) os.open ORs O_CLOEXEC (unix) / O_NOINHERIT (Windows) into flags so the descriptor is not inherited across exec, matching interp_posix.py. Assisted-by: Claude --- pyre/pyre-interpreter/src/module/posix/interp_posix.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs index 145882b9190..689a3d67387 100644 --- a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs +++ b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs @@ -441,6 +441,13 @@ pub fn register_module(ns: &mut DictStorage) { } else { 0o777 }; + // Open the fd non-inheritable (PEP 446) so the descriptor does not + // leak across exec into child processes: O_CLOEXEC on unix, + // O_NOINHERIT on Windows (O_CLOEXEC is unix-only in libc). + #[cfg(unix)] + let flags = flags | libc::O_CLOEXEC; + #[cfg(windows)] + let flags = flags | libc::O_NOINHERIT; let c_path = std::ffi::CString::new(path.as_bytes()) .map_err(|_| crate::PyError::value_error("embedded null in path"))?; let fd = unsafe { libc::open(c_path.as_ptr(), flags, mode as libc::c_uint) }; From b6466916c559b148b9c078767b6ba7a19d183bf0 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Fri, 3 Jul 2026 17:17:01 +0900 Subject: [PATCH 2/2] sandbox: introduce the RPython-style compile-out sandbox Add the `sandbox` compile-out surface (issue #285): a `pyre-sandbox` crate (rmarshal codec, protocol, VFS, controller, client trampoline, seccomp allowlist, e2e test) and the `pyre interact` controller subcommand, driven through a `host_seam` OS-call seam in pyre-interpreter that routes the `ll_os.*`/`ll_time.*` surface, program and diagnostic stdio, import source loading, and entropy through the controller under `--features sandbox`. Gate stray raw host-access sites out of the sandbox build and enforce it with a sandbox-scoped clippy `disallowed-methods/types` fence (ci/clippy-sandbox) run as a CI job. Install the seccomp BPF backstop by default on Linux (PYRE_SANDBOX_NO_SECCOMP escape). Port VirtualizedSocketProc `tcp://` mediation as opt-in (--allow-net) and add `--log`/`--heapsize` CLI parity. Assisted-by: Claude --- .github/workflows/pyre-ci.yml | 62 + Cargo.lock | 11 + Cargo.toml | 2 + ci/clippy-sandbox/clippy.toml | 68 + pyre/pyre-interpreter/Cargo.toml | 5 + pyre/pyre-interpreter/build.rs | 5 + pyre/pyre-interpreter/src/builtins.rs | 297 ++-- pyre/pyre-interpreter/src/call.rs | 48 +- pyre/pyre-interpreter/src/error.rs | 14 +- pyre/pyre-interpreter/src/eval.rs | 16 +- pyre/pyre-interpreter/src/host_seam.rs | 731 ++++++++++ pyre/pyre-interpreter/src/importing.rs | 228 ++- pyre/pyre-interpreter/src/lib.rs | 36 +- .../src/module/_locale/interp_locale.rs | 50 +- .../src/module/_random/mod.rs | 30 +- pyre/pyre-interpreter/src/module/mod.rs | 19 +- .../src/module/posix/interp_posix.rs | 559 ++++++-- .../src/module/signal/interp_signal.rs | 63 +- pyre/pyre-interpreter/src/module/sys/vm.rs | 117 +- .../pyre-interpreter/src/module/thread/mod.rs | 21 +- .../src/module/time/interp_time.rs | 79 +- pyre/pyre-interpreter/src/module/time/mod.rs | 31 +- pyre/pyre-interpreter/src/sandbox/mod.rs | 1 - pyre/pyre-interpreter/src/warn.rs | 2 +- pyre/pyre-sandbox/Cargo.toml | 14 + pyre/pyre-sandbox/src/client.rs | 342 +++++ pyre/pyre-sandbox/src/controller.rs | 405 ++++++ pyre/pyre-sandbox/src/lib.rs | 86 ++ pyre/pyre-sandbox/src/protocol.rs | 118 ++ pyre/pyre-sandbox/src/rmarshal.rs | 736 ++++++++++ pyre/pyre-sandbox/src/sandlib.rs | 1228 +++++++++++++++++ pyre/pyre-sandbox/src/seccomp.rs | 292 ++++ .../src/sandbox => pyre-sandbox/src}/vfs.rs | 253 +++- pyre/pyre-sandbox/tests/e2e_interact.rs | 234 ++++ pyre/pyrex/Cargo.toml | 13 + pyre/pyrex/src/lib.rs | 337 ++++- 36 files changed, 6068 insertions(+), 485 deletions(-) create mode 100644 ci/clippy-sandbox/clippy.toml create mode 100644 pyre/pyre-interpreter/src/host_seam.rs delete mode 100644 pyre/pyre-interpreter/src/sandbox/mod.rs create mode 100644 pyre/pyre-sandbox/Cargo.toml create mode 100644 pyre/pyre-sandbox/src/client.rs create mode 100644 pyre/pyre-sandbox/src/controller.rs create mode 100644 pyre/pyre-sandbox/src/lib.rs create mode 100644 pyre/pyre-sandbox/src/protocol.rs create mode 100644 pyre/pyre-sandbox/src/rmarshal.rs create mode 100644 pyre/pyre-sandbox/src/sandlib.rs create mode 100644 pyre/pyre-sandbox/src/seccomp.rs rename pyre/{pyre-interpreter/src/sandbox => pyre-sandbox/src}/vfs.rs (62%) create mode 100644 pyre/pyre-sandbox/tests/e2e_interact.rs diff --git a/.github/workflows/pyre-ci.yml b/.github/workflows/pyre-ci.yml index eb111445e0c..942c4c015b6 100644 --- a/.github/workflows/pyre-ci.yml +++ b/.github/workflows/pyre-ci.yml @@ -509,3 +509,65 @@ jobs: fi cargo build -p pyre-wasm --target wasm32-unknown-unknown \ --no-default-features --features "${{ matrix.binding }}" + + sandbox-build: + name: sandbox build + e2e (ubuntu-24.04) + runs-on: ubuntu-24.04 + needs: prepare-charon-llbc-linux + if: ${{ !cancelled() && needs.prepare-charon-llbc-linux.result == 'success' }} + env: + # See prepare-charon-llbc: downstream jobs download the prepared Charon + # artifact into this workspace path. The sandbox `pyre` binary still + # pulls pyre-jit, whose build needs the extracted LLBC. + PYRE_SHARED_BUILD: ${{ github.workspace }}/.pyre-build + CHARON_VERSION: nightly-2026.05.29 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + cache-bin: false + - name: Download Charon artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: charon-${{ runner.os }}-${{ runner.arch }} + path: .pyre-build/charon + - name: Download LLBC artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: llbc-${{ runner.os }}-${{ runner.arch }} + path: build/llbc + - name: Verify prepared Charon/LLBC + shell: bash + run: | + test -d .pyre-build/charon + test -s build/llbc/pyre-object.ullbc + test -s build/llbc/pyre-interpreter.ullbc + test -s build/llbc/pyre-jit.ullbc + - name: Build pyre --features sandbox + # A green sandbox build is the fails-closed proof: every mediated module + # names libc through host_seam::sys, so any direct syscall left outside + # the seam fails to compile here. On Linux `sandbox` installs the seccomp + # backstop by default, so this also compile-checks the install path. + run: cargo build --release -p pyrex --bin pyre --features sandbox + - name: Sandbox compile-out fence (clippy) + # Extends the fails-closed proof beyond libc. host_seam::sys already makes + # a stray `libc::` syscall fail to compile under sandbox; this forbids the + # non-libc host surface too (std::fs / std::env / std::io stdio / + # std::process / std::net) in the untrusted interpreter. `--no-deps` scopes + # it to pyre-interpreter's own code — the trusted `pyre-sandbox` controller + # and the host-side build script are exempt. CLIPPY_CONF_DIR points clippy + # at ci/clippy-sandbox/clippy.toml, which normal clippy never discovers, so + # a green run proves no raw host call survives in sandbox-live code. + env: + CLIPPY_CONF_DIR: ${{ github.workspace }}/ci/clippy-sandbox + run: cargo clippy -p pyre-interpreter --no-deps --features sandbox,dynasm -- -A clippy::all -D clippy::disallowed_methods -D clippy::disallowed_types + - name: Run sandbox end-to-end suite + # Exercises the compile-out sandbox (virtual FS + escape blocking) through + # the controller. On Linux the e2e binary (built `--features sandbox`) + # installs the seccomp allowlist, so this run also RUNTIME-VALIDATES it: a + # syscall the interpreter needs but the allowlist omits makes the child + # SIGSYS (exit 159) and fails the suite. + run: cargo test --release -p pyre-sandbox --test e2e_interact -- --ignored --nocapture diff --git a/Cargo.lock b/Cargo.lock index b4269c9dcbc..0db9efaef5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2006,6 +2006,7 @@ dependencies = [ "pyre-macros", "pyre-native", "pyre-object", + "pyre-sandbox", "rustpython-compiler", "rustpython-compiler-core", "rustpython-host_env", @@ -2104,6 +2105,15 @@ dependencies = [ "rustpython-wtf8", ] +[[package]] +name = "pyre-sandbox" +version = "0.0.2" +dependencies = [ + "indexmap", + "libc", + "tempfile", +] + [[package]] name = "pyre-wasm" version = "0.0.2" @@ -2148,6 +2158,7 @@ dependencies = [ "pyre-interpreter", "pyre-jit", "pyre-object", + "pyre-sandbox", "rustpython-compiler", "rustyline", ] diff --git a/Cargo.toml b/Cargo.toml index 88703db1f6a..1398fd21422 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "pyre/pyre-macros", "pyre/pyre-native", "pyre/pyre-interpreter", + "pyre/pyre-sandbox", "pyre/pyre-module", "pyre/pyre-jit", "pyre/pyre-jit-trace", @@ -53,6 +54,7 @@ pyre-object = { version = "0.0.2", path = "pyre/pyre-object" } pyre-macros = { version = "0.0.2", path = "pyre/pyre-macros" } pyre-native = { version = "0.0.2", path = "pyre/pyre-native" } pyre-interpreter = { version = "0.0.2", path = "pyre/pyre-interpreter" } +pyre-sandbox = { version = "0.0.2", path = "pyre/pyre-sandbox" } pyre-module = { version = "0.0.2", path = "pyre/pyre-module" } pyre-jit = { version = "0.0.2", path = "pyre/pyre-jit" } pyre-jit-trace = { version = "0.0.2", path = "pyre/pyre-jit-trace" } diff --git a/ci/clippy-sandbox/clippy.toml b/ci/clippy-sandbox/clippy.toml new file mode 100644 index 00000000000..af2aeab17e7 --- /dev/null +++ b/ci/clippy-sandbox/clippy.toml @@ -0,0 +1,68 @@ +# Sandbox compile-out fence. +# +# This config is applied ONLY by the dedicated sandbox clippy CI job, which +# points clippy at this directory via `CLIPPY_CONF_DIR`. Normal builds and +# normal `cargo clippy` never discover it (it is not an ancestor of any crate), +# so day-to-day development is unaffected. +# +# It forbids raw host-OS access in the `--features sandbox` build of the +# untrusted interpreter. Under sandbox every mediated OS call must go through +# `crate::host_seam::ops::*`, which marshals the request to the trusted +# controller; a direct `std::fs`/`std::env`/`std::io` stdio/`std::process`/ +# `std::net` call would bypass the seam and escape the sandbox. Because clippy +# only sees code that actually compiles under the feature set, the +# `#[cfg(not(feature = "sandbox"))]` real-syscall paths are invisible here — a +# clean run therefore proves no raw host call survives in sandbox-live code. +# +# The trusted controller lives in the separate `pyre-sandbox` crate and is not +# linted by this job (it lints `-p pyre-interpreter` only). + +disallowed-methods = [ + # Filesystem entry points + { path = "std::fs::read", reason = "raw host FS read; route through crate::host_seam::ops" }, + { path = "std::fs::write", reason = "raw host FS write; route through crate::host_seam::ops" }, + { path = "std::fs::read_to_string", reason = "raw host FS read; route through crate::host_seam::ops" }, + { path = "std::fs::read_link", reason = "raw host FS access; route through crate::host_seam::ops" }, + { path = "std::fs::read_dir", reason = "raw host FS listdir; route through crate::host_seam::ops" }, + { path = "std::fs::metadata", reason = "raw host FS stat; route through crate::host_seam::ops" }, + { path = "std::fs::symlink_metadata", reason = "raw host FS lstat; route through crate::host_seam::ops" }, + { path = "std::fs::canonicalize", reason = "raw host FS access; route through crate::host_seam::ops" }, + { path = "std::fs::remove_file", reason = "host FS mutation is forbidden in the sandbox" }, + { path = "std::fs::remove_dir", reason = "host FS mutation is forbidden in the sandbox" }, + { path = "std::fs::remove_dir_all", reason = "host FS mutation is forbidden in the sandbox" }, + { path = "std::fs::create_dir", reason = "host FS mutation is forbidden in the sandbox" }, + { path = "std::fs::create_dir_all", reason = "host FS mutation is forbidden in the sandbox" }, + { path = "std::fs::rename", reason = "host FS mutation is forbidden in the sandbox" }, + { path = "std::fs::copy", reason = "host FS mutation is forbidden in the sandbox" }, + { path = "std::fs::hard_link", reason = "host FS mutation is forbidden in the sandbox" }, + { path = "std::fs::set_permissions", reason = "host FS mutation is forbidden in the sandbox" }, + # Process environment + { path = "std::env::var", reason = "raw process env; route through crate::host_seam::ops::getenv" }, + { path = "std::env::var_os", reason = "raw process env; route through crate::host_seam::ops::getenv" }, + { path = "std::env::vars", reason = "raw process env; route through crate::host_seam::ops::envitems" }, + { path = "std::env::vars_os", reason = "raw process env; route through crate::host_seam::ops::envitems" }, + { path = "std::env::set_var", reason = "host env mutation is forbidden in the sandbox" }, + { path = "std::env::remove_var", reason = "host env mutation is forbidden in the sandbox" }, + { path = "std::env::current_dir", reason = "raw host cwd; route through crate::host_seam::ops::getcwd" }, + { path = "std::env::set_current_dir", reason = "host cwd mutation is forbidden in the sandbox" }, + { path = "std::env::current_exe", reason = "leaks the real executable path; forbidden in the sandbox" }, + { path = "std::env::temp_dir", reason = "leaks a real host path; forbidden in the sandbox" }, + # Standard streams (the marshalling pipe is the only sanctioned raw stdio) + { path = "std::io::stdout", reason = "raw stdout; route through crate::host_seam::ops::write" }, + { path = "std::io::stderr", reason = "raw stderr; route through crate::host_seam::ops::write" }, + { path = "std::io::stdin", reason = "raw stdin; mediated by the controller" }, + { path = "std::io::_print", reason = "print!/println! writes real stdout; route through the host_seam" }, + { path = "std::io::_eprint", reason = "eprint!/eprintln! writes real stderr; route through the host_seam" }, + # Process control + { path = "std::process::exit", reason = "raw process exit; the controller owns process lifetime" }, + { path = "std::process::abort", reason = "raw process abort; the controller owns process lifetime" }, +] + +disallowed-types = [ + { path = "std::fs::File", reason = "raw host file handle; route file I/O through crate::host_seam::ops" }, + { path = "std::fs::OpenOptions", reason = "raw host file open; route through crate::host_seam::ops::open" }, + { path = "std::process::Command", reason = "process spawn is forbidden in the sandbox" }, + { path = "std::net::TcpStream", reason = "raw network; use the mediated tcp:// controller path" }, + { path = "std::net::TcpListener", reason = "raw network listen is forbidden in the sandbox" }, + { path = "std::net::UdpSocket", reason = "raw network is forbidden in the sandbox" }, +] diff --git a/pyre/pyre-interpreter/Cargo.toml b/pyre/pyre-interpreter/Cargo.toml index 2523b1ae8c5..7a710f0fc58 100644 --- a/pyre/pyre-interpreter/Cargo.toml +++ b/pyre/pyre-interpreter/Cargo.toml @@ -9,6 +9,10 @@ description = "Python bytecode interpreter for pyre" [features] default = ["host_env"] host_env = ["dep:rustpython-host_env"] +# RPython-style sandbox: route every OS call through host_seam's marshalling +# trampoline instead of the real syscall. Implies host_env (same call surface, +# swapped bodies); the real-vs-trampoline choice lives only inside host_seam. +sandbox = ["host_env", "dep:pyre-sandbox"] cranelift = ["majit-metainterp/cranelift"] dynasm = ["majit-metainterp/dynasm"] # Embed the pure-Python stdlib closure needed for `import re` into the binary @@ -35,6 +39,7 @@ num-integer = { workspace = true } pymath = { workspace = true } sre-engine = { workspace = true } rustpython-host_env = { workspace = true, optional = true } +pyre-sandbox = { workspace = true, optional = true } libc = { workspace = true } siphasher = { workspace = true } caseless = { workspace = true } diff --git a/pyre/pyre-interpreter/build.rs b/pyre/pyre-interpreter/build.rs index 8bf5fad9d81..d16d96e4ee1 100644 --- a/pyre/pyre-interpreter/build.rs +++ b/pyre/pyre-interpreter/build.rs @@ -8,6 +8,11 @@ //! //! When the feature is off (every native build) this returns immediately and //! produces nothing. +//! +//! This build script runs on the host at compile time — it is not part of the +//! sandbox binary — so the sandbox compile-out fence (`ci/clippy-sandbox`) does +//! not apply to its host filesystem/env access. +#![allow(clippy::disallowed_methods, clippy::disallowed_types)] use std::path::Path; diff --git a/pyre/pyre-interpreter/src/builtins.rs b/pyre/pyre-interpreter/src/builtins.rs index f2add22fd4b..2e5a33e838a 100644 --- a/pyre/pyre-interpreter/src/builtins.rs +++ b/pyre/pyre-interpreter/src/builtins.rs @@ -2281,8 +2281,7 @@ fn builtin_print(args: &[PyObjectRef]) -> Result { if flush { match file { None => { - use std::io::Write; - let _ = std::io::stdout().flush(); + crate::host_seam::flush_stdout(); } Some(fp) => { let r = crate::baseobjspace::call_method(fp, "flush", &[]); @@ -7381,9 +7380,14 @@ fn init_file_wrapper_type(ns: &mut DictStorage) { if let Some(fd) = file_get_fd(args[0]) { #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] { + #[cfg(not(feature = "sandbox"))] return Ok(w_bool_from( unsafe { libc::lseek(fd, 0, libc::SEEK_CUR) } >= 0, )); + #[cfg(feature = "sandbox")] + return Ok(w_bool_from( + crate::host_seam::ops::lseek(fd, 0, libc::SEEK_CUR).is_ok(), + )); } #[cfg(any(not(feature = "host_env"), target_arch = "wasm32"))] { @@ -7411,10 +7415,17 @@ fn init_file_wrapper_type(ns: &mut DictStorage) { .unwrap_or(0) as i32; #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] { - let pos = unsafe { libc::lseek(fd, offset as libc::off_t, whence) }; - if pos < 0 { - return Err(fd_io_err(std::io::Error::last_os_error())); - } + #[cfg(not(feature = "sandbox"))] + let pos = { + let pos = unsafe { libc::lseek(fd, offset as libc::off_t, whence) }; + if pos < 0 { + return Err(fd_io_err(std::io::Error::last_os_error())); + } + pos + }; + #[cfg(feature = "sandbox")] + let pos = crate::host_seam::ops::lseek(fd, offset, whence) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; return Ok(w_int_new(pos as i64)); } #[cfg(any(not(feature = "host_env"), target_arch = "wasm32"))] @@ -7440,10 +7451,17 @@ fn init_file_wrapper_type(ns: &mut DictStorage) { if let Some(fd) = file_get_fd(args[0]) { #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] { - let pos = unsafe { libc::lseek(fd, 0, libc::SEEK_CUR) }; - if pos < 0 { - return Err(fd_io_err(std::io::Error::last_os_error())); - } + #[cfg(not(feature = "sandbox"))] + let pos = { + let pos = unsafe { libc::lseek(fd, 0, libc::SEEK_CUR) }; + if pos < 0 { + return Err(fd_io_err(std::io::Error::last_os_error())); + } + pos + }; + #[cfg(feature = "sandbox")] + let pos = crate::host_seam::ops::lseek(fd, 0, libc::SEEK_CUR) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; return Ok(w_int_new(pos as i64)); } #[cfg(any(not(feature = "host_env"), target_arch = "wasm32"))] @@ -7522,8 +7540,28 @@ fn file_is_binary(self_obj: PyObjectRef) -> bool { .unwrap_or(false) } +/// Reduce a [`SeamError`] to a `std::io::Error` so the fd helpers keep their +/// `io::Result` signature (the caller's `fd_io_err` then maps it to `OSError`). +#[cfg(all(feature = "host_env", not(target_arch = "wasm32"), feature = "sandbox"))] +fn seam_to_io(e: crate::host_seam::SeamError) -> std::io::Error { + match e { + crate::host_seam::SeamError::Os(errno) => std::io::Error::from_raw_os_error(errno), + _ => std::io::Error::other("sandbox error"), + } +} + #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] fn fd_read_into(fd: i32, buf: &mut [u8]) -> std::io::Result { + #[cfg(feature = "sandbox")] + { + // The controller services one read per request; copy the reply (at most + // `buf.len()` bytes) into the caller's buffer. + let data = crate::host_seam::ops::read(fd, buf.len() as i64).map_err(seam_to_io)?; + let n = data.len().min(buf.len()); + buf[..n].copy_from_slice(&data[..n]); + return Ok(n); + } + #[cfg(not(feature = "sandbox"))] loop { // `count` is `size_t` on Unix but `c_uint` on Windows; `as _` casts // to whichever the platform's `libc::read` expects. @@ -7733,13 +7771,21 @@ fn file_method_write(args: &[PyObjectRef]) -> Result Result Result Result<(), crate::PyError> { - let dirty = crate::baseobjspace::getattr_str(obj, "__file_dirty__") - .ok() - .map(|v| unsafe { pyre_object::is_bool(v) && pyre_object::w_bool_get_value(v) }) - .unwrap_or(false); - if !dirty { - return Ok(()); - } - if let (Ok(name), Ok(mode)) = ( - crate::baseobjspace::getattr_str(obj, "__file_name__"), - crate::baseobjspace::getattr_str(obj, "__file_mode__"), - ) { - let name_s = unsafe { pyre_object::w_str_get_value(name).to_string() }; - let mode_s = unsafe { pyre_object::w_str_get_value(mode).to_string() }; - let data = file_get_data(obj); - let append = mode_s.contains('a'); - let write_res = if append { - std::fs::OpenOptions::new() - .append(true) - .create(true) - .open(&name_s) - .and_then(|mut f| std::io::Write::write_all(&mut f, &data)) - } else { - std::fs::write(&name_s, &data) - }; - if let Err(e) = write_res { - return Err(crate::PyError::os_error_with_errno( - e.raw_os_error().unwrap_or(5), - format!("{e}: '{name_s}'"), - )); + // Under sandbox every file object is fd-backed (opens go through the seam) + // and the controller enforces read-only, so a dirty writable buffer never + // reaches here; keep the raw std::fs write out of the sandbox build. + #[cfg(feature = "sandbox")] + { + let _ = obj; + Ok(()) + } + #[cfg(not(feature = "sandbox"))] + { + let dirty = crate::baseobjspace::getattr_str(obj, "__file_dirty__") + .ok() + .map(|v| unsafe { pyre_object::is_bool(v) && pyre_object::w_bool_get_value(v) }) + .unwrap_or(false); + if !dirty { + return Ok(()); } - crate::baseobjspace::setattr_str(obj, "__file_dirty__", w_bool_from(false))?; + if let (Ok(name), Ok(mode)) = ( + crate::baseobjspace::getattr_str(obj, "__file_name__"), + crate::baseobjspace::getattr_str(obj, "__file_mode__"), + ) { + let name_s = unsafe { pyre_object::w_str_get_value(name).to_string() }; + let mode_s = unsafe { pyre_object::w_str_get_value(mode).to_string() }; + let data = file_get_data(obj); + let append = mode_s.contains('a'); + let write_res = if append { + std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(&name_s) + .and_then(|mut f| std::io::Write::write_all(&mut f, &data)) + } else { + std::fs::write(&name_s, &data) + }; + if let Err(e) = write_res { + return Err(crate::PyError::os_error_with_errno( + e.raw_os_error().unwrap_or(5), + format!("{e}: '{name_s}'"), + )); + } + crate::baseobjspace::setattr_str(obj, "__file_dirty__", w_bool_from(false))?; + } + Ok(()) } - Ok(()) } /// `flush()` — push any buffered writes to disk without closing. For @@ -8027,58 +8098,82 @@ pub fn builtin_open(args: &[PyObjectRef]) -> Result } } - let data: Vec = if reading && !mode.contains('w') && !mode.contains('x') { - #[cfg(any(not(feature = "host_env"), target_arch = "wasm32"))] - { - // Sandbox-intentional: with the host_env feature off the - // interpreter must not reach `std::fs` directly. Callers in - // sandbox builds route file I/O through the VFS shim instead; - // returning NotImplementedError keeps the open() builtin from - // silently leaking real-FS reads here. - let _ = (binary, &path); - return Err(crate::PyError::not_implemented( - "open() for reading requires host_env feature", - )); - } - #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] - let read_result = rustpython_host_env::fs::read(&path); - #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] - match read_result { - // Hold the exact file bytes; text-mode reads decode on the way - // out (`fd_bytes_to_obj`), so non-UTF-8 content is preserved. - Ok(bytes) => bytes, - Err(_e) if writing => Vec::new(), - Err(e) => { - return Err(crate::PyError::os_error_with_errno( - e.raw_os_error().unwrap_or(2), - format!("{e}: '{path}'"), + // The sandbox routes the whole open→read/write→close chain through the + // controller: acquire a real fd via the trampoline and hand back an + // fd-backed wrapper. The in-memory `host_env::fs::read` path below would + // otherwise read the real filesystem, escaping the sandbox. + #[cfg(feature = "sandbox")] + { + let _ = (reading, writing); + let flags = open_flags_for_mode(&mode); + let fd = crate::host_seam::ops::open(path.as_bytes(), flags, 0o666) + .map_err(|e| crate::host_seam::seam_os_err(e, &path))?; + let wrapper = pyre_object::w_instance_new(file_wrapper_type()); + let _ = crate::baseobjspace::setattr_str(wrapper, "__file_fd__", w_int_new(fd as i64)); + let _ = crate::baseobjspace::setattr_str(wrapper, "__file_binary__", w_bool_from(binary)); + let _ = crate::baseobjspace::setattr_str(wrapper, "__file_mode__", w_str_new(&mode)); + let _ = crate::baseobjspace::setattr_str(wrapper, "encoding", w_str_new(&encoding)); + let _ = crate::baseobjspace::setattr_str(wrapper, "errors", w_str_new(&errors)); + let _ = crate::baseobjspace::setattr_str(wrapper, "name", path_obj); + let _ = crate::baseobjspace::setattr_str(wrapper, "mode", w_str_new(&mode)); + let _ = crate::baseobjspace::setattr_str(wrapper, "closed", w_bool_from(false)); + Ok(wrapper) + } + #[cfg(not(feature = "sandbox"))] + { + let data: Vec = if reading && !mode.contains('w') && !mode.contains('x') { + #[cfg(any(not(feature = "host_env"), target_arch = "wasm32"))] + { + // Sandbox-intentional: with the host_env feature off the + // interpreter must not reach `std::fs` directly. Callers in + // sandbox builds route file I/O through the VFS shim instead; + // returning NotImplementedError keeps the open() builtin from + // silently leaking real-FS reads here. + let _ = (binary, &path); + return Err(crate::PyError::not_implemented( + "open() for reading requires host_env feature", )); } - } - } else { - Vec::new() - }; + #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] + let read_result = rustpython_host_env::fs::read(&path); + #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] + match read_result { + // Hold the exact file bytes; text-mode reads decode on the way + // out (`fd_bytes_to_obj`), so non-UTF-8 content is preserved. + Ok(bytes) => bytes, + Err(_e) if writing => Vec::new(), + Err(e) => { + return Err(crate::PyError::os_error_with_errno( + e.raw_os_error().unwrap_or(2), + format!("{e}: '{path}'"), + )); + } + } + } else { + Vec::new() + }; - let wrapper = pyre_object::w_instance_new(file_wrapper_type()); - let _ = crate::baseobjspace::setattr_str( - wrapper, - "__file_data__", - pyre_object::bytesobject::w_bytes_from_bytes(&data), - ); - let _ = crate::baseobjspace::setattr_str(wrapper, "__file_pos__", w_int_new(0)); - let _ = crate::baseobjspace::setattr_str(wrapper, "__file_name__", w_str_new(&path)); - let _ = crate::baseobjspace::setattr_str(wrapper, "__file_mode__", w_str_new(&mode)); - // Carry binary-ness so read/readline wrap their chunks as `bytes` in - // binary mode (`'rb'`), matching the fd-backed branch above. Without - // this a path-backed `open(p, 'rb').readline()` would hand back `str`, - // breaking `tokenize.detect_encoding` (`first.startswith(BOM_UTF8)`). - let _ = crate::baseobjspace::setattr_str(wrapper, "__file_binary__", w_bool_from(binary)); - let _ = crate::baseobjspace::setattr_str(wrapper, "encoding", w_str_new(&encoding)); - let _ = crate::baseobjspace::setattr_str(wrapper, "errors", w_str_new(&errors)); - let _ = crate::baseobjspace::setattr_str(wrapper, "name", w_str_new(&path)); - let _ = crate::baseobjspace::setattr_str(wrapper, "mode", w_str_new(&mode)); - let _ = crate::baseobjspace::setattr_str(wrapper, "closed", w_bool_from(false)); - Ok(wrapper) + let wrapper = pyre_object::w_instance_new(file_wrapper_type()); + let _ = crate::baseobjspace::setattr_str( + wrapper, + "__file_data__", + pyre_object::bytesobject::w_bytes_from_bytes(&data), + ); + let _ = crate::baseobjspace::setattr_str(wrapper, "__file_pos__", w_int_new(0)); + let _ = crate::baseobjspace::setattr_str(wrapper, "__file_name__", w_str_new(&path)); + let _ = crate::baseobjspace::setattr_str(wrapper, "__file_mode__", w_str_new(&mode)); + // Carry binary-ness so read/readline wrap their chunks as `bytes` in + // binary mode (`'rb'`), matching the fd-backed branch above. Without + // this a path-backed `open(p, 'rb').readline()` would hand back `str`, + // breaking `tokenize.detect_encoding` (`first.startswith(BOM_UTF8)`). + let _ = crate::baseobjspace::setattr_str(wrapper, "__file_binary__", w_bool_from(binary)); + let _ = crate::baseobjspace::setattr_str(wrapper, "encoding", w_str_new(&encoding)); + let _ = crate::baseobjspace::setattr_str(wrapper, "errors", w_str_new(&errors)); + let _ = crate::baseobjspace::setattr_str(wrapper, "name", w_str_new(&path)); + let _ = crate::baseobjspace::setattr_str(wrapper, "mode", w_str_new(&mode)); + let _ = crate::baseobjspace::setattr_str(wrapper, "closed", w_bool_from(false)); + Ok(wrapper) + } } // ── _io.TextIOWrapper — thin text layer over a binary buffer ───────── diff --git a/pyre/pyre-interpreter/src/call.rs b/pyre/pyre-interpreter/src/call.rs index 7be84e0e863..77683ecb73c 100644 --- a/pyre/pyre-interpreter/src/call.rs +++ b/pyre/pyre-interpreter/src/call.rs @@ -89,7 +89,16 @@ pub fn clear_call_error() { /// bool residual instead of `std::env::var`'s `Result` ABI. #[majit_macros::dont_look_inside] pub fn pyre_debug_call_enabled() -> bool { - std::env::var("PYRE_DEBUG_CALL").is_ok() + // The debug knob reads the real process env; under sandbox host access must + // go through the seam, so report disabled rather than touch the host env. + #[cfg(not(feature = "sandbox"))] + { + std::env::var("PYRE_DEBUG_CALL").is_ok() + } + #[cfg(feature = "sandbox")] + { + false + } } use pyre_object::{PY_NULL, PyObjectRef}; @@ -1834,6 +1843,9 @@ pub fn call_function_impl_raw(callable: PyObjectRef, args: &[PyObjectRef]) -> Py match call_function_impl_result(callable, args) { Ok(result) => result, Err(e) => { + // Debug diagnostic reads the real process env + writes real stderr; + // keep it out of the sandbox build (host access must go the seam). + #[cfg(not(feature = "sandbox"))] if pyre_debug_call_enabled() { eprintln!("[call_function_impl] error: {}", e.message); } @@ -2739,21 +2751,25 @@ fn build_class_inner( // Create frame with class_locals set AND closure from enclosing scope. // PyPy: executes class body with w_locals = fresh dict, w_globals = module globals, // and the closure tuple is passed through for LOAD_DEREF access. - // Debug: dump code object for __class__ cell investigation - let code_ref = unsafe { &*func_code }; - if std::env::var("PYRE_DEBUG_CLASS").is_ok() { - eprintln!("[build_class] name={name}"); - eprintln!(" varnames: {:?}", code_ref.varnames); - eprintln!(" cellvars: {:?}", code_ref.cellvars); - eprintln!(" freevars: {:?}", code_ref.freevars); - eprintln!( - " nlocals={} ncells={} nfree={}", - code_ref.varnames.len(), - code_ref.cellvars.len(), - code_ref.freevars.len() - ); - for (i, instr) in code_ref.instructions.iter().enumerate().take(20) { - eprintln!(" {i}: {:?}", instr); + // Debug: dump code object for __class__ cell investigation. Reads the real + // process env + writes real stderr, so keep it out of the sandbox build. + #[cfg(not(feature = "sandbox"))] + { + let code_ref = unsafe { &*func_code }; + if std::env::var("PYRE_DEBUG_CLASS").is_ok() { + eprintln!("[build_class] name={name}"); + eprintln!(" varnames: {:?}", code_ref.varnames); + eprintln!(" cellvars: {:?}", code_ref.cellvars); + eprintln!(" freevars: {:?}", code_ref.freevars); + eprintln!( + " nlocals={} ncells={} nfree={}", + code_ref.varnames.len(), + code_ref.cellvars.len(), + code_ref.freevars.len() + ); + for (i, instr) in code_ref.instructions.iter().enumerate().take(20) { + eprintln!(" {i}: {:?}", instr); + } } } diff --git a/pyre/pyre-interpreter/src/error.rs b/pyre/pyre-interpreter/src/error.rs index 9a3251c4250..0ab8b23b409 100644 --- a/pyre/pyre-interpreter/src/error.rs +++ b/pyre/pyre-interpreter/src/error.rs @@ -1059,7 +1059,11 @@ fn read_source_line(filename: &str, lineno: i64) -> Option { } #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] { - let content = rustpython_host_env::fs::read_to_string(filename).ok()?; + // Read through the import machinery's source provider, not std::fs: + // under sandbox that routes the read through the seam to the controller + // VFS, so a guest-controlled traceback path cannot leak a host file. + let content = + crate::importing::read_source_to_string(std::path::Path::new(filename)).ok()?; content .lines() .nth((lineno - 1) as usize) @@ -1078,8 +1082,12 @@ fn read_source_line(filename: &str, lineno: i64) -> Option { } pub fn eprint_exception(err: &PyError, include_traceback: bool) { - let mut stderr = std::io::stderr().lock(); - let _ = write_exception(&mut stderr, err, include_traceback); + // Buffer then emit through the host_seam so the traceback rides the same + // mediated stderr as sys.stderr under sandbox (raw fd 2 would bypass the + // controller / corrupt nothing but escape the seam). + let mut buf: Vec = Vec::new(); + let _ = write_exception(&mut buf, err, include_traceback); + crate::host_seam::emit_stderr(&buf); } pub fn get_cleared_operation_error(_space: PyObjectRef) -> OperationError { diff --git a/pyre/pyre-interpreter/src/eval.rs b/pyre/pyre-interpreter/src/eval.rs index 221718321cc..bf8e798e92a 100644 --- a/pyre/pyre-interpreter/src/eval.rs +++ b/pyre/pyre-interpreter/src/eval.rs @@ -392,7 +392,18 @@ unsafe fn walk_type_dicts_gc(forward: &mut dyn FnMut(&mut PyObjectRef)) { /// the rescan-everything-every-minor behavior). fn gc_prebuilt_remember_enabled() -> bool { static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); - *ENABLED.get_or_init(|| std::env::var("PYRE_GC_PREBUILT_REMEMBER").as_deref() != Ok("0")) + *ENABLED.get_or_init(|| { + #[cfg(not(feature = "sandbox"))] + { + std::env::var("PYRE_GC_PREBUILT_REMEMBER").as_deref() != Ok("0") + } + // The host env is off-limits under sandbox; keep the parity default + // (the prebuilt-remember minor-collection skip enabled). + #[cfg(feature = "sandbox")] + { + true + } + }) } fn walk_pyframe_roots(visitor: &mut dyn FnMut(&mut majit_ir::GcRef)) { @@ -2143,6 +2154,7 @@ impl ControlFlowOpcodeHandler for PyFrame { /// pyopcode.py:180-183 RETURN_VALUE — frame_finished_execution = True /// when the returning path exits the frame (matched by StepResult::Return). fn finish_value(&mut self, value: Self::Value) -> Result, PyError> { + #[cfg(not(feature = "sandbox"))] if std::env::var_os("PYRE_INTERP_RETURN_LOG").is_some() { unsafe { let code_ptr = crate::pyframe::pyframe_get_pycode(self); @@ -3435,7 +3447,7 @@ impl OpcodeStepExecutor for PyFrame { fn print_expr(&mut self, val: PyObjectRef) -> Result<(), PyError> { if !unsafe { pyre_object::is_none(val) } { let s = unsafe { crate::py_repr(val)? }; - println!("{}", s); + crate::host_seam::emit_stdout(format!("{s}\n").as_bytes()); } Ok(()) } diff --git a/pyre/pyre-interpreter/src/host_seam.rs b/pyre/pyre-interpreter/src/host_seam.rs new file mode 100644 index 00000000000..fb3f22ba593 --- /dev/null +++ b/pyre/pyre-interpreter/src/host_seam.rs @@ -0,0 +1,731 @@ +//! The OS-call seam — the single indirection through which the interpreter +//! reaches the operating system, so a sandbox build can swap real syscalls for +//! marshalling trampolines at compile time. +//! +//! This is the faithful Rust analog of RPython's `--sandbox` translation +//! (`rpython/translator/sandbox/rsandbox.py: make_sandbox_trampoline`): there, +//! the translator replaces each external C call with a trampoline; here, +//! `#[cfg(feature = "sandbox")]` selects [`TrampolineHost`] (which marshals to +//! the controller via `pyre_sandbox::client`) instead of [`RealHost`] (the real +//! libc/std bodies). Because pyre's builtins are non-capturing `fn` pointers +//! (`gateway.rs` `BuiltinCodeFn = fn(...)`), the seam cannot be a runtime-held +//! object; it is reached purely by module path through the free functions in +//! [`ops`], and the real-vs-trampoline choice is baked in by cfg. +//! +//! This seam is a *selective* compile-out, not RPython's whole-program one: +//! genc rewrites every external in an exhaustive translation pass, whereas here +//! the guarantee reaches only code that names `libc`/fenced `std` through the +//! seam and the CI fence. A host call outside that surface (a dependency's own +//! FFI, a raw `syscall!`, an un-rerouted site) still compiles; only the runtime +//! `seccomp` backstop (Linux) catches it. See `pyre_sandbox`'s crate-root +//! "Structural constraint" note for the full guarantee model. + +// Used only by RealHost's byte<->OsStr conversions (the non-sandbox build). +#[cfg(not(feature = "sandbox"))] +use std::os::unix::ffi::{OsStrExt, OsStringExt}; + +#[cfg(feature = "sandbox")] +use pyre_sandbox::client::{self, SyscallResult}; +#[cfg(feature = "sandbox")] +use pyre_sandbox::protocol::SandboxError; +#[cfg(feature = "sandbox")] +use pyre_sandbox::rmarshal::MarshalValue; + +/// `host_seam::sys` — the libc surface a sandbox-compiled module is allowed to +/// name. Off sandbox it is `libc` verbatim; under sandbox it re-exports only +/// TYPES, CONSTANTS, and the curated *pure* (no syscall, no host I/O) functions, +/// never a syscall function. A module that does `use crate::host_seam::sys as +/// libc;` therefore turns any direct syscall *call* outside this seam into a +/// compile error — the fails-closed analog of RPython leaving unsupported +/// externals unlinkable. Add an entry when a sandbox-reachable module needs it; +/// a missing one is a loud compile error, fails-closed in the safe direction. +#[cfg(not(feature = "sandbox"))] +pub use ::libc as sys; + +#[cfg(feature = "sandbox")] +pub mod sys { + // Types (zero-cost to name; cross no boundary). + pub use ::libc::{ + c_char, c_int, c_long, c_uint, c_void, clockid_t, gid_t, mode_t, off_t, pid_t, rusage, + size_t, time_t, timespec, timeval, tm, uid_t, + }; + // Calendar/formatting on a caller-supplied value: `asctime_r` is pure, and + // `gmtime_r` reads only glibc's timezone cache — which the seccomp backstop + // primes before lockdown (see `pyre_sandbox::seccomp`), so at runtime neither + // opens a host file. + pub use ::libc::{asctime_r, gmtime_r}; + // Pure functions: wait-status decoders are bit-twiddling on a caller-supplied + // integer; they make no syscall and read no host state. + pub use ::libc::{WEXITSTATUS, WIFEXITED, WIFSIGNALED, WIFSTOPPED, WSTOPSIG, WTERMSIG}; + // Type-only re-exports for names that libc also defines as a function, so + // the type resolves but the syscall call does not. + pub type stat = ::libc::stat; + pub use ::libc::winsize; + // Constants (added as sandbox-reachable modules need them). + pub use ::libc::{ + CODESET, EINTR, EINVAL, F_OK, LC_ALL, LC_COLLATE, LC_CTYPE, LC_MESSAGES, LC_MONETARY, + LC_NUMERIC, LC_TIME, O_APPEND, O_CREAT, O_DSYNC, O_EXCL, O_NONBLOCK, O_RDONLY, O_RDWR, + O_SYNC, O_TRUNC, O_WRONLY, PRIO_PGRP, PRIO_PROCESS, PRIO_USER, R_OK, RUSAGE_SELF, S_IFDIR, + S_IFMT, S_IFREG, SEEK_CUR, SEEK_END, SEEK_SET, TIOCGWINSZ, W_OK, WCONTINUED, WNOHANG, + WUNTRACED, X_OK, + }; +} + +/// An error from an OS seam operation. Self-contained in the interpreter so the +/// non-sandbox build does not depend on `pyre-sandbox`. Numeric codes mirror the +/// sandbox `EXCEPTION_TABLE`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SeamError { + /// `OSError(errno)`. + Os(i32), + Io, + Overflow, + Value, + ZeroDivision, + Memory, + Key, + Index, + Runtime, +} + +#[cfg(feature = "sandbox")] +impl From for SeamError { + fn from(e: SandboxError) -> Self { + match e { + SandboxError::Os(errno) => SeamError::Os(errno), + SandboxError::Io => SeamError::Io, + SandboxError::Overflow => SeamError::Overflow, + SandboxError::Value => SeamError::Value, + SandboxError::ZeroDivision => SeamError::ZeroDivision, + SandboxError::Memory => SeamError::Memory, + SandboxError::Key => SeamError::Key, + SandboxError::Index => SeamError::Index, + SandboxError::Runtime | SandboxError::Protocol(_) => SeamError::Runtime, + } + } +} + +pub type SeamResult = Result; + +/// Map a [`SeamError`] onto the interpreter's `PyError`, mirroring the +/// `io_err`/`fd_io_err` conversions the real-syscall call sites use. `context` +/// is the path/name woven into the `OSError` message (empty = omit, matching the +/// `""` path the fd call sites pass). Only the sandbox build routes errors +/// through here; the real build keeps its own inline `io_err`. +#[cfg(feature = "sandbox")] +pub fn seam_os_err(e: SeamError, context: &str) -> crate::PyError { + match e { + SeamError::Os(errno) => { + let io = std::io::Error::from_raw_os_error(errno); + let msg = if context.is_empty() { + io.to_string() + } else { + format!("{io}: '{context}'") + }; + crate::PyError::os_error_with_errno(errno, msg) + } + SeamError::Io => crate::PyError::os_error_with_errno(libc::EIO, "I/O error"), + SeamError::Value => crate::PyError::value_error("embedded null in path"), + SeamError::Overflow => crate::PyError::overflow_error("integer overflow"), + SeamError::ZeroDivision => crate::PyError::runtime_error("division by zero"), + SeamError::Memory => crate::PyError::memory_error("out of memory"), + SeamError::Key => crate::PyError::key_error("key error"), + SeamError::Index => crate::PyError::index_error("index out of range"), + SeamError::Runtime => crate::PyError::runtime_error("sandbox runtime error"), + } +} + +/// The not-implemented stub for OS surface that the sandbox controller does not +/// service (signal/socket/dup/ftruncate/…). Port of `rsandbox.py`'s +/// `get_sandbox_stub`/`not_implemented_stub`: raise `RuntimeError` rather than +/// touch the OS (`not_implemented_stub` does `raise RuntimeError(msg)`). +#[cfg(feature = "sandbox")] +pub fn stub(fnname: &str) -> crate::PyError { + crate::PyError::runtime_error(format!("{fnname} is not available in the sandbox")) +} + +/// The raw stat fields `make_stat_result` consumes. `RealHost` fills every field +/// from a `libc::stat`; under sandbox the wire `os.stat_result` carries only the +/// 10 protocol fields (mode/ino/dev/nlink/uid/gid/size + integer atime/mtime/ +/// ctime) and the remaining fields are zeroed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StatBuf { + pub mode: u32, + pub ino: u64, + pub dev: u64, + pub nlink: u64, + pub uid: u32, + pub gid: u32, + pub size: u64, + pub atime: i64, + pub mtime: i64, + pub ctime: i64, + pub atime_nsec: i64, + pub mtime_nsec: i64, + pub ctime_nsec: i64, + pub blksize: u64, + pub blocks: u64, + pub rdev: u64, + pub st_flags: u32, +} + +/// The real-syscall host (selected when the `sandbox` feature is off). +pub struct RealHost; + +/// The marshalling-trampoline host (selected when the `sandbox` feature is on); +/// every method round-trips through `pyre_sandbox::client::syscall`. +#[cfg(feature = "sandbox")] +pub struct TrampolineHost; + +// ── arg/return token -> Rust type ──────────────────────────────────────────── + +macro_rules! seam_arg_ty { + (bytes) => { + &[u8] + }; + (i32) => { + i32 + }; + (u32) => { + u32 + }; + (i64) => { + i64 + }; + (f64) => { + f64 + }; +} + +macro_rules! seam_ret_ty { + (unit) => { () }; + (i32) => { i32 }; + (i64) => { i64 }; + (longlong) => { i64 }; + (bytes) => { Vec }; + (bool) => { bool }; + (optbytes) => { Option> }; + (liststr) => { Vec> }; + (envitems) => { Vec<(Vec, Vec)> }; + (stat) => { StatBuf }; + (f64) => { f64 }; +} + +// The protocol ResultKind a return token decodes as. `longlong` forces 'I'+8 +// (RESULTTYPE_LONGLONG, e.g. lseek); plain `i64` accepts 'i'/'I'. +#[cfg(feature = "sandbox")] +macro_rules! seam_result_kind { + (unit) => { + pyre_sandbox::protocol::ResultKind::None + }; + (i32) => { + pyre_sandbox::protocol::ResultKind::Int + }; + (i64) => { + pyre_sandbox::protocol::ResultKind::Int + }; + (longlong) => { + pyre_sandbox::protocol::ResultKind::LongLong + }; + (bytes) => { + pyre_sandbox::protocol::ResultKind::Str + }; + (bool) => { + pyre_sandbox::protocol::ResultKind::Bool + }; + (optbytes) => { + pyre_sandbox::protocol::ResultKind::OptStr + }; + (liststr) => { + pyre_sandbox::protocol::ResultKind::ListStr + }; + (envitems) => { + pyre_sandbox::protocol::ResultKind::EnvItems + }; + (stat) => { + pyre_sandbox::protocol::ResultKind::StatResult + }; + (f64) => { + pyre_sandbox::protocol::ResultKind::Float + }; +} + +// Marshal one argument into a request MarshalValue (client int convention). +#[cfg(feature = "sandbox")] +macro_rules! seam_marshal { + (bytes, $v:expr) => { + MarshalValue::Str($v.to_vec()) + }; + (i32, $v:expr) => { + MarshalValue::Int($v as i64) + }; + (u32, $v:expr) => { + MarshalValue::Int($v as i64) + }; + (i64, $v:expr) => { + MarshalValue::Int($v) + }; + (f64, $v:expr) => { + MarshalValue::Float($v) + }; +} + +// Project a decoded SyscallResult back to the typed return value. +#[cfg(feature = "sandbox")] +macro_rules! seam_unwrap { + (unit, $r:expr) => { + match $r { + SyscallResult::None => Ok(()), + _ => Err(SeamError::Runtime), + } + }; + (i32, $r:expr) => { + match $r { + SyscallResult::Int(v) => Ok(v as i32), + _ => Err(SeamError::Runtime), + } + }; + (i64, $r:expr) => { + match $r { + SyscallResult::Int(v) => Ok(v), + _ => Err(SeamError::Runtime), + } + }; + (longlong, $r:expr) => { + match $r { + SyscallResult::Int(v) => Ok(v), + _ => Err(SeamError::Runtime), + } + }; + (bytes, $r:expr) => { + match $r { + SyscallResult::Str(v) => Ok(v), + _ => Err(SeamError::Runtime), + } + }; + (bool, $r:expr) => { + match $r { + SyscallResult::Bool(v) => Ok(v), + _ => Err(SeamError::Runtime), + } + }; + (optbytes, $r:expr) => { + match $r { + SyscallResult::OptStr(v) => Ok(v), + _ => Err(SeamError::Runtime), + } + }; + (liststr, $r:expr) => { + match $r { + SyscallResult::ListStr(v) => Ok(v), + _ => Err(SeamError::Runtime), + } + }; + (envitems, $r:expr) => { + match $r { + SyscallResult::EnvItems(v) => Ok(v), + _ => Err(SeamError::Runtime), + } + }; + (stat, $r:expr) => { + match $r { + SyscallResult::Stat(s) => Ok(StatBuf::from_wire(&s)), + _ => Err(SeamError::Runtime), + } + }; + (f64, $r:expr) => { + match $r { + SyscallResult::Float(v) => Ok(v), + _ => Err(SeamError::Runtime), + } + }; +} + +/// Declare the whole OS surface once: it generates the [`SandboxableHost`] trait, +/// the cfg-gated [`TrampolineHost`] impl (each body marshalling to the +/// controller), and the [`ops`] free-function forwarders. `RealHost` implements +/// the trait by hand below. +macro_rules! declare_seam { + ($( + $name:ident ( $($a:ident : $aty:tt),* ) -> $rty:tt = $ll:literal ; + )*) => { + /// The OS surface the sandbox protocol mediates. Implemented by + /// [`RealHost`] (real syscalls) and [`TrampolineHost`] (marshalling). + pub trait SandboxableHost { + $( + fn $name($($a : seam_arg_ty!($aty)),*) -> SeamResult; + )* + } + + #[cfg(feature = "sandbox")] + impl SandboxableHost for TrampolineHost { + $( + fn $name($($a : seam_arg_ty!($aty)),*) -> SeamResult { + let args = [ $( seam_marshal!($aty, $a) ),* ]; + let result = client::syscall($ll, &args, seam_result_kind!($rty))?; + seam_unwrap!($rty, result) + } + )* + } + + /// The free-function seam. Every OS call site reaches the host by naming + /// `host_seam::ops::*`; the real-vs-trampoline body is chosen by cfg. + pub mod ops { + use super::*; + + #[cfg(not(feature = "sandbox"))] + type Host = RealHost; + #[cfg(feature = "sandbox")] + type Host = TrampolineHost; + + $( + pub fn $name($($a : seam_arg_ty!($aty)),*) -> SeamResult { + ::$name($($a),*) + } + )* + } + }; +} + +declare_seam! { + open(path: bytes, flags: i32, mode: u32) -> i32 = "ll_os.ll_os_open"; + close(fd: i32) -> unit = "ll_os.ll_os_close"; + read(fd: i32, size: i64) -> bytes = "ll_os.ll_os_read"; + write(fd: i32, data: bytes) -> i64 = "ll_os.ll_os_write"; + lseek(fd: i32, pos: i64, how: i32) -> longlong = "ll_os.ll_os_lseek"; + stat(path: bytes) -> stat = "ll_os.ll_os_stat"; + lstat(path: bytes) -> stat = "ll_os.ll_os_lstat"; + fstat(fd: i32) -> stat = "ll_os.ll_os_fstat"; + access(path: bytes, mode: i32) -> bool = "ll_os.ll_os_access"; + isatty(fd: i32) -> bool = "ll_os.ll_os_isatty"; + getcwd() -> bytes = "ll_os.ll_os_getcwd"; + listdir(path: bytes) -> liststr = "ll_os.ll_os_listdir"; + getenv(name: bytes) -> optbytes = "ll_os.ll_os_getenv"; + envitems() -> envitems = "ll_os.ll_os_envitems"; + strerror(code: i32) -> bytes = "ll_os.ll_os_strerror"; + getuid() -> i64 = "ll_os.ll_os_getuid"; + geteuid() -> i64 = "ll_os.ll_os_geteuid"; + getgid() -> i64 = "ll_os.ll_os_getgid"; + getegid() -> i64 = "ll_os.ll_os_getegid"; + unlink(path: bytes) -> unit = "ll_os.ll_os_unlink"; + mkdir(path: bytes, mode: u32) -> unit = "ll_os.ll_os_mkdir"; + urandom(size: i64) -> bytes = "ll_os.ll_os_urandom"; + time() -> f64 = "ll_time.ll_time_time"; + clock() -> f64 = "ll_time.ll_time_clock"; + sleep(seconds: f64) -> unit = "ll_time.ll_time_sleep"; +} + +// ── Interpreter stdio ──────────────────────────────────────────────────────── +// +// Diagnostic output (tracebacks, warnings, the interactive displayhook) reaches +// fd 1/2 through these two helpers so it obeys the same seam as `sys.stdout`. +// Under sandbox fd 1 is the marshalling pipe, so a raw write would corrupt the +// protocol: route through `ll_os_write(1|2,…)` and let the controller relay it. +// Best-effort — a failed relay is dropped, matching a closed real stream. + +/// Emit bytes to the interpreter's stdout (fd 1). +pub fn emit_stdout(bytes: &[u8]) { + #[cfg(not(feature = "sandbox"))] + { + use std::io::Write; + let _ = std::io::stdout().write_all(bytes); + } + #[cfg(feature = "sandbox")] + { + let _ = ops::write(1, bytes); + } +} + +/// Emit bytes to the interpreter's stderr (fd 2). +pub fn emit_stderr(bytes: &[u8]) { + #[cfg(not(feature = "sandbox"))] + { + use std::io::Write; + let _ = std::io::stderr().write_all(bytes); + } + #[cfg(feature = "sandbox")] + { + let _ = ops::write(2, bytes); + } +} + +/// Flush the interpreter's stdout (fd 1). Under sandbox fd 1 is written through +/// unbuffered `ll_os_write` syscalls, so there is nothing to flush. +pub fn flush_stdout() { + #[cfg(not(feature = "sandbox"))] + { + use std::io::Write; + let _ = std::io::stdout().flush(); + } +} + +// ── StatBuf constructors ───────────────────────────────────────────────────── + +impl StatBuf { + /// Build from a `libc::stat` (the real-host path). `st_flags` exists only on + /// the BSD-derived platforms; elsewhere it is 0. + #[cfg(not(feature = "sandbox"))] + fn from_libc(st: &libc::stat) -> Self { + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))] + let st_flags = st.st_flags as u32; + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "freebsd")))] + let st_flags = 0u32; + StatBuf { + mode: st.st_mode as u32, + ino: st.st_ino as u64, + dev: st.st_dev as u64, + nlink: st.st_nlink as u64, + uid: st.st_uid as u32, + gid: st.st_gid as u32, + size: st.st_size as u64, + atime: st.st_atime as i64, + mtime: st.st_mtime as i64, + ctime: st.st_ctime as i64, + atime_nsec: st.st_atime_nsec as i64, + mtime_nsec: st.st_mtime_nsec as i64, + ctime_nsec: st.st_ctime_nsec as i64, + blksize: st.st_blksize as u64, + blocks: st.st_blocks as u64, + rdev: st.st_rdev as u64, + st_flags, + } + } + + /// Build from the 10-field wire `os.stat_result` (the trampoline path); the + /// non-protocol fields (nsec, blksize, blocks, rdev, st_flags) are zero. + #[cfg(feature = "sandbox")] + fn from_wire(st: &pyre_sandbox::vfs::StatResult) -> Self { + StatBuf { + mode: st.st_mode, + ino: st.st_ino, + dev: st.st_dev, + nlink: st.st_nlink, + uid: st.st_uid, + gid: st.st_gid, + size: st.st_size, + atime: st.st_atime, + mtime: st.st_mtime, + ctime: st.st_ctime, + ..StatBuf::default() + } + } +} + +// ── RealHost: the real-syscall bodies (non-sandbox build) ───────────────────── + +#[cfg(not(feature = "sandbox"))] +mod real { + use super::*; + use std::ffi::{CStr, CString}; + use std::os::raw::c_void; + + fn last_os_error() -> SeamError { + SeamError::Os( + std::io::Error::last_os_error() + .raw_os_error() + .unwrap_or(libc::EIO), + ) + } + + fn cstr(path: &[u8]) -> SeamResult { + CString::new(path).map_err(|_| SeamError::Value) + } + + fn real_stat(path: &[u8], symlink: bool) -> SeamResult { + let c = cstr(path)?; + // SAFETY: stat(2)/lstat(2) into a zeroed, owned `libc::stat`. + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + let r = unsafe { + if symlink { + libc::lstat(c.as_ptr(), &mut st) + } else { + libc::stat(c.as_ptr(), &mut st) + } + }; + if r < 0 { + return Err(last_os_error()); + } + Ok(StatBuf::from_libc(&st)) + } + + impl SandboxableHost for RealHost { + fn open(path: &[u8], flags: i32, mode: u32) -> SeamResult { + let c = cstr(path)?; + // SAFETY: open(2) with an owned NUL-terminated path. + let fd = unsafe { libc::open(c.as_ptr(), flags, mode as libc::c_uint) }; + if fd < 0 { Err(last_os_error()) } else { Ok(fd) } + } + + fn close(fd: i32) -> SeamResult<()> { + if unsafe { libc::close(fd) } < 0 { + Err(last_os_error()) + } else { + Ok(()) + } + } + + fn read(fd: i32, size: i64) -> SeamResult> { + let n = size.max(0) as usize; + let mut buf = vec![0u8; n]; + // SAFETY: read(2) into a buffer we own and sized to `n`. + let got = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut c_void, n) }; + if got < 0 { + return Err(last_os_error()); + } + buf.truncate(got as usize); + Ok(buf) + } + + fn urandom(size: i64) -> SeamResult> { + use std::io::Read; + let n = size.max(0) as usize; + let mut buf = vec![0u8; n]; + std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .map_err(|_| last_os_error())?; + Ok(buf) + } + + fn write(fd: i32, data: &[u8]) -> SeamResult { + // SAFETY: write(2) from a slice held for the call. + let n = unsafe { libc::write(fd, data.as_ptr() as *const c_void, data.len()) }; + if n < 0 { + Err(last_os_error()) + } else { + Ok(n as i64) + } + } + + fn lseek(fd: i32, pos: i64, how: i32) -> SeamResult { + let r = unsafe { libc::lseek(fd, pos as libc::off_t, how) }; + if r < 0 { + Err(last_os_error()) + } else { + Ok(r as i64) + } + } + + fn stat(path: &[u8]) -> SeamResult { + real_stat(path, false) + } + + fn lstat(path: &[u8]) -> SeamResult { + real_stat(path, true) + } + + fn fstat(fd: i32) -> SeamResult { + // SAFETY: fstat(2) into a zeroed, owned `libc::stat`. + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } < 0 { + return Err(last_os_error()); + } + Ok(StatBuf::from_libc(&st)) + } + + fn access(path: &[u8], mode: i32) -> SeamResult { + let c = cstr(path)?; + Ok(unsafe { libc::access(c.as_ptr(), mode) } == 0) + } + + fn isatty(fd: i32) -> SeamResult { + Ok(unsafe { libc::isatty(fd) } == 1) + } + + fn getcwd() -> SeamResult> { + std::env::current_dir() + .map(|p| p.into_os_string().into_vec()) + .map_err(|_| last_os_error()) + } + + fn listdir(path: &[u8]) -> SeamResult>> { + let p = std::path::Path::new(std::ffi::OsStr::from_bytes(path)); + let mut names = Vec::new(); + for entry in std::fs::read_dir(p).map_err(|_| last_os_error())? { + let entry = entry.map_err(|_| last_os_error())?; + names.push(entry.file_name().into_vec()); + } + Ok(names) + } + + fn getenv(name: &[u8]) -> SeamResult>> { + Ok(std::env::var_os(std::ffi::OsStr::from_bytes(name)).map(|v| v.into_vec())) + } + + fn envitems() -> SeamResult, Vec)>> { + Ok(std::env::vars_os() + .map(|(k, v)| (k.into_vec(), v.into_vec())) + .collect()) + } + + fn strerror(code: i32) -> SeamResult> { + // SAFETY: strerror returns a static string; copy it out immediately. + let bytes = unsafe { + let p = libc::strerror(code); + if p.is_null() { + return Ok(format!("Unknown error {code}").into_bytes()); + } + CStr::from_ptr(p).to_bytes().to_vec() + }; + Ok(bytes) + } + + fn getuid() -> SeamResult { + Ok(unsafe { libc::getuid() } as i64) + } + + fn geteuid() -> SeamResult { + Ok(unsafe { libc::geteuid() } as i64) + } + + fn getgid() -> SeamResult { + Ok(unsafe { libc::getgid() } as i64) + } + + fn getegid() -> SeamResult { + Ok(unsafe { libc::getegid() } as i64) + } + + fn unlink(path: &[u8]) -> SeamResult<()> { + let c = cstr(path)?; + if unsafe { libc::unlink(c.as_ptr()) } < 0 { + Err(last_os_error()) + } else { + Ok(()) + } + } + + fn mkdir(path: &[u8], mode: u32) -> SeamResult<()> { + let c = cstr(path)?; + if unsafe { libc::mkdir(c.as_ptr(), mode as libc::mode_t) } < 0 { + Err(last_os_error()) + } else { + Ok(()) + } + } + + fn time() -> SeamResult { + Ok(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0)) + } + + fn clock() -> SeamResult { + // Process CPU time (user + system), the ll_time_clock analog. + // SAFETY: getrusage into a zeroed, owned `libc::rusage`. + let mut usage: libc::rusage = unsafe { std::mem::zeroed() }; + if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } != 0 { + return Err(last_os_error()); + } + let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 * 1e-6; + Ok(secs(usage.ru_utime) + secs(usage.ru_stime)) + } + + fn sleep(seconds: f64) -> SeamResult<()> { + if seconds > 0.0 { + std::thread::sleep(std::time::Duration::from_secs_f64(seconds)); + } + Ok(()) + } + } +} diff --git a/pyre/pyre-interpreter/src/importing.rs b/pyre/pyre-interpreter/src/importing.rs index 6c12be9e61d..d5f8c238c50 100644 --- a/pyre/pyre-interpreter/src/importing.rs +++ b/pyre/pyre-interpreter/src/importing.rs @@ -68,7 +68,7 @@ pub(crate) mod host { } } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(feature = "sandbox")))] use host::fs as host_fs; use host::os as host_os; @@ -122,11 +122,29 @@ fn with_source_provider(f: impl FnOnce(&dyn SourceProvider) -> R) -> R { f(&*provider) } -#[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] +/// Read a source file through the installed [`SourceProvider`] — the +/// seam-mediated VFS under sandbox, the host FS otherwise. Traceback rendering +/// uses this so it honours the same jail as the import machinery instead of +/// reaching `std::fs` for a guest-controlled path. +#[cfg(feature = "host_env")] +pub fn read_source_to_string(path: &Path) -> std::io::Result { + with_source_provider(|p| p.read_to_string(path)) +} + +#[cfg(all( + feature = "host_env", + not(target_arch = "wasm32"), + not(feature = "sandbox") +))] fn default_source_provider() -> std::rc::Rc { std::rc::Rc::new(HostFsProvider) } +#[cfg(all(feature = "host_env", not(target_arch = "wasm32"), feature = "sandbox"))] +fn default_source_provider() -> std::rc::Rc { + std::rc::Rc::new(SeamSourceProvider) +} + #[cfg(all(feature = "host_env", target_arch = "wasm32"))] fn default_source_provider() -> std::rc::Rc { std::rc::Rc::new(NullSourceProvider) @@ -136,10 +154,18 @@ fn default_source_provider() -> std::rc::Rc { /// runner's real-FS path. `is_file`/`is_dir` go straight to `std::fs:: /// metadata` via the `Path` methods (matching the historical `find_in_dirs` /// probes); reads route through the host_env `fs` shim. -#[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] +#[cfg(all( + feature = "host_env", + not(target_arch = "wasm32"), + not(feature = "sandbox") +))] struct HostFsProvider; -#[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] +#[cfg(all( + feature = "host_env", + not(target_arch = "wasm32"), + not(feature = "sandbox") +))] impl SourceProvider for HostFsProvider { fn is_file(&self, path: &Path) -> bool { path.is_file() @@ -152,6 +178,59 @@ impl SourceProvider for HostFsProvider { } } +/// Sandbox provider: every import probe and source read round-trips through the +/// host_seam trampoline to the trusted controller, which enforces the virtual +/// filesystem policy (read-only, path-jailed). Replaces `HostFsProvider` so the +/// import machinery cannot `std::fs` its way to an attacker-chosen host path +/// (e.g. `sys.path.append('/etc'); __import__('shadow')`). +#[cfg(all(feature = "host_env", feature = "sandbox"))] +struct SeamSourceProvider; + +#[cfg(all(feature = "host_env", feature = "sandbox"))] +impl SeamSourceProvider { + fn stat_mode(path: &Path) -> Option { + use std::os::unix::ffi::OsStrExt; + crate::host_seam::ops::stat(path.as_os_str().as_bytes()) + .ok() + .map(|s| s.mode) + } +} + +#[cfg(all(feature = "host_env", feature = "sandbox"))] +impl SourceProvider for SeamSourceProvider { + fn is_file(&self, path: &Path) -> bool { + Self::stat_mode(path).is_some_and(|m| m & libc::S_IFMT as u32 == libc::S_IFREG as u32) + } + fn is_dir(&self, path: &Path) -> bool { + Self::stat_mode(path).is_some_and(|m| m & libc::S_IFMT as u32 == libc::S_IFDIR as u32) + } + fn read_to_string(&self, path: &Path) -> std::io::Result { + use std::os::unix::ffi::OsStrExt; + fn to_io(e: crate::host_seam::SeamError) -> std::io::Error { + match e { + crate::host_seam::SeamError::Os(errno) => std::io::Error::from_raw_os_error(errno), + _ => std::io::Error::other("sandbox source read failed"), + } + } + let bytes = path.as_os_str().as_bytes(); + let fd = crate::host_seam::ops::open(bytes, libc::O_RDONLY, 0).map_err(to_io)?; + let mut data = Vec::new(); + loop { + match crate::host_seam::ops::read(fd, 65536) { + Ok(chunk) if chunk.is_empty() => break, + Ok(chunk) => data.extend_from_slice(&chunk), + Err(e) => { + let _ = crate::host_seam::ops::close(fd); + return Err(to_io(e)); + } + } + } + let _ = crate::host_seam::ops::close(fd); + String::from_utf8(data) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "source not utf-8")) + } +} + /// Default provider on wasm before the bootstrap installs a real one: resolves /// nothing, preserving the historical "builtins only" behaviour. #[cfg(all(feature = "host_env", target_arch = "wasm32"))] @@ -401,31 +480,47 @@ pub fn install_builtin_modules() { pyre_install_module!("__pypy__" => crate::module::__pypy__::init); pyre_install_module!("__pypy__.builders" => crate::module::__pypy__::builders::init); - #[cfg(not(target_arch = "wasm32"))] - pyre_install_module!("_signal"(signal)); pyre_install_module!(atexit); - #[cfg(not(target_arch = "wasm32"))] - pyre_install_module!(pwd); - #[cfg(not(target_arch = "wasm32"))] - pyre_install_module!(grp); - #[cfg(unix)] - pyre_install_module!(resource); - #[cfg(unix)] - pyre_install_module!(fcntl); - #[cfg(unix)] - pyre_install_module!(syslog); - pyre_install_module!(select); - pyre_install_module!(termios); - pyre_install_module!(_socket); - #[cfg(not(target_arch = "wasm32"))] - pyre_install_module!(mmap); - #[cfg(not(target_arch = "wasm32"))] + // faulthandler installs host signal handlers and writes tracebacks to a raw + // fd, neither of which is mediated; like the other host-access modules below + // the sandbox interpreter omits it (PyPy keeps it out of default_modules + // under translation.sandbox). + #[cfg(all(not(target_arch = "wasm32"), not(feature = "sandbox")))] pyre_install_module!(faulthandler); - pyre_install_module!(_ctypes); - #[cfg(not(target_arch = "wasm32"))] - pyre_install_module!(_posixshmem); - pyre_install_module!(_posixsubprocess); - pyre_install_module!(_multiprocessing); + + // Host-access modules — network (`_socket`), arbitrary FFI (`_ctypes`), + // subprocess/`fork`+`exec` (`_posixsubprocess`), shared memory + // (`_multiprocessing`/`_posixshmem`), system log, fd/tty control + // (`fcntl`/`termios`/`select`/`resource`), real signals, and the host + // user/group databases (`pwd`/`grp`). None belong to the mediated + // ll_os/ll_time surface, so the sandbox interpreter omits them entirely: + // `import _socket` then raises ModuleNotFoundError, as in a build whose + // syscall code is absent. + #[cfg(not(feature = "sandbox"))] + { + #[cfg(not(target_arch = "wasm32"))] + pyre_install_module!("_signal"(signal)); + #[cfg(not(target_arch = "wasm32"))] + pyre_install_module!(pwd); + #[cfg(not(target_arch = "wasm32"))] + pyre_install_module!(grp); + #[cfg(unix)] + pyre_install_module!(resource); + #[cfg(unix)] + pyre_install_module!(fcntl); + #[cfg(unix)] + pyre_install_module!(syslog); + pyre_install_module!(select); + pyre_install_module!(termios); + pyre_install_module!(_socket); + #[cfg(not(target_arch = "wasm32"))] + pyre_install_module!(mmap); + pyre_install_module!(_ctypes); + #[cfg(not(target_arch = "wasm32"))] + pyre_install_module!(_posixshmem); + pyre_install_module!(_posixsubprocess); + pyre_install_module!(_multiprocessing); + } pyre_install_module!(_locale); pyre_install_module!(_random); pyre_install_module!(_pickle); @@ -676,8 +771,19 @@ pub fn init_sys_path(script_dir: &Path) { path.clear(); // Script directory first (PyPy: first entry in sys.path) path.push(script_dir.to_path_buf()); - // Current working directory as fallback - if let Ok(cwd) = host_os::current_dir() { + // Current working directory as fallback. Under sandbox read it through + // the seam so it resolves to the controller's virtual cwd (`/tmp`) + // rather than leaking the trusted parent's real working directory. + #[cfg(feature = "sandbox")] + let cwd = { + use std::os::unix::ffi::OsStrExt; + crate::host_seam::ops::getcwd() + .ok() + .map(|b| PathBuf::from(std::ffi::OsStr::from_bytes(&b))) + }; + #[cfg(not(feature = "sandbox"))] + let cwd = host_os::current_dir().ok(); + if let Some(cwd) = cwd { if cwd != script_dir { path.push(cwd); } @@ -693,7 +799,7 @@ pub fn init_sys_path(script_dir: &Path) { /// /// PyPy equivalent: initpath.py walks up from the executable to a /// directory containing `lib-python/X.Y`. -#[cfg(feature = "host_env")] +#[cfg(all(feature = "host_env", not(feature = "sandbox")))] fn find_intree_stdlib() -> Option { let exe = std::env::current_exe().ok()?; let mut dir = exe.parent(); @@ -717,31 +823,49 @@ fn find_intree_stdlib() -> Option { /// PyPy equivalent: initpath.py scans for lib-python/X.Y at startup. #[cfg(feature = "host_env")] pub(crate) fn detect_stdlib_path() -> Option { - // Explicit override. - if let Ok(p) = host_os::var("PYRE_STDLIB") { - let path = PathBuf::from(p); - if path.is_dir() { + // Under sandbox the controller provisions the stdlib mount via + // `PYRE_STDLIB`; trust it verbatim — the seam-backed SourceProvider + // mediates every subsequent read — and never read `current_exe` or spawn + // a `python3` subprocess (both escape the controller). + #[cfg(feature = "sandbox")] + { + // Read through the env seam so the lookup reaches the controller's + // virtual environment (the child's real env was cleared at spawn); the + // controller seeds PYRE_STDLIB to the `--lib` mount at `/bin/lib`. + use std::os::unix::ffi::OsStrExt; + return crate::host_seam::ops::getenv(b"PYRE_STDLIB") + .ok() + .flatten() + .map(|bytes| PathBuf::from(std::ffi::OsStr::from_bytes(&bytes))); + } + #[cfg(not(feature = "sandbox"))] + { + // Explicit override. + if let Ok(p) = host_os::var("PYRE_STDLIB") { + let path = PathBuf::from(p); + if path.is_dir() { + return Some(path); + } + } + // Vendored in-tree stdlib, located relative to the executable. + if let Some(path) = find_intree_stdlib() { return Some(path); } + // Last resort: borrow a host CPython's stdlib. + let output = std::process::Command::new("python3") + .args([ + "-c", + "import sysconfig; print(sysconfig.get_paths()['stdlib'])", + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8(output.stdout).ok()?; + let path = PathBuf::from(s.trim()); + if path.is_dir() { Some(path) } else { None } } - // Vendored in-tree stdlib, located relative to the executable. - if let Some(path) = find_intree_stdlib() { - return Some(path); - } - // Last resort: borrow a host CPython's stdlib. - let output = std::process::Command::new("python3") - .args([ - "-c", - "import sysconfig; print(sysconfig.get_paths()['stdlib'])", - ]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let s = String::from_utf8(output.stdout).ok()?; - let path = PathBuf::from(s.trim()); - if path.is_dir() { Some(path) } else { None } } /// Add a directory to sys.path. diff --git a/pyre/pyre-interpreter/src/lib.rs b/pyre/pyre-interpreter/src/lib.rs index 43220e05730..9fd0fbdaa8c 100644 --- a/pyre/pyre-interpreter/src/lib.rs +++ b/pyre/pyre-interpreter/src/lib.rs @@ -37,6 +37,35 @@ pub mod executioncontext; pub mod frame_array; pub mod function; pub mod gateway; +// The OS-call seam (real syscalls vs. sandbox marshalling trampolines). Unix +// only: the real bodies use libc and the unix `OsStr`/`OsString` byte views. +#[cfg(unix)] +pub mod host_seam; + +// On non-unix targets (wasm) the seam is configured out, but the diagnostic +// stdio emitters need neither libc nor the sandbox trampoline (sandbox is +// unix-only). Provide them so the shared `crate::host_seam::emit_*` call sites +// resolve everywhere; the bodies mirror the non-sandbox emit path. +#[cfg(not(unix))] +pub mod host_seam { + /// Emit bytes to the interpreter's stdout (fd 1). + pub fn emit_stdout(bytes: &[u8]) { + use std::io::Write; + let _ = std::io::stdout().write_all(bytes); + } + + /// Emit bytes to the interpreter's stderr (fd 2). + pub fn emit_stderr(bytes: &[u8]) { + use std::io::Write; + let _ = std::io::stderr().write_all(bytes); + } + + /// Flush the interpreter's stdout (fd 1). + pub fn flush_stdout() { + use std::io::Write; + let _ = std::io::stdout().flush(); + } +} pub mod jit_fnaddr; pub mod listobject; pub mod opcode_ops; @@ -45,7 +74,6 @@ pub mod pyopcode; pub mod pytraceback; pub mod reduce_protocol; pub mod runtime_ops; -pub mod sandbox; pub mod shared_opcode; pub mod sliceobject; pub mod stack_check; @@ -830,6 +858,12 @@ pub fn print_output(s: &str) { if let Some(hook) = *h.borrow() { hook(s); } else { + // Under sandbox fd 1 is the marshalling pipe, so route program + // output through ll_os_write(1,…) for the controller to relay; a + // raw `print!` would corrupt the protocol stream. + #[cfg(all(unix, feature = "sandbox"))] + let _ = crate::host_seam::ops::write(1, s.as_bytes()); + #[cfg(not(all(unix, feature = "sandbox")))] print!("{s}"); } }); diff --git a/pyre/pyre-interpreter/src/module/_locale/interp_locale.rs b/pyre/pyre-interpreter/src/module/_locale/interp_locale.rs index c83edb85587..09e76c72975 100644 --- a/pyre/pyre-interpreter/src/module/_locale/interp_locale.rs +++ b/pyre/pyre-interpreter/src/module/_locale/interp_locale.rs @@ -3,6 +3,10 @@ //! Verbatim move of the inline block previously in importing.rs. use crate::DictStorage; +// Under sandbox, name libc through the seam facade so any direct syscall call +// in this module is a compile error (only types/constants/pure fns resolve). +#[cfg(feature = "sandbox")] +use crate::host_seam::sys as libc; /// Raise `_locale.Error` with the supplied message. Mirrors /// `interp_locale.py:15-20 make_error`. @@ -177,6 +181,9 @@ pub fn register_module(ns: &mut DictStorage) { crate::dict_storage_store(ns, "Error", w_error); // localeconv() — numeric/monetary parameters of the current locale. + // Reads the host locale DB; under sandbox the stub override below replaces + // it, so the real body (and its libc/host_env calls) is compiled out. + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "localeconv", @@ -249,6 +256,9 @@ pub fn register_module(ns: &mut DictStorage) { 0, ), ); + // setlocale() mutates/reads the host locale (and $LANG/$LC_*); stubbed under + // sandbox, so the real body is compiled out. + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "setlocale", @@ -302,6 +312,9 @@ pub fn register_module(ns: &mut DictStorage) { } }), ); + // nl_langinfo() reads the active-locale codeset/DB; stubbed under sandbox, + // so the real body is compiled out. + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "nl_langinfo", @@ -362,7 +375,7 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "strcoll", |args| { - #[cfg(all(unix, feature = "host_env"))] + #[cfg(all(unix, feature = "host_env", not(feature = "sandbox")))] { if args.len() < 2 || !unsafe { pyre_object::is_str(args[0]) && pyre_object::is_str(args[1]) } @@ -381,7 +394,7 @@ pub fn register_module(ns: &mut DictStorage) { rustpython_host_env::locale::strcoll(&c1, &c2) as i64, )); } - #[cfg(not(all(unix, feature = "host_env")))] + #[cfg(not(all(unix, feature = "host_env", not(feature = "sandbox"))))] { if args.len() < 2 || !unsafe { pyre_object::is_str(args[0]) && pyre_object::is_str(args[1]) } @@ -390,9 +403,10 @@ pub fn register_module(ns: &mut DictStorage) { "strcoll: arguments must be strings", )); } - // No libc collation available — fall back to - // lexical bytewise comparison. Pure computation, - // no I/O, so the sandbox principle is unaffected. + // No libc collation available (or sandbox build) — fall back + // to lexical bytewise comparison. Pure computation, no I/O; + // under sandbox this keeps the fixed "C" collation and never + // calls host libc collation, which would leak host LC_COLLATE. let s1 = unsafe { pyre_object::w_str_get_value(args[0]).to_string() }; let s2 = unsafe { pyre_object::w_str_get_value(args[1]).to_string() }; let ord = match s1.as_str().cmp(s2.as_str()) { @@ -418,7 +432,7 @@ pub fn register_module(ns: &mut DictStorage) { "strxfrm() argument must be str", )); } - #[cfg(all(unix, feature = "host_env"))] + #[cfg(all(unix, feature = "host_env", not(feature = "sandbox")))] { let sv = unsafe { pyre_object::w_str_get_value(s).to_string() }; let c = std::ffi::CString::new(sv.as_bytes()) @@ -430,9 +444,11 @@ pub fn register_module(ns: &mut DictStorage) { // surrogateescape. Ok(pyre_object::w_str_new(&String::from_utf8_lossy(&out))) } - #[cfg(not(all(unix, feature = "host_env")))] + #[cfg(not(all(unix, feature = "host_env", not(feature = "sandbox"))))] { - // No libc collation available — the transform is identity. + // No libc collation available (or sandbox build) — the + // transform is identity, keeping the fixed "C" locale and + // never reaching host libc strxfrm. Ok(s) } }, @@ -448,4 +464,22 @@ pub fn register_module(ns: &mut DictStorage) { 0, ), ); + // setlocale/localeconv/nl_langinfo read the host locale database (and the + // active $LANG/$LC_* environment); stub them so the sandbox observes only + // the fixed "C" locale defaults already exposed above, never host state. + #[cfg(feature = "sandbox")] + { + fn locale_unavailable( + _: &[pyre_object::PyObjectRef], + ) -> Result { + Err(crate::host_seam::stub("this locale function")) + } + for name in ["setlocale", "localeconv", "nl_langinfo"] { + crate::dict_storage_store( + ns, + name, + crate::make_builtin_function(name, locale_unavailable), + ); + } + } } diff --git a/pyre/pyre-interpreter/src/module/_random/mod.rs b/pyre/pyre-interpreter/src/module/_random/mod.rs index 30052016015..8659c690c53 100644 --- a/pyre/pyre-interpreter/src/module/_random/mod.rs +++ b/pyre/pyre-interpreter/src/module/_random/mod.rs @@ -144,11 +144,16 @@ impl W_Random { #[default(pyre_object::w_none())] w_n: PyObjectRef, ) -> Result<(), crate::PyError> { // None: seed from os.urandom(8); fall back to a time-based int only - // when urandom raises (interp_random.py:28). + // when urandom raises (interp_random.py:28). Under sandbox the entropy + // comes from the trusted controller, not host getrandom. let w_n = if unsafe { is_none(w_n) } { - match crate::importing::host::os::urandom(8) { - Ok(buf) => w_bytes_from_bytes(&buf), - Err(_) => w_int_new(seed_from_time() as i64), + #[cfg(not(feature = "sandbox"))] + let entropy = crate::importing::host::os::urandom(8).ok(); + #[cfg(feature = "sandbox")] + let entropy = crate::host_seam::ops::urandom(8).ok(); + match entropy { + Some(buf) => w_bytes_from_bytes(&buf), + None => w_int_new(seed_from_time() as i64), } } else { w_n @@ -254,11 +259,18 @@ impl W_Random { /// Time-based fallback seed — `int(time.time() * 256)`. fn seed_from_time() -> u64 { - use std::time::{SystemTime, UNIX_EPOCH}; - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs_f64()) - .unwrap_or(0.0); + // Under sandbox the clock is read through the trusted controller, not the + // host directly. + #[cfg(feature = "sandbox")] + let secs = crate::host_seam::ops::time().unwrap_or(0.0); + #[cfg(not(feature = "sandbox"))] + let secs = { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) + }; (secs * 256.0) as u64 } diff --git a/pyre/pyre-interpreter/src/module/mod.rs b/pyre/pyre-interpreter/src/module/mod.rs index 3cfa9c494d5..6b4ebc45dca 100644 --- a/pyre/pyre-interpreter/src/module/mod.rs +++ b/pyre/pyre-interpreter/src/module/mod.rs @@ -21,6 +21,7 @@ pub mod _contextvars; #[allow(non_snake_case)] pub mod _csv; #[allow(non_snake_case)] +#[cfg(not(feature = "sandbox"))] pub mod _ctypes; #[allow(non_snake_case)] pub mod _functools; @@ -30,19 +31,22 @@ pub mod _io; #[allow(non_snake_case)] pub mod _locale; #[allow(non_snake_case)] +#[cfg(not(feature = "sandbox"))] pub mod _multiprocessing; #[allow(non_snake_case)] pub mod _opcode; #[allow(non_snake_case)] pub mod _pickle; #[allow(non_snake_case)] -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(feature = "sandbox")))] pub mod _posixshmem; #[allow(non_snake_case)] +#[cfg(not(feature = "sandbox"))] pub mod _posixsubprocess; #[allow(non_snake_case)] pub mod _random; #[allow(non_snake_case)] +#[cfg(not(feature = "sandbox"))] pub mod _socket; pub mod _sre; #[allow(non_snake_case)] @@ -55,26 +59,29 @@ pub mod atexit; pub mod binascii; pub mod cmath; pub mod errno; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(feature = "sandbox")))] pub mod faulthandler; +#[cfg(not(feature = "sandbox"))] pub mod fcntl; pub mod gc; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(feature = "sandbox")))] pub mod grp; #[allow(non_snake_case)] pub mod imp; pub mod importlib; pub mod itertools; pub mod math; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(feature = "sandbox")))] pub mod mmap; pub mod operator; #[cfg(not(target_arch = "wasm32"))] pub mod posix; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(feature = "sandbox")))] pub mod pwd; pub mod pyexpat; +#[cfg(not(feature = "sandbox"))] pub mod resource; +#[cfg(not(feature = "sandbox"))] pub mod select; #[allow(non_snake_case)] #[cfg(not(target_arch = "wasm32"))] @@ -82,7 +89,9 @@ pub mod signal; #[allow(non_snake_case)] pub mod r#struct; pub mod sys; +#[cfg(not(feature = "sandbox"))] pub mod syslog; +#[cfg(not(feature = "sandbox"))] pub mod termios; #[allow(non_snake_case)] pub mod thread; diff --git a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs index 689a3d67387..beda41a05fb 100644 --- a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs +++ b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs @@ -7,6 +7,10 @@ use crate::DictStorage; use crate::importing::host::{fs as host_fs, os as host_os}; use pyre_object::PyObjectRef; +// Under sandbox, name libc through the seam facade so any direct syscall call +// in this module is a compile error (only types/constants/pure fns resolve). +#[cfg(feature = "sandbox")] +use crate::host_seam::sys as libc; /// `posix.stat_result` — a real structseq (tuple subclass) so `st[0]`, /// `len(st)`, iteration and `isinstance(st, tuple)` all work, matching @@ -150,7 +154,22 @@ pub fn register_module(ns: &mut DictStorage) { // PyPy equivalent: posix.State.startup → _convertenviron copies // os.environ.items() into w_environ at interpreter startup. let w_environ = pyre_object::w_dict_new(); - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + // The controller delivers the virtual environment as (bytes, bytes). + if let Ok(items) = crate::host_seam::ops::envitems() { + for (k_bytes, v_bytes) in items { + unsafe { + pyre_object::w_dict_store( + w_environ, + pyre_object::w_bytes_from_bytes(&k_bytes), + pyre_object::w_bytes_from_bytes(&v_bytes), + ); + } + } + } + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { // On POSIX, posix.environ stores bytes → bytes. os.py's // _create_environ_mapping wraps this dict in an _Environ object that @@ -178,21 +197,38 @@ pub fn register_module(ns: &mut DictStorage) { // scandir/listdir do not accept a file descriptor. HAVE_LSTAT remains so // os.stat is reported in supports_follow_symlinks (follow_symlinks=False // works). + // Under sandbox the fd-relative host probes/mutators (fchdir/fchmod/fchown/ + // fexecve/fpathconf/fstatvfs/ftruncate) are replaced with raising stubs, so + // drop their capability bits — otherwise os.py picks an fd-relative path + // that deterministically fails. + let have_functions: &[&str] = &[ + #[cfg(not(feature = "sandbox"))] + "HAVE_FCHDIR", + #[cfg(not(feature = "sandbox"))] + "HAVE_FCHMOD", + #[cfg(not(feature = "sandbox"))] + "HAVE_FCHOWN", + #[cfg(not(feature = "sandbox"))] + "HAVE_FEXECVE", + #[cfg(not(feature = "sandbox"))] + "HAVE_FPATHCONF", + #[cfg(not(feature = "sandbox"))] + "HAVE_FSTATVFS", + #[cfg(not(feature = "sandbox"))] + "HAVE_FTRUNCATE", + "HAVE_FUTIMENS", + "HAVE_FUTIMES", + "HAVE_LSTAT", + ]; crate::dict_storage_store( ns, "_have_functions", - pyre_object::w_list_new(vec![ - pyre_object::w_str_new("HAVE_FCHDIR"), - pyre_object::w_str_new("HAVE_FCHMOD"), - pyre_object::w_str_new("HAVE_FCHOWN"), - pyre_object::w_str_new("HAVE_FEXECVE"), - pyre_object::w_str_new("HAVE_FPATHCONF"), - pyre_object::w_str_new("HAVE_FSTATVFS"), - pyre_object::w_str_new("HAVE_FTRUNCATE"), - pyre_object::w_str_new("HAVE_FUTIMENS"), - pyre_object::w_str_new("HAVE_FUTIMES"), - pyre_object::w_str_new("HAVE_LSTAT"), - ]), + pyre_object::w_list_new( + have_functions + .iter() + .map(|&n| pyre_object::w_str_new(n)) + .collect(), + ), ); // POSIX constants — real libc values (cross-platform subset). for (name, val) in [ @@ -441,19 +477,28 @@ pub fn register_module(ns: &mut DictStorage) { } else { 0o777 }; - // Open the fd non-inheritable (PEP 446) so the descriptor does not - // leak across exec into child processes: O_CLOEXEC on unix, - // O_NOINHERIT on Windows (O_CLOEXEC is unix-only in libc). - #[cfg(unix)] - let flags = flags | libc::O_CLOEXEC; - #[cfg(windows)] - let flags = flags | libc::O_NOINHERIT; - let c_path = std::ffi::CString::new(path.as_bytes()) - .map_err(|_| crate::PyError::value_error("embedded null in path"))?; - let fd = unsafe { libc::open(c_path.as_ptr(), flags, mode as libc::c_uint) }; - if fd < 0 { - return Err(io_err(std::io::Error::last_os_error(), &path)); - } + #[cfg(not(feature = "sandbox"))] + let fd = { + // Open the fd non-inheritable (PEP 446) so the descriptor does + // not leak across exec into child processes: O_CLOEXEC on unix, + // O_NOINHERIT on Windows (O_CLOEXEC is unix-only in libc). Moot + // under sandbox, where the controller hands out virtual fds, so + // it is applied only here. + #[cfg(unix)] + let flags = flags | libc::O_CLOEXEC; + #[cfg(windows)] + let flags = flags | libc::O_NOINHERIT; + let c_path = std::ffi::CString::new(path.as_bytes()) + .map_err(|_| crate::PyError::value_error("embedded null in path"))?; + let fd = unsafe { libc::open(c_path.as_ptr(), flags, mode as libc::c_uint) }; + if fd < 0 { + return Err(io_err(std::io::Error::last_os_error(), &path)); + } + fd + }; + #[cfg(feature = "sandbox")] + let fd = crate::host_seam::ops::open(path.as_bytes(), flags, mode) + .map_err(|e| crate::host_seam::seam_os_err(e, &path))?; Ok(pyre_object::w_int_new(fd as i64)) }), ); @@ -469,10 +514,15 @@ pub fn register_module(ns: &mut DictStorage) { return Err(crate::PyError::type_error("close() requires 1 argument")); } let fd = (unsafe { pyre_object::w_int_get_value(args[0]) }) as libc::c_int; - let ret = unsafe { libc::close(fd) }; - if ret < 0 { - return Err(io_err(std::io::Error::last_os_error(), "")); + #[cfg(not(feature = "sandbox"))] + { + let ret = unsafe { libc::close(fd) }; + if ret < 0 { + return Err(io_err(std::io::Error::last_os_error(), "")); + } } + #[cfg(feature = "sandbox")] + crate::host_seam::ops::close(fd).map_err(|e| crate::host_seam::seam_os_err(e, ""))?; Ok(pyre_object::w_none()) }, 1, @@ -490,13 +540,30 @@ pub fn register_module(ns: &mut DictStorage) { return Err(crate::PyError::type_error("read() requires 2 arguments")); } let fd = (unsafe { pyre_object::w_int_get_value(args[0]) }) as libc::c_int; - let n = (unsafe { pyre_object::w_int_get_value(args[1]) }) as usize; - let mut buf = vec![0u8; n]; - let ret = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, n as _) }; - if ret < 0 { - return Err(io_err(std::io::Error::last_os_error(), "")); + let n_signed = unsafe { pyre_object::w_int_get_value(args[1]) }; + // A negative size would wrap to a huge `usize` (and allocation); + // os.read rejects it with EINVAL, matching the host read(2). + if n_signed < 0 { + return Err(crate::PyError::os_error_with_errno( + libc::EINVAL, + "read: negative size", + )); } - buf.truncate(ret as usize); + let n = n_signed as usize; + #[cfg(not(feature = "sandbox"))] + let buf = { + let mut buf = vec![0u8; n]; + let ret = + unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, n as _) }; + if ret < 0 { + return Err(io_err(std::io::Error::last_os_error(), "")); + } + buf.truncate(ret as usize); + buf + }; + #[cfg(feature = "sandbox")] + let buf = crate::host_seam::ops::read(fd, n as i64) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; Ok(pyre_object::w_bytes_from_bytes(&buf)) }, 2, @@ -525,13 +592,20 @@ pub fn register_module(ns: &mut DictStorage) { )); } }; - let ret = unsafe { - libc::write(fd, data.as_ptr() as *const libc::c_void, data.len() as _) + #[cfg(not(feature = "sandbox"))] + let ret = { + let ret = unsafe { + libc::write(fd, data.as_ptr() as *const libc::c_void, data.len() as _) + }; + if ret < 0 { + return Err(io_err(std::io::Error::last_os_error(), "")); + } + ret as i64 }; - if ret < 0 { - return Err(io_err(std::io::Error::last_os_error(), "")); - } - Ok(pyre_object::w_int_new(ret as i64)) + #[cfg(feature = "sandbox")] + let ret = crate::host_seam::ops::write(fd, &data) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + Ok(pyre_object::w_int_new(ret)) }, 2, ), @@ -550,11 +624,18 @@ pub fn register_module(ns: &mut DictStorage) { let fd = (unsafe { pyre_object::w_int_get_value(args[0]) }) as libc::c_int; let offset = (unsafe { pyre_object::w_int_get_value(args[1]) }) as libc::off_t; let whence = (unsafe { pyre_object::w_int_get_value(args[2]) }) as libc::c_int; - let ret = unsafe { libc::lseek(fd, offset, whence) }; - if ret < 0 { - return Err(io_err(std::io::Error::last_os_error(), "")); - } - Ok(pyre_object::w_int_new(ret as i64)) + #[cfg(not(feature = "sandbox"))] + let ret = { + let ret = unsafe { libc::lseek(fd, offset, whence) }; + if ret < 0 { + return Err(io_err(std::io::Error::last_os_error(), "")); + } + ret as i64 + }; + #[cfg(feature = "sandbox")] + let ret = crate::host_seam::ops::lseek(fd, offset as i64, whence) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + Ok(pyre_object::w_int_new(ret)) }, 3, ), @@ -568,12 +649,18 @@ pub fn register_module(ns: &mut DictStorage) { return Err(crate::PyError::type_error("unlink() requires 1 argument")); } let path = extract_path(args[0])?; - let c_path = std::ffi::CString::new(path.as_bytes()) - .map_err(|_| crate::PyError::value_error("embedded null in path"))?; - let ret = unsafe { libc::unlink(c_path.as_ptr()) }; - if ret < 0 { - return Err(io_err(std::io::Error::last_os_error(), &path)); + #[cfg(not(feature = "sandbox"))] + { + let c_path = std::ffi::CString::new(path.as_bytes()) + .map_err(|_| crate::PyError::value_error("embedded null in path"))?; + let ret = unsafe { libc::unlink(c_path.as_ptr()) }; + if ret < 0 { + return Err(io_err(std::io::Error::last_os_error(), &path)); + } } + #[cfg(feature = "sandbox")] + crate::host_seam::ops::unlink(path.as_bytes()) + .map_err(|e| crate::host_seam::seam_os_err(e, &path))?; Ok(pyre_object::w_none()) } crate::dict_storage_store( @@ -590,6 +677,10 @@ pub fn register_module(ns: &mut DictStorage) { // ── posix.readlink(path, *, dir_fd=None) ── // Returns the symlink target; a non-symlink raises OSError(EINVAL), which // `posixpath.realpath` relies on to stop following links. + // Under sandbox readlink is unavailable (the controller has no ll_os + // readlink handler); the stub override loop registers a raising stub, so + // keep the raw std::fs::read_link body out of the sandbox build. + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "readlink", @@ -620,20 +711,29 @@ pub fn register_module(ns: &mut DictStorage) { } else { 0o777 }; - let c_path = std::ffi::CString::new(path.as_bytes()) - .map_err(|_| crate::PyError::value_error("embedded null in path"))?; - #[cfg(unix)] - let ret = unsafe { libc::mkdir(c_path.as_ptr(), _mode as libc::mode_t) }; - #[cfg(windows)] - let ret = unsafe { libc::mkdir(c_path.as_ptr()) }; - if ret < 0 { - return Err(io_err(std::io::Error::last_os_error(), &path)); + #[cfg(not(feature = "sandbox"))] + { + let c_path = std::ffi::CString::new(path.as_bytes()) + .map_err(|_| crate::PyError::value_error("embedded null in path"))?; + #[cfg(unix)] + let ret = unsafe { libc::mkdir(c_path.as_ptr(), _mode as libc::mode_t) }; + #[cfg(windows)] + let ret = unsafe { libc::mkdir(c_path.as_ptr()) }; + if ret < 0 { + return Err(io_err(std::io::Error::last_os_error(), &path)); + } } + #[cfg(feature = "sandbox")] + crate::host_seam::ops::mkdir(path.as_bytes(), _mode) + .map_err(|e| crate::host_seam::seam_os_err(e, &path))?; Ok(pyre_object::w_none()) }), ); // ── posix.rmdir(path) ── + // Mutates the host filesystem; stubbed under sandbox, so the real body + // (and its libc call) is compiled out. + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "rmdir", @@ -731,14 +831,27 @@ pub fn register_module(ns: &mut DictStorage) { } else { extract_path(args[0])? }; - let entries = host_fs::read_dir(&path).map_err(|e| io_err(e, &path))?; - let mut items = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| io_err(e, &path))?; - let name = entry.file_name(); - items.push(pyre_object::w_str_new(&name.to_string_lossy())); + #[cfg(feature = "sandbox")] + { + let names = crate::host_seam::ops::listdir(path.as_bytes()) + .map_err(|e| crate::host_seam::seam_os_err(e, &path))?; + let items = names + .into_iter() + .map(|n| pyre_object::w_str_new(&String::from_utf8_lossy(&n))) + .collect(); + return Ok(pyre_object::w_list_new(items)); + } + #[cfg(not(feature = "sandbox"))] + { + let entries = host_fs::read_dir(&path).map_err(|e| io_err(e, &path))?; + let mut items = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| io_err(e, &path))?; + let name = entry.file_name(); + items.push(pyre_object::w_str_new(&name.to_string_lossy())); + } + Ok(pyre_object::w_list_new(items)) } - Ok(pyre_object::w_list_new(items)) }), ); @@ -753,6 +866,13 @@ pub fn register_module(ns: &mut DictStorage) { return Ok(pyre_object::w_bool_from(false)); } let fd = (unsafe { pyre_object::w_int_get_value(args[0]) }) as i32; + #[cfg(feature = "sandbox")] + { + return Ok(pyre_object::w_bool_from( + crate::host_seam::ops::isatty(fd).unwrap_or(false), + )); + } + #[cfg(not(feature = "sandbox"))] Ok(pyre_object::w_bool_from(host_os::isatty(fd))) }, 1, @@ -770,7 +890,13 @@ pub fn register_module(ns: &mut DictStorage) { return Err(crate::PyError::type_error("urandom() requires 1 argument")); } let n = (unsafe { pyre_object::w_int_get_value(args[0]) }) as usize; + #[cfg(not(feature = "sandbox"))] let buf = host_os::urandom(n).unwrap_or_else(|_| vec![0u8; n]); + // Route host entropy through the trusted controller instead of + // reaching host getrandom directly. + #[cfg(feature = "sandbox")] + let buf = crate::host_seam::ops::urandom(n as i64) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; Ok(pyre_object::w_bytes_from_bytes(&buf)) }, 1, @@ -786,6 +912,9 @@ pub fn register_module(ns: &mut DictStorage) { crate::dict_storage_store(ns, "terminal_size", terminal_size_seq_type()); // ── posix.get_terminal_size(fd=1) → os.terminal_size(columns, lines) ── + // Inspects the controlling terminal via ioctl(TIOCGWINSZ); stubbed under + // sandbox, so the real body is compiled out. + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "get_terminal_size", @@ -860,7 +989,9 @@ pub fn register_module(ns: &mut DictStorage) { // `Metadata`/`MetadataExt` does not surface it, so read it with a raw // `stat`/`lstat`/`fstat`; on failure default to 0 (the primary // metadata read already succeeded). - #[cfg(target_os = "macos")] + // Under sandbox the stat path is mediated (st_flags arrives over the wire), + // so this raw-libc helper is compiled out. + #[cfg(all(target_os = "macos", not(feature = "sandbox")))] fn macos_path_st_flags(path: &str, follow: bool) -> u32 { let Ok(c) = std::ffi::CString::new(path) else { return 0; @@ -879,7 +1010,7 @@ pub fn register_module(ns: &mut DictStorage) { } } } - #[cfg(target_os = "macos")] + #[cfg(all(target_os = "macos", not(feature = "sandbox")))] fn macos_fd_st_flags(fd: i32) -> u32 { unsafe { let mut st: libc::stat = std::mem::zeroed(); @@ -1046,6 +1177,62 @@ pub fn register_module(ns: &mut DictStorage) { let _ = st_flags; crate::_structseq::new_instance_with_extra(stat_result_seq_type(), seq, extras) } + /// Build a `stat_result` from the sandbox wire `StatBuf` (sandbox build + /// only, hence unix-only): the controller delivers the 10 protocol fields + /// plus integer atime/mtime/ctime; the sub-second and block/device extras + /// are whatever `StatBuf` carries (zero over the wire). Mirrors the unix + /// slot/extra layout of `make_stat_result`. + #[cfg(feature = "sandbox")] + fn make_stat_result_from_statbuf(st: &crate::host_seam::StatBuf) -> pyre_object::PyObjectRef { + let st_atime = st.atime; + let st_mtime = st.mtime; + let st_ctime = st.ctime; + let st_atime_ns = st.atime * 1_000_000_000 + st.atime_nsec; + let st_mtime_ns = st.mtime * 1_000_000_000 + st.mtime_nsec; + let st_ctime_ns = st.ctime * 1_000_000_000 + st.ctime_nsec; + let seq = vec![ + pyre_object::w_int_new(st.mode as i64), + pyre_object::w_int_new(st.ino as i64), + pyre_object::w_int_new(st.dev as i64), + pyre_object::w_int_new(st.nlink as i64), + pyre_object::w_int_new(st.uid as i64), + pyre_object::w_int_new(st.gid as i64), + pyre_object::w_int_new(st.size as i64), + pyre_object::w_int_new(st_atime), + pyre_object::w_int_new(st_mtime), + pyre_object::w_int_new(st_ctime), + ]; + let st_atime_f = st_atime as f64 + 1e-9 * (st_atime_ns - st_atime * 1_000_000_000) as f64; + let st_mtime_f = st_mtime as f64 + 1e-9 * (st_mtime_ns - st_mtime * 1_000_000_000) as f64; + let st_ctime_f = st_ctime as f64 + 1e-9 * (st_ctime_ns - st_ctime * 1_000_000_000) as f64; + #[allow(unused_mut)] + let mut extras = vec![ + ("st_atime", pyre_object::w_float_new(st_atime_f)), + ("st_mtime", pyre_object::w_float_new(st_mtime_f)), + ("st_ctime", pyre_object::w_float_new(st_ctime_f)), + ("st_atime_ns", pyre_object::w_int_new(st_atime_ns)), + ("st_mtime_ns", pyre_object::w_int_new(st_mtime_ns)), + ("st_ctime_ns", pyre_object::w_int_new(st_ctime_ns)), + ( + "nsec_atime", + pyre_object::w_int_new(st_atime_ns.rem_euclid(1_000_000_000)), + ), + ( + "nsec_mtime", + pyre_object::w_int_new(st_mtime_ns.rem_euclid(1_000_000_000)), + ), + ( + "nsec_ctime", + pyre_object::w_int_new(st_ctime_ns.rem_euclid(1_000_000_000)), + ), + ("st_blksize", pyre_object::w_int_new(st.blksize as i64)), + ("st_blocks", pyre_object::w_int_new(st.blocks as i64)), + ("st_rdev", pyre_object::w_int_new(st.rdev as i64)), + ]; + #[cfg(target_os = "macos")] + extras.push(("st_flags", pyre_object::w_int_new(st.st_flags as i64))); + crate::_structseq::new_instance_with_extra(stat_result_seq_type(), seq, extras) + } fn stat_impl( args: &[pyre_object::PyObjectRef], follow_symlinks: bool, @@ -1057,25 +1244,38 @@ pub fn register_module(ns: &mut DictStorage) { let path_str = crate::gateway::fsencode_w(path_obj).map_err(|_| { crate::PyError::type_error("stat: path should be string, bytes, os.PathLike") })?; - let meta = if follow_symlinks { - host_fs::metadata(&path_str) - } else { - host_fs::symlink_metadata(&path_str) - }; - match meta { - Ok(m) => { - #[cfg(target_os = "macos")] - let st_flags = macos_path_st_flags(&path_str, follow_symlinks); - #[cfg(not(target_os = "macos"))] - let st_flags = 0u32; - Ok(make_stat_result(&m, st_flags)) + #[cfg(feature = "sandbox")] + { + let buf = if follow_symlinks { + crate::host_seam::ops::stat(path_str.as_bytes()) + } else { + crate::host_seam::ops::lstat(path_str.as_bytes()) } - Err(e) => { - let kind = e.raw_os_error().unwrap_or(2); - Err(crate::PyError::os_error_with_errno( - kind, - format!("{}: '{}'", e, path_str), - )) + .map_err(|e| crate::host_seam::seam_os_err(e, &path_str))?; + return Ok(make_stat_result_from_statbuf(&buf)); + } + #[cfg(not(feature = "sandbox"))] + { + let meta = if follow_symlinks { + host_fs::metadata(&path_str) + } else { + host_fs::symlink_metadata(&path_str) + }; + match meta { + Ok(m) => { + #[cfg(target_os = "macos")] + let st_flags = macos_path_st_flags(&path_str, follow_symlinks); + #[cfg(not(target_os = "macos"))] + let st_flags = 0u32; + Ok(make_stat_result(&m, st_flags)) + } + Err(e) => { + let kind = e.raw_os_error().unwrap_or(2); + Err(crate::PyError::os_error_with_errno( + kind, + format!("{}: '{}'", e, path_str), + )) + } } } } @@ -1160,9 +1360,9 @@ pub fn register_module(ns: &mut DictStorage) { }; match meta { Ok(m) => { - #[cfg(target_os = "macos")] + #[cfg(all(target_os = "macos", not(feature = "sandbox")))] let st_flags = macos_path_st_flags(&path, follow); - #[cfg(not(target_os = "macos"))] + #[cfg(not(all(target_os = "macos", not(feature = "sandbox"))))] let st_flags = 0u32; Ok(make_stat_result(&m, st_flags)) } @@ -1344,7 +1544,13 @@ pub fn register_module(ns: &mut DictStorage) { return Err(crate::PyError::type_error("fstat() missing argument")); } let fd = (unsafe { pyre_object::w_int_get_value(args[0]) }) as i32; - #[cfg(unix)] + #[cfg(feature = "sandbox")] + { + let buf = crate::host_seam::ops::fstat(fd) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + Ok(make_stat_result_from_statbuf(&buf)) + } + #[cfg(all(unix, not(feature = "sandbox")))] { use std::os::unix::io::FromRawFd; let f = unsafe { std::fs::File::from_raw_fd(fd) }; @@ -1364,7 +1570,7 @@ pub fn register_module(ns: &mut DictStorage) { )), } } - #[cfg(not(unix))] + #[cfg(not(any(unix, feature = "sandbox")))] Err(crate::PyError::os_error_with_errno( 9, "fstat unsupported".to_string(), @@ -1383,13 +1589,22 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "getcwd", |_| { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] { - if let Ok(cwd) = host_os::current_dir() { - return Ok(pyre_object::w_str_new(&cwd.to_string_lossy())); + let cwd = crate::host_seam::ops::getcwd() + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + Ok(pyre_object::w_str_new(&String::from_utf8_lossy(&cwd))) + } + #[cfg(not(feature = "sandbox"))] + { + #[cfg(feature = "host_env")] + { + if let Ok(cwd) = host_os::current_dir() { + return Ok(pyre_object::w_str_new(&cwd.to_string_lossy())); + } } + Ok(pyre_object::w_str_new("")) } - Ok(pyre_object::w_str_new("")) }, 0, ), @@ -1401,21 +1616,31 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "getcwdb", |_| { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] { - if let Ok(cwd) = host_os::current_dir() { - return Ok(pyre_object::w_bytes_from_bytes( - cwd.as_os_str().as_encoded_bytes(), - )); + let cwd = crate::host_seam::ops::getcwd() + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + Ok(pyre_object::w_bytes_from_bytes(&cwd)) + } + #[cfg(not(feature = "sandbox"))] + { + #[cfg(feature = "host_env")] + { + if let Ok(cwd) = host_os::current_dir() { + return Ok(pyre_object::w_bytes_from_bytes( + cwd.as_os_str().as_encoded_bytes(), + )); + } } + Ok(pyre_object::w_bytes_from_bytes(b"")) } - Ok(pyre_object::w_bytes_from_bytes(b"")) }, 0, ), ); - // os.getuid / geteuid / getgid / getegid — real syscalls. - #[cfg(unix)] + // os.getuid / geteuid / getgid / getegid — real syscalls (the sandbox + // build routes these through the controller instead, see below). + #[cfg(all(unix, not(feature = "sandbox")))] unsafe extern "C" { fn getuid() -> u32; fn geteuid() -> u32; @@ -1428,11 +1653,17 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "getuid", |_| { - #[cfg(unix)] + #[cfg(feature = "sandbox")] + { + let v = crate::host_seam::ops::getuid() + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + return Ok(pyre_object::w_int_new(v)); + } + #[cfg(all(unix, not(feature = "sandbox")))] unsafe { return Ok(pyre_object::w_int_new(getuid() as i64)); } - #[cfg(not(unix))] + #[cfg(not(any(unix, feature = "sandbox")))] Ok(pyre_object::w_int_new(0)) }, 0, @@ -1444,11 +1675,17 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "geteuid", |_| { - #[cfg(unix)] + #[cfg(feature = "sandbox")] + { + let v = crate::host_seam::ops::geteuid() + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + return Ok(pyre_object::w_int_new(v)); + } + #[cfg(all(unix, not(feature = "sandbox")))] unsafe { return Ok(pyre_object::w_int_new(geteuid() as i64)); } - #[cfg(not(unix))] + #[cfg(not(any(unix, feature = "sandbox")))] Ok(pyre_object::w_int_new(0)) }, 0, @@ -1460,11 +1697,17 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "getgid", |_| { - #[cfg(unix)] + #[cfg(feature = "sandbox")] + { + let v = crate::host_seam::ops::getgid() + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + return Ok(pyre_object::w_int_new(v)); + } + #[cfg(all(unix, not(feature = "sandbox")))] unsafe { return Ok(pyre_object::w_int_new(getgid() as i64)); } - #[cfg(not(unix))] + #[cfg(not(any(unix, feature = "sandbox")))] Ok(pyre_object::w_int_new(0)) }, 0, @@ -1476,11 +1719,17 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "getegid", |_| { - #[cfg(unix)] + #[cfg(feature = "sandbox")] + { + let v = crate::host_seam::ops::getegid() + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + return Ok(pyre_object::w_int_new(v)); + } + #[cfg(all(unix, not(feature = "sandbox")))] unsafe { return Ok(pyre_object::w_int_new(getegid() as i64)); } - #[cfg(not(unix))] + #[cfg(not(any(unix, feature = "sandbox")))] Ok(pyre_object::w_int_new(0)) }, 0, @@ -1513,7 +1762,13 @@ pub fn register_module(ns: &mut DictStorage) { return Ok(pyre_object::w_none()); } }; - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + if let Ok(Some(value)) = crate::host_seam::ops::getenv(key.as_bytes()) { + return Ok(pyre_object::w_str_new(&String::from_utf8_lossy(&value))); + } + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { if let Ok(value) = host_os::var(&key) { return Ok(pyre_object::w_str_new(&value)); @@ -1545,6 +1800,13 @@ pub fn register_module(ns: &mut DictStorage) { return Err(crate::PyError::type_error("strerror() requires 1 argument")); } }; + #[cfg(feature = "sandbox")] + { + let msg = crate::host_seam::ops::strerror(code) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + return Ok(pyre_object::w_str_new(&String::from_utf8_lossy(&msg))); + } + #[cfg(not(feature = "sandbox"))] Ok(pyre_object::w_str_new( &rustpython_host_env::time::strerror(code), )) @@ -1770,6 +2032,7 @@ pub fn register_module(ns: &mut DictStorage) { ); // os.getppid() -> int + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "getppid", @@ -1895,6 +2158,7 @@ pub fn register_module(ns: &mut DictStorage) { ); // os.dup(fd) -> new_fd + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "dup", @@ -1916,6 +2180,7 @@ pub fn register_module(ns: &mut DictStorage) { ); // os.dup2(fd, fd2, inheritable=True) -> fd2 + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "dup2", @@ -1934,6 +2199,7 @@ pub fn register_module(ns: &mut DictStorage) { ); // os.fsync(fd) + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "fsync", @@ -1956,6 +2222,7 @@ pub fn register_module(ns: &mut DictStorage) { // os.fdatasync(fd) — falls back to fsync on macOS, which has no // fdatasync syscall but exposes the same semantics through fsync. + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "fdatasync", @@ -1982,6 +2249,7 @@ pub fn register_module(ns: &mut DictStorage) { ); // os.mkfifo(path, mode=0o666) -> None + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "mkfifo", @@ -2006,6 +2274,7 @@ pub fn register_module(ns: &mut DictStorage) { ); // os.kill(pid, sig) / os.killpg(pgid, sig) + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "kill", @@ -2026,6 +2295,7 @@ pub fn register_module(ns: &mut DictStorage) { 2, ), ); + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "killpg", @@ -2140,6 +2410,7 @@ pub fn register_module(ns: &mut DictStorage) { ); // os.symlink(src, dst, target_is_directory=False) -> None + #[cfg(not(feature = "sandbox"))] crate::dict_storage_store( ns, "symlink", @@ -2249,6 +2520,14 @@ pub fn register_module(ns: &mut DictStorage) { } let path = extract_path(args[0])?; let mode = (unsafe { pyre_object::w_int_get_value(args[1]) }) as u8; + #[cfg(feature = "sandbox")] + { + return Ok(pyre_object::w_bool_from( + crate::host_seam::ops::access(path.as_bytes(), mode as i32) + .unwrap_or(false), + )); + } + #[cfg(not(feature = "sandbox"))] match host_posix::check_access(std::path::Path::new(&path), mode) { Ok(ok) => Ok(pyre_object::w_bool_from(ok)), Err(_) => Ok(pyre_object::w_bool_from(false)), @@ -2389,7 +2668,7 @@ pub fn register_module(ns: &mut DictStorage) { // wrappers don't do manual retry (relies on PEP 475 OS-level retry), // matching pyre-wide convention rather than introducing a single // outlier. - #[cfg(any(target_os = "linux", target_os = "macos"))] + #[cfg(all(any(target_os = "linux", target_os = "macos"), not(feature = "sandbox")))] crate::dict_storage_store( ns, "sendfile", @@ -3007,5 +3286,61 @@ pub fn register_module(ns: &mut DictStorage) { ); } + // The trampoline only mediates the curated ll_os/ll_time surface, so the + // real impls registered above for process control, fd duplication, host + // filesystem mutation and privilege changes would otherwise reach libc + // directly under sandbox. Overwrite each with a raising stub, mirroring the + // RPython sandbox where unsupported externals are simply unavailable. The + // mediated names (open/read/write/close/lseek/stat/access/getcwd/listdir/ + // getenv/isatty/strerror/get{u,g}id/unlink/mkdir) are intentionally absent + // here — they stay live through host_seam. + #[cfg(feature = "sandbox")] + { + fn sandbox_unavailable(_: &[PyObjectRef]) -> Result { + Err(crate::host_seam::stub("this OS operation")) + } + for name in [ + // process creation / control + "fork", "forkpty", "system", "popen", "execv", "execve", "execvp", + "execvpe", "spawnv", "spawnve", "spawnvp", "spawnvpe", "posix_spawn", + "posix_spawnp", "abort", "_exit", "register_at_fork", "wait", "waitpid", + "kill", "killpg", + // file-descriptor duplication / pipes / ttys / cross-fd copy + + // inheritance control (set_inheritable would mutate a real fd). + "dup", "dup2", "dup3", "pipe", "pipe2", "openpty", "login_tty", + "sendfile", "set_inheritable", + // host filesystem mutation that bypasses the controller + "chmod", "fchmod", "lchmod", "chown", "fchown", "lchown", "chroot", + "chdir", "fchdir", "link", "symlink", "truncate", "ftruncate", + "rename", "rmdir", "mkfifo", "mknod", + // privilege / scheduling + "setuid", "setgid", "setreuid", "setregid", "setresuid", "setresgid", + "setgroups", "initgroups", "setsid", "setpgid", "setpgrp", "nice", + "setpriority", "sched_get_priority_max", "sched_get_priority_min", + // durability + real process environment mutation + "sync", "fsync", "fdatasync", "setenv", "unsetenv", "putenv", + // host filesystem inspection that bypasses the controller VFS. + // DirEntry is a type, but its is_dir/is_file/stat/inode methods stat + // a guest-controlled `path` via host_fs, so neutralise it too (its + // only producer, scandir, is already stubbed here). + "readlink", "scandir", "DirEntry", "statvfs", "fstatvfs", + // host process / environment information leaks + "getpid", "getppid", "uname", "getlogin", "getloadavg", + "getpriority", "times", "umask", "getgroups", "cpu_count", + "_cpu_count", "getresuid", "getresgid", + // host system-configuration probes; pathconf consults a + // guest-controlled path on the real filesystem. + "pathconf", "fpathconf", "sysconf", + // terminal / tty inspection + control + "tcgetpgrp", "tcsetpgrp", "get_terminal_size", "ttyname", + ] { + crate::dict_storage_store( + ns, + name, + crate::make_builtin_function(name, sandbox_unavailable), + ); + } + } + crate::dict_storage_store(ns, "error", crate::typedef::w_object()); } diff --git a/pyre/pyre-interpreter/src/module/signal/interp_signal.rs b/pyre/pyre-interpreter/src/module/signal/interp_signal.rs index 2c012668cf0..2bfa5ae6ddc 100644 --- a/pyre/pyre-interpreter/src/module/signal/interp_signal.rs +++ b/pyre/pyre-interpreter/src/module/signal/interp_signal.rs @@ -350,8 +350,10 @@ fn report_wakeup_fd_error(errno_val: i32) { }; #[cfg(not(unix))] let msg = format!("error {errno_val}"); - eprintln!("Exception ignored when trying to write to the signal wakeup fd:"); - eprintln!("OSError: [Errno {errno_val}] {msg}"); + crate::host_seam::emit_stderr( + b"Exception ignored when trying to write to the signal wakeup fd:\n", + ); + crate::host_seam::emit_stderr(format!("OSError: [Errno {errno_val}] {msg}\n").as_bytes()); } impl AsyncActionOps for CheckSignalAction { @@ -526,7 +528,12 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "raise_signal", |args| { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + let _ = args; + return Err(crate::host_seam::stub("signal.raise_signal")); + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { let signum = if let Some(&a) = args.first() { unsafe { pyre_object::w_int_get_value(a) as i32 } @@ -639,7 +646,12 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "alarm", |args| { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + let _ = args; + return Err(crate::host_seam::stub("signal.alarm")); + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { let secs = if let Some(&a) = args.first() { unsafe { pyre_object::w_int_get_value(a) as u32 } @@ -667,7 +679,11 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "pause", |_| { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + return Err(crate::host_seam::stub("signal.pause")); + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { rustpython_host_env::signal::pause(); Ok(pyre_object::w_none()) @@ -687,7 +703,12 @@ pub fn register_module(ns: &mut DictStorage) { ns, "setitimer", crate::make_builtin_function("setitimer", |args| { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + let _ = args; + return Err(crate::host_seam::stub("signal.setitimer")); + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { if args.len() < 2 { return Err(crate::PyError::type_error( @@ -738,7 +759,12 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "getitimer", |args| { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + let _ = args; + return Err(crate::host_seam::stub("signal.getitimer")); + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { if args.is_empty() { return Err(crate::PyError::type_error( @@ -917,7 +943,12 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "pthread_kill", |args| { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + let _ = args; + return Err(crate::host_seam::stub("signal.pthread_kill")); + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { if args.len() < 2 { return Err(crate::PyError::type_error( @@ -956,7 +987,12 @@ pub fn register_module(ns: &mut DictStorage) { crate::make_builtin_function_with_arity( "pthread_sigmask", |args| { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + let _ = args; + return Err(crate::host_seam::stub("signal.pthread_sigmask")); + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { if args.len() < 2 { return Err(crate::PyError::type_error( @@ -1054,7 +1090,14 @@ pub fn register_module(ns: &mut DictStorage) { ns, "pidfd_send_signal", crate::make_builtin_function("pidfd_send_signal", |args| { - #[cfg(feature = "host_env")] + // Delivers a signal cross-process via a direct syscall, bypassing + // the controller; the `kill`/`killpg` twins are already stubbed. + #[cfg(feature = "sandbox")] + { + let _ = args; + return Err(crate::host_seam::stub("signal.pidfd_send_signal")); + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { if args.len() < 2 { return Err(crate::PyError::type_error( diff --git a/pyre/pyre-interpreter/src/module/sys/vm.rs b/pyre/pyre-interpreter/src/module/sys/vm.rs index a79dfa12032..6e22846c33a 100644 --- a/pyre/pyre-interpreter/src/module/sys/vm.rs +++ b/pyre/pyre-interpreter/src/module/sys/vm.rs @@ -632,10 +632,15 @@ pub fn register_module(ns: &mut DictStorage) { dict_storage_store(ns, "hexversion", w_int_new(0x030e06f0)); // sys.executable — absolute path to the running interpreter so that // subprocess spawns via `sys.executable` resolve. + #[cfg(not(feature = "sandbox"))] let executable = std::env::current_exe() .ok() .and_then(|p| p.to_str().map(str::to_owned)) .unwrap_or_else(|| "pyre".to_owned()); + // Under sandbox a fixed placeholder: current_exe() leaks the host binary + // path (and username), and subprocess spawning is unavailable anyway. + #[cfg(feature = "sandbox")] + let executable = "/bin/pyre".to_owned(); dict_storage_store(ns, "executable", w_str_new(&executable)); // sys.prefix / exec_prefix dict_storage_store(ns, "prefix", w_str_new("")); @@ -798,66 +803,30 @@ pub fn register_module(ns: &mut DictStorage) { dict_storage_store(ns, "warnoptions", w_list_new(vec![])); // sys.builtin_module_names — tuple of names of modules compiled into // the interpreter. PyPy: pypy/module/sys/state.py get_builtin_module_names. - // Pyre: include all stub/native built-ins from importing.rs. + // Pyre: include all stub/native built-ins from importing.rs. The host-access + // modules importing.rs omits under `sandbox` are gated out here too, so the + // advertised set matches what is actually importable. + #[allow(unused_mut)] + let mut builtin_names = vec![ + "__pypy__", "_abc", "_bisect", "_blake2", "_codecs", "_collections", + "_collections_abc", "_contextvars", "_csv", "_datetime", "_decimal", + "_functools", "_hashlib", "_heapq", "_imp", "_io", "_json", "_locale", + "_md5", "_opcode", "_operator", "_pickle", "_random", "_sha1", "_sha2", + "_sha3", "_signal", "_socket", "_sre", "_stat", "_string", "_struct", + "_thread", "_tokenize", "_tracemalloc", "_typing", "_warnings", "_weakref", + "atexit", "binascii", "builtins", "errno", "fcntl", "grp", "itertools", + "marshal", "math", "cmath", "operator", "posix", "pwd", "select", "sys", + "time", + ]; + // Host-access modules registered only in non-sandbox builds (importing.rs); + // under `sandbox` they are omitted, so drop them from the advertised set + // in place — the surrounding order is left untouched. + #[cfg(feature = "sandbox")] + builtin_names.retain(|n| !matches!(*n, "_signal" | "_socket" | "fcntl" | "grp" | "pwd" | "select")); dict_storage_store( ns, "builtin_module_names", - w_tuple_new(vec![ - w_str_new("__pypy__"), - w_str_new("_abc"), - w_str_new("_bisect"), - w_str_new("_blake2"), - w_str_new("_codecs"), - w_str_new("_collections"), - w_str_new("_collections_abc"), - w_str_new("_contextvars"), - w_str_new("_csv"), - w_str_new("_datetime"), - w_str_new("_decimal"), - w_str_new("_functools"), - w_str_new("_hashlib"), - w_str_new("_heapq"), - w_str_new("_imp"), - w_str_new("_io"), - w_str_new("_json"), - w_str_new("_locale"), - w_str_new("_md5"), - w_str_new("_opcode"), - w_str_new("_operator"), - w_str_new("_pickle"), - w_str_new("_random"), - w_str_new("_sha1"), - w_str_new("_sha2"), - w_str_new("_sha3"), - w_str_new("_signal"), - w_str_new("_socket"), - w_str_new("_sre"), - w_str_new("_stat"), - w_str_new("_string"), - w_str_new("_struct"), - w_str_new("_thread"), - w_str_new("_tokenize"), - w_str_new("_tracemalloc"), - w_str_new("_typing"), - w_str_new("_warnings"), - w_str_new("_weakref"), - w_str_new("atexit"), - w_str_new("binascii"), - w_str_new("builtins"), - w_str_new("errno"), - w_str_new("fcntl"), - w_str_new("grp"), - w_str_new("itertools"), - w_str_new("marshal"), - w_str_new("math"), - w_str_new("cmath"), - w_str_new("operator"), - w_str_new("posix"), - w_str_new("pwd"), - w_str_new("select"), - w_str_new("sys"), - w_str_new("time"), - ]), + w_tuple_new(builtin_names.into_iter().map(w_str_new).collect()), ); // sys.stdlib_module_names — frozenset of stdlib module names, read by // `traceback.TracebackException` (`wrong_name in sys.stdlib_module_names`) @@ -1094,20 +1063,35 @@ fn make_std_stream(name: &'static str, fd: i32) -> PyObjectRef { // `backslashreplace` → escaped) instead of panicking in `w_str_get_value`. let write_fn = if to_stderr { crate::make_builtin_function("write", |args| { - use std::io::Write; if let Some(s_obj) = pick_str(args) { let bytes = crate::type_methods::encode_object(s_obj, "utf-8", "backslashreplace")?; - let _ = std::io::stderr().write_all(&bytes); + // Under sandbox fd 1 is the marshalling pipe, so a raw write + // would corrupt the protocol: route through ll_os_write(2,…) + // and let the controller relay it to its own stderr. + #[cfg(not(feature = "sandbox"))] + { + use std::io::Write; + let _ = std::io::stderr().write_all(&bytes); + } + #[cfg(feature = "sandbox")] + crate::host_seam::ops::write(2, &bytes) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; return Ok(w_int_new(unsafe { w_str_len(s_obj) } as i64)); } Ok(w_int_new(0)) }) } else { crate::make_builtin_function("write", |args| { - use std::io::Write; if let Some(s_obj) = pick_str(args) { let bytes = crate::type_methods::encode_object(s_obj, "utf-8", "strict")?; - let _ = std::io::stdout().write_all(&bytes); + #[cfg(not(feature = "sandbox"))] + { + use std::io::Write; + let _ = std::io::stdout().write_all(&bytes); + } + #[cfg(feature = "sandbox")] + crate::host_seam::ops::write(1, &bytes) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; return Ok(w_int_new(unsafe { w_str_len(s_obj) } as i64)); } Ok(w_int_new(0)) @@ -1118,9 +1102,14 @@ fn make_std_stream(name: &'static str, fd: i32) -> PyObjectRef { stream, "flush", crate::make_builtin_function("flush", |_| { - use std::io::Write; - let _ = std::io::stdout().flush(); - let _ = std::io::stderr().flush(); + // The sandbox path writes unbuffered ll_os_write requests, so there + // is nothing to flush (and the real fds are the marshalling pipe). + #[cfg(not(feature = "sandbox"))] + { + use std::io::Write; + let _ = std::io::stdout().flush(); + let _ = std::io::stderr().flush(); + } Ok(w_none()) }), ); diff --git a/pyre/pyre-interpreter/src/module/thread/mod.rs b/pyre/pyre-interpreter/src/module/thread/mod.rs index 4e6c2cc5fde..3f32515baed 100644 --- a/pyre/pyre-interpreter/src/module/thread/mod.rs +++ b/pyre/pyre-interpreter/src/module/thread/mod.rs @@ -147,7 +147,13 @@ fn start_new_thread(args: &[PyObjectRef]) -> Result // host_env we always return 1 (single-threaded sentinel). #[crate::pyre_function] fn get_ident() -> i64 { - #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] + // The sandboxed child is a single logical thread; do not leak the real + // thread id (host state), return the single-threaded sentinel instead. + #[cfg(all( + feature = "host_env", + not(target_arch = "wasm32"), + not(feature = "sandbox") + ))] { return rustpython_host_env::thread::current_thread_id() as i64; } @@ -165,11 +171,14 @@ fn get_ident() -> i64 { // * Other Unix: pthread_self (no true TID concept) #[crate::pyre_function] fn get_native_id() -> i64 { - #[cfg(any(target_os = "linux", target_os = "android"))] + #[cfg(all( + not(feature = "sandbox"), + any(target_os = "linux", target_os = "android") + ))] { return unsafe { libc::syscall(libc::SYS_gettid) } as i64; } - #[cfg(target_os = "macos")] + #[cfg(all(not(feature = "sandbox"), target_os = "macos"))] { let mut tid: u64 = 0; let rc = unsafe { libc::pthread_threadid_np(0, &mut tid as *mut u64) }; @@ -179,13 +188,17 @@ fn get_native_id() -> i64 { return unsafe { libc::pthread_self() } as i64; } #[cfg(all( + not(feature = "sandbox"), unix, not(any(target_os = "linux", target_os = "android", target_os = "macos")) ))] { return unsafe { libc::pthread_self() } as i64; } - #[cfg(not(unix))] + // Sandbox and non-unix: a fixed single-thread sentinel — the sandboxed + // child must not issue the raw `gettid`/`pthread_self` that would expose the + // real kernel/pthread id. + #[allow(unreachable_code)] { 1 } diff --git a/pyre/pyre-interpreter/src/module/time/interp_time.rs b/pyre/pyre-interpreter/src/module/time/interp_time.rs index fe192813c77..ca48d4718a1 100644 --- a/pyre/pyre-interpreter/src/module/time/interp_time.rs +++ b/pyre/pyre-interpreter/src/module/time/interp_time.rs @@ -4,6 +4,11 @@ use pyre_object::*; +// Under sandbox, name libc through the seam facade so any direct syscall call +// in this module is a compile error (only types/constants/pure fns resolve). +#[cfg(feature = "sandbox")] +use crate::host_seam::sys as libc; + #[cfg(feature = "host_env")] use rustpython_host_env::time as host_time; use std::sync::OnceLock; @@ -40,11 +45,16 @@ fn monotonic_seconds() -> f64 { /// Wall-clock seconds since the unix epoch, falling back to 0 on /// `SystemTimeError`. Routes through `host_env::time` when enabled. fn duration_since_epoch() -> std::time::Duration { - #[cfg(feature = "host_env")] + #[cfg(feature = "sandbox")] + { + let secs = crate::host_seam::ops::time().unwrap_or(0.0).max(0.0); + std::time::Duration::from_secs_f64(secs) + } + #[cfg(all(feature = "host_env", not(feature = "sandbox")))] { host_time::duration_since_system_now().unwrap_or_default() } - #[cfg(not(feature = "host_env"))] + #[cfg(not(any(feature = "host_env", feature = "sandbox")))] { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -159,7 +169,14 @@ pub fn sleep(args: &[PyObjectRef]) -> Result { return Ok(w_none()); } let dur = std::time::Duration::from_nanos(timeout_ns as u64); - #[cfg(all(unix, feature = "host_env"))] + #[cfg(feature = "sandbox")] + { + // The controller services the sleep; signal handling is its concern. + crate::host_seam::ops::sleep(dur.as_secs_f64()) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + Ok(w_none()) + } + #[cfg(all(unix, feature = "host_env", not(feature = "sandbox")))] { // `interp_time.py:622-710 time_sleep` — sleep toward a monotonic // deadline; on EINTR deliver any pending signal and retry with the @@ -186,7 +203,7 @@ pub fn sleep(args: &[PyObjectRef]) -> Result { } } } - #[cfg(not(all(unix, feature = "host_env")))] + #[cfg(not(any(all(unix, feature = "host_env"), feature = "sandbox")))] { std::thread::sleep(dur); Ok(w_none()) @@ -236,19 +253,28 @@ pub fn perf_counter_ns(args: &[PyObjectRef]) -> Result Result { - if let Ok(d) = host_time::clock_gettime(host_time::ClockId::CLOCK_PROCESS_CPUTIME_ID) { - return Ok(d.as_nanos() as i128); + #[cfg(feature = "sandbox")] + { + let secs = + crate::host_seam::ops::clock().map_err(|e| crate::host_seam::seam_os_err(e, ""))?; + Ok((secs * 1_000_000_000.0) as i128) } - let mut usage: libc::rusage = unsafe { std::mem::zeroed() }; - if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } == 0 { - let tv_ns = |tv: &libc::timeval| -> i128 { - tv.tv_sec as i128 * 1_000_000_000 + tv.tv_usec as i128 * 1_000 - }; - return Ok(tv_ns(&usage.ru_utime) + tv_ns(&usage.ru_stime)); + #[cfg(not(feature = "sandbox"))] + { + if let Ok(d) = host_time::clock_gettime(host_time::ClockId::CLOCK_PROCESS_CPUTIME_ID) { + return Ok(d.as_nanos() as i128); + } + let mut usage: libc::rusage = unsafe { std::mem::zeroed() }; + if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } == 0 { + let tv_ns = |tv: &libc::timeval| -> i128 { + tv.tv_sec as i128 * 1_000_000_000 + tv.tv_usec as i128 * 1_000 + }; + return Ok(tv_ns(&usage.ru_utime) + tv_ns(&usage.ru_stime)); + } + Err(crate::PyError::runtime_error( + "the processor time used is not available or its value cannot be represented", + )) } - Err(crate::PyError::runtime_error( - "the processor time used is not available or its value cannot be represented", - )) } #[cfg(not(all(unix, feature = "host_env")))] @@ -317,7 +343,12 @@ pub fn clock_gettime_ns(args: &[PyObjectRef]) -> Result Result { if args.len() < 2 { return Err(crate::PyError::type_error( @@ -359,7 +390,12 @@ pub fn clock_settime(args: &[PyObjectRef]) -> Result Result { if args.len() < 2 { return Err(crate::PyError::type_error( @@ -863,8 +899,15 @@ pub fn strftime(args: &[PyObjectRef]) -> Result { "time.strftime is unavailable on wasm32", )) } + // strftime consults $TZ/tzname (%Z/%z) and the LC_TIME locale DB; under + // sandbox the registration is stubbed, so the real body is compiled out. + #[cfg(all(unix, feature = "sandbox"))] + { + let _ = c_fmt; + Err(crate::host_seam::stub("time.strftime")) + } // strftime is available on both Unix and Windows CRT. - #[cfg(unix)] + #[cfg(all(unix, not(feature = "sandbox")))] { let libc_tm = c_tm_to_libc_tm(&tm); let mut buf = vec![0u8; 256]; diff --git a/pyre/pyre-interpreter/src/module/time/mod.rs b/pyre/pyre-interpreter/src/module/time/mod.rs index b2c822f36eb..8cb4aba7901 100644 --- a/pyre/pyre-interpreter/src/module/time/mod.rs +++ b/pyre/pyre-interpreter/src/module/time/mod.rs @@ -52,10 +52,15 @@ crate::py_module! { { crate::dict_storage_store(ns, "clock_getres", crate::make_builtin_function_with_arity("clock_getres", t::clock_getres, 1)); - crate::dict_storage_store(ns, "clock_settime", - crate::make_builtin_function_with_arity("clock_settime", t::clock_settime, 2)); - crate::dict_storage_store(ns, "clock_settime_ns", - crate::make_builtin_function_with_arity("clock_settime_ns", t::clock_settime_ns, 2)); + // clock_settime{,_ns} set the system clock (a privileged + // syscall that escapes mediation); omit them under sandbox. + #[cfg(not(feature = "sandbox"))] + { + crate::dict_storage_store(ns, "clock_settime", + crate::make_builtin_function_with_arity("clock_settime", t::clock_settime, 2)); + crate::dict_storage_store(ns, "clock_settime_ns", + crate::make_builtin_function_with_arity("clock_settime_ns", t::clock_settime_ns, 2)); + } } crate::dict_storage_store(ns, "CLOCK_REALTIME", pyre_object::w_int_new(libc::CLOCK_REALTIME as i64)); @@ -80,6 +85,24 @@ crate::py_module! { crate::dict_storage_store(ns, "CLOCK_THREAD_CPUTIME_ID", pyre_object::w_int_new(libc::CLOCK_THREAD_CPUTIME_ID as i64)); } + // localtime/mktime/ctime/strftime consult $TZ + /etc/localtime (and + // the LC_TIME locale DB), reading host state outside the controller; + // gmtime (UTC) and asctime (fixed C format) stay pure. + #[cfg(feature = "sandbox")] + { + fn tz_unavailable( + _: &[pyre_object::PyObjectRef], + ) -> Result { + Err(crate::host_seam::stub("this time function")) + } + for name in ["localtime", "mktime", "ctime", "strftime"] { + crate::dict_storage_store( + ns, + name, + crate::make_builtin_function(name, tz_unavailable), + ); + } + } #[cfg(not(all(unix, feature = "host_env")))] let _ = ns; } diff --git a/pyre/pyre-interpreter/src/sandbox/mod.rs b/pyre/pyre-interpreter/src/sandbox/mod.rs deleted file mode 100644 index 10d397e7d28..00000000000 --- a/pyre/pyre-interpreter/src/sandbox/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod vfs; diff --git a/pyre/pyre-interpreter/src/warn.rs b/pyre/pyre-interpreter/src/warn.rs index 3d886cbbbc9..b7040313e53 100644 --- a/pyre/pyre-interpreter/src/warn.rs +++ b/pyre/pyre-interpreter/src/warn.rs @@ -17,5 +17,5 @@ pub fn warn_deprecation(msg: &str) { /// _warnings/interp_warnings.py:263: do_warn(space, w_message, w_category, stacklevel-1) /// do_warn_explicit formats: "{filename}:{lineno}: {category}: {message}" pub fn warn(msg: &str, category: &str) { - eprintln!("{category}: {msg}"); + crate::host_seam::emit_stderr(format!("{category}: {msg}\n").as_bytes()); } diff --git a/pyre/pyre-sandbox/Cargo.toml b/pyre/pyre-sandbox/Cargo.toml new file mode 100644 index 00000000000..70ade9cf361 --- /dev/null +++ b/pyre/pyre-sandbox/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "pyre-sandbox" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "RPython-style sandbox protocol, virtual filesystem and controller for pyre" + +[dependencies] +libc = { workspace = true } +indexmap = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/pyre/pyre-sandbox/src/client.rs b/pyre/pyre-sandbox/src/client.rs new file mode 100644 index 00000000000..6d3ca7f0f95 --- /dev/null +++ b/pyre/pyre-sandbox/src/client.rs @@ -0,0 +1,342 @@ +//! The untrusted-client half of the sandbox — port of +//! `rpython/translator/sandbox/rsandbox.py`. +//! +//! Inside `pyre --features sandbox`, every external OS call is replaced by a +//! trampoline that marshals `(fnname, args)` to STDOUT and reads the reply from +//! STDIN using only the raw, *un-sandboxed* `read`/`write` syscalls on fds 0/1 +//! (so the protocol I/O is not itself re-sandboxed). [`syscall`] is the single +//! generic trampoline body that `host_seam::TrampolineHost` calls; RPython +//! instead emits one `make_sandbox_trampoline` closure per external, but the +//! runtime behaviour is identical. + +use std::os::raw::c_void; + +use crate::protocol::{ResultKind, SandboxError, SandboxResult, error_from_code}; +use crate::rmarshal::{ + IntFlavor, Loader, MarshalValue, NeedMore, dump_string, dump_tuple, load_bool, load_float, + load_int, load_longlong, load_statresult, load_string, load_value, +}; +use crate::vfs::StatResult; + +/// A decoded reply payload — the typed Rust value a trampoline returns, keyed by +/// the [`ResultKind`] requested. (The wire `MarshalValue` cannot represent a stat +/// result, so decoding lands in this richer enum rather than `MarshalValue`.) +#[derive(Clone, Debug, PartialEq)] +pub enum SyscallResult { + None, + Int(i64), + Bool(bool), + Str(Vec), + OptStr(Option>), + ListStr(Vec>), + EnvItems(Vec<(Vec, Vec)>), + Stat(StatResult), + Float(f64), +} + +// rsandbox.py:73 — the protocol talks over the raw process fds. +const STDIN_FD: i32 = 0; +const STDOUT_FD: i32 = 1; + +/// A [`NeedMore`] that refills from a raw fd via the un-sandboxed `read(2)` — the +/// reply pipe (STDIN). `buflen` starts at 4096 and doubles on every refill, +/// matching `rsandbox.FdLoader.need_more_data` (rsandbox.py:54-70). A `FdLoader` +/// is therefore `Loader`. +pub struct FdNeedMore { + fd: i32, + buflen: usize, +} + +impl FdNeedMore { + pub fn new(fd: i32) -> Self { + FdNeedMore { fd, buflen: 4096 } + } +} + +impl NeedMore for FdNeedMore { + // rsandbox.py:60 `need_more_data`. + fn need_more(&mut self) -> SandboxResult> { + let mut buf = vec![0u8; self.buflen]; + // SAFETY: read(2) into a buffer we own and sized; raw un-sandboxed read. + // Retry on EINTR so a delivered signal does not spuriously fail the + // protocol read. + let count = loop { + let n = unsafe { libc::read(self.fd, buf.as_mut_ptr() as *mut c_void, self.buflen) }; + if n < 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) { + continue; + } + break n; + }; + if count <= 0 { + return Err(SandboxError::Io); + } + buf.truncate(count as usize); + self.buflen *= 2; + Ok(buf) + } +} + +/// `FdLoader(fd)` — a marshal loader fed by the raw fd. +pub fn fd_loader(fd: i32) -> Loader { + Loader::new(Vec::new(), FdNeedMore::new(fd)) +} + +/// rsandbox.py:42 `writeall_not_sandboxed` — write the whole buffer with the raw +/// un-sandboxed `write(2)`, looping over partial writes, IOError on `count <= 0`. +pub fn writeall_not_sandboxed(fd: i32, mut buf: &[u8]) -> SandboxResult<()> { + while !buf.is_empty() { + // SAFETY: write(2) from a slice we hold for the duration of the call. + // Retry on EINTR so a delivered signal does not spuriously fail the write. + let count = loop { + let n = unsafe { libc::write(fd, buf.as_ptr() as *const c_void, buf.len()) }; + if n < 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) { + continue; + } + break n; + }; + if count <= 0 { + return Err(SandboxError::Io); + } + buf = &buf[count as usize..]; + } + Ok(()) +} + +/// rsandbox.py:90 `reraise_error` — map the leading exception code to a +/// [`SandboxError`]. Code 1 (`OSError`) reads a second int as the errno; +/// 2..=8 map via [`error_from_code`]; anything else is `RuntimeError`. +fn reraise_error(error: i64, loader: &mut Loader) -> SandboxError { + if error == 1 { + match load_int(loader) { + Ok(errno) => SandboxError::Os(errno as i32), + Err(e) => e, + } + } else { + error_from_code(error) + } +} + +/// rsandbox.py:111 `not_implemented_stub` — raise `RuntimeError` for an external +/// whose signature cannot be marshalled. The RPython original also writes `msg` +/// to fd 2; this port omits that write (matching the majit sibling mirror) so +/// the untrusted child never writes directly to an inherited host fd. +pub fn not_implemented_stub(msg: &str) -> SandboxError { + let _ = msg; + SandboxError::Runtime +} + +/// rsandbox.py:158-161 — marshal `fnname` (TYPE_STRING) followed by the argument +/// TUPLE, with the client int convention (`IntFlavor::Rmarshal` = every int as +/// TYPE_INT64). Frozen by `rmarshal::tests::golden_request_open`. +pub fn encode_request(fnname: &str, args: &[MarshalValue]) -> Vec { + let mut buf = Vec::new(); + dump_string(&mut buf, fnname.as_bytes()); + dump_tuple(&mut buf, args, IntFlavor::Rmarshal); + buf +} + +// Project a marshalled string value to raw bytes. +fn as_bytes(v: MarshalValue) -> SandboxResult> { + match v { + MarshalValue::Str(s) => Ok(s), + _ => Err(SandboxError::Protocol("expected string element".into())), + } +} + +/// rsandbox.py:152,165 `load_result = rmarshal.get_loader(s_result)` — decode the +/// typed reply payload by dispatching on [`ResultKind`]. The reply shapes are +/// exactly what `sandlib::encode_reply` emits. +pub fn load_result( + loader: &mut Loader, + kind: ResultKind, +) -> SandboxResult { + match kind { + ResultKind::None => match load_value(loader)? { + MarshalValue::None => Ok(SyscallResult::None), + _ => Err(SandboxError::Protocol("expected None reply".into())), + }, + ResultKind::Int => Ok(SyscallResult::Int(load_int(loader)?)), + ResultKind::LongLong => Ok(SyscallResult::Int(load_longlong(loader)?)), + ResultKind::Bool => Ok(SyscallResult::Bool(load_bool(loader)?)), + ResultKind::Str => Ok(SyscallResult::Str(load_string(loader)?)), + ResultKind::OptStr => match load_value(loader)? { + MarshalValue::None => Ok(SyscallResult::OptStr(None)), + MarshalValue::Str(s) => Ok(SyscallResult::OptStr(Some(s))), + _ => Err(SandboxError::Protocol("expected str|None reply".into())), + }, + ResultKind::ListStr => match load_value(loader)? { + MarshalValue::List(items) => Ok(SyscallResult::ListStr( + items + .into_iter() + .map(as_bytes) + .collect::>()?, + )), + _ => Err(SandboxError::Protocol("expected list[str] reply".into())), + }, + ResultKind::EnvItems => match load_value(loader)? { + MarshalValue::List(items) => { + let mut pairs = Vec::with_capacity(items.len()); + for item in items { + match item { + MarshalValue::Tuple(mut kv) if kv.len() == 2 => { + let value = as_bytes(kv.pop().unwrap())?; + let key = as_bytes(kv.pop().unwrap())?; + pairs.push((key, value)); + } + _ => { + return Err(SandboxError::Protocol( + "expected (str,str) env item".into(), + )); + } + } + } + Ok(SyscallResult::EnvItems(pairs)) + } + _ => Err(SandboxError::Protocol( + "expected list[(str,str)] reply".into(), + )), + }, + ResultKind::StatResult => Ok(SyscallResult::Stat(load_statresult(loader)?)), + ResultKind::Float => Ok(SyscallResult::Float(load_float(loader)?)), + } +} + +/// rsandbox.py:82-88 `sandboxed_io` — write the request, build a loader on the +/// reply pipe, read the leading code, and either raise or return the loader +/// positioned at the result payload. +fn sandboxed_io(buf: &[u8]) -> SandboxResult> { + writeall_not_sandboxed(STDOUT_FD, buf)?; + let mut loader = fd_loader(STDIN_FD); + let error = load_int(&mut loader)?; + if error != 0 { + return Err(reraise_error(error, &mut loader)); + } + Ok(loader) +} + +/// The generic trampoline body (rsandbox.py:157-167 `execute`). Marshals the +/// request, performs the round-trip, decodes the typed result, and asserts the +/// reply was fully consumed. +pub fn syscall( + fnname: &str, + args: &[MarshalValue], + kind: ResultKind, +) -> SandboxResult { + let buf = encode_request(fnname, args); + let mut loader = sandboxed_io(&buf)?; + let result = load_result(&mut loader, kind)?; + loader.check_finished()?; + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sandlib::{Console, SandboxPolicy}; + use crate::vfs::{Dir, File, FsNode}; + use indexmap::IndexMap; + use std::rc::Rc; + + // Round-trip the CLIENT encode/decode against the already-verified + // controller, with no real fds: client encodes a request, the controller + // services it, and the client decodes the controller's reply bytes. This + // proves encode_request + load_result + reraise_error agree with + // sandlib::encode_reply on the wire. + fn files_root() -> FsNode { + let mut map: IndexMap = IndexMap::new(); + map.insert("hi.txt".into(), Rc::new(File::new("Hello, world!\n"))); + Rc::new(Dir::new(map)) + } + + fn serve(policy: &mut SandboxPolicy, request: &[u8]) -> Vec { + let mut replies = Vec::new(); + let (i, mut o, mut e) = (Vec::new(), Vec::new(), Vec::new()); + let mut console = Console { + input: &mut i.as_slice(), + output: &mut o, + error: &mut e, + input_isatty: false, + }; + policy + .handle_until_return(request, &mut replies, &mut console) + .unwrap(); + replies + } + + fn decode(replies: Vec, kind: ResultKind) -> SandboxResult { + let mut loader = Loader::from_bytes(replies); + let error = load_int(&mut loader)?; + if error != 0 { + return Err(reraise_error(error, &mut loader)); + } + let result = load_result(&mut loader, kind)?; + loader.check_finished()?; + Ok(result) + } + + #[test] + fn client_getcwd_roundtrip() { + let mut p = SandboxPolicy::new(files_root(), "/tmp", vec![], false); + let req = encode_request("ll_os.ll_os_getcwd", &[]); + let reply = serve(&mut p, &req); + assert_eq!( + decode(reply, ResultKind::Str).unwrap(), + SyscallResult::Str(b"/tmp".to_vec()) + ); + } + + #[test] + fn client_open_read_roundtrip() { + let mut p = SandboxPolicy::new(files_root(), "/", vec![], false); + let open = encode_request( + "ll_os.ll_os_open", + &[ + MarshalValue::Str(b"/hi.txt".to_vec()), + MarshalValue::Int(libc::O_RDONLY as i64), + MarshalValue::Int(0o777), + ], + ); + let fd = match decode(serve(&mut p, &open), ResultKind::Int).unwrap() { + SyscallResult::Int(fd) => fd, + other => panic!("fd: {other:?}"), + }; + let read = encode_request( + "ll_os.ll_os_read", + &[MarshalValue::Int(fd), MarshalValue::Int(100)], + ); + assert_eq!( + decode(serve(&mut p, &read), ResultKind::Str).unwrap(), + SyscallResult::Str(b"Hello, world!\n".to_vec()) + ); + } + + #[test] + fn client_decodes_oserror_with_errno() { + // open an existing file for write -> controller raises OSError(EPERM); + // the client must surface SandboxError::Os(EPERM). + let mut p = SandboxPolicy::new(files_root(), "/", vec![], false); + let req = encode_request( + "ll_os.ll_os_open", + &[ + MarshalValue::Str(b"/hi.txt".to_vec()), + MarshalValue::Int(libc::O_WRONLY as i64), + MarshalValue::Int(0o666), + ], + ); + let err = decode(serve(&mut p, &req), ResultKind::Int).unwrap_err(); + assert_eq!(err, SandboxError::Os(libc::EPERM)); + } + + #[test] + fn client_decodes_statresult() { + let mut p = SandboxPolicy::new(files_root(), "/", vec![], false); + let req = encode_request( + "ll_os.ll_os_stat", + &[MarshalValue::Str(b"/hi.txt".to_vec())], + ); + match decode(serve(&mut p, &req), ResultKind::StatResult).unwrap() { + SyscallResult::Stat(st) => assert_eq!(st.st_size, 14), + other => panic!("stat: {other:?}"), + } + } +} diff --git a/pyre/pyre-sandbox/src/controller.rs b/pyre/pyre-sandbox/src/controller.rs new file mode 100644 index 00000000000..f95eb6d56e4 --- /dev/null +++ b/pyre/pyre-sandbox/src/controller.rs @@ -0,0 +1,405 @@ +//! The trusted parent process — port of `pypy/sandbox/pypy_interact.py`'s +//! `PyPySandboxedProc` plus the `SandboxedProc` spawn/interact/timeout machinery +//! from `rpython/translator/sandbox/sandlib.py`. +//! +//! `PyPySandboxedProc::interact` spawns the untrusted child with a cleared +//! environment and piped stdio, then runs [`SandboxPolicy::handle_until_return`] +//! against the real console, servicing every `ll_os.*`/`ll_time.*` request over a +//! virtual filesystem. + +use std::io; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use indexmap::IndexMap; + +use crate::sandlib::{Console, SandboxPolicy, TimeoutControl}; +use crate::vfs::{Dir, FsNode, RealDir, RealFile}; + +// pypy_interact.py:39 `argv0 = '/bin/pypy3-c'`. +const ARGV0: &str = "/bin/pypy3-c"; +// pypy_interact.py:40 `virtual_cwd = '/tmp'`. +const VIRTUAL_CWD: &str = "/tmp"; + +/// A monotonic "last activity" timestamp shared with the timeout watchdog. The +/// loop pings it after each serviced message; the watchdog kills the child if +/// too long elapses between pings (the `signal.alarm` reset in `sandlib.py`). +#[derive(Clone)] +struct ActivityClock(Arc>); + +impl ActivityClock { + fn new() -> Self { + ActivityClock(Arc::new(Mutex::new(Instant::now()))) + } + + fn ping(&self) { + *self.0.lock().expect("activity clock poisoned") = Instant::now(); + } + + fn since(&self) -> Duration { + self.0.lock().expect("activity clock poisoned").elapsed() + } +} + +/// A watchdog thread that `SIGKILL`s the child if it goes quiet for `timeout`. +struct Watchdog { + stop: Arc, + handle: Option>, +} + +impl Watchdog { + fn spawn(pid: u32, clock: ActivityClock, timeout: Duration, control: TimeoutControl) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + let handle = thread::spawn(move || { + while !stop_thread.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(200)); + if stop_thread.load(Ordering::Relaxed) { + break; + } + // While the child is blocked at the interactive prompt, keep the + // activity clock fresh so idle time is not charged against the + // timeout (sandlib.py enter_idle/leave_idle). + if control.idle.load(Ordering::Relaxed) { + clock.ping(); + continue; + } + if clock.since() >= timeout { + // SAFETY: a plain kill(2) on the child's pid. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGKILL); + } + // Unblock a long sleep being serviced so the controller + // stops promptly instead of parking for the full duration. + control.cancelled.store(true, Ordering::Relaxed); + break; + } + } + }); + Watchdog { + stop, + handle: Some(handle), + } + } + + fn stop(mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} + +/// pypy_interact.py:43 `build_virtual_root`. +/// +/// `lib_root`, when present, is mounted read-only at `/bin/lib` so the child can +/// import the standard library; an import-free script needs only the executable +/// and `/tmp`. PyPy hardcodes two mounts (`/bin/lib-python` + `/bin/lib_pypy`) +/// matching its own source tree; pyre instead mounts the single `--lib` +/// directory at `/bin/lib` and seeds `PYRE_STDLIB=/bin/lib` (see +/// `PyPySandboxedProc::new`), so the child's importer locates the stdlib through +/// the same env convention it uses untranslated rather than PyPy's fixed layout. +fn build_virtual_root(executable: &Path, tmpdir: Option<&Path>, lib_root: Option<&Path>) -> FsNode { + // pypy_interact.py:47 `exclude = ['.pyc', '.pyo']`. + let exclude = vec![".pyc".to_owned(), ".pyo".to_owned()]; + + let tmpnode: FsNode = match tmpdir { + Some(dir) => Rc::new(RealDir::new(dir, false, false, exclude.clone())), + None => Rc::new(Dir::new(IndexMap::new())), + }; + + let mut bin: IndexMap = IndexMap::new(); + // pypy_interact.py:56 `RealFile(self.executable, mode=0o111)`. + bin.insert( + "pypy3-c".to_owned(), + Rc::new(RealFile::new(executable, 0o111)), + ); + if let Some(lib) = lib_root { + bin.insert( + "lib".to_owned(), + Rc::new(RealDir::new(lib, false, false, exclude)), + ); + } + + let mut root: IndexMap = IndexMap::new(); + root.insert("bin".to_owned(), Rc::new(Dir::new(bin))); + root.insert("tmp".to_owned(), tmpnode); + Rc::new(Dir::new(root)) +} + +/// The trusted controller around an untrusted pyre sandbox child. +pub struct PyPySandboxedProc { + policy: SandboxPolicy, + child: Child, + timeout: Option, +} + +impl PyPySandboxedProc { + /// pypy_interact.py:66 `__init__` + sandlib.py:`SandboxedProc.__init__`. + /// + /// Spawns `executable` (the real sandbox binary) with argv[0] forced to + /// `/bin/pypy3-c`, a cleared environment, and piped stdin/stdout. + pub fn new( + executable: impl AsRef, + arguments: &[String], + tmpdir: Option, + lib_root: Option, + timeout: Option, + allow_net: bool, + log_file: Option, + ) -> io::Result { + let executable = std::fs::canonicalize(executable.as_ref()) + .unwrap_or_else(|_| executable.as_ref().to_path_buf()); + let virtual_root = build_virtual_root(&executable, tmpdir.as_deref(), lib_root.as_deref()); + // Expose the mounted stdlib to the child's importer: `--lib` mounts the + // host directory read-only at `/bin/lib` (build_virtual_root), and the + // child resolves PYRE_STDLIB through the env seam — but `env_clear()` + // below wipes its real environment, so seed the value into the virtual + // environment the controller answers `getenv` from. + let virtual_env = match lib_root { + Some(_) => vec![(b"PYRE_STDLIB".to_vec(), b"/bin/lib".to_vec())], + None => Vec::new(), + }; + // pypy_interact.py:41 `virtual_console_isatty = True`. + let mut policy = SandboxPolicy::new(virtual_root, VIRTUAL_CWD, virtual_env, true); + // VirtualizedSocketProc (sandlib.py:546): the operator opts into + // `tcp://` mediation with `--allow-net`; the default policy is + // network-closed. + policy.set_allow_net(allow_net); + // setlogfile (sandlib.py:334): `--log FILE` appends the guest's stdin + // to FILE. Open eagerly so a bad path fails before the child starts. + if let Some(path) = log_file { + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + policy.set_input_log(file); + } + + let mut command = Command::new(&executable); + command + .arg0(ARGV0) + .args(arguments) + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + // Close any inherited fds beyond stdio before exec, so a fd leaked from + // the trusted controller cannot cross into the untrusted child (the + // analog of subprocess `close_fds=True`). Runs post-fork/pre-exec and is + // async-signal-safe (only raw close(2)/close_range(2)). + // SAFETY: the hook calls only async-signal-safe syscalls and allocates + // nothing. + unsafe { + command.pre_exec(close_inherited_fds); + } + let child = command.spawn()?; + + Ok(PyPySandboxedProc { + policy, + child, + timeout, + }) + } + + /// sandlib.py:`interact` — drive the request/reply loop against the real + /// console until the child exits, returning its exit code. + pub fn interact(&mut self) -> io::Result { + let child_stdout = self + .child + .stdout + .take() + .expect("child stdout is piped at spawn"); + let mut child_stdin = self + .child + .stdin + .take() + .expect("child stdin is piped at spawn"); + + let stdin = io::stdin(); + let stdout = io::stdout(); + let stderr = io::stderr(); + let mut input = stdin.lock(); + let mut output = stdout.lock(); + let mut error = stderr.lock(); + // SAFETY: isatty(2) on fd 0 — read-only query, no aliasing. + let input_isatty = unsafe { libc::isatty(0) == 1 }; + let mut console = Console { + input: &mut input, + output: &mut output, + error: &mut error, + input_isatty, + }; + + let clock = ActivityClock::new(); + // Shared with the request handlers so an interactive read pauses the + // timeout and a watchdog kill unblocks a long sleep. + let control = TimeoutControl::default(); + self.policy.set_timeout_control(control.clone()); + let watchdog = self + .timeout + .map(|t| Watchdog::spawn(self.child.id(), clock.clone(), t, control.clone())); + + let result = { + let mut ping = { + let clock = clock.clone(); + move || clock.ping() + }; + self.policy.handle_until_return_ticked( + child_stdout, + &mut child_stdin, + &mut console, + &mut ping, + ) + }; + + // Close the child's stdin so it observes EOF, then reap it. The watchdog + // is stopped before the reap (inside reap_with_deadline) to close the + // pid-reuse race; the post-EOF exit deadline is enforced there instead. + drop(child_stdin); + let status = self.reap_with_deadline(watchdog); + result?; + let status = status?; + Ok(status.code().unwrap_or(-1)) + } + + /// Reap the child after stdin EOF, stopping the timeout watchdog first. + /// + /// Stopping the watchdog before the reap closes the pid-reuse race: once + /// `wait()`/`try_wait()` reaps the child the OS may recycle its pid, and the + /// watchdog holds only the bare pid, so a late `SIGKILL` could hit an + /// unrelated process. A well-behaved child exits promptly once stdin closes; + /// one that closed stdout but will not exit is bounded by `self.timeout` + /// (the same budget the watchdog enforced per message) and then SIGKILLed + /// via the owned `Child`, so it cannot hang the controller. + fn reap_with_deadline(&mut self, watchdog: Option) -> io::Result { + if let Some(w) = watchdog { + w.stop(); + } + let Some(timeout) = self.timeout else { + return self.child.wait(); + }; + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = self.child.try_wait()? { + return Ok(status); + } + if Instant::now() >= deadline { + let _ = self.child.kill(); + return self.child.wait(); + } + thread::sleep(Duration::from_millis(20)); + } + } +} + +/// Post-fork/pre-exec hook: close every fd >= 3 so only stdio (0/1/2) crosses +/// into the untrusted child. Async-signal-safe — only raw close syscalls, no +/// allocation. A fd already closed is ignored. +fn close_inherited_fds() -> io::Result<()> { + // Bounded brute-force close(2) loop; closing an unopened fd is a harmless + // error. SAFETY: only raw sysconf/close syscalls, no allocation. + unsafe fn close_from_brute_force() { + let max = libc::sysconf(libc::_SC_OPEN_MAX); + let max = if max < 0 { 1024 } else { max as i32 }; + for fd in 3..max { + libc::close(fd); + } + } + #[cfg(target_os = "linux")] + // SAFETY: close_range(2) over a fd range; harmless on already-closed fds. + unsafe { + let r = libc::syscall( + libc::SYS_close_range, + 3 as libc::c_long, + libc::c_uint::MAX as libc::c_long, + 0 as libc::c_long, + ); + // Pre-5.9 kernels lack close_range and return ENOSYS; never leave an + // inherited fd open — fall back to the brute-force loop, as + // _posixsubprocess reverts to brute force whenever the primary + // mechanism is unavailable. + if r != 0 { + close_from_brute_force(); + } + } + #[cfg(not(target_os = "linux"))] + // SAFETY: bounded close(2) loop. + unsafe { + close_from_brute_force(); + } + Ok(()) +} + +impl Drop for PyPySandboxedProc { + fn drop(&mut self) { + // Never leave an orphaned child if interact() bailed early. + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + #[test] + fn virtual_root_exposes_bin_and_tmp() { + let exe = std::env::current_exe().unwrap(); + let root = build_virtual_root(&exe, None, None); + let mut keys = root.keys().unwrap(); + keys.sort(); + assert_eq!(keys, vec!["bin".to_owned(), "tmp".to_owned()]); + + let bin = root.join("bin").unwrap(); + let bin_keys = bin.keys().unwrap(); + assert_eq!(bin_keys, vec!["pypy3-c".to_owned()]); + + // the exe is mounted read-only and openable + let exe_node = bin.join("pypy3-c").unwrap(); + let mut data = Vec::new(); + exe_node.open().unwrap().read_to_end(&mut data).unwrap(); + assert!(!data.is_empty()); + } + + #[test] + fn virtual_root_with_lib_mounts_lib() { + let exe = std::env::current_exe().unwrap(); + let libdir = exe.parent().unwrap(); + let root = build_virtual_root(&exe, None, Some(libdir)); + let bin = root.join("bin").unwrap(); + let mut bin_keys = bin.keys().unwrap(); + bin_keys.sort(); + assert_eq!(bin_keys, vec!["lib".to_owned(), "pypy3-c".to_owned()]); + } + + // The protocol loop itself is covered by sandlib.rs's in-memory test; this + // exercises the spawn + reap path: a child that closes its stdout at once + // produces a clean EOF, ending the loop with the child's exit code. + #[test] + fn interact_reaps_a_child_that_closes_immediately() { + let candidates = ["/usr/bin/true", "/bin/true"]; + let exe = candidates.iter().find(|p| Path::new(p).exists()); + let Some(exe) = exe else { + return; // no `true` binary on this platform: skip + }; + let mut proc = PyPySandboxedProc::new( + exe, + &[], + None, + None, + Some(Duration::from_secs(5)), + false, + None, + ) + .unwrap(); + let code = proc.interact().unwrap(); + assert_eq!(code, 0); + } +} diff --git a/pyre/pyre-sandbox/src/lib.rs b/pyre/pyre-sandbox/src/lib.rs new file mode 100644 index 00000000000..26fb2003143 --- /dev/null +++ b/pyre/pyre-sandbox/src/lib.rs @@ -0,0 +1,86 @@ +//! RPython-style sandbox for pyre. +//! +//! This crate is the single live home of the sandbox protocol shared by the +//! untrusted client (compiled into `pyre --features sandbox`) and the trusted +//! controller (`pyre interact`). It ports, from the RPython/PyPy source tree: +//! +//! - `rpython/rlib/rmarshal.py` + `rpython/translator/sandbox/_marshal.py` +//! -> [`rmarshal`] (the byte-exact wire codec), +//! - `rpython/translator/sandbox/rsandbox.py` (runtime half) +//! -> `client` (the trampoline body; added in a later phase), +//! - `rpython/translator/sandbox/vfs.py` -> [`vfs`], +//! - `rpython/translator/sandbox/sandlib.py` -> [`sandlib`], +//! - `pypy/sandbox/pypy_interact.py` -> [`controller`]. +//! +//! The translator-side shells under `majit-translate/.../sandbox/` stay as inert +//! RPython structural-parity mirrors; this crate is the runtime implementation. +//! +//! # Structural constraint — the guarantee model without a genc backend +//! +//! RPython's `--sandbox` earns a *whole-program, compiler-derived* proof that no +//! syscall instruction survives in the shipped binary: genc emits the entire +//! interpreter + runtime + GC to C from one exhaustive database traversal and, +//! in that same pass, replaces every external funcptr's graph with a fd1/fd0 +//! marshal stub (`rpython/translator/c/node.py:new_funcnode`, +//! `rffi.py:llexternal(sandboxsafe=...)`). The property is total *by +//! construction* — an external call is an explicit, `sandboxsafe`-tagged graph +//! node, and nothing reachable escapes the traversal. +//! +//! pyre has no genc and cannot reproduce that mechanism: the shipped binary is +//! built by rustc/cargo, `majit-translate` is a JIT that only compiles hot +//! *guest* bytecode traces (never the interpreter itself), and an external call +//! is an ordinary `direct_call` funcptr with no `sandboxsafe` tag for a pass to +//! intercept. The translator-side `sandbox/` shells stay inert precisely because +//! they presume that missing backend. So the "no syscall in the binary" property +//! here is **not derived from translation**; it is reconstructed from two layers +//! of a different shape, and the gap between them and genc *is* the boundary to +//! keep in mind: +//! +//! - **Compile time — selective, not total.** The `host_seam` choke-point, the +//! fails-closed `host_seam::sys` facade, and the CI clippy fence make a stray +//! `libc::*` / `std::{fs,env,io,net,process}` call a compile/CI error. This +//! is the closest analog to genc, but it is only as complete as the seam and +//! the fence's *enumerated* surface: a host-access path that names neither +//! `libc` nor a fenced `std` item — a new dependency's own FFI, a raw +//! `syscall!`, an un-rerouted call site — still compiles. genc's proof is +//! exhaustive; this one rests on the fence staying complete as code grows. +//! +//! - **Runtime — total, but platform-bound.** [`seccomp`] is the only +//! whole-program guarantee pyre actually holds: the kernel refuses every +//! non-allowlisted syscall regardless of what is in the binary, so it also +//! covers what the source layer cannot (the linked `host_env` crate, Rust +//! std, an un-rerouted site, a syscall reached by a memory-safety exploit). +//! But it is Linux-only (`#![cfg(target_os = "linux")]`), rides the opt-in +//! `sandbox` feature, can be disabled with `PYRE_SANDBOX_NO_SECCOMP`, and the +//! trusted `interact` controller is exempt by design. +//! +//! **Threat-model bottom line.** On Linux with seccomp active, the child has a +//! genuine kernel-enforced whole-program boundary *despite* the missing genc — +//! it is not "unsafe without genc". Off Linux, or with seccomp disabled, there +//! is no kernel backstop and containment is only as strong as the compile-time +//! seam + fence coverage; the residual exposure (a syscall reached outside the +//! seam, or via unsafe memory corruption) is exactly what genc's whole-program C +//! emission would have closed *structurally*, and it cannot be closed here +//! without re-architecting pyre into an ahead-of-time translator. Widening the +//! child's trusted surface (new host APIs, new dependencies compiled into it) +//! must be weighed against the fence and seccomp allowlist — never assumed away +//! by "it's translated". + +// The trampoline client, the controller, and the policy/dispatch in sandlib +// drive a fork/fd-based sandbox via unix-only syscalls (kill/SIGKILL, arg0, +// O_ACCMODE, pointer-sized read/write); they are unix-only. The wire codec +// (rmarshal), the protocol enums, and the virtual filesystem are +// host-neutral and compile everywhere so `cargo test --all` builds the crate +// on non-unix targets. +#[cfg(unix)] +pub mod client; +#[cfg(unix)] +pub mod controller; +pub mod protocol; +pub mod rmarshal; +#[cfg(unix)] +pub mod sandlib; +// OS-level seccomp backstop for the sandboxed child (Linux only). +#[cfg(target_os = "linux")] +pub mod seccomp; +pub mod vfs; diff --git a/pyre/pyre-sandbox/src/protocol.rs b/pyre/pyre-sandbox/src/protocol.rs new file mode 100644 index 00000000000..0cf3fa0fb42 --- /dev/null +++ b/pyre/pyre-sandbox/src/protocol.rs @@ -0,0 +1,118 @@ +//! Shared sandbox protocol types: the error taxonomy exchanged over the wire and +//! the result-kind tags that drive client-side decoding. +//! +//! The error codes are the contract between `rsandbox.reraise_error` +//! (`rpython/translator/sandbox/rsandbox.py:90-108`) and `sandlib.write_exception` +//! / `EXCEPTION_TABLE` (`rpython/translator/sandbox/sandlib.py:69-94`). The two +//! tables MUST stay in sync; they are unified here. + +/// An error raised across the sandbox boundary. +/// +/// The numeric `Os` payload is the errno carried as a second marshalled int after +/// the exception code (`rsandbox.py:92`). `Protocol` is local to this port: it +/// covers malformed marshal data / EOF on the pipe, which RPython surfaces as an +/// `IOError`/`ValueError` from the loader. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SandboxError { + /// `OSError(errno, ...)` — code 1, followed by the errno int. + Os(i32), + /// `IOError` — code 2. + Io, + /// `OverflowError` — code 3. + Overflow, + /// `ValueError` — code 4. + Value, + /// `ZeroDivisionError` — code 5. + ZeroDivision, + /// `MemoryError` — code 6. + Memory, + /// `KeyError` — code 7. + Key, + /// `IndexError` — code 8. + Index, + /// `RuntimeError` — code 9 (and the catch-all). + Runtime, + /// Malformed wire data / unexpected EOF (loader-level failure). + Protocol(String), +} + +impl std::fmt::Display for SandboxError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SandboxError::Os(e) => write!(f, "OSError({e})"), + SandboxError::Io => f.write_str("IOError"), + SandboxError::Overflow => f.write_str("OverflowError"), + SandboxError::Value => f.write_str("ValueError"), + SandboxError::ZeroDivision => f.write_str("ZeroDivisionError"), + SandboxError::Memory => f.write_str("MemoryError"), + SandboxError::Key => f.write_str("KeyError"), + SandboxError::Index => f.write_str("IndexError"), + SandboxError::Runtime => f.write_str("RuntimeError"), + SandboxError::Protocol(msg) => write!(f, "protocol error: {msg}"), + } + } +} + +impl std::error::Error for SandboxError {} + +pub type SandboxResult = Result; + +/// Map an exception code (the first int of a reply) to a [`SandboxError`]. +/// +/// For code 1 (`OSError`) the caller must subsequently read the errno int and +/// patch it into the returned `Os(0)`. Mirrors `rsandbox.reraise_error`. +pub fn error_from_code(code: i64) -> SandboxError { + match code { + 1 => SandboxError::Os(0), + 2 => SandboxError::Io, + 3 => SandboxError::Overflow, + 4 => SandboxError::Value, + 5 => SandboxError::ZeroDivision, + 6 => SandboxError::Memory, + 7 => SandboxError::Key, + 8 => SandboxError::Index, + _ => SandboxError::Runtime, + } +} + +/// Map a [`SandboxError`] to its wire code. Mirrors `sandlib.EXCEPTION_TABLE`. +pub fn code_for_error(err: &SandboxError) -> i64 { + match err { + SandboxError::Os(_) => 1, + SandboxError::Io => 2, + SandboxError::Overflow => 3, + SandboxError::Value => 4, + SandboxError::ZeroDivision => 5, + SandboxError::Memory => 6, + SandboxError::Key => 7, + SandboxError::Index => 8, + SandboxError::Runtime | SandboxError::Protocol(_) => 9, + } +} + +/// The static shape of a reply value, so the client knows which loader to run +/// after the success code. Each variant corresponds to a `load_result` +/// specialization in `rsandbox.make_sandbox_trampoline`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResultKind { + /// `None` reply (e.g. `ll_os_close`). + None, + /// A plain int (`'i'`/`'I'`). + Int, + /// A forced 64-bit int (`RESULTTYPE_LONGLONG`, e.g. `ll_os_lseek`). + LongLong, + /// A bool (`'T'`/`'F'`). + Bool, + /// A byte string (`ll_os_read`, `ll_os_getcwd`, ...). + Str, + /// `str | None` (`ll_os_getenv`). + OptStr, + /// A list of byte strings (`ll_os_listdir`). + ListStr, + /// A list of `(str, str)` pairs (`ll_os_envitems`). + EnvItems, + /// A hand-packed stat result (`RESULTTYPE_STATRESULT`). + StatResult, + /// A float (`ll_time_time` / `ll_time_clock`). + Float, +} diff --git a/pyre/pyre-sandbox/src/rmarshal.rs b/pyre/pyre-sandbox/src/rmarshal.rs new file mode 100644 index 00000000000..bc5b98f2f52 --- /dev/null +++ b/pyre/pyre-sandbox/src/rmarshal.rs @@ -0,0 +1,736 @@ +//! Byte-exact port of the RPython sandbox wire format. +//! +//! Two upstream files define the format, and they differ in one respect that is +//! load-bearing for interop: +//! +//! - `rpython/rlib/rmarshal.py` is the codec used by the sandboxed *client* +//! (`rsandbox.py`). On a 64-bit host its `dump_int` always emits +//! `TYPE_INT64` (`rmarshal.py:157-164` -> `dump_longlong`). +//! - `rpython/translator/sandbox/_marshal.py` is the *controller* codec +//! (`sandlib.py`). Its `dump_int` emits `TYPE_INT` for ints that fit in 31 +//! bits and `TYPE_INT64` otherwise (`_marshal.py:108-116`). +//! +//! Both *decoders* accept either int tag, which is why the wire interoperates. +//! [`IntFlavor`] selects the encoder convention; the loaders accept both. + +use crate::protocol::{SandboxError, SandboxResult}; +use crate::vfs::StatResult; + +// rmarshal.py:71-80 +pub const TYPE_NONE: u8 = b'N'; +pub const TYPE_FALSE: u8 = b'F'; +pub const TYPE_TRUE: u8 = b'T'; +pub const TYPE_INT: u8 = b'i'; +pub const TYPE_INT64: u8 = b'I'; +pub const TYPE_FLOAT: u8 = b'f'; +pub const TYPE_STRING: u8 = b's'; +pub const TYPE_TUPLE: u8 = b'('; +pub const TYPE_LIST: u8 = b'['; +pub const TYPE_DICT: u8 = b'{'; + +/// End-of-dict marker byte (`rmarshal.py:436`, the ASCII char `'0'`). +const DICT_END: u8 = b'0'; + +/// Which `dump_int` convention to use when encoding ints. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum IntFlavor { + /// `rmarshal.py` (client): every int is `TYPE_INT64`. + Rmarshal, + /// `_marshal.py` (controller): small ints `TYPE_INT`, large `TYPE_INT64`. + Marshal, +} + +/// A generic marshalled value. Strings hold raw bytes (the wire allows embedded +/// NULs and non-UTF-8 — e.g. `os.read` of binary data), so this is `Vec`, +/// not `String`. +#[derive(Clone, Debug, PartialEq)] +pub enum MarshalValue { + None, + Bool(bool), + Int(i64), + Str(Vec), + Float(f64), + Tuple(Vec), + List(Vec), + Dict(Vec<(MarshalValue, MarshalValue)>), +} + +// ───────────────────────────────────────────────────────────────────────────── +// Encoders (append to a Vec, mirroring rmarshal's "buf is a list of chars"). +// ───────────────────────────────────────────────────────────────────────────── + +/// rmarshal.py:119-127 `w_long` — the low 32 bits, little-endian. +pub fn w_long(buf: &mut Vec, x: i64) { + buf.extend_from_slice(&(x as u32).to_le_bytes()); +} + +/// rmarshal.py:184-188 `dump_longlong` body — low dword then high dword. +fn w_long64(buf: &mut Vec, x: i64) { + w_long(buf, x); + w_long(buf, x >> 32); +} + +/// rmarshal.py:129-131 +pub fn dump_none(buf: &mut Vec) { + buf.push(TYPE_NONE); +} + +/// rmarshal.py:140-145 +pub fn dump_bool(buf: &mut Vec, x: bool) { + buf.push(if x { TYPE_TRUE } else { TYPE_FALSE }); +} + +/// rmarshal.py:157-164 (client) / _marshal.py:108-116 (controller). +pub fn dump_int(buf: &mut Vec, x: i64, flavor: IntFlavor) { + match flavor { + IntFlavor::Rmarshal => { + buf.push(TYPE_INT64); + w_long64(buf, x); + } + IntFlavor::Marshal => { + let y = x >> 31; + if y != 0 && y != -1 { + buf.push(TYPE_INT64); + w_long64(buf, x); + } else { + buf.push(TYPE_INT); + w_long(buf, x); + } + } + } +} + +/// rmarshal.py:223-230 `dump_string_or_none` (the non-None branch). +pub fn dump_string(buf: &mut Vec, s: &[u8]) { + buf.push(TYPE_STRING); + w_long(buf, s.len() as i64); + buf.extend_from_slice(s); +} + +/// rmarshal.py:208-213 `dump_float` — `formatd(x, 'g', 17)` with a single length +/// byte. +/// +/// The wire field is `'f'` + one length byte + an ASCII float that the peer +/// parses back with `float()` (`rmarshal.py:215-221` / `_marshal.py:...`). The +/// single length byte caps the text at 255 chars, so `%.17g` is the right +/// choice: it always round-trips and stays compact (it uses an exponent for +/// large/small magnitudes), unlike a plain decimal expansion. The controller +/// (`_marshal.py`) nominally uses `repr(x)`; for an all-Rust client+controller +/// the exact digits are an implementation detail of the format, and `%.17g` +/// round-trips identically. +pub fn dump_float(buf: &mut Vec, x: f64) { + let s = c_format_g(x, 17); + buf.push(TYPE_FLOAT); + buf.push(s.len() as u8); + buf.extend_from_slice(&s); +} + +/// rmarshal.py:472-484 `dump_tuple`. +pub fn dump_tuple(buf: &mut Vec, items: &[MarshalValue], flavor: IntFlavor) { + buf.push(TYPE_TUPLE); + w_long(buf, items.len() as i64); + for item in items { + dump_value(buf, item, flavor); + } +} + +/// rmarshal.py:390-405 `dump_list_or_none` (non-None branch). +pub fn dump_list(buf: &mut Vec, items: &[MarshalValue], flavor: IntFlavor) { + buf.push(TYPE_LIST); + w_long(buf, items.len() as i64); + for item in items { + dump_value(buf, item, flavor); + } +} + +/// rmarshal.py:428-447 `dump_dict_or_none` (non-None branch). +pub fn dump_dict(buf: &mut Vec, items: &[(MarshalValue, MarshalValue)], flavor: IntFlavor) { + buf.push(TYPE_DICT); + for (key, value) in items { + dump_value(buf, key, flavor); + dump_value(buf, value, flavor); + } + buf.push(DICT_END); +} + +/// Dispatch encoder for a [`MarshalValue`]. +pub fn dump_value(buf: &mut Vec, value: &MarshalValue, flavor: IntFlavor) { + match value { + MarshalValue::None => dump_none(buf), + MarshalValue::Bool(b) => dump_bool(buf, *b), + MarshalValue::Int(i) => dump_int(buf, *i, flavor), + MarshalValue::Str(s) => dump_string(buf, s), + MarshalValue::Float(f) => dump_float(buf, *f), + MarshalValue::Tuple(items) => dump_tuple(buf, items, flavor), + MarshalValue::List(items) => dump_list(buf, items, flavor), + MarshalValue::Dict(items) => dump_dict(buf, items, flavor), + } +} + +// ── The two hand-packed reply encoders (sandlib.py:43-64) ──────────────────── + +/// `RESULTTYPE_STATRESULT` (`sandlib.py:43-61`), format string `"iIIiiiIfff"` +/// over the 10-field `os.stat_result`. rmarshal's stat loader insists on the +/// exact per-field int widths, so this is hand-packed rather than a plain tuple. +pub fn dump_statresult(buf: &mut Vec, st: &StatResult) { + buf.push(TYPE_TUPLE); + w_long(buf, 10); + pack_i(buf, st.st_mode as i64); // st_mode + pack_big(buf, st.st_ino as i64); // st_ino + pack_big(buf, st.st_dev as i64); // st_dev + pack_i(buf, st.st_nlink as i64); // st_nlink + pack_i(buf, st.st_uid as i64); // st_uid + pack_i(buf, st.st_gid as i64); // st_gid + pack_big(buf, st.st_size as i64); // st_size + pack_g(buf, st.st_atime as f64); // st_atime + pack_g(buf, st.st_mtime as f64); // st_mtime + pack_g(buf, st.st_ctime as f64); // st_ctime +} + +/// `RESULTTYPE_LONGLONG` (`sandlib.py:62-64`), `struct.pack(", v: i64) { + buf.push(TYPE_INT64); + buf.extend_from_slice(&v.to_le_bytes()); +} + +// `struct.pack(", v: i64) { + buf.push(TYPE_INT); + w_long(buf, v); +} + +// `struct.pack(", v: i64) { + buf.push(TYPE_INT64); + buf.extend_from_slice(&v.to_le_bytes()); +} + +// `'f'` + one length byte + `"%g" % v`. +fn pack_g(buf: &mut Vec, v: f64) { + let s = c_format_g(v, 6); + buf.push(TYPE_FLOAT); + buf.push(s.len() as u8); + buf.extend_from_slice(&s); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Loaders (a streaming Loader over a NeedMore source — port of rmarshal.Loader +// + rsandbox.FdLoader). +// ───────────────────────────────────────────────────────────────────────────── + +/// Source of more bytes when the loader runs out. `rmarshal.Loader.need_more_data` +/// (`rmarshal.py:292`) errors; `rsandbox.FdLoader` (`rsandbox.py:54-71`) reads the +/// pipe. Returning an empty `Vec` signals EOF. +pub trait NeedMore { + fn need_more(&mut self) -> SandboxResult>; +} + +/// A `NeedMore` over a complete in-memory buffer: any request for more data is a +/// protocol error (`rmarshal.py:293`). +pub struct Complete; + +impl NeedMore for Complete { + fn need_more(&mut self) -> SandboxResult> { + Err(SandboxError::Protocol("not enough data".into())) + } +} + +/// A `NeedMore` that pulls more bytes from any reader (the controller reading the +/// child's stdout pipe — the `FdLoader` analog on the controller side). A short +/// read of 0 bytes means EOF and is surfaced as an empty `Vec`. +pub struct ReadNeedMore { + reader: R, + chunk: usize, +} + +impl ReadNeedMore { + pub fn new(reader: R) -> Self { + ReadNeedMore { + reader, + chunk: 4096, + } + } +} + +impl NeedMore for ReadNeedMore { + fn need_more(&mut self) -> SandboxResult> { + let mut buf = vec![0u8; self.chunk]; + let n = self + .reader + .read(&mut buf) + .map_err(|e| SandboxError::Protocol(e.to_string()))?; + buf.truncate(n); + Ok(buf) + } +} + +/// rmarshal.py:282-332 `Loader`. +pub struct Loader { + buf: Vec, + pos: usize, + src: N, +} + +impl Loader { + /// Build a loader over a fully-available buffer. + pub fn from_bytes(buf: Vec) -> Self { + Loader { + buf, + pos: 0, + src: Complete, + } + } +} + +impl Loader { + pub fn new(buf: Vec, src: N) -> Self { + Loader { buf, pos: 0, src } + } + + fn ensure(&mut self, end: usize) -> SandboxResult<()> { + while end > self.buf.len() { + let more = self.src.need_more()?; + if more.is_empty() { + return Err(SandboxError::Protocol("unexpected EOF".into())); + } + self.buf.extend_from_slice(&more); + } + Ok(()) + } + + // rmarshal.py:312-317 + fn readchr(&mut self) -> SandboxResult { + self.ensure(self.pos + 1)?; + let c = self.buf[self.pos]; + self.pos += 1; + Ok(c) + } + + // rmarshal.py:319-323 + fn peekchr(&mut self) -> SandboxResult { + self.ensure(self.pos + 1)?; + Ok(self.buf[self.pos]) + } + + // rmarshal.py:325-332 `readlong` — 4 signed LE bytes. + fn readlong(&mut self) -> SandboxResult { + self.ensure(self.pos + 4)?; + let b = &self.buf[self.pos..self.pos + 4]; + let v = i32::from_le_bytes([b[0], b[1], b[2], b[3]]); + self.pos += 4; + Ok(v) + } + + // rmarshal.py:298-310 `readstr`. + fn readstr(&mut self, count: usize) -> SandboxResult> { + self.ensure(self.pos + count)?; + let s = self.buf[self.pos..self.pos + count].to_vec(); + self.pos += count; + Ok(s) + } + + fn read_i64_le(&mut self) -> SandboxResult { + // low dword zero-extended, high dword sign-extended (rmarshal.py:176-178). + let lo = self.readlong()? as u32 as i64; + let hi = (self.readlong()? as i64) << 32; + Ok(lo | hi) + } + + /// rmarshal.py:282-290 `check_finished`. + pub fn check_finished(&self) -> SandboxResult<()> { + if self.pos != self.buf.len() { + Err(SandboxError::Protocol("not all data consumed".into())) + } else { + Ok(()) + } + } + + /// Drop the bytes already consumed (`0..pos`), so a long-lived request + /// stream does not let the buffer grow without bound; the next message then + /// starts at offset 0. `drain(..pos)` (not `clear`) preserves any bytes of a + /// following pipelined message already read into the buffer. + pub fn drain_consumed(&mut self) { + self.buf.drain(..self.pos); + self.pos = 0; + } + + /// Returns `true` if the stream is exhausted at a message boundary (no + /// buffered bytes and the source is at EOF). This is the controller's + /// `read_message` -> `EOFError` check (`sandlib.py:237-240`): a clean EOF + /// between messages ends the loop, whereas EOF mid-message is an error. + pub fn at_message_boundary_eof(&mut self) -> SandboxResult { + if self.pos < self.buf.len() { + return Ok(false); + } + let more = self.src.need_more()?; + if more.is_empty() { + return Ok(true); + } + self.buf.extend_from_slice(&more); + Ok(false) + } +} + +/// rmarshal.py:173-182 `load_int` — accepts both `TYPE_INT` and `TYPE_INT64`. +pub fn load_int(ld: &mut Loader) -> SandboxResult { + match ld.readchr()? { + TYPE_INT64 => ld.read_i64_le(), + TYPE_INT => Ok(ld.readlong()? as i64), + _ => Err(SandboxError::Protocol("expected an int".into())), + } +} + +/// rmarshal.py:200-205 `load_longlong` — requires `TYPE_INT64`. +pub fn load_longlong(ld: &mut Loader) -> SandboxResult { + if ld.readchr()? != TYPE_INT64 { + return Err(SandboxError::Protocol("expected a longlong".into())); + } + ld.read_i64_le() +} + +/// rmarshal.py:246-252 `load_string` — bytes (NULs allowed). +pub fn load_string(ld: &mut Loader) -> SandboxResult> { + if ld.readchr()? != TYPE_STRING { + return Err(SandboxError::Protocol("expected a string".into())); + } + let length = ld.readlong()?; + if length < 0 { + return Err(SandboxError::Protocol("negative string length".into())); + } + ld.readstr(length as usize) +} + +/// rmarshal.py:215-221 `load_float`. +pub fn load_float(ld: &mut Loader) -> SandboxResult { + if ld.readchr()? != TYPE_FLOAT { + return Err(SandboxError::Protocol("expected a float".into())); + } + let length = ld.readchr()? as usize; + let s = ld.readstr(length)?; + parse_ascii_float(&s) +} + +/// rmarshal.py:147-154 `load_bool`. +pub fn load_bool(ld: &mut Loader) -> SandboxResult { + match ld.readchr()? { + TYPE_TRUE => Ok(true), + TYPE_FALSE => Ok(false), + _ => Err(SandboxError::Protocol("expected a bool".into())), + } +} + +/// Generic value loader (used by the controller to read `(fnname, args)` and by +/// round-trip tests). Dispatches on the tag byte. +pub fn load_value(ld: &mut Loader) -> SandboxResult { + let tag = ld.readchr()?; + match tag { + TYPE_NONE => Ok(MarshalValue::None), + TYPE_TRUE => Ok(MarshalValue::Bool(true)), + TYPE_FALSE => Ok(MarshalValue::Bool(false)), + TYPE_INT => Ok(MarshalValue::Int(ld.readlong()? as i64)), + TYPE_INT64 => Ok(MarshalValue::Int(ld.read_i64_le()?)), + TYPE_FLOAT => { + let length = ld.readchr()? as usize; + let s = ld.readstr(length)?; + Ok(MarshalValue::Float(parse_ascii_float(&s)?)) + } + TYPE_STRING => { + let length = ld.readlong()?; + if length < 0 { + return Err(SandboxError::Protocol("negative string length".into())); + } + Ok(MarshalValue::Str(ld.readstr(length as usize)?)) + } + TYPE_TUPLE | TYPE_LIST => { + let length = ld.readlong()?; + if length < 0 { + return Err(SandboxError::Protocol("negative sequence length".into())); + } + // Do not pre-size from the wire-declared length: an untrusted child + // can claim a huge count in a few bytes and force a multi-gigabyte + // `with_capacity` (OOM) before any item is read. Grow as items + // actually arrive — a lying length simply hits EOF below. + let mut items = Vec::new(); + for _ in 0..length { + items.push(load_value(ld)?); + } + if tag == TYPE_TUPLE { + Ok(MarshalValue::Tuple(items)) + } else { + Ok(MarshalValue::List(items)) + } + } + TYPE_DICT => { + let mut items = Vec::new(); + while ld.peekchr()? != DICT_END { + let key = load_value(ld)?; + let value = load_value(ld)?; + items.push((key, value)); + } + ld.readchr()?; // consume DICT_END + Ok(MarshalValue::Dict(items)) + } + other => Err(SandboxError::Protocol(format!( + "bad marshal tag {other:#x}" + ))), + } +} + +/// Decode a `RESULTTYPE_STATRESULT` reply into a [`StatResult`]. The field order +/// and per-field tag widths mirror [`dump_statresult`]. +pub fn load_statresult(ld: &mut Loader) -> SandboxResult { + if ld.readchr()? != TYPE_TUPLE { + return Err(SandboxError::Protocol("expected a stat tuple".into())); + } + let count = ld.readlong()?; + if count != 10 { + return Err(SandboxError::Protocol( + "stat tuple must have 10 fields".into(), + )); + } + let st_mode = load_int(ld)? as u32; + let st_ino = load_int(ld)? as u64; + let st_dev = load_int(ld)? as u64; + let st_nlink = load_int(ld)? as u64; + let st_uid = load_int(ld)? as u32; + let st_gid = load_int(ld)? as u32; + let st_size = load_int(ld)? as u64; + let st_atime = load_float(ld)? as i64; + let st_mtime = load_float(ld)? as i64; + let st_ctime = load_float(ld)? as i64; + Ok(StatResult { + st_mode, + st_ino, + st_dev, + st_nlink, + st_uid, + st_gid, + st_size, + st_atime, + st_mtime, + st_ctime, + }) +} + +// ── float formatting helpers ───────────────────────────────────────────────── + +/// C `printf("%.*g", precision, x)` — matches RPython `formatd(x, 'g', precision)`. +fn c_format_g(x: f64, precision: i32) -> Vec { + unsafe { + let fmt = b"%.*g\0".as_ptr() as *const libc::c_char; + let needed = libc::snprintf(std::ptr::null_mut(), 0, fmt, precision as libc::c_int, x); + if needed < 0 { + return Vec::new(); + } + let mut out = vec![0u8; needed as usize + 1]; + libc::snprintf( + out.as_mut_ptr() as *mut libc::c_char, + out.len(), + fmt, + precision as libc::c_int, + x, + ); + out.truncate(needed as usize); // drop the trailing NUL + out + } +} + +fn parse_ascii_float(s: &[u8]) -> SandboxResult { + let text = + std::str::from_utf8(s).map_err(|_| SandboxError::Protocol("non-ascii float".into()))?; + match text { + "inf" => Ok(f64::INFINITY), + "-inf" => Ok(f64::NEG_INFINITY), + "nan" => Ok(f64::NAN), + _ => text + .parse::() + .map_err(|_| SandboxError::Protocol(format!("bad float {text:?}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // The golden byte vectors below were generated by an independent Python + // oracle replicating the exact `rmarshal.py` / `sandlib.py` byte layout (NOT + // by hand). They freeze the wire format. + + #[test] + fn golden_request_open() { + // ("ll_os.ll_os_open", ("/tmp/foobar", 0, 0o777)) + let mut buf = Vec::new(); + dump_string(&mut buf, b"ll_os.ll_os_open"); + dump_tuple( + &mut buf, + &[ + MarshalValue::Str(b"/tmp/foobar".to_vec()), + MarshalValue::Int(0), + MarshalValue::Int(0o777), + ], + IntFlavor::Rmarshal, + ); + let expected: &[u8] = &[ + 0x73, 0x10, 0x00, 0x00, 0x00, 0x6c, 0x6c, 0x5f, 0x6f, 0x73, 0x2e, 0x6c, 0x6c, 0x5f, + 0x6f, 0x73, 0x5f, 0x6f, 0x70, 0x65, 0x6e, 0x28, 0x03, 0x00, 0x00, 0x00, 0x73, 0x0b, + 0x00, 0x00, 0x00, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72, + 0x49, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x49, 0xff, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + ]; + assert_eq!(buf.as_slice(), expected); + } + + #[test] + fn golden_reply_ok_fd77() { + // error code 0, then fd 77 — controller `_marshal` int flavor. + let mut buf = Vec::new(); + dump_int(&mut buf, 0, IntFlavor::Marshal); + dump_int(&mut buf, 77, IntFlavor::Marshal); + let expected: &[u8] = &[0x69, 0x00, 0x00, 0x00, 0x00, 0x69, 0x4d, 0x00, 0x00, 0x00]; + assert_eq!(buf.as_slice(), expected); + } + + #[test] + fn golden_reply_oserror_2() { + // write_exception: exception code 1 then errno 2 (no leading success 0). + let mut buf = Vec::new(); + dump_int(&mut buf, 1, IntFlavor::Marshal); + dump_int(&mut buf, 2, IntFlavor::Marshal); + let expected: &[u8] = &[0x69, 0x01, 0x00, 0x00, 0x00, 0x69, 0x02, 0x00, 0x00, 0x00]; + assert_eq!(buf.as_slice(), expected); + } + + #[test] + fn golden_statresult() { + // os.stat_result((55,0,0,0,0,0,0x12380000007,0,0,0)) + let st = StatResult { + st_mode: 55, + st_ino: 0, + st_dev: 0, + st_nlink: 0, + st_uid: 0, + st_gid: 0, + st_size: 0x12380000007, + st_atime: 0, + st_mtime: 0, + st_ctime: 0, + }; + let mut buf = Vec::new(); + dump_statresult(&mut buf, &st); + let expected: &[u8] = &[ + 0x28, 0x0a, 0x00, 0x00, 0x00, 0x69, 0x37, 0x00, 0x00, 0x00, 0x49, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x49, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x69, 0x00, 0x00, 0x00, 0x00, 0x69, 0x00, 0x00, 0x00, 0x00, 0x69, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x07, 0x00, 0x00, 0x80, 0x23, 0x01, 0x00, 0x00, 0x66, 0x01, 0x30, 0x66, + 0x01, 0x30, 0x66, 0x01, 0x30, + ]; + assert_eq!(buf.as_slice(), expected); + } + + #[test] + fn golden_longlong_result() { + let mut buf = Vec::new(); + dump_longlong_result(&mut buf, 0x12380000007); + let expected: &[u8] = &[0x49, 0x07, 0x00, 0x00, 0x80, 0x23, 0x01, 0x00, 0x00]; + assert_eq!(buf.as_slice(), expected); + } + + fn roundtrip(value: MarshalValue, flavor: IntFlavor) { + let mut buf = Vec::new(); + dump_value(&mut buf, &value, flavor); + let encoded = buf.clone(); + let mut ld = Loader::from_bytes(buf); + let back = load_value(&mut ld) + .unwrap_or_else(|e| panic!("load {value:?} ({flavor:?}) from {encoded:02x?}: {e}")); + ld.check_finished() + .unwrap_or_else(|e| panic!("finish {value:?} ({flavor:?}) from {encoded:02x?}: {e}")); + assert_eq!(back, value); + } + + #[test] + fn roundtrip_all_types() { + for flavor in [IntFlavor::Rmarshal, IntFlavor::Marshal] { + roundtrip(MarshalValue::None, flavor); + roundtrip(MarshalValue::Bool(true), flavor); + roundtrip(MarshalValue::Bool(false), flavor); + roundtrip(MarshalValue::Int(0), flavor); + roundtrip(MarshalValue::Int(77), flavor); + roundtrip(MarshalValue::Int(-1), flavor); + roundtrip(MarshalValue::Int(0x12380000007), flavor); + roundtrip(MarshalValue::Int(i64::MIN), flavor); + roundtrip(MarshalValue::Int(i64::MAX), flavor); + // embedded NUL + non-ascii bytes + roundtrip(MarshalValue::Str(b"he\x00llo\xff".to_vec()), flavor); + roundtrip(MarshalValue::Str(Vec::new()), flavor); + roundtrip(MarshalValue::Float(0.0), flavor); + roundtrip(MarshalValue::Float(3.011), flavor); + roundtrip(MarshalValue::Float(-1.5e300), flavor); + roundtrip( + MarshalValue::Tuple(vec![ + MarshalValue::Str(b"/tmp/foobar".to_vec()), + MarshalValue::Int(0), + MarshalValue::Int(0o777), + MarshalValue::Bool(true), + ]), + flavor, + ); + roundtrip( + MarshalValue::List(vec![MarshalValue::Int(1), MarshalValue::Int(2)]), + flavor, + ); + roundtrip( + MarshalValue::Dict(vec![( + MarshalValue::Str(b"k".to_vec()), + MarshalValue::Str(b"v".to_vec()), + )]), + flavor, + ); + } + } + + #[test] + fn both_int_tags_decode_equal() { + // 'i'-encoded and 'I'-encoded 77 must load to the same value. + let mut small = Vec::new(); + dump_int(&mut small, 77, IntFlavor::Marshal); // 'i' + let mut big = Vec::new(); + dump_int(&mut big, 77, IntFlavor::Rmarshal); // 'I' + assert_eq!(small[0], TYPE_INT); + assert_eq!(big[0], TYPE_INT64); + let a = load_int(&mut Loader::from_bytes(small)).unwrap(); + let b = load_int(&mut Loader::from_bytes(big)).unwrap(); + assert_eq!(a, 77); + assert_eq!(b, 77); + } + + #[test] + fn statresult_roundtrip() { + let st = StatResult { + st_mode: 0o100644, + st_ino: 42, + st_dev: 1, + st_nlink: 1, + st_uid: 0, + st_gid: 0, + st_size: 0x12380000007, + st_atime: 0, + st_mtime: 0, + st_ctime: 0, + }; + let mut buf = Vec::new(); + dump_statresult(&mut buf, &st); + let mut ld = Loader::from_bytes(buf); + let back = load_statresult(&mut ld).unwrap(); + ld.check_finished().unwrap(); + assert_eq!(back, st); + } + + #[test] + fn longlong_result_roundtrips_via_load_longlong() { + let mut buf = Vec::new(); + dump_longlong_result(&mut buf, -5); + let mut ld = Loader::from_bytes(buf); + assert_eq!(load_longlong(&mut ld).unwrap(), -5); + ld.check_finished().unwrap(); + } +} diff --git a/pyre/pyre-sandbox/src/sandlib.rs b/pyre/pyre-sandbox/src/sandlib.rs new file mode 100644 index 00000000000..c2007859534 --- /dev/null +++ b/pyre/pyre-sandbox/src/sandlib.rs @@ -0,0 +1,1228 @@ +//! Controller-side policy + message loop — port of +//! `rpython/translator/sandbox/sandlib.py`. +//! +//! `SandboxPolicy` merges the two upstream mixins that the only concrete +//! controller (`PyPySandboxedProc`) inherits from: `VirtualizedSandboxedProc` +//! (a virtual filesystem + virtual env over `vfs.py`) and +//! `SimpleIOSandboxedProc` (stdin/stdout/stderr pass-through + real time). The +//! `do_ll_os__*` / `do_ll_time__*` handlers are dispatched by the function-name +//! string the sandboxed client marshals. + +use std::collections::HashMap; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::net::TcpStream; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use crate::protocol::{SandboxError, SandboxResult, code_for_error}; +use crate::rmarshal::{ + IntFlavor, Loader, MarshalValue, ReadNeedMore, dump_int, dump_longlong_result, dump_statresult, + dump_value, load_string, load_value, +}; +use crate::vfs::{self, FsNode, GID, ReadSeek, StatResult, UID}; + +// sandlib.py:401 `virtual_fd_range = range(3, 50)`. +const FD_RANGE_START: i32 = 3; +const FD_RANGE_END: i32 = 50; + +// don't read more than 256KB from a virtual file at once (sandlib.py:506). +const MAX_READ: usize = 256 * 1024; + +/// The static shape of a successful reply, mirroring `sandlib.write_message`'s +/// `resulttype` parameter (`sandlib.py:37-66`). +pub enum Reply { + /// Marshalled with the normal `_marshal` codec. + Value(MarshalValue), + /// `RESULTTYPE_STATRESULT` — hand-packed `os.stat_result`. + Stat(StatResult), + /// `RESULTTYPE_LONGLONG` — forced 64-bit int (`ll_os_lseek`). + LongLong(i64), +} + +/// The controller's console streams (the SimpleIO side). For `pyre interact` +/// these are the real process stdin/stdout/stderr; tests substitute buffers. +pub struct Console<'a> { + pub input: &'a mut dyn Read, + pub output: &'a mut dyn Write, + pub error: &'a mut dyn Write, + pub input_isatty: bool, +} + +struct OpenFd { + handle: Box, + node: FsNode, +} + +/// Shared timeout state between the controller's watchdog and the request +/// handlers, restoring sandlib.py's idle/poll behaviour across pyre's +/// policy/controller split. Defaults to "not idle, not cancelled" so a policy +/// run without a watchdog (no `--timeout`) behaves unchanged. +#[derive(Clone, Default)] +pub struct TimeoutControl { + /// True while the controller is blocked on interactive console input. The + /// watchdog keeps the activity clock fresh while it is set, so time spent + /// waiting at the prompt is not charged against `--timeout` (sandlib.py + /// enter_idle/leave_idle). + pub idle: Arc, + /// Set when the watchdog SIGKILLs the child; a long sleep being serviced + /// polls this between chunks and returns early instead of parking the + /// controller for the full requested duration (sandlib.py + /// do_ll_time__ll_time_sleep's self.poll()). + pub cancelled: Arc, +} + +/// The virtualized sandbox policy. Owns the virtual filesystem, the virtual +/// environment, and the open-fd table. +pub struct SandboxPolicy { + pub virtual_root: FsNode, + pub virtual_cwd: String, + pub virtual_env: Vec<(Vec, Vec)>, + pub virtual_console_isatty: bool, + open_fds: HashMap, + /// Opt-in `tcp://host:port` mediation (`VirtualizedSocketProc`, sandlib.py:546). + /// Off by default so the standard policy stays network-closed; the controller + /// flips it on with `set_allow_net` when `--allow-net` is passed. + allow_net: bool, + /// Fds returned by a `tcp://` open, sharing the `open_fds` fd space + /// (`VirtualizedSocketProc.sockets`, sandlib.py:552). read/write route these + /// to the connected stream instead of a virtual file. + sockets: HashMap, + /// Append-mode log of the guest's stdin (`inputlogfile`, sandlib.py:294), + /// enabled by `--log FILE`. Each fd-0 read appends the bytes handed to the + /// child. `None` unless a log file was opened. + input_log: Option, + clock_start: Option, + timeout_control: TimeoutControl, +} + +impl SandboxPolicy { + pub fn new( + virtual_root: FsNode, + virtual_cwd: impl Into, + virtual_env: Vec<(Vec, Vec)>, + virtual_console_isatty: bool, + ) -> Self { + SandboxPolicy { + virtual_root, + virtual_cwd: virtual_cwd.into(), + virtual_env, + virtual_console_isatty, + open_fds: HashMap::new(), + allow_net: false, + sockets: HashMap::new(), + input_log: None, + clock_start: None, + timeout_control: TimeoutControl::default(), + } + } + + /// Share the controller's timeout state so `do_read`/`do_sleep` can honour + /// the idle and cancellation signals. Called once before the request loop. + pub fn set_timeout_control(&mut self, control: TimeoutControl) { + self.timeout_control = control; + } + + /// Enable `tcp://host:port` mediation (`VirtualizedSocketProc`). Off by + /// default; the controller calls this when the operator opts in with + /// `--allow-net`. + pub fn set_allow_net(&mut self, allow: bool) { + self.allow_net = allow; + } + + /// Log the guest's stdin to `file` (`setlogfile`, sandlib.py:334). The + /// controller opens the file in append mode before the request loop. + pub fn set_input_log(&mut self, file: std::fs::File) { + self.input_log = Some(file); + } + + // ── path resolution (sandlib.py:417-437) ───────────────────────────────── + + // sandlib.py:417 `translate_path`. + fn translate_path(&self, vpath: &str) -> SandboxResult<(FsNode, String)> { + let joined = posix_join(&self.virtual_cwd, vpath); + let norm = posix_normpath(&joined); + let components: Vec<&str> = norm.split('/').collect(); + let (last, dirs) = components + .split_last() + .expect("split always yields >= 1 component"); + let mut dirnode = self.virtual_root.clone(); + for component in dirs { + if !component.is_empty() { + dirnode = dirnode.join(component).map_err(vfs_err)?; + if !vfs::is_dir(dirnode.kind()) { + return Err(SandboxError::Os(libc::ENOTDIR)); + } + } + } + Ok((dirnode, (*last).to_owned())) + } + + // sandlib.py:429 `get_node`. + fn get_node(&self, vpath: &str) -> SandboxResult { + let (dirnode, name) = self.translate_path(vpath)?; + if name.is_empty() { + Ok(dirnode) + } else { + dirnode.join(&name).map_err(vfs_err) + } + } + + // sandlib.py:458 `allocate_fd`. Files and `tcp://` sockets share one fd + // space, so both tables are consulted for a free slot. + fn allocate_fd(&mut self, handle: Box, node: FsNode) -> SandboxResult { + let fd = self.next_free_fd()?; + self.open_fds.insert(fd, OpenFd { handle, node }); + Ok(fd) + } + + // Socket variant of `allocate_fd` (`VirtualizedSocketProc`, sandlib.py:562). + fn allocate_socket_fd(&mut self, stream: TcpStream) -> SandboxResult { + let fd = self.next_free_fd()?; + self.sockets.insert(fd, stream); + Ok(fd) + } + + fn next_free_fd(&self) -> SandboxResult { + for fd in FD_RANGE_START..FD_RANGE_END { + if !self.open_fds.contains_key(&fd) && !self.sockets.contains_key(&fd) { + return Ok(fd); + } + } + Err(SandboxError::Os(libc::EMFILE)) + } + + // ── dispatch (sandlib.py:276-284) ──────────────────────────────────────── + + /// Dispatch a marshalled request to the matching `do_*` handler. Rejects any + /// fnname containing `"__"` first (`sandlib.py:277`), so only the curated + /// `ll_os.*` / `ll_time.*` names below are reachable. + pub fn handle_message( + &mut self, + fnname: &str, + args: &MarshalValue, + console: &mut Console, + ) -> SandboxResult { + if fnname.contains("__") { + return Err(SandboxError::Value); + } + let args = match args { + MarshalValue::Tuple(items) => items.as_slice(), + _ => return Err(SandboxError::Value), + }; + match fnname { + "ll_os.ll_os_open" => self.do_open(args), + "ll_os.ll_os_close" => self.do_close(args), + "ll_os.ll_os_read" => self.do_read(args, console), + "ll_os.ll_os_write" => self.do_write(args, console), + "ll_os.ll_os_stat" | "ll_os.ll_os_lstat" => self.do_stat(args), + "ll_os.ll_os_fstat" => self.do_fstat(args), + "ll_os.ll_os_lseek" => self.do_lseek(args), + "ll_os.ll_os_access" => self.do_access(args), + "ll_os.ll_os_isatty" => self.do_isatty(args), + "ll_os.ll_os_getcwd" => self.do_getcwd(), + "ll_os.ll_os_strerror" => self.do_strerror(args), + "ll_os.ll_os_listdir" => self.do_listdir(args), + "ll_os.ll_os_getenv" => self.do_getenv(args), + "ll_os.ll_os_envitems" => self.do_envitems(), + "ll_os.ll_os_unlink" | "ll_os.ll_os_mkdir" => Err(SandboxError::Os(libc::EPERM)), + "ll_os.ll_os_urandom" => self.do_urandom(args), + "ll_os.ll_os_getuid" | "ll_os.ll_os_geteuid" => { + Ok(Reply::Value(MarshalValue::Int(UID as i64))) + } + "ll_os.ll_os_getgid" | "ll_os.ll_os_getegid" => { + Ok(Reply::Value(MarshalValue::Int(GID as i64))) + } + "ll_time.ll_time_time" => self.do_time(), + "ll_time.ll_time_clock" => self.do_clock(), + "ll_time.ll_time_sleep" => self.do_sleep(args), + _ => Err(SandboxError::Runtime), + } + } + + // ── VirtualizedSandboxedProc handlers ──────────────────────────────────── + + // sandlib.py:485 `do_ll_os__ll_os_open`, with the `VirtualizedSocketProc` + // `tcp://` override (sandlib.py:554) folded in when `allow_net` is set. + fn do_open(&mut self, args: &[MarshalValue]) -> SandboxResult { + let vpath = arg_path(args, 0)?; + // sandlib.py:555: sockets are checked before the read-only flag gate, + // since a connected stream is inherently read-write. + if self.allow_net { + if let Some(target) = vpath.strip_prefix("tcp://") { + return self.do_open_socket(target); + } + } + let flags = arg_int(args, 1)? as i32; + let node = self.get_node(&vpath)?; + if flags & libc::O_ACCMODE != libc::O_RDONLY { + return Err(SandboxError::Os(libc::EPERM)); // "write access denied" + } + let handle = node.open().map_err(vfs_err)?; + let fd = self.allocate_fd(handle, node)?; + Ok(Reply::Value(MarshalValue::Int(fd as i64))) + } + + // sandlib.py:558-564: `host, port = name[6:].split(":")`, connect a real + // AF_INET/SOCK_STREAM socket on the trusted side, and hand the child an fd. + fn do_open_socket(&mut self, target: &str) -> SandboxResult { + let (host, port) = target + .split_once(':') + .ok_or(SandboxError::Os(libc::EINVAL))?; + let port: u16 = port.parse().map_err(|_| SandboxError::Os(libc::EINVAL))?; + let stream = TcpStream::connect((host, port)) + .map_err(|e| SandboxError::Os(e.raw_os_error().unwrap_or(libc::ECONNREFUSED)))?; + let fd = self.allocate_socket_fd(stream)?; + Ok(Reply::Value(MarshalValue::Int(fd as i64))) + } + + // sandlib.py:493 `do_ll_os__ll_os_close`. + fn do_close(&mut self, args: &[MarshalValue]) -> SandboxResult { + let fd = arg_int(args, 0)? as i32; + // A `tcp://` fd closes by dropping the stream (sandlib.py:495-496). + if self.open_fds.remove(&fd).is_none() && self.sockets.remove(&fd).is_none() { + return Err(SandboxError::Os(libc::EBADF)); + } + Ok(Reply::Value(MarshalValue::None)) + } + + // sandlib.py:498 `do_ll_os__ll_os_read` (+ SimpleIO fallback for fd 0). + fn do_read(&mut self, args: &[MarshalValue], console: &mut Console) -> SandboxResult { + let fd = arg_int(args, 0)? as i32; + let size = arg_int(args, 1)?; + // sandlib.py:566-567: a `tcp://` fd recv's one chunk from the stream. + if let Some(stream) = self.sockets.get_mut(&fd) { + if size < 0 { + return Err(SandboxError::Os(libc::EINVAL)); + } + // Cap like the virtual-file branch so the child cannot force an + // unbounded controller-side buffer allocation. + let want = (size as usize).min(MAX_READ); + let mut buf = vec![0u8; want]; + let got = stream + .read(&mut buf) + .map_err(|_| SandboxError::Os(libc::EIO))?; + buf.truncate(got); + return Ok(Reply::Value(MarshalValue::Str(buf))); + } + if let Some(entry) = self.open_fds.get_mut(&fd) { + if size < 0 { + return Err(SandboxError::Os(libc::EINVAL)); + } + let want = (size as usize).min(MAX_READ); + let data = + read_upto(&mut entry.handle, want).map_err(|_| SandboxError::Os(libc::EIO))?; + Ok(Reply::Value(MarshalValue::Str(data))) + } else if fd == 0 { + // SimpleIOSandboxedProc.do_ll_os__ll_os_read (sandlib.py:337). + if size < 0 { + return Err(SandboxError::Os(libc::EINVAL)); + } + // Cap the request like the virtual-file branch above so an + // untrusted child cannot pin a 4 GiB read buffer on the controller. + let want = (size as usize).min(MAX_READ); + let data = if self.virtual_console_isatty || console.input_isatty { + // Waiting at the interactive prompt is idle time: flag it so the + // watchdog keeps the activity clock fresh and does not charge the + // wait against --timeout (sandlib.py:348 enter_idle/leave_idle). + self.timeout_control.idle.store(true, Ordering::Relaxed); + let r = read_line(console.input, want); + self.timeout_control.idle.store(false, Ordering::Relaxed); + r + } else { + read_upto(console.input, want) + } + .map_err(|_| SandboxError::Io)?; + // sandlib.py:355-356: mirror the bytes handed to the child into the + // input log when `--log` opened one. + if let Some(log) = self.input_log.as_mut() { + let _ = log.write_all(&data); + } + Ok(Reply::Value(MarshalValue::Str(data))) + } else { + // sandlib.py:358 raises an errno-less OSError("trying to read from + // fd ..."); write_exception serializes its None errno as EPERM. + Err(SandboxError::Os(0)) + } + } + + // sandlib.py:360 `do_ll_os__ll_os_write` (SimpleIO: fd 1/2 only). + fn do_write(&mut self, args: &[MarshalValue], console: &mut Console) -> SandboxResult { + let fd = arg_int(args, 0)? as i32; + let data = arg_bytes(args, 1)?; + // sandlib.py:572-574: a `tcp://` fd send's the payload down the stream. + if let Some(stream) = self.sockets.get_mut(&fd) { + let sent = stream.write(&data).map_err(|_| SandboxError::Io)?; + return Ok(Reply::Value(MarshalValue::Int(sent as i64))); + } + let sink: &mut dyn Write = match fd { + 1 => console.output, + 2 => console.error, + // sandlib.py:367 raises an errno-less OSError("trying to write to + // fd ..."); write_exception serializes its None errno as EPERM. + _ => return Err(SandboxError::Os(0)), + }; + sink.write_all(&data).map_err(|_| SandboxError::Io)?; + sink.flush().map_err(|_| SandboxError::Io)?; + Ok(Reply::Value(MarshalValue::Int(data.len() as i64))) + } + + // Mediated host entropy. pyre's os.urandom / _random seed use getrandom + // rather than reading /dev/urandom through ll_os, so the trusted controller + // serves the bytes here instead of the untrusted child touching host entropy. + fn do_urandom(&mut self, args: &[MarshalValue]) -> SandboxResult { + let size = arg_int(args, 0)?; + if size < 0 { + return Err(SandboxError::Os(libc::EINVAL)); + } + // Bound the reply like reads so the child cannot demand an unbounded + // allocation; os.urandom needs its exact length, so refuse an over-large + // request rather than truncating it. + if size as usize > MAX_READ { + return Err(SandboxError::Os(libc::EINVAL)); + } + let mut buf = vec![0u8; size as usize]; + std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .map_err(|_| SandboxError::Io)?; + Ok(Reply::Value(MarshalValue::Str(buf))) + } + + // sandlib.py:439 `do_ll_os__ll_os_stat` (and lstat alias). + fn do_stat(&mut self, args: &[MarshalValue]) -> SandboxResult { + let vpath = arg_path(args, 0)?; + let node = self.get_node(&vpath)?; + Ok(Reply::Stat(node.stat().map_err(vfs_err)?)) + } + + // sandlib.py:509 `do_ll_os__ll_os_fstat`. + fn do_fstat(&mut self, args: &[MarshalValue]) -> SandboxResult { + let fd = arg_int(args, 0)? as i32; + let entry = self + .open_fds + .get(&fd) + .ok_or(SandboxError::Os(libc::EBADF))?; + Ok(Reply::Stat(entry.node.stat().map_err(vfs_err)?)) + } + + // sandlib.py:514 `do_ll_os__ll_os_lseek`. + fn do_lseek(&mut self, args: &[MarshalValue]) -> SandboxResult { + let fd = arg_int(args, 0)? as i32; + let pos = arg_int(args, 1)?; + let how = arg_int(args, 2)? as i32; + let entry = self + .open_fds + .get_mut(&fd) + .ok_or(SandboxError::Os(libc::EBADF))?; + let whence = match how { + // A negative absolute offset is invalid; without this guard `pos as + // u64` wraps to a huge position the cursor would accept, so Python + // sees success instead of OSError(EINVAL). + libc::SEEK_SET if pos < 0 => return Err(SandboxError::Os(libc::EINVAL)), + libc::SEEK_SET => SeekFrom::Start(pos as u64), + libc::SEEK_CUR => SeekFrom::Current(pos), + libc::SEEK_END => SeekFrom::End(pos), + _ => return Err(SandboxError::Os(libc::EINVAL)), + }; + let newpos = entry + .handle + .seek(whence) + .map_err(|_| SandboxError::Os(libc::EINVAL))?; + Ok(Reply::LongLong(newpos as i64)) + } + + // sandlib.py:446 `do_ll_os__ll_os_access`. + fn do_access(&mut self, args: &[MarshalValue]) -> SandboxResult { + let vpath = arg_path(args, 0)?; + let mode = arg_int(args, 1)? as u32; + match self.get_node(&vpath) { + Ok(node) => Ok(Reply::Value(MarshalValue::Bool( + node.access(mode).map_err(vfs_err)?, + ))), + Err(SandboxError::Os(e)) if e == libc::ENOENT => { + Ok(Reply::Value(MarshalValue::Bool(false))) + } + Err(e) => Err(e), + } + } + + // sandlib.py:455 `do_ll_os__ll_os_isatty`. + fn do_isatty(&mut self, args: &[MarshalValue]) -> SandboxResult { + let fd = arg_int(args, 0)?; + let isatty = self.virtual_console_isatty && (fd == 0 || fd == 1 || fd == 2); + Ok(Reply::Value(MarshalValue::Bool(isatty))) + } + + // sandlib.py:520 `do_ll_os__ll_os_getcwd`. + fn do_getcwd(&mut self) -> SandboxResult { + Ok(Reply::Value(MarshalValue::Str( + self.virtual_cwd.as_bytes().to_vec(), + ))) + } + + // sandlib.py:523 `do_ll_os__ll_os_strerror`. + fn do_strerror(&mut self, args: &[MarshalValue]) -> SandboxResult { + let errnum = arg_int(args, 0)? as i32; + Ok(Reply::Value(MarshalValue::Str(strerror(errnum)))) + } + + // sandlib.py:527 `do_ll_os__ll_os_listdir`. + fn do_listdir(&mut self, args: &[MarshalValue]) -> SandboxResult { + let vpath = arg_path(args, 0)?; + let node = self.get_node(&vpath)?; + let names = node.keys().map_err(vfs_err)?; + Ok(Reply::Value(MarshalValue::List( + names + .into_iter() + .map(|n| MarshalValue::Str(n.into_bytes())) + .collect(), + ))) + } + + // sandlib.py:414 `do_ll_os__ll_os_getenv`. + fn do_getenv(&mut self, args: &[MarshalValue]) -> SandboxResult { + let name = arg_bytes(args, 0)?; + let value = self + .virtual_env + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| MarshalValue::Str(v.clone())) + .unwrap_or(MarshalValue::None); + Ok(Reply::Value(value)) + } + + // sandlib.py:411 `do_ll_os__ll_os_envitems`. + fn do_envitems(&mut self) -> SandboxResult { + let items = self + .virtual_env + .iter() + .map(|(k, v)| { + MarshalValue::Tuple(vec![ + MarshalValue::Str(k.clone()), + MarshalValue::Str(v.clone()), + ]) + }) + .collect(); + Ok(Reply::Value(MarshalValue::List(items))) + } + + // ── SimpleIOSandboxedProc time handlers ────────────────────────────────── + + // sandlib.py:380 `do_ll_time__ll_time_time`. + fn do_time(&mut self) -> SandboxResult { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0); + Ok(Reply::Value(MarshalValue::Float(now))) + } + + // sandlib.py:383 `do_ll_time__ll_time_clock`. + fn do_clock(&mut self) -> SandboxResult { + let start = *self.clock_start.get_or_insert_with(Instant::now); + Ok(Reply::Value(MarshalValue::Float( + start.elapsed().as_secs_f64(), + ))) + } + + // sandlib.py:370 `do_ll_time__ll_time_sleep`. + fn do_sleep(&mut self, args: &[MarshalValue]) -> SandboxResult { + let mut seconds = arg_float(args, 0)?; + // Reject a non-finite request (NaN/±inf) rather than looping forever or + // feeding `Duration::from_secs_f64` a value it would panic on. + if !seconds.is_finite() { + return Err(SandboxError::Os(libc::EINVAL)); + } + // Sleep in 5-second chunks and poll the cancellation flag between them + // (sandlib.py:373's self.poll()), so a child SIGKILLed mid-sleep does + // not keep the controller parked for the full requested duration. + while seconds > 5.0 { + std::thread::sleep(std::time::Duration::from_secs(5)); + seconds -= 5.0; + if self.timeout_control.cancelled.load(Ordering::Relaxed) { + return Ok(Reply::Value(MarshalValue::None)); + } + } + if seconds > 0.0 { + std::thread::sleep(std::time::Duration::from_secs_f64(seconds)); + } + Ok(Reply::Value(MarshalValue::None)) + } + + // ── the request/reply loop (sandlib.py:222-268) ────────────────────────── + + /// Read marshalled `(fnname, args)` requests from `child_stdout`, dispatch + /// them, and marshal replies to `child_stdin`, until the child closes its + /// stdout (clean EOF between messages). + pub fn handle_until_return( + &mut self, + child_stdout: RIN, + child_stdin: &mut WOUT, + console: &mut Console, + ) -> io::Result<()> + where + RIN: Read, + WOUT: Write, + { + self.handle_until_return_ticked(child_stdout, child_stdin, console, &mut || {}) + } + + /// Like [`handle_until_return`](Self::handle_until_return), but `on_message` + /// is invoked once before the loop and after every serviced message. The + /// controller uses it to reset the per-message timeout watchdog — the + /// `signal.alarm` reset RPython wraps around each `read_message` + /// (`sandlib.py:_signal_alarm`). + pub fn handle_until_return_ticked( + &mut self, + child_stdout: RIN, + child_stdin: &mut WOUT, + console: &mut Console, + on_message: &mut dyn FnMut(), + ) -> io::Result<()> + where + RIN: Read, + WOUT: Write, + { + let mut loader = Loader::new(Vec::new(), ReadNeedMore::new(child_stdout)); + on_message(); + loop { + // A clean EOF *at a message boundary* is normal termination; a read + // error mid-stream (the child died, or sent a truncated frame) is a + // real failure and must surface — otherwise a crashed child is + // reported as a successful run. + match loader.at_message_boundary_eof() { + Ok(true) => break, + Ok(false) => {} + Err(e) => return Err(protocol_io_error(e)), + } + let fnname = load_string(&mut loader).map_err(protocol_io_error)?; + let args = load_value(&mut loader).map_err(protocol_io_error)?; + let fnname = String::from_utf8_lossy(&fnname).into_owned(); + let mut out = Vec::new(); + match self.handle_message(&fnname, &args, console) { + Ok(reply) => { + dump_int(&mut out, 0, IntFlavor::Marshal); // success code + encode_reply(&mut out, &reply); + } + Err(exc) => encode_exception(&mut out, &exc), + } + child_stdin.write_all(&out)?; + child_stdin.flush()?; + // Reclaim the bytes of the message just serviced so the loader's + // buffer does not grow without bound over a long request stream. + loader.drain_consumed(); + on_message(); + } + Ok(()) + } +} + +/// Map a marshal-decode failure on the request stream to an `io::Error` so the +/// controller loop propagates a crashed/rogue child instead of treating the +/// malformed input as a clean exit. A truncated frame surfaces as `UnexpectedEof`; +/// anything else as `InvalidData`. +fn protocol_io_error(e: SandboxError) -> io::Error { + match e { + SandboxError::Protocol(msg) if msg.contains("unexpected EOF") => { + io::Error::new(io::ErrorKind::UnexpectedEof, msg) + } + other => io::Error::new(io::ErrorKind::InvalidData, other.to_string()), + } +} + +// sandlib.py:258-259 reply framing + sandlib.py:37-66 resulttype encoding. +fn encode_reply(out: &mut Vec, reply: &Reply) { + match reply { + Reply::Value(v) => dump_value(out, v, IntFlavor::Marshal), + Reply::Stat(st) => dump_statresult(out, st), + Reply::LongLong(v) => dump_longlong_result(out, *v), + } +} + +// sandlib.py:81-94 `write_exception`. +fn encode_exception(out: &mut Vec, exc: &SandboxError) { + dump_int(out, code_for_error(exc), IntFlavor::Marshal); + if let SandboxError::Os(errno) = exc { + let errno = if *errno == 0 { libc::EPERM } else { *errno }; + dump_int(out, errno as i64, IntFlavor::Marshal); + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn vfs_err(e: vfs::VfsError) -> SandboxError { + SandboxError::Os(e.errno) +} + +fn arg_int(args: &[MarshalValue], i: usize) -> SandboxResult { + match args.get(i) { + Some(MarshalValue::Int(v)) => Ok(*v), + Some(MarshalValue::Bool(b)) => Ok(*b as i64), + _ => Err(SandboxError::Value), + } +} + +fn arg_float(args: &[MarshalValue], i: usize) -> SandboxResult { + match args.get(i) { + Some(MarshalValue::Float(v)) => Ok(*v), + Some(MarshalValue::Int(v)) => Ok(*v as f64), + _ => Err(SandboxError::Value), + } +} + +fn arg_bytes(args: &[MarshalValue], i: usize) -> SandboxResult> { + match args.get(i) { + Some(MarshalValue::Str(s)) => Ok(s.clone()), + _ => Err(SandboxError::Value), + } +} + +fn arg_path(args: &[MarshalValue], i: usize) -> SandboxResult { + // The VFS is keyed by `String` (vfs.rs), so a path is decoded lossily here + // rather than carried as raw bytes through posixpath as PyPy's sandlib.py + // does. A non-UTF-8 child path gets U+FFFD substituted, which cannot + // synthesize `/` or `..` and so only fails to resolve (ENOENT) — strictly + // more restrictive than a host lookup, never an escape. + let bytes = arg_bytes(args, i)?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +// Read up to `n` bytes, looping over short reads until `n` or EOF. +fn read_upto(reader: &mut dyn Read, n: usize) -> io::Result> { + let mut out = Vec::new(); + let mut chunk = [0u8; 8192]; + while out.len() < n { + let want = (n - out.len()).min(chunk.len()); + let got = reader.read(&mut chunk[..want])?; + if got == 0 { + break; + } + out.extend_from_slice(&chunk[..got]); + } + Ok(out) +} + +// Read at most `n` bytes, stopping early after a newline (tty line behaviour, +// sandlib.py:344-352). +fn read_line(reader: &mut dyn Read, n: usize) -> io::Result> { + let mut out = Vec::new(); + let mut byte = [0u8; 1]; + while out.len() < n { + let got = reader.read(&mut byte)?; + if got == 0 { + break; + } + out.push(byte[0]); + if byte[0] == b'\n' { + break; + } + } + Ok(out) +} + +fn strerror(errno: i32) -> Vec { + unsafe { + let ptr = libc::strerror(errno); + if ptr.is_null() { + return format!("Unknown error {errno}").into_bytes(); + } + std::ffi::CStr::from_ptr(ptr).to_bytes().to_vec() + } +} + +// posixpath.join(a, b) — `b` absolute wins (posixpath.py). +fn posix_join(a: &str, b: &str) -> String { + if b.starts_with('/') { + b.to_owned() + } else if a.is_empty() || a.ends_with('/') { + format!("{a}{b}") + } else { + format!("{a}/{b}") + } +} + +// posixpath.normpath — collapse '.', '..', and redundant separators. +fn posix_normpath(path: &str) -> String { + if path.is_empty() { + return ".".to_owned(); + } + let absolute = path.starts_with('/'); + // posixpath preserves exactly two leading slashes, but the sandbox never + // relies on that quirk; a single leading slash is sufficient here. + let mut out: Vec<&str> = Vec::new(); + for comp in path.split('/') { + match comp { + "" | "." => {} + ".." => { + if let Some(&last) = out.last() { + if last != ".." { + out.pop(); + continue; + } + } + if !absolute { + out.push(".."); + } + } + other => out.push(other), + } + } + let joined = out.join("/"); + match (absolute, joined.is_empty()) { + (true, _) => format!("/{joined}"), + (false, true) => ".".to_owned(), + (false, false) => joined, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vfs::{Dir, File, RealFile}; + use indexmap::IndexMap; + use std::rc::Rc; + + fn dir(entries: Vec<(&str, FsNode)>) -> FsNode { + let mut map: IndexMap = IndexMap::new(); + for (k, v) in entries { + map.insert(k.to_owned(), v); + } + Rc::new(Dir::new(map)) + } + + // VFS used by the sandlib integration tests, mirroring + // test_sandlib.py SandboxedProcWithFiles.build_virtual_root. + fn files_root() -> FsNode { + dir(vec![ + ("hi.txt", Rc::new(File::new("Hello, world!\n"))), + ("this.bin", Rc::new(RealFile::new(file!(), 0))), + ]) + } + + fn policy() -> SandboxPolicy { + SandboxPolicy::new(files_root(), "/", vec![], false) + } + + fn no_console() -> (Vec, Vec, Vec) { + (Vec::new(), Vec::new(), Vec::new()) + } + + fn tup(items: Vec) -> MarshalValue { + MarshalValue::Tuple(items) + } + + fn call(p: &mut SandboxPolicy, fnname: &str, args: Vec) -> SandboxResult { + let (i, mut o, mut e) = no_console(); + let mut console = Console { + input: &mut i.as_slice(), + output: &mut o, + error: &mut e, + input_isatty: false, + }; + p.handle_message(fnname, &tup(args), &mut console) + } + + fn expect_int(r: SandboxResult) -> i64 { + match r { + Ok(Reply::Value(MarshalValue::Int(v))) => v, + other => panic!("expected int reply, got {:?}", reply_dbg(&other)), + } + } + + fn reply_dbg(r: &SandboxResult) -> String { + match r { + Ok(Reply::Value(v)) => format!("Value({v:?})"), + Ok(Reply::Stat(_)) => "Stat".into(), + Ok(Reply::LongLong(v)) => format!("LongLong({v})"), + Err(e) => format!("Err({e})"), + } + } + + #[test] + fn unsafe_fnname_rejected() { + // a fnname with '__' must be refused before dispatch (sandlib.py:277). + let mut p = policy(); + assert!(matches!( + call(&mut p, "ll_os.ll_os__secret", vec![]), + Err(SandboxError::Value) + )); + } + + #[test] + fn unhandled_fnname_is_runtimeerror() { + // dup/dup2/ftruncate are test-harness helpers, not part of the + // production controller; an unknown fnname maps to RuntimeError + // ("no handler", sandlib.py:284). + let mut p = policy(); + for name in [ + "ll_os.ll_os_dup", + "ll_os.ll_os_dup2", + "ll_os.ll_os_ftruncate", + ] { + assert!(matches!( + call(&mut p, name, vec![MarshalValue::Int(3)]), + Err(SandboxError::Runtime) + )); + } + } + + #[test] + fn getuid_family_is_1000() { + // test_sandlib.py test_getuid + let mut p = policy(); + for name in [ + "ll_os.ll_os_getuid", + "ll_os.ll_os_geteuid", + "ll_os.ll_os_getgid", + "ll_os.ll_os_getegid", + ] { + assert_eq!(expect_int(call(&mut p, name, vec![])), 1000); + } + } + + #[test] + fn open_read_close_virtual_file() { + let mut p = policy(); + let fd = expect_int(call( + &mut p, + "ll_os.ll_os_open", + vec![ + MarshalValue::Str(b"/hi.txt".to_vec()), + MarshalValue::Int(libc::O_RDONLY as i64), + MarshalValue::Int(0o777), + ], + )); + assert_eq!(fd, 3); // first fd in range 3..50 + let data = match call( + &mut p, + "ll_os.ll_os_read", + vec![MarshalValue::Int(fd), MarshalValue::Int(100)], + ) { + Ok(Reply::Value(MarshalValue::Str(d))) => d, + other => panic!("read: {}", reply_dbg(&other)), + }; + assert_eq!(data, b"Hello, world!\n"); + assert!(matches!( + call(&mut p, "ll_os.ll_os_close", vec![MarshalValue::Int(fd)]), + Ok(Reply::Value(MarshalValue::None)) + )); + // closing again -> EBADF + assert!(matches!( + call(&mut p, "ll_os.ll_os_close", vec![MarshalValue::Int(fd)]), + Err(SandboxError::Os(_)) + )); + } + + #[test] + fn open_write_mode_denied() { + // an obvious attack: opening for write must fail with EPERM. + let mut p = policy(); + let r = call( + &mut p, + "ll_os.ll_os_open", + vec![ + MarshalValue::Str(b"/hi.txt".to_vec()), + MarshalValue::Int((libc::O_WRONLY | libc::O_CREAT) as i64), + MarshalValue::Int(0o666), + ], + ); + assert!(matches!(r, Err(SandboxError::Os(e)) if e == libc::EPERM)); + } + + #[test] + fn unlink_and_mkdir_denied() { + let mut p = policy(); + for name in ["ll_os.ll_os_unlink", "ll_os.ll_os_mkdir"] { + assert!(matches!( + call(&mut p, name, vec![MarshalValue::Str(b"/x".to_vec())]), + Err(SandboxError::Os(e)) if e == libc::EPERM + )); + } + } + + #[test] + fn fstat_matches_stat() { + // test_sandlib.py test_fstat + let mut p = policy(); + let stat_st = match call( + &mut p, + "ll_os.ll_os_stat", + vec![MarshalValue::Str(b"/hi.txt".to_vec())], + ) { + Ok(Reply::Stat(s)) => s, + other => panic!("stat: {}", reply_dbg(&other)), + }; + let fd = expect_int(call( + &mut p, + "ll_os.ll_os_open", + vec![ + MarshalValue::Str(b"/hi.txt".to_vec()), + MarshalValue::Int(libc::O_RDONLY as i64), + MarshalValue::Int(0o777), + ], + )); + let fstat_st = match call(&mut p, "ll_os.ll_os_fstat", vec![MarshalValue::Int(fd)]) { + Ok(Reply::Stat(s)) => s, + other => panic!("fstat: {}", reply_dbg(&other)), + }; + assert_eq!(stat_st, fstat_st); + assert_eq!(stat_st.st_size, 14); // "Hello, world!\n" + } + + #[test] + fn lseek_offsets() { + // test_sandlib.py test_lseek + let mut p = policy(); + let fd = expect_int(call( + &mut p, + "ll_os.ll_os_open", + vec![ + MarshalValue::Str(b"/hi.txt".to_vec()), + MarshalValue::Int(libc::O_RDONLY as i64), + MarshalValue::Int(0o777), + ], + )); + let lseek = |p: &mut SandboxPolicy, pos: i64, how: i32| -> i64 { + match call( + p, + "ll_os.ll_os_lseek", + vec![ + MarshalValue::Int(fd), + MarshalValue::Int(pos), + MarshalValue::Int(how as i64), + ], + ) { + Ok(Reply::LongLong(v)) => v, + other => panic!("lseek: {}", reply_dbg(&other)), + } + }; + assert_eq!(lseek(&mut p, 0, libc::SEEK_END), 14); + assert_eq!(lseek(&mut p, 0, libc::SEEK_SET), 0); + assert_eq!(lseek(&mut p, 7, libc::SEEK_CUR), 7); + } + + #[test] + fn too_many_opens_emfile() { + // test_sandlib.py test_too_many_opens — fd range is 3..50 (47 slots). + let mut p = policy(); + for _ in 0..(FD_RANGE_END - FD_RANGE_START) { + expect_int(call( + &mut p, + "ll_os.ll_os_open", + vec![ + MarshalValue::Str(b"/hi.txt".to_vec()), + MarshalValue::Int(libc::O_RDONLY as i64), + MarshalValue::Int(0o777), + ], + )); + } + let r = call( + &mut p, + "ll_os.ll_os_open", + vec![ + MarshalValue::Str(b"/hi.txt".to_vec()), + MarshalValue::Int(libc::O_RDONLY as i64), + MarshalValue::Int(0o777), + ], + ); + assert!(matches!(r, Err(SandboxError::Os(e)) if e == libc::EMFILE)); + } + + #[test] + fn stdout_stderr_write() { + let mut p = policy(); + let mut i: &[u8] = b""; + let mut o: Vec = Vec::new(); + let mut e: Vec = Vec::new(); + { + let mut console = Console { + input: &mut i, + output: &mut o, + error: &mut e, + input_isatty: false, + }; + let n = p + .handle_message( + "ll_os.ll_os_write", + &tup(vec![ + MarshalValue::Int(1), + MarshalValue::Str(b"hi\n".to_vec()), + ]), + &mut console, + ) + .unwrap(); + assert!(matches!(n, Reply::Value(MarshalValue::Int(3)))); + p.handle_message( + "ll_os.ll_os_write", + &tup(vec![ + MarshalValue::Int(2), + MarshalValue::Str(b"err".to_vec()), + ]), + &mut console, + ) + .unwrap(); + } + assert_eq!(o, b"hi\n"); + assert_eq!(e, b"err"); + } + + #[test] + fn access_missing_is_false() { + let mut p = policy(); + let r = call( + &mut p, + "ll_os.ll_os_access", + vec![MarshalValue::Str(b"/nope".to_vec()), MarshalValue::Int(4)], + ); + assert!(matches!(r, Ok(Reply::Value(MarshalValue::Bool(false))))); + } + + #[test] + fn handle_until_return_drives_a_request_stream() { + // Build a request stream (client -> controller) for getcwd, feed it + // through the loop, and decode the reply. + let mut p = SandboxPolicy::new(files_root(), "/tmp", vec![], false); + let mut request = Vec::new(); + crate::rmarshal::dump_string(&mut request, b"ll_os.ll_os_getcwd"); + crate::rmarshal::dump_tuple(&mut request, &[], IntFlavor::Rmarshal); + + let mut replies: Vec = Vec::new(); + let (i, mut o, mut e) = no_console(); + { + let mut console = Console { + input: &mut i.as_slice(), + output: &mut o, + error: &mut e, + input_isatty: false, + }; + p.handle_until_return(request.as_slice(), &mut replies, &mut console) + .unwrap(); + } + // reply = success int 0, then the cwd string. + let mut ld = Loader::from_bytes(replies); + assert_eq!(crate::rmarshal::load_int(&mut ld).unwrap(), 0); + assert_eq!(crate::rmarshal::load_string(&mut ld).unwrap(), b"/tmp"); + ld.check_finished().unwrap(); + } + + #[test] + fn tcp_open_denied_when_net_disabled() { + // Default policy is network-closed: `tcp://` is not special-cased and + // falls through to virtual-file resolution, which has no such node. + let mut p = policy(); + let r = call( + &mut p, + "ll_os.ll_os_open", + vec![ + MarshalValue::Str(b"tcp://127.0.0.1:9".to_vec()), + MarshalValue::Int(libc::O_RDONLY as i64), + MarshalValue::Int(0o777), + ], + ); + assert!( + matches!(r, Err(SandboxError::Os(_))), + "tcp:// must not open without --allow-net: {}", + reply_dbg(&r) + ); + } + + #[test] + fn tcp_open_read_write_roundtrip() { + // VirtualizedSocketProc (sandlib.py:546): with allow_net on, opening + // `tcp://host:port` connects a real socket and routes read/write to it. + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut sock, _) = listener.accept().expect("accept"); + let mut buf = [0u8; 4]; + sock.read_exact(&mut buf).expect("server read"); + assert_eq!(&buf, b"ping"); + sock.write_all(b"pong\n").expect("server write"); + }); + + let mut p = policy(); + p.set_allow_net(true); + let fd = expect_int(call( + &mut p, + "ll_os.ll_os_open", + vec![ + MarshalValue::Str(format!("tcp://127.0.0.1:{port}").into_bytes()), + MarshalValue::Int(libc::O_RDONLY as i64), + MarshalValue::Int(0o777), + ], + )); + assert!(fd >= 3, "socket fd in virtual range: {fd}"); + + let sent = expect_int(call( + &mut p, + "ll_os.ll_os_write", + vec![MarshalValue::Int(fd), MarshalValue::Str(b"ping".to_vec())], + )); + assert_eq!(sent, 4); + + let data = match call( + &mut p, + "ll_os.ll_os_read", + vec![MarshalValue::Int(fd), MarshalValue::Int(100)], + ) { + Ok(Reply::Value(MarshalValue::Str(d))) => d, + other => panic!("read: {}", reply_dbg(&other)), + }; + assert_eq!(data, b"pong\n"); + + assert!(matches!( + call(&mut p, "ll_os.ll_os_close", vec![MarshalValue::Int(fd)]), + Ok(Reply::Value(MarshalValue::None)) + )); + // closing again -> EBADF (socket fd is gone from both tables) + assert!(matches!( + call(&mut p, "ll_os.ll_os_close", vec![MarshalValue::Int(fd)]), + Err(SandboxError::Os(e)) if e == libc::EBADF + )); + server.join().expect("server thread"); + } + + #[test] + fn input_log_records_guest_stdin() { + // setlogfile/inputlogfile (sandlib.py:334, 355-356): an fd-0 read + // appends the bytes handed to the child into the log file. + let mut path = std::env::temp_dir(); + path.push(format!("pyre_sandbox_inputlog_{}.log", std::process::id())); + let _ = std::fs::remove_file(&path); + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .expect("open log"); + + let mut p = policy(); + p.set_input_log(file); + + let input = b"hello\nworld".to_vec(); + let (mut o, mut e) = (Vec::new(), Vec::new()); + { + let mut console = Console { + input: &mut input.as_slice(), + output: &mut o, + error: &mut e, + input_isatty: false, + }; + let r = p.handle_message( + "ll_os.ll_os_read", + &tup(vec![MarshalValue::Int(0), MarshalValue::Int(100)]), + &mut console, + ); + assert!(matches!(r, Ok(Reply::Value(MarshalValue::Str(_))))); + } + + let mut logged = Vec::new(); + std::fs::File::open(&path) + .unwrap() + .read_to_end(&mut logged) + .unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(logged, input); + } +} diff --git a/pyre/pyre-sandbox/src/seccomp.rs b/pyre/pyre-sandbox/src/seccomp.rs new file mode 100644 index 00000000000..51b0373a7ae --- /dev/null +++ b/pyre/pyre-sandbox/src/seccomp.rs @@ -0,0 +1,292 @@ +//! OS-level hardening: a seccomp-bpf syscall allowlist for the sandboxed child. +//! +//! The compile-out seam ([`host_seam`](../../pyre_interpreter/host_seam) in the +//! interpreter) routes every *intended* OS access through the marshalling pipe, +//! and the fails-closed `host_seam::sys` facade makes a stray direct `libc::` +//! syscall a compile error. This module is the backstop for everything those +//! source-level mechanisms cannot cover: the linked `host_env` crate, the Rust +//! std library and allocator, a future un-rerouted call site, or a syscall +//! reached by a memory-safety exploit. It is the analog of RPython's +//! `os_level_sandboxing`. +//! +//! [`install_runtime_filter`] installs a classic-BPF program that ALLOWS only a +//! curated set of host-neutral runtime syscalls (memory, signals, time, and I/O +//! on the already-open marshalling fds 0/1/2) and TRAPs anything else to a +//! SIGSYS handler that names the blocked syscall and exits — so +//! `open`/`openat`/`socket`/`connect`/`execve`/`fork`/`clone`/`ptrace` and the +//! rest of the host-affecting surface are simply unreachable. It is +//! installed in the child *after* interpreter startup (which legitimately opens +//! files, allocates, seeds hashing, …) and *before* the first byte of untrusted +//! code, so those startup syscalls run unfiltered while user code does not. +//! +//! Over-listing a benign syscall here cannot widen the escape surface (every +//! listed call is host-neutral); omitting one the runtime needs only +//! over-restricts and kills the child — i.e. it fails in the safe direction. +//! +//! Because pyre has no genc backend, this is the *only* whole-program guarantee +//! in the sandbox — the compile-time seam + clippy fence are selective, not a +//! translation-derived total proof. That makes the boundary Linux-bound: off +//! Linux (or with `PYRE_SANDBOX_NO_SECCOMP`) there is no kernel backstop and +//! containment falls back to seam + fence coverage alone. See the crate root's +//! "Structural constraint" note for the full guarantee model. +#![cfg(target_os = "linux")] + +use std::io; + +// Classic-BPF instruction encodings (`linux/bpf_common.h`; not in `libc`). +const BPF_LD: u16 = 0x00; +const BPF_W: u16 = 0x00; +const BPF_ABS: u16 = 0x20; +const BPF_JMP: u16 = 0x05; +const BPF_JEQ: u16 = 0x10; +const BPF_K: u16 = 0x00; +const BPF_RET: u16 = 0x06; + +const LD_ABS_W: u16 = BPF_LD | BPF_W | BPF_ABS; // load a 32-bit word at an absolute offset +const JEQ_K: u16 = BPF_JMP | BPF_JEQ | BPF_K; // jump-if-equal against an immediate +const RET_K: u16 = BPF_RET | BPF_K; // return an immediate action + +// Byte offsets into `struct seccomp_data` (the BPF input): `nr` then `arch`. +const SECCOMP_DATA_NR_OFFSET: u32 = 0; +const SECCOMP_DATA_ARCH_OFFSET: u32 = 4; + +// AUDIT_ARCH_* (`linux/audit.h`) = EM_ | __AUDIT_ARCH_64BIT | __AUDIT_ARCH_LE. +#[cfg(target_arch = "x86_64")] +const AUDIT_ARCH: u32 = 0xC000_003E; +#[cfg(target_arch = "aarch64")] +const AUDIT_ARCH: u32 = 0xC000_00B7; + +// The SIGSYS branch of `siginfo_t` (linux, LP64). `libc::siginfo_t` does not +// expose `si_syscall`, so the handler reinterprets the leading fields. +#[repr(C)] +struct SigSysSiginfo { + si_signo: libc::c_int, + si_errno: libc::c_int, + si_code: libc::c_int, + _pad0: libc::c_int, + si_call_addr: *mut libc::c_void, + si_syscall: libc::c_int, + si_arch: libc::c_uint, +} + +/// SIGSYS handler for `SECCOMP_RET_TRAP`: write the blocked syscall number to +/// stderr, then exit. Deny still means die, but the kill is no longer silent — +/// the number names exactly which call to add to [`allowed_syscalls`] (or which +/// reroute is missing). Async-signal-safe: only `write(2)`/`_exit(2)`, both +/// allowlisted, and no allocation. +extern "C" fn report_blocked_syscall( + _signo: libc::c_int, + info: *mut libc::siginfo_t, + _ctx: *mut libc::c_void, +) { + let nr = if info.is_null() { + -1 + } else { + unsafe { (*(info as *const SigSysSiginfo)).si_syscall } + }; + let prefix = b"pyre: sandbox seccomp blocked syscall "; + let mut buf = [0u8; 64]; + let mut len = prefix.len(); + buf[..len].copy_from_slice(prefix); + let mut n = nr as i64; + if n < 0 { + buf[len] = b'-'; + len += 1; + n = -n; + } + let mut digits = [0u8; 20]; + let mut d = 0; + loop { + digits[d] = b'0' + (n % 10) as u8; + d += 1; + n /= 10; + if n == 0 { + break; + } + } + while d > 0 { + d -= 1; + buf[len] = digits[d]; + len += 1; + } + buf[len] = b'\n'; + len += 1; + unsafe { + libc::write(2, buf.as_ptr() as *const libc::c_void, len); + libc::_exit(159); + } +} + +fn stmt(code: u16, k: u32) -> libc::sock_filter { + libc::sock_filter { + code, + jt: 0, + jf: 0, + k, + } +} + +/// `if nr == syscall { pc += 1 + jt } else { pc += 1 }` — `jt` skips forward to +/// the trailing ALLOW terminator. +fn jeq(syscall: u32, jt: u8) -> libc::sock_filter { + libc::sock_filter { + code: JEQ_K, + jt, + jf: 0, + k: syscall, + } +} + +/// Host-neutral syscalls the interpreter runtime, the system allocator and the +/// JIT legitimately issue while untrusted code runs. `libc::SYS_*` are the +/// numbers for THIS compile target; the arch guard in the filter refuses to run +/// it under any other syscall personality, so the numbers cannot be confused. +fn allowed_syscalls() -> Vec { + let mut nums: Vec = vec![ + // I/O on the already-open marshalling pipe (0/1) + stderr (2); seek/stat/ + // fcntl/positional I/O only ever touch those fds (real file access is + // marshalled), and dup just clones an already-open fd. + libc::SYS_read, + libc::SYS_write, + libc::SYS_readv, + libc::SYS_writev, + libc::SYS_pread64, + libc::SYS_pwrite64, + libc::SYS_close, + libc::SYS_lseek, + libc::SYS_fstat, + libc::SYS_fcntl, + libc::SYS_getcwd, + libc::SYS_dup, + libc::SYS_dup3, + libc::SYS_ppoll, + // Memory: system malloc (brk/mmap/madvise) + the JIT's executable maps. + libc::SYS_mmap, + libc::SYS_munmap, + libc::SYS_mremap, + libc::SYS_mprotect, + libc::SYS_madvise, + libc::SYS_brk, + libc::SYS_membarrier, + // Signals: panic/abort delivery and the runtime's signal scaffolding. + libc::SYS_rt_sigaction, + libc::SYS_rt_sigprocmask, + libc::SYS_rt_sigreturn, + libc::SYS_rt_sigtimedwait, + libc::SYS_sigaltstack, + // Synchronisation, scheduling, hashing entropy. + libc::SYS_futex, + libc::SYS_sched_yield, + libc::SYS_sched_getaffinity, + libc::SYS_getrandom, + libc::SYS_set_robust_list, + libc::SYS_set_tid_address, + libc::SYS_rseq, + // Thread creation: the JIT driver spawns one background loop-invalidation + // thread on first trace (majit `jitdriver.rs`), so `pthread_create` runs + // after this filter is installed. glibc issues `clone3` on new kernels and + // falls back to `clone`; allow both. A spawned thread inherits this same + // filter, so it is confined identically — thread creation stays + // host-neutral (its stack/sync syscalls are already listed above). + libc::SYS_clone, + libc::SYS_clone3, + // Time (mostly served by the vDSO, but allow the syscall fallbacks). + libc::SYS_clock_gettime, + libc::SYS_clock_getres, + libc::SYS_clock_nanosleep, + libc::SYS_nanosleep, + libc::SYS_gettimeofday, + // Process self-info (read-only) + own resource limits/usage + abort path + // + clean exit. tkill/tgkill only ever target this single-threaded child. + libc::SYS_getpid, + libc::SYS_gettid, + libc::SYS_getrusage, + libc::SYS_prlimit64, + libc::SYS_tkill, + libc::SYS_tgkill, + libc::SYS_exit, + libc::SYS_exit_group, + libc::SYS_restart_syscall, + ]; + // x86_64 sets the thread-pointer (TLS) base via arch_prctl; absent on the + // generic syscall ABI (aarch64), which uses a register convention instead. + #[cfg(target_arch = "x86_64")] + nums.push(libc::SYS_arch_prctl); + nums.iter().map(|&n| n as u32).collect() +} + +/// Install the syscall allowlist on the current (single) thread/process. After +/// it returns `Ok`, any syscall outside [`allowed_syscalls`] traps to the +/// SIGSYS handler ([`report_blocked_syscall`]), which writes the blocked +/// syscall number to stderr and exits — deny still means die, just diagnosably. +/// +/// Fails-closed: the caller MUST treat an `Err` as fatal and refuse to run +/// untrusted code, since a failed install means the backstop is absent. +pub fn install_runtime_filter() -> io::Result<()> { + // `gmtime_r` is permitted as a pure calendar call (re-exported by + // `host_seam::sys`), but glibc loads the timezone database on the first + // conversion, opening `/etc/localtime` — a direct `openat` the filter below + // would trap. Prime the cache now, while file opens are still allowed, by + // running one `gmtime_r` through the exact path the runtime uses; the + // post-lockdown conversions reuse the in-memory cache and issue no `openat`. + // (`tzset` alone does not open the zone file when `TZ` is unset in the + // controller-cleared environment, so drive the real call instead.) + unsafe { + let t: libc::time_t = 0; + let mut tm: libc::tm = core::mem::zeroed(); + libc::gmtime_r(&t, &mut tm); + } + + // Install the SIGSYS handler before the filter so a denied syscall is + // reported and exits cleanly rather than dying silently. + let mut sa: libc::sigaction = unsafe { core::mem::zeroed() }; + let handler: extern "C" fn(libc::c_int, *mut libc::siginfo_t, *mut libc::c_void) = + report_blocked_syscall; + sa.sa_sigaction = handler as usize; + sa.sa_flags = libc::SA_SIGINFO; + unsafe { libc::sigemptyset(&mut sa.sa_mask) }; + if unsafe { libc::sigaction(libc::SIGSYS, &sa, core::ptr::null_mut()) } != 0 { + return Err(io::Error::last_os_error()); + } + + let syscalls = allowed_syscalls(); + let n = syscalls.len(); + // Layout (indices): 0 load-arch, 1 arch-check, 2 arch-mismatch kill, + // 3 load-nr, 4..4+n per-syscall allow checks, 4+n default trap, 4+n+1 allow. + let mut prog: Vec = Vec::with_capacity(n + 6); + prog.push(stmt(LD_ABS_W, SECCOMP_DATA_ARCH_OFFSET)); + // arch == AUDIT_ARCH -> skip the next (kill) instruction; else fall into it. + prog.push(jeq(AUDIT_ARCH, 1)); + prog.push(stmt(RET_K, libc::SECCOMP_RET_KILL_PROCESS)); + prog.push(stmt(LD_ABS_W, SECCOMP_DATA_NR_OFFSET)); + for (i, &sc) in syscalls.iter().enumerate() { + // From the check at index 4+i, taking jt lands at 4+i+1+jt; the ALLOW + // terminator is at 4+n+1, so jt = n - i. + prog.push(jeq(sc, (n - i) as u8)); + } + // Default deny: trap to the SIGSYS handler, which names the syscall and + // exits. (The arch-mismatch path above stays an unconditional kill.) + prog.push(stmt(RET_K, libc::SECCOMP_RET_TRAP)); + prog.push(stmt(RET_K, libc::SECCOMP_RET_ALLOW)); + + // A non-privileged process may only install a filter after NO_NEW_PRIVS, so + // the filter can never be used to gain privileges via a set-uid exec. + if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { + return Err(io::Error::last_os_error()); + } + let fprog = libc::sock_fprog { + len: prog.len() as libc::c_ushort, + filter: prog.as_mut_ptr(), + }; + if unsafe { + libc::prctl( + libc::PR_SET_SECCOMP, + libc::SECCOMP_MODE_FILTER as libc::c_ulong, + &fprog as *const libc::sock_fprog as libc::c_ulong, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) +} diff --git a/pyre/pyre-interpreter/src/sandbox/vfs.rs b/pyre/pyre-sandbox/src/vfs.rs similarity index 62% rename from pyre/pyre-interpreter/src/sandbox/vfs.rs rename to pyre/pyre-sandbox/src/vfs.rs index 32d7032927c..63c1092aa93 100644 --- a/pyre/pyre-interpreter/src/sandbox/vfs.rs +++ b/pyre/pyre-sandbox/src/vfs.rs @@ -1,7 +1,7 @@ use indexmap::IndexMap; use std::cell::Cell; use std::fs; -use std::io::{self, Cursor, Read}; +use std::io::{self, Cursor, Read, Seek}; use std::path::PathBuf; use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -13,6 +13,12 @@ pub type Mode = u32; pub type FsNode = Rc; pub type VfsResult = Result; +/// A readable + seekable handle — the Rust shape of vfs.py's file-like objects +/// (`cStringIO.StringIO` for in-memory `File`, an `open(...)` handle for +/// `RealFile`). The controller needs `Seek` to service `ll_os_lseek`. +pub trait ReadSeek: Read + Seek {} +impl ReadSeek for T {} + // vfs.py:4 pub const UID: u32 = 1000; // vfs.py:5 @@ -146,8 +152,18 @@ pub trait FSObject { }) } + // vfs.py only defines `join` on `Dir`/`RealDir`; a non-directory raising on + // `join` is the Rust equivalent of the `AttributeError`/`ENOTDIR` a file + // would produce when `translate_path` walks into it. + fn join(&self, name: &str) -> VfsResult { + Err(VfsError { + errno: ENOTDIR, + object: name.to_owned(), + }) + } + // vfs.py:52 - fn open(&self) -> VfsResult> { + fn open(&self) -> VfsResult> { Err(VfsError { errno: EACCES, object: "self".to_owned(), @@ -175,14 +191,6 @@ impl Dir { entries, } } - - // vfs.py:65 - pub fn join(&self, name: &str) -> VfsResult { - self.entries.get(name).cloned().ok_or_else(|| VfsError { - errno: ENOENT, - object: name.to_owned(), - }) - } } impl FSObject for Dir { @@ -200,6 +208,14 @@ impl FSObject for Dir { fn keys(&self) -> VfsResult> { Ok(self.entries.keys().cloned().collect()) } + + // vfs.py:65 + fn join(&self, name: &str) -> VfsResult { + self.entries.get(name).cloned().ok_or_else(|| VfsError { + errno: ENOENT, + object: name.to_owned(), + }) + } } // vfs.py:71 @@ -235,6 +251,40 @@ impl RealDir { pub fn repr(&self) -> String { format!("", self.path.display()) } +} + +impl FSObject for RealDir { + // Rust support for vfs.py:15 + fn state(&self) -> &FSObjectState { + &self.state + } + + // vfs.py:60 + fn kind(&self) -> Mode { + S_IFDIR + } + + // vfs.py:87 + fn keys(&self) -> VfsResult> { + let mut names = Vec::new(); + for entry in fs::read_dir(&self.path) + .map_err(|err| io_error(err, self.path.display().to_string()))? + { + let entry = entry.map_err(|err| io_error(err, self.path.display().to_string()))?; + // The VFS is keyed by `String`, so a non-UTF-8 host filename is + // decoded lossily (U+FFFD) instead of preserved as raw bytes like + // PyPy's os.listdir. A lossy name only fails to resolve through the + // single-`Normal`-component jail in `join`; it cannot escape. + names.push(entry.file_name().to_string_lossy().into_owned()); + } + if !self.show_dotfiles { + names.retain(|name| !name.starts_with('.')); + } + for excl in &self.exclude { + names.retain(|name| !name.to_lowercase().ends_with(excl)); + } + Ok(names) + } // vfs.py:94 // @@ -249,12 +299,18 @@ impl RealDir { // child names only). This keeps the join inside `self.path` // unconditionally — the rest of the sandbox depends on that // invariant. - pub fn join(&self, name: &str) -> VfsResult { - if name.is_empty() - || name == ".." - || name.contains(std::path::MAIN_SEPARATOR) - || std::path::Path::new(name).is_absolute() - { + fn join(&self, name: &str) -> VfsResult { + // A child name must be exactly one ordinary path component. Reject both + // separators explicitly (the VFS is platform-neutral, so `\` is barred on + // unix too) and require a single `Component::Normal`, which also turns + // away `..`, the empty string, absolute paths, and Windows drive-prefixed + // names like `C:` / `C:foo` (neither absolute nor separator-bearing, yet + // they would escape the base on join). + let mut components = std::path::Path::new(name).components(); + let single_normal_child = + matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(); + if name.contains(['/', '\\']) || !single_normal_child { return Err(VfsError { errno: ENOENT, object: name.to_owned(), @@ -302,36 +358,6 @@ impl RealDir { } } -impl FSObject for RealDir { - // Rust support for vfs.py:15 - fn state(&self) -> &FSObjectState { - &self.state - } - - // vfs.py:60 - fn kind(&self) -> Mode { - S_IFDIR - } - - // vfs.py:87 - fn keys(&self) -> VfsResult> { - let mut names = Vec::new(); - for entry in fs::read_dir(&self.path) - .map_err(|err| io_error(err, self.path.display().to_string()))? - { - let entry = entry.map_err(|err| io_error(err, self.path.display().to_string()))?; - names.push(entry.file_name().to_string_lossy().into_owned()); - } - if !self.show_dotfiles { - names.retain(|name| !name.starts_with('.')); - } - for excl in &self.exclude { - names.retain(|name| !name.to_lowercase().ends_with(excl)); - } - Ok(names) - } -} - // vfs.py:115 pub struct File { state: FSObjectState, @@ -365,7 +391,7 @@ impl FSObject for File { } // vfs.py:121 - fn open(&self) -> VfsResult> { + fn open(&self) -> VfsResult> { Ok(Box::new(Cursor::new(self.data.clone()))) } } @@ -412,15 +438,15 @@ impl FSObject for RealFile { } // vfs.py:133 - fn open(&self) -> VfsResult> { + fn open(&self) -> VfsResult> { fs::File::open(&self.path) - .map(|file| Box::new(file) as Box) + .map(|file| Box::new(file) as Box) .map_err(|err| io_error(err, self.path.display().to_string())) } } // vfs.py:25 -fn is_dir(mode: Mode) -> bool { +pub fn is_dir(mode: Mode) -> bool { (mode & S_IFMT) == S_IFDIR } @@ -431,3 +457,130 @@ fn io_error(err: io::Error, object: String) -> VfsError { object, } } + +// Port of rpython/translator/sandbox/test/test_vfs.py. +#[cfg(test)] +mod tests { + use super::*; + + // POSIX access() mode bits. + const R_OK: Mode = 4; + const W_OK: Mode = 2; + const X_OK: Mode = 1; + + fn read_all(node: &FsNode) -> Vec { + let mut data = Vec::new(); + node.open().unwrap().read_to_end(&mut data).unwrap(); + data + } + + // `FsNode` is not `Debug`, so `Result::unwrap_err` is unavailable on a + // `VfsResult`; extract the errno of an expected failure by hand. + fn errno_of(r: VfsResult) -> i32 { + match r { + Ok(_) => panic!("expected an error"), + Err(e) => e.errno, + } + } + + fn sorted_keys(node: &FsNode) -> Vec { + let mut names = node.keys().unwrap(); + names.sort(); + names + } + + // test_vfs.py:22 test_dir + #[test] + fn test_dir() { + let mut entries: IndexMap = IndexMap::new(); + entries.insert("foo".to_owned(), Rc::new(Dir::default())); + let d = Dir::new(entries); + + assert_eq!(d.keys().unwrap(), vec!["foo".to_owned()]); + assert!(d.open().is_err()); + assert!(d.getsize().is_ok()); + + let d1 = d.join("foo").unwrap(); + assert!(is_dir(d1.kind())); + assert_eq!(d1.keys().unwrap(), Vec::::new()); + + // join('bar') raises + assert!(d.join("bar").is_err()); + + let st = d.stat().unwrap(); + assert!(is_dir(st.st_mode)); + assert!(d.access(R_OK | X_OK).unwrap()); + assert!(!d.access(W_OK).unwrap()); + } + + // test_vfs.py:36 test_file + #[test] + fn test_file() { + let f = File::new("hello world"); + assert_eq!(f.kind() & S_IFMT, S_IFREG); + assert!(f.keys().is_err()); + assert_eq!(f.getsize().unwrap(), 11); + assert_eq!( + read_all(&(Rc::new(File::new("hello world")) as FsNode)), + b"hello world" + ); + + let st = f.stat().unwrap(); + assert_eq!(st.st_mode & S_IFMT, S_IFREG); + assert_eq!(st.st_size, 11); + assert!(f.access(R_OK).unwrap()); + assert!(!f.access(W_OK).unwrap()); + } + + // test_vfs.py:51 — RealDir/RealFile keys + join + read. + #[test] + fn test_realdir_realfile() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("file1"), b"somedata1").unwrap(); + std::fs::write(tmp.path().join(".hidden"), b"secret").unwrap(); + std::fs::create_dir(tmp.path().join("subdir1")).unwrap(); + std::fs::write(tmp.path().join("subdir1/subfile1"), b"spam").unwrap(); + + let v = RealDir::new(tmp.path(), false, false, Vec::new()); + // dotfiles hidden by default + assert_eq!( + { + let mut k = v.keys().unwrap(); + k.sort(); + k + }, + vec!["file1".to_owned(), "subdir1".to_owned()] + ); + + assert_eq!(read_all(&v.join("file1").unwrap()), b"somedata1"); + + let sub = v.join("subdir1").unwrap(); + assert!(is_dir(sub.kind())); + assert_eq!(sorted_keys(&sub), vec!["subfile1".to_owned()]); + + // missing + hidden + traversal are all ENOENT + assert!(v.join("does_not_exist").is_err()); + assert!(v.join(".hidden").is_err()); + assert_eq!(errno_of(v.join("..")), ENOENT); + assert_eq!(errno_of(v.join("subdir1/subfile1")), ENOENT); + } + + // test_vfs.py:100 test_realdir_exclude — case-insensitive suffix exclusion. + #[test] + fn test_realdir_exclude() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("thing.yes"), b"").unwrap(); + std::fs::write(tmp.path().join("thing.no"), b"").unwrap(); + + let v = RealDir::new(tmp.path(), false, false, vec![".no".to_owned()]); + let keys = v.keys().unwrap(); + assert!(keys.contains(&"thing.yes".to_owned())); + assert!(!keys.contains(&"thing.no".to_owned())); + + assert!(v.join("thing.yes").is_ok()); + assert!(v.join("thing.no").is_err()); + // case variants are excluded too + assert!(v.join("thing.No").is_err()); + assert!(v.join("thing.NO").is_err()); + } +} diff --git a/pyre/pyre-sandbox/tests/e2e_interact.rs b/pyre/pyre-sandbox/tests/e2e_interact.rs new file mode 100644 index 00000000000..10f35df882e --- /dev/null +++ b/pyre/pyre-sandbox/tests/e2e_interact.rs @@ -0,0 +1,234 @@ +//! End-to-end sandbox test (port of `pypy/sandbox/test/test_pypy_interact.py`). +//! +//! Builds `pyre` with the `sandbox` feature — the variant whose mediated OS +//! calls are compiled into marshalling trampolines — and drives it through the +//! Rust controller (`pyre interact`) over a virtual filesystem. The same +//! binary serves as both the trusted controller (the `interact` subcommand +//! never touches `host_seam`) and the untrusted child. +//! +//! Marked `#[ignore]` because it shells out to a release `cargo build`; run it +//! explicitly with: +//! +//! ```text +//! cargo test -p pyre-sandbox --test e2e_interact -- --ignored --nocapture +//! ``` +//! +//! The probes use only builtin modules (`posix`/`time`/`sys`/`_locale`): the +//! controller mounts no stdlib, so `import os` (a stdlib `.py`) is unavailable. +//! +//! Unix-only: the `controller` it drives compiles only on unix, so the whole +//! test is gated out elsewhere to keep `cargo test --all` green on non-unix. +#![cfg(unix)] + +use std::path::PathBuf; +use std::process::Command; + +/// Workspace root is two directories above this crate (`pyre/pyre-sandbox`). +fn workspace_root() -> PathBuf { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.pop(); // pyre/ + p.pop(); // workspace root + p +} + +/// Build the sandbox `pyre` binary once and return its path. Cargo skips the +/// work when the binary is already current, so repeated runs are cheap. +fn build_sandbox_pyre() -> PathBuf { + let root = workspace_root(); + let status = Command::new(env!("CARGO")) + .current_dir(&root) + .args([ + "build", + "--release", + "-p", + "pyrex", + "--bin", + "pyre", + "--features", + "sandbox", + ]) + .status() + .expect("spawn cargo build"); + assert!(status.success(), "building sandbox pyre failed"); + let bin = root.join("target/release/pyre"); + assert!(bin.is_file(), "sandbox pyre missing at {}", bin.display()); + bin +} + +/// Run `pyre interact --tmp -c