diff --git a/NOTICE b/NOTICE index 4060149..d5aa5b4 100644 --- a/NOTICE +++ b/NOTICE @@ -4,3 +4,10 @@ cd2607360a17a63bd83a6c99e33c4545f467b57e. Copyright 2026 the Wazy authors. Licensed under the Apache License, Version 2.0. See the repository root LICENSE. + +Selected Component Model conformance fixtures and test scenarios are adapted +from github.com/bytecodealliance/wasmtime at commit +899e66bef961f63a795a371a19a1db019ef9e015. + +Copyright the Wasmtime authors. +Licensed under Apache-2.0 WITH LLVM-exception. diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 5c31ecb..5a922b6 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -63,6 +63,14 @@ type Function interface { CallWithStack(context.Context, []uint64) error } +// IsHostFunction reports whether fn is a synthetic function implemented by +// the host. Such functions can only be entered through a core wasm import and +// must not be called directly by component lifecycle code. +func IsHostFunction(fn Function) bool { + _, ok := fn.(*hostFunction) + return ok +} + type Memory interface { Size() uint32 Read(uint32, uint32) ([]byte, bool) diff --git a/internal/instance/instance.go b/internal/instance/instance.go index ff845d9..3778211 100644 --- a/internal/instance/instance.go +++ b/internal/instance/instance.go @@ -1160,6 +1160,16 @@ func Instantiate(ctx context.Context, r wazy.Runtime, componentBytes []byte, opt // table (not just funcs), and a core func index space where canon-produced // funcs (lower, resource.*) and core-level func aliases interleave. func needsGraphPath(comp *binary.Component) bool { + // The trivial path intentionally binds only the lifted core function and + // does not resolve canonical options. Routing a lift with post-return (or + // any other option carrying runtime behavior) through it would silently + // discard that behavior. The graph path resolves and validates the full + // option set. + for _, canon := range comp.Canons { + if len(canon.Opts) != 0 { + return true + } + } for _, ci := range comp.CoreInstances { if ci.Kind != 0x01 { continue @@ -1823,7 +1833,7 @@ func (in *Instance) invokeEntered(ctx context.Context, be *boundExport, exportNa } putCoreValueSlice(coreArgsPtr) // coreArgs' bits are now copied into stack; done with it - if err := be.coreFn.CallWithStack(ctx, stack); err != nil { + if err := callCoreWithStack(ctx, be.coreFn, stack); err != nil { putUint64Slice(stackPtr) in.poisoned.Store(true) // guest code actually ran and trapped -- see this func's doc err = wrapUnreachableTrap(err) @@ -1855,7 +1865,7 @@ func (in *Instance) invokeEntered(ctx context.Context, be *boundExport, exportNa } // post-return takes the same flat results as params; CallWithStack lets // it reuse rawResults' own buffer (the guest reads params, writes none). - if err := be.postReturnFn.CallWithStack(ctx, rawResults); err != nil { + if err := callCoreWithStack(ctx, be.postReturnFn, rawResults); err != nil { putUint64Slice(stackPtr) in.poisoned.Store(true) // guest code actually ran and trapped -- see this func's doc return nil, fmt.Errorf("component/instance: export %q: post-return %q: %w", exportName, be.postReturnFuncName, err) @@ -1866,6 +1876,34 @@ func (in *Instance) invokeEntered(ctx context.Context, be *boundExport, exportNa return results, nil } +// callCoreWithStack converts an error panic raised by a Component Model host +// adapter into the trap error returned by the public component call. The core +// engine already returns its own traps as errors, but deliberately re-panics +// unknown host panic values. Canonical ABI adapters use error panics to abort a +// guest call when lifting or lowering detects invalid guest-controlled memory. +// Non-error panics remain programmer bugs and are not hidden. +func callCoreWithStack(ctx context.Context, fn api.Function, stack []uint64) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + if recoveredErr, ok := recovered.(error); ok { + err = recoveredErr + return + } + panic(recovered) + } + }() + // Some core engines use a zero-length stack as the sentinel for "nothing + // to execute" in their optimized path. A real () -> () function must still + // run: post-return functions commonly have that signature and may trap or + // perform mandatory cleanup. Use the ordinary entry point for this one + // shape so it cannot be skipped. + if len(stack) == 0 { + _, err = fn.Call(ctx) + return err + } + return fn.CallWithStack(ctx, stack) +} + // lowerParams lowers each component-level argument into its flattened core // values, in parameter order, using be's precomputed per-param type/ // usesMemory/error (see finalizeBoundExport) instead of recomputing them. @@ -2100,6 +2138,9 @@ func (in *Instance) DropResource(ctx context.Context, iface, resourceName string dtorName := iface + "#[dtor]" + resourceName for _, mod := range in.closers { if fn := safeExportedFunction(mod, dtorName); fn != nil { + if api.IsHostFunction(fn) { + continue + } if _, err := fn.Call(ctx, uint64(rep)); err != nil { return fmt.Errorf("component/instance: DropResource %s/%s: destructor: %w", iface, resourceName, err) } diff --git a/testdata/wasmtime/post_return_scalars.wasm b/testdata/wasmtime/post_return_scalars.wasm new file mode 100644 index 0000000..6e9b8ba Binary files /dev/null and b/testdata/wasmtime/post_return_scalars.wasm differ diff --git a/testdata/wasmtime/post_return_scalars.wat b/testdata/wasmtime/post_return_scalars.wat new file mode 100644 index 0000000..75e68e6 --- /dev/null +++ b/testdata/wasmtime/post_return_scalars.wat @@ -0,0 +1,32 @@ +;; Adapted from Wasmtime tests/all/component_model/post_return.rs at +;; 899e66bef961f63a795a371a19a1db019ef9e015. +;; Licensed under Apache-2.0 WITH LLVM-exception. +(component + (core module $m + (func (export "i32") (result i32) i32.const 1) + (func (export "i64") (result i64) i64.const 2) + (func (export "f32") (result f32) f32.const 3) + (func (export "f64") (result f64) f64.const 4) + (func (export "post-i32") (param i32) + local.get 0 i32.const 1 i32.ne if unreachable end) + (func (export "post-i64") (param i64) + local.get 0 i64.const 2 i64.ne if unreachable end) + (func (export "post-f32") (param f32) + local.get 0 f32.const 3 f32.ne if unreachable end) + (func (export "post-f64") (param f64) + local.get 0 f64.const 4 f64.ne if unreachable end) + ) + (core instance $i (instantiate $m)) + (alias core export $i "post-i32" (core func $post-i32)) + (alias core export $i "post-i64" (core func $post-i64)) + (alias core export $i "post-f32" (core func $post-f32)) + (alias core export $i "post-f64" (core func $post-f64)) + (func (export "i32") (result u32) + (canon lift (core func $i "i32") (post-return $post-i32))) + (func (export "i64") (result u64) + (canon lift (core func $i "i64") (post-return $post-i64))) + (func (export "f32") (result float32) + (canon lift (core func $i "f32") (post-return $post-f32))) + (func (export "f64") (result float64) + (canon lift (core func $i "f64") (post-return $post-f64))) +) diff --git a/testdata/wasmtime/post_return_string.wasm b/testdata/wasmtime/post_return_string.wasm new file mode 100644 index 0000000..96f4aa6 Binary files /dev/null and b/testdata/wasmtime/post_return_string.wasm differ diff --git a/testdata/wasmtime/post_return_string.wat b/testdata/wasmtime/post_return_string.wat new file mode 100644 index 0000000..3541e39 --- /dev/null +++ b/testdata/wasmtime/post_return_string.wat @@ -0,0 +1,22 @@ +;; Adapted from Wasmtime tests/all/component_model/post_return.rs at +;; 899e66bef961f63a795a371a19a1db019ef9e015. +;; Licensed under Apache-2.0 WITH LLVM-exception. +(component + (core module $m + (memory (export "memory") 1) + (func (export "get") (result i32) + (i32.store offset=0 (i32.const 8) (i32.const 100)) + (i32.store offset=4 (i32.const 8) (i32.const 11)) + i32.const 8) + (func (export "post") (param i32) + local.get 0 i32.const 8 i32.ne if unreachable end) + (data (i32.const 100) "hello world") + ) + (core instance $i (instantiate $m)) + (alias core export $i "memory" (core memory $memory)) + (alias core export $i "post" (core func $post)) + (func (export "get") (result string) + (canon lift (core func $i "get") + (post-return $post) + (memory $memory))) +) diff --git a/testdata/wasmtime/post_return_trap.wasm b/testdata/wasmtime/post_return_trap.wasm new file mode 100644 index 0000000..df08197 Binary files /dev/null and b/testdata/wasmtime/post_return_trap.wasm differ diff --git a/testdata/wasmtime/post_return_trap.wat b/testdata/wasmtime/post_return_trap.wat new file mode 100644 index 0000000..e61ca64 --- /dev/null +++ b/testdata/wasmtime/post_return_trap.wat @@ -0,0 +1,13 @@ +;; Adapted from Wasmtime tests/all/component_model/post_return.rs at +;; 899e66bef961f63a795a371a19a1db019ef9e015. +;; Licensed under Apache-2.0 WITH LLVM-exception. +(component + (core module $m + (func (export "call")) + (func (export "post") unreachable) + ) + (core instance $i (instantiate $m)) + (alias core export $i "post" (core func $post)) + (func (export "call") + (canon lift (core func $i "call") (post-return $post))) +) diff --git a/testdata/wasmtime/string_length_overflow.wasm b/testdata/wasmtime/string_length_overflow.wasm new file mode 100644 index 0000000..f69f408 Binary files /dev/null and b/testdata/wasmtime/string_length_overflow.wasm differ diff --git a/testdata/wasmtime/string_length_overflow.wat b/testdata/wasmtime/string_length_overflow.wat new file mode 100644 index 0000000..34f3135 --- /dev/null +++ b/testdata/wasmtime/string_length_overflow.wat @@ -0,0 +1,48 @@ +;; Adapted from Wasmtime tests/all/component_model/strings.rs at +;; 899e66bef961f63a795a371a19a1db019ef9e015. +;; Licensed under Apache-2.0 WITH LLVM-exception. +(component + (component $receiver + (core module $m + (func (export "") (param i32 i32)) + (func (export "realloc") (param i32 i32 i32 i32) (result i32) + i32.const 0) + (memory (export "memory") 1) + ) + (core instance $m (instantiate $m)) + (alias core export $m "realloc" (core func $realloc)) + (alias core export $m "memory" (core memory $memory)) + (func (export "accept") (param "value" string) + (canon lift (core func $m "") + (realloc $realloc) + (memory $memory) + string-encoding=utf8) + ) + ) + + (component $sender + (import "accept" (func $accept (param "value" string))) + (core module $memory + (memory (export "memory") 1) + ) + (core instance $memory (instantiate $memory)) + (alias core export $memory "memory" (core memory $linear-memory)) + (core func $accept (canon lower (func $accept) + string-encoding=utf8 + (memory $linear-memory))) + (core module $call + (import "" "accept" (func $accept (param i32 i32))) + (func (export "call") (param i32) + (call $accept (i32.const 1000) (local.get 0))) + ) + (core instance $call (instantiate $call + (with "" (instance (export "accept" (func $accept)))))) + (func (export "call") (param "length" u32) + (canon lift (core func $call "call"))) + ) + + (instance $receiver (instantiate $receiver)) + (instance $sender (instantiate $sender + (with "accept" (func $receiver "accept")))) + (export "call" (func $sender "call")) +) diff --git a/testdata/wasmtime/string_ptr_oob.wasm b/testdata/wasmtime/string_ptr_oob.wasm new file mode 100644 index 0000000..75f1710 Binary files /dev/null and b/testdata/wasmtime/string_ptr_oob.wasm differ diff --git a/testdata/wasmtime/string_ptr_oob.wat b/testdata/wasmtime/string_ptr_oob.wat new file mode 100644 index 0000000..ff4ac74 --- /dev/null +++ b/testdata/wasmtime/string_ptr_oob.wat @@ -0,0 +1,46 @@ +;; Adapted from Wasmtime tests/all/component_model/strings.rs at +;; 899e66bef961f63a795a371a19a1db019ef9e015. +;; Licensed under Apache-2.0 WITH LLVM-exception. +(component + (component $receiver + (core module $m + (func (export "") (param i32 i32)) + (func (export "realloc") (param i32 i32 i32 i32) (result i32) + i32.const 0) + (memory (export "memory") 1) + ) + (core instance $m (instantiate $m)) + (alias core export $m "realloc" (core func $realloc)) + (alias core export $m "memory" (core memory $memory)) + (func (export "accept") (param "value" string) + (canon lift (core func $m "") + (realloc $realloc) + (memory $memory) + string-encoding=utf8) + ) + ) + + (component $sender + (import "accept" (func $accept (param "value" string))) + (core module $memory + (memory (export "memory") 1) + ) + (core instance $memory (instantiate $memory)) + (alias core export $memory "memory" (core memory $linear-memory)) + (core func $accept (canon lower (func $accept) + string-encoding=utf8 + (memory $linear-memory))) + (core module $start + (import "" "accept" (func $accept (param i32 i32))) + (func $start + (call $accept (i32.const 0x80000000) (i32.const 1))) + (start $start) + ) + (core instance (instantiate $start + (with "" (instance (export "accept" (func $accept)))))) + ) + + (instance $receiver (instantiate $receiver)) + (instance $sender (instantiate $sender + (with "accept" (func $receiver "accept")))) +) diff --git a/testdata/wasmtime/string_realloc_oob.wasm b/testdata/wasmtime/string_realloc_oob.wasm new file mode 100644 index 0000000..c91b9c9 Binary files /dev/null and b/testdata/wasmtime/string_realloc_oob.wasm differ diff --git a/testdata/wasmtime/string_realloc_oob.wat b/testdata/wasmtime/string_realloc_oob.wat new file mode 100644 index 0000000..7039e41 --- /dev/null +++ b/testdata/wasmtime/string_realloc_oob.wat @@ -0,0 +1,48 @@ +;; Adapted from Wasmtime tests/all/component_model/strings.rs at +;; 899e66bef961f63a795a371a19a1db019ef9e015. +;; Licensed under Apache-2.0 WITH LLVM-exception. +(component + (component $receiver + (core module $m + (func (export "") (param i32 i32)) + (func (export "realloc") (param i32 i32 i32 i32) (result i32) + i32.const 100000) + (memory (export "memory") 1) + ) + (core instance $m (instantiate $m)) + (alias core export $m "realloc" (core func $realloc)) + (alias core export $m "memory" (core memory $memory)) + (func (export "accept") (param "value" string) + (canon lift (core func $m "") + (realloc $realloc) + (memory $memory) + string-encoding=utf8) + ) + ) + + (component $sender + (import "accept" (func $accept (param "value" string))) + (core module $memory + (memory (export "memory") 1) + (data (i32.const 1000) "0123456789") + ) + (core instance $memory (instantiate $memory)) + (alias core export $memory "memory" (core memory $linear-memory)) + (core func $accept (canon lower (func $accept) + string-encoding=utf8 + (memory $linear-memory))) + (core module $call + (import "" "accept" (func $accept (param i32 i32))) + (func (export "call") + (call $accept (i32.const 1000) (i32.const 10))) + ) + (core instance $call (instantiate $call + (with "" (instance (export "accept" (func $accept)))))) + (func (export "call") (canon lift (core func $call "call"))) + ) + + (instance $receiver (instantiate $receiver)) + (instance $sender (instantiate $sender + (with "accept" (func $receiver "accept")))) + (export "call" (func $sender "call")) +) diff --git a/wasmtime_conformance_test.go b/wasmtime_conformance_test.go new file mode 100644 index 0000000..2c89e9a --- /dev/null +++ b/wasmtime_conformance_test.go @@ -0,0 +1,188 @@ +package component_test + +import ( + "context" + _ "embed" + "strings" + "testing" + + "github.com/wago-org/component-model" + "github.com/wago-org/wago" +) + +//go:embed testdata/wasmtime/string_ptr_oob.wasm +var wasmtimeStringPointerOutOfBounds []byte + +//go:embed testdata/wasmtime/string_length_overflow.wasm +var wasmtimeStringLengthOverflow []byte + +//go:embed testdata/wasmtime/string_realloc_oob.wasm +var wasmtimeStringReallocOutOfBounds []byte + +//go:embed testdata/wasmtime/post_return_scalars.wasm +var wasmtimePostReturnScalars []byte + +//go:embed testdata/wasmtime/post_return_trap.wasm +var wasmtimePostReturnTrap []byte + +//go:embed testdata/wasmtime/post_return_string.wasm +var wasmtimePostReturnString []byte + +// Ported from Wasmtime's component-model string pointer bounds regression. +// Instantiation executes the sender's start function, which attempts to pass a +// string whose guest pointer is outside its linear memory. +func TestWasmtimeStringPointerOutOfBounds(t *testing.T) { + runtime := wago.NewRuntime() + defer runtime.Close() + components, err := component.Enable(runtime) + if err != nil { + t.Fatalf("enable component plugin: %v", err) + } + + instance, err := components.Instantiate(context.Background(), wasmtimeStringPointerOutOfBounds) + if instance != nil { + _ = instance.Close(context.Background()) + } + if err == nil { + t.Fatal("instantiated component with out-of-bounds string pointer") + } + if message := strings.ToLower(err.Error()); !strings.Contains(message, "string") || + (!strings.Contains(message, "bound") && !strings.Contains(message, "overflow")) { + t.Fatalf("out-of-bounds string error = %q", err) + } +} + +// Ported from Wasmtime's string length overflow regression. Canonical ABI +// adapters must reject malicious lengths before pointer arithmetic wraps. +func TestWasmtimeStringLengthOverflow(t *testing.T) { + runtime := wago.NewRuntime() + defer runtime.Close() + components, err := component.Enable(runtime) + if err != nil { + t.Fatalf("enable component plugin: %v", err) + } + instance, err := components.Instantiate(context.Background(), wasmtimeStringLengthOverflow) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + defer instance.Close(context.Background()) + + _, err = instance.Call(context.Background(), "call", uint32(1<<31)) + if err == nil { + t.Fatal("accepted overflowing string length") + } + if message := strings.ToLower(err.Error()); !strings.Contains(message, "string") || + (!strings.Contains(message, "bound") && !strings.Contains(message, "overflow")) { + t.Fatalf("overflowing string length error = %q", err) + } +} + +// Ported from Wasmtime's realloc bounds regression. A guest realloc function +// returning memory outside its linear memory must trap before any host access. +func TestWasmtimeStringReallocOutOfBounds(t *testing.T) { + runtime := wago.NewRuntime() + defer runtime.Close() + components, err := component.Enable(runtime) + if err != nil { + t.Fatalf("enable component plugin: %v", err) + } + instance, err := components.Instantiate(context.Background(), wasmtimeStringReallocOutOfBounds) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + defer instance.Close(context.Background()) + + _, err = instance.Call(context.Background(), "call") + if err == nil { + t.Fatal("accepted realloc result outside guest memory") + } + if message := strings.ToLower(err.Error()); !strings.Contains(message, "string") || + (!strings.Contains(message, "bound") && !strings.Contains(message, "overflow")) { + t.Fatalf("out-of-bounds realloc error = %q", err) + } +} + +// Ported from Wasmtime's post-return scalar coverage. This proves the adapter +// passes the exact flattened result values to each post-return function. +func TestWasmtimePostReturnReceivesScalarResults(t *testing.T) { + runtime := wago.NewRuntime() + defer runtime.Close() + components, err := component.Enable(runtime) + if err != nil { + t.Fatalf("enable component plugin: %v", err) + } + instance, err := components.Instantiate(context.Background(), wasmtimePostReturnScalars) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + defer instance.Close(context.Background()) + + tests := []struct { + name string + want component.Value + }{ + {"i32", uint32(1)}, + {"i64", uint64(2)}, + {"f32", float32(3)}, + {"f64", float64(4)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := instance.Call(context.Background(), test.name) + if err != nil { + t.Fatalf("call: %v", err) + } + if len(got) != 1 || got[0] != test.want { + t.Fatalf("result = %#v, want [%#v]", got, test.want) + } + }) + } +} + +// Ported from Wasmtime's post-return trap regression. Once guest code traps +// during post-return, the Component Model forbids all subsequent entry. +func TestWasmtimePostReturnTrapPoisonsInstance(t *testing.T) { + runtime := wago.NewRuntime() + defer runtime.Close() + components, err := component.Enable(runtime) + if err != nil { + t.Fatalf("enable component plugin: %v", err) + } + instance, err := components.Instantiate(context.Background(), wasmtimePostReturnTrap) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + defer instance.Close(context.Background()) + + if _, err := instance.Call(context.Background(), "call"); err == nil || !strings.Contains(strings.ToLower(err.Error()), "unreachable") { + t.Fatalf("first call error = %v, want unreachable trap", err) + } + if _, err := instance.Call(context.Background(), "call"); err == nil || !strings.Contains(strings.ToLower(err.Error()), "cannot enter") { + t.Fatalf("second call error = %v, want poisoned instance", err) + } +} + +// Ported from Wasmtime's indirect-result post-return coverage. The adapter +// must finish lifting the string before passing its return-area pointer to the +// guest cleanup function. +func TestWasmtimePostReturnString(t *testing.T) { + runtime := wago.NewRuntime() + defer runtime.Close() + components, err := component.Enable(runtime) + if err != nil { + t.Fatalf("enable component plugin: %v", err) + } + instance, err := components.Instantiate(context.Background(), wasmtimePostReturnString) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + defer instance.Close(context.Background()) + + got, err := instance.Call(context.Background(), "get") + if err != nil { + t.Fatalf("call: %v", err) + } + if len(got) != 1 || got[0] != "hello world" { + t.Fatalf("result = %#v, want [\"hello world\"]", got) + } +}