Skip to main content

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;