feat(plugin): validate keyed load-once manifests

This commit is contained in:
Alex Dunmow 2026-08-10 23:44:40 +08:00
parent 9e802e005b
commit d3e2bc65a2
8 changed files with 121 additions and 7 deletions

View File

@ -0,0 +1,20 @@
# Keyed load-once manifests are validated before packing
The plugin SDK and CMS host support keyed `LoadOnce` callbacks whose durable
identity is the `(plugin name, key)` pair. A malformed or duplicate key would
make execution ambiguous and should not survive until production installation.
Decision: both `ninja plugin build` and `ninja plugin verify` validate every
`load_once_keys` manifest entry with the SDK's canonical rules. Keys are 1128
bytes, start alphanumeric, and contain only lower-case letters, digits, `.`,
`_`, or `-`; duplicates are rejected. Codeless artifacts reject load-once keys
because they have no guest callback to execute.
Consequences:
- Build and verification fail before an invalid artifact can be published.
- CLI and host use the same SDK validator instead of drifting grammars.
- Changing a valid key remains the deliberate way to define a new one-time
operation.
Keywords: plugin CLI, LoadOnce, load_once_keys, BNP, validation, codeless

2
go.mod
View File

@ -5,7 +5,7 @@ go 1.26.4
require ( require (
connectrpc.com/connect v1.20.0 connectrpc.com/connect v1.20.0
git.dev.alexdunmow.com/block/core v0.18.2 git.dev.alexdunmow.com/block/core v0.18.2
git.dev.alexdunmow.com/block/pluginsdk v0.3.2 git.dev.alexdunmow.com/block/pluginsdk v0.3.3
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1 github.com/chromedp/chromedp v0.15.1
github.com/klauspost/compress v1.18.6 github.com/klauspost/compress v1.18.6

4
go.sum
View File

@ -2,8 +2,8 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
git.dev.alexdunmow.com/block/core v0.18.2 h1:+3OfZ424yoc1k1CucXYARk8TBkh8Z693HRkhf5Ci2wU= git.dev.alexdunmow.com/block/core v0.18.2 h1:+3OfZ424yoc1k1CucXYARk8TBkh8Z693HRkhf5Ci2wU=
git.dev.alexdunmow.com/block/core v0.18.2/go.mod h1:GGuUu826AoJepC/hKLGJ7BX3PQaDss9ueCT0se6Ao2w= git.dev.alexdunmow.com/block/core v0.18.2/go.mod h1:GGuUu826AoJepC/hKLGJ7BX3PQaDss9ueCT0se6Ao2w=
git.dev.alexdunmow.com/block/pluginsdk v0.3.2 h1:aLGkLzmJ0+docoke1e9sZTtugNugPaD3PNJEonzurgI= git.dev.alexdunmow.com/block/pluginsdk v0.3.3 h1:SEvKqCcvYmqy6ToA9j9oaSORmQFplOeQFbqAZfrJeUE=
git.dev.alexdunmow.com/block/pluginsdk v0.3.2/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM= git.dev.alexdunmow.com/block/pluginsdk v0.3.3/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=

View File

@ -165,6 +165,9 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
if err := applyManifestYAML(dir, manifest); err != nil { if err := applyManifestYAML(dir, manifest); err != nil {
return nil, err return nil, err
} }
if err := ValidateLoadOnceManifest(manifest); err != nil {
return nil, err
}
if err := ValidateMCPManifest(manifest, mod.Plugin.Scope, mod.Plugin.Name); err != nil { if err := ValidateMCPManifest(manifest, mod.Plugin.Scope, mod.Plugin.Name); err != nil {
return nil, err return nil, err
} }
@ -320,6 +323,9 @@ func hooksPresent(m *abiv1.PluginManifest) []string {
if m.GetHasLoadHook() { if m.GetHasLoadHook() {
hooks = append(hooks, "load") hooks = append(hooks, "load")
} }
if len(m.GetLoadOnceKeys()) > 0 {
hooks = append(hooks, "load-once")
}
if m.GetHasUnloadHook() { if m.GetHasUnloadHook() {
hooks = append(hooks, "unload") hooks = append(hooks, "unload")
} }

View File

@ -177,6 +177,7 @@ func TestCodelessHookViolation(t *testing.T) {
} }
bad := []*abiv1.PluginManifest{ bad := []*abiv1.PluginManifest{
{Codeless: true, HasHttpHandler: true}, {Codeless: true, HasHttpHandler: true},
{Codeless: true, LoadOnceKeys: []string{"defaults.v1"}},
{Codeless: true, JobTypes: []string{"j"}}, {Codeless: true, JobTypes: []string{"j"}},
{Codeless: true, DeclaredTags: []string{"t"}}, {Codeless: true, DeclaredTags: []string{"t"}},
{Codeless: true, Blocks: []*abiv1.BlockMeta{{Key: "k"}}}, {Codeless: true, Blocks: []*abiv1.BlockMeta{{Key: "k"}}},

17
internal/bnp/load_once.go Normal file
View File

@ -0,0 +1,17 @@
package bnp
import (
"fmt"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
)
// ValidateLoadOnceManifest enforces the stable keyed lifecycle contract before
// an artifact can be packed or accepted by ninja plugin verify.
func ValidateLoadOnceManifest(manifest *abiv1.PluginManifest) error {
if err := plugin.ValidateLoadOnceKeys(manifest.GetLoadOnceKeys()); err != nil {
return fmt.Errorf("bnp: %w", err)
}
return nil
}

View File

@ -0,0 +1,65 @@
package bnp
import (
"path/filepath"
"slices"
"strings"
"testing"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"google.golang.org/protobuf/proto"
)
func TestHooksPresentIncludesLoadOnce(t *testing.T) {
hooks := hooksPresent(&abiv1.PluginManifest{LoadOnceKeys: []string{"defaults.v1"}})
if !slices.Contains(hooks, "load-once") {
t.Fatalf("hooksPresent() = %v, want load-once", hooks)
}
}
func TestValidateLoadOnceManifest(t *testing.T) {
tests := []struct {
name string
keys []string
ok bool
}{
{name: "valid", keys: []string{"documentation-main-menu.v1"}, ok: true},
{name: "uppercase", keys: []string{"DocumentationMenu.v1"}},
{name: "duplicate", keys: []string{"menu.v1", "menu.v1"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateLoadOnceManifest(&abiv1.PluginManifest{LoadOnceKeys: test.keys})
if test.ok && err != nil {
t.Fatalf("ValidateLoadOnceManifest() error = %v", err)
}
if !test.ok && err == nil {
t.Fatal("ValidateLoadOnceManifest() error = nil")
}
})
}
}
func TestVerifyRejectsDuplicateLoadOnceKeys(t *testing.T) {
manifestBytes, err := proto.Marshal(&abiv1.PluginManifest{
AbiVersion: 1,
Name: "fixture",
Version: "1.0.0",
LoadOnceKeys: []string{"defaults.v1", "defaults.v1"},
})
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
out := filepath.Join(t.TempDir(), "fixture.bnp")
_, err = packArtifact(out, []packEntry{
{ArtifactPath: fileWasm, Data: []byte("wasm")},
{ArtifactPath: fileMod, Data: []byte("[plugin]\nname = \"fixture\"\nversion = \"1.0.0\"\n")},
{ArtifactPath: fileManifest, Data: manifestBytes},
})
if err != nil {
t.Fatalf("pack artifact: %v", err)
}
if _, err := Verify(out); err == nil || !strings.Contains(err.Error(), "duplicate load-once key") {
t.Fatalf("Verify() error = %v, want duplicate key rejection", err)
}
}

View File

@ -121,6 +121,9 @@ func Verify(bnpPath string) (*VerifyResult, error) {
if modName != name { if modName != name {
return nil, fmt.Errorf("bnp: manifest name %q != plugin.mod name %q", name, modName) return nil, fmt.Errorf("bnp: manifest name %q != plugin.mod name %q", name, modName)
} }
if err := ValidateLoadOnceManifest(manifest); err != nil {
return nil, err
}
if err := ValidateMCPManifest(manifest, parseModString(modBytes, "scope"), name); err != nil { if err := ValidateMCPManifest(manifest, parseModString(modBytes, "scope"), name); err != nil {
return nil, err return nil, err
} }
@ -176,6 +179,8 @@ func CodelessHookViolation(m *abiv1.PluginManifest) error {
return viol("media hooks") return viol("media hooks")
case m.GetHasProvisioner(): case m.GetHasProvisioner():
return viol("a provisioner hook") return viol("a provisioner hook")
case len(m.GetLoadOnceKeys()) > 0:
return viol("load-once hooks")
case len(m.GetJobTypes()) > 0: case len(m.GetJobTypes()) > 0:
return viol("job handlers") return viol("job handlers")
case len(m.GetRagContentFetcherTypes()) > 0: case len(m.GetRagContentFetcherTypes()) > 0: