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 "" }