diff --git a/Cargo.lock b/Cargo.lock index 6d24ad6..d6014f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,7 @@ dependencies = [ "binarrow-host-api", "binarrow-linux-runtime", "binarrow-loader", + "binarrow-memory-fs", "binarrow-runtime-core", "wasm-bindgen", ] @@ -58,6 +59,7 @@ dependencies = [ "binarrow-host-api", "binarrow-linux-runtime", "binarrow-loader", + "binarrow-memory-fs", "binarrow-runtime-core", "getrandom", ] @@ -94,6 +96,7 @@ dependencies = [ "binarrow-host-api", "binarrow-linux-abi", "binarrow-loader", + "binarrow-memory-fs", "binarrow-runtime-core", ] @@ -107,6 +110,13 @@ dependencies = [ "binarrow-runtime-core", ] +[[package]] +name = "binarrow-memory-fs" +version = "0.1.0" +dependencies = [ + "binarrow-host-api", +] + [[package]] name = "binarrow-runtime-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 310c353..8fff1ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/linux-abi", "crates/linux-runtime", "crates/loader", + "crates/memory-fs", "crates/runtime-core", "crates/wasm-probe", "tools/wasm-bindgen", diff --git a/PLAN.md b/PLAN.md index c9367fe..7eaa285 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,7 +1,7 @@ # AArch64 ELF-to-WebAssembly Browser Runtime ## Engineering Build Plan and Agent Handoff -**Status:** Phase 1 complete; a static AArch64 Rust `std` ELF executes in native and browser Workers +**Status:** Phase 2 complete; controlled C/Rust/filesystem guests execute in native and browser Workers **Primary implementation language:** Rust **Initial browser target:** Google Chrome **Guest architecture:** AArch64, little-endian, Linux userspace @@ -1855,6 +1855,8 @@ The file-free Rust checkpoint is complete: `guest-tests/rust-hello` is an ordina The Phase 2 control checkpoint is complete: the Worker accepts explicit C, Rust, and infinite-loop runs with browser-selected instruction, syscall, output, and committed-memory limits. Execution traps are returned as stable diagnostic codes with partial counters and trace state. The browser can unconditionally stop an infinite guest by terminating the active Worker and creates a fresh ready Worker afterward. The remaining Phase 2 implementation item is the initial in-memory filesystem and its shared native/browser guest regression. +Phase 2 is complete. `binarrow-memory-fs` supplies a bounded ephemeral `/tmp` regular-file store behind the project host filesystem trait, while `binarrow-linux-runtime` owns guest descriptors and the observed `openat`, `close`, `lseek`, `read`, and file `write` ABI. A freestanding C fixture creates, writes, seeks, reads, and closes `/tmp/roundtrip.txt` before printing the recovered bytes; native, CLI, and Chromium tests all run the identical ELF. ADR-0002 records the interpreter's interim browser memory design: project-owned sparse 64-bit guest mappings backed by bounded Wasm32 allocations, with Memory64 retained as a required feature gate for the future translator. Every Phase 2 deliverable and acceptance criterion now has an automated regression. Phase 3 begins with a mountable VFS, directory and metadata syscalls, OPFS persistence, terminal input, clock/random services, and the suspension model needed by CPython. + Do not begin the full web IDE before item 30 passes. --- diff --git a/README.md b/README.md index 90068a9..d0fbf02 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The project has completed the Phase 0 feasibility stage described in [PLAN.md](P - `crates/linux-abi`: minimal AArch64 Linux syscall numbers and errno return encoding. - `crates/host-api`: browser-independent terminal service traits. - `crates/linux-runtime`: bounded process, signal, memory, terminal, and exit syscall dispatch plus the static-process execution loop. +- `crates/memory-fs`: bounded ephemeral files and offsets behind the host filesystem trait. - `crates/browser-runtime`: wasm-bindgen bridge that runs the same static process inside a Worker. - `crates/cli`: the native `binarrow inspect ` and `binarrow run ` commands. - `crates/wasm-probe`: generation of the Memory64 WebAssembly module used by browser tests. @@ -59,13 +60,13 @@ Run an ELF through the native interpreter and propagate its guest exit status: cargo run -p binarrow-cli -- run path/to/aarch64-static.elf [guest arguments...] ``` -The runnable instruction/syscall profile remains fixture-driven. The current static Rust program adds single-thread atomics and barriers, 128-bit vector moves/stores, byte popcount and reduction, multiplication/division, and signed shifts to the earlier libc instruction path. The runtime implements `ppoll`, `set_tid_address`, one-CPU `sched_getaffinity`, `sigaltstack`, `rt_sigaction`, `rt_sigprocmask`, `mmap`, `mprotect`, `munmap`, terminal `write`, `exit`, and `exit_group`. Invalid guest arguments return Linux errno values; instruction, syscall, committed-memory, and combined-output limits are enforced before host side effects. +The runnable instruction/syscall profile remains fixture-driven. The current static Rust program adds single-thread atomics and barriers, 128-bit vector moves/stores, byte popcount and reduction, multiplication/division, and signed shifts to the earlier libc instruction path. The runtime implements `openat`, `close`, `lseek`, file `read`/`write`, polling, deterministic process/signal setup, anonymous memory management, terminal output, and exit. Invalid guest arguments return Linux errno values; instruction, syscall, committed-memory, ephemeral-filesystem, open-file, and combined-output limits are enforced before host side effects. The browser build generates its Memory64, JSPI, and P-code `.wasm` probes before starting Vite. Generated artifacts are not committed. ## Scope -The Phase 2 browser controller can start the checked-in freestanding C, musl C, Rust `std`, and infinite-loop fixtures with explicit instruction, syscall, output, and committed-memory limits. Execution errors return stable diagnostic codes instead of rejected JavaScript calls. The Stop action terminates the active Worker, so even a guest that never reaches a syscall or yield point can be interrupted and the runtime restarted. Chromium verifies the C/Rust outputs, deterministic counters, resource-limit diagnostic, and manual infinite-loop termination. The next Phase 2 checkpoint is the initial in-memory filesystem and guest file syscall path. See [PLAN.md](PLAN.md) for the roadmap, [the Rust fixture documentation](guest-tests/rust-hello/README.md) for reproduction details, and [docs/architecture.md](docs/architecture.md) for the current boundaries. +The Phase 2 browser controller can start the checked-in freestanding C, musl C, Rust `std`, filesystem, and infinite-loop fixtures with explicit instruction, syscall, output, committed-memory, and filesystem limits. Execution errors return stable diagnostic codes instead of rejected JavaScript calls. The Stop action terminates the active Worker, so even a guest that never reaches a syscall or yield point can be interrupted and the runtime restarted. Chromium verifies the C/Rust outputs, in-memory file round trip, deterministic counters, resource-limit diagnostic, and manual infinite-loop termination. The interpreter's interim browser memory design is recorded in [ADR-0002](docs/decisions/0002-use-sparse-memory-for-browser-interpreter.md). See [PLAN.md](PLAN.md) for the roadmap and [docs/architecture.md](docs/architecture.md) for the current boundaries. ## License diff --git a/crates/browser-runtime/Cargo.toml b/crates/browser-runtime/Cargo.toml index 53cd13b..0a5c185 100644 --- a/crates/browser-runtime/Cargo.toml +++ b/crates/browser-runtime/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true binarrow-host-api = { path = "../host-api", version = "0.1.0" } binarrow-linux-runtime = { path = "../linux-runtime", version = "0.1.0" } binarrow-loader = { path = "../loader", version = "0.1.0" } +binarrow-memory-fs = { path = "../memory-fs", version = "0.1.0" } binarrow-runtime-core = { path = "../runtime-core", version = "0.1.0" } wasm-bindgen.workspace = true diff --git a/crates/browser-runtime/src/lib.rs b/crates/browser-runtime/src/lib.rs index 767ac7e..53a22dd 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -5,6 +5,7 @@ use core::convert::Infallible; use binarrow_host_api::{HostTerminal, TerminalStream}; use binarrow_linux_runtime::{ExecutionError, Process}; use binarrow_loader::{Credentials, ProcessConfig, ProcessParameters, load_process}; +use binarrow_memory_fs::MemoryFileSystem; use binarrow_runtime_core::{MemoryAccess, ResourceLimit, Trap}; use wasm_bindgen::prelude::*; @@ -16,6 +17,8 @@ const RUST_HELLO_ELF: &[u8] = include_bytes!("../../../guest-tests/rust-hello/rust-hello.aarch64.elf"); const INFINITE_LOOP_ELF: &[u8] = include_bytes!("../../../guest-tests/infinite-loop/infinite-loop.aarch64.elf"); +const FILE_ROUNDTRIP_ELF: &[u8] = + include_bytes!("../../../guest-tests/file-roundtrip/file-roundtrip.aarch64.elf"); /// Browser-safe result returned after a guest exits or stops diagnostically. #[wasm_bindgen] @@ -104,6 +107,7 @@ pub fn run_fixture( syscall_budget: u64, max_output_bytes: u64, max_memory_bytes: u64, + max_filesystem_bytes: u64, ) -> BrowserExecution { execute_fixture( fixture_name, @@ -111,6 +115,7 @@ pub fn run_fixture( syscall_budget, max_output_bytes, max_memory_bytes, + max_filesystem_bytes, ) } @@ -120,6 +125,7 @@ fn execute_fixture( syscall_budget: u64, max_output_bytes: u64, max_memory_bytes: u64, + max_filesystem_bytes: u64, ) -> BrowserExecution { let Some((elf, argv0)) = fixture(fixture_name) else { return BrowserExecution::diagnostic( @@ -132,6 +138,7 @@ fn execute_fixture( config.limits.syscall_budget = syscall_budget; config.limits.max_output_bytes = max_output_bytes; config.limits.max_memory_bytes = max_memory_bytes; + config.limits.max_filesystem_bytes = max_filesystem_bytes; let image = match load_process( elf, &ProcessParameters { @@ -154,7 +161,8 @@ fn execute_fixture( } }; let mut terminal = CapturedTerminal::default(); - let execution = process.run(&mut terminal); + let mut filesystem = MemoryFileSystem::new(max_filesystem_bytes); + let execution = process.run_with_filesystem(&mut terminal, &mut filesystem); let trace = process .trace() .iter() @@ -211,6 +219,7 @@ fn fixture(name: &str) -> Option<(&'static [u8], &'static [u8])> { "libc-c" => Some((LIBC_HELLO_ELF, b"/libc-hello")), "rust" => Some((RUST_HELLO_ELF, b"/rust-hello")), "infinite-loop" => Some((INFINITE_LOOP_ELF, b"/infinite-loop")), + "file-roundtrip" => Some((FILE_ROUNDTRIP_ELF, b"/file-roundtrip")), _ => None, } } @@ -286,6 +295,7 @@ mod tests { const DEFAULT_SYSCALLS: u64 = 100_000; const DEFAULT_OUTPUT: u64 = 4 * 1024 * 1024; const DEFAULT_MEMORY: u64 = 256 * 1024 * 1024; + const DEFAULT_FILESYSTEM: u64 = 16 * 1024 * 1024; #[test] fn executes_the_c_and_rust_browser_fixtures_on_the_native_test_host() { @@ -300,6 +310,7 @@ mod tests { DEFAULT_SYSCALLS, DEFAULT_OUTPUT, DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, ); assert_eq!(result.outcome, "exited"); @@ -321,6 +332,7 @@ mod tests { DEFAULT_SYSCALLS, DEFAULT_OUTPUT, DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, ); assert_eq!(result.outcome, "diagnostic"); @@ -333,4 +345,22 @@ mod tests { assert_eq!(result.executed_instructions, 128); assert_eq!(result.dispatched_syscalls, 0); } + + #[test] + fn executes_the_file_roundtrip_fixture() { + let result = execute_fixture( + "file-roundtrip", + DEFAULT_INSTRUCTIONS, + DEFAULT_SYSCALLS, + DEFAULT_OUTPUT, + DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, + ); + + assert_eq!(result.outcome, "exited"); + assert_eq!(result.stdout, "filesystem hello\n"); + assert_eq!(result.exit_code, 0); + assert_eq!(result.executed_instructions, 54); + assert_eq!(result.dispatched_syscalls, 7); + } } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index cbdbcf6..0869e7a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -12,6 +12,7 @@ binarrow-elf = { path = "../elf", version = "0.1.0" } binarrow-host-api = { path = "../host-api", version = "0.1.0" } binarrow-linux-runtime = { path = "../linux-runtime", version = "0.1.0" } binarrow-loader = { path = "../loader", version = "0.1.0" } +binarrow-memory-fs = { path = "../memory-fs", version = "0.1.0" } getrandom = { workspace = true, features = ["wasm_js"] } [dev-dependencies] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b086720..4b762f3 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -10,6 +10,7 @@ use std::{ use binarrow_host_api::{HostTerminal, TerminalStream}; use binarrow_linux_runtime::Process; use binarrow_loader::{Credentials, ProcessConfig, ProcessParameters, load_process}; +use binarrow_memory_fs::MemoryFileSystem; const USAGE: &str = "usage: binarrow inspect \n binarrow run [guest arguments...]"; @@ -63,6 +64,8 @@ fn run_guest(path: &PathBuf, arguments: impl Iterator) -> Resul .map_err(|error| format!("could not initialize guest randomness: {error}"))?; let mut argv = vec![path.to_string_lossy().as_bytes().to_vec()]; argv.extend(arguments.map(|argument| argument.to_string_lossy().as_bytes().to_vec())); + let config = ProcessConfig::default(); + let mut filesystem = MemoryFileSystem::new(config.limits.max_filesystem_bytes); let image = load_process( &bytes, &ProcessParameters { @@ -71,13 +74,13 @@ fn run_guest(path: &PathBuf, arguments: impl Iterator) -> Resul random_bytes, credentials: Credentials::default(), }, - ProcessConfig::default(), + config, ) .map_err(|error| format!("{}: {error}", path.display()))?; let mut process = Process::new(image).map_err(|error| error.to_string())?; let mut terminal = NativeTerminal; let result = process - .run(&mut terminal) + .run_with_filesystem(&mut terminal, &mut filesystem) .map_err(|error| error.to_string())?; Ok(result.exit_code) } diff --git a/crates/cli/tests/run.rs b/crates/cli/tests/run.rs index 13b4833..7a212a9 100644 --- a/crates/cli/tests/run.rs +++ b/crates/cli/tests/run.rs @@ -31,6 +31,21 @@ fn run_command_propagates_the_guest_exit_code() { assert!(output.stderr.is_empty()); } +#[test] +fn run_command_supplies_the_ephemeral_filesystem() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../guest-tests/file-roundtrip/file-roundtrip.aarch64.elf"); + let output = Command::new(env!("CARGO_BIN_EXE_binarrow")) + .arg("run") + .arg(fixture) + .output() + .expect("binarrow should start"); + + assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, b"filesystem hello\n"); + assert!(output.stderr.is_empty()); +} + struct TempFixture { path: std::path::PathBuf, } diff --git a/crates/host-api/src/lib.rs b/crates/host-api/src/lib.rs index 1580e16..93b0478 100644 --- a/crates/host-api/src/lib.rs +++ b/crates/host-api/src/lib.rs @@ -22,3 +22,150 @@ pub trait HostTerminal { /// Returns the host-specific error when the bytes cannot be delivered. fn write(&mut self, stream: TerminalStream, bytes: &[u8]) -> Result<(), Self::Error>; } + +/// Access requested for an opened host-backed file. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileAccess { + ReadOnly, + WriteOnly, + ReadWrite, +} + +impl FileAccess { + #[must_use] + pub const fn can_read(self) -> bool { + matches!(self, Self::ReadOnly | Self::ReadWrite) + } + + #[must_use] + pub const fn can_write(self) -> bool { + matches!(self, Self::WriteOnly | Self::ReadWrite) + } +} + +/// Host-independent creation and offset flags decoded from Linux `openat`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FileOpenFlags(u8); + +impl FileOpenFlags { + pub const NONE: Self = Self(0); + pub const CREATE: Self = Self(1 << 0); + pub const EXCLUSIVE: Self = Self(1 << 1); + pub const TRUNCATE: Self = Self(1 << 2); + pub const APPEND: Self = Self(1 << 3); + + #[must_use] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + #[must_use] + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + +/// Host-independent options decoded from Linux `openat` flags. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FileOpenOptions { + pub access: FileAccess, + pub flags: FileOpenFlags, +} + +/// Origin used for a host-independent file seek. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileSeekFrom { + Start, + Current, + End, +} + +/// Filesystem failures that map deterministically onto Linux errno values. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileSystemError { + NotFound, + AlreadyExists, + NotDirectory, + IsDirectory, + BadDescriptor, + PermissionDenied, + InvalidInput, + NoSpace, + Unsupported, +} + +/// Synchronous filesystem service supplied to the Linux runtime. +pub trait HostFileSystem { + /// Open a normalized absolute byte path and return an opaque host handle. + /// + /// # Errors + /// + /// Returns a stable filesystem error when the path or options cannot be + /// satisfied. + fn open(&mut self, path: &[u8], options: FileOpenOptions) -> Result; + + /// Read from an opaque handle at its current offset. + /// + /// # Errors + /// + /// Returns a stable filesystem error for invalid handles or access. + fn read(&mut self, handle: u64, destination: &mut [u8]) -> Result; + + /// Write to an opaque handle at its current offset. + /// + /// # Errors + /// + /// Returns a stable filesystem error for invalid handles, access, or + /// capacity exhaustion. + fn write(&mut self, handle: u64, source: &[u8]) -> Result; + + /// Change the current offset and return its resulting absolute value. + /// + /// # Errors + /// + /// Returns a stable filesystem error for invalid handles or offsets. + fn seek( + &mut self, + handle: u64, + offset: i64, + from: FileSeekFrom, + ) -> Result; + + /// Close one opaque handle. + /// + /// # Errors + /// + /// Returns [`FileSystemError::BadDescriptor`] when the handle is unknown. + fn close(&mut self, handle: u64) -> Result<(), FileSystemError>; +} + +/// Filesystem used by callers that intentionally provide no file service. +#[derive(Default)] +pub struct NullFileSystem; + +impl HostFileSystem for NullFileSystem { + fn open(&mut self, _path: &[u8], _options: FileOpenOptions) -> Result { + Err(FileSystemError::Unsupported) + } + + fn read(&mut self, _handle: u64, _destination: &mut [u8]) -> Result { + Err(FileSystemError::BadDescriptor) + } + + fn write(&mut self, _handle: u64, _source: &[u8]) -> Result { + Err(FileSystemError::BadDescriptor) + } + + fn seek( + &mut self, + _handle: u64, + _offset: i64, + _from: FileSeekFrom, + ) -> Result { + Err(FileSystemError::BadDescriptor) + } + + fn close(&mut self, _handle: u64) -> Result<(), FileSystemError> { + Err(FileSystemError::BadDescriptor) + } +} diff --git a/crates/linux-abi/src/lib.rs b/crates/linux-abi/src/lib.rs index f66a49a..bacb367 100644 --- a/crates/linux-abi/src/lib.rs +++ b/crates/linux-abi/src/lib.rs @@ -4,6 +4,10 @@ #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u64)] pub enum Syscall { + Openat = 56, + Close = 57, + Lseek = 62, + Read = 63, Write = 64, Ppoll = 73, Exit = 93, @@ -23,6 +27,10 @@ impl Syscall { #[must_use] pub const fn from_number(number: u64) -> Option { match number { + 56 => Some(Self::Openat), + 57 => Some(Self::Close), + 62 => Some(Self::Lseek), + 63 => Some(Self::Read), 73 => Some(Self::Ppoll), 64 => Some(Self::Write), 93 => Some(Self::Exit), @@ -50,10 +58,17 @@ impl Syscall { #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(i32)] pub enum Errno { + NoEntry = 2, BadFileDescriptor = 9, OutOfMemory = 12, + PermissionDenied = 13, Fault = 14, + AlreadyExists = 17, + NotDirectory = 20, + IsDirectory = 21, InvalidArgument = 22, + TooManyOpenFiles = 24, + NoSpace = 28, NoSystemCall = 38, } @@ -71,6 +86,10 @@ mod tests { #[test] fn decodes_the_minimal_aarch64_table() { + assert_eq!(Syscall::from_number(56), Some(Syscall::Openat)); + assert_eq!(Syscall::from_number(57), Some(Syscall::Close)); + assert_eq!(Syscall::from_number(62), Some(Syscall::Lseek)); + assert_eq!(Syscall::from_number(63), Some(Syscall::Read)); assert_eq!(Syscall::from_number(64), Some(Syscall::Write)); assert_eq!(Syscall::from_number(73), Some(Syscall::Ppoll)); assert_eq!(Syscall::from_number(93), Some(Syscall::Exit)); @@ -83,7 +102,7 @@ mod tests { assert_eq!(Syscall::from_number(215), Some(Syscall::Munmap)); assert_eq!(Syscall::from_number(222), Some(Syscall::Mmap)); assert_eq!(Syscall::from_number(226), Some(Syscall::Mprotect)); - assert_eq!(Syscall::from_number(63), None); + assert_eq!(Syscall::from_number(55), None); } #[test] diff --git a/crates/linux-runtime/Cargo.toml b/crates/linux-runtime/Cargo.toml index f6d9bea..87389b7 100644 --- a/crates/linux-runtime/Cargo.toml +++ b/crates/linux-runtime/Cargo.toml @@ -15,5 +15,8 @@ binarrow-linux-abi = { path = "../linux-abi", version = "0.1.0" } binarrow-loader = { path = "../loader", version = "0.1.0" } binarrow-runtime-core = { path = "../runtime-core", version = "0.1.0" } +[dev-dependencies] +binarrow-memory-fs = { path = "../memory-fs", version = "0.1.0" } + [lints] workspace = true diff --git a/crates/linux-runtime/src/lib.rs b/crates/linux-runtime/src/lib.rs index d273a59..bf60e4b 100644 --- a/crates/linux-runtime/src/lib.rs +++ b/crates/linux-runtime/src/lib.rs @@ -1,10 +1,14 @@ //! Bounded Linux syscall dispatch for a loaded `AArch64` process. use core::fmt; +use std::collections::BTreeMap; use binarrow_aarch64::{Aarch64State, Interpreter, InterpreterInitializationError}; use binarrow_guest_memory::{AddressSpace, Permissions, RegionKind}; -use binarrow_host_api::{HostTerminal, TerminalStream}; +use binarrow_host_api::{ + FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, HostFileSystem, + HostTerminal, NullFileSystem, TerminalStream, +}; use binarrow_linux_abi::{Errno, Syscall}; use binarrow_loader::ProcessImage; use binarrow_runtime_core::{GuestAddress, ResourceLimit, ResourceLimits, Trap}; @@ -12,6 +16,16 @@ use binarrow_runtime_core::{GuestAddress, ResourceLimit, ResourceLimits, Trap}; const STANDARD_OUTPUT: u64 = 1; const STANDARD_ERROR: u64 = 2; const MAIN_THREAD_ID: u64 = 1; +const FIRST_FILE_DESCRIPTOR: u32 = 3; +const AT_FDCWD: u64 = (-100_i64).cast_unsigned(); +const MAX_PATH_BYTES: usize = 4096; +const OPEN_ACCESS_MASK: u64 = 3; +const OPEN_CREATE: u64 = 0x40; +const OPEN_EXCLUSIVE: u64 = 0x80; +const OPEN_TRUNCATE: u64 = 0x200; +const OPEN_APPEND: u64 = 0x400; +const SUPPORTED_OPEN_FLAGS: u64 = + OPEN_ACCESS_MASK | OPEN_CREATE | OPEN_EXCLUSIVE | OPEN_TRUNCATE | OPEN_APPEND; const LINUX_SIGNAL_COUNT: usize = 64; const KERNEL_SIGACTION_SIZE: usize = 32; const KERNEL_SIGNAL_SET_SIZE: u64 = 8; @@ -77,6 +91,27 @@ pub struct SyscallEvent { impl fmt::Display for SyscallEvent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match Syscall::from_number(self.number) { + Some(Syscall::Openat) => write!( + formatter, + "openat(dirfd={}, path={:#x}, flags={:#x}, mode={:#o})", + self.arguments[0].cast_signed(), + self.arguments[1], + self.arguments[2], + self.arguments[3], + )?, + Some(Syscall::Close) => write!(formatter, "close(fd={})", self.arguments[0])?, + Some(Syscall::Lseek) => write!( + formatter, + "lseek(fd={}, offset={}, whence={})", + self.arguments[0], + self.arguments[1].cast_signed(), + self.arguments[2], + )?, + Some(Syscall::Read) => write!( + formatter, + "read(fd={}, buf={:#x}, count={})", + self.arguments[0], self.arguments[1], self.arguments[2], + )?, Some(Syscall::Ppoll) => write!( formatter, "ppoll(fds={:#x}, nfds={}, timeout={:#x}, sigmask={:#x}, sigsetsize={})", @@ -190,6 +225,7 @@ pub struct Process { signal_stack: SignalStack, signal_mask: u64, next_mmap_address: GuestAddress, + file_descriptors: BTreeMap, trace: Vec, } @@ -214,6 +250,7 @@ impl Process { signal_stack: SignalStack::default(), signal_mask: 0, next_mmap_address: GuestAddress::new(MMAP_ARENA_START), + file_descriptors: BTreeMap::new(), trace: Vec::new(), }) } @@ -261,6 +298,19 @@ impl Process { pub fn run( &mut self, terminal: &mut T, + ) -> Result> { + self.run_with_filesystem(terminal, &mut NullFileSystem) + } + + /// Run with an explicit synchronous filesystem service. + /// + /// # Errors + /// + /// Returns the same structured execution failures as [`Self::run`]. + pub fn run_with_filesystem( + &mut self, + terminal: &mut T, + filesystem: &mut F, ) -> Result> { loop { let remaining = self @@ -297,15 +347,16 @@ impl Process { let number = stop.supervisor_call.syscall_number; let arguments = self.syscall_arguments(); - if let Some(result) = self.dispatch_syscall(terminal, number, arguments)? { + if let Some(result) = self.dispatch_syscall(terminal, filesystem, number, arguments)? { return Ok(result); } } } - fn dispatch_syscall( + fn dispatch_syscall( &mut self, terminal: &mut T, + filesystem: &mut F, number: u64, arguments: [u64; 6], ) -> Result, ExecutionError> { @@ -325,8 +376,12 @@ impl Process { output_bytes: self.output_bytes, })); } + Some(Syscall::Openat) => self.dispatch_openat(filesystem), + Some(Syscall::Close) => self.dispatch_close(filesystem), + Some(Syscall::Lseek) => self.dispatch_lseek(filesystem), + Some(Syscall::Read) => self.dispatch_read(filesystem), Some(Syscall::Ppoll) => self.set_return(0), - Some(Syscall::Write) => self.dispatch_write(terminal)?, + Some(Syscall::Write) => self.dispatch_write(terminal, filesystem)?, Some(Syscall::SetTidAddress) => { self.clear_child_tid = Some(GuestAddress::new(self.register(0))); self.set_return(MAIN_THREAD_ID); @@ -348,17 +403,20 @@ impl Process { Ok(None) } - fn dispatch_write( + fn dispatch_write( &mut self, terminal: &mut T, + filesystem: &mut F, ) -> Result<(), ExecutionError> { - let stream = match self.register(0) { - STANDARD_OUTPUT => TerminalStream::StandardOutput, - STANDARD_ERROR => TerminalStream::StandardError, - _ => { - self.set_return(Errno::BadFileDescriptor.return_value()); - return Ok(()); - } + let file_descriptor = self.register(0); + if !matches!(file_descriptor, STANDARD_OUTPUT | STANDARD_ERROR) { + self.dispatch_file_write(filesystem); + return Ok(()); + } + let stream = if file_descriptor == STANDARD_OUTPUT { + TerminalStream::StandardOutput + } else { + TerminalStream::StandardError }; let address = GuestAddress::new(self.register(1)); let count = self.register(2); @@ -386,6 +444,207 @@ impl Process { Ok(()) } + fn dispatch_openat(&mut self, filesystem: &mut F) { + let flags = self.register(2); + let access = match flags & OPEN_ACCESS_MASK { + 0 => FileAccess::ReadOnly, + 1 => FileAccess::WriteOnly, + 2 => FileAccess::ReadWrite, + _ => { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + }; + if flags & !SUPPORTED_OPEN_FLAGS != 0 { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let path = match self.read_guest_path(GuestAddress::new(self.register(1))) { + Ok(path) => path, + Err(errno) => { + self.set_return(errno.return_value()); + return; + } + }; + if path.first() != Some(&b'/') || self.register(0) != AT_FDCWD { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let Some(file_descriptor) = self.allocate_file_descriptor() else { + self.set_return(Errno::TooManyOpenFiles.return_value()); + return; + }; + let mut open_flags = FileOpenFlags::NONE; + for (linux_flag, host_flag) in [ + (OPEN_CREATE, FileOpenFlags::CREATE), + (OPEN_EXCLUSIVE, FileOpenFlags::EXCLUSIVE), + (OPEN_TRUNCATE, FileOpenFlags::TRUNCATE), + (OPEN_APPEND, FileOpenFlags::APPEND), + ] { + if flags & linux_flag != 0 { + open_flags = open_flags.union(host_flag); + } + } + match filesystem.open( + &path, + FileOpenOptions { + access, + flags: open_flags, + }, + ) { + Ok(handle) => { + self.file_descriptors.insert(file_descriptor, handle); + self.set_return(u64::from(file_descriptor)); + } + Err(error) => self.set_return(filesystem_error_return(error)), + } + } + + fn dispatch_close(&mut self, filesystem: &mut F) { + let Ok(file_descriptor) = u32::try_from(self.register(0)) else { + self.set_return(Errno::BadFileDescriptor.return_value()); + return; + }; + let Some(handle) = self.file_descriptors.get(&file_descriptor).copied() else { + self.set_return(Errno::BadFileDescriptor.return_value()); + return; + }; + match filesystem.close(handle) { + Ok(()) => { + self.file_descriptors.remove(&file_descriptor); + self.set_return(0); + } + Err(error) => self.set_return(filesystem_error_return(error)), + } + } + + fn dispatch_lseek(&mut self, filesystem: &mut F) { + let Some(handle) = self.file_handle(self.register(0)) else { + self.set_return(Errno::BadFileDescriptor.return_value()); + return; + }; + let from = match self.register(2) { + 0 => FileSeekFrom::Start, + 1 => FileSeekFrom::Current, + 2 => FileSeekFrom::End, + _ => { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + }; + match filesystem.seek(handle, self.register(1).cast_signed(), from) { + Ok(position) => self.set_return(position), + Err(error) => self.set_return(filesystem_error_return(error)), + } + } + + fn dispatch_read(&mut self, filesystem: &mut F) { + let Some(handle) = self.file_handle(self.register(0)) else { + self.set_return(Errno::BadFileDescriptor.return_value()); + return; + }; + let count = self.register(2); + let Ok(host_count) = usize::try_from(count) else { + self.set_return(Errno::InvalidArgument.return_value()); + return; + }; + if count > self.limits.max_memory_bytes { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let position = match filesystem.seek(handle, 0, FileSeekFrom::Current) { + Ok(position) => position, + Err(error) => { + self.set_return(filesystem_error_return(error)); + return; + } + }; + let mut bytes = vec![0; host_count]; + let read = match filesystem.read(handle, &mut bytes) { + Ok(read) => read, + Err(error) => { + self.set_return(filesystem_error_return(error)); + return; + } + }; + if self + .memory + .write(GuestAddress::new(self.register(1)), &bytes[..read]) + .is_err() + { + if let Ok(position) = i64::try_from(position) { + let _ = filesystem.seek(handle, position, FileSeekFrom::Start); + } + self.set_return(Errno::Fault.return_value()); + return; + } + self.set_return(read as u64); + } + + fn dispatch_file_write(&mut self, filesystem: &mut F) { + let Some(handle) = self.file_handle(self.register(0)) else { + self.set_return(Errno::BadFileDescriptor.return_value()); + return; + }; + let count = self.register(2); + let Ok(host_count) = usize::try_from(count) else { + self.set_return(Errno::InvalidArgument.return_value()); + return; + }; + if count > self.limits.max_memory_bytes { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let mut bytes = vec![0; host_count]; + if self + .memory + .read_exact(GuestAddress::new(self.register(1)), &mut bytes) + .is_err() + { + self.set_return(Errno::Fault.return_value()); + return; + } + match filesystem.write(handle, &bytes) { + Ok(written) => self.set_return(written as u64), + Err(error) => self.set_return(filesystem_error_return(error)), + } + } + + fn read_guest_path(&self, address: GuestAddress) -> Result, Errno> { + let mut path = Vec::new(); + for offset in 0..MAX_PATH_BYTES { + let Some(address) = address.checked_add(offset as u64) else { + return Err(Errno::Fault); + }; + let mut byte = [0]; + self.memory + .read_exact(address, &mut byte) + .map_err(|_| Errno::Fault)?; + if byte[0] == 0 { + return Ok(path); + } + path.push(byte[0]); + } + Err(Errno::InvalidArgument) + } + + fn allocate_file_descriptor(&self) -> Option { + let open_count = u32::try_from(self.file_descriptors.len()) + .ok()? + .checked_add(3)?; + if open_count >= self.limits.max_open_files { + return None; + } + (FIRST_FILE_DESCRIPTOR..self.limits.max_open_files) + .find(|descriptor| !self.file_descriptors.contains_key(descriptor)) + } + + fn file_handle(&self, file_descriptor: u64) -> Option { + self.file_descriptors + .get(&u32::try_from(file_descriptor).ok()?) + .copied() + } + fn dispatch_rt_sigaction(&mut self) { let signal = self.register(0); let Ok(signal_index) = usize::try_from(signal.saturating_sub(1)) else { @@ -652,13 +911,29 @@ impl Process { } } +const fn filesystem_error_return(error: FileSystemError) -> u64 { + match error { + FileSystemError::NotFound => Errno::NoEntry, + FileSystemError::AlreadyExists => Errno::AlreadyExists, + FileSystemError::NotDirectory => Errno::NotDirectory, + FileSystemError::IsDirectory => Errno::IsDirectory, + FileSystemError::BadDescriptor => Errno::BadFileDescriptor, + FileSystemError::PermissionDenied => Errno::PermissionDenied, + FileSystemError::InvalidInput => Errno::InvalidArgument, + FileSystemError::NoSpace => Errno::NoSpace, + FileSystemError::Unsupported => Errno::NoSystemCall, + } + .return_value() +} + #[cfg(test)] mod tests { use core::convert::Infallible; - use binarrow_host_api::{HostTerminal, TerminalStream}; + use binarrow_host_api::{HostTerminal, NullFileSystem, TerminalStream}; use binarrow_linux_abi::Errno; use binarrow_loader::{Credentials, ProcessConfig, ProcessParameters, load_process}; + use binarrow_memory_fs::MemoryFileSystem; use binarrow_runtime_core::{GuestAddress, ResourceLimit, Trap}; use super::{ExecutionError, MAIN_THREAD_ID, Process, SyscallEvent, SyscallOutcome}; @@ -679,6 +954,9 @@ mod tests { include_bytes!("../../../guest-tests/rust-hello/rust-hello.aarch64.elf"); const INFINITE_LOOP_ELF: &[u8] = include_bytes!("../../../guest-tests/infinite-loop/infinite-loop.aarch64.elf"); + const FILE_ROUNDTRIP_ELF: &[u8] = + include_bytes!("../../../guest-tests/file-roundtrip/file-roundtrip.aarch64.elf"); + const FILE_ROUNDTRIP_MESSAGE: &[u8] = b"filesystem hello\n"; const RUST_MESSAGE: &[u8] = b"rust hello\n"; #[derive(Default)] @@ -930,6 +1208,55 @@ mod tests { assert!(process.trace().is_empty()); } + #[test] + fn file_roundtrip_uses_the_in_memory_filesystem_and_terminal() { + let image = load_process( + FILE_ROUNDTRIP_ELF, + &ProcessParameters { + argv: vec![b"/file-roundtrip".to_vec()], + envp: Vec::new(), + random_bytes: [0x42; 16], + credentials: Credentials::default(), + }, + ProcessConfig::default(), + ) + .unwrap(); + let mut process = Process::new(image).unwrap(); + let mut terminal = RecordingTerminal::default(); + let mut filesystem = MemoryFileSystem::new(1024); + + let result = process + .run_with_filesystem(&mut terminal, &mut filesystem) + .unwrap(); + + assert_eq!(result.exit_code, 0); + assert_eq!(result.executed_instructions, 54); + assert_eq!(result.dispatched_syscalls, 7); + assert_eq!(result.output_bytes, FILE_ROUNDTRIP_MESSAGE.len() as u64); + assert_eq!(terminal.standard_output, FILE_ROUNDTRIP_MESSAGE); + assert!(terminal.standard_error.is_empty()); + assert_eq!( + filesystem.read_file(b"/tmp/roundtrip.txt"), + Some(FILE_ROUNDTRIP_MESSAGE) + ); + assert_eq!( + process + .trace() + .iter() + .map(|event| (event.number, event.outcome)) + .collect::>(), + [ + (56, SyscallOutcome::Returned(3)), + (64, SyscallOutcome::Returned(17)), + (62, SyscallOutcome::Returned(0)), + (63, SyscallOutcome::Returned(17)), + (57, SyscallOutcome::Returned(0)), + (64, SyscallOutcome::Returned(17)), + (93, SyscallOutcome::Exited(0)), + ] + ); + } + #[test] fn syscall_limit_bounds_dispatch_and_trace_growth() { let mut config = ProcessConfig::default(); @@ -964,7 +1291,9 @@ mod tests { .set_x(2, MESSAGE.len().try_into().unwrap()) .unwrap(); - process.dispatch_write(&mut terminal).unwrap(); + process + .dispatch_write(&mut terminal, &mut NullFileSystem) + .unwrap(); assert_eq!(process.register(0), errno.return_value()); assert!(terminal.standard_output.is_empty()); diff --git a/crates/memory-fs/Cargo.toml b/crates/memory-fs/Cargo.toml new file mode 100644 index 0000000..2630823 --- /dev/null +++ b/crates/memory-fs/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "binarrow-memory-fs" +description = "Bounded in-memory filesystem for binarrow browser guests" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +binarrow-host-api = { path = "../host-api", version = "0.1.0" } + +[lints] +workspace = true diff --git a/crates/memory-fs/src/lib.rs b/crates/memory-fs/src/lib.rs new file mode 100644 index 0000000..9dc1b82 --- /dev/null +++ b/crates/memory-fs/src/lib.rs @@ -0,0 +1,317 @@ +//! Bounded ephemeral regular-file storage for browser and deterministic hosts. + +use std::collections::{BTreeMap, BTreeSet}; + +use binarrow_host_api::{ + FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, HostFileSystem, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct OpenFile { + path: Vec, + position: u64, + access: FileAccess, + append: bool, +} + +/// An ephemeral filesystem containing regular files and a small directory set. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MemoryFileSystem { + directories: BTreeSet>, + files: BTreeMap, Vec>, + open_files: BTreeMap, + next_handle: u64, + stored_bytes: u64, + byte_limit: u64, +} + +impl MemoryFileSystem { + #[must_use] + pub fn new(byte_limit: u64) -> Self { + Self { + directories: BTreeSet::from([b"/".to_vec(), b"/tmp".to_vec()]), + files: BTreeMap::new(), + open_files: BTreeMap::new(), + next_handle: 1, + stored_bytes: 0, + byte_limit, + } + } + + #[must_use] + pub const fn stored_bytes(&self) -> u64 { + self.stored_bytes + } + + #[must_use] + pub const fn byte_limit(&self) -> u64 { + self.byte_limit + } + + #[must_use] + pub fn read_file(&self, path: &[u8]) -> Option<&[u8]> { + let path = normalize_path(path).ok()?; + self.files.get(&path).map(Vec::as_slice) + } + + fn open_file( + &mut self, + path: Vec, + options: FileOpenOptions, + ) -> Result { + if self.directories.contains(&path) { + return Err(FileSystemError::IsDirectory); + } + let exists = self.files.contains_key(&path); + if exists + && options.flags.contains(FileOpenFlags::CREATE) + && options.flags.contains(FileOpenFlags::EXCLUSIVE) + { + return Err(FileSystemError::AlreadyExists); + } + if !exists { + if !options.flags.contains(FileOpenFlags::CREATE) { + return Err(FileSystemError::NotFound); + } + let parent = parent_path(&path).ok_or(FileSystemError::InvalidInput)?; + if !self.directories.contains(parent) { + return Err(FileSystemError::NotDirectory); + } + self.files.insert(path.clone(), Vec::new()); + } + if options.flags.contains(FileOpenFlags::TRUNCATE) { + if !options.access.can_write() { + return Err(FileSystemError::PermissionDenied); + } + let file = self + .files + .get_mut(&path) + .expect("an existing or newly created file is present"); + self.stored_bytes -= file.len() as u64; + file.clear(); + } + let handle = self.next_handle; + self.next_handle = self + .next_handle + .checked_add(1) + .ok_or(FileSystemError::NoSpace)?; + self.open_files.insert( + handle, + OpenFile { + path, + position: 0, + access: options.access, + append: options.flags.contains(FileOpenFlags::APPEND), + }, + ); + Ok(handle) + } + + fn read_open_file( + &mut self, + handle: u64, + destination: &mut [u8], + ) -> Result { + let open_file = self + .open_files + .get_mut(&handle) + .ok_or(FileSystemError::BadDescriptor)?; + if !open_file.access.can_read() { + return Err(FileSystemError::PermissionDenied); + } + let file = self + .files + .get(&open_file.path) + .ok_or(FileSystemError::NotFound)?; + let position = + usize::try_from(open_file.position).map_err(|_| FileSystemError::InvalidInput)?; + if position >= file.len() { + return Ok(0); + } + let available = file.len().saturating_sub(position); + let count = destination.len().min(available); + destination[..count].copy_from_slice(&file[position..position + count]); + open_file.position += count as u64; + Ok(count) + } + + fn write_open_file(&mut self, handle: u64, source: &[u8]) -> Result { + let open_file = self + .open_files + .get_mut(&handle) + .ok_or(FileSystemError::BadDescriptor)?; + if !open_file.access.can_write() { + return Err(FileSystemError::PermissionDenied); + } + let file = self + .files + .get_mut(&open_file.path) + .ok_or(FileSystemError::NotFound)?; + let position = if open_file.append { + file.len() + } else { + usize::try_from(open_file.position).map_err(|_| FileSystemError::InvalidInput)? + }; + let end = position + .checked_add(source.len()) + .ok_or(FileSystemError::NoSpace)?; + let growth = end.saturating_sub(file.len()) as u64; + if self.stored_bytes.saturating_add(growth) > self.byte_limit { + return Err(FileSystemError::NoSpace); + } + if end > file.len() { + file.resize(end, 0); + self.stored_bytes += growth; + } + file[position..end].copy_from_slice(source); + open_file.position = end as u64; + Ok(source.len()) + } + + fn seek_open_file( + &mut self, + handle: u64, + offset: i64, + from: FileSeekFrom, + ) -> Result { + let open_file = self + .open_files + .get_mut(&handle) + .ok_or(FileSystemError::BadDescriptor)?; + let file_length = self + .files + .get(&open_file.path) + .ok_or(FileSystemError::NotFound)? + .len() as u64; + let base = match from { + FileSeekFrom::Start => 0, + FileSeekFrom::Current => open_file.position, + FileSeekFrom::End => file_length, + }; + let position = if offset < 0 { + base.checked_sub(offset.unsigned_abs()) + } else { + base.checked_add(offset.cast_unsigned()) + } + .ok_or(FileSystemError::InvalidInput)?; + open_file.position = position; + Ok(position) + } +} + +impl HostFileSystem for MemoryFileSystem { + fn open(&mut self, path: &[u8], options: FileOpenOptions) -> Result { + self.open_file(normalize_path(path)?, options) + } + + fn read(&mut self, handle: u64, destination: &mut [u8]) -> Result { + self.read_open_file(handle, destination) + } + + fn write(&mut self, handle: u64, source: &[u8]) -> Result { + self.write_open_file(handle, source) + } + + fn seek( + &mut self, + handle: u64, + offset: i64, + from: FileSeekFrom, + ) -> Result { + self.seek_open_file(handle, offset, from) + } + + fn close(&mut self, handle: u64) -> Result<(), FileSystemError> { + self.open_files + .remove(&handle) + .map(|_| ()) + .ok_or(FileSystemError::BadDescriptor) + } +} + +fn normalize_path(path: &[u8]) -> Result, FileSystemError> { + if path.first() != Some(&b'/') || path.contains(&0) { + return Err(FileSystemError::InvalidInput); + } + let mut normalized = Vec::with_capacity(path.len()); + normalized.push(b'/'); + let mut first = true; + for component in path + .split(|byte| *byte == b'/') + .filter(|part| !part.is_empty()) + { + if component == b"." { + continue; + } + if component == b".." { + return Err(FileSystemError::PermissionDenied); + } + if !first { + normalized.push(b'/'); + } + normalized.extend_from_slice(component); + first = false; + } + Ok(normalized) +} + +fn parent_path(path: &[u8]) -> Option<&[u8]> { + let separator = path.iter().rposition(|byte| *byte == b'/')?; + Some(if separator == 0 { + b"/" + } else { + &path[..separator] + }) +} + +#[cfg(test)] +mod tests { + use binarrow_host_api::{ + FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, HostFileSystem, + }; + + use super::MemoryFileSystem; + + const CREATE_READ_WRITE: FileOpenOptions = FileOpenOptions { + access: FileAccess::ReadWrite, + flags: FileOpenFlags::CREATE.union(FileOpenFlags::TRUNCATE), + }; + + #[test] + fn creates_writes_seeks_and_reads_a_bounded_tmp_file() { + let mut filesystem = MemoryFileSystem::new(16); + let handle = filesystem + .open(b"/tmp/message.txt", CREATE_READ_WRITE) + .unwrap(); + + assert_eq!(filesystem.write(handle, b"hello").unwrap(), 5); + assert_eq!(filesystem.seek(handle, 0, FileSeekFrom::Start).unwrap(), 0); + let mut bytes = [0; 5]; + assert_eq!(filesystem.read(handle, &mut bytes).unwrap(), 5); + assert_eq!(&bytes, b"hello"); + assert_eq!( + filesystem.read_file(b"/tmp/message.txt"), + Some(&b"hello"[..]) + ); + assert_eq!(filesystem.stored_bytes(), 5); + filesystem.close(handle).unwrap(); + } + + #[test] + fn rejects_traversal_missing_parents_access_errors_and_capacity_exhaustion() { + let mut filesystem = MemoryFileSystem::new(4); + assert_eq!( + filesystem.open(b"/tmp/../secret", CREATE_READ_WRITE), + Err(FileSystemError::PermissionDenied) + ); + assert_eq!( + filesystem.open(b"/missing/file", CREATE_READ_WRITE), + Err(FileSystemError::NotDirectory) + ); + let handle = filesystem.open(b"/tmp/full", CREATE_READ_WRITE).unwrap(); + assert_eq!( + filesystem.write(handle, b"12345"), + Err(FileSystemError::NoSpace) + ); + } +} diff --git a/crates/runtime-core/src/lib.rs b/crates/runtime-core/src/lib.rs index df44de0..80390fc 100644 --- a/crates/runtime-core/src/lib.rs +++ b/crates/runtime-core/src/lib.rs @@ -64,6 +64,8 @@ pub struct ResourceLimits { pub max_output_bytes: u64, /// Maximum simultaneously open guest descriptors. pub max_open_files: u32, + /// Maximum bytes stored in the ephemeral guest filesystem. + pub max_filesystem_bytes: u64, } impl Default for ResourceLimits { @@ -74,6 +76,7 @@ impl Default for ResourceLimits { syscall_budget: 100_000, max_output_bytes: 4 * 1024 * 1024, max_open_files: 256, + max_filesystem_bytes: 16 * 1024 * 1024, } } } @@ -135,5 +138,6 @@ mod tests { assert!(limits.syscall_budget > 0); assert!(limits.max_output_bytes > 0); assert!(limits.max_open_files > 0); + assert!(limits.max_filesystem_bytes > 0); } } diff --git a/docs/architecture.md b/docs/architecture.md index b812bb2..3dc1d7b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,11 +60,15 @@ Owns the minimal AArch64 Linux syscall table and errno return encoding. It conta ### `binarrow-host-api` -Defines host services without choosing a native or browser implementation. The first boundary is an atomic terminal write tagged as stdout or stderr. Filesystem, clocks, randomness, and suspension-aware calls will extend this crate as their syscall users arrive. +Defines host services without choosing a native or browser implementation. The current boundaries are atomic terminal writes and synchronous file open/read/write/seek/close operations with stable host-independent errors. Clocks, randomness, and suspension-aware calls will extend this crate as their syscall users arrive. + +### `binarrow-memory-fs` + +Implements the Phase 2 ephemeral filesystem behind `binarrow-host-api`. It normalizes absolute byte paths, provides `/tmp`, tracks independent open handles and offsets, enforces access/append/truncate behavior, and bounds total stored bytes. It intentionally provides regular files only; directory mutation, metadata, persistence, and OPFS mounts belong to Phase 3's VFS expansion. ### `binarrow-linux-runtime` -Consumes a loaded process image, owns its architectural execution state, and repeatedly runs the interpreter to structured supervisor-call stops. The dispatcher implements the process calls reached by the static Rust fixture: polling, deterministic TID and one-CPU affinity, signal actions/masks/alternate stack, anonymous mapping/protection/unmapping, terminal writes, and process exit. Unsupported calls return `ENOSYS`, invalid arguments return Linux errno values, and output is bounded before bytes cross the host trait. Successful termination reports the guest exit code plus instruction, syscall, and output counters. Every completed dispatch also appends a project-owned trace event whose arguments are captured before return-register mutation; an explicit syscall budget bounds trace growth. +Consumes a loaded process image, owns its architectural execution state and descriptor table, and repeatedly runs the interpreter to structured supervisor-call stops. The dispatcher implements the process calls reached by the static Rust fixture plus `openat`, `close`, `lseek`, and regular-file `read`/`write`. File contents remain behind `HostFileSystem`; guest descriptor allocation, guest-memory copying, errno mapping, and open-file limits remain Linux-runtime responsibilities. Unsupported calls return `ENOSYS`, invalid arguments return Linux errno values, and terminal output is bounded before bytes cross the host trait. Successful termination reports the guest exit code plus instruction, syscall, and output counters. Every completed dispatch also appends a project-owned trace event whose arguments are captured before return-register mutation; an explicit syscall budget bounds trace growth. ### `binarrow-browser-runtime` @@ -88,4 +92,4 @@ The Icicle feasibility spike is isolated under `experiments/icicle`; it is not a `experiments/icicle-wasm` separately validates Icicle's lightweight `pcode` crate in raw `wasm32-unknown-unknown`. The production `binarrow-aarch64` crate pins `pcode`, `sleigh-runtime`, and the filesystem-free portion of `sleigh-compile` at that same revision and keeps their types private. The required AHash/getrandom browser backend is selected in the workspace's target configuration; `icicle-cpu` and its native VM/JIT remain outside the production graph. -Worker start/stop control and structured execution diagnostics are complete. The next production boundary is the first in-memory filesystem and file syscall fixture, expanding the explicit host-service interface without taking on Phase 3 persistence or OPFS concerns. +Phase 2's Worker control, structured diagnostics, bounded in-memory filesystem, and shared native/browser fixture matrix are complete. Phase 3 begins by turning the regular-file store into a mountable VFS with directory mutation, metadata, OPFS persistence, terminal input, clocks, and randomness. diff --git a/docs/decisions/0002-use-sparse-memory-for-browser-interpreter.md b/docs/decisions/0002-use-sparse-memory-for-browser-interpreter.md new file mode 100644 index 0000000..28d7e5b --- /dev/null +++ b/docs/decisions/0002-use-sparse-memory-for-browser-interpreter.md @@ -0,0 +1,64 @@ +# ADR-0002: Use sparse software mappings for the browser interpreter + +- Status: accepted for the interpreter +- Date: 2026-07-24 +- Owners: project maintainers + +## Context + +Phase 2 requires either Memory64 integration or a documented interim browser +memory design. Chrome can compile and instantiate Memory64, as proven by the +Worker feature gate, but a Memory64 linear memory does not by itself provide +Linux-style sparse mappings, per-region permissions, guard pages, or cheap +unmapping. The current execution engine is an interpreter rather than the +planned P-code-to-Wasm translator. + +## Decision + +Use `binarrow-guest-memory` unchanged on native and `wasm32-unknown-unknown` +for the browser interpreter. Guest addresses remain 64-bit values. Each mapped +Linux region owns a bounded byte vector inside the browser runtime's ordinary +Wasm32 memory, while a sorted region table enforces guest read, write, and +execute permissions. Mapping, protection, splitting, unmapping, and committed +byte accounting remain project-owned and host-independent. + +The loader and Linux runtime apply the same configurable committed-memory +limit in native and browser hosts. The browser also exposes instruction, +syscall, output, and ephemeral-filesystem limits. A Memory64 probe remains a +required Chrome gate so the future translator can adopt direct 64-bit linear +addressing without discovering a missing platform capability later. + +Do not expose the interim backing representation through the loader, CPU, +Linux, or host-service APIs. The future translator may replace region byte +vectors with a Memory64-backed page store behind `AddressSpace` while retaining +the same checked mapping contract. + +## Consequences + +### Positive + +- Native and browser interpreters execute identical memory code and semantic + tests. +- Sparse high guest addresses do not force a correspondingly large browser + allocation. +- Guard pages and Linux permission changes are enforced immediately. +- Committed bytes are bounded independently from the guest virtual address + range. +- Phase 2 does not couple interpreter correctness to translator layout work. + +### Negative + +- Every guest access performs software region lookup and bounds checking. +- Guest bytes are copied into ordinary Wasm32 allocations rather than directly + addressed through a Memory64 memory. +- Large workloads will require paging and translator-oriented invalidation + work before the browser can host CPython or compilers efficiently. + +## Validation + +The Worker dynamically instantiates the generated Memory64 probe, then runs the +same C, Rust, filesystem, and non-terminating AArch64 fixtures as the native +runtime. Playwright verifies the Memory64 gate, deterministic guest results, +structured instruction-limit diagnostics, and unconditional Worker +termination. Workspace checks compile the production crates for +`wasm32-unknown-unknown`. diff --git a/guest-tests/file-roundtrip/README.md b/guest-tests/file-roundtrip/README.md new file mode 100644 index 0000000..2c8eebd --- /dev/null +++ b/guest-tests/file-roundtrip/README.md @@ -0,0 +1,21 @@ +# In-memory filesystem AArch64 fixture + +This freestanding C fixture creates `/tmp/roundtrip.txt`, writes a message, +seeks to the beginning, reads the same bytes, closes the file, and writes the +round-tripped contents to standard output. It exercises the initial Phase 2 +`openat`, file `write`, `lseek`, `read`, and `close` path without libc startup +adding unrelated calls. + +Build from any working directory with Zig 0.16.0: + +```sh +guest-tests/file-roundtrip/build.sh +``` + +The stripped ELF is checked in for identical native and browser regressions. + +Expected SHA-256: + +```text +e1f66de0f93c82ea18f512e332495da1a56a2b574f81f2fb06d5bf618975b8ba +``` diff --git a/guest-tests/file-roundtrip/build.sh b/guest-tests/file-roundtrip/build.sh new file mode 100755 index 0000000..af8b3d4 --- /dev/null +++ b/guest-tests/file-roundtrip/build.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +fixture_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cache_directory=${TMPDIR:-/tmp}/binarrow-zig-cache +output=$fixture_directory/file-roundtrip.aarch64.elf +zig_version=$(zig version) + +if [ "$zig_version" != "0.16.0" ]; then + echo "file-roundtrip requires Zig 0.16.0; found $zig_version" >&2 + exit 1 +fi + +env \ + ZIG_LOCAL_CACHE_DIR="$cache_directory/local" \ + ZIG_GLOBAL_CACHE_DIR="$cache_directory/global" \ + zig cc \ + -target aarch64-linux-musl \ + -nostdlib \ + -static \ + -fno-stack-protector \ + -O1 \ + -g0 \ + -s \ + -Wl,--build-id=none \ + -Wl,-e,_start \ + "$fixture_directory/main.c" \ + -o "$output" + +chmod 0644 "$output" diff --git a/guest-tests/file-roundtrip/file-roundtrip.aarch64.elf b/guest-tests/file-roundtrip/file-roundtrip.aarch64.elf new file mode 100644 index 0000000000000000000000000000000000000000..0c653b79599887519c4944bac1a9d470cf0df03a GIT binary patch literal 1376 zcmb<-^>JfjWMqH=CWh?{Al?y1h@b;hVgWOh!N9=az+l0^%HY7j&cMdN#=yY90v3bF zJ1~H?Gr(vGkYNlA3^1Alq71AEqz}pkQBaj&DTX6Z9*l+>3zlSMU_g?GxD)2y45&UB z4bvC!v?K*&UjtOWfe~yJ1B?~{xqyLz0Y-z=f`kH}mZX6E2x1e!P?I65^hqI4{hM>pH@~b#FAoB7q3?Cbr7(yN}bFO;GEWhdz zv-~QM{0XT31SWXh&$jA2isxK0C&R+s2w0T9Aw0Fg9cQ70VBhf z1vt!Xz#$HH8fI7k@kEJ)$Nz`y{~2UUusih&^wDu_!zOfdit;?6|? literal 0 HcmV?d00001 diff --git a/guest-tests/file-roundtrip/main.c b/guest-tests/file-roundtrip/main.c new file mode 100644 index 0000000..7a3140f --- /dev/null +++ b/guest-tests/file-roundtrip/main.c @@ -0,0 +1,70 @@ +typedef unsigned long size_t; + +enum { + SYS_OPENAT = 56, + SYS_CLOSE = 57, + SYS_LSEEK = 62, + SYS_READ = 63, + SYS_WRITE = 64, + SYS_EXIT = 93, + AT_FDCWD = -100, + O_RDWR = 2, + O_CREAT = 0x40, + O_TRUNC = 0x200, +}; + +static long syscall4(long number, long first, long second, long third, long fourth) { + register long x0 __asm__("x0") = first; + register long x1 __asm__("x1") = second; + register long x2 __asm__("x2") = third; + register long x3 __asm__("x3") = fourth; + register long x8 __asm__("x8") = number; + __asm__ volatile("svc #0" + : "+r"(x0) + : "r"(x1), "r"(x2), "r"(x3), "r"(x8) + : "memory"); + return x0; +} + +static long syscall3(long number, long first, long second, long third) { + return syscall4(number, first, second, third, 0); +} + +__attribute__((noreturn)) static void exit_guest(long status) { + (void)syscall3(SYS_EXIT, status, 0, 0); + __builtin_unreachable(); +} + +__attribute__((noreturn)) void _start(void) { + static const char path[] = "/tmp/roundtrip.txt"; + static const char message[] = "filesystem hello\n"; + char buffer[sizeof(message) - 1]; + + long descriptor = syscall4( + SYS_OPENAT, + AT_FDCWD, + (long)path, + O_RDWR | O_CREAT | O_TRUNC, + 0600); + if (descriptor < 0) { + exit_guest(1); + } + if (syscall3(SYS_WRITE, descriptor, (long)message, sizeof(message) - 1) != + sizeof(message) - 1) { + exit_guest(2); + } + if (syscall3(SYS_LSEEK, descriptor, 0, 0) != 0) { + exit_guest(3); + } + long count = syscall3(SYS_READ, descriptor, (long)buffer, sizeof(buffer)); + if (count != sizeof(buffer)) { + exit_guest(4); + } + if (syscall3(SYS_CLOSE, descriptor, 0, 0) != 0) { + exit_guest(5); + } + if (syscall3(SYS_WRITE, 1, (long)buffer, count) != count) { + exit_guest(6); + } + exit_guest(0); +} diff --git a/web/index.html b/web/index.html index 232fd1e..b9d0454 100644 --- a/web/index.html +++ b/web/index.html @@ -29,6 +29,7 @@ + @@ -38,6 +39,8 @@ + +
diff --git a/web/src/main.ts b/web/src/main.ts index 031b8d1..cfb28ca 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -33,6 +33,7 @@ const instructionBudget = requiredElement("#instruction-budget const syscallBudget = requiredElement("#syscall-budget"); const outputLimit = requiredElement("#output-limit"); const memoryLimit = requiredElement("#memory-limit"); +const filesystemLimit = requiredElement("#filesystem-limit"); const startButton = requiredElement("#start"); const stopButton = requiredElement("#stop"); @@ -115,6 +116,7 @@ function readLimits(): ExecutionLimits { syscallBudget: parseLimit(syscallBudget), maxOutputBytes: parseLimit(outputLimit), maxMemoryBytes: parseLimit(memoryLimit), + maxFilesystemBytes: parseLimit(filesystemLimit), }; } diff --git a/web/src/probe.ts b/web/src/probe.ts index 415fb87..5dbdc0a 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -16,7 +16,8 @@ export type FixtureName = | "compiler-c" | "libc-c" | "rust" - | "infinite-loop"; + | "infinite-loop" + | "file-roundtrip"; export interface FeatureResult { name: FeatureName; @@ -29,6 +30,7 @@ export interface ExecutionLimits { syscallBudget: bigint; maxOutputBytes: bigint; maxMemoryBytes: bigint; + maxFilesystemBytes: bigint; } export interface ExecutionDiagnostic { diff --git a/web/src/probe.worker.ts b/web/src/probe.worker.ts index e13676c..a021845 100644 --- a/web/src/probe.worker.ts +++ b/web/src/probe.worker.ts @@ -149,6 +149,7 @@ function execute(command: WorkerCommand): ExecutionReport { command.limits.syscallBudget, command.limits.maxOutputBytes, command.limits.maxMemoryBytes, + command.limits.maxFilesystemBytes, ); try { const diagnosticCode = result.diagnostic_code; diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index 0693b0a..4275bb0 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -70,6 +70,26 @@ test("surfaces instruction exhaustion as a structured diagnostic", async ({ await expect(page.locator("#instruction-count")).toHaveText("128"); }); +test("round trips a file through the bounded in-memory filesystem", async ({ + page, +}) => { + await page.getByLabel("Fixture").selectOption("file-roundtrip"); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page.getByRole("status")).toHaveText("Guest exited"); + await expect(page.getByLabel("Guest terminal output")).toHaveText( + "filesystem hello", + ); + await expect(page.locator("#instruction-count")).toHaveText("54"); + await expect(page.locator("#syscall-count")).toHaveText("7"); + await expect(page.getByLabel("System call trace")).toContainText( + "openat(dirfd=-100", + ); + await expect(page.getByLabel("System call trace")).toContainText( + "close(fd=3) = 0", + ); +}); + test("terminates and restarts a Worker running an infinite guest", async ({ page }) => { await page.getByLabel("Fixture").selectOption("infinite-loop"); await page