Skip to content

Module Format and WAT

sarmakska edited this page Jun 7, 2026 · 2 revisions

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.

What run accepts

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 .wasm binary (what a real toolchain emits), or
  • .wat text 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.

The contract a module must satisfy

A module sandboxd can run successfully has to meet a short list:

  1. It exports the function you ask for. run(bytes, "add", ...) needs an (export "add" ...). A missing export is SandboxError::Export.
  2. That function's parameters match the Values you pass, in count and scalar type. The supported types are i32, i64, f32, f64. A mismatch is SandboxError::Export, caught by check_signature before the call.
  3. It imports nothing the host did not grant. The default grants nothing; allow_log grants host::log. Anything else is SandboxError::DisallowedImport.
  4. If it uses host::log, it exports its linear memory as memory. The host reads the log string out of that memory by name. Without it the host call traps with a clear message.
  5. 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.

Reading the fixtures

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.

well_behaved.wat: the happy path

(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.

infinite_loop.wat: the compute attack

(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.

memory_bomb.wat: the memory attack

(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.

disallowed_import.wat: the capability attack

(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.

logger.wat: the audited capability

(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.

random.wat: the second audited capability

(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.

grow_within_cap.wat: a guest that allocates but behaves

(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.

How host::log reads guest memory

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.

The page model

%%{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]
Loading

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

Clone this wiki locally