Skip to main content

confium_sandbox_wasm/
wasm.rs

1//! `WasmSandbox` — the wasmtime-backed implementation of [`Sandbox`].
2//!
3//! Compiles WASM modules with a shared [`wasmtime::Engine`] (so the
4//! JIT cache is reused across loads), wires the host imports from
5//! [`crate::imports`] into the linker, and gates every import
6//! invocation against the instance's [`CapabilitySet`].
7//!
8//! See `TODO.roadmap/15-wasm-sandboxing.md` § Architecture.
9
10use snafu::Backtrace;
11use snafu::GenerateImplicitData;
12use wasmtime::Caller;
13use wasmtime::Engine;
14use wasmtime::InstancePre;
15use wasmtime::Linker;
16use wasmtime::Module;
17use wasmtime::Store;
18
19use crate::Error;
20use crate::Result;
21use crate::error::WasmtimeError;
22use crate::imports::CapabilitySet;
23use crate::imports::HostImports;
24use crate::imports::ImportOutcome;
25use crate::sandbox::Capability;
26use crate::sandbox::Sandbox;
27use crate::sandbox::SandboxInstance;
28use crate::sandbox::Value;
29
30/// Host-side state threaded through every guest call. Lives in the
31/// wasmtime `Store` so the host-import trampolines can reach it via
32/// `Caller::data()`.
33pub(crate) struct HostState {
34    pub caps: CapabilitySet,
35}
36
37/// Default linear-memory cap target for a sandboxed instance.
38/// Matches the design doc (§ Performance considerations). Enforced
39/// via `Store::limiter` (TODO) rather than `Config`, since wasmtime's
40/// `Config::max_memory_size` is gated behind the pooling allocator.
41const DEFAULT_MEMORY_BYTES: usize = 32 * 1024 * 1024;
42
43/// The wasmtime-backed sandbox.
44///
45/// Clone-cheap: the engine and linker template are shared via `Arc`.
46/// Each [`load_module`](WasmSandbox::load_module) yields a fresh
47/// [`WasmInstance`] with its own `Store`, linear memory, and empty
48/// capability envelope.
49#[derive(Clone)]
50pub struct WasmSandbox {
51    engine: Engine,
52    /// Linker with the host imports already defined but NOT bound to
53    /// a particular store — cloned per instance to attach state.
54    linker_template: std::sync::Arc<Linker<HostState>>,
55}
56
57impl WasmSandbox {
58    /// Construct a new sandbox with the default config (no WASI,
59    /// no filesystem, no network other than what host imports
60    /// provide).
61    pub fn new() -> Result<Self> {
62        // No WASI: plugins have no ambient filesystem or network.
63        // They reach the host only through `cfm_*` imports.
64        let config = wasmtime::Config::new();
65        let engine = Engine::new(&config).map_err(|e| Error::Engine {
66            source: WasmtimeError::from_display(e),
67            backtrace: Backtrace::generate(),
68        })?;
69        let linker = build_linker(&engine);
70        Ok(Self {
71            engine,
72            linker_template: std::sync::Arc::new(linker),
73        })
74    }
75
76    /// Construct with a caller-supplied engine (advanced use: shared
77    /// cache, custom config).
78    pub fn with_engine(engine: Engine) -> Result<Self> {
79        let linker = build_linker(&engine);
80        Ok(Self {
81            engine,
82            linker_template: std::sync::Arc::new(linker),
83        })
84    }
85
86    /// Access the underlying wasmtime engine.
87    pub fn engine(&self) -> &Engine {
88        &self.engine
89    }
90}
91
92// Keep the design-doc constant visible (avoid dead-code warning).
93const _: usize = DEFAULT_MEMORY_BYTES;
94
95impl Sandbox for WasmSandbox {
96    fn load_module(&self, bytes: &[u8]) -> Result<Box<dyn SandboxInstance>> {
97        let module = Module::new(&self.engine, bytes).map_err(|e| Error::ModuleCompile {
98            source: WasmtimeError::from_display(e),
99            backtrace: Backtrace::generate(),
100        })?;
101        // Pre-link against the host imports so instantiation can
102        // only fail on missing exports, not on a host-import mismatch.
103        let linker = (*self.linker_template).clone();
104        let instance_pre = linker
105            .instantiate_pre(&module)
106            .map_err(|e| Error::Instantiation {
107                source: WasmtimeError::from_display(e),
108                backtrace: Backtrace::generate(),
109            })?;
110
111        let state = HostState {
112            caps: CapabilitySet::new(),
113        };
114        let store = Store::new(&self.engine, state);
115
116        Ok(Box::new(WasmInstance {
117            instance_pre,
118            store,
119        }))
120    }
121
122    fn name(&self) -> &'static str {
123        "wasmtime"
124    }
125}
126
127impl Default for WasmSandbox {
128    fn default() -> Self {
129        Self::new().expect("default wasmtime engine config must succeed")
130    }
131}
132
133/// A loaded WASM module + its store, exposed through [`SandboxInstance`].
134///
135/// Each `call` instantiates the pre-linked module against the current
136/// store so the capability state currently installed on `HostState`
137/// is the one the guest sees. Instantiation from `InstancePre` is
138/// cheap (the costly step is pre-linking, done once at load time).
139pub struct WasmInstance {
140    instance_pre: InstancePre<HostState>,
141    store: Store<HostState>,
142}
143
144impl WasmInstance {
145    /// Borrow the store mutably. Private so callers go through the
146    /// trait surface.
147    fn store_mut(&mut self) -> &mut Store<HostState> {
148        &mut self.store
149    }
150
151    fn invocation_error<E: std::fmt::Display>(function: &str, e: E) -> Error {
152        Error::Invocation {
153            function: function.to_string(),
154            source: WasmtimeError::from_display(e),
155            backtrace: Backtrace::generate(),
156        }
157    }
158}
159
160impl SandboxInstance for WasmInstance {
161    fn call(&mut self, function: &str, args: &[Value]) -> Result<Vec<Value>> {
162        // Clone the pre-linker out so we don't fight the borrow
163        // checker on `self.instance_pre` vs `self.store`.
164        let instance_pre = self.instance_pre.clone();
165        let store = self.store_mut();
166        let instance = instance_pre
167            .instantiate(&mut *store)
168            .map_err(|e| Self::invocation_error(function, e))?;
169
170        let export =
171            instance
172                .get_export(&mut *store, function)
173                .ok_or_else(|| Error::FunctionNotFound {
174                    function: function.to_string(),
175                    backtrace: Backtrace::generate(),
176                })?;
177
178        let func = export.into_func().ok_or_else(|| Error::ExportNotFound {
179            export: function.to_string(),
180            backtrace: Backtrace::generate(),
181        })?;
182
183        // Try typed fast paths first (the common host-import
184        // smoke-test shapes), then fall back to the untyped
185        // multi-arg path for general use.
186        if let Ok(typed) = func.typed::<i32, i64>(&mut *store) {
187            let arg = match args.first() {
188                Some(Value::I32(v)) => *v,
189                _ => 0,
190            };
191            let out = typed
192                .call(&mut *store, arg)
193                .map_err(|e| Self::invocation_error(function, e))?;
194            return Ok(vec![Value::I64(out)]);
195        }
196
197        if let Ok(typed) = func.typed::<i64, i64>(&mut *store) {
198            let arg = match args.first() {
199                Some(Value::I64(v)) => *v,
200                Some(Value::I32(v)) => i64::from(*v),
201                _ => 0,
202            };
203            let out = typed
204                .call(&mut *store, arg)
205                .map_err(|e| Self::invocation_error(function, e))?;
206            return Ok(vec![Value::I64(out)]);
207        }
208
209        if let Ok(typed) = func.typed::<(), i32>(&mut *store) {
210            let out = typed
211                .call(&mut *store, ())
212                .map_err(|e| Self::invocation_error(function, e))?;
213            return Ok(vec![Value::I32(out)]);
214        }
215
216        // Untyped fallback: marshal via wasmtime::Val.
217        let wasm_args: Vec<wasmtime::Val> = args
218            .iter()
219            .map(value_to_wasmval)
220            .collect::<std::result::Result<_, _>>()
221            .map_err(|()| Error::ArgumentType {
222                function: function.to_string(),
223                backtrace: Backtrace::generate(),
224            })?;
225
226        let result_count = func.ty(&mut *store).results().len();
227        let mut wasm_outs = vec![wasmtime::Val::I32(0); result_count];
228        func.call(&mut *store, &wasm_args, &mut wasm_outs)
229            .map_err(|e| Self::invocation_error(function, e))?;
230
231        wasm_outs
232            .iter()
233            .map(wasmval_to_value)
234            .collect::<std::result::Result<_, _>>()
235            .map_err(|()| Error::ArgumentType {
236                function: function.to_string(),
237                backtrace: Backtrace::generate(),
238            })
239    }
240
241    fn grant_capability(&mut self, cap: Capability) -> Result<()> {
242        self.store.data().caps.grant(cap);
243        Ok(())
244    }
245
246    fn revoke_capability(&mut self, cap: &Capability) -> Result<()> {
247        self.store.data().caps.revoke(cap);
248        Ok(())
249    }
250}
251
252fn value_to_wasmval(v: &Value) -> std::result::Result<wasmtime::Val, ()> {
253    Ok(match v {
254        Value::I32(x) => wasmtime::Val::I32(*x),
255        Value::I64(x) => wasmtime::Val::I64(*x),
256        Value::F32(x) => wasmtime::Val::F32(x.to_bits()),
257        Value::F64(x) => wasmtime::Val::F64(x.to_bits()),
258        // Bytes don't have a Val representation; they must cross via
259        // linear-memory copy through a host import. Surface as a type
260        // error here — caller passed Bytes to a typed function.
261        Value::Bytes(_) => return Err(()),
262    })
263}
264
265fn wasmval_to_value(v: &wasmtime::Val) -> std::result::Result<Value, ()> {
266    Ok(match v {
267        wasmtime::Val::I32(x) => Value::I32(*x),
268        wasmtime::Val::I64(x) => Value::I64(*x),
269        wasmtime::Val::F32(x) => Value::F32(f32::from_bits(*x)),
270        wasmtime::Val::F64(x) => Value::F64(f64::from_bits(*x)),
271        _ => return Err(()),
272    })
273}
274
275/// Build the host-import [`Linker`] that every instance shares.
276///
277/// Each import reads the per-instance [`HostState`] from `Caller` and
278/// routes through [`HostImports`] so capability gating is centralized.
279fn build_linker(engine: &Engine) -> Linker<HostState> {
280    let mut linker: Linker<HostState> = Linker::new(engine);
281
282    // `cfm_hash_update(len: i32) -> i64` — gated by
283    // InterfaceAccess { name: "hash" }.
284    let _ = linker.func_wrap(
285        "confium",
286        "cfm_hash_update",
287        |caller: Caller<'_, HostState>, len: i32| -> i64 {
288            let caps = &caller.data().caps;
289            match HostImports::cfm_hash_update(caps, len) {
290                ImportOutcome::Done(v) => v,
291                // Deny: sentinel. A trap would also be defensible;
292                // the sentinel keeps the test surface observable.
293                ImportOutcome::Denied => -1,
294            }
295        },
296    );
297
298    // `cfm_net_send(url_id: i64) -> i64` — gated by NetworkEndpoint.
299    let _ = linker.func_wrap(
300        "confium",
301        "cfm_net_send",
302        |caller: Caller<'_, HostState>, url_id: i64| -> i64 {
303            let caps = &caller.data().caps;
304            match HostImports::cfm_net_send(caps, url_id) {
305                ImportOutcome::Done(v) => v,
306                ImportOutcome::Denied => -1,
307            }
308        },
309    );
310
311    // `cfm_key_get_secret(key_id: i64) -> i64` — gated by KeyAccess.
312    let _ = linker.func_wrap(
313        "confium",
314        "cfm_key_get_secret",
315        |caller: Caller<'_, HostState>, key_id: i64| -> i64 {
316            let caps = &caller.data().caps;
317            match HostImports::cfm_key_get_secret(caps, key_id) {
318                ImportOutcome::Done(v) => v,
319                ImportOutcome::Denied => -1,
320            }
321        },
322    );
323
324    linker
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn sandbox_name_is_wasmtime() {
333        let sb = WasmSandbox::new().unwrap();
334        assert_eq!(sb.name(), "wasmtime");
335    }
336
337    #[test]
338    fn sandbox_is_cloneable_and_shares_engine() {
339        let sb = WasmSandbox::new().unwrap();
340        let _clone = sb.clone();
341    }
342}