`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>
139 lines
3.6 KiB
Go
139 lines
3.6 KiB
Go
package bnp
|
|
|
|
import (
|
|
"archive/tar"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
)
|
|
|
|
// packEntry is one file destined for the artifact: its path inside the .bnp
|
|
// (always forward-slash relative) and either literal Data or a Source file to
|
|
// stream from.
|
|
type packEntry struct {
|
|
ArtifactPath string
|
|
Data []byte
|
|
Source string
|
|
}
|
|
|
|
// packArtifact writes entries as a zstd-compressed tar to outPath. Entries are
|
|
// sorted by artifact path for a deterministic archive. Directory entries are
|
|
// synthesized as needed so the reader's dirExists checks fire for
|
|
// migrations/, schemas/, assets/, web/.
|
|
func packArtifact(outPath string, entries []packEntry) (int64, error) {
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].ArtifactPath < entries[j].ArtifactPath })
|
|
|
|
out, err := os.Create(outPath)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create %s: %w", outPath, err)
|
|
}
|
|
defer func() { _ = out.Close() }()
|
|
|
|
enc, err := zstd.NewWriter(out, zstd.WithEncoderLevel(zstd.SpeedDefault))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
tw := tar.NewWriter(enc)
|
|
|
|
seenDir := map[string]bool{}
|
|
writeDir := func(dir string) error {
|
|
if dir == "" || dir == "." || seenDir[dir] {
|
|
return nil
|
|
}
|
|
seenDir[dir] = true
|
|
return tw.WriteHeader(&tar.Header{
|
|
Name: dir + "/",
|
|
Typeflag: tar.TypeDir,
|
|
Mode: 0o755,
|
|
})
|
|
}
|
|
|
|
var total int64
|
|
for _, e := range entries {
|
|
name := filepath.ToSlash(e.ArtifactPath)
|
|
// Ensure parent directories exist as explicit entries.
|
|
for _, d := range parentDirs(name) {
|
|
if err := writeDir(d); err != nil {
|
|
return 0, fmt.Errorf("write dir header %q: %w", d, err)
|
|
}
|
|
}
|
|
|
|
var data []byte
|
|
if e.Source != "" {
|
|
data, err = os.ReadFile(e.Source)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("read %s: %w", e.Source, err)
|
|
}
|
|
} else {
|
|
data = e.Data
|
|
}
|
|
if err := tw.WriteHeader(&tar.Header{
|
|
Name: name,
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0o644,
|
|
Size: int64(len(data)),
|
|
}); err != nil {
|
|
return 0, fmt.Errorf("write header %q: %w", name, err)
|
|
}
|
|
if _, err := tw.Write(data); err != nil {
|
|
return 0, fmt.Errorf("write %q: %w", name, err)
|
|
}
|
|
total += int64(len(data))
|
|
}
|
|
|
|
if err := tw.Close(); err != nil {
|
|
return 0, fmt.Errorf("close tar: %w", err)
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return 0, fmt.Errorf("close zstd: %w", err)
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// parentDirs returns the ancestor directories of a forward-slash path, from
|
|
// shallowest to deepest ("a/b/c.txt" → ["a", "a/b"]).
|
|
func parentDirs(name string) []string {
|
|
var dirs []string
|
|
parts := strings.Split(name, "/")
|
|
for i := 1; i < len(parts); i++ {
|
|
dirs = append(dirs, strings.Join(parts[:i], "/"))
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// collectDir returns pack entries for every regular file under root, mapped
|
|
// under artifactPrefix. Symlinks and irregular files are skipped. Returns
|
|
// (entries, hadFiles).
|
|
func collectDir(root, artifactPrefix string) ([]packEntry, bool, error) {
|
|
info, err := os.Stat(root)
|
|
if err != nil || !info.IsDir() {
|
|
return nil, false, nil //nolint:nilerr // absent optional dir is not an error
|
|
}
|
|
var entries []packEntry
|
|
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() || !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries = append(entries, packEntry{
|
|
ArtifactPath: artifactPrefix + "/" + filepath.ToSlash(rel),
|
|
Source: path,
|
|
})
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
return nil, false, fmt.Errorf("walk %s: %w", root, walkErr)
|
|
}
|
|
return entries, len(entries) > 0, nil
|
|
}
|