The .bnp packers never carried preview.png (the .so-era source archives did implicitly), so any republish silently wiped a theme's registry preview. Both builders now pack an optional root preview.png and a screenshots/ dir (png/jpg/jpeg/webp, <=8MB each, <=12 files, validated), reported in the build/verify summaries. ninja theme screenshot gains --pages/--screenshots-dir to capture NN-<slug>.png per gallery page for the registry gallery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
352 lines
11 KiB
Go
352 lines
11 KiB
Go
package bnp
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"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
|
|
Codeless bool
|
|
DataDir bool
|
|
BlockCount int
|
|
HasMigrations bool
|
|
HasSchemas bool
|
|
HasAssets bool
|
|
HasWeb bool
|
|
HasBlocks bool
|
|
HasTemplates bool
|
|
HasSeed bool
|
|
// HasPreview/ScreenshotCount report the presentation members the registry
|
|
// ingests into the plugin gallery at publish.
|
|
HasPreview bool
|
|
ScreenshotCount int
|
|
}
|
|
|
|
// 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{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)
|
|
}
|
|
// plugin.wasm is required exactly when the manifest is NOT codeless; a
|
|
// codeless artifact must additionally declare no computing hooks
|
|
// (WO-WZ-020 — the classifier should have caught this at build).
|
|
if manifest.GetCodeless() {
|
|
if fileRegular(filepath.Join(destDir, fileWasm)) {
|
|
return nil, errors.New("bnp: codeless manifest but plugin.wasm present — rebuild without the wasm or drop codeless")
|
|
}
|
|
if err := CodelessHookViolation(manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if !fileRegular(filepath.Join(destDir, fileWasm)) {
|
|
return nil, fmt.Errorf("bnp: artifact missing required %s", fileWasm)
|
|
}
|
|
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(),
|
|
Codeless: manifest.GetCodeless(),
|
|
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")),
|
|
HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)),
|
|
HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)),
|
|
HasSeed: dirExists(filepath.Join(destDir, dirSeed)),
|
|
HasPreview: fileRegular(filepath.Join(destDir, filePreview)),
|
|
ScreenshotCount: countScreenshots(filepath.Join(destDir, dirScreenshots)),
|
|
}, nil
|
|
}
|
|
|
|
// countScreenshots counts accepted image files directly under an extracted
|
|
// screenshots/ dir (0 when absent).
|
|
func countScreenshots(root string) int {
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
n := 0
|
|
for _, e := range entries {
|
|
if !e.IsDir() && screenshotExtOK(e.Name()) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// CodelessHookViolation returns a named error when a codeless manifest
|
|
// declares any capability that requires guest code. Mirrored by the CMS
|
|
// reader (lockstep duplication, same as the rest of this file).
|
|
func CodelessHookViolation(m *abiv1.PluginManifest) error {
|
|
viol := func(what string) error {
|
|
return fmt.Errorf("bnp: codeless manifest declares %s — that requires guest code; build a wasm plugin instead", what)
|
|
}
|
|
switch {
|
|
case m.GetHasHttpHandler():
|
|
return viol("an HTTP handler")
|
|
case m.GetHasLoadHook() || m.GetHasUnloadHook():
|
|
return viol("load/unload hooks")
|
|
case m.GetHasMediaHooks():
|
|
return viol("media hooks")
|
|
case m.GetHasProvisioner():
|
|
return viol("a provisioner hook")
|
|
case len(m.GetJobTypes()) > 0:
|
|
return viol("job handlers")
|
|
case len(m.GetRagContentFetcherTypes()) > 0:
|
|
return viol("RAG content fetchers")
|
|
case len(m.GetDeclaredTags()) > 0 || len(m.GetDeclaredFilters()) > 0:
|
|
return viol("template tags/filters (express pure snippets as template partials)")
|
|
case len(m.GetRbacMethodRoles()) > 0 || len(m.GetCoreServiceBindings()) > 0:
|
|
return viol("Connect services")
|
|
case len(m.GetBlocks()) > 0 || len(m.GetTemplateKeys()) > 0:
|
|
return viol("guest-rendered blocks/template keys (codeless blocks live in blocks/blocks.yaml; layouts are declared system/page templates rendered host-side)")
|
|
case m.GetDirectoryExtensions().GetPanelSectionCount() > 0 || m.GetDirectoryExtensions().GetPinDecoratorCount() > 0:
|
|
return viol("directory extension callbacks")
|
|
case m.GetDataDir():
|
|
return viol("a data_dir grant")
|
|
}
|
|
return 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)
|
|
}
|
|
if slices.Contains(strings.Split(norm, "/"), "..") {
|
|
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 ""
|
|
}
|