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 } // 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)), }, nil } // 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 || len(m.GetSystemTemplates()) > 0 || len(m.GetPageTemplates()) > 0: return viol("guest-rendered blocks/templates (codeless blocks live in blocks/blocks.yaml)") 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 "" }