// 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 := "" 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 }