`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>
276 lines
8.1 KiB
Go
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 ""
|
|
}
|