add --format flag for proto/JSON plan bundles (#8825)

This change adds a new flag for emitting plan bundles in the new protobuf wire format. `opa build --format=json|proto`. With `--format=proto`, the bundle contains `/plan.pb` and `/.manifest.pb` in place of `/plan.json`and `/.manifest`. Bundle Reader auto-detects both forms; mixed-format bundles are rejected at read, merge, and write time.

Signed-off-by: Sebastian Spaink <sebastianspaink@gmail.com>
This commit is contained in:
Sebastian Spaink
2026-07-06 12:50:33 -05:00
committed by GitHub
parent d7a9d913f8
commit f6092b9ce4
22 changed files with 6122 additions and 33 deletions
+4 -1
View File
@@ -40,4 +40,7 @@ docs/website/.hugo_build.lock
docs/website/data/versions
# generated when building windows binary to add icon and product version
resource.syso
resource.syso
# generated when building proto codegen plugin (see Makefile generate-proto)
build/tools/bin/
+9
View File
@@ -91,6 +91,15 @@ ifeq ($(GOOS),windows)
endif
$(GO) generate
.PHONY: generate-proto
generate-proto:
cd build/tools && $(GO) build -o $(CURDIR)/build/tools/bin/protoc-gen-go google.golang.org/protobuf/cmd/protoc-gen-go
PATH="$(CURDIR)/build/tools/bin:$$PATH" protoc \
--go_out=. \
--go_opt=module=github.com/open-policy-agent/opa \
v1/ir/plan.proto \
v1/bundle/manifest.proto
.PHONY: build
build: go-build
+2
View File
@@ -7,6 +7,7 @@ tool (
github.com/rogpeppe/go-internal/cmd/testscript
golang.org/x/perf/cmd/benchstat
golang.org/x/vuln/cmd/govulncheck
google.golang.org/protobuf/cmd/protoc-gen-go
rsc.io/cmd/benchlab
)
@@ -27,6 +28,7 @@ require (
golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/vuln v1.3.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
rsc.io/cmd/benchlab v0.0.0-20260520161042-9fc40f0f0431 // indirect
)
+9
View File
@@ -12,10 +12,13 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU=
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/josephspurrier/goversioninfo v1.7.0 h1:LQzXOlVm/CtbwJ9/UHl5a2HT0BjcLAwid5gqGd7ZUJ8=
@@ -48,5 +51,11 @@ golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTF
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/vuln v1.3.0 h1:hZYzR8uRhYhDSX88d+40TWbKAVw7BIvRWm26rtEn8jw=
golang.org/x/vuln v1.3.0/go.mod h1:MIY2PaR1y52stzZM3uHBboUAdVJvSVMl5nP3OQrwQaE=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+9 -4
View File
@@ -29,6 +29,7 @@ const defaultPublicKeyID = "default"
type buildParams struct {
capabilities *capabilitiesFlag
target *util.EnumFlag
planFormat *util.EnumFlag
bundleMode bool
pruneUnused bool
optimizationLevel int
@@ -57,6 +58,7 @@ func newBuildParams() buildParams {
return buildParams{
capabilities: newCapabilitiesFlag(),
target: util.NewEnumFlag(compile.TargetRego, compile.Targets),
planFormat: util.NewEnumFlag(compile.PlanFormatJSON, compile.PlanFormats),
stderr: os.Stderr,
}
}
@@ -254,6 +256,7 @@ against ` + brand + ` v0.22.0:
}
buildCommand.Flags().VarP(buildParams.target, "target", "t", "set the output bundle target type")
buildCommand.Flags().Var(buildParams.planFormat, "format", "set the plan output format (only applies when --target=plan)")
buildCommand.Flags().BoolVar(&buildParams.pruneUnused, "prune-unused", false, "exclude dependents of entrypoints")
buildCommand.Flags().BoolVar(&buildParams.debug, "debug", false, "enable debug output")
buildCommand.Flags().IntVarP(&buildParams.optimizationLevel, "optimize", "O", 0, "set optimization level")
@@ -312,11 +315,12 @@ func dobuild(params buildParams, args []string) error {
continue
}
if _, err := os.Stat(filepath.Join(arg, ".manifest")); err != nil {
continue
for _, name := range []string{bundle.ManifestExt, bundle.ManifestProtoExt} {
if _, err := os.Stat(filepath.Join(arg, name)); err == nil {
fmt.Fprintf(params.stderr, "Warning: %s file found in %q but -b flag not specified. Manifest will be ignored.\n", name, arg)
break
}
}
fmt.Fprintf(params.stderr, "Warning: .manifest file found in %q but -b flag not specified. Manifest will be ignored.\n", arg)
}
}
@@ -329,6 +333,7 @@ func dobuild(params buildParams, args []string) error {
compiler := compile.New().
WithCapabilities(capabilities).
WithTarget(params.target.String()).
WithPlanFormat(params.planFormat.String()).
WithAsBundle(params.bundleMode).
WithPruneUnused(params.pruneUnused).
WithOptimizationLevel(params.optimizationLevel).
+119
View File
@@ -449,6 +449,114 @@ func TestBuildPlanWithPruneUnused(t *testing.T) {
})
}
func TestBuildPlanProtoFormat(t *testing.T) {
files := map[string]string{
"test.rego": `
package test
default p := false
p if input.user == "alice"
`,
}
test.WithTempFS(files, func(root string) {
params := newBuildParams()
if err := params.target.Set("plan"); err != nil {
t.Fatal(err)
}
if err := params.planFormat.Set("proto"); err != nil {
t.Fatal(err)
}
params.entrypoints.v = []string{"test"}
params.outputFile = path.Join(root, "bundle.tar.gz")
if err := dobuild(params, []string{root}); err != nil {
t.Fatal(err)
}
f, err := os.Open(params.outputFile)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gr, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
tr := tar.NewReader(gr)
var foundPlan, foundManifest bool
var sawJSONPlan, sawJSONManifest bool
for {
h, err := tr.Next()
if err == io.EOF {
break
} else if err != nil {
t.Fatal(err)
}
switch h.Name {
case "/plan.pb":
foundPlan = true
case "/.manifest.pb":
foundManifest = true
case "/plan.json":
sawJSONPlan = true
case "/.manifest":
sawJSONManifest = true
}
}
if !foundPlan {
t.Error("plan.pb not found in bundle")
}
if !foundManifest {
t.Error("/.manifest.pb not found in bundle")
}
if sawJSONPlan {
t.Error("plan.json should not be present when --format=proto")
}
if sawJSONManifest {
t.Error("/.manifest should not be present when --format=proto")
}
loaded, err := loader.NewFileLoader().AsBundle(params.outputFile)
if err != nil {
t.Fatalf("load proto bundle: %v", err)
}
if len(loaded.PlanModules) != 1 {
t.Fatalf("expected 1 plan module, got %d", len(loaded.PlanModules))
}
if filepath.Base(loaded.PlanModules[0].Path) != "plan.pb" {
t.Errorf("plan module path should end in plan.pb, got %q", loaded.PlanModules[0].Path)
}
if loaded.Manifest.Empty() {
t.Error("manifest should not be empty after loading proto bundle")
}
})
}
func TestBuildFormatRequiresPlanTarget(t *testing.T) {
files := map[string]string{
"test.rego": `package test`,
}
test.WithTempFS(files, func(root string) {
params := newBuildParams()
if err := params.planFormat.Set("proto"); err != nil {
t.Fatal(err)
}
params.outputFile = path.Join(root, "bundle.tar.gz")
err := dobuild(params, []string{root})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "plan") {
t.Fatalf("error should mention plan target, got: %v", err)
}
})
}
func TestBuildPlanWithPrintStatements(t *testing.T) {
files := map[string]string{
@@ -3266,6 +3374,17 @@ Warning: .manifest file found in %q but -b flag not specified. Manifest will be
path.Join(root, "bundle1"), path.Join(root, "bundle2"))
},
},
"warns when proto manifest ignored": {
files: map[string]string{
"bundle/.manifest.pb": "ignored-content",
"bundle/data.json": `{"data": "value"}`,
},
bundleMode: false,
buildArgs: []string{"bundle"},
expectedStderr: func(root string) string {
return fmt.Sprintf("Warning: .manifest.pb file found in %q but -b flag not specified. Manifest will be ignored.\n", path.Join(root, "bundle"))
},
},
}
for name, tc := range testCases {
+5
View File
@@ -29,6 +29,11 @@ func TestManifestProtoConsistency(t *testing.T) {
{
Name: "Manifest",
GoType: reflect.TypeOf(bundle.Manifest{}),
// roots_set is wire-form bookkeeping: it preserves the
// nil-vs-explicit-empty distinction that Manifest.Roots
// (*[]string) carries on the Go side. No Go counterpart by
// design.
SkipProtoFields: []string{"roots_set"},
},
{
Name: "WasmResolver",
@@ -73,6 +73,11 @@ type MessageSpec struct {
// is intentionally absent from the proto because consumers consult
// their own registry for builtin signatures.
SkipGoFields []string
// SkipProtoFields names proto fields with no Go counterpart by design —
// wire-form bookkeeping that the Go side carries differently. For
// example, bundle.Manifest.roots_set distinguishes nil from explicit-
// empty on the wire; the Go side already carries that as Roots *[]string.
SkipProtoFields []string
}
// OneofSpec asserts that the named oneof on MessageName has exactly the
@@ -289,6 +294,14 @@ func checkMessage(t *testing.T, declared map[string]protoreflect.MessageDescript
}
}
skipProto := map[string]bool{}
for _, n := range m.SkipProtoFields {
skipProto[n] = true
if msg.Fields().ByName(protoreflect.Name(n)) == nil {
t.Errorf("%s: SkipProtoFields entry %q does not match any proto field on this message", m.Name, n)
}
}
// Validate FieldNameOverride entries point at real Go fields.
for goJSONName := range override {
if !goJSONNameExists(m.GoType, goJSONName) {
@@ -333,6 +346,9 @@ func checkMessage(t *testing.T, declared map[string]protoreflect.MessageDescript
if opaque[name] {
continue
}
if skipProto[name] {
continue
}
// Fields belonging to a oneof handled by an OneofSpec are
// validated there, not here. Skip them so the orphan check
// doesn't double-fire.
+6 -2
View File
@@ -3172,7 +3172,7 @@ func (b *metadataParser) Parse() (result *Annotations, err error) {
switch v := v.(type) {
case string:
a.Schema, err = parseSchemaRef(v)
a.Schema, err = ParseSchemaRef(v)
if err != nil {
return nil, err
}
@@ -3275,10 +3275,14 @@ func unwrapPair(pair map[string]any) (string, any) {
var errInvalidSchemaRef = errors.New("invalid schema reference")
// ParseSchemaRef parses a schema reference string into a Ref. Unlike
// ParseRef, it accepts the bare `schema` Var and Refs prefixed with the
// schema root document.
//
// NOTE(tsandall): 'schema' is not registered as a root because it's not
// supported by the compiler or evaluator today. Once we fix that, we can remove
// this function.
func parseSchemaRef(s string) (Ref, error) {
func ParseSchemaRef(s string) (Ref, error) {
term, err := ParseTerm(s)
if err == nil {
+134 -21
View File
@@ -23,11 +23,13 @@ import (
"github.com/gobwas/glob"
"golang.org/x/sync/errgroup"
"google.golang.org/protobuf/proto"
"github.com/open-policy-agent/opa/internal/file/archive"
"github.com/open-policy-agent/opa/internal/merge"
"github.com/open-policy-agent/opa/v1/ast"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
pb "github.com/open-policy-agent/opa/v1/bundle/v1pb"
"github.com/open-policy-agent/opa/v1/format"
"github.com/open-policy-agent/opa/v1/metrics"
"github.com/open-policy-agent/opa/v1/storage"
@@ -39,7 +41,9 @@ const (
RegoExt = ".rego"
WasmFile = "policy.wasm"
PlanFile = "plan.json"
PlanProtoFile = "plan.pb"
ManifestExt = ".manifest"
ManifestProtoExt = ".manifest.pb"
SignaturesFile = "signatures.json"
patchFile = "patch.json"
dataFile = "data.json"
@@ -70,6 +74,13 @@ type Bundle struct {
lazyLoadingMode bool
sizeLimitBytes int64
manifestProto bool
}
// SetManifestProto configures the bundle to serialize its manifest as
// protobuf at /.manifest.pb instead of JSON at /.manifest.
func (b *Bundle) SetManifestProto(yes bool) {
b.manifestProto = yes
}
// Raw contains raw bytes representing the bundle's content
@@ -641,6 +652,7 @@ func (r *Reader) Read() (Bundle, error) {
}
var modules []ModuleFile
var manifestPath string
for _, f := range descriptors {
buf, err := readFile(f, r.sizeLimitBytes)
if err != nil {
@@ -695,7 +707,7 @@ func (r *Reader) Read() (Bundle, error) {
Path: r.fullPath(path),
Raw: buf.Bytes(),
})
} else if filepath.Base(path) == PlanFile {
} else if filepath.Base(path) == PlanFile || filepath.Base(path) == PlanProtoFile {
bundle.PlanModules = append(bundle.PlanModules, PlanModuleFile{
URL: f.URL(),
Path: r.fullPath(path),
@@ -741,7 +753,26 @@ func (r *Reader) Read() (Bundle, error) {
return empty, err
}
} else if strings.HasSuffix(path, ManifestProtoExt) {
if manifestPath != "" {
return empty, fmt.Errorf("bundle contains multiple manifest files: %q and %q", manifestPath, path)
}
manifestPath = path
pbManifest := &pb.Manifest{}
if err := proto.Unmarshal(buf.Bytes(), pbManifest); err != nil {
return empty, fmt.Errorf("bundle load failed on manifest decode: %w", err)
}
m, err := ManifestFromProto(pbManifest)
if err != nil {
return empty, fmt.Errorf("bundle load failed on manifest decode: %w", err)
}
bundle.Manifest = *m
bundle.manifestProto = true
} else if strings.HasSuffix(path, ManifestExt) {
if manifestPath != "" {
return empty, fmt.Errorf("bundle contains multiple manifest files: %q and %q", manifestPath, path)
}
manifestPath = path
if err := util.NewJSONDecoder(&buf).Decode(&bundle.Manifest); err != nil {
return empty, fmt.Errorf("bundle load failed on manifest decode: %w", err)
}
@@ -935,6 +966,9 @@ func (w *Writer) DisableFormat(yes bool) *Writer {
// Write writes the bundle to the writer's output stream.
func (w *Writer) Write(bundle Bundle) error {
if err := validateBundleFormat(&bundle); err != nil {
return err
}
tw := archive.NewTarGzWriter(w.w)
if bundle.Type() == SnapshotBundleType {
@@ -971,8 +1005,18 @@ func (w *Writer) Write(bundle Bundle) error {
}
if !bundle.Manifest.Empty() {
if err := tw.WriteJSONFile("/.manifest", bundle.Manifest); err != nil {
return err
if bundle.manifestProto {
bs, err := marshalManifestProto(&bundle.Manifest)
if err != nil {
return err
}
if err := tw.WriteFile(util.WithPrefix(ManifestProtoExt, "/"), bs); err != nil {
return err
}
} else {
if err := tw.WriteJSONFile("/.manifest", bundle.Manifest); err != nil {
return err
}
}
}
@@ -1060,32 +1104,74 @@ func hashBundleFiles(hash SignatureHasher, b *Bundle) ([]FileInfo, error) {
files = append(files, NewFile(strings.TrimPrefix(planmodule.Path, "/"), hex.EncodeToString(bs), defaultHashingAlg))
}
// If the manifest is essentially empty, don't add it to the signatures since it
// won't be written to the bundle. Otherwise:
// parse the manifest into a JSON structure;
// then recursively order the fields of all objects alphabetically and then apply
// the hash function to result to compute the hash.
// Skip empty manifest — Writer.Write skips it too, so no entry to hash.
// Proto manifest is hashed as raw deterministic-marshal bytes (matches
// what VerifyBundleFile sees, since IsStructuredDoc is false for /.manifest.pb).
if !b.Manifest.Empty() {
mbs, err := json.Marshal(b.Manifest)
if err != nil {
return files, err
}
if b.manifestProto {
pbBytes, err := marshalManifestProto(&b.Manifest)
if err != nil {
return files, err
}
if bs, err = hash.HashFile(pbBytes); err != nil {
return files, err
}
files = append(files, NewFile(strings.TrimPrefix(ManifestProtoExt, "/"), hex.EncodeToString(bs), defaultHashingAlg))
} else {
mbs, err := json.Marshal(b.Manifest)
if err != nil {
return files, err
}
var result map[string]any
if err := util.Unmarshal(mbs, &result); err != nil {
return files, err
}
var result map[string]any
if err := util.Unmarshal(mbs, &result); err != nil {
return files, err
}
if bs, err = hash.HashFile(result); err != nil {
return files, err
}
if bs, err = hash.HashFile(result); err != nil {
return files, err
}
files = append(files, NewFile(strings.TrimPrefix(ManifestExt, "/"), hex.EncodeToString(bs), defaultHashingAlg))
files = append(files, NewFile(strings.TrimPrefix(ManifestExt, "/"), hex.EncodeToString(bs), defaultHashingAlg))
}
}
return files, err
}
// marshalManifestProto returns the deterministic protobuf wire form so
// signer and writer produce byte-identical output (sign/verify on
// /.manifest.pb depends on it).
func marshalManifestProto(m *Manifest) ([]byte, error) {
pbManifest, err := ManifestToProto(m)
if err != nil {
return nil, err
}
return proto.MarshalOptions{Deterministic: true}.Marshal(pbManifest)
}
// validateBundleFormat rejects bundles whose plan format disagrees with
// the manifest format (e.g. /plan.pb + /.manifest).
func validateBundleFormat(b *Bundle) error {
if b.Manifest.Empty() {
return nil
}
for _, pm := range b.PlanModules {
base := filepath.Base(pm.Path)
switch base {
case PlanFile:
if b.manifestProto {
return fmt.Errorf("bundle has proto manifest but JSON plan %q; SetManifestProto must agree with plan format", pm.Path)
}
case PlanProtoFile:
if !b.manifestProto {
return fmt.Errorf("bundle has JSON manifest but proto plan %q; SetManifestProto(true) required", pm.Path)
}
}
}
return nil
}
// FormatModules formats Rego modules
// Modules will be formatted to comply with [ast.DefaultRegoVersion], but Rego compatibility of individual parsed modules will be respected (e.g. if 'rego.v1' is imported).
func (b *Bundle) FormatModules(useModulePath bool) error {
@@ -1473,11 +1559,34 @@ func MergeWithRegoVersion(bundles []*Bundle, regoVersion ast.RegoVersion, usePat
var roots []string
var result Bundle
var planFile string
var manifestProto bool
var manifestProtoSet bool
for _, b := range bundles {
if b.Manifest.Roots == nil {
return nil, errors.New("bundle manifest not initialized")
}
for _, pm := range b.PlanModules {
base := filepath.Base(pm.Path)
if base != PlanFile && base != PlanProtoFile {
continue
}
if planFile == "" {
planFile = base
} else if planFile != base {
return nil, fmt.Errorf("cannot merge bundles with mixed plan formats (%s and %s)", planFile, base)
}
}
if !manifestProtoSet {
manifestProto = b.manifestProto
manifestProtoSet = true
} else if manifestProto != b.manifestProto {
return nil, errors.New("cannot merge bundles with mixed manifest formats")
}
roots = append(roots, *b.Manifest.Roots...)
result.Modules = append(result.Modules, b.Modules...)
@@ -1508,6 +1617,8 @@ func MergeWithRegoVersion(bundles []*Bundle, regoVersion ast.RegoVersion, usePat
}
}
result.manifestProto = manifestProto
// We respect the bundle rego-version, defaulting to the provided rego version if not set.
result.SetRegoVersion(result.RegoVersion(regoVersion))
@@ -1666,7 +1777,9 @@ func modulePathWithPrefix(bundleName string, modulePath string) string {
return path.Join(bundleName, modulePath)
}
// IsStructuredDoc checks if the file name equals a structured file extension ex. ".json"
// IsStructuredDoc checks if the file name equals a structured file extension ex. ".json".
// Note: ManifestProtoExt (".manifest.pb") is intentionally absent — proto manifests are
// hashed as raw wire bytes on both the sign and verify paths.
func IsStructuredDoc(name string) bool {
base := filepath.Base(name)
return base == dataFile || base == yamlDataFile || base == SignaturesFile || base == ManifestExt
+271
View File
@@ -492,6 +492,46 @@ func TestReadWithManifest(t *testing.T) {
}
}
func TestReadRejectsDuplicateManifests(t *testing.T) {
pbBytes, err := marshalManifestProto(&Manifest{Revision: "from-proto"})
if err != nil {
t.Fatal(err)
}
cases := []struct {
note string
files [][2]string
}{
{
note: "json + proto manifest",
files: [][2]string{
{"/.manifest", `{"revision": "from-json"}`},
{"/.manifest.pb", string(pbBytes)},
},
},
{
note: "two json manifests at different paths (HasSuffix shadowing)",
files: [][2]string{
{"/.manifest", `{"revision": "from-root"}`},
{"/sub/release.manifest", `{"revision": "from-sub"}`},
},
},
}
for _, tc := range cases {
t.Run(tc.note, func(t *testing.T) {
buf := archive.MustWriteTarGz(tc.files)
_, err := NewReader(buf).Read()
if err == nil {
t.Fatal("expected error for duplicate manifests, got nil")
}
if !strings.Contains(err.Error(), "multiple manifest files") {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestManifestMetadata(t *testing.T) {
files := [][2]string{
{"/.manifest", `{
@@ -1297,6 +1337,168 @@ func TestRoundtrip(t *testing.T) {
}
}
func TestWriterRejectsMixedPlanManifestFormats(t *testing.T) {
manifest := Manifest{Roots: &[]string{""}, Revision: "r"}
t.Run("proto manifest with JSON plan", func(t *testing.T) {
b := Bundle{
Manifest: manifest,
PlanModules: []PlanModuleFile{{
Path: PlanFile, URL: PlanFile, Raw: []byte("{}"),
}},
}
b.SetManifestProto(true)
var buf bytes.Buffer
err := NewWriter(&buf).Write(b)
if err == nil || !strings.Contains(err.Error(), "proto manifest but JSON plan") {
t.Fatalf("expected mixed-format error, got: %v", err)
}
})
t.Run("JSON manifest with proto plan", func(t *testing.T) {
b := Bundle{
Manifest: manifest,
PlanModules: []PlanModuleFile{{
Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
}},
}
var buf bytes.Buffer
err := NewWriter(&buf).Write(b)
if err == nil || !strings.Contains(err.Error(), "JSON manifest but proto plan") {
t.Fatalf("expected mixed-format error, got: %v", err)
}
})
t.Run("empty manifest exempts proto plan", func(t *testing.T) {
b := Bundle{
Manifest: Manifest{},
PlanModules: []PlanModuleFile{{
Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
}},
}
var buf bytes.Buffer
if err := NewWriter(&buf).Write(b); err != nil {
t.Fatalf("expected empty-manifest write to succeed, got: %v", err)
}
})
t.Run("uniform proto succeeds", func(t *testing.T) {
b := Bundle{
Manifest: manifest,
PlanModules: []PlanModuleFile{{
Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
}},
}
b.SetManifestProto(true)
var buf bytes.Buffer
if err := NewWriter(&buf).Write(b); err != nil {
t.Fatalf("expected uniform-proto write to succeed, got: %v", err)
}
})
}
func TestRoundtripWithProtoManifestSigned(t *testing.T) {
bundle := Bundle{
Data: map[string]any{
"foo": map[string]any{
"bar": []any{json.Number("1"), json.Number("2"), json.Number("3")},
},
},
PlanModules: []PlanModuleFile{
{
Path: PlanProtoFile,
URL: PlanProtoFile,
Raw: []byte("plan-proto-bytes"),
},
},
Manifest: Manifest{
Roots: &[]string{""},
Revision: "quickbrownfaux",
Metadata: map[string]any{"version": "v1", "hello": "world"},
},
}
bundle.SetManifestProto(true)
if err := bundle.GenerateSignature(NewSigningConfig("secret", "HS256", ""), "foo", false); err != nil {
t.Fatal("Unexpected error:", err)
}
var buf bytes.Buffer
if err := NewWriter(&buf).Write(bundle); err != nil {
t.Fatal("Unexpected error:", err)
}
vc := NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "foo", "", nil)
bundle2, err := NewReader(&buf).WithBundleVerificationConfig(vc).Read()
if err != nil {
t.Fatalf("verify failed for proto-manifest bundle: %v", err)
}
if bundle2.Manifest.Revision != bundle.Manifest.Revision {
t.Fatalf("manifest not preserved through proto round-trip: got revision %q", bundle2.Manifest.Revision)
}
if !reflect.DeepEqual(bundle2.Signatures, bundle.Signatures) {
t.Fatal("expected signatures to be same after read")
}
}
func TestReadProtoManifestPreservesFormatIntent(t *testing.T) {
src := Bundle{
Data: map[string]any{},
Manifest: Manifest{
Roots: &[]string{""},
Revision: "abc",
},
}
src.SetManifestProto(true)
var buf bytes.Buffer
if err := NewWriter(&buf).Write(src); err != nil {
t.Fatal(err)
}
loaded, err := NewReader(&buf).Read()
if err != nil {
t.Fatal(err)
}
if !loaded.manifestProto {
t.Fatal("Reader did not propagate manifestProto onto the loaded bundle")
}
var out bytes.Buffer
if err := NewWriter(&out).Write(loaded); err != nil {
t.Fatal(err)
}
gr, err := gzip.NewReader(&out)
if err != nil {
t.Fatal(err)
}
tr := tar.NewReader(gr)
var sawProto, sawJSON bool
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
switch h.Name {
case "/.manifest.pb":
sawProto = true
case "/.manifest":
sawJSON = true
}
}
if !sawProto {
t.Error("re-written bundle missing /.manifest.pb")
}
if sawJSON {
t.Error("re-written bundle silently downgraded to /.manifest")
}
}
func TestRoundtripWithPlanModules(t *testing.T) {
b := Bundle{
@@ -1804,6 +2006,75 @@ func TestMergeCorruptManifest(t *testing.T) {
}
}
func TestMergeRejectsMixedFormats(t *testing.T) {
rootEmpty := &[]string{""}
t.Run("mixed plan formats", func(t *testing.T) {
jsonBundle := &Bundle{
Manifest: Manifest{Roots: rootEmpty},
PlanModules: []PlanModuleFile{{
Path: PlanFile, URL: PlanFile, Raw: []byte("{}"),
}},
}
protoBundle := &Bundle{
Manifest: Manifest{Roots: rootEmpty},
PlanModules: []PlanModuleFile{{
Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
}},
}
_, err := Merge([]*Bundle{jsonBundle, protoBundle})
if err == nil {
t.Fatal("expected error for mixed plan formats, got nil")
}
if !strings.Contains(err.Error(), "mixed plan formats") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("mixed manifest formats", func(t *testing.T) {
jsonManifestBundle := &Bundle{
Manifest: Manifest{Roots: rootEmpty, Revision: "j"},
}
protoManifestBundle := &Bundle{
Manifest: Manifest{Roots: rootEmpty, Revision: "p"},
}
protoManifestBundle.SetManifestProto(true)
_, err := Merge([]*Bundle{jsonManifestBundle, protoManifestBundle})
if err == nil {
t.Fatal("expected error for mixed manifest formats, got nil")
}
if !strings.Contains(err.Error(), "mixed manifest formats") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("uniform proto inputs propagate flag", func(t *testing.T) {
a := &Bundle{
Manifest: Manifest{Roots: &[]string{"a"}, Revision: "a"},
PlanModules: []PlanModuleFile{{
Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
}},
}
a.SetManifestProto(true)
b := &Bundle{
Manifest: Manifest{Roots: &[]string{"b"}, Revision: "b"},
PlanModules: []PlanModuleFile{{
Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x01},
}},
}
b.SetManifestProto(true)
merged, err := Merge([]*Bundle{a, b})
if err != nil {
t.Fatalf("expected uniform-format merge to succeed, got %v", err)
}
if !merged.manifestProto {
t.Fatal("merged bundle should carry manifestProto=true forward from inputs")
}
})
}
func TestMerge(t *testing.T) {
expRegoVersion := ast.DefaultRegoVersion.Int()
+4 -1
View File
@@ -200,7 +200,10 @@ func formatPath(fileName string, root string, pathFormat PathFormat) string {
case Chrooted:
// Trim off the root directory and return path as if chrooted
result := strings.TrimPrefix(fileName, filepath.FromSlash(root))
if root == "." && filepath.Base(fileName) == ManifestExt {
// TrimPrefix at root="." strips the leading dot from dotfile manifests
// (".manifest" → "manifest"), which then misses the Reader's HasSuffix
// check. Restore the original name for both manifest forms.
if root == "." && (filepath.Base(fileName) == ManifestExt || filepath.Base(fileName) == ManifestProtoExt) {
result = fileName
}
if !strings.HasPrefix(result, string(filepath.Separator)) {
+44
View File
@@ -544,3 +544,47 @@ func getFilter(pattern string, minDepth int) filter.LoaderFilter {
return match && depth >= minDepth
}
}
func TestFormatPathChrootedManifests(t *testing.T) {
sep := string(filepath.Separator)
cases := []struct {
note string
fileName string
root string
want string
}{
{
note: "json manifest at chroot .",
fileName: ".manifest",
root: ".",
want: sep + ".manifest",
},
{
note: "proto manifest at chroot .",
fileName: ".manifest.pb",
root: ".",
want: sep + ".manifest.pb",
},
{
note: "json manifest at named chroot",
fileName: filepath.Join("a", "b", ".manifest"),
root: filepath.Join("a", "b"),
want: sep + ".manifest",
},
{
note: "proto manifest at named chroot",
fileName: filepath.Join("a", "b", ".manifest.pb"),
root: filepath.Join("a", "b"),
want: sep + ".manifest.pb",
},
}
for _, tc := range cases {
t.Run(tc.note, func(t *testing.T) {
got := formatPath(tc.fileName, tc.root, Chrooted)
if got != tc.want {
t.Fatalf("formatPath(%q, %q, Chrooted) = %q, want %q", tc.fileName, tc.root, got, tc.want)
}
})
}
}
+5 -1
View File
@@ -16,7 +16,7 @@ message Manifest {
// Bundle revision string.
string revision = 1;
// Root paths the bundle owns.
// Root paths the bundle owns. See `roots_set` for nil-vs-empty.
repeated string roots = 2;
// Wasm resolvers attached to the bundle. JSON key is `wasm`.
@@ -31,6 +31,10 @@ message Manifest {
// Free-form metadata object. Modeled as `Struct` because the Go field
// is `map[string]any`.
google.protobuf.Struct metadata = 6;
// True if `bundle.Manifest.Roots` was non-nil. `repeated string` can't
// distinguish nil (default to [""]) from explicit-empty (owns no paths).
bool roots_set = 7;
}
// WasmResolver mirrors `bundle.WasmResolver` in v1/bundle/bundle.go.
+434
View File
@@ -0,0 +1,434 @@
// Copyright 2026 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package bundle
import (
"encoding/json"
"fmt"
"net/url"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/ast/location"
pb "github.com/open-policy-agent/opa/v1/bundle/v1pb"
)
// ManifestToProto converts a bundle Manifest to its protobuf wire-form,
// defined in v1/bundle/manifest.proto. The compiled-only fileRegoVersions
// cache is intentionally not modeled. Roots presence (nil vs explicit-empty)
// is preserved via the `roots_set` wire field.
func ManifestToProto(m *Manifest) (*pb.Manifest, error) {
if m == nil {
return nil, nil
}
out := &pb.Manifest{
Revision: proto.String(m.Revision),
}
if m.Roots != nil {
out.Roots = append([]string(nil), (*m.Roots)...)
out.RootsSet = proto.Bool(true)
}
if len(m.WasmResolvers) > 0 {
out.Wasm = make([]*pb.WasmResolver, len(m.WasmResolvers))
for i := range m.WasmResolvers {
wr, err := wasmResolverToProto(&m.WasmResolvers[i])
if err != nil {
return nil, fmt.Errorf("manifest wasm[%d]: %w", i, err)
}
out.Wasm[i] = wr
}
}
if m.RegoVersion != nil {
out.RegoVersion = proto.Int32(int32(*m.RegoVersion))
}
if len(m.FileRegoVersions) > 0 {
out.FileRegoVersions = make(map[string]int32, len(m.FileRegoVersions))
for k, v := range m.FileRegoVersions {
out.FileRegoVersions[k] = int32(v)
}
}
if len(m.Metadata) > 0 {
s, err := jsonNormalizeStruct(m.Metadata)
if err != nil {
return nil, fmt.Errorf("manifest metadata: %w", err)
}
out.Metadata = s
}
return out, nil
}
func wasmResolverToProto(w *WasmResolver) (*pb.WasmResolver, error) {
if w == nil {
return nil, nil
}
out := &pb.WasmResolver{
Entrypoint: proto.String(w.Entrypoint),
Module: proto.String(w.Module),
}
if len(w.Annotations) > 0 {
out.Annotations = make([]*pb.Annotations, len(w.Annotations))
for i, a := range w.Annotations {
ap, err := annotationsToProto(a)
if err != nil {
return nil, fmt.Errorf("annotations[%d]: %w", i, err)
}
out.Annotations[i] = ap
}
}
return out, nil
}
func annotationsToProto(a *ast.Annotations) (*pb.Annotations, error) {
if a == nil {
return nil, nil
}
out := &pb.Annotations{
Scope: proto.String(a.Scope),
Title: proto.String(a.Title),
Entrypoint: proto.Bool(a.Entrypoint),
Description: proto.String(a.Description),
Organizations: append([]string(nil), a.Organizations...),
}
if len(a.RelatedResources) > 0 {
out.RelatedResources = make([]*pb.RelatedResourceAnnotation, len(a.RelatedResources))
for i, r := range a.RelatedResources {
out.RelatedResources[i] = relatedResourceToProto(r)
}
}
if len(a.Authors) > 0 {
out.Authors = make([]*pb.AuthorAnnotation, len(a.Authors))
for i, au := range a.Authors {
out.Authors[i] = authorToProto(au)
}
}
if len(a.Schemas) > 0 {
out.Schemas = make([]*pb.SchemaAnnotation, len(a.Schemas))
for i, s := range a.Schemas {
sa, err := schemaToProto(s)
if err != nil {
return nil, fmt.Errorf("schemas[%d]: %w", i, err)
}
out.Schemas[i] = sa
}
}
if a.Compile != nil {
out.Compile = compileToProto(a.Compile)
}
if len(a.Custom) > 0 {
s, err := jsonNormalizeStruct(a.Custom)
if err != nil {
return nil, fmt.Errorf("custom: %w", err)
}
out.Custom = s
}
if len(a.Labels) > 0 {
s, err := jsonNormalizeStruct(a.Labels)
if err != nil {
return nil, fmt.Errorf("labels: %w", err)
}
out.Labels = s
}
if a.Location != nil {
out.Location = locationToProto(a.Location)
}
return out, nil
}
func relatedResourceToProto(r *ast.RelatedResourceAnnotation) *pb.RelatedResourceAnnotation {
if r == nil {
return nil
}
return &pb.RelatedResourceAnnotation{
Ref: proto.String(r.Ref.String()),
Description: proto.String(r.Description),
}
}
func authorToProto(a *ast.AuthorAnnotation) *pb.AuthorAnnotation {
if a == nil {
return nil
}
return &pb.AuthorAnnotation{
Name: proto.String(a.Name),
Email: proto.String(a.Email),
}
}
func schemaToProto(s *ast.SchemaAnnotation) (*pb.SchemaAnnotation, error) {
if s == nil {
return nil, nil
}
out := &pb.SchemaAnnotation{
Path: proto.String(s.Path.String()),
Schema: proto.String(s.Schema.String()),
}
if s.Definition != nil {
v, err := jsonNormalizeValue(*s.Definition)
if err != nil {
return nil, fmt.Errorf("definition: %w", err)
}
out.Definition = v
}
return out, nil
}
func compileToProto(c *ast.CompileAnnotation) *pb.CompileAnnotation {
if c == nil {
return nil
}
out := &pb.CompileAnnotation{
MaskRule: proto.String(c.MaskRule.String()),
}
if len(c.Unknowns) > 0 {
out.Unknowns = make([]string, len(c.Unknowns))
for i, u := range c.Unknowns {
out.Unknowns[i] = u.String()
}
}
return out
}
func locationToProto(l *location.Location) *pb.Location {
if l == nil {
return nil
}
return &pb.Location{
File: proto.String(l.File),
Row: proto.Int32(int32(l.Row)),
Col: proto.Int32(int32(l.Col)),
}
}
// ManifestFromProto is the inverse of ManifestToProto.
func ManifestFromProto(m *pb.Manifest) (*Manifest, error) {
if m == nil {
return nil, nil
}
out := &Manifest{
Revision: m.GetRevision(),
}
if m.GetRootsSet() {
roots := make([]string, len(m.Roots))
copy(roots, m.Roots)
out.Roots = &roots
}
if len(m.Wasm) > 0 {
out.WasmResolvers = make([]WasmResolver, len(m.Wasm))
for i, wr := range m.Wasm {
converted, err := wasmResolverFromProto(wr)
if err != nil {
return nil, fmt.Errorf("manifest wasm[%d]: %w", i, err)
}
out.WasmResolvers[i] = converted
}
}
if m.RegoVersion != nil {
v := int(*m.RegoVersion)
out.RegoVersion = &v
}
if len(m.FileRegoVersions) > 0 {
out.FileRegoVersions = make(map[string]int, len(m.FileRegoVersions))
for k, v := range m.FileRegoVersions {
out.FileRegoVersions[k] = int(v)
}
}
if m.Metadata != nil {
out.Metadata = m.Metadata.AsMap()
}
return out, nil
}
func wasmResolverFromProto(w *pb.WasmResolver) (WasmResolver, error) {
out := WasmResolver{
Entrypoint: w.GetEntrypoint(),
Module: w.GetModule(),
}
if len(w.Annotations) > 0 {
out.Annotations = make([]*ast.Annotations, len(w.Annotations))
for i, a := range w.Annotations {
converted, err := annotationsFromProto(a)
if err != nil {
return WasmResolver{}, fmt.Errorf("annotations[%d]: %w", i, err)
}
out.Annotations[i] = converted
}
}
return out, nil
}
func annotationsFromProto(a *pb.Annotations) (*ast.Annotations, error) {
if a == nil {
return nil, nil
}
out := &ast.Annotations{
Scope: a.GetScope(),
Title: a.GetTitle(),
Entrypoint: a.GetEntrypoint(),
Description: a.GetDescription(),
}
if len(a.Organizations) > 0 {
out.Organizations = append([]string(nil), a.Organizations...)
}
if len(a.RelatedResources) > 0 {
out.RelatedResources = make([]*ast.RelatedResourceAnnotation, len(a.RelatedResources))
for i, r := range a.RelatedResources {
converted, err := relatedResourceFromProto(r)
if err != nil {
return nil, fmt.Errorf("related_resources[%d]: %w", i, err)
}
out.RelatedResources[i] = converted
}
}
if len(a.Authors) > 0 {
out.Authors = make([]*ast.AuthorAnnotation, len(a.Authors))
for i, au := range a.Authors {
out.Authors[i] = authorFromProto(au)
}
}
if len(a.Schemas) > 0 {
out.Schemas = make([]*ast.SchemaAnnotation, len(a.Schemas))
for i, s := range a.Schemas {
converted, err := schemaFromProto(s)
if err != nil {
return nil, fmt.Errorf("schemas[%d]: %w", i, err)
}
out.Schemas[i] = converted
}
}
if a.Compile != nil {
converted, err := compileFromProto(a.Compile)
if err != nil {
return nil, fmt.Errorf("compile: %w", err)
}
out.Compile = converted
}
if a.Custom != nil {
out.Custom = a.Custom.AsMap()
}
if a.Labels != nil {
out.Labels = a.Labels.AsMap()
}
if a.Location != nil {
out.Location = locationFromProto(a.Location)
}
return out, nil
}
func relatedResourceFromProto(r *pb.RelatedResourceAnnotation) (*ast.RelatedResourceAnnotation, error) {
if r == nil {
return nil, nil
}
out := &ast.RelatedResourceAnnotation{
Description: r.GetDescription(),
}
if ref := r.GetRef(); ref != "" {
u, err := url.Parse(ref)
if err != nil {
return nil, fmt.Errorf("ref %q: %w", ref, err)
}
out.Ref = *u
}
return out, nil
}
func authorFromProto(a *pb.AuthorAnnotation) *ast.AuthorAnnotation {
if a == nil {
return nil
}
return &ast.AuthorAnnotation{
Name: a.GetName(),
Email: a.GetEmail(),
}
}
func schemaFromProto(s *pb.SchemaAnnotation) (*ast.SchemaAnnotation, error) {
if s == nil {
return nil, nil
}
out := &ast.SchemaAnnotation{}
if p := s.GetPath(); p != "" {
ref, err := ast.ParseRef(p)
if err != nil {
return nil, fmt.Errorf("path %q: %w", p, err)
}
out.Path = ref
}
if sc := s.GetSchema(); sc != "" {
ref, err := ast.ParseSchemaRef(sc)
if err != nil {
return nil, fmt.Errorf("schema %q: %w", sc, err)
}
out.Schema = ref
}
if s.Definition != nil {
def := s.Definition.AsInterface()
out.Definition = &def
}
return out, nil
}
func compileFromProto(c *pb.CompileAnnotation) (*ast.CompileAnnotation, error) {
if c == nil {
return nil, nil
}
out := &ast.CompileAnnotation{}
if mr := c.GetMaskRule(); mr != "" {
ref, err := ast.ParseRef(mr)
if err != nil {
return nil, fmt.Errorf("mask_rule %q: %w", mr, err)
}
out.MaskRule = ref
}
if len(c.Unknowns) > 0 {
out.Unknowns = make([]ast.Ref, len(c.Unknowns))
for i, u := range c.Unknowns {
ref, err := ast.ParseRef(u)
if err != nil {
return nil, fmt.Errorf("unknowns[%d] %q: %w", i, u, err)
}
out.Unknowns[i] = ref
}
}
return out, nil
}
func locationFromProto(l *pb.Location) *location.Location {
if l == nil {
return nil
}
return &location.Location{
File: l.GetFile(),
Row: int(l.GetRow()),
Col: int(l.GetCol()),
}
}
// jsonNormalizeStruct routes a map through JSON before structpb.NewStruct
// so the proto path accepts the same value types the JSON path does.
func jsonNormalizeStruct(m map[string]any) (*structpb.Struct, error) {
bs, err := json.Marshal(m)
if err != nil {
return nil, err
}
var normalized map[string]any
if err := json.Unmarshal(bs, &normalized); err != nil {
return nil, err
}
return structpb.NewStruct(normalized)
}
func jsonNormalizeValue(v any) (*structpb.Value, error) {
bs, err := json.Marshal(v)
if err != nil {
return nil, err
}
var normalized any
if err := json.Unmarshal(bs, &normalized); err != nil {
return nil, err
}
return structpb.NewValue(normalized)
}
+236
View File
@@ -0,0 +1,236 @@
// Copyright 2026 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package bundle
import (
"encoding/json"
"net/url"
"reflect"
"testing"
"google.golang.org/protobuf/proto"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/ast/location"
pb "github.com/open-policy-agent/opa/v1/bundle/v1pb"
)
func TestManifestProtoRoundTrip(t *testing.T) {
regoV1 := 1
roots := []string{"", "a/b"}
relatedURL, err := url.Parse("https://example.com/policy")
if err != nil {
t.Fatal(err)
}
def := any(map[string]any{"type": "string"})
m := &Manifest{
Revision: "rev-1",
Roots: &roots,
WasmResolvers: []WasmResolver{{
Entrypoint: "data.example.allow",
Module: "/policy.wasm",
Annotations: []*ast.Annotations{{
Scope: "rule",
Title: "t",
Entrypoint: true,
Description: "d",
Organizations: []string{"o1", "o2"},
RelatedResources: []*ast.RelatedResourceAnnotation{{
Ref: *relatedURL,
Description: "rr",
}},
Authors: []*ast.AuthorAnnotation{{Name: "Ada", Email: "ada@example.com"}},
Schemas: []*ast.SchemaAnnotation{{
Path: ast.MustParseRef("input.x"),
Schema: ast.MustParseRef("schema.foo"),
Definition: &def,
}},
Compile: &ast.CompileAnnotation{
MaskRule: ast.MustParseRef("data.mask.rule"),
Unknowns: []ast.Ref{ast.MustParseRef("input.unknown")},
},
Custom: map[string]any{"k": "v"},
Labels: map[string]any{"team": "core"},
Location: &location.Location{File: "f.rego", Row: 1, Col: 2},
}},
}},
RegoVersion: &regoV1,
FileRegoVersions: map[string]int{"a.rego": 1},
Metadata: map[string]any{"k": "v", "n": float64(7)},
}
pbManifest, err := ManifestToProto(m)
if err != nil {
t.Fatalf("to proto: %v", err)
}
bs, err := proto.Marshal(pbManifest)
if err != nil {
t.Fatalf("marshal: %v", err)
}
decoded := &pb.Manifest{}
if err := proto.Unmarshal(bs, decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got, err := ManifestFromProto(decoded)
if err != nil {
t.Fatalf("from proto: %v", err)
}
if !m.Equal(*got) {
t.Fatal("manifest semantic equality failed after round trip")
}
if !reflect.DeepEqual(m.FileRegoVersions, got.FileRegoVersions) {
t.Fatalf("file rego versions: want %v, got %v", m.FileRegoVersions, got.FileRegoVersions)
}
if got.WasmResolvers[0].Annotations[0].Compile.MaskRule.String() != "data.mask.rule" {
t.Fatalf("compile mask rule round-trip lost: %v", got.WasmResolvers[0].Annotations[0].Compile.MaskRule)
}
if got.WasmResolvers[0].Annotations[0].RelatedResources[0].Ref.String() != "https://example.com/policy" {
t.Fatalf("related resource ref round-trip lost: %v", got.WasmResolvers[0].Annotations[0].RelatedResources[0].Ref)
}
}
func TestManifestToProtoNil(t *testing.T) {
got, err := ManifestToProto(nil)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
}
func TestManifestFromProtoNil(t *testing.T) {
got, err := ManifestFromProto(nil)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
}
// TestManifestMetadataAcceptsJSONTypes pins fidelity parity with the
// JSON path. structpb.NewStruct rejects ints, json.RawMessage,
// time-like values, etc.; the encoder routes Metadata through JSON so
// the proto path accepts whatever the JSON path does.
func TestManifestMetadataAcceptsJSONTypes(t *testing.T) {
m := &Manifest{
Revision: "r",
Metadata: map[string]any{
"count": int(7),
"raw": json.RawMessage(`{"k":"v"}`),
"nested": map[string]any{
"items": []any{int64(1), int64(2)},
},
},
}
pm, err := ManifestToProto(m)
if err != nil {
t.Fatalf("ManifestToProto rejected JSON-friendly types: %v", err)
}
out, err := ManifestFromProto(pm)
if err != nil {
t.Fatal(err)
}
got := out.Metadata
if got["count"].(float64) != 7 {
t.Fatalf("count not preserved: %v", got["count"])
}
raw, ok := got["raw"].(map[string]any)
if !ok || raw["k"].(string) != "v" {
t.Fatalf("json.RawMessage not preserved: %v", got["raw"])
}
}
func TestManifestFromProtoMalformedRef(t *testing.T) {
pm := &pb.Manifest{
Wasm: []*pb.WasmResolver{{
Annotations: []*pb.Annotations{{
Compile: &pb.CompileAnnotation{MaskRule: proto.String("not a ref!!!")},
}},
}},
}
if _, err := ManifestFromProto(pm); err == nil {
t.Fatal("expected error for malformed mask_rule, got nil")
}
}
// TestManifestRootsPresenceRoundTrip pins the nil-vs-explicit-empty
// distinction across the proto round-trip. `repeated string` cannot
// carry that bit on its own; the `roots_set` wire field does.
func TestManifestRootsPresenceRoundTrip(t *testing.T) {
cases := []struct {
note string
in *[]string
}{
{note: "nil (default to [\"\"])", in: nil},
{note: "explicit empty (owns nothing)", in: &[]string{}},
{note: "populated", in: &[]string{"a", "b"}},
}
for _, tc := range cases {
t.Run(tc.note, func(t *testing.T) {
pm, err := ManifestToProto(&Manifest{Revision: "r", Roots: tc.in})
if err != nil {
t.Fatal(err)
}
out, err := ManifestFromProto(pm)
if err != nil {
t.Fatal(err)
}
if (tc.in == nil) != (out.Roots == nil) {
t.Fatalf("roots nil-ness lost: in=%v out=%v", tc.in, out.Roots)
}
if tc.in != nil && !reflect.DeepEqual(*tc.in, *out.Roots) {
t.Fatalf("roots values lost: want %v, got %v", *tc.in, *out.Roots)
}
})
}
}
// TestSchemaAnnotationBareVarRoundTrip pins the schema-ref decoder.
// Annotations using `schemas: [{input: schema}]` produce a bare Var Ref
// from the canonical parser (parseSchemaRef in v1/ast). The encoder
// serializes that as the literal "schema". ast.ParseRef cannot decode a
// bare Var, so the proto round-trip used to fail with `expected ref but
// got schema` — load-blocking for any annotation using this idiom.
func TestSchemaAnnotationBareVarRoundTrip(t *testing.T) {
cases := []struct {
note string
ref ast.Ref
}{
{note: "bare schema var", ref: ast.SchemaRootRef.Copy()},
{note: "schema with subpath", ref: ast.MustParseRef("schema.foo")},
}
for _, tc := range cases {
t.Run(tc.note, func(t *testing.T) {
m := &Manifest{
Revision: "r1",
WasmResolvers: []WasmResolver{{
Annotations: []*ast.Annotations{{
Schemas: []*ast.SchemaAnnotation{{
Path: ast.MustParseRef("input.x"),
Schema: tc.ref,
}},
}},
}},
}
pm, err := ManifestToProto(m)
if err != nil {
t.Fatal(err)
}
out, err := ManifestFromProto(pm)
if err != nil {
t.Fatalf("decode failed for %v: %v", tc.ref, err)
}
gotRef := out.WasmResolvers[0].Annotations[0].Schemas[0].Schema
if !gotRef.Equal(tc.ref) {
t.Fatalf("schema ref not preserved: want %v, got %v", tc.ref, gotRef)
}
})
}
}
+751
View File
@@ -0,0 +1,751 @@
// Copyright 2026 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// source: v1/bundle/manifest.proto
package v1pb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
structpb "google.golang.org/protobuf/types/known/structpb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Manifest mirrors `bundle.Manifest` in v1/bundle/bundle.go.
type Manifest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Bundle revision string.
Revision *string `protobuf:"bytes,1,opt,name=revision" json:"revision,omitempty"`
// Root paths the bundle owns. See `roots_set` for nil-vs-empty.
Roots []string `protobuf:"bytes,2,rep,name=roots" json:"roots,omitempty"`
// Wasm resolvers attached to the bundle. JSON key is `wasm`.
Wasm []*WasmResolver `protobuf:"bytes,3,rep,name=wasm" json:"wasm,omitempty"`
// Global Rego version for the bundle. Currently 0 (RegoV0) or 1 (RegoV1).
RegoVersion *int32 `protobuf:"varint,4,opt,name=rego_version,json=regoVersion" json:"rego_version,omitempty"`
// Per-file Rego version overrides keyed by file path.
FileRegoVersions map[string]int32 `protobuf:"bytes,5,rep,name=file_rego_versions,json=fileRegoVersions" json:"file_rego_versions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"`
// Free-form metadata object. Modeled as `Struct` because the Go field
// is `map[string]any`.
Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata" json:"metadata,omitempty"`
// True iff `bundle.Manifest.Roots` was non-nil. `repeated string` can't
// distinguish nil (default to [""]) from explicit-empty (owns no paths).
RootsSet *bool `protobuf:"varint,7,opt,name=roots_set,json=rootsSet" json:"roots_set,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Manifest) Reset() {
*x = Manifest{}
mi := &file_v1_bundle_manifest_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Manifest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Manifest) ProtoMessage() {}
func (x *Manifest) ProtoReflect() protoreflect.Message {
mi := &file_v1_bundle_manifest_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Manifest.ProtoReflect.Descriptor instead.
func (*Manifest) Descriptor() ([]byte, []int) {
return file_v1_bundle_manifest_proto_rawDescGZIP(), []int{0}
}
func (x *Manifest) GetRevision() string {
if x != nil && x.Revision != nil {
return *x.Revision
}
return ""
}
func (x *Manifest) GetRoots() []string {
if x != nil {
return x.Roots
}
return nil
}
func (x *Manifest) GetWasm() []*WasmResolver {
if x != nil {
return x.Wasm
}
return nil
}
func (x *Manifest) GetRegoVersion() int32 {
if x != nil && x.RegoVersion != nil {
return *x.RegoVersion
}
return 0
}
func (x *Manifest) GetFileRegoVersions() map[string]int32 {
if x != nil {
return x.FileRegoVersions
}
return nil
}
func (x *Manifest) GetMetadata() *structpb.Struct {
if x != nil {
return x.Metadata
}
return nil
}
func (x *Manifest) GetRootsSet() bool {
if x != nil && x.RootsSet != nil {
return *x.RootsSet
}
return false
}
// WasmResolver mirrors `bundle.WasmResolver` in v1/bundle/bundle.go.
type WasmResolver struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Entrypoint policy ref this resolver targets.
Entrypoint *string `protobuf:"bytes,1,opt,name=entrypoint" json:"entrypoint,omitempty"`
// Path to the wasm module within the bundle.
Module *string `protobuf:"bytes,2,opt,name=module" json:"module,omitempty"`
// Rego annotations attached to the entrypoint.
Annotations []*Annotations `protobuf:"bytes,3,rep,name=annotations" json:"annotations,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WasmResolver) Reset() {
*x = WasmResolver{}
mi := &file_v1_bundle_manifest_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WasmResolver) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WasmResolver) ProtoMessage() {}
func (x *WasmResolver) ProtoReflect() protoreflect.Message {
mi := &file_v1_bundle_manifest_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WasmResolver.ProtoReflect.Descriptor instead.
func (*WasmResolver) Descriptor() ([]byte, []int) {
return file_v1_bundle_manifest_proto_rawDescGZIP(), []int{1}
}
func (x *WasmResolver) GetEntrypoint() string {
if x != nil && x.Entrypoint != nil {
return *x.Entrypoint
}
return ""
}
func (x *WasmResolver) GetModule() string {
if x != nil && x.Module != nil {
return *x.Module
}
return ""
}
func (x *WasmResolver) GetAnnotations() []*Annotations {
if x != nil {
return x.Annotations
}
return nil
}
// Annotations mirrors `ast.Annotations` in v1/ast/annotations.go.
type Annotations struct {
state protoimpl.MessageState `protogen:"open.v1"`
Scope *string `protobuf:"bytes,1,opt,name=scope" json:"scope,omitempty"`
Title *string `protobuf:"bytes,2,opt,name=title" json:"title,omitempty"`
Entrypoint *bool `protobuf:"varint,3,opt,name=entrypoint" json:"entrypoint,omitempty"`
Description *string `protobuf:"bytes,4,opt,name=description" json:"description,omitempty"`
Organizations []string `protobuf:"bytes,5,rep,name=organizations" json:"organizations,omitempty"`
RelatedResources []*RelatedResourceAnnotation `protobuf:"bytes,6,rep,name=related_resources,json=relatedResources" json:"related_resources,omitempty"`
Authors []*AuthorAnnotation `protobuf:"bytes,7,rep,name=authors" json:"authors,omitempty"`
Schemas []*SchemaAnnotation `protobuf:"bytes,8,rep,name=schemas" json:"schemas,omitempty"`
Compile *CompileAnnotation `protobuf:"bytes,9,opt,name=compile" json:"compile,omitempty"`
// `custom` and `labels` are `map[string]any` in Go — genuinely
// free-form, so `Struct` is the right model.
Custom *structpb.Struct `protobuf:"bytes,10,opt,name=custom" json:"custom,omitempty"`
Labels *structpb.Struct `protobuf:"bytes,11,opt,name=labels" json:"labels,omitempty"`
Location *Location `protobuf:"bytes,12,opt,name=location" json:"location,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Annotations) Reset() {
*x = Annotations{}
mi := &file_v1_bundle_manifest_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Annotations) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Annotations) ProtoMessage() {}
func (x *Annotations) ProtoReflect() protoreflect.Message {
mi := &file_v1_bundle_manifest_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Annotations.ProtoReflect.Descriptor instead.
func (*Annotations) Descriptor() ([]byte, []int) {
return file_v1_bundle_manifest_proto_rawDescGZIP(), []int{2}
}
func (x *Annotations) GetScope() string {
if x != nil && x.Scope != nil {
return *x.Scope
}
return ""
}
func (x *Annotations) GetTitle() string {
if x != nil && x.Title != nil {
return *x.Title
}
return ""
}
func (x *Annotations) GetEntrypoint() bool {
if x != nil && x.Entrypoint != nil {
return *x.Entrypoint
}
return false
}
func (x *Annotations) GetDescription() string {
if x != nil && x.Description != nil {
return *x.Description
}
return ""
}
func (x *Annotations) GetOrganizations() []string {
if x != nil {
return x.Organizations
}
return nil
}
func (x *Annotations) GetRelatedResources() []*RelatedResourceAnnotation {
if x != nil {
return x.RelatedResources
}
return nil
}
func (x *Annotations) GetAuthors() []*AuthorAnnotation {
if x != nil {
return x.Authors
}
return nil
}
func (x *Annotations) GetSchemas() []*SchemaAnnotation {
if x != nil {
return x.Schemas
}
return nil
}
func (x *Annotations) GetCompile() *CompileAnnotation {
if x != nil {
return x.Compile
}
return nil
}
func (x *Annotations) GetCustom() *structpb.Struct {
if x != nil {
return x.Custom
}
return nil
}
func (x *Annotations) GetLabels() *structpb.Struct {
if x != nil {
return x.Labels
}
return nil
}
func (x *Annotations) GetLocation() *Location {
if x != nil {
return x.Location
}
return nil
}
// SchemaAnnotation mirrors `ast.SchemaAnnotation`. Path/Schema are
// `ast.Ref` in Go (a list of terms); the wire form is the canonical
// dotted ref string (e.g. `data.foo.bar`). Modeling the term tree
// faithfully would pull most of the AST into this schema and isn't
// worth the cost for annotations.
type SchemaAnnotation struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path *string `protobuf:"bytes,1,opt,name=path" json:"path,omitempty"`
Schema *string `protobuf:"bytes,2,opt,name=schema" json:"schema,omitempty"`
// `*any` on the Go side — a parsed JSON Schema document or any
// JSON value. `Value` (not `Struct`) because the top level may be
// a scalar, list, or null, not just an object.
Definition *structpb.Value `protobuf:"bytes,3,opt,name=definition" json:"definition,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SchemaAnnotation) Reset() {
*x = SchemaAnnotation{}
mi := &file_v1_bundle_manifest_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SchemaAnnotation) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SchemaAnnotation) ProtoMessage() {}
func (x *SchemaAnnotation) ProtoReflect() protoreflect.Message {
mi := &file_v1_bundle_manifest_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SchemaAnnotation.ProtoReflect.Descriptor instead.
func (*SchemaAnnotation) Descriptor() ([]byte, []int) {
return file_v1_bundle_manifest_proto_rawDescGZIP(), []int{3}
}
func (x *SchemaAnnotation) GetPath() string {
if x != nil && x.Path != nil {
return *x.Path
}
return ""
}
func (x *SchemaAnnotation) GetSchema() string {
if x != nil && x.Schema != nil {
return *x.Schema
}
return ""
}
func (x *SchemaAnnotation) GetDefinition() *structpb.Value {
if x != nil {
return x.Definition
}
return nil
}
// CompileAnnotation mirrors `ast.CompileAnnotation`. Refs are the
// canonical dotted form; see SchemaAnnotation for the trade-off.
type CompileAnnotation struct {
state protoimpl.MessageState `protogen:"open.v1"`
Unknowns []string `protobuf:"bytes,1,rep,name=unknowns" json:"unknowns,omitempty"`
MaskRule *string `protobuf:"bytes,2,opt,name=mask_rule,json=maskRule" json:"mask_rule,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CompileAnnotation) Reset() {
*x = CompileAnnotation{}
mi := &file_v1_bundle_manifest_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CompileAnnotation) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CompileAnnotation) ProtoMessage() {}
func (x *CompileAnnotation) ProtoReflect() protoreflect.Message {
mi := &file_v1_bundle_manifest_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CompileAnnotation.ProtoReflect.Descriptor instead.
func (*CompileAnnotation) Descriptor() ([]byte, []int) {
return file_v1_bundle_manifest_proto_rawDescGZIP(), []int{4}
}
func (x *CompileAnnotation) GetUnknowns() []string {
if x != nil {
return x.Unknowns
}
return nil
}
func (x *CompileAnnotation) GetMaskRule() string {
if x != nil && x.MaskRule != nil {
return *x.MaskRule
}
return ""
}
// AuthorAnnotation mirrors `ast.AuthorAnnotation`.
type AuthorAnnotation struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name *string `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"`
Email *string `protobuf:"bytes,2,opt,name=email" json:"email,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AuthorAnnotation) Reset() {
*x = AuthorAnnotation{}
mi := &file_v1_bundle_manifest_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthorAnnotation) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuthorAnnotation) ProtoMessage() {}
func (x *AuthorAnnotation) ProtoReflect() protoreflect.Message {
mi := &file_v1_bundle_manifest_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AuthorAnnotation.ProtoReflect.Descriptor instead.
func (*AuthorAnnotation) Descriptor() ([]byte, []int) {
return file_v1_bundle_manifest_proto_rawDescGZIP(), []int{5}
}
func (x *AuthorAnnotation) GetName() string {
if x != nil && x.Name != nil {
return *x.Name
}
return ""
}
func (x *AuthorAnnotation) GetEmail() string {
if x != nil && x.Email != nil {
return *x.Email
}
return ""
}
// RelatedResourceAnnotation mirrors `ast.RelatedResourceAnnotation`.
// `Ref` is a `url.URL` in Go, serialized to its `String()` form.
type RelatedResourceAnnotation struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ref *string `protobuf:"bytes,1,opt,name=ref" json:"ref,omitempty"`
Description *string `protobuf:"bytes,2,opt,name=description" json:"description,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RelatedResourceAnnotation) Reset() {
*x = RelatedResourceAnnotation{}
mi := &file_v1_bundle_manifest_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RelatedResourceAnnotation) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RelatedResourceAnnotation) ProtoMessage() {}
func (x *RelatedResourceAnnotation) ProtoReflect() protoreflect.Message {
mi := &file_v1_bundle_manifest_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RelatedResourceAnnotation.ProtoReflect.Descriptor instead.
func (*RelatedResourceAnnotation) Descriptor() ([]byte, []int) {
return file_v1_bundle_manifest_proto_rawDescGZIP(), []int{6}
}
func (x *RelatedResourceAnnotation) GetRef() string {
if x != nil && x.Ref != nil {
return *x.Ref
}
return ""
}
func (x *RelatedResourceAnnotation) GetDescription() string {
if x != nil && x.Description != nil {
return *x.Description
}
return ""
}
// Location mirrors `ast.Location` (= `location.Location`). Only the
// File/Row/Col triple is wire-relevant; `Text`, `Offset`, and `Tabs`
// are tagged `json:"-"` and intentionally absent.
//
// Distinct from `ir.Location`, which is promoted onto the `Stmt`
// envelope in plan.proto. The two are independent Go types.
type Location struct {
state protoimpl.MessageState `protogen:"open.v1"`
File *string `protobuf:"bytes,1,opt,name=file" json:"file,omitempty"`
Row *int32 `protobuf:"varint,2,opt,name=row" json:"row,omitempty"`
Col *int32 `protobuf:"varint,3,opt,name=col" json:"col,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Location) Reset() {
*x = Location{}
mi := &file_v1_bundle_manifest_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Location) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Location) ProtoMessage() {}
func (x *Location) ProtoReflect() protoreflect.Message {
mi := &file_v1_bundle_manifest_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Location.ProtoReflect.Descriptor instead.
func (*Location) Descriptor() ([]byte, []int) {
return file_v1_bundle_manifest_proto_rawDescGZIP(), []int{7}
}
func (x *Location) GetFile() string {
if x != nil && x.File != nil {
return *x.File
}
return ""
}
func (x *Location) GetRow() int32 {
if x != nil && x.Row != nil {
return *x.Row
}
return 0
}
func (x *Location) GetCol() int32 {
if x != nil && x.Col != nil {
return *x.Col
}
return 0
}
var File_v1_bundle_manifest_proto protoreflect.FileDescriptor
const file_v1_bundle_manifest_proto_rawDesc = "" +
"\n" +
"\x18v1/bundle/manifest.proto\x12\ropa.bundle.v1\x1a\x1cgoogle/protobuf/struct.proto\"\x84\x03\n" +
"\bManifest\x12\x1a\n" +
"\brevision\x18\x01 \x01(\tR\brevision\x12\x14\n" +
"\x05roots\x18\x02 \x03(\tR\x05roots\x12/\n" +
"\x04wasm\x18\x03 \x03(\v2\x1b.opa.bundle.v1.WasmResolverR\x04wasm\x12!\n" +
"\frego_version\x18\x04 \x01(\x05R\vregoVersion\x12[\n" +
"\x12file_rego_versions\x18\x05 \x03(\v2-.opa.bundle.v1.Manifest.FileRegoVersionsEntryR\x10fileRegoVersions\x123\n" +
"\bmetadata\x18\x06 \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12\x1b\n" +
"\troots_set\x18\a \x01(\bR\brootsSet\x1aC\n" +
"\x15FileRegoVersionsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01\"\x84\x01\n" +
"\fWasmResolver\x12\x1e\n" +
"\n" +
"entrypoint\x18\x01 \x01(\tR\n" +
"entrypoint\x12\x16\n" +
"\x06module\x18\x02 \x01(\tR\x06module\x12<\n" +
"\vannotations\x18\x03 \x03(\v2\x1a.opa.bundle.v1.AnnotationsR\vannotations\"\xc1\x04\n" +
"\vAnnotations\x12\x14\n" +
"\x05scope\x18\x01 \x01(\tR\x05scope\x12\x14\n" +
"\x05title\x18\x02 \x01(\tR\x05title\x12\x1e\n" +
"\n" +
"entrypoint\x18\x03 \x01(\bR\n" +
"entrypoint\x12 \n" +
"\vdescription\x18\x04 \x01(\tR\vdescription\x12$\n" +
"\rorganizations\x18\x05 \x03(\tR\rorganizations\x12U\n" +
"\x11related_resources\x18\x06 \x03(\v2(.opa.bundle.v1.RelatedResourceAnnotationR\x10relatedResources\x129\n" +
"\aauthors\x18\a \x03(\v2\x1f.opa.bundle.v1.AuthorAnnotationR\aauthors\x129\n" +
"\aschemas\x18\b \x03(\v2\x1f.opa.bundle.v1.SchemaAnnotationR\aschemas\x12:\n" +
"\acompile\x18\t \x01(\v2 .opa.bundle.v1.CompileAnnotationR\acompile\x12/\n" +
"\x06custom\x18\n" +
" \x01(\v2\x17.google.protobuf.StructR\x06custom\x12/\n" +
"\x06labels\x18\v \x01(\v2\x17.google.protobuf.StructR\x06labels\x123\n" +
"\blocation\x18\f \x01(\v2\x17.opa.bundle.v1.LocationR\blocation\"v\n" +
"\x10SchemaAnnotation\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n" +
"\x06schema\x18\x02 \x01(\tR\x06schema\x126\n" +
"\n" +
"definition\x18\x03 \x01(\v2\x16.google.protobuf.ValueR\n" +
"definition\"L\n" +
"\x11CompileAnnotation\x12\x1a\n" +
"\bunknowns\x18\x01 \x03(\tR\bunknowns\x12\x1b\n" +
"\tmask_rule\x18\x02 \x01(\tR\bmaskRule\"<\n" +
"\x10AuthorAnnotation\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
"\x05email\x18\x02 \x01(\tR\x05email\"O\n" +
"\x19RelatedResourceAnnotation\x12\x10\n" +
"\x03ref\x18\x01 \x01(\tR\x03ref\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\"B\n" +
"\bLocation\x12\x12\n" +
"\x04file\x18\x01 \x01(\tR\x04file\x12\x10\n" +
"\x03row\x18\x02 \x01(\x05R\x03row\x12\x10\n" +
"\x03col\x18\x03 \x01(\x05R\x03colB3P\x01Z/github.com/open-policy-agent/opa/v1/bundle/v1pbb\beditionsp\xe8\a"
var (
file_v1_bundle_manifest_proto_rawDescOnce sync.Once
file_v1_bundle_manifest_proto_rawDescData []byte
)
func file_v1_bundle_manifest_proto_rawDescGZIP() []byte {
file_v1_bundle_manifest_proto_rawDescOnce.Do(func() {
file_v1_bundle_manifest_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_v1_bundle_manifest_proto_rawDesc), len(file_v1_bundle_manifest_proto_rawDesc)))
})
return file_v1_bundle_manifest_proto_rawDescData
}
var file_v1_bundle_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_v1_bundle_manifest_proto_goTypes = []any{
(*Manifest)(nil), // 0: opa.bundle.v1.Manifest
(*WasmResolver)(nil), // 1: opa.bundle.v1.WasmResolver
(*Annotations)(nil), // 2: opa.bundle.v1.Annotations
(*SchemaAnnotation)(nil), // 3: opa.bundle.v1.SchemaAnnotation
(*CompileAnnotation)(nil), // 4: opa.bundle.v1.CompileAnnotation
(*AuthorAnnotation)(nil), // 5: opa.bundle.v1.AuthorAnnotation
(*RelatedResourceAnnotation)(nil), // 6: opa.bundle.v1.RelatedResourceAnnotation
(*Location)(nil), // 7: opa.bundle.v1.Location
nil, // 8: opa.bundle.v1.Manifest.FileRegoVersionsEntry
(*structpb.Struct)(nil), // 9: google.protobuf.Struct
(*structpb.Value)(nil), // 10: google.protobuf.Value
}
var file_v1_bundle_manifest_proto_depIdxs = []int32{
1, // 0: opa.bundle.v1.Manifest.wasm:type_name -> opa.bundle.v1.WasmResolver
8, // 1: opa.bundle.v1.Manifest.file_rego_versions:type_name -> opa.bundle.v1.Manifest.FileRegoVersionsEntry
9, // 2: opa.bundle.v1.Manifest.metadata:type_name -> google.protobuf.Struct
2, // 3: opa.bundle.v1.WasmResolver.annotations:type_name -> opa.bundle.v1.Annotations
6, // 4: opa.bundle.v1.Annotations.related_resources:type_name -> opa.bundle.v1.RelatedResourceAnnotation
5, // 5: opa.bundle.v1.Annotations.authors:type_name -> opa.bundle.v1.AuthorAnnotation
3, // 6: opa.bundle.v1.Annotations.schemas:type_name -> opa.bundle.v1.SchemaAnnotation
4, // 7: opa.bundle.v1.Annotations.compile:type_name -> opa.bundle.v1.CompileAnnotation
9, // 8: opa.bundle.v1.Annotations.custom:type_name -> google.protobuf.Struct
9, // 9: opa.bundle.v1.Annotations.labels:type_name -> google.protobuf.Struct
7, // 10: opa.bundle.v1.Annotations.location:type_name -> opa.bundle.v1.Location
10, // 11: opa.bundle.v1.SchemaAnnotation.definition:type_name -> google.protobuf.Value
12, // [12:12] is the sub-list for method output_type
12, // [12:12] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
}
func init() { file_v1_bundle_manifest_proto_init() }
func file_v1_bundle_manifest_proto_init() {
if File_v1_bundle_manifest_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_bundle_manifest_proto_rawDesc), len(file_v1_bundle_manifest_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_v1_bundle_manifest_proto_goTypes,
DependencyIndexes: file_v1_bundle_manifest_proto_depIdxs,
MessageInfos: file_v1_bundle_manifest_proto_msgTypes,
}.Build()
File_v1_bundle_manifest_proto = out.File
file_v1_bundle_manifest_proto_goTypes = nil
file_v1_bundle_manifest_proto_depIdxs = nil
}
+48 -3
View File
@@ -19,6 +19,8 @@ import (
"sort"
"strings"
"google.golang.org/protobuf/proto"
"github.com/open-policy-agent/opa/internal/compiler/wasm"
"github.com/open-policy-agent/opa/internal/debug"
"github.com/open-policy-agent/opa/internal/planner"
@@ -50,6 +52,15 @@ const (
TargetPlan = "plan"
)
// Plan output formats. Only meaningful when Target == TargetPlan.
const (
PlanFormatJSON = "json"
PlanFormatProto = "proto"
)
// PlanFormats contains the list of plan output formats supported by the compiler.
var PlanFormats = []string{PlanFormatJSON, PlanFormatProto}
// Targets contains the list of targets supported by the compiler.
var Targets = []string{
TargetRego,
@@ -73,6 +84,7 @@ type Compiler struct {
useRegoAnnotationEntrypoints bool // allow compiler to late-bind entrypoints from annotated rules in policies.
optimizationLevel int // how aggressive should optimization be
target string // target type (wasm, rego, etc.)
planFormat string // plan output format (json or proto)
output *io.Writer // output stream to write bundle to
entrypointrefs []*ast.Term // validated entrypoints computed from default decision or manually supplied entrypoints
compiler *ast.Compiler // rego ast compiler used for semantic checks and rewriting
@@ -97,6 +109,7 @@ func New() *Compiler {
asBundle: false,
optimizationLevel: 0,
target: TargetRego,
planFormat: PlanFormatJSON,
debug: debug.Discard(),
regoVersion: ast.DefaultRegoVersion,
}
@@ -158,6 +171,12 @@ func (c *Compiler) WithTarget(t string) *Compiler {
return c
}
// WithPlanFormat sets the wire-format (json or proto) for plan-target builds.
func (c *Compiler) WithPlanFormat(f string) *Compiler {
c.planFormat = f
return c
}
// WithOutput sets the output stream to write the bundle to.
func (c *Compiler) WithOutput(w io.Writer) *Compiler {
c.output = &w
@@ -315,6 +334,13 @@ func (c *Compiler) Build(ctx context.Context) error {
return errors.New("rego-version not set")
}
if !slices.Contains(PlanFormats, c.planFormat) {
return fmt.Errorf("unsupported plan format %q (want one of %v)", c.planFormat, PlanFormats)
}
if c.planFormat != PlanFormatJSON && c.target != TargetPlan {
return fmt.Errorf("plan format %q is only valid with target %q", c.planFormat, TargetPlan)
}
if err := c.init(); err != nil {
return err
}
@@ -374,14 +400,31 @@ func (c *Compiler) Build(ctx context.Context) error {
return err
}
bs, err := json.Marshal(c.policy)
var (
bs []byte
planPath string
err error
)
switch c.planFormat {
case PlanFormatProto:
// Deterministic for byte-identical plan.pb across builds.
pbPolicy, perr := ir.PolicyToProto(c.policy)
if perr != nil {
return perr
}
bs, err = proto.MarshalOptions{Deterministic: true}.Marshal(pbPolicy)
planPath = bundle.PlanProtoFile
case PlanFormatJSON:
bs, err = json.Marshal(c.policy)
planPath = bundle.PlanFile
}
if err != nil {
return err
}
c.bundle.PlanModules = append(c.bundle.PlanModules, bundle.PlanModuleFile{
Path: bundle.PlanFile,
URL: bundle.PlanFile,
Path: planPath,
URL: planPath,
Raw: bs,
})
case TargetRego:
@@ -396,6 +439,8 @@ func (c *Compiler) Build(ctx context.Context) error {
c.bundle.Manifest.Metadata = *c.metadata
}
c.bundle.SetManifestProto(c.target == TargetPlan && c.planFormat == PlanFormatProto)
if err := c.bundle.FormatModulesWithOptions(bundle.BundleFormatOptions{
RegoVersion: c.regoVersion,
Capabilities: c.capabilities,
+43
View File
@@ -104,6 +104,16 @@ func TestCompilerInitErrors(t *testing.T) {
c: New().WithTarget("plan"),
want: errors.New("plan compilation requires at least one entrypoint"),
},
{
note: "unsupported plan format",
c: New().WithPlanFormat("xml"),
want: fmt.Errorf("unsupported plan format %q (want one of %v)", "xml", PlanFormats),
},
{
note: "plan format requires plan target",
c: New().WithPlanFormat(PlanFormatProto),
want: fmt.Errorf("plan format %q is only valid with target %q", PlanFormatProto, TargetPlan),
},
}
for _, tc := range tests {
@@ -1012,6 +1022,39 @@ func TestCompilerLoadFilesystemWithEnablePrintStatementsTrue(t *testing.T) {
}
}
func TestCompilerProtoPlanIsDeterministic(t *testing.T) {
files := map[string]string{
"test.rego": `
package test
import rego.v1
allow if { input.user == "alice" }
deny if { input.user == "mallory" }
`,
}
// Single root keeps Static.Files (source paths) stable across builds.
test.WithTempFS(files, func(root string) {
build := func() []byte {
compiler := New().
WithPaths(root).
WithTarget(TargetPlan).
WithPlanFormat(PlanFormatProto).
WithEntrypoints("test/allow", "test/deny")
if err := compiler.Build(t.Context()); err != nil {
t.Fatal(err)
}
return append([]byte(nil), compiler.Bundle().PlanModules[0].Raw...)
}
a := build()
b := build()
if !bytes.Equal(a, b) {
t.Fatalf("plan.pb bytes differ across builds (len %d vs %d)", len(a), len(b))
}
})
}
func TestCompilerLoadHonorsFilter(t *testing.T) {
files := map[string]string{
"test.rego": `
+340
View File
@@ -0,0 +1,340 @@
// Copyright 2026 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package ir
import (
"fmt"
"math"
"google.golang.org/protobuf/proto"
pb "github.com/open-policy-agent/opa/v1/ir/v1pb"
)
// PolicyToProto converts an IR Policy to its protobuf wire-form,
// defined in v1/ir/plan.proto. Returns an error if the policy contains
// a Stmt or Val kind not yet covered by the encoder switch.
func PolicyToProto(p *Policy) (out *pb.Policy, err error) {
if p == nil {
return nil, nil
}
defer func() {
if r := recover(); r != nil {
out = nil
err = fmt.Errorf("ir: PolicyToProto: %v", r)
}
}()
return &pb.Policy{
Static: staticToProto(p.Static),
Plans: plansToProto(p.Plans),
Funcs: funcsToProto(p.Funcs),
}, nil
}
func staticToProto(s *Static) *pb.Static {
if s == nil {
return nil
}
out := &pb.Static{
Strings: make([]*pb.StringConst, len(s.Strings)),
BuiltinFuncs: make([]*pb.BuiltinFunc, len(s.BuiltinFuncs)),
Files: make([]*pb.StringConst, len(s.Files)),
}
for i, sc := range s.Strings {
out.Strings[i] = stringConstToProto(sc)
}
for i, bf := range s.BuiltinFuncs {
out.BuiltinFuncs[i] = builtinFuncToProto(bf)
}
for i, f := range s.Files {
out.Files[i] = stringConstToProto(f)
}
return out
}
func stringConstToProto(s *StringConst) *pb.StringConst {
if s == nil {
return nil
}
return &pb.StringConst{Value: proto.String(s.Value)}
}
func builtinFuncToProto(b *BuiltinFunc) *pb.BuiltinFunc {
if b == nil {
return nil
}
return &pb.BuiltinFunc{Name: proto.String(b.Name)}
}
func plansToProto(p *Plans) *pb.Plans {
if p == nil {
return nil
}
out := &pb.Plans{Plans: make([]*pb.Plan, len(p.Plans))}
for i, pl := range p.Plans {
out.Plans[i] = planToProto(pl)
}
return out
}
func planToProto(p *Plan) *pb.Plan {
if p == nil {
return nil
}
out := &pb.Plan{Name: proto.String(p.Name), Blocks: make([]*pb.Block, len(p.Blocks))}
for i, b := range p.Blocks {
out.Blocks[i] = blockToProto(b)
}
return out
}
func funcsToProto(f *Funcs) *pb.Funcs {
if f == nil {
return nil
}
out := &pb.Funcs{Funcs: make([]*pb.Func, len(f.Funcs))}
for i, fn := range f.Funcs {
out.Funcs[i] = funcToProto(fn)
}
return out
}
func funcToProto(f *Func) *pb.Func {
if f == nil {
return nil
}
out := &pb.Func{
Name: proto.String(f.Name),
Params: localsToInt32s(f.Params),
Result: proto.Int32(toInt32(f.Return)),
Blocks: make([]*pb.Block, len(f.Blocks)),
Path: f.Path,
}
for i, b := range f.Blocks {
out.Blocks[i] = blockToProto(b)
}
return out
}
func blockToProto(b *Block) *pb.Block {
if b == nil {
return nil
}
out := &pb.Block{Stmts: make([]*pb.Stmt, len(b.Stmts))}
for i, s := range b.Stmts {
out.Stmts[i] = stmtToProto(s)
}
return out
}
func operandToProto(o Operand) *pb.Operand {
return &pb.Operand{Value: valToProto(o.Value)}
}
func operandsToProto(os []Operand) []*pb.Operand {
out := make([]*pb.Operand, len(os))
for i, o := range os {
out[i] = operandToProto(o)
}
return out
}
func valToProto(v Val) *pb.Val {
if v == nil {
return nil
}
switch x := v.(type) {
case Local:
return &pb.Val{Kind: &pb.Val_Local{Local: toInt32(x)}}
case StringIndex:
return &pb.Val{Kind: &pb.Val_StringIndex{StringIndex: toInt32(x)}}
case Bool:
return &pb.Val{Kind: &pb.Val_Bool{Bool: bool(x)}}
default:
panic(fmt.Sprintf("unsupported Val type %T", v))
}
}
// toInt32 narrows an int-based value to int32, panicking if it would
// overflow. PolicyToProto recovers from the panic and returns it as an
// error, so callers don't need to check the bound themselves.
func toInt32[T ~int](v T) int32 {
if int64(v) > math.MaxInt32 || int64(v) < math.MinInt32 {
panic(fmt.Sprintf("value %d overflows int32", int64(v)))
}
return int32(v)
}
func localsToInt32s(ls []Local) []int32 {
out := make([]int32, len(ls))
for i, l := range ls {
out[i] = toInt32(l)
}
return out
}
func intsToInt32s(is []int) []int32 {
out := make([]int32, len(is))
for i, v := range is {
out[i] = toInt32(v)
}
return out
}
func stmtToProto(s Stmt) *pb.Stmt {
if s == nil {
return nil
}
loc := s.GetLocation()
out := &pb.Stmt{
File: proto.Int32(toInt32(loc.File)),
Col: proto.Int32(toInt32(loc.Col)),
Row: proto.Int32(toInt32(loc.Row)),
}
switch x := s.(type) {
case *ArrayAppendStmt:
out.Kind = &pb.Stmt_ArrayAppendStmt{ArrayAppendStmt: &pb.ArrayAppendStmt{
Value: operandToProto(x.Value),
Array: proto.Int32(toInt32(x.Array)),
}}
case *AssignIntStmt:
out.Kind = &pb.Stmt_AssignIntStmt{AssignIntStmt: &pb.AssignIntStmt{
Value: proto.Int64(x.Value),
Target: proto.Int32(toInt32(x.Target)),
}}
case *AssignVarOnceStmt:
out.Kind = &pb.Stmt_AssignVarOnceStmt{AssignVarOnceStmt: &pb.AssignVarOnceStmt{
Source: operandToProto(x.Source),
Target: proto.Int32(toInt32(x.Target)),
}}
case *AssignVarStmt:
out.Kind = &pb.Stmt_AssignVarStmt{AssignVarStmt: &pb.AssignVarStmt{
Source: operandToProto(x.Source),
Target: proto.Int32(toInt32(x.Target)),
}}
case *BlockStmt:
body := &pb.BlockStmt{Blocks: make([]*pb.Block, len(x.Blocks))}
for i, b := range x.Blocks {
body.Blocks[i] = blockToProto(b)
}
out.Kind = &pb.Stmt_BlockStmt{BlockStmt: body}
case *BreakStmt:
out.Kind = &pb.Stmt_BreakStmt{BreakStmt: &pb.BreakStmt{Index: proto.Uint32(x.Index)}}
case *CallDynamicStmt:
out.Kind = &pb.Stmt_CallDynamicStmt{CallDynamicStmt: &pb.CallDynamicStmt{
Args: localsToInt32s(x.Args),
Result: proto.Int32(toInt32(x.Result)),
Path: operandsToProto(x.Path),
}}
case *CallStmt:
out.Kind = &pb.Stmt_CallStmt{CallStmt: &pb.CallStmt{
Function: proto.String(x.Func),
Args: operandsToProto(x.Args),
Result: proto.Int32(toInt32(x.Result)),
}}
case *DotStmt:
out.Kind = &pb.Stmt_DotStmt{DotStmt: &pb.DotStmt{
Source: operandToProto(x.Source),
Key: operandToProto(x.Key),
Target: proto.Int32(toInt32(x.Target)),
}}
case *EqualStmt:
out.Kind = &pb.Stmt_EqualStmt{EqualStmt: &pb.EqualStmt{
A: operandToProto(x.A),
B: operandToProto(x.B),
}}
case *IsArrayStmt:
out.Kind = &pb.Stmt_IsArrayStmt{IsArrayStmt: &pb.IsArrayStmt{Source: operandToProto(x.Source)}}
case *IsDefinedStmt:
out.Kind = &pb.Stmt_IsDefinedStmt{IsDefinedStmt: &pb.IsDefinedStmt{Source: proto.Int32(toInt32(x.Source))}}
case *IsObjectStmt:
out.Kind = &pb.Stmt_IsObjectStmt{IsObjectStmt: &pb.IsObjectStmt{Source: operandToProto(x.Source)}}
case *IsSetStmt:
out.Kind = &pb.Stmt_IsSetStmt{IsSetStmt: &pb.IsSetStmt{Source: operandToProto(x.Source)}}
case *IsUndefinedStmt:
out.Kind = &pb.Stmt_IsUndefinedStmt{IsUndefinedStmt: &pb.IsUndefinedStmt{Source: proto.Int32(toInt32(x.Source))}}
case *LenStmt:
out.Kind = &pb.Stmt_LenStmt{LenStmt: &pb.LenStmt{
Source: operandToProto(x.Source),
Target: proto.Int32(toInt32(x.Target)),
}}
case *MakeArrayStmt:
out.Kind = &pb.Stmt_MakeArrayStmt{MakeArrayStmt: &pb.MakeArrayStmt{
Capacity: proto.Int32(x.Capacity),
Target: proto.Int32(toInt32(x.Target)),
}}
case *MakeNullStmt:
out.Kind = &pb.Stmt_MakeNullStmt{MakeNullStmt: &pb.MakeNullStmt{Target: proto.Int32(toInt32(x.Target))}}
case *MakeNumberIntStmt:
out.Kind = &pb.Stmt_MakeNumberIntStmt{MakeNumberIntStmt: &pb.MakeNumberIntStmt{
Value: proto.Int64(x.Value),
Target: proto.Int32(toInt32(x.Target)),
}}
case *MakeNumberRefStmt:
out.Kind = &pb.Stmt_MakeNumberRefStmt{MakeNumberRefStmt: &pb.MakeNumberRefStmt{
Index: proto.Int32(toInt32(x.Index)),
Target: proto.Int32(toInt32(x.Target)),
}}
case *MakeObjectStmt:
out.Kind = &pb.Stmt_MakeObjectStmt{MakeObjectStmt: &pb.MakeObjectStmt{Target: proto.Int32(toInt32(x.Target))}}
case *MakeSetStmt:
out.Kind = &pb.Stmt_MakeSetStmt{MakeSetStmt: &pb.MakeSetStmt{Target: proto.Int32(toInt32(x.Target))}}
case *NopStmt:
out.Kind = &pb.Stmt_NopStmt{NopStmt: &pb.NopStmt{}}
case *NotEqualStmt:
out.Kind = &pb.Stmt_NotEqualStmt{NotEqualStmt: &pb.NotEqualStmt{
A: operandToProto(x.A),
B: operandToProto(x.B),
}}
case *NotStmt:
out.Kind = &pb.Stmt_NotStmt{NotStmt: &pb.NotStmt{Block: blockToProto(x.Block)}}
case *ObjectInsertOnceStmt:
out.Kind = &pb.Stmt_ObjectInsertOnceStmt{ObjectInsertOnceStmt: &pb.ObjectInsertOnceStmt{
Key: operandToProto(x.Key),
Value: operandToProto(x.Value),
Object: proto.Int32(toInt32(x.Object)),
}}
case *ObjectInsertStmt:
out.Kind = &pb.Stmt_ObjectInsertStmt{ObjectInsertStmt: &pb.ObjectInsertStmt{
Key: operandToProto(x.Key),
Value: operandToProto(x.Value),
Object: proto.Int32(toInt32(x.Object)),
}}
case *ObjectMergeStmt:
out.Kind = &pb.Stmt_ObjectMergeStmt{ObjectMergeStmt: &pb.ObjectMergeStmt{
A: proto.Int32(toInt32(x.A)),
B: proto.Int32(toInt32(x.B)),
Target: proto.Int32(toInt32(x.Target)),
}}
case *ResetLocalStmt:
out.Kind = &pb.Stmt_ResetLocalStmt{ResetLocalStmt: &pb.ResetLocalStmt{Target: proto.Int32(toInt32(x.Target))}}
case *ResultSetAddStmt:
out.Kind = &pb.Stmt_ResultSetAddStmt{ResultSetAddStmt: &pb.ResultSetAddStmt{Value: proto.Int32(toInt32(x.Value))}}
case *ReturnLocalStmt:
out.Kind = &pb.Stmt_ReturnLocalStmt{ReturnLocalStmt: &pb.ReturnLocalStmt{Source: proto.Int32(toInt32(x.Source))}}
case *ScanStmt:
out.Kind = &pb.Stmt_ScanStmt{ScanStmt: &pb.ScanStmt{
Source: proto.Int32(toInt32(x.Source)),
Key: proto.Int32(toInt32(x.Key)),
Value: proto.Int32(toInt32(x.Value)),
Block: blockToProto(x.Block),
}}
case *SetAddStmt:
out.Kind = &pb.Stmt_SetAddStmt{SetAddStmt: &pb.SetAddStmt{
Value: operandToProto(x.Value),
Set: proto.Int32(toInt32(x.Set)),
}}
case *WithStmt:
out.Kind = &pb.Stmt_WithStmt{WithStmt: &pb.WithStmt{
Local: proto.Int32(toInt32(x.Local)),
Path: intsToInt32s(x.Path),
Value: operandToProto(x.Value),
Block: blockToProto(x.Block),
}}
default:
panic(fmt.Sprintf("unsupported Stmt type %T", s))
}
return out
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright 2026 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package ir_test
import (
"math"
"strings"
"testing"
"google.golang.org/protobuf/proto"
"github.com/open-policy-agent/opa/v1/ir"
pb "github.com/open-policy-agent/opa/v1/ir/v1pb"
)
func TestPolicyToProtoRoundTrip(t *testing.T) {
loc := ir.Location{File: 1, Row: 2, Col: 3}
withLoc := func(s ir.Stmt) ir.Stmt {
s.SetLocation(loc.File, loc.Row, loc.Col, "", "")
return s
}
p := &ir.Policy{
Static: &ir.Static{
Strings: []*ir.StringConst{{Value: "hello"}, {Value: "world"}},
BuiltinFuncs: []*ir.BuiltinFunc{{Name: "eq"}},
Files: []*ir.StringConst{{Value: "a.rego"}},
},
Plans: &ir.Plans{Plans: []*ir.Plan{{
Name: "p",
Blocks: []*ir.Block{{Stmts: []ir.Stmt{
withLoc(&ir.ReturnLocalStmt{Source: ir.Local(1)}),
withLoc(&ir.CallStmt{Func: "f", Args: []ir.Operand{{Value: ir.Local(2)}}, Result: ir.Local(3)}),
withLoc(&ir.CallDynamicStmt{Args: []ir.Local{1, 2}, Result: 3, Path: []ir.Operand{{Value: ir.StringIndex(0)}}}),
withLoc(&ir.BlockStmt{Blocks: []*ir.Block{{Stmts: []ir.Stmt{withLoc(&ir.NopStmt{})}}}}),
withLoc(&ir.BreakStmt{Index: 1}),
withLoc(&ir.DotStmt{Source: ir.Operand{Value: ir.Local(1)}, Key: ir.Operand{Value: ir.StringIndex(0)}, Target: ir.Local(2)}),
withLoc(&ir.LenStmt{Source: ir.Operand{Value: ir.Local(1)}, Target: ir.Local(2)}),
withLoc(&ir.ScanStmt{Source: 1, Key: 2, Value: 3, Block: &ir.Block{Stmts: []ir.Stmt{withLoc(&ir.NopStmt{})}}}),
withLoc(&ir.NotStmt{Block: &ir.Block{Stmts: []ir.Stmt{withLoc(&ir.NopStmt{})}}}),
withLoc(&ir.AssignIntStmt{Value: 42, Target: ir.Local(1)}),
withLoc(&ir.AssignVarStmt{Source: ir.Operand{Value: ir.Local(1)}, Target: ir.Local(2)}),
withLoc(&ir.AssignVarOnceStmt{Source: ir.Operand{Value: ir.Local(1)}, Target: ir.Local(2)}),
withLoc(&ir.ResetLocalStmt{Target: ir.Local(1)}),
withLoc(&ir.MakeNullStmt{Target: ir.Local(1)}),
withLoc(&ir.MakeNumberIntStmt{Value: 7, Target: ir.Local(1)}),
withLoc(&ir.MakeNumberRefStmt{Index: 0, Target: ir.Local(1)}),
withLoc(&ir.MakeArrayStmt{Capacity: 4, Target: ir.Local(1)}),
withLoc(&ir.MakeObjectStmt{Target: ir.Local(1)}),
withLoc(&ir.MakeSetStmt{Target: ir.Local(1)}),
withLoc(&ir.EqualStmt{A: ir.Operand{Value: ir.Local(1)}, B: ir.Operand{Value: ir.Bool(true)}}),
withLoc(&ir.NotEqualStmt{A: ir.Operand{Value: ir.Local(1)}, B: ir.Operand{Value: ir.Local(2)}}),
withLoc(&ir.IsArrayStmt{Source: ir.Operand{Value: ir.Local(1)}}),
withLoc(&ir.IsObjectStmt{Source: ir.Operand{Value: ir.Local(1)}}),
withLoc(&ir.IsSetStmt{Source: ir.Operand{Value: ir.Local(1)}}),
withLoc(&ir.IsDefinedStmt{Source: ir.Local(1)}),
withLoc(&ir.IsUndefinedStmt{Source: ir.Local(1)}),
withLoc(&ir.ArrayAppendStmt{Value: ir.Operand{Value: ir.Local(1)}, Array: ir.Local(2)}),
withLoc(&ir.ObjectInsertStmt{Key: ir.Operand{Value: ir.StringIndex(0)}, Value: ir.Operand{Value: ir.Local(1)}, Object: ir.Local(2)}),
withLoc(&ir.ObjectInsertOnceStmt{Key: ir.Operand{Value: ir.StringIndex(0)}, Value: ir.Operand{Value: ir.Local(1)}, Object: ir.Local(2)}),
withLoc(&ir.ObjectMergeStmt{A: 1, B: 2, Target: 3}),
withLoc(&ir.SetAddStmt{Value: ir.Operand{Value: ir.Local(1)}, Set: ir.Local(2)}),
withLoc(&ir.WithStmt{Local: 1, Path: []int{0, 1}, Value: ir.Operand{Value: ir.Local(2)}, Block: &ir.Block{Stmts: []ir.Stmt{withLoc(&ir.NopStmt{})}}}),
withLoc(&ir.ResultSetAddStmt{Value: ir.Local(1)}),
}}},
}}},
Funcs: &ir.Funcs{Funcs: []*ir.Func{{
Name: "g",
Params: []ir.Local{1, 2},
Return: ir.Local(3),
Path: []string{"a", "b"},
Blocks: []*ir.Block{{Stmts: []ir.Stmt{withLoc(&ir.NopStmt{})}}},
}}},
}
pbPolicy, err := ir.PolicyToProto(p)
if err != nil {
t.Fatalf("PolicyToProto: %v", err)
}
bs, err := proto.Marshal(pbPolicy)
if err != nil {
t.Fatalf("marshal: %v", err)
}
decoded := &pb.Policy{}
if err := proto.Unmarshal(bs, decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
bs2, err := proto.Marshal(decoded)
if err != nil {
t.Fatalf("re-marshal: %v", err)
}
if !proto.Equal(pbPolicy, decoded) {
t.Fatal("decoded proto differs from original")
}
if len(bs) != len(bs2) {
t.Fatalf("re-encoded byte length differs: %d vs %d", len(bs), len(bs2))
}
}
func TestPolicyToProtoNil(t *testing.T) {
got, err := ir.PolicyToProto(nil)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatalf("expected nil for nil input, got %v", got)
}
}
func TestPolicyToProtoEmitsLocationTripleAlways(t *testing.T) {
p := &ir.Policy{
Plans: &ir.Plans{Plans: []*ir.Plan{{
Name: "p",
Blocks: []*ir.Block{{Stmts: []ir.Stmt{
&ir.NopStmt{}, // no SetLocation call
}}},
}}},
}
pbPolicy, err := ir.PolicyToProto(p)
if err != nil {
t.Fatal(err)
}
stmt := pbPolicy.Plans.Plans[0].Blocks[0].Stmts[0]
if stmt.File == nil || *stmt.File != 0 {
t.Errorf("File: want 0 (always present), got %v", stmt.File)
}
if stmt.Row == nil || *stmt.Row != 0 {
t.Errorf("Row: want 0 (always present), got %v", stmt.Row)
}
if stmt.Col == nil || *stmt.Col != 0 {
t.Errorf("Col: want 0 (always present), got %v", stmt.Col)
}
}
// unsupportedStmt is a Stmt kind the encoder switch doesn't handle.
type unsupportedStmt struct{ ir.Location }
func TestPolicyToProtoUnknownStmtReturnsError(t *testing.T) {
p := &ir.Policy{
Plans: &ir.Plans{Plans: []*ir.Plan{{
Name: "p",
Blocks: []*ir.Block{{Stmts: []ir.Stmt{
&unsupportedStmt{},
}}},
}}},
}
got, err := ir.PolicyToProto(p)
if err == nil {
t.Fatal("expected error for unknown Stmt type, got nil")
}
if got != nil {
t.Fatalf("expected nil result on error, got %v", got)
}
if !strings.Contains(err.Error(), "unsupportedStmt") {
t.Fatalf("error should name the offending type: %v", err)
}
}
func TestPolicyToProtoInt32OverflowReturnsError(t *testing.T) {
p := &ir.Policy{
Plans: &ir.Plans{Plans: []*ir.Plan{{
Name: "p",
Blocks: []*ir.Block{{Stmts: []ir.Stmt{
&ir.ReturnLocalStmt{Source: ir.Local(math.MaxInt32 + 1)},
}}},
}}},
}
got, err := ir.PolicyToProto(p)
if err == nil {
t.Fatal("expected error for int32 overflow, got nil")
}
if got != nil {
t.Fatalf("expected nil result on error, got %v", got)
}
if !strings.Contains(err.Error(), "overflows int32") {
t.Fatalf("error should mention the overflow: %v", err)
}
}
File diff suppressed because it is too large Load Diff