From dd10bf277f8974be79788b45cb22eb547e2d4c35 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sun, 8 Feb 2026 13:10:51 +0900 Subject: [PATCH 1/2] Add host_env feature for sandbox isolation Introduce a `host_env` feature flag that gates all host environment access (filesystem, network, signals, processes). When disabled, the VM operates in sandbox mode: - _io module always available; FileIO gated by host_env - SandboxStdio provides lightweight stdin/stdout/stderr via Rust std::io - BytesIO/StringIO/BufferedIO/TextIOWrapper work without host_env - open() returns UnsupportedOperation in sandbox - stdlib modules (os, socket, signal, etc.) gated by host_env - CI checks both host_env ON and OFF builds --- .cspell.json | 1 + .github/workflows/ci.yaml | 17 +- Cargo.toml | 3 +- crates/stdlib/Cargo.toml | 3 +- crates/stdlib/src/lib.rs | 153 ++++++++----- crates/vm/Cargo.toml | 3 +- crates/vm/src/exceptions.rs | 298 ++++++++++++++++---------- crates/vm/src/import.rs | 10 +- crates/vm/src/lib.rs | 2 +- crates/vm/src/signal.rs | 3 +- crates/vm/src/stdlib/io.rs | 91 +------- crates/vm/src/stdlib/mod.rs | 36 ++-- crates/vm/src/stdlib/os.rs | 17 +- crates/vm/src/stdlib/sys.rs | 127 ++++++++++- crates/vm/src/stdlib/thread.rs | 2 +- crates/vm/src/vm/mod.rs | 35 ++- crates/vm/src/vm/python_run.rs | 219 ++++++++++--------- extra_tests/snippets/sandbox_smoke.py | 55 +++++ src/lib.rs | 28 ++- 19 files changed, 696 insertions(+), 407 deletions(-) create mode 100644 extra_tests/snippets/sandbox_smoke.py diff --git a/.cspell.json b/.cspell.json index ebed8664e58..a7f814a340b 100644 --- a/.cspell.json +++ b/.cspell.json @@ -113,6 +113,7 @@ "pytype", "reducelib", "richcompare", + "rustix", "RustPython", "significand", "struc", diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a61b60bd24e..80816a69098 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,8 +16,8 @@ concurrency: cancel-in-progress: true env: - CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls - CARGO_ARGS_NO_SSL: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite + CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls,host_env + CARGO_ARGS_NO_SSL: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,host_env # Crates excluded from workspace builds: # - rustpython_wasm: requires wasm target # - rustpython-compiler-source: deprecated @@ -153,6 +153,19 @@ jobs: - name: check compilation without threading run: cargo check ${{ env.CARGO_ARGS }} + - name: check compilation without host_env (sandbox mode) + run: | + cargo check -p rustpython-vm --no-default-features --features compiler + cargo check -p rustpython-stdlib --no-default-features --features compiler + cargo build --no-default-features --features stdlib,importlib,stdio,encodings,freeze-stdlib + if: runner.os == 'Linux' + + - name: sandbox smoke test + run: | + target/debug/rustpython extra_tests/snippets/sandbox_smoke.py + target/debug/rustpython extra_tests/snippets/stdlib_re.py + if: runner.os == 'Linux' + - name: Test openssl build run: cargo build --no-default-features --features ssl-openssl if: runner.os == 'Linux' diff --git a/Cargo.toml b/Cargo.toml index ef09e081d73..f1cd02e34fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,8 @@ repository.workspace = true license.workspace = true [features] -default = ["threading", "stdlib", "stdio", "importlib", "ssl-rustls"] +default = ["threading", "stdlib", "stdio", "importlib", "ssl-rustls", "host_env"] +host_env = ["rustpython-vm/host_env", "rustpython-stdlib?/host_env"] importlib = ["rustpython-vm/importlib"] encodings = ["rustpython-vm/encodings"] stdio = ["rustpython-vm/stdio"] diff --git a/crates/stdlib/Cargo.toml b/crates/stdlib/Cargo.toml index 6081c961a20..d0da89d42ef 100644 --- a/crates/stdlib/Cargo.toml +++ b/crates/stdlib/Cargo.toml @@ -11,7 +11,8 @@ license.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["compiler"] +default = ["compiler", "host_env"] +host_env = ["rustpython-vm/host_env"] compiler = ["rustpython-vm/compiler"] threading = ["rustpython-common/threading", "rustpython-vm/threading"] sqlite = ["dep:libsqlite3-sys"] diff --git a/crates/stdlib/src/lib.rs b/crates/stdlib/src/lib.rs index 02b7407446f..7a472c6ce9c 100644 --- a/crates/stdlib/src/lib.rs +++ b/crates/stdlib/src/lib.rs @@ -34,12 +34,15 @@ mod sha512; mod json; -#[cfg(not(any(target_os = "ios", target_arch = "wasm32")))] +#[cfg(all( + feature = "host_env", + not(any(target_os = "ios", target_arch = "wasm32")) +))] mod locale; mod _opcode; mod math; -#[cfg(any(unix, windows))] +#[cfg(all(feature = "host_env", any(unix, windows)))] mod mmap; mod pyexpat; mod pystruct; @@ -48,20 +51,26 @@ mod statistics; mod suggestions; // TODO: maybe make this an extension module, if we ever get those // mod re; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] pub mod socket; -#[cfg(all(unix, not(target_os = "redox")))] +#[cfg(all(feature = "host_env", unix, not(target_os = "redox")))] mod syslog; mod unicodedata; +#[cfg(feature = "host_env")] mod faulthandler; -#[cfg(any(unix, target_os = "wasi"))] +#[cfg(all(feature = "host_env", any(unix, target_os = "wasi")))] mod fcntl; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] mod multiprocessing; -#[cfg(all(unix, not(target_os = "redox"), not(target_os = "android")))] +#[cfg(all( + feature = "host_env", + unix, + not(target_os = "redox"), + not(target_os = "android") +))] mod posixshmem; -#[cfg(unix)] +#[cfg(all(feature = "host_env", unix))] mod posixsubprocess; // libc is missing constants on redox #[cfg(all( @@ -69,36 +78,56 @@ mod posixsubprocess; not(any(target_os = "android", target_arch = "wasm32")) ))] mod _sqlite3; -#[cfg(all(unix, not(any(target_os = "android", target_os = "redox"))))] +#[cfg(all( + feature = "host_env", + unix, + not(any(target_os = "android", target_os = "redox")) +))] mod grp; -#[cfg(windows)] +#[cfg(all(feature = "host_env", windows))] mod overlapped; -#[cfg(all(unix, not(target_os = "redox")))] +#[cfg(all(feature = "host_env", unix, not(target_os = "redox")))] mod resource; -#[cfg(target_os = "macos")] +#[cfg(all(feature = "host_env", target_os = "macos"))] mod scproxy; -#[cfg(any(unix, windows, target_os = "wasi"))] +#[cfg(all(feature = "host_env", any(unix, windows, target_os = "wasi")))] mod select; -#[cfg(all(not(target_arch = "wasm32"), feature = "ssl-openssl"))] +#[cfg(all( + feature = "host_env", + not(target_arch = "wasm32"), + feature = "ssl-openssl" +))] mod openssl; -#[cfg(all(not(target_arch = "wasm32"), feature = "ssl-rustls"))] +#[cfg(all( + feature = "host_env", + not(target_arch = "wasm32"), + feature = "ssl-rustls" +))] mod ssl; #[cfg(all(feature = "ssl-openssl", feature = "ssl-rustls"))] compile_error!("features \"ssl-openssl\" and \"ssl-rustls\" are mutually exclusive"); -#[cfg(all(unix, not(target_os = "redox"), not(target_os = "ios")))] +#[cfg(all( + feature = "host_env", + unix, + not(target_os = "redox"), + not(target_os = "ios") +))] mod termios; -#[cfg(not(any( - target_os = "android", - target_os = "ios", - target_os = "windows", - target_arch = "wasm32", - target_os = "redox", -)))] +#[cfg(all( + feature = "host_env", + not(any( + target_os = "android", + target_os = "ios", + target_os = "windows", + target_arch = "wasm32", + target_os = "redox", + )) +))] mod uuid; -#[cfg(feature = "tkinter")] +#[cfg(all(feature = "host_env", feature = "tkinter"))] mod tkinter; use rustpython_common as common; @@ -122,69 +151,97 @@ pub fn stdlib_module_defs(ctx: &Context) -> Vec<&'static builtins::PyModuleDef> cmath::module_def(ctx), contextvars::module_def(ctx), csv::module_def(ctx), + #[cfg(feature = "host_env")] faulthandler::module_def(ctx), - #[cfg(any(unix, target_os = "wasi"))] + #[cfg(all(feature = "host_env", any(unix, target_os = "wasi")))] fcntl::module_def(ctx), - #[cfg(all(unix, not(any(target_os = "android", target_os = "redox"))))] + #[cfg(all( + feature = "host_env", + unix, + not(any(target_os = "android", target_os = "redox")) + ))] grp::module_def(ctx), hashlib::module_def(ctx), json::module_def(ctx), - #[cfg(not(any(target_os = "ios", target_arch = "wasm32")))] + #[cfg(all( + feature = "host_env", + not(any(target_os = "ios", target_arch = "wasm32")) + ))] locale::module_def(ctx), #[cfg(not(any(target_os = "android", target_arch = "wasm32")))] lzma::module_def(ctx), math::module_def(ctx), md5::module_def(ctx), - #[cfg(any(unix, windows))] + #[cfg(all(feature = "host_env", any(unix, windows)))] mmap::module_def(ctx), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] multiprocessing::module_def(ctx), - #[cfg(all(not(target_arch = "wasm32"), feature = "ssl-openssl"))] + #[cfg(all( + feature = "host_env", + not(target_arch = "wasm32"), + feature = "ssl-openssl" + ))] openssl::module_def(ctx), - #[cfg(windows)] + #[cfg(all(feature = "host_env", windows))] overlapped::module_def(ctx), - #[cfg(unix)] + #[cfg(all(feature = "host_env", unix))] posixsubprocess::module_def(ctx), - #[cfg(all(unix, not(target_os = "redox"), not(target_os = "android")))] + #[cfg(all( + feature = "host_env", + unix, + not(target_os = "redox"), + not(target_os = "android") + ))] posixshmem::module_def(ctx), pyexpat::module_def(ctx), pystruct::module_def(ctx), random::module_def(ctx), - #[cfg(all(unix, not(target_os = "redox")))] + #[cfg(all(feature = "host_env", unix, not(target_os = "redox")))] resource::module_def(ctx), - #[cfg(target_os = "macos")] + #[cfg(all(feature = "host_env", target_os = "macos"))] scproxy::module_def(ctx), - #[cfg(any(unix, windows, target_os = "wasi"))] + #[cfg(all(feature = "host_env", any(unix, windows, target_os = "wasi")))] select::module_def(ctx), sha1::module_def(ctx), sha256::module_def(ctx), sha3::module_def(ctx), sha512::module_def(ctx), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] socket::module_def(ctx), #[cfg(all( feature = "sqlite", not(any(target_os = "android", target_arch = "wasm32")) ))] _sqlite3::module_def(ctx), - #[cfg(all(not(target_arch = "wasm32"), feature = "ssl-rustls"))] + #[cfg(all( + feature = "host_env", + not(target_arch = "wasm32"), + feature = "ssl-rustls" + ))] ssl::module_def(ctx), statistics::module_def(ctx), suggestions::module_def(ctx), - #[cfg(all(unix, not(target_os = "redox")))] + #[cfg(all(feature = "host_env", unix, not(target_os = "redox")))] syslog::module_def(ctx), - #[cfg(all(unix, not(any(target_os = "ios", target_os = "redox"))))] + #[cfg(all( + feature = "host_env", + unix, + not(any(target_os = "ios", target_os = "redox")) + ))] termios::module_def(ctx), - #[cfg(feature = "tkinter")] + #[cfg(all(feature = "host_env", feature = "tkinter"))] tkinter::module_def(ctx), unicodedata::module_def(ctx), - #[cfg(not(any( - target_os = "android", - target_os = "ios", - target_os = "windows", - target_arch = "wasm32", - target_os = "redox" - )))] + #[cfg(all( + feature = "host_env", + not(any( + target_os = "android", + target_os = "ios", + target_os = "windows", + target_arch = "wasm32", + target_os = "redox" + )) + ))] uuid::module_def(ctx), zlib::module_def(ctx), ] diff --git a/crates/vm/Cargo.toml b/crates/vm/Cargo.toml index 6e05c7cde48..ba9cc4a6719 100644 --- a/crates/vm/Cargo.toml +++ b/crates/vm/Cargo.toml @@ -10,7 +10,8 @@ repository.workspace = true license.workspace = true [features] -default = ["compiler", "wasmbind", "stdio", "gc"] +default = ["compiler", "wasmbind", "gc", "host_env", "stdio"] +host_env = [] stdio = [] importlib = [] encodings = ["importlib"] diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index f751d0677a1..8d21b8c468c 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -8,7 +8,7 @@ use crate::{ traceback::{PyTraceback, PyTracebackRef}, }, class::{PyClassImpl, StaticType}, - convert::{ToPyException, ToPyObject}, + convert::{IntoPyException, ToPyException, ToPyObject}, function::{ArgIterable, FuncArgs, IntoFuncArgs, PySetterValue}, py_io::{self, Write}, stdlib::sys, @@ -1241,138 +1241,208 @@ pub(crate) fn errno_to_exc_type(_errno: i32, _vm: &VirtualMachine) -> Option<&'s None } -pub(crate) use types::{OSErrorBuilder, ToOSErrorBuilder}; - -pub(super) mod types { - use crate::common::lock::PyRwLock; - use crate::object::{MaybeTraverse, Traverse, TraverseFn}; - #[cfg_attr(target_arch = "wasm32", allow(unused_imports))] - use crate::{ - AsObject, Py, PyAtomicRef, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, - VirtualMachine, - builtins::{ - PyInt, PyStrRef, PyTupleRef, PyType, PyTypeRef, traceback::PyTracebackRef, - tuple::IntoPyTuple, - }, - convert::ToPyObject, - convert::ToPyResult, - function::{ArgBytesLike, FuncArgs, KwArgs}, - types::{Constructor, Initializer}, - }; - use crossbeam_utils::atomic::AtomicCell; - use itertools::Itertools; - use rustpython_common::str::UnicodeEscapeCodepoint; +pub(crate) trait ToOSErrorBuilder { + fn to_os_error_builder(&self, vm: &VirtualMachine) -> OSErrorBuilder; +} - pub(crate) trait ToOSErrorBuilder { - fn to_os_error_builder(&self, vm: &VirtualMachine) -> OSErrorBuilder; - } +pub(crate) struct OSErrorBuilder { + exc_type: PyTypeRef, + errno: Option, + strerror: Option, + filename: Option, + #[cfg(windows)] + winerror: Option, + filename2: Option, +} - pub struct OSErrorBuilder { +impl OSErrorBuilder { + #[must_use] + pub fn with_subtype( exc_type: PyTypeRef, errno: Option, - strerror: Option, - filename: Option, - #[cfg(windows)] - winerror: Option, - filename2: Option, + strerror: impl ToPyObject, + vm: &VirtualMachine, + ) -> Self { + let strerror = strerror.to_pyobject(vm); + Self { + exc_type, + errno, + strerror: Some(strerror), + filename: None, + #[cfg(windows)] + winerror: None, + filename2: None, + } } - impl OSErrorBuilder { - #[must_use] - pub fn with_subtype( - exc_type: PyTypeRef, - errno: Option, - strerror: impl ToPyObject, - vm: &VirtualMachine, - ) -> Self { - let strerror = strerror.to_pyobject(vm); - Self { - exc_type, - errno, - strerror: Some(strerror), - filename: None, - #[cfg(windows)] - winerror: None, - filename2: None, - } - } - #[must_use] - pub fn with_errno(errno: i32, strerror: impl ToPyObject, vm: &VirtualMachine) -> Self { - let exc_type = crate::exceptions::errno_to_exc_type(errno, vm) - .unwrap_or(vm.ctx.exceptions.os_error) - .to_owned(); - Self::with_subtype(exc_type, Some(errno), strerror, vm) - } + #[must_use] + pub fn with_errno(errno: i32, strerror: impl ToPyObject, vm: &VirtualMachine) -> Self { + let exc_type = errno_to_exc_type(errno, vm) + .unwrap_or(vm.ctx.exceptions.os_error) + .to_owned(); + Self::with_subtype(exc_type, Some(errno), strerror, vm) + } - // #[must_use] - // pub(crate) fn errno(mut self, errno: i32) -> Self { - // self.errno.replace(errno); - // self - // } + #[must_use] + #[allow(dead_code)] + pub(crate) fn filename(mut self, filename: PyObjectRef) -> Self { + self.filename.replace(filename); + self + } - #[must_use] - pub(crate) fn filename(mut self, filename: PyObjectRef) -> Self { - self.filename.replace(filename); - self - } + #[must_use] + #[allow(dead_code)] + pub(crate) fn filename2(mut self, filename: PyObjectRef) -> Self { + self.filename2.replace(filename); + self + } - #[must_use] - pub(crate) fn filename2(mut self, filename: PyObjectRef) -> Self { - self.filename2.replace(filename); - self - } + #[must_use] + #[cfg(windows)] + pub(crate) fn winerror(mut self, winerror: PyObjectRef) -> Self { + self.winerror.replace(winerror); + self + } - #[must_use] - #[cfg(windows)] - pub(crate) fn winerror(mut self, winerror: PyObjectRef) -> Self { - self.winerror.replace(winerror); - self - } + pub fn build(self, vm: &VirtualMachine) -> PyRef { + use types::PyOSError; - pub fn build(self, vm: &VirtualMachine) -> PyRef { - let OSErrorBuilder { - exc_type, - errno, - strerror, - filename, - #[cfg(windows)] + let OSErrorBuilder { + exc_type, + errno, + strerror, + filename, + #[cfg(windows)] + winerror, + filename2, + } = self; + + let args = if let Some(errno) = errno { + #[cfg(windows)] + let winerror = winerror.to_pyobject(vm); + #[cfg(not(windows))] + let winerror = vm.ctx.none(); + + vec![ + errno.to_pyobject(vm), + strerror.to_pyobject(vm), + filename.to_pyobject(vm), winerror, - filename2, - } = self; + filename2.to_pyobject(vm), + ] + } else { + vec![strerror.to_pyobject(vm)] + }; - let args = if let Some(errno) = errno { - #[cfg(windows)] - let winerror = winerror.to_pyobject(vm); - #[cfg(not(windows))] - let winerror = vm.ctx.none(); - - vec![ - errno.to_pyobject(vm), - strerror.to_pyobject(vm), - filename.to_pyobject(vm), - winerror, - filename2.to_pyobject(vm), - ] + let payload = PyOSError::py_new(&exc_type, args.clone().into(), vm) + .expect("new_os_error usage error"); + let os_error = payload + .into_ref_with_type(vm, exc_type) + .expect("new_os_error usage error"); + PyOSError::slot_init(os_error.as_object().to_owned(), args.into(), vm) + .expect("new_os_error usage error"); + os_error + } +} + +impl IntoPyException for OSErrorBuilder { + fn into_pyexception(self, vm: &VirtualMachine) -> PyBaseExceptionRef { + self.build(vm).upcast() + } +} + +impl ToOSErrorBuilder for std::io::Error { + fn to_os_error_builder(&self, vm: &VirtualMachine) -> OSErrorBuilder { + use crate::common::os::ErrorExt; + + let errno = self.posix_errno(); + #[cfg(windows)] + let msg = 'msg: { + // Use C runtime's strerror for POSIX errno values. + // For Windows-specific error codes, fall back to FormatMessage. + const MAX_POSIX_ERRNO: i32 = 127; + if errno > 0 && errno <= MAX_POSIX_ERRNO { + let ptr = unsafe { libc::strerror(errno) }; + if !ptr.is_null() { + let s = unsafe { core::ffi::CStr::from_ptr(ptr) }.to_string_lossy(); + if !s.starts_with("Unknown error") { + break 'msg s.into_owned(); + } + } + } + self.to_string() + }; + #[cfg(unix)] + let msg = { + let ptr = unsafe { libc::strerror(errno) }; + if !ptr.is_null() { + unsafe { core::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned() } else { - vec![strerror.to_pyobject(vm)] - }; + self.to_string() + } + }; + #[cfg(not(any(windows, unix)))] + let msg = self.to_string(); + + #[allow(unused_mut)] + let mut builder = OSErrorBuilder::with_errno(errno, msg, vm); - let payload = PyOSError::py_new(&exc_type, args.clone().into(), vm) - .expect("new_os_error usage error"); - let os_error = payload - .into_ref_with_type(vm, exc_type) - .expect("new_os_error usage error"); - PyOSError::slot_init(os_error.as_object().to_owned(), args.into(), vm) - .expect("new_os_error usage error"); - os_error + #[cfg(windows)] + if let Some(winerror) = self.raw_os_error() { + builder = builder.winerror(winerror.to_pyobject(vm)); } + + builder } +} - impl crate::convert::IntoPyException for OSErrorBuilder { - fn into_pyexception(self, vm: &VirtualMachine) -> PyBaseExceptionRef { - self.build(vm).upcast() - } +impl ToPyException for std::io::Error { + fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { + let builder = self.to_os_error_builder(vm); + builder.into_pyexception(vm) + } +} + +impl IntoPyException for std::io::Error { + fn into_pyexception(self, vm: &VirtualMachine) -> PyBaseExceptionRef { + self.to_pyexception(vm) } +} + +#[cfg(unix)] +impl IntoPyException for nix::Error { + fn into_pyexception(self, vm: &VirtualMachine) -> PyBaseExceptionRef { + std::io::Error::from(self).into_pyexception(vm) + } +} + +#[cfg(unix)] +impl IntoPyException for rustix::io::Errno { + fn into_pyexception(self, vm: &VirtualMachine) -> PyBaseExceptionRef { + std::io::Error::from(self).into_pyexception(vm) + } +} + +pub(super) mod types { + use crate::common::lock::PyRwLock; + use crate::object::{MaybeTraverse, Traverse, TraverseFn}; + #[cfg_attr(target_arch = "wasm32", allow(unused_imports))] + use crate::{ + AsObject, Py, PyAtomicRef, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, + VirtualMachine, + builtins::{ + PyInt, PyStrRef, PyTupleRef, PyType, PyTypeRef, traceback::PyTracebackRef, + tuple::IntoPyTuple, + }, + convert::ToPyResult, + function::{ArgBytesLike, FuncArgs, KwArgs}, + types::{Constructor, Initializer}, + }; + use crossbeam_utils::atomic::AtomicCell; + use itertools::Itertools; + use rustpython_common::str::UnicodeEscapeCodepoint; // Re-export exception group types from dedicated module pub use crate::exception_group::types::PyBaseExceptionGroup; diff --git a/crates/vm/src/import.rs b/crates/vm/src/import.rs index d1b77407ef8..5657d1a3c14 100644 --- a/crates/vm/src/import.rs +++ b/crates/vm/src/import.rs @@ -1,8 +1,8 @@ //! Import mechanics use crate::{ - AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, - builtins::{PyCode, list, traceback::PyTraceback}, + AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, + builtins::{PyCode, traceback::PyTraceback}, exceptions::types::PyBaseException, scope::Scope, vm::{VirtualMachine, resolve_frozen_alias, thread}, @@ -33,12 +33,14 @@ pub(crate) fn init_importlib_base(vm: &mut VirtualMachine) -> PyResult PyResult<()> { + use crate::{TryFromObject, builtins::PyListRef}; + thread::enter_vm(vm, || { flame_guard!("install_external"); // same deal as imports above - #[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] import_builtin(vm, crate::stdlib::os::MODULE_NAME)?; #[cfg(windows)] import_builtin(vm, "winreg")?; @@ -51,7 +53,7 @@ pub(crate) fn init_importlib_package(vm: &VirtualMachine, importlib: PyObjectRef let zipimport = vm.import("zipimport", 0)?; let zipimporter = zipimport.get_attr("zipimporter", vm)?; let path_hooks = vm.sys_module.get_attr("path_hooks", vm)?; - let path_hooks = list::PyListRef::try_from_object(vm, path_hooks)?; + let path_hooks = PyListRef::try_from_object(vm, path_hooks)?; path_hooks.insert(0, zipimporter); Ok(()) })(); diff --git a/crates/vm/src/lib.rs b/crates/vm/src/lib.rs index a458bc0cbc0..8a15688f591 100644 --- a/crates/vm/src/lib.rs +++ b/crates/vm/src/lib.rs @@ -67,7 +67,7 @@ mod intern; pub mod iter; pub mod object; -#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] +#[cfg(feature = "host_env")] pub mod ospath; pub mod prelude; diff --git a/crates/vm/src/signal.rs b/crates/vm/src/signal.rs index 3ad207b333d..fca58a3a26b 100644 --- a/crates/vm/src/signal.rs +++ b/crates/vm/src/signal.rs @@ -80,6 +80,7 @@ pub(crate) fn set_triggered() { /// Reset all signal trigger state after fork in child process. /// Stale triggers from the parent must not fire in the child. #[cfg(unix)] +#[cfg(feature = "host_env")] pub(crate) fn clear_after_fork() { ANY_TRIGGERED.store(false, Ordering::Release); for trigger in &TRIGGERS { @@ -99,7 +100,7 @@ pub fn assert_in_range(signum: i32, vm: &VirtualMachine) -> PyResult<()> { /// /// Missing signal handler for the given signal number is silently ignored. #[allow(dead_code)] -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "host_env"))] pub fn set_interrupt_ex(signum: i32, vm: &VirtualMachine) -> PyResult<()> { use crate::stdlib::signal::_signal::{SIG_DFL, SIG_IGN, run_signal}; assert_in_range(signum, vm)?; diff --git a/crates/vm/src/stdlib/io.rs b/crates/vm/src/stdlib/io.rs index cb345ca5634..c6af68f476a 100644 --- a/crates/vm/src/stdlib/io.rs +++ b/crates/vm/src/stdlib/io.rs @@ -21,76 +21,10 @@ cfg_if::cfg_if! { } use crate::{ - AsObject, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, - builtins::{PyBaseExceptionRef, PyModule}, - common::os::ErrorExt, - convert::{IntoPyException, ToPyException}, - exceptions::{OSErrorBuilder, ToOSErrorBuilder}, + AsObject, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, builtins::PyModule, }; pub use _io::{OpenArgs, io_open as open}; -impl ToOSErrorBuilder for std::io::Error { - fn to_os_error_builder(&self, vm: &VirtualMachine) -> OSErrorBuilder { - let errno = self.posix_errno(); - #[cfg(windows)] - let msg = 'msg: { - // On Windows, use C runtime's strerror for POSIX errno values - // For Windows-specific error codes, fall back to FormatMessage - - // UCRT's strerror returns "Unknown error" for invalid errno values - // Windows UCRT defines errno values 1-42 plus some more up to ~127 - const MAX_POSIX_ERRNO: i32 = 127; - if errno > 0 && errno <= MAX_POSIX_ERRNO { - let ptr = unsafe { libc::strerror(errno) }; - if !ptr.is_null() { - let s = unsafe { core::ffi::CStr::from_ptr(ptr) }.to_string_lossy(); - if !s.starts_with("Unknown error") { - break 'msg s.into_owned(); - } - } - } - self.to_string() - }; - #[cfg(unix)] - let msg = { - let ptr = unsafe { libc::strerror(errno) }; - if !ptr.is_null() { - unsafe { core::ffi::CStr::from_ptr(ptr) } - .to_string_lossy() - .into_owned() - } else { - self.to_string() - } - }; - #[cfg(not(any(windows, unix)))] - let msg = self.to_string(); - - #[allow(unused_mut)] - let mut builder = OSErrorBuilder::with_errno(errno, msg, vm); - - #[cfg(windows)] - if let Some(winerror) = self.raw_os_error() { - use crate::convert::ToPyObject; - builder = builder.winerror(winerror.to_pyobject(vm)); - } - - builder - } -} - -impl ToPyException for std::io::Error { - fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { - let builder = self.to_os_error_builder(vm); - builder.into_pyexception(vm) - } -} - -impl IntoPyException for std::io::Error { - fn into_pyexception(self, vm: &VirtualMachine) -> PyBaseExceptionRef { - self.to_pyexception(vm) - } -} - fn file_closed(file: &PyObject, vm: &VirtualMachine) -> PyResult { file.get_attr("closed", vm)?.try_to_bool(vm) } @@ -301,15 +235,8 @@ mod _io { } fn os_err(vm: &VirtualMachine, err: io::Error) -> PyBaseExceptionRef { - #[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] - { - use crate::convert::ToPyException; - err.to_pyexception(vm) - } - #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] - { - vm.new_os_error(err.to_string()) - } + use crate::convert::ToPyException; + err.to_pyexception(vm) } pub(super) fn io_closed_error(vm: &VirtualMachine) -> PyBaseExceptionRef { @@ -4900,7 +4827,7 @@ mod _io { } // check file descriptor validity - #[cfg(unix)] + #[cfg(all(unix, feature = "host_env"))] if let Ok(crate::ospath::OsPathOrFd::Fd(fd)) = file.clone().try_into_value(vm) { nix::fcntl::fcntl(fd, nix::fcntl::F_GETFD).map_err(|_| vm.new_last_errno_error())?; } @@ -4909,7 +4836,7 @@ mod _io { // This is subsequently consumed by a Buffered Class. let file_io_class: &Py = { cfg_if::cfg_if! { - if #[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] { + if #[cfg(feature = "host_env")] { Some(super::fileio::FileIO::static_type()) } else { None @@ -5104,8 +5031,8 @@ mod _io { // Call auto-generated initialization first __module_exec(vm, module); - // Initialize FileIO types on non-WASM platforms - #[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] + // Initialize FileIO types (requires host_env for filesystem access) + #[cfg(feature = "host_env")] super::fileio::module_exec(vm, module)?; let unsupported_operation = unsupported_operation().to_owned(); @@ -5116,8 +5043,8 @@ mod _io { Ok(()) } } -// disable FileIO on WASM -#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] +// FileIO requires host environment for filesystem access +#[cfg(feature = "host_env")] #[pymodule] mod fileio { use super::{_io::*, Offset, iobase_finalize}; diff --git a/crates/vm/src/stdlib/mod.rs b/crates/vm/src/stdlib/mod.rs index 74a498f5472..c9cd66346f3 100644 --- a/crates/vm/src/stdlib/mod.rs +++ b/crates/vm/src/stdlib/mod.rs @@ -31,37 +31,39 @@ pub mod typing; pub mod warnings; mod weakref; -#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] +#[cfg(feature = "host_env")] #[macro_use] pub mod os; -#[cfg(windows)] +#[cfg(all(feature = "host_env", windows))] pub mod nt; -#[cfg(unix)] +#[cfg(all(feature = "host_env", unix))] pub mod posix; -#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] -#[cfg(not(any(unix, windows)))] +#[cfg(all(feature = "host_env", not(any(unix, windows))))] #[path = "posix_compat.rs"] pub mod posix; #[cfg(all( + feature = "host_env", any(target_os = "linux", target_os = "macos", target_os = "windows"), not(any(target_env = "musl", target_env = "sgx")) ))] mod ctypes; -#[cfg(windows)] +#[cfg(all(feature = "host_env", windows))] pub(crate) mod msvcrt; #[cfg(all( + feature = "host_env", unix, not(any(target_os = "ios", target_os = "wasi", target_os = "redox")) ))] mod pwd; +#[cfg(feature = "host_env")] pub(crate) mod signal; pub mod sys; -#[cfg(windows)] +#[cfg(all(feature = "host_env", windows))] mod winapi; -#[cfg(windows)] +#[cfg(all(feature = "host_env", windows))] mod winreg; use crate::{Context, builtins::PyModuleDef}; @@ -81,6 +83,7 @@ pub fn builtin_module_defs(ctx: &Context) -> Vec<&'static PyModuleDef> { codecs::module_def(ctx), collections::module_def(ctx), #[cfg(all( + feature = "host_env", any(target_os = "linux", target_os = "macos", target_os = "windows"), not(any(target_env = "musl", target_env = "sgx")) ))] @@ -92,23 +95,22 @@ pub fn builtin_module_defs(ctx: &Context) -> Vec<&'static PyModuleDef> { io::module_def(ctx), itertools::module_def(ctx), marshal::module_def(ctx), - #[cfg(windows)] + #[cfg(all(feature = "host_env", windows))] msvcrt::module_def(ctx), - #[cfg(windows)] + #[cfg(all(feature = "host_env", windows))] nt::module_def(ctx), operator::module_def(ctx), - #[cfg(any(unix, target_os = "wasi"))] + #[cfg(all(feature = "host_env", any(unix, target_os = "wasi")))] posix::module_def(ctx), - #[cfg(all( - any(not(target_arch = "wasm32"), target_os = "wasi"), - not(any(unix, windows)) - ))] + #[cfg(all(feature = "host_env", not(any(unix, windows, target_os = "wasi"))))] posix::module_def(ctx), #[cfg(all( + feature = "host_env", unix, not(any(target_os = "ios", target_os = "wasi", target_os = "redox")) ))] pwd::module_def(ctx), + #[cfg(feature = "host_env")] signal::module_def(ctx), sre::module_def(ctx), stat::module_def(ctx), @@ -123,9 +125,9 @@ pub fn builtin_module_defs(ctx: &Context) -> Vec<&'static PyModuleDef> { typing::module_def(ctx), warnings::module_def(ctx), weakref::module_def(ctx), - #[cfg(windows)] + #[cfg(all(feature = "host_env", windows))] winapi::module_def(ctx), - #[cfg(windows)] + #[cfg(all(feature = "host_env", windows))] winreg::module_def(ctx), ] } diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index 4a1affea986..6664bc7efb7 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -20,20 +20,6 @@ pub(crate) fn fs_metadata>( } } -#[cfg(unix)] -impl crate::convert::IntoPyException for nix::Error { - fn into_pyexception(self, vm: &VirtualMachine) -> crate::builtins::PyBaseExceptionRef { - io::Error::from(self).into_pyexception(vm) - } -} - -#[cfg(unix)] -impl crate::convert::IntoPyException for rustix::io::Errno { - fn into_pyexception(self, vm: &VirtualMachine) -> crate::builtins::PyBaseExceptionRef { - io::Error::from(self).into_pyexception(vm) - } -} - #[allow(dead_code)] #[derive(FromArgs, Default)] pub struct TargetIsDirectory { @@ -174,7 +160,6 @@ pub(super) mod _os { AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, builtins::{ PyBytesRef, PyGenericAlias, PyIntRef, PyStrRef, PyTuple, PyTupleRef, PyTypeRef, - ToOSErrorBuilder, }, common::{ crt_fd, @@ -183,7 +168,7 @@ pub(super) mod _os { suppress_iph, }, convert::{IntoPyException, ToPyObject}, - exceptions::OSErrorBuilder, + exceptions::{OSErrorBuilder, ToOSErrorBuilder}, function::{ArgBytesLike, ArgMemoryBuffer, FsPath, FuncArgs, OptionalArg}, ospath::{OsPath, OsPathOrFd, OutputMode, PathConverter}, protocol::PyIterReturn, diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 323d1193376..4c672af110b 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -1,5 +1,7 @@ use crate::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule, convert::ToPyObject}; +#[cfg(all(not(feature = "host_env"), feature = "stdio"))] +pub(crate) use sys::SandboxStdio; pub(crate) use sys::{DOC, MAXSIZE, RUST_MULTIARCH, UnraisableHookArgsData, module_def, multiarch}; #[pymodule(name = "_jit")] @@ -50,7 +52,7 @@ mod sys { use num_traits::ToPrimitive; use std::{ env::{self, VarError}, - io::{Read, Write}, + io::{IsTerminal, Read, Write}, }; #[cfg(windows)] @@ -91,6 +93,129 @@ mod sys { } } + /// Lightweight stdio wrapper for sandbox mode (no host_env). + /// Directly uses Rust's std::io for stdin/stdout/stderr without FileIO. + #[pyclass(no_attr, name = "_SandboxStdio", module = "sys")] + #[derive(Debug, PyPayload)] + pub struct SandboxStdio { + pub fd: i32, + pub name: String, + pub mode: String, + } + + #[pyclass] + impl SandboxStdio { + #[pymethod] + fn write(&self, s: PyStrRef, vm: &VirtualMachine) -> PyResult { + if self.fd == 0 { + return Err(vm.new_os_error("not writable".to_owned())); + } + let bytes = s.as_bytes(); + if self.fd == 2 { + std::io::stderr() + .write_all(bytes) + .map_err(|e| vm.new_os_error(e.to_string()))?; + } else { + std::io::stdout() + .write_all(bytes) + .map_err(|e| vm.new_os_error(e.to_string()))?; + } + Ok(bytes.len()) + } + + #[pymethod] + fn readline(&self, size: OptionalArg, vm: &VirtualMachine) -> PyResult { + if self.fd != 0 { + return Err(vm.new_os_error("not readable".to_owned())); + } + let size = size.unwrap_or(-1); + if size == 0 { + return Ok(String::new()); + } + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .map_err(|e| vm.new_os_error(e.to_string()))?; + if size > 0 { + line.truncate(size as usize); + } + Ok(line) + } + + #[pymethod] + fn flush(&self, vm: &VirtualMachine) -> PyResult<()> { + match self.fd { + 1 => { + std::io::stdout() + .flush() + .map_err(|e| vm.new_os_error(e.to_string()))?; + } + 2 => { + std::io::stderr() + .flush() + .map_err(|e| vm.new_os_error(e.to_string()))?; + } + _ => {} + } + Ok(()) + } + + #[pymethod] + fn fileno(&self) -> i32 { + self.fd + } + + #[pymethod] + fn isatty(&self) -> bool { + match self.fd { + 0 => std::io::stdin().is_terminal(), + 1 => std::io::stdout().is_terminal(), + 2 => std::io::stderr().is_terminal(), + _ => false, + } + } + + #[pymethod] + fn readable(&self) -> bool { + self.fd == 0 + } + + #[pymethod] + fn writable(&self) -> bool { + self.fd == 1 || self.fd == 2 + } + + #[pygetset] + fn closed(&self) -> bool { + false + } + + #[pygetset] + fn encoding(&self) -> String { + "utf-8".to_owned() + } + + #[pygetset] + fn errors(&self) -> String { + if self.fd == 2 { + "backslashreplace" + } else { + "strict" + } + .to_owned() + } + + #[pygetset(name = "name")] + fn name_prop(&self) -> String { + self.name.clone() + } + + #[pygetset(name = "mode")] + fn mode_prop(&self) -> String { + self.mode.clone() + } + } + #[pyattr(name = "_rustpython_debugbuild")] const RUSTPYTHON_DEBUGBUILD: bool = cfg!(debug_assertions); diff --git a/crates/vm/src/stdlib/thread.rs b/crates/vm/src/stdlib/thread.rs index f7d00787858..12a741f62f0 100644 --- a/crates/vm/src/stdlib/thread.rs +++ b/crates/vm/src/stdlib/thread.rs @@ -479,7 +479,7 @@ pub(crate) mod _thread { }); } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "host_env"))] #[pyfunction] fn interrupt_main(signum: OptionalArg, vm: &VirtualMachine) -> PyResult<()> { crate::signal::set_interrupt_ex(signum.unwrap_or(libc::SIGINT), vm) diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 92d03020586..59481e914e6 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -311,7 +311,7 @@ impl VirtualMachine { let mut essential_init = || -> PyResult { import::import_builtin(self, "_typing")?; - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "host_env"))] import::import_builtin(self, "_signal")?; #[cfg(any(feature = "parser", feature = "compiler"))] import::import_builtin(self, "_ast")?; @@ -320,11 +320,12 @@ impl VirtualMachine { let importlib = import::init_importlib_base(self)?; self.import_ascii_utf8_encodings()?; - #[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] { let io = import::import_builtin(self, "_io")?; - #[cfg(feature = "stdio")] - let make_stdio = |name, fd, write| { + + // Full stdio: FileIO → BufferedWriter → TextIOWrapper + #[cfg(feature = "host_env")] + let make_stdio = |name: &str, fd: i32, write: bool| { let buffered_stdio = self.state.config.settings.buffered_stdio; let unbuffered = write && !buffered_stdio; let buf = crate::stdlib::io::open( @@ -372,12 +373,28 @@ impl VirtualMachine { stdio.set_attr("mode", self.ctx.new_str(mode), self)?; Ok(stdio) }; + + // Sandbox stdio: lightweight wrapper using Rust's std::io directly + #[cfg(all(not(feature = "host_env"), feature = "stdio"))] + let make_stdio = |name: &str, fd: i32, write: bool| { + let mode = if write { "w" } else { "r" }; + let stdio = stdlib::sys::SandboxStdio { + fd, + name: format!("<{name}>"), + mode: mode.to_owned(), + } + .into_ref(&self.ctx); + Ok(stdio.into()) + }; + + // No stdio: set to None (embedding use case) #[cfg(not(feature = "stdio"))] - let make_stdio = - |_name, _fd, _write| Ok(crate::builtins::PyNone.into_pyobject(self)); + let make_stdio = |_name: &str, _fd: i32, _write: bool| { + Ok(crate::builtins::PyNone.into_pyobject(self)) + }; let set_stdio = |name, fd, write| { - let stdio = make_stdio(name, fd, write)?; + let stdio: PyObjectRef = make_stdio(name, fd, write)?; let dunder_name = self.ctx.intern_str(format!("__{name}__")); self.sys_module.set_attr( dunder_name, // e.g. __stdin__ @@ -401,6 +418,7 @@ impl VirtualMachine { let res = essential_init(); let importlib = self.expect_pyresult(res, "essential initialization failed"); + #[cfg(feature = "host_env")] if self.state.config.settings.allow_external_library && cfg!(feature = "rustpython-compiler") && let Err(e) = import::init_importlib_package(self, importlib) @@ -411,6 +429,9 @@ impl VirtualMachine { self.print_exception(e); } + #[cfg(not(feature = "host_env"))] + let _ = importlib; + let _expect_stdlib = cfg!(feature = "freeze-stdlib") || !self.state.config.paths.module_search_paths.is_empty(); diff --git a/crates/vm/src/vm/python_run.rs b/crates/vm/src/vm/python_run.rs index 1c712279f8b..e651b34cc50 100644 --- a/crates/vm/src/vm/python_run.rs +++ b/crates/vm/src/vm/python_run.rs @@ -1,71 +1,12 @@ //! Python code execution functions. use crate::{ - Py, PyResult, VirtualMachine, - builtins::{PyCode, PyDict}, + PyResult, VirtualMachine, compiler::{self}, scope::Scope, }; impl VirtualMachine { - /// _PyRun_AnyFileObject (internal) - /// - /// Execute a Python file. Currently always delegates to run_simple_file - /// (interactive mode is handled separately in shell.rs). - /// - /// Note: This is an internal function. Use `run_file` for the public interface. - #[doc(hidden)] - pub fn run_any_file(&self, scope: Scope, path: &str) -> PyResult<()> { - let path = if path.is_empty() { "???" } else { path }; - self.run_simple_file(scope, path) - } - - /// _PyRun_SimpleFileObject - /// - /// Execute a Python file with __main__ module setup. - /// Sets __file__ and __cached__ before execution, removes them after. - fn run_simple_file(&self, scope: Scope, path: &str) -> PyResult<()> { - self.with_simple_run(path, |module_dict| { - self.run_simple_file_inner(module_dict, scope, path) - }) - } - - fn run_simple_file_inner( - &self, - module_dict: &Py, - scope: Scope, - path: &str, - ) -> PyResult<()> { - let pyc = maybe_pyc_file(path); - if pyc { - // pyc file execution - set_main_loader(module_dict, path, "SourcelessFileLoader", self)?; - let loader = module_dict.get_item("__loader__", self)?; - let get_code = loader.get_attr("get_code", self)?; - let code_obj = get_code.call((identifier!(self, __main__).to_owned(),), self)?; - let code = code_obj - .downcast::() - .map_err(|_| self.new_runtime_error("Bad code object in .pyc file".to_owned()))?; - self.run_code_obj(code, scope)?; - } else { - if path != "" { - set_main_loader(module_dict, path, "SourceFileLoader", self)?; - } - match std::fs::read_to_string(path) { - Ok(source) => { - let code_obj = self - .compile(&source, compiler::Mode::Exec, path.to_owned()) - .map_err(|err| self.new_syntax_error(&err, Some(&source)))?; - self.run_code_obj(code_obj, scope)?; - } - Err(err) => { - return Err(self.new_os_error(err.to_string())); - } - } - } - Ok(()) - } - /// PyRun_SimpleString /// /// Execute a string of Python code in a new scope with builtins. @@ -89,11 +30,6 @@ impl VirtualMachine { self.run_string(scope, source, source_path) } - // #[deprecated(note = "use rustpython::run_file instead; if this changes causes problems, please report an issue.")] - pub fn run_script(&self, scope: Scope, path: &str) -> PyResult<()> { - self.run_any_file(scope, path) - } - pub fn run_block_expr(&self, scope: Scope, source: &str) -> PyResult { let code_obj = self .compile(source, compiler::Mode::BlockExpr, "".to_owned()) @@ -102,50 +38,125 @@ impl VirtualMachine { } } -fn set_main_loader( - module_dict: &Py, - filename: &str, - loader_name: &str, - vm: &VirtualMachine, -) -> PyResult<()> { - vm.import("importlib.machinery", 0)?; - let sys_modules = vm.sys_module.get_attr(identifier!(vm, modules), vm)?; - let machinery = sys_modules.get_item("importlib.machinery", vm)?; - let loader_name = vm.ctx.new_str(loader_name); - let loader_class = machinery.get_attr(&loader_name, vm)?; - let loader = loader_class.call((identifier!(vm, __main__).to_owned(), filename), vm)?; - module_dict.set_item("__loader__", loader, vm)?; - Ok(()) -} +#[cfg(feature = "host_env")] +mod file_run { + use crate::{ + Py, PyResult, VirtualMachine, + builtins::{PyCode, PyDict}, + compiler::{self}, + scope::Scope, + }; -/// Check whether a file is maybe a pyc file. -/// -/// Detection is performed by: -/// 1. Checking if the filename ends with ".pyc" -/// 2. If not, reading the first 2 bytes and comparing with the magic number -fn maybe_pyc_file(path: &str) -> bool { - if path.ends_with(".pyc") { - return true; - } - maybe_pyc_file_with_magic(path).unwrap_or(false) -} + impl VirtualMachine { + /// _PyRun_AnyFileObject (internal) + /// + /// Execute a Python file. Currently always delegates to run_simple_file + /// (interactive mode is handled separately in shell.rs). + /// + /// Note: This is an internal function. Use `run_file` for the public interface. + #[doc(hidden)] + pub fn run_any_file(&self, scope: Scope, path: &str) -> PyResult<()> { + let path = if path.is_empty() { "???" } else { path }; + self.run_simple_file(scope, path) + } + + /// _PyRun_SimpleFileObject + /// + /// Execute a Python file with __main__ module setup. + /// Sets __file__ and __cached__ before execution, removes them after. + fn run_simple_file(&self, scope: Scope, path: &str) -> PyResult<()> { + self.with_simple_run(path, |module_dict| { + self.run_simple_file_inner(module_dict, scope, path) + }) + } -fn maybe_pyc_file_with_magic(path: &str) -> std::io::Result { - let path_obj = std::path::Path::new(path); - if !path_obj.is_file() { - return Ok(false); + fn run_simple_file_inner( + &self, + module_dict: &Py, + scope: Scope, + path: &str, + ) -> PyResult<()> { + let pyc = maybe_pyc_file(path); + if pyc { + // pyc file execution + set_main_loader(module_dict, path, "SourcelessFileLoader", self)?; + let loader = module_dict.get_item("__loader__", self)?; + let get_code = loader.get_attr("get_code", self)?; + let code_obj = get_code.call((identifier!(self, __main__).to_owned(),), self)?; + let code = code_obj.downcast::().map_err(|_| { + self.new_runtime_error("Bad code object in .pyc file".to_owned()) + })?; + self.run_code_obj(code, scope)?; + } else { + if path != "" { + set_main_loader(module_dict, path, "SourceFileLoader", self)?; + } + match std::fs::read_to_string(path) { + Ok(source) => { + let code_obj = self + .compile(&source, compiler::Mode::Exec, path.to_owned()) + .map_err(|err| self.new_syntax_error(&err, Some(&source)))?; + self.run_code_obj(code_obj, scope)?; + } + Err(err) => { + return Err(self.new_os_error(err.to_string())); + } + } + } + Ok(()) + } + + // #[deprecated(note = "use rustpython::run_file instead; if this changes causes problems, please report an issue.")] + pub fn run_script(&self, scope: Scope, path: &str) -> PyResult<()> { + self.run_any_file(scope, path) + } } - let mut file = std::fs::File::open(path)?; - let mut buf = [0u8; 2]; + fn set_main_loader( + module_dict: &Py, + filename: &str, + loader_name: &str, + vm: &VirtualMachine, + ) -> PyResult<()> { + vm.import("importlib.machinery", 0)?; + let sys_modules = vm.sys_module.get_attr(identifier!(vm, modules), vm)?; + let machinery = sys_modules.get_item("importlib.machinery", vm)?; + let loader_name = vm.ctx.new_str(loader_name); + let loader_class = machinery.get_attr(&loader_name, vm)?; + let loader = loader_class.call((identifier!(vm, __main__).to_owned(), filename), vm)?; + module_dict.set_item("__loader__", loader, vm)?; + Ok(()) + } - use std::io::Read; - if file.read(&mut buf)? != 2 { - return Ok(false); + /// Check whether a file is maybe a pyc file. + /// + /// Detection is performed by: + /// 1. Checking if the filename ends with ".pyc" + /// 2. If not, reading the first 2 bytes and comparing with the magic number + fn maybe_pyc_file(path: &str) -> bool { + if path.ends_with(".pyc") { + return true; + } + maybe_pyc_file_with_magic(path).unwrap_or(false) } - // Read only two bytes of the magic. If the file was opened in - // text mode, the bytes 3 and 4 of the magic (\r\n) might not - // be read as they are on disk. - Ok(crate::import::check_pyc_magic_number_bytes(&buf)) + fn maybe_pyc_file_with_magic(path: &str) -> std::io::Result { + let path_obj = std::path::Path::new(path); + if !path_obj.is_file() { + return Ok(false); + } + + let mut file = std::fs::File::open(path)?; + let mut buf = [0u8; 2]; + + use std::io::Read; + if file.read(&mut buf)? != 2 { + return Ok(false); + } + + // Read only two bytes of the magic. If the file was opened in + // text mode, the bytes 3 and 4 of the magic (\r\n) might not + // be read as they are on disk. + Ok(crate::import::check_pyc_magic_number_bytes(&buf)) + } } diff --git a/extra_tests/snippets/sandbox_smoke.py b/extra_tests/snippets/sandbox_smoke.py new file mode 100644 index 00000000000..b977cdc5daa --- /dev/null +++ b/extra_tests/snippets/sandbox_smoke.py @@ -0,0 +1,55 @@ +"""Sandbox mode smoke test. + +Verifies basic functionality that works in both sandbox and normal mode: +- stdio (print, sys.stdout/stdin/stderr) +- builtin modules (math, json) +- in-memory IO (BytesIO, StringIO) +- open() is properly blocked when FileIO is unavailable (sandbox) +""" + +import sys +import math +import json +import _io + +SANDBOX = not hasattr(_io, "FileIO") + +# stdio +print("1. print works") +assert sys.stdout.writable() +assert sys.stderr.writable() +assert sys.stdin.readable() +assert sys.stdout.fileno() == 1 + +# math +assert math.pi > 3.14 +print("2. math works:", math.pi) + +# json +d = json.loads('{"a": 1}') +assert d == {"a": 1} +print("3. json works:", d) + +# BytesIO / StringIO +buf = _io.BytesIO(b"hello") +assert buf.read() == b"hello" +sio = _io.StringIO("world") +assert sio.read() == "world" +print("4. BytesIO/StringIO work") + +# open() behavior depends on mode +if SANDBOX: + try: + open("/tmp/x", "w") + assert False, "should have raised" + except _io.UnsupportedOperation: + print("5. open() properly blocked (sandbox)") +else: + print("5. open() available (host_env)") + +# builtins +assert list(range(5)) == [0, 1, 2, 3, 4] +assert sorted([3, 1, 2]) == [1, 2, 3] +print("6. builtins work") + +print("All smoke tests passed!", "(sandbox)" if SANDBOX else "(host_env)") diff --git a/src/lib.rs b/src/lib.rs index b73725a0fe2..60b66d83b3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -173,7 +173,20 @@ fn run_file(vm: &VirtualMachine, scope: Scope, path: &str) -> PyResult<()> { vm.insert_sys_path(vm.new_pyobj(dir))?; } - vm.run_any_file(scope, path) + #[cfg(feature = "host_env")] + { + vm.run_any_file(scope, path) + } + #[cfg(not(feature = "host_env"))] + { + // In sandbox mode, the binary reads the file and feeds source to the VM. + // The VM itself has no filesystem access. + let path = if path.is_empty() { "???" } else { path }; + match std::fs::read_to_string(path) { + Ok(source) => vm.run_string(scope, &source, path.to_owned()).map(drop), + Err(err) => Err(vm.new_os_error(err.to_string())), + } + } } fn get_importer(path: &str, vm: &VirtualMachine) -> PyResult> { @@ -366,11 +379,14 @@ mod tests { vm.unwrap_pyresult((|| { let scope = vm.new_scope_with_main()?; // test file run - vm.run_any_file(scope, "extra_tests/snippets/dir_main/__main__.py")?; - - let scope = vm.new_scope_with_main()?; - // test module run (directory with __main__.py) - run_file(vm, scope, "extra_tests/snippets/dir_main")?; + run_file(vm, scope, "extra_tests/snippets/dir_main/__main__.py")?; + + #[cfg(feature = "host_env")] + { + let scope = vm.new_scope_with_main()?; + // test module run (directory with __main__.py) + run_file(vm, scope, "extra_tests/snippets/dir_main")?; + } Ok(()) })()); From aae539a2bbb8c5fa43f9a9a176307b34120bccd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Feb 2026 12:52:27 +0000 Subject: [PATCH 2/2] Auto-format: ruff check --select I --fix --- extra_tests/snippets/sandbox_smoke.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extra_tests/snippets/sandbox_smoke.py b/extra_tests/snippets/sandbox_smoke.py index b977cdc5daa..13fa0722742 100644 --- a/extra_tests/snippets/sandbox_smoke.py +++ b/extra_tests/snippets/sandbox_smoke.py @@ -7,10 +7,10 @@ - open() is properly blocked when FileIO is unavailable (sandbox) """ -import sys -import math -import json import _io +import json +import math +import sys SANDBOX = not hasattr(_io, "FileIO")