From a3b261dbe4a7ca42543d749d686a677028150384 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Sat, 4 Jul 2026 11:30:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(bnp):=20codeless=20.bnp=20artifacts=20?= =?UTF-8?q?=E2=80=94=20declarative=20plugins=20with=20no=20wasm=20(WO-WZ-0?= =?UTF-8?q?20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PluginManifest gains `codeless` (additive, buf-breaking clean): an artifact with NO plugin.wasm that the host runs entirely. `ninja plugin build` classifies by repo shape — no Go source → codeless (manifest synthesized from plugin.mod + optional manifest.yaml: theme presets, fonts, settings schema, master pages, CSS, icon packs, deps); Go source → wasm as always. New `--codeless` flag asserts the expectation. Both artifact kinds now pack the declarative dirs: blocks/ (the cms blocks.yaml manifest-FS layout — definition-backed blocks), templates/, and seed/ (seed.json: settings/media/pages/menu items, applied host-side via the WO-WZ-019 provisioner). The packer structurally validates blocks.yaml and seed.json (schema JSON validity, file presence, traversal safety); full semantic validation stays host-side at install. `ninja plugin verify`: plugin.wasm is required exactly when NOT codeless; a codeless manifest declaring any computing hook (http/jobs/load/unload/media/ RAG/tags/filters/services/guest blocks/data_dir) is rejected — CodelessHookViolation mirrors the cms reader check (lockstep duplication). Co-Authored-By: Claude Fable 5 --- abi/proto/v1/manifest.proto | 12 + abi/v1/manifest.pb.go | 27 +- cmd/ninja/cmd/plugin_build.go | 25 +- cmd/ninja/internal/bnp/build.go | 13 + cmd/ninja/internal/bnp/codeless.go | 429 ++++++++++++++++++++++++ cmd/ninja/internal/bnp/codeless_test.go | 152 +++++++++ cmd/ninja/internal/bnp/verify.go | 57 +++- go.mod | 1 + 8 files changed, 709 insertions(+), 7 deletions(-) create mode 100644 cmd/ninja/internal/bnp/codeless.go create mode 100644 cmd/ninja/internal/bnp/codeless_test.go diff --git a/abi/proto/v1/manifest.proto b/abi/proto/v1/manifest.proto index 63a6736..3a25384 100644 --- a/abi/proto/v1/manifest.proto +++ b/abi/proto/v1/manifest.proto @@ -113,6 +113,18 @@ message PluginManifest { // name the host registers a pongo2 filter that invokes the guest via // HOOK_APPLY_FILTER. Captured by DESCRIBE from the blocks filter registry. repeated string declared_filters = 32; + + // codeless marks a declarative artifact with NO plugin.wasm (WO-WZ-020): + // the host runs it entirely — block definitions from the artifact's + // blocks/ dir (blocks.yaml manifest-FS layout), seed data from seed/, + // assets/migrations as usual — and never instantiates a guest. A codeless + // manifest MUST NOT declare any computing hook (http handler, jobs, + // load/unload, RAG fetchers, media hooks, tags/filters, provisioner, + // service bindings, directory callbacks); the packer and the host reader + // both reject the combination. Not produced by DESCRIBE (there is no guest + // to describe): `ninja plugin build` synthesizes the manifest from + // plugin.mod + the artifact's declarative files. + bool codeless = 33; } // Dependency mirrors plugin.Dependency. diff --git a/abi/v1/manifest.pb.go b/abi/v1/manifest.pb.go index 984b4ee..822aa11 100644 --- a/abi/v1/manifest.pb.go +++ b/abi/v1/manifest.pb.go @@ -112,8 +112,19 @@ type PluginManifest struct { // name the host registers a pongo2 filter that invokes the guest via // HOOK_APPLY_FILTER. Captured by DESCRIBE from the blocks filter registry. DeclaredFilters []string `protobuf:"bytes,32,rep,name=declared_filters,json=declaredFilters,proto3" json:"declared_filters,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // codeless marks a declarative artifact with NO plugin.wasm (WO-WZ-020): + // the host runs it entirely — block definitions from the artifact's + // blocks/ dir (blocks.yaml manifest-FS layout), seed data from seed/, + // assets/migrations as usual — and never instantiates a guest. A codeless + // manifest MUST NOT declare any computing hook (http handler, jobs, + // load/unload, RAG fetchers, media hooks, tags/filters, provisioner, + // service bindings, directory callbacks); the packer and the host reader + // both reject the combination. Not produced by DESCRIBE (there is no guest + // to describe): `ninja plugin build` synthesizes the manifest from + // plugin.mod + the artifact's declarative files. + Codeless bool `protobuf:"varint,33,opt,name=codeless,proto3" json:"codeless,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PluginManifest) Reset() { @@ -370,6 +381,13 @@ func (x *PluginManifest) GetDeclaredFilters() []string { return nil } +func (x *PluginManifest) GetCodeless() bool { + if x != nil { + return x.Codeless + } + return false +} + // Dependency mirrors plugin.Dependency. type Dependency struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1282,7 +1300,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor const file_v1_manifest_proto_rawDesc = "" + "\n" + - "\x11v1/manifest.proto\x12\x06abi.v1\"\xf2\f\n" + + "\x11v1/manifest.proto\x12\x06abi.v1\"\x8e\r\n" + "\x0ePluginManifest\x12\x1f\n" + "\vabi_version\x18\x01 \x01(\rR\n" + "abiVersion\x12\x12\n" + @@ -1319,7 +1337,8 @@ const file_v1_manifest_proto_rawDesc = "" + "\x15core_service_bindings\x18\x1d \x03(\v2\x1a.abi.v1.CoreServiceBindingR\x13coreServiceBindings\x12\x19\n" + "\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" + "\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" + - "\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x1aB\n" + + "\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" + + "\bcodeless\x18! \x01(\bR\bcodeless\x1aB\n" + "\x14RbacMethodRolesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" + diff --git a/cmd/ninja/cmd/plugin_build.go b/cmd/ninja/cmd/plugin_build.go index c83e1bd..c241f76 100644 --- a/cmd/ninja/cmd/plugin_build.go +++ b/cmd/ninja/cmd/plugin_build.go @@ -12,6 +12,7 @@ import ( func newPluginBuildCmd() *cobra.Command { var dir, output string + var codeless bool cmd := &cobra.Command{ Use: "build", Short: "Compile a plugin to wasm and pack a .bnp artifact", @@ -23,7 +24,22 @@ schemas/, assets/, web/dist when present). No Docker or podman: the whole pipeline is the local Go toolchain (>= 1.24) plus wazero. This replaces the in-container .so compile.`, RunE: func(c *cobra.Command, _ []string) error { - res, err := bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir, Output: output}) + // Classify by repo shape (WO-WZ-020): no Go source → codeless + // (declarative, no wasm); Go source → wasm. --codeless asserts + // the expectation and fails loudly on a mismatch. + isCodeless, err := bnp.IsCodelessRepo(dir) + if err != nil { + return err + } + if codeless && !isCodeless { + return fmt.Errorf("--codeless asserted but %s contains Go source — a codeless plugin has no code (delete the Go or drop the flag)", dir) + } + var res *bnp.BuildResult + if isCodeless { + res, err = bnp.BuildCodeless(context.Background(), bnp.BuildOptions{Dir: dir, Output: output}) + } else { + res, err = bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir, Output: output}) + } if err != nil { return err } @@ -33,6 +49,7 @@ plus wazero. This replaces the in-container .so compile.`, } cmd.Flags().StringVar(&dir, "dir", ".", "Plugin repo directory") cmd.Flags().StringVarP(&output, "output", "o", "", "Output .bnp path (default -.bnp)") + cmd.Flags().BoolVar(&codeless, "codeless", false, "Assert the repo builds a codeless (no-wasm) artifact; fail if it contains Go source") return cmd } @@ -66,7 +83,11 @@ func printBuildSummary(r *bnp.BuildResult) { if len(r.IncludedDirs) > 0 { dirs = strings.Join(r.IncludedDirs, ", ") } - fmt.Printf("Built %s@%s → %s\n", r.Name, r.Version, r.OutputPath) + kind := "wasm" + if r.Codeless { + kind = "codeless" + } + fmt.Printf("Built %s@%s (%s) → %s\n", r.Name, r.Version, kind, r.OutputPath) fmt.Println(" ┌───────────────────────────────────────────") fmt.Printf(" │ artifact size %s (%s uncompressed)\n", humanBytes(r.ArtifactBytes), humanBytes(r.UncompBytes)) fmt.Printf(" │ plugin.wasm %s\n", humanBytes(r.WasmBytes)) diff --git a/cmd/ninja/internal/bnp/build.go b/cmd/ninja/internal/bnp/build.go index ea22197..b8e6078 100644 --- a/cmd/ninja/internal/bnp/build.go +++ b/cmd/ninja/internal/bnp/build.go @@ -44,6 +44,8 @@ type BuildResult struct { OutputPath string Name string Version string + // Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020). + Codeless bool WasmBytes int64 ManifestBytes int64 ArtifactBytes int64 // on-disk .bnp size @@ -136,11 +138,22 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) { // dir-name → source subpath. web ships the Module Federation build output // (web/dist) flattened under web/ so the reader's dirExists("web") fires. optional := []struct{ artifact, src string }{ + {dirBlocks, dirBlocks}, + {dirTemplates, dirTemplates}, + {dirSeed, dirSeed}, {"migrations", "migrations"}, {"schemas", "schemas"}, {"assets", "assets"}, {"web", filepath.Join("web", "dist")}, } + // Declarative dirs ride along on wasm artifacts too (a reduced plugin may + // mix definition-backed blocks with logic); validate them identically. + if _, err := validateBlocksDir(dir); err != nil { + return nil, err + } + if err := validateSeedDir(dir); err != nil { + return nil, err + } for _, o := range optional { dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact) if dErr != nil { diff --git a/cmd/ninja/internal/bnp/codeless.go b/cmd/ninja/internal/bnp/codeless.go new file mode 100644 index 0000000..5386e1a --- /dev/null +++ b/cmd/ninja/internal/bnp/codeless.go @@ -0,0 +1,429 @@ +package bnp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" + core "git.dev.alexdunmow.com/block/core/plugin" + "google.golang.org/protobuf/proto" + "gopkg.in/yaml.v3" +) + +// Codeless artifacts (WO-WZ-020): a .bnp with NO plugin.wasm — pure +// declaration the host runs. Classification is by repo shape: a plugin repo +// with no Go source builds codeless; a repo with Go builds wasm as always +// (a converted repo DELETES its Go — that's the point). Both artifact kinds +// may carry blocks/, templates/, and seed/; codeless just has nothing else. + +// Declarative artifact dirs shared by both build paths. +const ( + dirBlocks = "blocks" + dirTemplates = "templates" + dirSeed = "seed" +) + +// manifestYAMLName is the optional root-level declarative manifest source for +// codeless plugins (the counterpart of what DESCRIBE captures from Go code). +const manifestYAMLName = "manifest.yaml" + +// manifestYAML mirrors the declarative PluginManifest fields a codeless +// plugin can set. File-valued keys are paths relative to the repo root. +type manifestYAML struct { + ThemePresets string `yaml:"theme_presets"` // JSON file + BundledFonts string `yaml:"bundled_fonts"` // JSON file + SettingsSchema string `yaml:"settings_schema"` // JSON file + MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON) + RequiredIconPacks []string `yaml:"required_icon_packs"` + CSS *struct { + NpmPackages map[string]string `yaml:"npm_packages"` + CSSDirectives []string `yaml:"css_directives"` + InputCSSAppend string `yaml:"input_css_append"` + } `yaml:"css"` + Dependencies []struct { + Plugin string `yaml:"plugin"` + MinVersion string `yaml:"min_version"` + Required bool `yaml:"required"` + } `yaml:"dependencies"` +} + +// masterPageJSON mirrors abiv1.MasterPageDefinition for the declarative file. +type masterPageJSON struct { + Key string `json:"key"` + Title string `json:"title"` + PageTemplates []string `json:"page_templates"` + Blocks []struct { + BlockKey string `json:"block_key"` + Title string `json:"title"` + Content map[string]any `json:"content"` + HTMLContent *string `json:"html_content"` + Slot string `json:"slot"` + SortOrder int32 `json:"sort_order"` + } `json:"blocks"` +} + +// blocksYAML is the structural mirror of the CMS blocks.yaml manifest +// (cms blocks.LoadManifest) — a deliberate light duplication, same as the +// Verify↔reader pairing: the packer validates shape and file presence; full +// semantic validation (schema/template/provider checks) runs at install. +type blocksYAML struct { + Blocks []struct { + Key string `yaml:"key"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Category string `yaml:"category"` + Schema string `yaml:"schema"` + Template string `yaml:"template"` + SampleData string `yaml:"sample_data"` + Providers []string `yaml:"providers"` + RequiredTags []string `yaml:"required_tags"` + Aliases []string `yaml:"aliases"` + } `yaml:"blocks"` +} + +// seedJSON is the declarative seed schema (seed/seed.json). Applied by the +// host at load via the WO-WZ-019 provisioner (idempotent). +type seedJSON struct { + Settings *struct { + Merge map[string]any `json:"merge"` + Override map[string]any `json:"override"` + Ensure map[string]any `json:"ensure"` + } `json:"settings"` + Media []struct { + ID string `json:"id"` // UUID, required (deterministic media key) + File string `json:"file"` + Alt string `json:"alt"` + Folder string `json:"folder"` + } `json:"media"` + Pages []struct { + Slug string `json:"slug"` + ParentSlug string `json:"parent_slug"` + Title string `json:"title"` + TemplateKey string `json:"template_key"` + ReconcileBlocks bool `json:"reconcile_blocks"` + ReconcileTemplate bool `json:"reconcile_template"` + Blocks []struct { + BlockKey string `json:"block_key"` + Title string `json:"title"` + Content map[string]any `json:"content"` + HTMLContent *string `json:"html_content"` + Slot string `json:"slot"` + SortOrder int32 `json:"sort_order"` + } `json:"blocks"` + } `json:"pages"` + MenuItems []struct { + Menu string `json:"menu"` + Label string `json:"label"` + URL string `json:"url"` + PageSlug string `json:"page_slug"` + SortOrder int32 `json:"sort_order"` + } `json:"menu_items"` +} + +// IsCodelessRepo reports whether dir is a declarative plugin repo: it has a +// plugin.mod but no Go source at the root (the wasm main package's home). +func IsCodelessRepo(dir string) (bool, error) { + if _, err := os.Stat(filepath.Join(dir, fileMod)); err != nil { + return false, fmt.Errorf("read plugin.mod (is %s a plugin repo?): %w", dir, err) + } + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") { + return false, nil + } + } + return true, nil +} + +// BuildCodeless synthesizes manifest.pb from plugin.mod + the declarative +// files and packs a codeless .bnp (no plugin.wasm, no DESCRIBE probe). +func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) { + dir := opts.Dir + if dir == "" { + dir = "." + } + dir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("resolve dir: %w", err) + } + + modPath := filepath.Join(dir, fileMod) + modBytes, err := os.ReadFile(modPath) + if err != nil { + return nil, fmt.Errorf("read plugin.mod: %w", err) + } + mod, err := core.ParseModFull(modBytes) + if err != nil { + return nil, fmt.Errorf("parse plugin.mod: %w", err) + } + if mod.Plugin.Name == "" || mod.Plugin.Version == "" { + return nil, fmt.Errorf("plugin.mod must set both name and version") + } + if mod.Plugin.DataDir { + return nil, fmt.Errorf("codeless plugin cannot request data_dir — there is no code to use it") + } + + manifest := &abiv1.PluginManifest{ + AbiVersion: hostAbiVersion, + Name: mod.Plugin.Name, + Version: mod.Plugin.Version, + Codeless: true, + } + + blockCount, err := validateBlocksDir(dir) + if err != nil { + return nil, err + } + if err := validateSeedDir(dir); err != nil { + return nil, err + } + if err := applyManifestYAML(dir, manifest); err != nil { + return nil, err + } + + manifestBytes, err := proto.Marshal(manifest) + if err != nil { + return nil, fmt.Errorf("marshal manifest.pb: %w", err) + } + + entries := []packEntry{ + {ArtifactPath: fileMod, Source: modPath}, + {ArtifactPath: fileManifest, Data: manifestBytes}, + } + var includedDirs []string + optional := []struct{ artifact, src string }{ + {dirBlocks, dirBlocks}, + {dirTemplates, dirTemplates}, + {dirSeed, dirSeed}, + {"migrations", "migrations"}, + {"schemas", "schemas"}, + {"assets", "assets"}, + } + for _, o := range optional { + dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact) + if dErr != nil { + return nil, dErr + } + if has { + entries = append(entries, dirEntries...) + includedDirs = append(includedDirs, o.artifact) + } + } + + outPath := opts.Output + if outPath == "" { + outPath = fmt.Sprintf("%s-%s.bnp", mod.Plugin.Name, mod.Plugin.Version) + } + uncomp, err := packArtifact(outPath, entries) + if err != nil { + return nil, err + } + artInfo, err := os.Stat(outPath) + if err != nil { + return nil, fmt.Errorf("stat artifact: %w", err) + } + + return &BuildResult{ + OutputPath: outPath, + Name: manifest.GetName(), + Version: manifest.GetVersion(), + Codeless: true, + ManifestBytes: int64(len(manifestBytes)), + ArtifactBytes: artInfo.Size(), + UncompBytes: uncomp, + BlockCount: blockCount, + IncludedDirs: includedDirs, + }, nil +} + +// validateBlocksDir structurally validates blocks/blocks.yaml when present: +// every entry has key/title/schema/template, referenced files exist within +// blocks/, and schemas are valid JSON. Returns the block count (0 when the +// dir is absent). +func validateBlocksDir(dir string) (int, error) { + root := filepath.Join(dir, dirBlocks) + manifestPath := filepath.Join(root, "blocks.yaml") + if _, err := os.Stat(root); os.IsNotExist(err) { + return 0, nil + } + raw, err := os.ReadFile(manifestPath) + if err != nil { + return 0, fmt.Errorf("blocks/ present but blocks.yaml unreadable: %w", err) + } + var bf blocksYAML + if err := yaml.Unmarshal(raw, &bf); err != nil { + return 0, fmt.Errorf("parse blocks/blocks.yaml: %w", err) + } + seen := map[string]bool{} + for i, b := range bf.Blocks { + if b.Key == "" { + return 0, fmt.Errorf("blocks.yaml: entry %d has no key", i) + } + if seen[b.Key] { + return 0, fmt.Errorf("blocks.yaml: duplicate key %q", b.Key) + } + seen[b.Key] = true + if b.Title == "" || b.Schema == "" || b.Template == "" { + return 0, fmt.Errorf("blocks.yaml: %q must declare title, schema, and template", b.Key) + } + for _, f := range []string{b.Schema, b.Template, b.SampleData} { + if f == "" { + continue + } + if err := fileWithin(root, f); err != nil { + return 0, fmt.Errorf("blocks.yaml: %q: %w", b.Key, err) + } + } + schemaRaw, err := os.ReadFile(filepath.Join(root, b.Schema)) + if err != nil { + return 0, fmt.Errorf("blocks.yaml: %q schema: %w", b.Key, err) + } + if !json.Valid(schemaRaw) { + return 0, fmt.Errorf("blocks.yaml: %q schema %s is not valid JSON", b.Key, b.Schema) + } + } + return len(bf.Blocks), nil +} + +// validateSeedDir validates seed/seed.json when the dir is present: it +// parses, media entries carry an id + an existing file, pages carry slugs. +func validateSeedDir(dir string) error { + root := filepath.Join(dir, dirSeed) + if _, err := os.Stat(root); os.IsNotExist(err) { + return nil + } + raw, err := os.ReadFile(filepath.Join(root, "seed.json")) + if err != nil { + return fmt.Errorf("seed/ present but seed.json unreadable: %w", err) + } + var sf seedJSON + if err := json.Unmarshal(raw, &sf); err != nil { + return fmt.Errorf("parse seed/seed.json: %w", err) + } + for i, m := range sf.Media { + if m.ID == "" || m.File == "" { + return fmt.Errorf("seed.json: media entry %d needs both id and file", i) + } + if err := fileWithin(root, m.File); err != nil { + return fmt.Errorf("seed.json: media %q: %w", m.ID, err) + } + } + for i, p := range sf.Pages { + if p.Slug == "" || p.Title == "" { + return fmt.Errorf("seed.json: page entry %d needs slug and title", i) + } + } + for i, mi := range sf.MenuItems { + if mi.Menu == "" || mi.Label == "" { + return fmt.Errorf("seed.json: menu item %d needs menu and label", i) + } + } + return nil +} + +// applyManifestYAML folds the optional root manifest.yaml into the manifest. +func applyManifestYAML(dir string, m *abiv1.PluginManifest) error { + raw, err := os.ReadFile(filepath.Join(dir, manifestYAMLName)) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read %s: %w", manifestYAMLName, err) + } + var my manifestYAML + if err := yaml.Unmarshal(raw, &my); err != nil { + return fmt.Errorf("parse %s: %w", manifestYAMLName, err) + } + + readJSONFile := func(rel, what string) ([]byte, error) { + if rel == "" { + return nil, nil + } + if err := fileWithin(dir, rel); err != nil { + return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err) + } + b, err := os.ReadFile(filepath.Join(dir, rel)) + if err != nil { + return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err) + } + if !json.Valid(b) { + return nil, fmt.Errorf("%s: %s %s is not valid JSON", manifestYAMLName, what, rel) + } + return b, nil + } + + if m.ThemePresets, err = readJSONFile(my.ThemePresets, "theme_presets"); err != nil { + return err + } + if m.BundledFonts, err = readJSONFile(my.BundledFonts, "bundled_fonts"); err != nil { + return err + } + if m.SettingsSchema, err = readJSONFile(my.SettingsSchema, "settings_schema"); err != nil { + return err + } + m.RequiredIconPacks = my.RequiredIconPacks + if my.CSS != nil { + m.CssManifest = &abiv1.CssManifest{ + NpmPackages: my.CSS.NpmPackages, + CssDirectives: my.CSS.CSSDirectives, + InputCssAppend: my.CSS.InputCSSAppend, + } + } + for _, d := range my.Dependencies { + m.Dependencies = append(m.Dependencies, &abiv1.Dependency{ + Plugin: d.Plugin, MinVersion: d.MinVersion, Required: d.Required, + }) + } + + if my.MasterPages != "" { + mpRaw, err := readJSONFile(my.MasterPages, "master_pages") + if err != nil { + return err + } + var mps []masterPageJSON + if err := json.Unmarshal(mpRaw, &mps); err != nil { + return fmt.Errorf("%s: master_pages %s: %w", manifestYAMLName, my.MasterPages, err) + } + for _, mp := range mps { + def := &abiv1.MasterPageDefinition{ + Key: mp.Key, Title: mp.Title, PageTemplates: mp.PageTemplates, + } + for _, b := range mp.Blocks { + contentJSON, err := json.Marshal(b.Content) + if err != nil { + return fmt.Errorf("master page %q block %q: %w", mp.Key, b.BlockKey, err) + } + def.Blocks = append(def.Blocks, &abiv1.MasterPageBlock{ + BlockKey: b.BlockKey, Title: b.Title, ContentJson: contentJSON, + HtmlContent: b.HTMLContent, Slot: b.Slot, SortOrder: b.SortOrder, + }) + } + m.MasterPages = append(m.MasterPages, def) + } + } + return nil +} + +// fileWithin ensures rel names an existing regular file inside root (no +// traversal escapes). +func fileWithin(root, rel string) error { + clean := filepath.Clean(rel) + if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") { + return fmt.Errorf("path %q escapes the plugin directory", rel) + } + fi, err := os.Stat(filepath.Join(root, clean)) + if err != nil { + return fmt.Errorf("referenced file %q: %w", rel, err) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("referenced path %q is not a regular file", rel) + } + return nil +} diff --git a/cmd/ninja/internal/bnp/codeless_test.go b/cmd/ninja/internal/bnp/codeless_test.go new file mode 100644 index 0000000..e17e404 --- /dev/null +++ b/cmd/ninja/internal/bnp/codeless_test.go @@ -0,0 +1,152 @@ +package bnp + +import ( + "context" + "os" + "path/filepath" + "testing" + + abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" +) + +// writeFixture lays out a minimal codeless plugin repo. +func writeFixture(t *testing.T, dir string, files map[string]string) { + t.Helper() + for rel, content := range files { + path := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func codelessFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + writeFixture(t, dir, map[string]string{ + "plugin.mod": "[plugin]\nname = \"fixture-theme\"\nversion = \"0.1.0\"\nkind = \"theme\"\n", + "manifest.yaml": "theme_presets: presets.json\nrequired_icon_packs: [lucide]\n" + + "master_pages: master_pages.json\n", + "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"}`, + "blocks/hero.ninjatpl": `

{{ title }}

`, + "seed/seed.json": `{"settings":{"ensure":{"welcome":true}},` + + `"pages":[{"slug":"/","title":"Home","template_key":"landing"}],` + + `"menu_items":[{"menu":"main","label":"Home","page_slug":"/","sort_order":1}]}`, + "assets/logo.svg": ``, + }) + return dir +} + +func TestCodelessBuildAndVerifyRoundTrip(t *testing.T) { + dir := codelessFixture(t) + + isCodeless, err := IsCodelessRepo(dir) + if err != nil || !isCodeless { + t.Fatalf("IsCodelessRepo = %v, %v; want true", isCodeless, err) + } + + out := filepath.Join(t.TempDir(), "fixture-theme-0.1.0.bnp") + res, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out}) + if err != nil { + t.Fatalf("BuildCodeless: %v", err) + } + if !res.Codeless || res.BlockCount != 1 { + t.Errorf("result = %+v; want codeless with 1 block", res) + } + + v, err := Verify(out) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !v.Codeless || !v.HasBlocks || !v.HasSeed || !v.HasAssets || v.Name != "fixture-theme" { + t.Errorf("verify = %+v", v) + } + + m, err := ReadManifest(out) + if err != nil { + t.Fatal(err) + } + if !m.GetCodeless() || len(m.GetMasterPages()) != 1 || len(m.GetThemePresets()) == 0 { + t.Errorf("manifest = codeless:%v masters:%d presets:%dB", + m.GetCodeless(), len(m.GetMasterPages()), len(m.GetThemePresets())) + } + if len(m.GetRequiredIconPacks()) != 1 || m.GetRequiredIconPacks()[0] != "lucide" { + t.Errorf("icon packs = %v", m.GetRequiredIconPacks()) + } +} + +func TestIsCodelessRepoFalseWithGoSource(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, map[string]string{ + "plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n", + "main.go": "package main\nfunc main() {}\n", + }) + isCodeless, err := IsCodelessRepo(dir) + if err != nil || isCodeless { + t.Fatalf("IsCodelessRepo = %v, %v; want false", isCodeless, err) + } +} + +func TestCodelessBuildRejectsBadDeclarations(t *testing.T) { + cases := []struct { + name string + files map[string]string + }{ + {"bad blocks yaml", map[string]string{ + "plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n", + "blocks/blocks.yaml": "blocks:\n - key: hero\n", // missing title/schema/template + }}, + {"missing template file", map[string]string{ + "plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n", + "blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n" + + " schema: hero.schema.json\n template: missing.ninjatpl\n", + "blocks/hero.schema.json": `{}`, + }}, + {"seed media without id", map[string]string{ + "plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n", + "seed/seed.json": `{"media":[{"file":"a.jpg"}]}`, + "seed/a.jpg": "x", + }}, + {"data_dir grant", map[string]string{ + "plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\ndata_dir = true\n", + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, tc.files) + _, err := BuildCodeless(context.Background(), BuildOptions{ + Dir: dir, Output: filepath.Join(t.TempDir(), "x.bnp"), + }) + if err == nil { + t.Fatal("BuildCodeless succeeded; want error") + } + }) + } +} + +func TestCodelessHookViolation(t *testing.T) { + ok := &abiv1.PluginManifest{Codeless: true, Name: "x"} + if err := CodelessHookViolation(ok); err != nil { + t.Fatalf("clean manifest flagged: %v", err) + } + bad := []*abiv1.PluginManifest{ + {Codeless: true, HasHttpHandler: true}, + {Codeless: true, JobTypes: []string{"j"}}, + {Codeless: true, DeclaredTags: []string{"t"}}, + {Codeless: true, Blocks: []*abiv1.BlockMeta{{Key: "k"}}}, + {Codeless: true, DataDir: true}, + } + for i, m := range bad { + if err := CodelessHookViolation(m); err == nil { + t.Errorf("case %d not flagged", i) + } + } +} diff --git a/cmd/ninja/internal/bnp/verify.go b/cmd/ninja/internal/bnp/verify.go index 8af8d50..e3f4e53 100644 --- a/cmd/ninja/internal/bnp/verify.go +++ b/cmd/ninja/internal/bnp/verify.go @@ -37,12 +37,16 @@ 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: @@ -67,7 +71,7 @@ func Verify(bnpPath string) (*VerifyResult, error) { return nil, err } - for _, req := range []string{fileWasm, fileMod, fileManifest} { + for _, req := range []string{fileMod, fileManifest} { if !fileRegular(filepath.Join(destDir, req)) { return nil, fmt.Errorf("bnp: artifact missing required %s", req) } @@ -81,6 +85,19 @@ func Verify(bnpPath string) (*VerifyResult, error) { 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) } @@ -105,15 +122,53 @@ func Verify(bnpPath string) (*VerifyResult, error) { 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. diff --git a/go.mod b/go.mod index c3f3f33..e2e7a15 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/tetratelabs/wazero v1.12.0 golang.org/x/mod v0.34.0 google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 ) require (