core/cmd/ninja/internal/bnp/verify.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

276 lines
8.1 KiB
Go

package bnp
import (
"archive/tar"
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/klauspost/compress/zstd"
"google.golang.org/protobuf/proto"
)
// These constants and the validation flow below are a DELIBERATE duplication
// of the CMS reader (cms backend/plugin/bnp/reader.go, WO-WZ-008). core cannot
// import the CMS module, so `ninja plugin verify` re-implements the same gate a
// publisher's artifact will meet at load time. WO-WZ-010's integration suite
// keeps the two copies in lockstep; change both together.
const (
// supportedABIMajor is the ABI major version a host understands.
supportedABIMajor uint32 = 1
// maxArtifactBytes caps total decompressed size (matches the registry
// publish cap, 1 GiB) to bound a decompression bomb.
maxArtifactBytes int64 = 1 << 30
// maxEntryBytes caps a single extracted file.
maxEntryBytes int64 = 512 * 1024 * 1024
)
// VerifyResult reports what a valid artifact contains.
type VerifyResult struct {
Name string
Version string
ABIVersion uint32
DataDir bool
BlockCount int
HasMigrations bool
HasSchemas bool
HasAssets bool
HasWeb bool
}
// Verify extracts bnpPath into a temp dir and applies the reader's checks:
// path-safety + size caps on extraction, required members present, manifest
// decodes, abi major supported, non-empty name, and manifest name == plugin.mod
// name. The temp dir is always cleaned up. A named error is returned for each
// malformed class.
func Verify(bnpPath string) (*VerifyResult, error) {
destDir, err := os.MkdirTemp("", "ninja-verify-*")
if err != nil {
return nil, fmt.Errorf("bnp: temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(destDir) }()
f, err := os.Open(bnpPath)
if err != nil {
return nil, fmt.Errorf("bnp: open %s: %w", bnpPath, err)
}
defer func() { _ = f.Close() }()
if err := extractTarZst(f, destDir); err != nil {
return nil, err
}
for _, req := range []string{fileWasm, fileMod, fileManifest} {
if !fileRegular(filepath.Join(destDir, req)) {
return nil, fmt.Errorf("bnp: artifact missing required %s", req)
}
}
manifestBytes, err := os.ReadFile(filepath.Join(destDir, fileManifest))
if err != nil {
return nil, fmt.Errorf("bnp: read manifest.pb: %w", err)
}
manifest := &abiv1.PluginManifest{}
if err := proto.Unmarshal(manifestBytes, manifest); err != nil {
return nil, fmt.Errorf("bnp: decode manifest.pb: %w", err)
}
if v := manifest.GetAbiVersion(); v != supportedABIMajor {
return nil, fmt.Errorf("bnp: unsupported abi_version %d (host supports %d)", v, supportedABIMajor)
}
name := manifest.GetName()
if name == "" {
return nil, errors.New("bnp: manifest has empty name")
}
modBytes, err := os.ReadFile(filepath.Join(destDir, fileMod))
if err != nil {
return nil, fmt.Errorf("bnp: read plugin.mod: %w", err)
}
modName := parseModName(modBytes)
if modName == "" {
return nil, errors.New("bnp: plugin.mod has no name")
}
if modName != name {
return nil, fmt.Errorf("bnp: manifest name %q != plugin.mod name %q", name, modName)
}
return &VerifyResult{
Name: name,
Version: manifest.GetVersion(),
ABIVersion: manifest.GetAbiVersion(),
DataDir: manifest.GetDataDir(),
BlockCount: len(manifest.GetBlocks()),
HasMigrations: dirExists(filepath.Join(destDir, "migrations")),
HasSchemas: dirExists(filepath.Join(destDir, "schemas")),
HasAssets: dirExists(filepath.Join(destDir, "assets")),
HasWeb: dirExists(filepath.Join(destDir, "web")),
}, nil
}
// ReadManifest extracts a .bnp into a temp dir (with the same path-safety and
// size caps as Verify) and returns its decoded manifest.pb. It does not enforce
// the name-match / abi checks — use Verify for the full gate.
func ReadManifest(bnpPath string) (*abiv1.PluginManifest, error) {
destDir, err := os.MkdirTemp("", "ninja-manifest-*")
if err != nil {
return nil, fmt.Errorf("bnp: temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(destDir) }()
f, err := os.Open(bnpPath)
if err != nil {
return nil, fmt.Errorf("bnp: open %s: %w", bnpPath, err)
}
defer func() { _ = f.Close() }()
if err := extractTarZst(f, destDir); err != nil {
return nil, err
}
b, err := os.ReadFile(filepath.Join(destDir, fileManifest))
if err != nil {
return nil, fmt.Errorf("bnp: read manifest.pb: %w", err)
}
m := &abiv1.PluginManifest{}
if err := proto.Unmarshal(b, m); err != nil {
return nil, fmt.Errorf("bnp: decode manifest.pb: %w", err)
}
return m, nil
}
// extractTarZst streams a zstd-compressed tar into destDir, rejecting
// non-regular entries, absolute/traversal paths, and enforcing size caps.
func extractTarZst(r io.Reader, destDir string) error {
zr, err := zstd.NewReader(r)
if err != nil {
return fmt.Errorf("bnp: open zstd stream: %w", err)
}
defer zr.Close()
tr := tar.NewReader(zr)
var total int64
root, err := filepath.Abs(destDir)
if err != nil {
return fmt.Errorf("bnp: resolve dest: %w", err)
}
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("bnp: read tar: %w", err)
}
switch hdr.Typeflag {
case tar.TypeReg, tar.TypeDir:
default:
return fmt.Errorf("bnp: rejected non-regular entry %q (type %d)", hdr.Name, hdr.Typeflag)
}
target, err := safeJoin(root, hdr.Name)
if err != nil {
return err
}
if hdr.Typeflag == tar.TypeDir {
if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("bnp: mkdir %q: %w", hdr.Name, err)
}
continue
}
if hdr.Size > maxEntryBytes {
return fmt.Errorf("bnp: entry %q exceeds per-file cap (%d > %d)", hdr.Name, hdr.Size, maxEntryBytes)
}
if total+hdr.Size > maxArtifactBytes {
return fmt.Errorf("bnp: artifact exceeds total size cap (%d)", maxArtifactBytes)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("bnp: mkdir for %q: %w", hdr.Name, err)
}
n, err := writeExtractedFile(target, io.LimitReader(tr, maxArtifactBytes-total))
if err != nil {
return fmt.Errorf("bnp: extract %q: %w", hdr.Name, err)
}
total += n
if total > maxArtifactBytes {
return fmt.Errorf("bnp: artifact exceeds total size cap (%d)", maxArtifactBytes)
}
}
return nil
}
// safeJoin joins a relative tar entry onto root, rejecting absolute paths and
// traversal segments.
func safeJoin(root, name string) (string, error) {
norm := strings.ReplaceAll(name, `\`, "/")
if norm == "" || norm == "." {
return "", fmt.Errorf("bnp: empty entry name %q", name)
}
if filepath.IsAbs(name) || strings.HasPrefix(norm, "/") {
return "", fmt.Errorf("bnp: absolute entry path %q rejected", name)
}
for _, seg := range strings.Split(norm, "/") {
if seg == ".." {
return "", fmt.Errorf("bnp: entry %q contains a traversal segment", name)
}
}
target := filepath.Join(root, filepath.Clean(norm))
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("bnp: entry %q escapes artifact root", name)
}
return target, nil
}
func writeExtractedFile(path string, r io.Reader) (int64, error) {
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return 0, err
}
n, err := io.Copy(out, r)
closeErr := out.Close()
if err != nil {
return n, err
}
return n, closeErr
}
func fileRegular(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.Mode().IsRegular()
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
// parseModName extracts the `name = "..."` value from a plugin.mod body,
// mirroring the reader's tolerant line scan (works whether or not the key sits
// under a [plugin] table).
func parseModName(data []byte) string {
sc := bufio.NewScanner(bytes.NewReader(data))
for sc.Scan() {
after, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), "name")
if !ok {
continue
}
val, ok := strings.CutPrefix(strings.TrimSpace(after), "=")
if !ok {
continue
}
val = strings.Trim(strings.TrimSpace(val), `"`)
if val != "" {
return val
}
}
return ""
}