feat(plugin): validate keyed load-once manifests
This commit is contained in:
parent
9e802e005b
commit
d3e2bc65a2
20
docs/adr/0002-keyed-load-once-manifests-are-validated.md
Normal file
20
docs/adr/0002-keyed-load-once-manifests-are-validated.md
Normal 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 1–128
|
||||
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
2
go.mod
@ -5,7 +5,7 @@ go 1.26.4
|
||||
require (
|
||||
connectrpc.com/connect v1.20.0
|
||||
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/chromedp v0.15.1
|
||||
github.com/klauspost/compress v1.18.6
|
||||
|
||||
4
go.sum
4
go.sum
@ -2,8 +2,8 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||
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/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.2/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
|
||||
git.dev.alexdunmow.com/block/pluginsdk v0.3.3 h1:SEvKqCcvYmqy6ToA9j9oaSORmQFplOeQFbqAZfrJeUE=
|
||||
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/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
||||
|
||||
@ -165,6 +165,9 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
|
||||
if err := applyManifestYAML(dir, manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateLoadOnceManifest(manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateMCPManifest(manifest, mod.Plugin.Scope, mod.Plugin.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -320,6 +323,9 @@ func hooksPresent(m *abiv1.PluginManifest) []string {
|
||||
if m.GetHasLoadHook() {
|
||||
hooks = append(hooks, "load")
|
||||
}
|
||||
if len(m.GetLoadOnceKeys()) > 0 {
|
||||
hooks = append(hooks, "load-once")
|
||||
}
|
||||
if m.GetHasUnloadHook() {
|
||||
hooks = append(hooks, "unload")
|
||||
}
|
||||
|
||||
@ -37,8 +37,8 @@ func codelessFixture(t *testing.T) string {
|
||||
"templates/fixture/landing.ninjatpl": `<main>{{ content }}</main>`,
|
||||
"templates/overrides/fixture/heading.ninjatpl": `<h2 class="deco">{{ text }}</h2>`,
|
||||
"templates/email/fixture.ninjatpl": `<table><tr><td>{{ body|safe }}</td></tr></table>`,
|
||||
"presets.json": `{"presets":[{"key":"default"}]}`,
|
||||
"master_pages.json": `[{"key":"landing","title":"Landing","blocks":[{"block_key":"html","title":"Hero","content":{"x":1},"slot":"main","sort_order":1}]}]`,
|
||||
"presets.json": `{"presets":[{"key":"default"}]}`,
|
||||
"master_pages.json": `[{"key":"landing","title":"Landing","blocks":[{"block_key":"html","title":"Hero","content":{"x":1},"slot":"main","sort_order":1}]}]`,
|
||||
"blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n category: content\n" +
|
||||
" schema: hero.schema.json\n template: hero.ninjatpl\n providers: [site]\n",
|
||||
"blocks/hero.schema.json": `{"type":"object"}`,
|
||||
@ -147,8 +147,8 @@ func TestCodelessBuildRejectsBadDeclarations(t *testing.T) {
|
||||
"manifest.yaml": "template_overrides:\n - template: x\n block: heading\n",
|
||||
}},
|
||||
{"override missing block key", map[string]string{
|
||||
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
|
||||
"manifest.yaml": "template_overrides:\n - template: x\n",
|
||||
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
|
||||
"manifest.yaml": "template_overrides:\n - template: x\n",
|
||||
"templates/overrides/x/heading.ninjatpl": `<h1>{{ text }}</h1>`,
|
||||
}},
|
||||
{"email wrapper missing template file", map[string]string{
|
||||
@ -177,6 +177,7 @@ func TestCodelessHookViolation(t *testing.T) {
|
||||
}
|
||||
bad := []*abiv1.PluginManifest{
|
||||
{Codeless: true, HasHttpHandler: true},
|
||||
{Codeless: true, LoadOnceKeys: []string{"defaults.v1"}},
|
||||
{Codeless: true, JobTypes: []string{"j"}},
|
||||
{Codeless: true, DeclaredTags: []string{"t"}},
|
||||
{Codeless: true, Blocks: []*abiv1.BlockMeta{{Key: "k"}}},
|
||||
|
||||
17
internal/bnp/load_once.go
Normal file
17
internal/bnp/load_once.go
Normal 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
|
||||
}
|
||||
65
internal/bnp/load_once_test.go
Normal file
65
internal/bnp/load_once_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -121,6 +121,9 @@ func Verify(bnpPath string) (*VerifyResult, error) {
|
||||
if modName != name {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -176,6 +179,8 @@ func CodelessHookViolation(m *abiv1.PluginManifest) error {
|
||||
return viol("media hooks")
|
||||
case m.GetHasProvisioner():
|
||||
return viol("a provisioner hook")
|
||||
case len(m.GetLoadOnceKeys()) > 0:
|
||||
return viol("load-once hooks")
|
||||
case len(m.GetJobTypes()) > 0:
|
||||
return viol("job handlers")
|
||||
case len(m.GetRagContentFetcherTypes()) > 0:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user