-
Notifications
You must be signed in to change notification settings - Fork 0
Module Format and WAT
What sandboxd accepts as input, what shape a module must have to run, and how to read the shipped fixtures. This page is the bridge between "I have some code" and "sandboxd runs it". For compiling a guest from a real language, see Writing a Guest Module.
Sandbox::run and Sandbox::compile both take &[u8]. wasmtime parses both binary .wasm and textual .wat from the same bytes, because the wat feature is enabled in Cargo.toml:
wasmtime = { version = "45", default-features = false, features = ["cranelift", "runtime", "parallel-compilation", "gc-drc", "wat"] }So you can pass:
- a compiled
.wasmbinary (what a real toolchain emits), or -
.wattext source (what the fixtures are, and what is convenient for tests and examples).
Anything that is neither becomes SandboxError::InvalidModule. The invalid_module_is_reported test feeds it b"this is not wasm" and asserts that variant.
A module sandboxd can run successfully has to meet a short list:
-
It exports the function you ask for.
run(bytes, "add", ...)needs an(export "add" ...). A missing export isSandboxError::Export. -
That function's parameters match the
Values you pass, in count and scalar type. The supported types arei32,i64,f32,f64. A mismatch isSandboxError::Export, caught bycheck_signaturebefore the call. -
It imports nothing the host did not grant. The default grants nothing;
allow_loggrantshost::log. Anything else isSandboxError::DisallowedImport. -
If it uses
host::log, it exports its linear memory asmemory. The host reads the log string out of that memory by name. Without it the host call traps with a clear message. - It stays within the fuel, time and memory limits. Otherwise it is stopped with the matching error.
That is the whole contract. There is no manifest, no required entry point name (the CLI defaults to run, but the library lets you name any export), and no metadata.
The .wat files in fixtures/ are both the test corpus and the documentation of each behaviour. Each one is small enough to read in full.
(module
(func (export "add") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add)
(func (export "fib") (param $n i32) (result i32)
(local $a i32) (local $b i32) (local $i i32) (local $tmp i32)
(local.set $a (i32.const 0))
(local.set $b (i32.const 1))
(local.set $i (i32.const 0))
(block $done
(loop $next
(br_if $done (i32.ge_s (local.get $i) (local.get $n)))
(local.set $tmp (i32.add (local.get $a) (local.get $b)))
(local.set $a (local.get $b))
(local.set $b (local.get $tmp))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $next)))
local.get $a))Two pure functions, no imports, no memory. add returns the sum of two i32s. fib computes a Fibonacci number iteratively. Both terminate quickly and deterministically, so they run under any sane limits. fib is the determinism witness: fib(20) returns 6765 and burns identical fuel on every run, which the pure_module_is_deterministic test asserts.
(module
(func (export "run")
(loop $spin
br $spin)))A back-edge loop with no exit. Stopped two independent ways: fuel runs out (FuelExhausted), or with near-infinite fuel the watchdog interrupts it at the deadline (Timeout). The br $spin back-edge is exactly where the epoch check lives, which is why the watchdog can stop it.
(module
(memory (export "memory") 1)
(func (export "run")
(loop $grow
(i32.const 16)
memory.grow
(i32.const -1)
i32.eq
(if (then unreachable))
br $grow)))Grows linear memory 16 pages (1 MiB) at a time in a loop. When the cap is reached, memory.grow returns -1, the guest compares to -1 and executes unreachable. sandboxd reports MemoryLimitExceeded because the limiter recorded the denied growth, regardless of the unreachable.
(module
(import "env" "secret" (func $secret (result i32)))
(func (export "run") (result i32)
call $secret))Imports env::secret, which sandboxd does not provide. Rejected at instantiation with DisallowedImport naming env::secret, before run ever executes.
(module
(import "host" "log" (func $log (param i32 i32)))
(memory (export "memory") 1)
(data (i32.const 0) "hello from the guest")
(func (export "run")
(call $log (i32.const 0) (i32.const 20))))Stores 20 bytes into linear memory at offset 0 and calls host::log with that pointer and length. Denied by default (DisallowedImport), captured when granted (allow_log). Note the (memory (export "memory") 1): that export is what lets the host read the string.
(module
(import "host" "random" (func $random (result i64)))
(func (export "roll") (result i32)
(i32.wrap_i64 (call $random))))Imports host::random, a seeded deterministic generator, and returns the next value truncated to i32. Denied by default (DisallowedImport), reproducible when granted with allow_random(seed). Unlike the logger it needs no linear memory: the capability hands back a number directly, so there is no pointer to validate.
(module
(memory (export "memory") 1)
(func (export "run") (result i32)
(drop (memory.grow (i32.const 31)))
(memory.size)))The counterpart to memory_bomb.wat: it grows from 1 page to 32 pages (2 MiB) and stops, returning the final page count. Under a cap above 2 MiB the growth is allowed and the run reports peak_memory_bytes == 2 MiB. This is what proves the high-water mark is recorded on a successful run, not only on a breach.
The ABI for the one shipped capability is (ptr: i32, len: i32) -> (). The guest writes a UTF-8 string into its own linear memory and passes the offset and length. The host copies it out with full bounds checking and never hands a pointer back. The detail, including the overflow and out-of-bounds guards, is on the Host ABI page.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#0d1117','primaryTextColor':'#f5f7fa','primaryBorderColor':'#38bdf8','lineColor':'#22d3ee','secondaryColor':'#0f172a','tertiaryColor':'#0d1117','fontFamily':'ui-monospace, monospace'}}}%%
flowchart LR
A[Guest linear memory] -->|grows in 64 KiB pages| B[memory_bytes cap]
A -->|host::log ptr,len| C[bounds-checked copy]
C --> D[LogSink owned by you]
WebAssembly linear memory is a contiguous byte array that grows in 64 KiB pages. The memory_bytes limit caps its total size, rounded down to a page boundary. The guest addresses it with plain integer offsets, which is why the host::log bounds check validates ptr and ptr + len against the actual memory size before reading.
SarmaLinux . sarmalinux.com . repo
SarmaLinux . sarmalinux.com . sandboxd on GitHub
Understand it
Reference
Operate it
Security
Extend it
Decide on it
SarmaLinux . sarmalinux.com