Rewrite import prefix git.dev.alexdunmow.com/block/core/{plugin,blocks,
templates,abi} to git.dev.alexdunmow.com/block/pluginsdk/* across the CLI's
plugin build/verify logic (internal/bnp), plugin commands (cmd/ninja/cmd),
and the wasm build fixtures (testdata/fixture, testdata/capfixture). go.mod
adds pluginsdk@v0.1.0; core is retained only for cmd/ninja/cmd/theme.go
(ParseModFull), which is left untouched as in-flight work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
172 lines
6.2 KiB
Go
172 lines
6.2 KiB
Go
// Package bnp implements `ninja plugin build` and `ninja plugin verify`: it
|
|
// compiles a plugin to reactor-mode wasm, extracts its static manifest by
|
|
// instantiating the module once and calling HOOK_DESCRIBE, packs the .bnp
|
|
// artifact (tar.zst), and re-runs the CMS reader's layout/name/abi/path/size
|
|
// checks standalone.
|
|
//
|
|
// The verify rules here are a deliberate, documented duplication of the CMS
|
|
// reader (cms backend/plugin/bnp/reader.go, WO-WZ-008): core cannot import the
|
|
// CMS module, so publishers get the same gate the loader applies. WO-WZ-010's
|
|
// integration suite keeps the two in lockstep.
|
|
package bnp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
|
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
|
"github.com/tetratelabs/wazero"
|
|
"github.com/tetratelabs/wazero/api"
|
|
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// hostAbiVersion is the ABI major version the packer speaks when driving
|
|
// DESCRIBE. It must equal core's guest AbiVersion / the reader's supported
|
|
// major (1) or the guest's symmetric version gate rejects the call.
|
|
const hostAbiVersion uint32 = 1
|
|
|
|
// ExtractManifest instantiates a reactor-mode plugin.wasm with wazero, drives
|
|
// one HOOK_DESCRIBE round trip, and returns the decoded PluginManifest.
|
|
//
|
|
// The host functions the guest imports (blockninja.host_call) are stubbed to
|
|
// FAIL: DESCRIBE is publish-time and must not need a live host. A plugin that
|
|
// reaches a host capability while its manifest is being extracted (e.g. a
|
|
// db.* call from Register) gets an actionable error naming the offending
|
|
// method rather than a mystery hang or nil deref.
|
|
func ExtractManifest(ctx context.Context, wasm []byte) (*abiv1.PluginManifest, error) {
|
|
r := wazero.NewRuntime(ctx)
|
|
defer func() { _ = r.Close(ctx) }()
|
|
|
|
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
|
|
return nil, fmt.Errorf("instantiate wasi: %w", err)
|
|
}
|
|
if err := installFailingHost(ctx, r); err != nil {
|
|
return nil, fmt.Errorf("install host stub: %w", err)
|
|
}
|
|
|
|
mod, err := r.InstantiateWithConfig(ctx, wasm,
|
|
wazero.NewModuleConfig().WithStartFunctions("_initialize").WithName("plugin"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("instantiate module (is it a reactor-mode wasip1 c-shared build?): %w", err)
|
|
}
|
|
for _, export := range []string{"bn_alloc", "bn_invoke", "bn_free"} {
|
|
if mod.ExportedFunction(export) == nil {
|
|
return nil, fmt.Errorf("module does not export %s — not a BlockNinja wasm guest (missing wasmguest.Serve boilerplate?)", export)
|
|
}
|
|
}
|
|
|
|
resp, err := invokeDescribe(ctx, mod)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if e := resp.GetError(); e != nil {
|
|
return nil, fmt.Errorf("DESCRIBE failed (%s): %s", e.GetCode(), e.GetMessage())
|
|
}
|
|
dr := &abiv1.DescribeResponse{}
|
|
if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil {
|
|
return nil, fmt.Errorf("decode DescribeResponse: %w", err)
|
|
}
|
|
m := dr.GetManifest()
|
|
if m == nil {
|
|
return nil, fmt.Errorf("DESCRIBE returned an empty manifest")
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// invokeDescribe drives one bn_alloc / bn_invoke round trip for HOOK_DESCRIBE
|
|
// through guest linear memory, mirroring the calling convention in
|
|
// core/docs/wasm-abi.md.
|
|
func invokeDescribe(ctx context.Context, mod api.Module) (*abiv1.InvokeResponse, error) {
|
|
payload, err := proto.Marshal(&abiv1.DescribeRequest{HostAbiVersion: hostAbiVersion})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal DescribeRequest: %w", err)
|
|
}
|
|
req, err := proto.Marshal(&abiv1.InvokeRequest{Hook: abiv1.Hook_HOOK_DESCRIBE, Payload: payload})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal InvokeRequest: %w", err)
|
|
}
|
|
|
|
res, err := mod.ExportedFunction("bn_alloc").Call(ctx, uint64(len(req)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bn_alloc: %w", err)
|
|
}
|
|
ptr := uint32(res[0])
|
|
if ptr == 0 {
|
|
return nil, fmt.Errorf("bn_alloc returned 0")
|
|
}
|
|
if !mod.Memory().Write(ptr, req) {
|
|
return nil, fmt.Errorf("write DESCRIBE request out of range")
|
|
}
|
|
|
|
res, err = mod.ExportedFunction("bn_invoke").Call(ctx,
|
|
uint64(abiv1.Hook_HOOK_DESCRIBE), uint64(ptr), uint64(len(req)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bn_invoke(DESCRIBE): %w", err)
|
|
}
|
|
packed := res[0]
|
|
if packed == 0 {
|
|
return nil, fmt.Errorf("bn_invoke returned packed 0 (guest could not produce a response envelope)")
|
|
}
|
|
respBytes, ok := mod.Memory().Read(uint32(packed>>32), uint32(packed))
|
|
if !ok {
|
|
return nil, fmt.Errorf("read DESCRIBE response out of range")
|
|
}
|
|
resp := &abiv1.InvokeResponse{}
|
|
if err := proto.Unmarshal(respBytes, resp); err != nil {
|
|
return nil, fmt.Errorf("decode InvokeResponse: %w", err)
|
|
}
|
|
_, _ = mod.ExportedFunction("bn_free").Call(ctx, uint64(ptr))
|
|
return resp, nil
|
|
}
|
|
|
|
// installFailingHost exports blockninja.host_call so the guest module can be
|
|
// instantiated, but answers every capability call with a PERMISSION_DENIED
|
|
// AbiError naming the method — DESCRIBE must not depend on the host.
|
|
func installFailingHost(ctx context.Context, r wazero.Runtime) error {
|
|
_, err := r.NewHostModuleBuilder("blockninja").
|
|
NewFunctionBuilder().
|
|
WithFunc(func(ctx context.Context, mod api.Module, ptr, size uint32) uint64 {
|
|
method := "<unknown>"
|
|
if data, ok := mod.Memory().Read(ptr, size); ok {
|
|
hcr := &abiv1.HostCallRequest{}
|
|
if proto.Unmarshal(data, hcr) == nil && hcr.GetMethod() != "" {
|
|
method = hcr.GetMethod()
|
|
}
|
|
}
|
|
out, err := proto.Marshal(&abiv1.HostCallResponse{
|
|
Error: &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_PERMISSION_DENIED,
|
|
Message: fmt.Sprintf(
|
|
"capability %q is unavailable during manifest extraction (DESCRIBE): a plugin must not call host capabilities at register/describe time",
|
|
method),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
res, err := mod.ExportedFunction("bn_alloc").Call(ctx, uint64(len(out)))
|
|
if err != nil || len(res) == 0 || res[0] == 0 {
|
|
return 0
|
|
}
|
|
respPtr := uint32(res[0])
|
|
if !mod.Memory().Write(respPtr, out) {
|
|
return 0
|
|
}
|
|
return uint64(respPtr)<<32 | uint64(uint32(len(out)))
|
|
}).
|
|
Export("host_call").
|
|
Instantiate(ctx)
|
|
return err
|
|
}
|
|
|
|
// readWasm is a tiny helper so callers can pass a path.
|
|
func readWasm(path string) ([]byte, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read wasm %s: %w", path, err)
|
|
}
|
|
return b, nil
|
|
}
|