rustledger_plugin/sandbox.rs
1//! Shared wasmtime sandbox configuration.
2//!
3//! Both the directive-plugin runtime ([`crate::runtime`]) and the WASM
4//! importer host (`rustledger-importer/src/wasm.rs`) load untrusted
5//! `.wasm` modules into wasmtime. They have the same security model
6//! and should agree on:
7//!
8//! - Which wasm proposals are enabled (attack surface)
9//! - Whether fuel metering is on (`DoS` bound)
10//! - How per-call `Store` resource limits are enforced
11//! - The cost of `Engine` creation (compilation cache + thread pool)
12//!
13//! This module is the single source of truth for those decisions.
14//! Adding a feature flag here applies it to every WASM-loaded
15//! component in rustledger.
16//!
17//! # ⚠️ Breaking change for user WASM plugins
18//!
19//! As of the v0.16-pre reshape, [`sandbox_config`] explicitly disables
20//! these wasm proposals (full list — the rustdoc on `sandbox_config`
21//! explains the rationale for each):
22//!
23//! - `wasm_threads`, `wasm_shared_everything_threads`
24//! - `wasm_multi_memory`, `wasm_memory64`
25//! - `wasm_component_model` (and all sub-flags)
26//! - `wasm_gc`, `wasm_function_references`
27//! - `wasm_stack_switching`, `wasm_tail_call`
28//!
29//! A user-shipped `.wasm` plugin or importer that relies on any
30//! disabled proposal will now fail to compile at load time with a
31//! wasmtime validation error. This is intentional security
32//! tightening, but plugin authors targeting earlier rustledger
33//! versions may need to recompile against the new sandbox profile.
34//!
35//! # Why share the `Engine`?
36//!
37//! wasmtime's `Engine` owns the JIT compilation cache and the
38//! background-compilation thread pool. wasmtime documentation
39//! explicitly recommends one `Engine` per process — sharing it
40//! across all imported modules lets us amortize that cost. A
41//! per-call `Store` still provides isolation; the `Engine` only
42//! holds compiled-code state.
43
44use std::sync::{Arc, OnceLock};
45
46use wasmtime::{Config, Engine, Instance, ResourceLimiter, Store};
47
48/// Default per-instance linear-memory cap (in bytes) for any
49/// sandboxed wasmtime [`Store`] in rustledger.
50///
51/// 256 MiB is generous enough for legitimate plugins / importers /
52/// `CPython`-WASI on import + AST compilation, and small enough to
53/// keep a single hostile call well under host-OOM territory on
54/// memory-constrained hosts (Docker containers, CI runners). The
55/// wasm32 linear-memory ceiling is 4 GiB per `Store` by spec; this
56/// cap brings the per-call ceiling 16x lower.
57///
58/// Currently shared by all three sandboxed wasmtime paths in
59/// rustledger:
60///
61/// - The regular WASM plugin runtime via
62/// [`crate::runtime::RuntimeConfig::default`]
63/// - The WASM importer host via
64/// `rustledger_importer::wasm::WasmRuntimeConfig::default`
65/// - The Python plugin runtime via `crate::python::runtime`
66///
67/// The three subsystems happen to converge on the same value today
68/// because each independently judged 256 MiB to fit its workload
69/// while preserving host headroom — not because the value is
70/// structurally fixed. A subsystem whose workload legitimately needs
71/// a different cap should introduce its own per-subsystem constant
72/// rather than bend this shared default; the shared constant exists
73/// to eliminate drift between subsystems that ARE aligned, not to
74/// force alignment where it would harm correctness.
75pub const DEFAULT_SANDBOX_MAX_MEMORY: usize = 256 * 1024 * 1024;
76
77/// Default per-call CPU-time budget (in seconds) for sandboxed
78/// wasmtime calls in rustledger.
79///
80/// Combined with the "1M wasmtime fuel ~ 1 second of wasm
81/// execution" convention used by [`make_sandboxed_store`], this
82/// gives every sandboxed call ~30 million fuel before exhaustion
83/// trips a trap. Generous enough for legitimate plugins (booking
84/// transactions, classifying entries) and importers (parsing
85/// CSV/OFX statements) while small enough that a runaway call
86/// surfaces as an error within a sensible interactive window
87/// rather than hanging.
88///
89/// Shared by the WASM plugin runtime
90/// ([`crate::runtime::RuntimeConfig::default`]) and the WASM
91/// importer host
92/// (`rustledger_importer::wasm::WasmRuntimeConfig::default`).
93///
94/// # Python opts out
95///
96/// The Python plugin runtime does NOT use this constant. `CPython`
97/// compiled to WASI runs as an interpreter that emits many wasm
98/// instructions per Python-source operation, so a Python workload
99/// at "the same wall-clock budget" needs ~10-100x more wasmtime
100/// fuel than equivalent native wasm. The Python path therefore
101/// sets fuel directly via its own `PYTHON_FUEL` constant
102/// (`crate::python::runtime::PYTHON_FUEL`), independent of this
103/// seconds-based default. The opt-out is principled — interpreter
104/// overhead is a structural property of CPython-on-wasm, not an
105/// oversight.
106pub const DEFAULT_SANDBOX_MAX_TIME_SECS: u64 = 30;
107
108/// Hard cap on the number of elements in any single WASM table.
109///
110/// Importers/plugins don't typically need indirect-call tables at all,
111/// let alone large ones. Each ref-typed slot is pointer-sized (8 bytes
112/// on 64-bit), so 1M elements = ~8 MiB worst case — well under the
113/// memory cap but enough headroom for any plausible indirect-dispatch
114/// pattern. Without this cap, `table.grow` would bypass the memory
115/// limiter (`Memory` and `Table` are separate resource classes in
116/// wasmtime's accounting).
117pub const MAX_TABLE_ELEMENTS: usize = 1024 * 1024;
118
119/// Per-process shared wasmtime [`Engine`] with rustledger's security
120/// posture. Cheap to clone (`Arc`).
121///
122/// # Panics
123///
124/// Panics if wasmtime fails to construct an `Engine` with our config —
125/// this is a process-start invariant; if it fires, the binary is
126/// fundamentally broken, not a runtime condition worth handling.
127#[must_use]
128pub fn shared_engine() -> Arc<Engine> {
129 static ENGINE: OnceLock<Arc<Engine>> = OnceLock::new();
130 ENGINE
131 .get_or_init(|| {
132 let config = sandbox_config();
133 // Bare `.expect` would swallow the wasmtime error detail —
134 // explicit panic preserves the cause for debugging.
135 Arc::new(
136 Engine::new(&config).unwrap_or_else(|e| panic!("wasmtime engine init failed: {e}")),
137 )
138 })
139 .clone()
140}
141
142/// Per-store memory limiter.
143///
144/// Wired into [`Store::limiter`] so wasmtime rejects `memory.grow`
145/// past `max_memory`. Without this, configured memory caps would be
146/// silently ignored — the sandbox would have unbounded heap, which
147/// defeats the "self-contained module" guarantee.
148pub struct MemoryLimiter {
149 max_memory: usize,
150}
151
152impl MemoryLimiter {
153 /// Build a limiter that caps growth (and initial allocation) at
154 /// `max_memory` bytes.
155 #[must_use]
156 pub const fn new(max_memory: usize) -> Self {
157 Self { max_memory }
158 }
159}
160
161impl ResourceLimiter for MemoryLimiter {
162 fn memory_growing(
163 &mut self,
164 _current: usize,
165 desired: usize,
166 _maximum: Option<usize>,
167 ) -> wasmtime::Result<bool> {
168 Ok(desired <= self.max_memory)
169 }
170
171 fn table_growing(
172 &mut self,
173 _current: usize,
174 desired: usize,
175 _maximum: Option<usize>,
176 ) -> wasmtime::Result<bool> {
177 // wasmtime accounts memory and tables separately — without
178 // this cap, `table.grow` would bypass the memory limiter.
179 // `MAX_TABLE_ELEMENTS` is conservative; bump it if a
180 // legitimate module ever needs more.
181 Ok(desired <= MAX_TABLE_ELEMENTS)
182 }
183}
184
185/// Store user-data — just the [`MemoryLimiter`] today.
186///
187/// Kept in a named struct so [`Store::limiter`]'s closure can return
188/// a stable reference and future additions (e.g. a host-side metrics
189/// counter) can land without changing the `Store<T>` type.
190pub struct StoreState {
191 limiter: MemoryLimiter,
192}
193
194impl StoreState {
195 /// Build a state initialized with the given memory cap.
196 #[must_use]
197 pub const fn new(max_memory: usize) -> Self {
198 Self {
199 limiter: MemoryLimiter::new(max_memory),
200 }
201 }
202}
203
204/// Create a [`Store`] with rustledger's sandbox enforcement wired in:
205///
206/// - [`MemoryLimiter`] enforcing `max_memory` on both initial
207/// allocation and `memory.grow`
208/// - Fuel budget computed from `max_time_secs` (clamped `≥1` to
209/// avoid zero-fuel starvation; `saturating_mul` to avoid overflow
210/// on absurd configurations)
211///
212/// Used by both the WASM importer host and the directive-plugin
213/// runtime so the per-call enforcement is identical across the
214/// workspace.
215///
216/// # Errors
217///
218/// Returns `wasmtime::Error` if `set_fuel` fails — which only happens
219/// when `consume_fuel(false)` is configured on the [`Engine`], and
220/// [`sandbox_config`] always sets it true. The `Result` is therefore
221/// defensive: a future refactor flipping the flag will surface the
222/// error rather than silently producing an unmetered Store.
223pub fn make_sandboxed_store(
224 engine: &Engine,
225 max_memory: usize,
226 max_time_secs: u64,
227) -> wasmtime::Result<Store<StoreState>> {
228 let mut store = Store::new(engine, StoreState::new(max_memory));
229 store.limiter(|s| &mut s.limiter);
230 // 1M instructions per second is the same rough budget used
231 // across the workspace.
232 let fuel = max_time_secs.max(1).saturating_mul(1_000_000);
233 store.set_fuel(fuel)?;
234 Ok(store)
235}
236
237/// The `plugin-types` ABI version this host build speaks.
238///
239/// A loaded guest must advertise a matching version via its
240/// `__rustledger_abi_version` export. Re-exported from
241/// [`rustledger_plugin_types::ABI_VERSION`] so host and guest share one
242/// source of truth.
243pub const HOST_ABI_VERSION: u32 = rustledger_plugin_types::ABI_VERSION;
244
245/// Outcome of reading a freshly instantiated guest's ABI version.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum AbiCheck {
248 /// The guest advertises [`HOST_ABI_VERSION`]; it is safe to call.
249 Match,
250 /// The guest does not export `__rustledger_abi_version` (built
251 /// without the `wasm_*_main!` macros, or against a `plugin-types`
252 /// from before the handshake existed), or the export trapped /
253 /// has the wrong signature. Either way the host can't confirm
254 /// compatibility, so the guest is rejected.
255 Missing,
256 /// The guest advertises a different ABI version than the host.
257 Mismatch {
258 /// Version the guest reported.
259 found: u32,
260 },
261}
262
263/// Read a freshly instantiated guest's ABI version and compare it to
264/// [`HOST_ABI_VERSION`].
265///
266/// Call this immediately after [`wasmtime::Linker::instantiate`] and
267/// before invoking any guest entry point. A guest compiled against an
268/// incompatible `plugin-types` otherwise fails far from the load site
269/// with an opaque trap (a misread `PluginInput`, a bad pointer); the
270/// handshake converts that into a clear, actionable error up front
271/// (issue #1234).
272///
273/// The check is intentionally total — a missing export, a wrong
274/// signature, or a trap while calling it all collapse to
275/// [`AbiCheck::Missing`] rather than propagating a `wasmtime::Error`,
276/// because from the host's perspective they are the same condition:
277/// "this guest can't prove it speaks our ABI."
278pub fn check_guest_abi(instance: &Instance, store: &mut Store<StoreState>) -> AbiCheck {
279 let Ok(func) = instance
280 .get_typed_func::<(), u32>(&mut *store, rustledger_plugin_types::ABI_VERSION_EXPORT)
281 else {
282 return AbiCheck::Missing;
283 };
284 match func.call(&mut *store, ()) {
285 Ok(v) if v == HOST_ABI_VERSION => AbiCheck::Match,
286 Ok(found) => AbiCheck::Mismatch { found },
287 Err(_) => AbiCheck::Missing,
288 }
289}
290
291/// Build a wasmtime [`Config`] with rustledger's locked-down security
292/// posture. Exposed for tests and embedders who need to construct an
293/// `Engine` with the same flags but different lifetimes.
294///
295/// Composes [`apply_proposal_disables`] (the WASM-proposal disable
296/// set, shared with the Python runtime's `engine_config`) with
297/// `consume_fuel(true)`. The proposal-list rationale and the
298/// wasmtime-bump maintenance audit both live on
299/// [`apply_proposal_disables`].
300#[must_use]
301pub fn sandbox_config() -> Config {
302 let mut c = Config::new();
303 c.consume_fuel(true);
304 apply_proposal_disables(&mut c);
305 c
306}
307
308/// Apply rustledger's WASM-proposal disable list to an existing
309/// [`Config`].
310///
311/// Single source of truth for the disable set; every rustledger
312/// sandboxed [`Engine`] (the regular plugin / importer path via
313/// [`sandbox_config`], the Python runtime via
314/// `crate::python::runtime`) should call this.
315///
316/// Each disable matches a rationale documented on [`sandbox_config`]:
317///
318/// - `wasm_threads`, `wasm_shared_everything_threads` — concurrency
319/// proposals that bypass per-call `Store` isolation.
320/// - `wasm_multi_memory`, `wasm_memory64` — invalidate the single-
321/// memory accounting in [`ResourceLimiter::memory_growing`] and the
322/// u32-based ABI offset math.
323/// - `wasm_component_model` — we use a custom `MessagePack` ABI, not
324/// components.
325/// - `wasm_gc`, `wasm_function_references` — typed-ref / GC proposals
326/// we don't use; disabled to shrink attack surface.
327/// - `wasm_stack_switching`, `wasm_tail_call` — unused control-flow
328/// proposals.
329///
330/// Proposals NOT touched (default-on and we rely on or tolerate them):
331/// `wasm_simd`, `wasm_bulk_memory`, `wasm_reference_types`,
332/// `wasm_multi_value`, `wasm_extended_const`, `wasm_relaxed_simd`.
333///
334/// # Maintenance: re-audit on every wasmtime bump
335///
336/// wasmtime's `Config::new()` returns its *current* defaults, which
337/// evolve across versions — new proposals routinely land as
338/// default-on. On every wasmtime bump in `Cargo.toml`, re-audit this
339/// function: check wasmtime's release notes for new `wasm_*` features
340/// and decide whether to keep, disable, or leave at default. The
341/// audit covers BOTH sandbox paths because every sandboxed config
342/// flows through this function.
343pub fn apply_proposal_disables(c: &mut Config) {
344 // Concurrency / shared-state proposals.
345 c.wasm_threads(false);
346 c.wasm_shared_everything_threads(false);
347
348 // Multi-memory / 64-bit memory.
349 c.wasm_multi_memory(false);
350 c.wasm_memory64(false);
351
352 // Component model.
353 c.wasm_component_model(false);
354
355 // GC + typed function references.
356 c.wasm_gc(false);
357 c.wasm_function_references(false);
358
359 // Control-flow features we don't use.
360 c.wasm_stack_switching(false);
361 c.wasm_tail_call(false);
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn shared_engine_is_idempotent() {
370 let a = shared_engine();
371 let b = shared_engine();
372 // Same Arc target (both clones of the OnceLock'd engine).
373 assert!(
374 Arc::ptr_eq(&a, &b),
375 "shared_engine must return the same Arc each call"
376 );
377 }
378
379 #[test]
380 fn memory_limiter_rejects_grow_above_max() {
381 let mut limiter = MemoryLimiter::new(1024);
382 assert!(
383 limiter
384 .memory_growing(0, 512, None)
385 .expect("under cap is Ok")
386 );
387 assert!(limiter.memory_growing(0, 1024, None).expect("at cap is Ok"));
388 assert!(
389 !limiter
390 .memory_growing(0, 1025, None)
391 .expect("over cap is Ok(false)")
392 );
393 }
394
395 #[test]
396 fn table_limiter_rejects_grow_above_max() {
397 let mut limiter = MemoryLimiter::new(usize::MAX);
398 assert!(
399 limiter
400 .table_growing(0, MAX_TABLE_ELEMENTS, None)
401 .expect("at cap is Ok")
402 );
403 assert!(
404 !limiter
405 .table_growing(0, MAX_TABLE_ELEMENTS + 1, None)
406 .expect("over cap is Ok(false)")
407 );
408 }
409
410 #[test]
411 fn make_sandboxed_store_wires_fuel_and_limiter() {
412 let engine = shared_engine();
413 let store =
414 make_sandboxed_store(&engine, 1024 * 1024, 30).expect("default config builds a store");
415 // Fuel was set (wasmtime returns Some when set_fuel succeeded).
416 assert!(store.get_fuel().expect("get_fuel succeeds") > 0);
417 }
418
419 #[test]
420 fn make_sandboxed_store_clamps_zero_max_time_secs() {
421 // Regression: max_time_secs = 0 previously caused immediate
422 // fuel-exhaustion trap on first instruction.
423 let engine = shared_engine();
424 let store =
425 make_sandboxed_store(&engine, 1024 * 1024, 0).expect("zero secs clamps, not starves");
426 assert!(store.get_fuel().expect("get_fuel succeeds") > 0);
427 }
428
429 #[test]
430 fn make_sandboxed_store_saturates_huge_max_time_secs() {
431 // Regression: max_time_secs = u64::MAX would overflow the
432 // `* 1_000_000` calc (debug panic, release silent wrap).
433 let engine = shared_engine();
434 let store = make_sandboxed_store(&engine, 1024 * 1024, u64::MAX)
435 .expect("huge secs saturates, doesn't overflow");
436 assert_eq!(store.get_fuel().expect("get_fuel succeeds"), u64::MAX);
437 }
438
439 #[test]
440 fn sandbox_config_rejects_threads_module() {
441 // A module that declares a shared memory (requires
442 // `wasm_threads`) must fail to compile under our config.
443 let wat = r#"
444 (module
445 (memory (export "memory") 1 1 shared)
446 )
447 "#;
448 let bytes = wat::parse_str(wat).expect("WAT parses");
449 let engine = Engine::new(&sandbox_config()).unwrap();
450 let result = wasmtime::Module::new(&engine, &bytes);
451 assert!(
452 result.is_err(),
453 "shared-memory module should be rejected when wasm_threads=false"
454 );
455 }
456
457 #[test]
458 fn sandbox_config_rejects_multi_memory_module() {
459 let wat = r#"
460 (module
461 (memory (export "memory") 1)
462 (memory (export "memory2") 1)
463 )
464 "#;
465 let bytes = wat::parse_str(wat).expect("WAT parses");
466 let engine = Engine::new(&sandbox_config()).unwrap();
467 let result = wasmtime::Module::new(&engine, &bytes);
468 assert!(
469 result.is_err(),
470 "multi-memory module should be rejected when wasm_multi_memory=false"
471 );
472 }
473
474 #[test]
475 fn sandbox_config_rejects_memory64_module() {
476 // `(memory i64 1)` declares an i64-indexed (64-bit) memory,
477 // which requires `wasm_memory64`. Must be rejected.
478 let wat = r#"
479 (module
480 (memory (export "memory") i64 1)
481 )
482 "#;
483 let bytes = wat::parse_str(wat).expect("WAT parses");
484 let engine = Engine::new(&sandbox_config()).unwrap();
485 let result = wasmtime::Module::new(&engine, &bytes);
486 assert!(
487 result.is_err(),
488 "memory64 module should be rejected when wasm_memory64=false"
489 );
490 }
491
492 #[test]
493 fn sandbox_config_rejects_component_module() {
494 // Component-model top-level `(component …)` requires
495 // `wasm_component_model`. We use a custom MessagePack ABI,
496 // not components, so this must be rejected.
497 let wat = r"(component)";
498 let bytes = wat::parse_str(wat).expect("WAT parses");
499 let engine = Engine::new(&sandbox_config()).unwrap();
500 // Components compile via `Component::new`, not `Module::new`.
501 // `Module::new` on component bytes should fail outright.
502 let result = wasmtime::Module::new(&engine, &bytes);
503 assert!(
504 result.is_err(),
505 "component-model module should be rejected when wasm_component_model=false"
506 );
507 }
508
509 #[test]
510 fn sandbox_config_rejects_gc_module() {
511 // A `(struct …)` type definition requires the GC proposal
512 // (`wasm_gc` + `wasm_function_references` for typed refs).
513 // Must be rejected.
514 let wat = r"
515 (module
516 (type $point (struct (field i32) (field i32)))
517 )
518 ";
519 let bytes = wat::parse_str(wat).expect("WAT parses");
520 let engine = Engine::new(&sandbox_config()).unwrap();
521 let result = wasmtime::Module::new(&engine, &bytes);
522 assert!(
523 result.is_err(),
524 "GC struct-type module should be rejected when wasm_gc=false"
525 );
526 }
527}