runtime/sandbox.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The execution boundary: a seam with one method,
7//! behind which a guest program runs with exactly the capabilities it
8//! was handed and no others.
9//!
10//! **This is the guest half.** A plain host command — the exec tool's
11//! program and shell arms — is not a guest, so its isolation is bought
12//! from the platform instead; `runtime::tools` owns that half, names
13//! the arm in use, and states what the arm does not promise.
14//!
15//! The capability surface is the preopen set. A guest can reach a host
16//! directory if and only if a [`Mount`] named it; there is no ambient
17//! filesystem, no environment inheritance, and — because wasip1 has no
18//! socket host implementation — no network. That last one matters: the
19//! Python arm cannot reach the network, and the proof is structural
20//! rather than a rule someone remembered to write down.
21//!
22//! Fuel exhaustion is an outcome, not an error. Running out of fuel is
23//! the guest doing something the budget did not cover, which the caller
24//! must see and decide about; `Err` here is reserved for the host
25//! failing to run the job at all. The distinction is what lets a
26//! watchdog treat a runaway loop differently from a broken engine.
27
28use std::path::PathBuf;
29
30use kernel::{AxCode, AxError};
31
32/// Instruction budget. Exhaustion ends the guest; it does not fail the
33/// host.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct Fuel(pub u64);
36
37/// One granted capability: a host directory visible to the guest under
38/// `guest`, writable or not. The mount list is the whole of what the
39/// job can reach.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Mount {
42 pub host: PathBuf,
43 pub guest: String,
44 pub writable: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct SandboxJob {
49 pub wasm: PathBuf,
50 pub argv: Vec<String>,
51 pub env: Vec<(String, String)>,
52 pub stdin: Vec<u8>,
53 pub mounts: Vec<Mount>,
54 pub fuel: Fuel,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct SandboxOutcome {
59 pub stdout: Vec<u8>,
60 pub stderr: Vec<u8>,
61 pub exit: SandboxExit,
62}
63
64/// How the guest ended. Every variant is a fact about the guest — the
65/// host never hides a guest failure behind a success.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum SandboxExit {
68 Success,
69 Failure { code: u64 },
70 FuelExhausted,
71 Trap { message: String },
72}
73
74/// The seam. One method: hand it a job, get back what the guest did.
75/// `Send`, because the `exec` tool that holds one is `Send`
76/// (`crates/sprawling/Spec.lean` §8-44); every adapter here already was.
77pub trait Sandbox: Send {
78 fn run(&mut self, job: &SandboxJob) -> Result<SandboxOutcome, AxError>;
79}
80
81/// What a binary built without an execution engine offers: a refusal
82/// that names the missing piece.
83///
84/// It exists so the absence is a verdict rather than a stand-in. An
85/// echo in this position would answer "success" to a guest that never
86/// ran, and the first person to notice would be whoever trusted the
87/// output.
88pub struct AbsentSandbox;
89
90impl Sandbox for AbsentSandbox {
91 fn run(&mut self, _job: &SandboxJob) -> Result<SandboxOutcome, AxError> {
92 Err(AxError::failure(
93 AxCode::ToolUnavailable,
94 "run in the sandbox",
95 "this build carries no execution engine",
96 )
97 .with_recovery("use the program arm, or install a build with the `wasm` feature"))
98 }
99}
100
101/// A pass-through stand-in: stdout echoes stdin, plus whatever script
102/// output the test queued. Used where the test is about the caller's
103/// logic rather than the boundary.
104#[derive(Default)]
105pub struct EchoSandbox {
106 scripted: Vec<Vec<u8>>,
107 pub seen: Vec<SandboxJob>,
108}
109
110impl EchoSandbox {
111 pub fn new() -> EchoSandbox {
112 EchoSandbox::default()
113 }
114
115 pub fn queue_stdout(&mut self, bytes: Vec<u8>) {
116 self.scripted.push(bytes);
117 }
118}
119
120impl Sandbox for EchoSandbox {
121 fn run(&mut self, job: &SandboxJob) -> Result<SandboxOutcome, AxError> {
122 self.seen.push(job.clone());
123 let stdout = if self.scripted.is_empty() {
124 job.stdin.clone()
125 } else {
126 self.scripted.remove(0)
127 };
128 Ok(SandboxOutcome {
129 stdout,
130 stderr: Vec::new(),
131 exit: SandboxExit::Success,
132 })
133 }
134}
135
136/// A fault stand-in: pops a scripted outcome per call, so a caller's
137/// handling of exhaustion and traps is testable without provoking the
138/// real engine into either.
139pub struct FaultSandbox {
140 scripted: Vec<SandboxExit>,
141}
142
143impl FaultSandbox {
144 pub fn new(scripted: Vec<SandboxExit>) -> FaultSandbox {
145 FaultSandbox { scripted }
146 }
147}
148
149impl Sandbox for FaultSandbox {
150 fn run(&mut self, _job: &SandboxJob) -> Result<SandboxOutcome, AxError> {
151 let exit = if self.scripted.is_empty() {
152 SandboxExit::Success
153 } else {
154 self.scripted.remove(0)
155 };
156 Ok(SandboxOutcome {
157 stdout: Vec::new(),
158 stderr: Vec::new(),
159 exit,
160 })
161 }
162}
163
164/// The wasmtime adapter lives in its own file, behind the `wasm`
165/// feature: the seam above is what every caller programs against.
166#[cfg(feature = "wasm")]
167mod engine;
168
169#[cfg(feature = "wasm")]
170pub use engine::WasmtimeSandbox;
171
172/// Conformance: any Sandbox must run twice in a row without the first
173/// job poisoning the second, and must return a well-formed outcome.
174#[cfg(feature = "conformance")]
175pub fn assert_sandbox_conformance<S: Sandbox>(sandbox: &mut S, job: &SandboxJob) {
176 let first = sandbox.run(job);
177 let second = sandbox.run(job);
178 match (first, second) {
179 (Ok(one), Ok(two)) => {
180 // Well-ordered back-to-back calls: the second must not
181 // inherit the first's state.
182 assert!(
183 matches!(
184 one.exit,
185 SandboxExit::Success
186 | SandboxExit::Failure { .. }
187 | SandboxExit::FuelExhausted
188 | SandboxExit::Trap { .. }
189 ),
190 "outcome shape is one of the four"
191 );
192 assert!(
193 matches!(
194 two.exit,
195 SandboxExit::Success
196 | SandboxExit::Failure { .. }
197 | SandboxExit::FuelExhausted
198 | SandboxExit::Trap { .. }
199 ),
200 "the second call still returns a well-formed outcome"
201 );
202 }
203 (one, two) => {
204 assert!(
205 one.is_err() == two.is_err(),
206 "a host that can run a job once can run it twice"
207 );
208 }
209 }
210}
211
212#[cfg(test)]
213#[allow(
214 clippy::unwrap_used,
215 clippy::expect_used,
216 clippy::panic,
217 clippy::indexing_slicing,
218 reason = "test code"
219)]
220mod tests;