Files
zandGitHub 760573b9b1 Explicit Memory64 detection and safe sandbox/AOT separation (#1)
* Detect imported and defined Memory64 modules; name unsupported sandbox backend

* Fail explicitly on Memory64 guests in 32-bit wazero sandbox

* Test imported/defined Memory64 classification and explicit runtime rejection

* Document explicit Memory64 sandbox contract and migration requirements

* Clarify Memory64 capability and native-versus-sandbox boundary

* CI: verify formatting, race safety and Memory64 rejection tests

* Format Memory64 sandbox test for Go CI

* Scope CI to self-contained sandbox tests; compiler fixtures require external binaries
2026-10-08 19:21:59 -07:00

217 lines
8.8 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
// Package wasm runs WebAssembly in the calling process.
//
// It WRAPS wazero and does not replace it. wazero is pure Go with no cgo, so a
// binary that embeds it cross-compiles like any other Go program and carries no
// runtime to install beside it — which is the whole reason it is here rather
// than a container runtime. What this package adds is the part every caller
// would otherwise get wrong in the same three ways: compiling once, bounding a
// guest, and closing what it opened.
//
// # Why not a sandbox runtime
//
// A wasm module is ALREADY a sandbox: it cannot reach memory it was not given,
// and it holds no file descriptors, no syscalls and no network unless a host
// hands them over. Putting one inside a container to sandbox it pays for a
// second boundary the first one already provides. Measured on one machine, a
// fresh in-process instance costs ~249 µs and a warm call ~89 ns, against
// ~37.5 ms for a pooled container and ~228 ms to spawn a wasm CLI — four orders
// of magnitude for isolation wasm gives away.
//
// So the rule is: wasm runs HERE. Work that needs a kernel — a shell, pytest,
// pip, cargo — runs under Visor, which is a different question with a different
// answer.
package wasm
import (
"context"
"errors"
"fmt"
"time"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
// ErrNoFunc means the module did not export the name a caller asked for.
var ErrNoFunc = errors.New("wasm: module exports no such function")
// Limits bound ONE guest. The zero value is not a free-for-all: New fills in
// defaults, because a guest with no bound is a guest that can hang the host.
type Limits struct {
// Pages caps guest memory in 64 KiB pages. 0 takes the default (256 pages,
// 16 MiB) rather than meaning "unlimited" — the useful reading of an unset
// bound is a modest one, not an absent one.
Pages uint32
// Run bounds a single call, and the start function Start runs. 0 takes the
// default (5s). A guest loop is not interruptible from outside except by
// cancelling its context, so this is what stops one from holding a goroutine
// forever. It stops loops and nothing else; see New.
Run time.Duration
// NoWASI declines the WASI preview 1 interface, which New otherwise grants.
//
// The default is ON, and that is a deliberate reversal of "capabilities are
// opt-in". A Rust cdylib built for wasm32-wasip1 imports
// wasi_snapshot_preview1 through std whether or not the code calls it, so a
// module that never touches a file still refuses to instantiate without it —
// and the error names the missing module rather than the reason, which is a
// afternoon lost to a panic that has nothing to do with the guest.
//
// It costs little to grant: wazero starts WASI with nothing mounted, so the
// fd calls exist and reach nothing. There is no filesystem and no network
// behind it. Reaching the outside stays the Store's job, where the host holds
// the credential.
//
// Set this only for a guest that genuinely imports nothing — a hand-written
// module, or one from a toolchain that targets wasm32-unknown-unknown.
NoWASI bool
}
const (
defaultPages = uint32(256)
defaultRun = 5 * time.Second
)
func (l Limits) pages() uint32 {
if l.Pages == 0 {
return defaultPages
}
return l.Pages
}
func (l Limits) run() time.Duration {
if l.Run <= 0 {
return defaultRun
}
return l.Run
}
// Engine holds COMPILED modules. Compilation is the expensive half and the
// reusable one — ~833 µs against ~249 µs to instantiate, on the same module and
// machine — so a host compiles once at startup and instantiates per call. An
// Engine is safe for concurrent use; an Instance is not.
type Engine struct {
rt wazero.Runtime
limits Limits
}
// New compiles nothing yet; it prepares the runtime a Module will compile into.
// The returned Engine owns host resources and must be Closed.
//
// It grants WASI unless Limits.NoWASI says otherwise, because almost every
// guest needs it to instantiate at all and nobody should have to learn that from
// a panic. See Limits.NoWASI.
func New(ctx context.Context, l Limits) (*Engine, error) {
// CloseOnContextDone is what lets a context reach a running guest: wazero
// then checks it at the head of every loop, in Go, where the scheduler can
// also stop the goroutine for a garbage collection. Without it the guest is
// native code that neither a context nor the collector can interrupt, and a
// collection that waits on a guest that never returns stops every
// goroutine in the process.
//
// Loops are the only place wazero checks. A guest that runs long through
// recursion alone, with no loop in it, stays native code until it returns:
// Run does not stop it, and a collection waits on it as above.
cfg := wazero.NewRuntimeConfig().WithMemoryLimitPages(l.pages()).WithCloseOnContextDone(true)
rt := wazero.NewRuntimeWithConfig(ctx, cfg)
if !l.NoWASI {
if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil {
_ = rt.Close(ctx)
return nil, fmt.Errorf("wasm: wasi: %w", err)
}
}
return &Engine{rt: rt, limits: l}, nil
}
// Close releases the runtime and every module compiled into it.
func (e *Engine) Close(ctx context.Context) error { return e.rt.Close(ctx) }
// Module is one compiled guest, ready to be instantiated many times.
type Module struct {
eng *Engine
compiled wazero.CompiledModule
}
// Compile turns bytes into a Module. It is the slow call; do it once.
func (e *Engine) Compile(ctx context.Context, src []byte) (*Module, error) {
// A wasm64 module cannot run safely with the current wazero backend.
// Diagnose this explicitly instead of letting an opaque decoder error
// suggest the module is broken. Native wasm2go AOT is a separate,
// unsandboxed execution model and must never be selected implicitly.
if usesMemory64(src) {
return nil, ErrMemory64Unsupported
}
c, err := e.rt.CompileModule(ctx, src)
if err != nil {
return nil, fmt.Errorf("wasm: compile: %w", err)
}
return &Module{eng: e, compiled: c}, nil
}
// Instance is ONE sandbox: its own memory, its own globals, sharing nothing
// with any other. Not safe for concurrent use — take one per caller, which is
// cheap precisely because the module was compiled once.
type Instance struct {
mod api.Module
lim Limits
}
// Start instantiates the module into a fresh sandbox. A start function, or
// _start in a command module, runs before it returns, so Run bounds it as it
// bounds a Call.
func (m *Module) Start(ctx context.Context) (*Instance, error) {
ctx, cancel := context.WithTimeout(ctx, m.eng.limits.run())
defer cancel()
// An anonymous name, deliberately: a named module registers in the runtime
// and a second instantiation under the same name fails. Callers want many
// live sandboxes of one module, which is the ordinary case and must not
// require them to invent unique names.
im, err := m.eng.rt.InstantiateModule(ctx, m.compiled, wazero.NewModuleConfig().WithName(""))
if err != nil {
return nil, fmt.Errorf("wasm: instantiate: %w", err)
}
return &Instance{mod: im, lim: m.eng.limits}, nil
}
// Close frees the sandbox. Every Start needs one.
func (i *Instance) Close(ctx context.Context) error { return i.mod.Close(ctx) }
// Memory is the guest's linear memory, which is how bulk data crosses the
// boundary in the direction the Store does not cover.
//
// Call takes and returns integers, because that is all a wasm signature can
// carry. Anything larger travels as (ptr,len) into this, so a host handing a
// guest a git object writes the bytes here and passes their address. It is the
// same mechanism the host functions use in reverse.
//
// It is the WHOLE sandbox and nothing outside it: a write lands in this
// instance's memory and no other, which is what makes an instance per caller
// the isolation unit rather than a convention.
func (i *Instance) Memory() api.Memory { return i.mod.Memory() }
// Call invokes an exported function under the engine's Run bound.
//
// The deadline bounds a call, not a sandbox: an instance may sit idle for as
// long as its caller likes, and only a running guest can hang.
//
// A call its context ends, by Run or by the caller, stops at the guest's next
// loop iteration and closes the instance, which cannot pick up what the guest
// left half done. The error matches context.DeadlineExceeded or
// context.Canceled with errors.Is, and every later call on the instance
// answers that it is closed.
func (i *Instance) Call(ctx context.Context, name string, args ...uint64) ([]uint64, error) {
fn := i.mod.ExportedFunction(name)
if fn == nil {
return nil, fmt.Errorf("%w: %q", ErrNoFunc, name)
}
ctx, cancel := context.WithTimeout(ctx, i.lim.run())
defer cancel()
out, err := fn.Call(ctx, args...)
if err != nil {
return nil, fmt.Errorf("wasm: call %q: %w", name, err)
}
return out, nil
}