core/cmd/ninja/internal/bnp/manifest.go
Alex Dunmow ad6d87bf06 feat(ninja): plugin build/verify → .bnp packer + ABI riders (WO-WZ-009)
`ninja plugin build` compiles a plugin to reactor-mode wasip1 wasm (Go >= 1.24
enforced), extracts manifest.pb by driving one HOOK_DESCRIBE over wazero with
failing host stubs, and packs a tar.zst .bnp (plugin.wasm, plugin.mod,
manifest.pb + migrations/schemas/assets/web-dist when present) with a summary
table. A describe-time capability call (e.g. db.* from Register) fails with an
actionable error naming the offending method. `ninja plugin verify` re-runs the
CMS reader's layout/name/abi/path-safety/size checks standalone (deliberate
duplication of cms backend/plugin/bnp/reader.go; kept in lockstep by WO-WZ-010).

ABI riders (additive; buf breaking clean):
- ABI_ERROR_CODE_TX_EXPIRED enum value + bnwasm guest mapping to a new
  bnwasm.ErrTxExpired sentinel (retryable tx expiry, distinct from real faults);
  the cms dbexec side adopts the emit separately.
- PluginManifest.data_dir bool + a first-class `data_dir` key on the plugin.mod
  parser (so writeMod's struct round-trip can't drop it); `plugin build` stamps
  it from plugin.mod into the manifest.

Docs: wasm-abi.md gains a Building & packing section, the error-code table row,
the manifest data_dir mapping, and the plugin.mod reference. Tests: CLI e2e
builds the WZ-002 fixture → verify + manifest block keys; a capfixture proves
the actionable describe-time error; verify rejects each malformed class;
bnwasm TX_EXPIRED classification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:34:17 +08:00

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/core/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
}