Add support for Go 1.27 & jsonv2 (#8947)

Makes OPA build and pass its tests on Go 1.27, while keeping Go 1.25 and
1.26 working. JSON output is unchanged on every supported version.

Go 1.27 json package honours `encoding.TextAppender`. Many v1 ast types
implement AppendText to build their Rego string cheaply, so on 1.27 they
would have marshalled as Rego text. Files built only with 1.27 now
implement MarshalJSONTo.

Library users should keep using `json.Marshal` etc. The MarshalJSONTo
methods are implementation details, are absent from 1.25 and 1.26
builds, and may change.

---------

Signed-off-by: Anders Eknert <anders.eknert@apple.com>
Signed-off-by: Charlie Egan <charlie_egan@apple.com>
Co-authored-by: Charlie Egan <charlie_egan@apple.com>
This commit is contained in:
Anders Eknert
2026-07-30 18:41:28 +02:00
committed by GitHub
parent 27fe5ceac8
commit db035b09fc
64 changed files with 26296 additions and 699 deletions
+42
View File
@@ -502,6 +502,47 @@ jobs:
env:
DOCKER_RUNNING: 0
# TEMPORARY JOB - safe to delete once Go 1.27 is released and OPA is updated to it
go-1-27-compat:
name: Go 1.27 compat build/test (${{ matrix.version }})
needs: [generate, check-changes]
if: ${{ needs.check-changes.outputs.go == 'true' }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- version: "1.26.5"
sha256: 5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053
- version: "1.27rc2"
sha256: e2dfdfc2b2d4092bf23d5ffb0a11221c2f3eed2d8acfc51344066b9c83a368db
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download generated artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: generated
- name: Install Go ${{ matrix.version }}
env:
VERSION: ${{ matrix.version }}
SHA256: ${{ matrix.sha256 }}
run: |
set -euo pipefail
tarball="go${VERSION}.linux-amd64.tar.gz"
curl -fsSL --retry 3 -o "${RUNNER_TEMP}/${tarball}" "https://go.dev/dl/${tarball}"
echo "${SHA256} ${RUNNER_TEMP}/${tarball}" | sha256sum --check --strict -
mkdir -p "${RUNNER_TEMP}/toolchain"
tar -C "${RUNNER_TEMP}/toolchain" -xzf "${RUNNER_TEMP}/${tarball}"
echo "${RUNNER_TEMP}/toolchain/go/bin" >> "${GITHUB_PATH}"
- name: Report Go version
run: go version
- run: make go-test
env:
DOCKER_RUNNING: 0
GOTOOLCHAIN: local
# Run PR metadata against Rego policies
rego-check-pr:
name: Rego PR checks
@@ -713,6 +754,7 @@ jobs:
smoke-test-docker-images,
smoke-test-binaries,
go-version-build,
go-1-27-compat,
rego-check-pr,
docs-build,
docs-fmt-check,
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
// Copyright 2020 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.
File diff suppressed because it is too large Load Diff
+67
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
package cmd
import (
@@ -17,6 +19,7 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/internal/file/archive"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/loader"
@@ -3415,3 +3418,67 @@ Warning: .manifest file found in %q but -b flag not specified. Manifest will be
})
}
}
func TestBuildPlanJSONOutputBytes(t *testing.T) {
files := map[string]string{
"test.rego": `
package test
p = 1
`,
}
test.WithTempFS(files, func(root string) {
params := newBuildParams()
if err := params.target.Set("plan"); 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 planBytes []byte
var found bool
for {
h, err := tr.Next()
if err == io.EOF {
break
} else if err != nil {
t.Fatal(err)
}
if h.Name == "/plan.json" {
found = true
if planBytes, err = io.ReadAll(tr); err != nil {
t.Fatal(err)
}
}
}
if !found {
t.Fatal("plan.json not found")
}
got := strings.ReplaceAll(string(planBytes), root, "TEMPDIR")
expected := `{"static":{"strings":[{"value":"result"},{"value":"p"},{"value":"1"},{"value":"test"}],"files":[{"value":"TEMPDIR/test.rego"}]},"plans":{"plans":[{"name":"test","blocks":[{"stmts":[{"type":"MakeObjectStmt","stmt":{"target":2,"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.test.p","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":3,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":1},"value":{"type":"local","value":3},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":3},"target":5,"file":0,"col":0,"row":0}},{"type":"ObjectMergeStmt","stmt":{"a":5,"b":2,"target":4,"file":0,"col":0,"row":0}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":4,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":4},"target":6,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":7,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":6},"object":7,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":7,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.test.p","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":4,"row":3}},{"type":"MakeNumberRefStmt","stmt":{"file":0,"col":4,"row":3,"index":2,"Index":2,"target":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":4},"target":3,"file":0,"col":4,"row":3}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":4,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":4,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":4,"row":3}}]}],"path":["g0","test","p"]}]}}`
if diff := cmp.Diff(expected, got); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
+218
View File
@@ -0,0 +1,218 @@
//go:build go1.27
// Copyright 2022 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 cmd
import (
"bytes"
"path"
"slices"
"sort"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/util/test"
)
func TestCapabilitiesNoArgs(t *testing.T) {
t.Run("test with no arguments", func(t *testing.T) {
_, err := doCapabilities(capabilitiesParams{})
if err != nil {
t.Fatal("expected success", err)
}
})
}
func TestCapabilitiesVersion(t *testing.T) {
t.Run("test with version", func(t *testing.T) {
params := capabilitiesParams{
version: "v0.39.0",
}
_, err := doCapabilities(params)
if err != nil {
t.Fatal("expected success", err)
}
})
}
func TestCapabilitiesFile(t *testing.T) {
t.Run("test with file", func(t *testing.T) {
files := map[string]string{
"test-capabilities.json": `
{
"builtins": [
{
"name": "plus",
"infix": "+",
"decl": {
"type": "function",
"args": [
{
"type": "number"
},
{
"type": "number"
}
],
"result": {
"type": "number"
}
}
}
]
}
`,
}
test.WithTempFS(files, func(root string) {
params := capabilitiesParams{
file: path.Join(root, "test-capabilities.json"),
}
_, err := doCapabilities(params)
if err != nil {
t.Fatal("expected success", err)
}
})
})
}
func TestCapabilitiesJSONOutputBytes(t *testing.T) {
files := map[string]string{
"test-capabilities.json": `
{
"builtins": [
{
"name": "plus",
"infix": "+",
"decl": {
"type": "function",
"args": [
{
"type": "number"
},
{
"type": "number"
}
],
"result": {
"type": "number"
}
}
}
]
}
`,
}
test.WithTempFS(files, func(root string) {
params := capabilitiesParams{
file: path.Join(root, "test-capabilities.json"),
}
got, err := doCapabilities(params)
if err != nil {
t.Fatal("expected success", err)
}
expected := `{
"builtins": [
{
"name": "plus",
"decl": {
"args": [
{
"type": "number"
},
{
"type": "number"
}
],
"result": {
"type": "number"
},
"type": "function"
},
"infix": "+"
}
]
}`
if diff := cmp.Diff(expected, got); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
func TestCapabilitiesCurrent(t *testing.T) {
tests := []struct {
note string
v0Compatible bool
expFeatures []string
expFutureKeywords []string
}{
{
note: "current",
expFeatures: []string{
ast.FeatureRegoV1,
ast.FeatureKeywordsInRefs,
ast.FeatureTemplateStrings,
},
expFutureKeywords: []string{
"not",
},
},
{
note: "current --v0-compatible",
v0Compatible: true,
expFeatures: []string{
ast.FeatureRefHeadStringPrefixes,
ast.FeatureRefHeads,
ast.FeatureRegoV1Import,
ast.FeatureRegoV1,
ast.FeatureKeywordsInRefs,
},
expFutureKeywords: []string{
"in",
"every",
"contains",
"if",
"not",
},
},
}
for _, tc := range tests {
t.Run(tc.note, func(t *testing.T) {
// These are sorted in the output
sort.Strings(tc.expFutureKeywords)
sort.Strings(tc.expFeatures)
params := capabilitiesParams{
showCurrent: true,
v0Compatible: tc.v0Compatible,
}
capsStr, err := doCapabilities(params)
if err != nil {
t.Fatal("expected success", err)
}
caps, err := ast.LoadCapabilitiesJSON(bytes.NewReader([]byte(capsStr)))
if err != nil {
t.Fatal("expected success", err)
}
if !slices.Equal(caps.Features, tc.expFeatures) {
t.Errorf("expected features:\n\n%v\n\nbut got:\n\n%v", tc.expFeatures, caps.Features)
}
if !slices.Equal(caps.FutureKeywords, tc.expFutureKeywords) {
t.Errorf("expected future keywords:\n\n%v\n\nbut got:\n\n%v", tc.expFutureKeywords, caps.FutureKeywords)
}
})
}
}
+69
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
// Copyright 2022 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.
@@ -11,6 +13,7 @@ import (
"sort"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/util/test"
)
@@ -79,6 +82,72 @@ func TestCapabilitiesFile(t *testing.T) {
})
}
func TestCapabilitiesJSONOutputBytes(t *testing.T) {
files := map[string]string{
"test-capabilities.json": `
{
"builtins": [
{
"name": "plus",
"infix": "+",
"decl": {
"type": "function",
"args": [
{
"type": "number"
},
{
"type": "number"
}
],
"result": {
"type": "number"
}
}
}
]
}
`,
}
test.WithTempFS(files, func(root string) {
params := capabilitiesParams{
file: path.Join(root, "test-capabilities.json"),
}
got, err := doCapabilities(params)
if err != nil {
t.Fatal("expected success", err)
}
expected := `{
"builtins": [
{
"name": "plus",
"decl": {
"args": [
{
"type": "number"
},
{
"type": "number"
}
],
"result": {
"type": "number"
},
"type": "function"
},
"infix": "+"
}
]
}`
if diff := cmp.Diff(expected, got); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
func TestCapabilitiesCurrent(t *testing.T) {
tests := []struct {
note string
File diff suppressed because it is too large Load Diff
+46
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
// Copyright 2022 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.
@@ -5,6 +7,7 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"maps"
@@ -14,7 +17,10 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/internal/file/archive"
pr "github.com/open-policy-agent/opa/internal/presentation"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/util/test"
)
@@ -292,6 +298,46 @@ func TestCheckFailsOnInvalidRego(t *testing.T) {
})
}
func TestCheckJSONOutputBytes(t *testing.T) {
files := map[string]string{
"test.rego": `package test
{}`,
}
test.WithTempFS(files, func(root string) {
params := newCheckParams()
checkErr := checkModules(params, []string{root})
if checkErr == nil {
t.Fatal("expected error but received none")
}
var buf bytes.Buffer
if err := pr.JSON(&buf, pr.Output{Errors: pr.NewOutputErrors(checkErr)}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := strings.ReplaceAll(`{
"errors": [
{
"message": "object cannot be used for rule name",
"code": "rego_parse_error",
"location": {
"file": "TEMPDIR/test.rego",
"row": 2,
"col": 1
}
}
]
}
`, "TEMPDIR", root)
if diff := cmp.Diff(expected, buf.String()); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
// Assert that 'schemas' annotations with schema refs are only informing the type checker when the --schema flag is used
func TestCheckWithSchemasAnnotationButNoSchemaFlag(t *testing.T) {
policiesWithSchemaRef := []string{`
+634
View File
@@ -0,0 +1,634 @@
//go:build go1.27
// Copyright 2024 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 cmd
import (
"bytes"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/cmd/formats"
"github.com/open-policy-agent/opa/internal/file/archive"
"github.com/open-policy-agent/opa/v1/util/test"
)
func TestDepsJSONOutputBytes(t *testing.T) {
files := map[string]string{
"test.rego": `package test
p if { input.x }`,
}
test.WithTempFS(files, func(rootPath string) {
params := newDepsCommandParams()
_ = params.outputFormat.Set(formats.JSON)
for f := range files {
_ = params.dataPaths.Set(filepath.Join(rootPath, f))
}
var buf bytes.Buffer
if err := deps([]string{"data.test.p"}, params, &buf); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
expectedOutput := `{
"base": [
[
{
"type": "var",
"value": "input"
},
{
"type": "string",
"value": "x"
}
]
],
"virtual": [
[
{
"type": "var",
"value": "data"
},
{
"type": "string",
"value": "test"
},
{
"type": "string",
"value": "p"
}
]
]
}
`
if diff := cmp.Diff(expectedOutput, buf.String()); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
func TestDeps_DefaultRegoVersion(t *testing.T) {
tests := []struct {
note string
module string
query string
expErrs []string
}{
{
note: "v0 module",
module: `package test
a[x] {
x := 42
}`,
query: `data.test.p`,
expErrs: []string{
"test.rego:2: rego_parse_error: `if` keyword is required before rule body",
"test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules",
},
},
{
note: "v1 module",
module: `package test
a contains x if {
x := 42
}`,
query: `data.test.a`,
},
}
for _, tc := range tests {
t.Run(tc.note, func(t *testing.T) {
files := map[string]string{
"test.rego": tc.module,
}
test.WithTempFS(files, func(rootPath string) {
params := newDepsCommandParams()
_ = params.outputFormat.Set(formats.Pretty)
for f := range files {
_ = params.dataPaths.Set(filepath.Join(rootPath, f))
}
err := deps([]string{tc.query}, params, io.Discard)
if len(tc.expErrs) > 0 {
if err == nil {
t.Fatalf("Expected error but got nil")
}
for _, expErr := range tc.expErrs {
if !strings.Contains(err.Error(), expErr) {
t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, err.Error())
}
}
} else if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
})
})
}
}
func TestDepsCompatibleFlags(t *testing.T) {
tests := []struct {
note string
v0Compatible bool
v1Compatible bool
module string
query string
expErrs []string
}{
{
note: "v0, no keywords",
v0Compatible: true,
module: `package test
p[3] {
input.x = 1
}`,
query: `data.test.p`,
},
{
note: "v0, keywords not imported, but used",
v0Compatible: true,
module: `package test
p contains 3 if {
input.x = 1
}`,
query: `data.test.p`,
expErrs: []string{
"rego_parse_error: var cannot be used for rule name",
"rego_parse_error: number cannot be used for rule name",
},
},
{
note: "v0, keywords imported",
v0Compatible: true,
module: `package test
import future.keywords
p contains 3 if {
input.x = 1
}`,
query: `data.test.p`,
},
{
note: "v0, rego.v1 imported",
v0Compatible: true,
module: `package test
import rego.v1
p contains 3 if {
input.x = 1
}`,
query: `data.test.p`,
},
{
note: "v1, no keywords",
v1Compatible: true,
module: `package test
p[3] {
input.x = 1
}`,
query: `data.test.p`,
expErrs: []string{
"rego_parse_error: `if` keyword is required before rule body",
"rego_parse_error: `contains` keyword is required for partial set rules",
},
},
{
note: "v1, no keyword imports",
v1Compatible: true,
module: `package test
p contains 3 if {
input.x = 1
}`,
query: `data.test.p`,
},
{
note: "v1, keywords imported",
v1Compatible: true,
module: `package test
import future.keywords
p contains 3 if {
input.x = 1
}`,
query: `data.test.p`,
},
{
note: "v1, rego.v1 imported",
v1Compatible: true,
module: `package test
import rego.v1
p contains 3 if {
input.x = 1
}`,
query: `data.test.p`,
},
// v0 takes precedence over v1
{
note: "v0+v1, no keywords",
v0Compatible: true,
v1Compatible: true,
module: `package test
p[3] {
input.x = 1
}`,
query: `data.test.p`,
},
{
note: "v0+v1, keywords not imported, but used",
v0Compatible: true,
v1Compatible: true,
module: `package test
p contains 3 if {
input.x = 1
}`,
query: `data.test.p`,
expErrs: []string{
"rego_parse_error: var cannot be used for rule name",
"rego_parse_error: number cannot be used for rule name",
},
},
{
note: "v0+v1, keywords imported",
v0Compatible: true,
v1Compatible: true,
module: `package test
import future.keywords
p contains 3 if {
input.x = 1
}`,
query: `data.test.p`,
},
{
note: "v0+v1, rego.v1 imported",
v0Compatible: true,
v1Compatible: true,
module: `package test
import rego.v1
p contains 3 if {
input.x = 1
}`,
query: `data.test.p`,
},
}
for _, tc := range tests {
t.Run(tc.note, func(t *testing.T) {
files := map[string]string{
"test.rego": tc.module,
}
test.WithTempFS(files, func(rootPath string) {
params := newDepsCommandParams()
params.v0Compatible = tc.v0Compatible
params.v1Compatible = tc.v1Compatible
_ = params.outputFormat.Set(formats.Pretty)
for f := range files {
_ = params.dataPaths.Set(filepath.Join(rootPath, f))
}
err := deps([]string{tc.query}, params, io.Discard)
if len(tc.expErrs) > 0 {
if err == nil {
t.Fatalf("Expected error but got nil")
}
for _, expErr := range tc.expErrs {
if !strings.Contains(err.Error(), expErr) {
t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, err.Error())
}
}
} else if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
})
})
}
}
func TestDepsV1WithBundleRegoVersion(t *testing.T) {
tests := []struct {
note string
files map[string]string
query string
expErrs []string
}{
{
note: "v0.x bundle, no keywords",
files: map[string]string{
".manifest": `{"rego_version": 0}`,
"policy.rego": `package test
p[3] {
input.x = 1
}`,
},
query: `data.test.p`,
},
{
note: "v0.x bundle, keywords not imported, but used",
files: map[string]string{
".manifest": `{"rego_version": 0}`,
"policy.rego": `package test
p contains 3 if {
input.x = 1
}`,
},
query: `data.test.p`,
expErrs: []string{
"rego_parse_error: var cannot be used for rule name",
"rego_parse_error: number cannot be used for rule name",
},
},
{
note: "v0.x bundle, keywords imported",
files: map[string]string{
".manifest": `{"rego_version": 0}`,
"policy.rego": `package test
import future.keywords
p contains 3 if {
input.x = 1
}`,
},
query: `data.test.p`,
},
{
note: "v0.x bundle, rego.v1 imported",
files: map[string]string{
".manifest": `{"rego_version": 0}`,
"policy.rego": `package test
import rego.v1
p contains 3 if {
input.x = 1
}`,
},
query: `data.test.p`,
},
{
note: "v0 bundle, v1 per-file override",
files: map[string]string{
".manifest": `{
"rego_version": 0,
"file_rego_versions": {
"/policy2.rego": 1
}
}`,
"policy1.rego": `package test
p[3] {
input.x = 1
}`,
"policy2.rego": `package test
p contains 4 if {
input.x = 1
}`,
},
},
{
note: "v0 bundle, v1 per-file override (glob)",
files: map[string]string{
".manifest": `{
"rego_version": 0,
"file_rego_versions": {
"/bar/*.rego": 1
}
}`,
"foo/policy1.rego": `package test
p[3] {
input.x = 1
}`,
"bar/policy2.rego": `package test
p contains 4 if {
input.x = 1
}`,
},
},
{
note: "v0 bundle, v1 per-file override, incompliant",
files: map[string]string{
".manifest": `{
"rego_version": 0,
"file_rego_versions": {
"/policy2.rego": 1
}
}`,
"policy1.rego": `package test
p[3] {
input.x = 1
}`,
"policy2.rego": `package test
p[4] {
input.x = 1
}`,
},
expErrs: []string{
"rego_parse_error: `if` keyword is required before rule body",
"rego_parse_error: `contains` keyword is required for partial set rules",
},
},
{
note: "v1.0 bundle, no keywords",
files: map[string]string{
".manifest": `{"rego_version": 1}`,
"policy.rego": `package test
p[3] {
input.x = 1
}`,
},
query: `data.test.p`,
expErrs: []string{
"rego_parse_error: `if` keyword is required before rule body",
"rego_parse_error: `contains` keyword is required for partial set rules",
},
},
{
note: "v1.0 bundle, no keyword imports",
files: map[string]string{
".manifest": `{"rego_version": 1}`,
"policy.rego": `package test
p contains 3 if {
input.x = 1
}`,
},
query: `data.test.p`,
},
{
note: "v1.0 bundle, keywords imported",
files: map[string]string{
".manifest": `{"rego_version": 1}`,
"policy.rego": `package test
import future.keywords
p contains 3 if {
input.x = 1
}`,
},
query: `data.test.p`,
},
{
note: "v1.0 bundle, rego.v1 imported",
files: map[string]string{
".manifest": `{"rego_version": 1}`,
"policy.rego": `package test
import rego.v1
p contains 3 if {
input.x = 1
}`,
},
query: `data.test.p`,
},
{
note: "v1 bundle, v0 per-file override",
files: map[string]string{
".manifest": `{
"rego_version": 1,
"file_rego_versions": {
"/policy1.rego": 0
}
}`,
"policy1.rego": `package test
p[3] {
input.x = 1
}`,
"policy2.rego": `package test
p contains 4 if {
input.x = 1
}`,
},
},
{
note: "v1 bundle, v0 per-file override (glob)",
files: map[string]string{
".manifest": `{
"rego_version": 1,
"file_rego_versions": {
"/foo/*.rego": 0
}
}`,
"foo/policy1.rego": `package test
p[3] {
input.x = 1
}`,
"bar/policy2.rego": `package test
p contains 4 if {
input.x = 1
}`,
},
},
{
note: "v1 bundle, v0 per-file override, incompliant",
files: map[string]string{
".manifest": `{
"rego_version": 1,
"file_rego_versions": {
"/policy1.rego": 0
}
}`,
"policy1.rego": `package test
p contains 3 if {
input.x = 1
}`,
"policy2.rego": `package test
p contains 4 if {
input.x = 1
}`,
},
expErrs: []string{
"rego_parse_error: var cannot be used for rule name",
"rego_parse_error: number cannot be used for rule name",
},
},
}
bundleTypeCases := []struct {
note string
tar bool
}{
{
"bundle dir", false,
},
{
"bundle tar", true,
},
}
v1CompatibleFlagCases := []struct {
note string
used bool
}{
{
"no --v1-compatible", false,
},
{
"--v1-compatible", true,
},
}
for _, bundleType := range bundleTypeCases {
for _, v1CompatibleFlag := range v1CompatibleFlagCases {
for _, tc := range tests {
t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, v1CompatibleFlag.note, tc.note), func(t *testing.T) {
files := map[string]string{}
if bundleType.tar {
files["bundle.tar.gz"] = ""
} else {
maps.Copy(files, tc.files)
}
test.WithTempFS(files, func(root string) {
p := root
if bundleType.tar {
p = filepath.Join(root, "bundle.tar.gz")
files := make([][2]string, 0, len(tc.files))
for k, v := range tc.files {
files = append(files, [2]string{k, v})
}
buf := archive.MustWriteTarGz(files)
bf, err := os.Create(p)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
_, err = bf.Write(buf.Bytes())
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
params := newDepsCommandParams()
if err := params.bundlePaths.Set(p); err != nil {
t.Fatalf("Unexpected error: %s", err)
}
params.v1Compatible = v1CompatibleFlag.used
_ = params.outputFormat.Set(formats.Pretty)
err := deps([]string{tc.query}, params, io.Discard)
if len(tc.expErrs) > 0 {
if err == nil {
t.Fatalf("Expected error but got nil")
}
for _, expErr := range tc.expErrs {
if !strings.Contains(err.Error(), expErr) {
t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, err.Error())
}
}
} else if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
})
})
}
}
}
}
+61
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
// Copyright 2024 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.
@@ -5,6 +7,7 @@
package cmd
import (
"bytes"
"fmt"
"io"
"maps"
@@ -13,11 +16,69 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/cmd/formats"
"github.com/open-policy-agent/opa/internal/file/archive"
"github.com/open-policy-agent/opa/v1/util/test"
)
func TestDepsJSONOutputBytes(t *testing.T) {
files := map[string]string{
"test.rego": `package test
p if { input.x }`,
}
test.WithTempFS(files, func(rootPath string) {
params := newDepsCommandParams()
_ = params.outputFormat.Set(formats.JSON)
for f := range files {
_ = params.dataPaths.Set(filepath.Join(rootPath, f))
}
var buf bytes.Buffer
if err := deps([]string{"data.test.p"}, params, &buf); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
expectedOutput := `{
"base": [
[
{
"type": "var",
"value": "input"
},
{
"type": "string",
"value": "x"
}
]
],
"virtual": [
[
{
"type": "var",
"value": "data"
},
{
"type": "string",
"value": "test"
},
{
"type": "string",
"value": "p"
}
]
]
}
`
if diff := cmp.Diff(expectedOutput, buf.String()); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
func TestDeps_DefaultRegoVersion(t *testing.T) {
tests := []struct {
note string
File diff suppressed because it is too large Load Diff
Executable → Regular
+37
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
// Copyright 2018 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.
@@ -3946,3 +3948,38 @@ func TestWithQueryImports(t *testing.T) {
})
}
}
func TestEvalJSONOutputBytes(t *testing.T) {
params := newEvalCommandParams()
var buf bytes.Buffer
defined, err := eval([]string{"1 == 1"}, params, &buf, nil)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !defined {
t.Fatal("expected result to be defined")
}
expected := `{
"result": [
{
"expressions": [
{
"value": true,
"text": "1 == 1",
"location": {
"row": 1,
"col": 1
}
}
]
}
]
}
`
if diff := cmp.Diff(expected, buf.String()); diff != "" {
t.Fatalf("unexpected JSON output (-want +got):\n%s", diff)
}
}
File diff suppressed because it is too large Load Diff
+53
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
package cmd
import (
@@ -16,6 +18,8 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/cmd/internal/exec"
"github.com/open-policy-agent/opa/internal/file/archive"
loggingtest "github.com/open-policy-agent/opa/v1/logging/test"
@@ -1310,6 +1314,55 @@ func TestFailFlagCases(t *testing.T) {
}
}
func TestExecJSONOutputBytes(t *testing.T) {
files := map[string]string{
"files/test.json": `{"foo": 7}`,
"bundle/x.rego": `package system
import rego.v1
test_fun := x if {
x = false
x
}
undefined_test if {
test_fun
}`,
}
test.WithTempFS(files, func(dir string) {
var buf bytes.Buffer
params := exec.NewParams(&buf)
_ = params.OutputFormat.Set("json")
params.BundlePaths = []string{dir + "/bundle/"}
params.Paths = append(params.Paths, dir+"/files/")
params.FailDefined = true
if err := runExec(params); err != nil {
t.Fatal("unexpected error in test:", err)
}
actual := bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil)
expected := `{
"result": [
{
"path": "/files/test.json",
"error": {
"code": "opa_undefined_error",
"message": "/system/main decision was undefined"
}
}
]
}
`
if diff := cmp.Diff(expected, string(actual)); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
func TestExecWithInvalidInputOptions(t *testing.T) {
tests := []struct {
description string
File diff suppressed because it is too large Load Diff
+59
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
// Copyright 2021 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.
@@ -14,6 +16,7 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/cmd/formats"
"github.com/open-policy-agent/opa/internal/file/archive"
"github.com/open-policy-agent/opa/v1/util"
@@ -21,6 +24,62 @@ import (
"github.com/open-policy-agent/opa/v1/util/test"
)
func TestDoInspectJSONOutputBytes(t *testing.T) {
files := [][2]string{
{"/.manifest", `{"revision": "rev", "roots": ["foo", "bar", "fuz", "baz", "a", "x"]}`},
{"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`},
{"/example/foo.rego", `package foo`},
}
buf := archive.MustWriteTarGz(files)
bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz")
if err := os.WriteFile(bundleFile, buf.Bytes(), 0o644); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
var out bytes.Buffer
params := newInspectCommandParams()
if err := params.outputFormat.Set(formats.JSON); err != nil {
t.Fatalf("Unexpected error: %s", err)
}
if err := doInspect(params, bundleFile, &out); err != nil {
t.Fatalf("Unexpected error %v", err)
}
expected := `{
"manifest": {
"revision": "rev",
"roots": [
"foo",
"bar",
"fuz",
"baz",
"a",
"x"
]
},
"signatures_config": {},
"namespaces": {
"data": [
"/data.json"
],
"data.foo": [
"/example/foo.rego"
]
},
"capabilities": {
"features": [
"rego_v1"
]
}
}
`
if diff := cmp.Diff(expected, out.String()); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
}
func TestDoInspect(t *testing.T) {
files := [][2]string{
{"/.manifest", `{"revision": "rev", "roots": ["foo", "bar", "fuz", "baz", "a", "x"]}`},
+244
View File
@@ -0,0 +1,244 @@
//go:build go1.27
package cmd
import (
"bytes"
"errors"
"fmt"
"path"
"reflect"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/v1/util"
"github.com/open-policy-agent/opa/v1/util/test"
)
func TestOracleFindDefinition(t *testing.T) {
cases := []struct {
note string
v0Compatible bool
onDiskModule string
stdin string
paths []string
}{
{
note: "v0",
v0Compatible: true,
onDiskModule: `package test
p { r }
r = true`,
stdin: `package test
p { q }
q = true`,
paths: []string{
"test.rego:10",
"test.rego:15",
"test.rego:18",
},
},
{
note: "v1",
onDiskModule: `package test
p if { r }
r = true`,
stdin: `package test
p if { q }
q = true`,
paths: []string{
"test.rego:10",
"test.rego:15",
"test.rego:21",
},
},
}
for _, tc := range cases {
t.Run(tc.note, func(t *testing.T) {
stdin := bytes.NewBufferString(tc.stdin)
files := map[string]string{
"test.rego": tc.onDiskModule,
"document.txt": "this should not be included",
"ignore.json": `{"neither": "should this"}`,
}
test.WithTempFS(files, func(rootDir string) {
params := findDefinitionParams{
bundlePaths: repeatedStringFlag{
v: []string{rootDir},
isSet: true,
},
stdinBuffer: true,
v0Compatible: tc.v0Compatible,
}
stdout := bytes.NewBuffer(nil)
err := dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, tc.paths[0])})
expectJSON(t, err, stdout, `{"error": {"code": "oracle_no_match_found"}}`)
err = dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, tc.paths[1])})
expectJSON(t, err, stdout, `{"error": {"code": "oracle_no_definition_found"}}`)
err = dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, tc.paths[2])})
expectJSON(t, err, stdout, fmt.Sprintf(`{"result": {
"file": %q,
"row": 5,
"col": 1
}}`, path.Join(rootDir, "test.rego")))
})
})
}
}
func TestOracleFindDefinitionJSONOutputBytes(t *testing.T) {
onDiskModule := `package test
p if { r }
r = true`
stdin := bytes.NewBufferString(`package test
p if { q }
q = true`)
files := map[string]string{
"test.rego": onDiskModule,
"document.txt": "this should not be included",
"ignore.json": `{"neither": "should this"}`,
}
test.WithTempFS(files, func(rootDir string) {
params := findDefinitionParams{
bundlePaths: repeatedStringFlag{
v: []string{rootDir},
isSet: true,
},
stdinBuffer: true,
}
stdout := bytes.NewBuffer(nil)
err := dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, "test.rego:10")})
if err != nil {
t.Fatal(err)
}
exp := `{
"error": {
"code": "oracle_no_match_found"
}
}
`
if diff := cmp.Diff(exp, stdout.String()); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
func expectJSON(t *testing.T, err error, buffer *bytes.Buffer, exp string) {
t.Helper()
if err != nil {
t.Fatal(err)
}
var x any
if err := util.UnmarshalJSON(buffer.Bytes(), &x); err != nil {
t.Fatal(err)
}
var y any
if err := util.UnmarshalJSON([]byte(exp), &y); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(x, y) {
t.Fatalf("expected %v but got %v", y, x)
}
buffer.Reset()
}
func TestOracleParseFilenameOffset(t *testing.T) {
tests := []struct {
input string
wantFile string
wantPos int
}{
{
input: "x.rego:10",
wantFile: "x.rego",
wantPos: 10,
},
{
input: "/x.rego:10",
wantFile: "/x.rego",
wantPos: 10,
},
{
input: "x.rego:0x10",
wantFile: "x.rego",
wantPos: 16,
},
{
input: "file://x.rego:10",
wantFile: "x.rego",
wantPos: 10,
},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
filename, pos, err := parseFilenameOffset(tc.input)
if err != nil {
t.Fatal(err)
}
if tc.wantFile != filename || tc.wantPos != pos {
t.Fatalf("expected %v:%v but got %v:%v", tc.wantFile, tc.wantPos, filename, pos)
}
})
}
}
func TestOracleParseFilenameOffsetError(t *testing.T) {
tests := []struct {
input string
wantErr error
}{
{
input: "x.rego",
wantErr: errors.New("expected <filename>:<offset> argument"),
},
{
input: "x.rego:",
wantErr: errors.New("invalid syntax"),
},
{
input: "x.rego:3.14",
wantErr: errors.New("invalid syntax"),
},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
_, _, err := parseFilenameOffset(tc.input)
if err == nil || !strings.Contains(err.Error(), tc.wantErr.Error()) {
t.Fatalf("expected %v but got %v", tc.wantErr, err)
}
})
}
}
+49
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
package cmd
import (
@@ -9,6 +11,7 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/v1/util"
"github.com/open-policy-agent/opa/v1/util/test"
)
@@ -101,6 +104,52 @@ q = true`,
}
func TestOracleFindDefinitionJSONOutputBytes(t *testing.T) {
onDiskModule := `package test
p if { r }
r = true`
stdin := bytes.NewBufferString(`package test
p if { q }
q = true`)
files := map[string]string{
"test.rego": onDiskModule,
"document.txt": "this should not be included",
"ignore.json": `{"neither": "should this"}`,
}
test.WithTempFS(files, func(rootDir string) {
params := findDefinitionParams{
bundlePaths: repeatedStringFlag{
v: []string{rootDir},
isSet: true,
},
stdinBuffer: true,
}
stdout := bytes.NewBuffer(nil)
err := dofindDefinition(params, stdin, stdout, []string{path.Join(rootDir, "test.rego:10")})
if err != nil {
t.Fatal(err)
}
exp := `{
"error": {
"code": "oracle_no_match_found"
}
}
`
if diff := cmp.Diff(exp, stdout.String()); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
func expectJSON(t *testing.T, err error, buffer *bytes.Buffer, exp string) {
t.Helper()
if err != nil {
File diff suppressed because it is too large Load Diff
+20 -63
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
package cmd
import (
@@ -12,7 +14,6 @@ import (
)
func TestParseExit0(t *testing.T) {
files := map[string]string{
"x.rego": `package x
@@ -62,7 +63,6 @@ func TestParseExit1(t *testing.T) {
}
func TestParseJSONOutput(t *testing.T) {
files := map[string]string{
"x.rego": `package x
@@ -121,13 +121,12 @@ func TestParseJSONOutput(t *testing.T) {
}
`
if got, want := string(stdout), expectedOutput; got != want {
t.Fatalf("Expected output\n%v\n, got\n%v", want, got)
if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
}
func TestParseJSONOutputWithLocations(t *testing.T) {
files := map[string]string{
"x.rego": `package x
@@ -241,21 +240,8 @@ p = 1
}
`, "TEMPDIR", tempDirPath)
gotLines := strings.Split(string(stdout), "\n")
wantLines := strings.Split(expectedOutput, "\n")
min := len(gotLines)
if len(wantLines) < min {
min = len(wantLines)
}
for i := range min {
if gotLines[i] != wantLines[i] {
t.Fatalf("Expected line %d to be\n%v\n, got\n%v", i, wantLines[i], gotLines[i])
}
}
if len(gotLines) != len(wantLines) {
t.Fatalf("Expected %d lines, got %d", len(wantLines), len(gotLines))
if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
}
@@ -633,7 +619,6 @@ func TestParseOutputWithNotImport(t *testing.T) {
}
func TestParseRefsJSONOutput(t *testing.T) {
files := map[string]string{
"x.rego": `package x
@@ -700,13 +685,12 @@ func TestParseRefsJSONOutput(t *testing.T) {
}
`
if got, want := string(stdout), expectedOutput; got != want {
t.Fatalf("Expected output\n%v\n, got\n%v", want, got)
if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
}
func TestParseRefsJSONOutputWithLocations(t *testing.T) {
files := map[string]string{
"x.rego": `package x
@@ -840,21 +824,8 @@ a.b.c := true
}
`, "TEMPDIR", tempDirPath)
gotLines := strings.Split(string(stdout), "\n")
wantLines := strings.Split(expectedOutput, "\n")
min := len(gotLines)
if len(wantLines) < min {
min = len(wantLines)
}
for i := range min {
if gotLines[i] != wantLines[i] {
t.Fatalf("Expected line %d to be\n%v\n, got\n%v", i, wantLines[i], gotLines[i])
}
}
if len(gotLines) != len(wantLines) {
t.Fatalf("Expected %d lines, got %d", len(wantLines), len(gotLines))
if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
}
func TestParseRulesBlockJSONOutputWithLocations(t *testing.T) {
@@ -1301,26 +1272,12 @@ allow = true if {
}
`, "TEMPDIR", tempDirPath)
gotLines := strings.Split(string(stdout), "\n")
wantLines := strings.Split(expectedOutput, "\n")
min := len(gotLines)
if len(wantLines) < min {
min = len(wantLines)
}
for i := range min {
if gotLines[i] != wantLines[i] {
t.Fatalf("Expected line %d to be\n%v\n, got\n%v", i, wantLines[i], gotLines[i])
}
}
if len(gotLines) != len(wantLines) {
t.Fatalf("Expected %d lines, got %d", len(wantLines), len(gotLines))
if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
}
func TestParseJSONOutputComments(t *testing.T) {
files := map[string]string{
"x.rego": `package x
@@ -1560,15 +1517,15 @@ func testParse(t *testing.T, files map[string]string, params *parseParams) (int,
var errc int
var tempDirUsed string
test.WithTempFS(files, func(path string) {
args := make([]string, 0, len(files))
for file := range files {
args = append(args, filepath.Join(path, file))
}
errc = parse(args, params, stdout, stderr)
path := test.TempDir(t, files)
tempDirUsed = path
})
args := make([]string, 0, len(files))
for file := range files {
args = append(args, filepath.Join(path, file))
}
errc = parse(args, params, stdout, stderr)
tempDirUsed = path
return errc, stdout.Bytes(), stderr.Bytes(), tempDirUsed
}
+510
View File
@@ -0,0 +1,510 @@
//go:build go1.27
// Copyright 2020 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 cmd
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
internal_logging "github.com/open-policy-agent/opa/internal/logging"
"github.com/open-policy-agent/opa/v1/logging"
"github.com/open-policy-agent/opa/v1/repl"
"github.com/open-policy-agent/opa/v1/storage/inmem"
"github.com/open-policy-agent/opa/v1/test/e2e"
"github.com/open-policy-agent/opa/v1/util/test"
"github.com/spf13/cobra"
)
func TestREPLJSONOutputBytes(t *testing.T) {
store := inmem.New()
var buf bytes.Buffer
r := repl.New(store, "", &buf, "json", 0, "")
ctx := context.Background()
if err := r.OneShot(ctx, "1 == 1"); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
expected := `{
"result": [
{
"expressions": [
{
"value": true,
"text": "1 == 1",
"location": {
"row": 1,
"col": 1
}
}
]
}
]
}
`
if diff := cmp.Diff(expected, buf.String()); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
}
func TestRunServerBase(t *testing.T) {
params := newTestRunParams()
ctx, cancel := context.WithCancel(t.Context())
rt, err := initRuntime(ctx, params, nil, false)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
testRuntime := e2e.WrapRuntime(ctx, cancel, rt)
done := make(chan bool)
go func() {
err := rt.Serve(ctx)
if err != nil {
t.Errorf("Unexpected error: %s", err)
}
done <- true
}()
err = testRuntime.WaitForServer()
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
validateBasicServe(t, testRuntime)
cancel()
<-done
}
func TestRunServerBaseListenOnLocalhost(t *testing.T) {
params := newTestRunParams()
params.rt.V1Compatible = true
ctx, cancel := context.WithCancel(t.Context())
rt, err := initRuntime(ctx, params, nil, true)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
testRuntime := e2e.WrapRuntime(ctx, cancel, rt)
done := make(chan bool)
go func() {
err := rt.Serve(ctx)
if err != nil {
t.Errorf("Unexpected error: %s", err)
}
done <- true
}()
err = testRuntime.WaitForServer()
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
validateBasicServe(t, testRuntime)
if len(rt.Addrs()) != 1 {
t.Fatalf("Expected 1 listening address but got %v", len(rt.Addrs()))
}
expected := "127.0.0.1:8181"
if rt.Addrs()[0] != expected {
t.Fatalf("Expected listening address %v but got %v", expected, rt.Addrs()[0])
}
cancel()
<-done
}
func TestRunServerWithDiagnosticAddr(t *testing.T) {
params := newTestRunParams()
params.rt.DiagnosticAddrs = &[]string{"localhost:0"}
ctx, cancel := context.WithCancel(t.Context())
rt, err := initRuntime(ctx, params, nil, false)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
testRuntime := e2e.WrapRuntime(ctx, cancel, rt)
done := make(chan bool)
go func() {
err := rt.Serve(ctx)
if err != nil {
t.Errorf("Unexpected error: %s", err)
}
done <- true
}()
err = testRuntime.WaitForServer()
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
validateBasicServe(t, testRuntime)
diagURL, err := testRuntime.AddrToURL(rt.DiagnosticAddrs()[0])
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
if err := testRuntime.HealthCheck(diagURL); err != nil {
t.Error(err)
}
cancel()
<-done
}
func TestInitRuntimeVerifyNonBundle(t *testing.T) {
params := newTestRunParams()
params.pubKey = "secret"
params.serverMode = false
_, err := initRuntime(t.Context(), params, nil, false)
if err == nil {
t.Fatal("Expected error but got nil")
}
exp := "enable bundle mode (ie. --bundle) to verify bundle files or directories"
if err.Error() != exp {
t.Fatalf("expected error message %v but got %v", exp, err.Error())
}
}
func TestInitRuntimeCipherSuites(t *testing.T) {
testCases := []struct {
name string
cipherSuites []string
expErr bool
expCipherSuites []uint16
}{
{"no cipher suites", []string{}, false, []uint16{}},
{"secure and insecure cipher suites", []string{"TLS_RSA_WITH_AES_128_CBC_SHA", "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", "TLS_RSA_WITH_RC4_128_SHA"}, false, []uint16{tls.TLS_RSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_RSA_WITH_RC4_128_SHA}},
{"invalid cipher suites", []string{"foo"}, true, []uint16{}},
{"tls 1.3 cipher suite", []string{"TLS_AES_128_GCM_SHA256"}, true, []uint16{}},
{"tls 1.2-1.3 cipher suite", []string{"TLS_RSA_WITH_AES_128_GCM_SHA256", "TLS_AES_128_GCM_SHA256"}, true, []uint16{}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
params := newTestRunParams()
if len(tc.cipherSuites) != 0 {
params.cipherSuites = tc.cipherSuites
}
rt, err := initRuntime(t.Context(), params, nil, false)
fmt.Println(err)
if !tc.expErr && err != nil {
t.Fatal("Unexpected error occurred:", err)
} else if tc.expErr && err == nil {
t.Fatal("Expected error but got nil")
} else if err == nil {
if len(tc.expCipherSuites) > 0 {
if !slices.Equal(*rt.Params.CipherSuites, tc.expCipherSuites) {
t.Fatalf("expected cipher suites %v but got %v", tc.expCipherSuites, *rt.Params.CipherSuites)
}
} else {
if rt.Params.CipherSuites != nil {
t.Fatal("expected no value defined for cipher suites")
}
}
}
})
}
}
func TestInitRuntimeSkipKnownSchemaCheck(t *testing.T) {
fs := map[string]string{
"test/authz.rego": `package system.authz
import rego.v1
default allow := false
allow if {
input.identty = "foo" # this is a typo
}`,
}
test.WithTempFS(fs, func(rootDir string) {
rootDir = filepath.Join(rootDir, "test")
params := newTestRunParams()
err := params.authorization.Set("basic")
if err != nil {
t.Fatal(err)
}
_, err = initRuntime(t.Context(), params, []string{rootDir}, false)
if err == nil {
t.Fatal("Expected error but got nil")
}
if !strings.Contains(err.Error(), "undefined ref: input.identty") {
t.Errorf("Expected error \"%v\" not found", "undefined ref: input.identty")
}
// skip type checking for known input schemas
params.skipKnownSchemaCheck = true
_, err = initRuntime(t.Context(), params, []string{rootDir}, false)
if err != nil {
t.Fatal(err)
}
})
}
func TestRunServerUploadPolicy(t *testing.T) {
v0Policy := `package test
p { q["a"] }
q[x] {
x = "a"
}`
v1Policy := `package test
p if { q["a"] }
q contains x if {
x = "a"
}`
tests := []struct {
note string
v0Compatible bool
module string
expErr bool
}{
{
note: "v0-compatible, v0 policy",
v0Compatible: true,
module: v0Policy,
},
{
note: "v0-compatible, v1 policy",
v0Compatible: true,
module: v1Policy,
expErr: true,
},
{
note: "v1, v0 policy",
v0Compatible: false,
module: v0Policy,
expErr: true,
},
{
note: "v1, v1 policy",
v0Compatible: false,
module: v1Policy,
},
}
for i, tc := range tests {
t.Run(tc.note, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
params := newTestRunParams()
params.rt.V0Compatible = tc.v0Compatible
rt, err := initRuntime(ctx, params, nil, false)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
testRuntime := e2e.WrapRuntime(ctx, cancel, rt)
done := make(chan bool)
go func() {
err := rt.Serve(ctx)
if err != nil {
t.Errorf("Unexpected error: %s", err)
}
done <- true
}()
err = testRuntime.WaitForServer()
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
// upload policy
err = testRuntime.UploadPolicy(fmt.Sprintf("mod%d", i), bytes.NewBufferString(tc.module))
if tc.expErr {
if err == nil {
t.Fatalf("Expected error but got nil")
}
} else {
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
}
cancel()
<-done
})
}
}
func TestRunServerCheckLogTimestampFormat(t *testing.T) {
for _, format := range []string{time.Kitchen, time.RFC3339Nano} {
t.Run(format, func(t *testing.T) {
t.Run("parameter", func(t *testing.T) {
params := newTestRunParams()
params.logTimestampFormat = format
params.rt.Addrs = &[]string{"localhost:0"}
checkLogTimeStampFormat(t, params, format)
})
t.Run("environment variable", func(t *testing.T) {
t.Setenv("OPA_LOG_TIMESTAMP_FORMAT", format)
params := newTestRunParams()
params.rt.Addrs = &[]string{"localhost:0"}
checkLogTimeStampFormat(t, params, format)
})
})
}
}
func checkLogTimeStampFormat(t *testing.T, params runCmdParams, format string) {
ctx, cancel := context.WithCancel(t.Context())
// Pass a pre-configured StandardLogger to bypass BufferedLogger and capture logs directly.
var buf bytes.Buffer
stdLogger := logging.New()
stdLogger.SetFormatter(internal_logging.GetFormatter(params.logFormat.String(), format))
stdLogger.SetOutput(&buf)
params.rt.Logger = stdLogger
rt, err := initRuntime(ctx, params, nil, false)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
testRuntime := e2e.WrapRuntime(ctx, cancel, rt)
done := make(chan bool)
go func() {
err := rt.Serve(ctx)
if err != nil {
t.Errorf("Unexpected error: %s", err)
}
done <- true
}()
err = testRuntime.WaitForServer()
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
validateBasicServe(t, testRuntime)
cancel()
<-done
for line := range strings.SplitSeq(buf.String(), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var rec struct {
Time string `json:"time"`
}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("incorrect log message %s: %v", line, err)
}
if rec.Time == "" {
t.Fatalf("the time field is empty in log message: %s", line)
}
if _, err := time.Parse(format, rec.Time); err != nil {
t.Fatalf("incorrect timestamp format %q: %v", rec.Time, err)
}
}
}
func TestInitRuntimeAddrSetByUser(t *testing.T) {
testCases := []struct {
name string
addrValue string
addrFlagSet bool
}{
{"AddrSetByUser_True", "localhost:8181", true},
{"AddrSetByUser_False", "", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("addr", "", "set address")
if tc.addrFlagSet {
if err := cmd.Flags().Set("addr", tc.addrValue); err != nil {
t.Fatalf("Failed to set addr flag: %v", err)
}
}
params := newTestRunParams()
params.rt.Addrs = &[]string{"localhost:0"}
ctx, cancel := context.WithCancel(t.Context())
rt, err := initRuntime(ctx, params, []string{}, cmd.Flags().Changed("addr"))
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if rt.Params.AddrSetByUser != tc.addrFlagSet {
t.Errorf("Expected AddrSetByUser to be %v, but got %v", tc.addrFlagSet, rt.Params.AddrSetByUser)
}
cancel()
})
}
}
func newTestRunParams() runCmdParams {
params := newRunParams()
params.rt.GracefulShutdownPeriod = 1
params.rt.Addrs = &[]string{"localhost:8181"}
params.rt.DiagnosticAddrs = &[]string{}
params.serverMode = true
return params
}
func validateBasicServe(t *testing.T, runtime *e2e.TestRuntime) {
t.Helper()
err := runtime.UploadData(bytes.NewBufferString(`{"x": 1}`))
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
resp := struct {
Result int `json:"result"`
}{}
err = runtime.GetDataWithInputTyped("x", nil, &resp)
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
if resp.Result != 1 {
t.Fatalf("Expected x to be 1, got %v", resp)
}
}
+39
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
// Copyright 2020 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.
@@ -16,13 +18,50 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
internal_logging "github.com/open-policy-agent/opa/internal/logging"
"github.com/open-policy-agent/opa/v1/logging"
"github.com/open-policy-agent/opa/v1/repl"
"github.com/open-policy-agent/opa/v1/storage/inmem"
"github.com/open-policy-agent/opa/v1/test/e2e"
"github.com/open-policy-agent/opa/v1/util/test"
"github.com/spf13/cobra"
)
func TestREPLJSONOutputBytes(t *testing.T) {
store := inmem.New()
var buf bytes.Buffer
r := repl.New(store, "", &buf, "json", 0, "")
ctx := context.Background()
if err := r.OneShot(ctx, "1 == 1"); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
expected := `{
"result": [
{
"expressions": [
{
"value": true,
"text": "1 == 1",
"location": {
"row": 1,
"col": 1
}
}
]
}
]
}
`
if diff := cmp.Diff(expected, buf.String()); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
}
func TestRunServerBase(t *testing.T) {
params := newTestRunParams()
ctx, cancel := context.WithCancel(t.Context())
+207
View File
@@ -0,0 +1,207 @@
//go:build go1.27
// Copyright 2018 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 cmd
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/internal/file/archive"
"github.com/open-policy-agent/opa/v1/bundle"
"github.com/open-policy-agent/opa/v1/keys"
"github.com/open-policy-agent/opa/v1/util/test"
)
func TestWriteTokenToFile(t *testing.T) {
token := `eyJhbGciOiJSUzI1NiJ9.eyJmaWxlcyI6W3sibmFtZSI6ImJ1bmRsZS8ubWFuaWZlc3QiLCJoYXNoIjoiZWUwZWRiZGZkMjgzNTBjNDk2ZjA4ODI3Y2E1Y2VhYjgwMzA2NzI0YjYyZGY1ZjY0MDRlNzBjYjc2NjYxNWQ5ZCIsImFsZ29yaXRobSI6IlNIQTI1NiJ9LHsibmFtZSI6ImJ1bmRsZS9odHRwL2V4YW1wbGUvYXV0aHovYXV0aHoucmVnbyIsImhhc2giOiI2MDJiZTcwMWIyYmE4ZTc3YTljNTNmOWIzM2QwZTkwM2MzNGMwMGMzMDkzM2Y2NDZiYmU3NGI3YzE2NGY2OGM2IiwiYWxnb3JpdGhtIjoiU0hBMjU2In0seyJuYW1lIjoiYnVuZGxlL3JvbGVzL2JpbmRpbmcvZGF0YS5qc29uIiwiaGFzaCI6ImIxODg1NTViZjczMGVlNDdkZjBiY2Y4MzVlYTNmNTQ1MjlmMzc4N2Y0ODQxZjFhZGE2MDM5M2RhYWZhZmJkYzciLCJhbGdvcml0aG0iOiJTSEEyNTYifV0sImtleWlkIjoiZm9vIiwic2NvcGUiOiJyZWFkIn0.YojuPnGWutdlDL7lwFGBXqPfDtxOG2BuZmShN5zm-G9zfMprI1AMqKDoPoNv4tuCGIBNXwoNsYHYiK538CHfJEfY1v4iDX3JFEWQlwx_CfJWDonwqT9SY9tHUW7PUUrI_WgJXZ5zei8RAMYMymKSb9hpSAtfGg_PU0kZr52WzjbPUj4SRiB19Swi61r0CFXYjbfx3GDJdjrGTNBSWrUCMrdhHYLEWqJPfSQ-fYfRrgQVhq3BJLwJJe66dgBEGnHEgA7XMuxkNIOv7mj3Y_EChbv2tjrD9NJPekDcYH1zCEc4BycHjNCcsGiQXDE6sFtoNZiCXLB2D0sLqUnBx4TCw27wTPfcOuL2KauLPahZitnH5mYvQD8NI76Pm4NSyJfevwdWjSsrT7vf0DCLS-dU6r9dJ79xM_hJU7136CT8ARcmSrk-EvCqfkrH2c4WwZyAzdyyyFumMZh4CYc2vcC7ap0NANHJT193fTud1i23mx1PBslwXdsIqXvBGlTbR7nb9o661m-B_mxbHMkG4nIeoGpZoaBJw8RVaA6-4D55gtk8aaMyLJIlIIlV2_AKOLk3nPG3ACHiLSndasLDOIRIYkCluIEaM2FLEEPEtJfKNR6e1K-EK2TvNKMDAEUtJW71ggOuGQ3b5otYOoVVENJLwm-PsO7qb2Tq6PyAquI3ExU`
expected := make(map[string]any)
expected["signatures"] = []string{token}
files := map[string]string{}
test.WithTempFS(files, func(rootDir string) {
err := writeTokenToFile(token, rootDir)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
bs, err := os.ReadFile(filepath.Join(rootDir, ".signatures.json"))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
expectedBytes, err := json.MarshalIndent(expected, "", " ")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(expectedBytes, bs) {
t.Fatal("Unexpected content in \".signatures.json\" file")
}
})
}
func TestWriteTokenToFileJSONOutputBytes(t *testing.T) {
test.WithTempFS(map[string]string{}, func(rootDir string) {
if err := writeTokenToFile("footoken", rootDir); err != nil {
t.Fatalf("Unexpected error %v", err)
}
gotBytes, err := os.ReadFile(filepath.Join(rootDir, ".signatures.json"))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
expected := "{\n \"signatures\": [\n \"footoken\"\n ]\n}"
if diff := cmp.Diff(expected, string(gotBytes)); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
func TestDoSign(t *testing.T) {
files := map[string]string{
"foo/bar/data.json": `{"y": 2}`,
"/example/example.rego": `package example`,
"/.signatures.json": `{"signatures": []}`,
}
test.WithTempFS(files, func(rootDir string) {
params := signCmdParams{
algorithm: "HS256",
key: "mysecret",
outputFilePath: rootDir,
bundleMode: true,
}
err := doSign([]string{rootDir}, params)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
})
}
func TestBundleSignVerification(t *testing.T) {
// files to be included in the bundle
files := map[string]string{
"/.manifest": `{"revision": "quickbrownfaux"}`,
"/a/b/c/data.json": "[1,2,3]",
"/a/b/d/data.json": "true",
"/a/b/y/data.yaml": `foo: 1`,
"/example/example.rego": `package example`,
"/policy.wasm": `modules-compiled-as-wasm-binary`,
"/data.json": `{"x": {"y": true}, "a": {"b": {"z": true}}}`,
}
test.WithTempFS(files, func(rootDir string) {
params := signCmdParams{
algorithm: "HS256",
key: "mysecret",
outputFilePath: rootDir,
bundleMode: true,
}
err := doSign([]string{rootDir}, params)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
// create gzipped tarball
var filesInBundle [][2]string
err = filepath.Walk(rootDir, func(path string, info os.FileInfo, _ error) error {
if !info.IsDir() {
bs, err := os.ReadFile(path)
if err != nil {
return err
}
filesInBundle = append(filesInBundle, [2]string{path, string(bs)})
}
return nil
})
if err != nil {
t.Fatal(err)
}
buf := archive.MustWriteTarGz(filesInBundle)
// bundle verification config
kc := keys.Config{
Key: "mysecret",
Algorithm: "HS256",
}
bvc := bundle.NewVerificationConfig(map[string]*keys.Config{"foo": &kc}, "foo", "", nil)
reader := bundle.NewReader(buf).WithBundleVerificationConfig(bvc).WithBaseDir(rootDir)
_, err = reader.Read()
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
})
}
func TestValidateSignParams(t *testing.T) {
tests := map[string]struct {
args []string
params signCmdParams
wantErr bool
err error
}{
"no_args": {
[]string{},
newSignCmdParams(),
true, errors.New("specify atleast one path containing policy and/or data files"),
},
"no_signing_key": {
[]string{"foo"},
newSignCmdParams(),
true, errors.New("specify the secret (HMAC) or path of the PEM file containing the private key (RSA and ECDSA)"),
},
"empty_signing_key": {
[]string{"foo"},
signCmdParams{key: "", bundleMode: true},
true, errors.New("specify the secret (HMAC) or path of the PEM file containing the private key (RSA and ECDSA)"),
},
"non_bundle_mode": {
[]string{"foo"},
signCmdParams{key: "foo"},
true, errors.New("enable bundle mode (ie. --bundle) to sign bundle files or directories"),
},
"no_error": {
[]string{"foo"},
signCmdParams{key: "foo", bundleMode: true},
false, nil,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
err := validateSignParams(tc.args, tc.params)
if tc.wantErr {
if err == nil {
t.Fatal("Expected error but got nil")
}
if tc.err != nil && tc.err.Error() != err.Error() {
t.Fatalf("Expected error message %v but got %v", tc.err.Error(), err.Error())
}
} else if err != nil {
t.Fatalf("Unexpected error %v", err)
}
})
}
}
+23
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
// Copyright 2018 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.
@@ -11,6 +13,8 @@ import (
"path/filepath"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/internal/file/archive"
"github.com/open-policy-agent/opa/v1/bundle"
"github.com/open-policy-agent/opa/v1/keys"
@@ -47,6 +51,25 @@ func TestWriteTokenToFile(t *testing.T) {
})
}
func TestWriteTokenToFileJSONOutputBytes(t *testing.T) {
test.WithTempFS(map[string]string{}, func(rootDir string) {
if err := writeTokenToFile("footoken", rootDir); err != nil {
t.Fatalf("Unexpected error %v", err)
}
gotBytes, err := os.ReadFile(filepath.Join(rootDir, ".signatures.json"))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
expected := "{\n \"signatures\": [\n \"footoken\"\n ]\n}"
if diff := cmp.Diff(expected, string(gotBytes)); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
})
}
func TestDoSign(t *testing.T) {
files := map[string]string{
"foo/bar/data.json": `{"y": 2}`,
File diff suppressed because it is too large Load Diff
+51
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
package cmd
import (
@@ -15,6 +17,9 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/open-policy-agent/opa/cmd/formats"
"github.com/open-policy-agent/opa/internal/file/archive"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/bundle"
@@ -3865,3 +3870,49 @@ func (*testPlugin) Eval(context.Context, *rego.EvalContext, ast.Value) (ast.Valu
}
const targetPlugin = "rego_test_default_plugin"
var durationJSONRe = regexp.MustCompile(`"duration":\s*\d+`)
func TestOpaTestJSONOutputBytes(t *testing.T) {
files := map[string]string{
"test.rego": `package test
test_p if { true }
`,
}
var stdout bytes.Buffer
var tempDirPath string
test.WithTempFS(files, func(root string) {
tempDirPath = root
testParams := newTestCommandParams()
testParams.count = 1
testParams.outputFormat = formats.Flag(formats.JSON, formats.Pretty)
testParams.output = &stdout
testParams.errOutput = io.Discard
if exitCode := opaTest([]string{root}, testParams); exitCode != 0 {
t.Fatalf("unexpected exit code: %d", exitCode)
}
})
normalized := durationJSONRe.ReplaceAll(stdout.Bytes(), []byte(`"duration":0`))
expectedOutput := strings.ReplaceAll(`[
{
"location": {
"file": "TEMPDIR/test.rego",
"row": 3,
"col": 1
},
"package": "data.test",
"name": "test_p",
"duration":0
}
]
`, "TEMPDIR", tempDirPath)
if diff := cmp.Diff(expectedOutput, string(normalized)); diff != "" {
t.Errorf("unexpected result (-want, +got):\n%s", diff)
}
}
+79
View File
@@ -0,0 +1,79 @@
// 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.
//go:build go1.27
package jsonv2
// This migration must not change OPA's JSON output, but json.Marshal defaults
// to v2 semantics (no HTML escaping, non-deterministic map key order). So
// every entry point into v2 here must establish v1 options (see
// [jsonv1.DefaultOptionsV1]); nested encodes inherit them from the caller's
// encoder. MarshalMarshalerTo is the only such entry point today, but that's
// incidental — any new json.Marshal, json.MarshalWrite, or jsontext.NewEncoder
// added here must do the same.
import (
jsonv1 "encoding/json"
"encoding/json/jsontext"
"encoding/json/v2"
"reflect"
)
// WriteMarshalerToArray writes the JSON array of items to the encoder.
func WriteMarshalerToArray[T json.MarshalerTo](e *jsontext.Encoder, items []T) error {
e.WriteToken(jsontext.BeginArray)
for _, item := range items {
if err := item.MarshalJSONTo(e); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndArray)
}
// WriteField writes the object member name and then v's JSON encoding, so that
// the member is written and checked in one statement. A nil v is written as
// JSON null rather than dispatched to MarshalJSONTo: v1's reflection-based
// encoder already does this for a nil pointer, so writing null here keeps
// output identical to v1, rather than panicking on the types whose
// MarshalJSONTo assumes a non-nil receiver.
func WriteField[T json.MarshalerTo](e *jsontext.Encoder, name string, v T) error {
e.WriteToken(jsontext.String(name))
if rv := reflect.ValueOf(v); rv.Kind() == reflect.Pointer && rv.IsNil() {
return e.WriteToken(jsontext.Null)
}
return v.MarshalJSONTo(e)
}
// WriteFieldArray writes the object member name and then the JSON array of items.
func WriteFieldArray[T json.MarshalerTo](e *jsontext.Encoder, name string, items []T) error {
e.WriteToken(jsontext.String(name))
return WriteMarshalerToArray(e, items)
}
// WriteFieldValue is [WriteField] for values that don't implement [json.MarshalerTo].
func WriteFieldValue(e *jsontext.Encoder, name string, v any) error {
e.WriteToken(jsontext.String(name))
return json.MarshalEncode(e, v)
}
// WriteMarshalerToArrayOrNull is [WriteMarshalerToArray] but writes null for a nil
// slice, as encoding/json v1 does. Types whose pre-1.27 MarshalJSON returns "[]"
// for an empty value must keep using [WriteMarshalerToArray].
func WriteMarshalerToArrayOrNull[T json.MarshalerTo](e *jsontext.Encoder, items []T) error {
if items == nil {
return e.WriteToken(jsontext.Null)
}
return WriteMarshalerToArray(e, items)
}
// MarshalMarshalerTo provides a MarshalJSON implementation for any type that
// implements json.MarshalerTo. json.Marshal dispatches to MarshalJSONTo, so this
// doesn't recurse; the constraint is what guarantees that at compile time.
//
// This is the entry point into v2 that establishes v1 options, per the
// package-level comment above.
func MarshalMarshalerTo[T json.MarshalerTo](v T) ([]byte, error) {
return json.Marshal(v, jsonv1.DefaultOptionsV1())
}
+58
View File
@@ -0,0 +1,58 @@
// 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.
//go:build go1.27
package jsonv2
import (
"bytes"
"encoding/json/jsontext"
"strings"
"testing"
)
// widget's MarshalJSONTo assumes a non-nil receiver, mirroring the ast
// package's marshalers, to check that WriteField only calls it when v is
// non-nil.
type widget struct {
Name string
}
func (w *widget) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
e.WriteToken(jsontext.String("name"))
e.WriteToken(jsontext.String(w.Name))
return e.WriteToken(jsontext.EndObject)
}
func TestWriteFieldNilPointer(t *testing.T) {
var buf bytes.Buffer
enc := jsontext.NewEncoder(&buf)
enc.WriteToken(jsontext.BeginObject)
if err := WriteField(enc, "widget", (*widget)(nil)); err != nil {
t.Fatalf("WriteField with nil pointer panicked or errored: %v", err)
}
enc.WriteToken(jsontext.EndObject)
if got, want := strings.TrimSpace(buf.String()), `{"widget":null}`; got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestWriteFieldNonNilPointer(t *testing.T) {
var buf bytes.Buffer
enc := jsontext.NewEncoder(&buf)
enc.WriteToken(jsontext.BeginObject)
if err := WriteField(enc, "widget", &widget{Name: "foo"}); err != nil {
t.Fatalf("WriteField: %v", err)
}
enc.WriteToken(jsontext.EndObject)
if got, want := strings.TrimSpace(buf.String()), `{"widget":{"name":"foo"}}`; got != want {
t.Fatalf("expected %s, got %s", want, got)
}
}
+4 -2
View File
@@ -3,8 +3,10 @@
// license that can be found in the LICENSE file.
//
// NOTE: Different go runtime metrics in pretty much
// every Go version. Let's only test these on latest.
//go:build go1.26
// every Go version. The expected metrics below are those of Go 1.26, so pin
// this test to exactly that version: it fails on both older and newer ones.
//go:build go1.26 && !go1.27
package prometheus
+3 -3
View File
@@ -101,10 +101,10 @@ func Compare(a, b string) int {
return aV.Compare(bV)
}
// AppendText appends the textual representation of the version to b and returns the extended buffer.
// AppendString appends the textual representation of the version to b and returns the extended buffer.
// This method conforms to the encoding.TextAppender interface, and is useful for serializing the Version
// without allocating, provided the caller has pre-allocated sufficient space in b.
func (v Version) AppendText(b []byte) ([]byte, error) {
func (v Version) AppendString(b []byte) ([]byte, error) {
if b == nil {
b = make([]byte, 0, length(v))
}
@@ -126,7 +126,7 @@ func (v Version) AppendText(b []byte) ([]byte, error) {
// String returns the string representation of the version.
func (v Version) String() string {
bs := make([]byte, 0, length(v))
bs, _ = v.AppendText(bs)
bs, _ = v.AppendString(bs)
return string(bs)
}
+4 -4
View File
@@ -161,18 +161,18 @@ func BenchmarkString(b *testing.B) {
}
}
func BenchmarkAppendText(b *testing.B) {
func BenchmarkAppendString(b *testing.B) {
v := MustParse("1.2.3-alpha.1+build.123")
for b.Loop() {
_, err := v.AppendText(nil)
_, err := v.AppendString(nil)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkAppendTextPreAllocated(b *testing.B) {
func BenchmarkAppendStringPreAllocated(b *testing.B) {
v, err := Parse("1.2.3-alpha.1+build.123")
if err != nil {
b.Fatal(err)
@@ -181,7 +181,7 @@ func BenchmarkAppendTextPreAllocated(b *testing.B) {
buf := make([]byte, 0, 32)
for b.Loop() {
if buf, err = v.AppendText(buf); err != nil {
if buf, err = v.AppendString(buf); err != nil {
b.Fatal(err)
}
if string(buf) != "1.2.3-alpha.1+build.123" {
-99
View File
@@ -13,7 +13,6 @@ import (
"strings"
"github.com/open-policy-agent/opa/internal/deepcopy"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
"github.com/open-policy-agent/opa/v1/util"
)
@@ -192,64 +191,6 @@ func (a *Annotations) GetTargetPath() Ref {
}
}
func (a *Annotations) MarshalJSON() ([]byte, error) {
if a == nil {
return []byte(`{"scope":""}`), nil
}
data := map[string]any{
"scope": a.Scope,
}
if a.Title != "" {
data["title"] = a.Title
}
if a.Description != "" {
data["description"] = a.Description
}
if a.Entrypoint {
data["entrypoint"] = a.Entrypoint
}
if len(a.Organizations) > 0 {
data["organizations"] = a.Organizations
}
if len(a.RelatedResources) > 0 {
data["related_resources"] = a.RelatedResources
}
if len(a.Authors) > 0 {
data["authors"] = a.Authors
}
if len(a.Schemas) > 0 {
data["schemas"] = a.Schemas
}
if a.Compile != nil {
data["compile"] = a.Compile
}
if len(a.Custom) > 0 {
data["custom"] = a.Custom
}
if len(a.Labels) > 0 {
data["labels"] = a.Labels
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Annotations {
if a.Location != nil {
data["location"] = a.Location
}
}
return json.Marshal(data)
}
func NewAnnotationsRef(a *Annotations) *AnnotationsRef {
var loc *Location
if a.node != nil {
@@ -284,34 +225,6 @@ func (ar *AnnotationsRef) GetRule() *Rule {
}
}
func (ar *AnnotationsRef) MarshalJSON() ([]byte, error) {
data := map[string]any{
"path": ar.Path,
}
if ar.Annotations != nil {
data["annotations"] = ar.Annotations
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.AnnotationsRef {
if ar.Location != nil {
data["location"] = ar.Location
}
// The location set for the schema ref terms is wrong (always set to
// row 1) and not really useful anyway.. so strip it out before marshalling
for _, schema := range ar.Annotations.Schemas {
if schema.Path != nil {
for _, term := range schema.Path {
term.Location = nil
}
}
}
}
return json.Marshal(data)
}
func scopeCompare(s1, s2 string) int {
o1 := scopeOrder(s1)
o2 := scopeOrder(s2)
@@ -697,18 +610,6 @@ func (rr *RelatedResourceAnnotation) String() string {
return string(bs)
}
func (rr *RelatedResourceAnnotation) MarshalJSON() ([]byte, error) {
d := map[string]any{
"ref": rr.Ref.String(),
}
if len(rr.Description) > 0 {
d["description"] = rr.Description
}
return json.Marshal(d)
}
// Copy returns a deep copy of s.
func (s *SchemaAnnotation) Copy() *SchemaAnnotation {
cpy := *s
+124
View File
@@ -0,0 +1,124 @@
//go:build !go1.27
package ast
import (
"encoding/json"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
)
func (a *Annotations) MarshalJSON() ([]byte, error) {
if a == nil {
return []byte(`{"scope":""}`), nil
}
data := map[string]any{
"scope": a.Scope,
}
if a.Title != "" {
data["title"] = a.Title
}
if a.Description != "" {
data["description"] = a.Description
}
if a.Entrypoint {
data["entrypoint"] = a.Entrypoint
}
if len(a.Organizations) > 0 {
data["organizations"] = a.Organizations
}
if len(a.RelatedResources) > 0 {
data["related_resources"] = a.RelatedResources
}
if len(a.Authors) > 0 {
data["authors"] = a.Authors
}
if len(a.Schemas) > 0 {
data["schemas"] = a.Schemas
}
if a.Compile != nil {
data["compile"] = a.Compile
}
if len(a.Custom) > 0 {
data["custom"] = a.Custom
}
if len(a.Labels) > 0 {
data["labels"] = a.Labels
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Annotations {
if a.Location != nil {
data["location"] = a.Location
}
}
return json.Marshal(data)
}
func (rr *RelatedResourceAnnotation) MarshalJSON() ([]byte, error) {
d := map[string]any{
"ref": rr.Ref.String(),
}
if len(rr.Description) > 0 {
d["description"] = rr.Description
}
return json.Marshal(d)
}
func (ar *AnnotationsRef) MarshalJSON() ([]byte, error) {
data := map[string]any{
"path": ar.Path,
}
if ar.Annotations != nil {
data["annotations"] = ar.Annotations
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.AnnotationsRef {
if ar.Location != nil {
data["location"] = ar.Location
}
}
return json.Marshal(data)
}
// schemaAnnotationJSON mirrors SchemaAnnotation's JSON tags, with location-free
// path terms.
type schemaAnnotationJSON struct {
Path []termJSON `json:"path"`
Schema Ref `json:"schema,omitempty"`
Definition *any `json:"definition,omitempty"`
}
func (s *SchemaAnnotation) MarshalJSON() ([]byte, error) {
d := schemaAnnotationJSON{
Schema: s.Schema,
Definition: s.Definition,
}
if s.Path != nil {
d.Path = make([]termJSON, len(s.Path))
for i, t := range s.Path {
// The location is omitted: path terms are parsed on their own from
// the annotation's YAML key, so their locations are offsets into that
// key (always row 1) rather than positions in the module.
d.Path[i] = termJSON{Type: ValueName(t.Value), Value: t.Value}
}
}
return json.Marshal(d)
}
+195
View File
@@ -0,0 +1,195 @@
//go:build go1.27
package ast
import (
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"github.com/open-policy-agent/opa/internal/jsonv2"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
)
// These are exported types, so losing MarshalJSON here would be a breaking
// API change even though callers should go through json.Marshal, not this
// method directly.
var (
_ json.Marshaler = &Annotations{}
_ json.Marshaler = &AnnotationsRef{}
_ json.Marshaler = &SchemaAnnotation{}
_ json.Marshaler = &RelatedResourceAnnotation{}
)
func (a *Annotations) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if a == nil {
e.WriteToken(jsontext.String("scope"))
e.WriteToken(jsontext.String(""))
return e.WriteToken(jsontext.EndObject)
}
if a.Description != "" {
e.WriteToken(jsontext.String("description"))
e.WriteToken(jsontext.String(a.Description))
}
if a.Entrypoint {
e.WriteToken(jsontext.String("entrypoint"))
e.WriteToken(jsontext.True)
}
if len(a.Organizations) > 0 {
if err := jsonv2.WriteFieldValue(e, "organizations", a.Organizations); err != nil {
return err
}
}
if len(a.RelatedResources) > 0 {
if err := jsonv2.WriteFieldArray(e, "related_resources", a.RelatedResources); err != nil {
return err
}
}
if len(a.Authors) > 0 {
if err := jsonv2.WriteFieldValue(e, "authors", a.Authors); err != nil {
return err
}
}
if len(a.Schemas) > 0 {
if err := jsonv2.WriteFieldArray(e, "schemas", a.Schemas); err != nil {
return err
}
}
if a.Compile != nil {
if err := jsonv2.WriteFieldValue(e, "compile", a.Compile); err != nil {
return err
}
}
if len(a.Custom) > 0 {
if err := jsonv2.WriteFieldValue(e, "custom", a.Custom); err != nil {
return err
}
}
if len(a.Labels) > 0 {
if err := jsonv2.WriteFieldValue(e, "labels", a.Labels); err != nil {
return err
}
}
e.WriteToken(jsontext.String("scope"))
e.WriteToken(jsontext.String(a.Scope))
if a.Title != "" {
e.WriteToken(jsontext.String("title"))
e.WriteToken(jsontext.String(a.Title))
}
if a.Location != nil && astJSON.GetOptions().MarshalOptions.IncludeLocation.Annotations {
if err := jsonv2.WriteField(e, "location", a.Location); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (a *Annotations) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(a)
}
func (ar *AnnotationsRef) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if ar.Annotations != nil {
if err := jsonv2.WriteField(e, "annotations", ar.Annotations); err != nil {
return err
}
}
if ar.Location != nil && astJSON.GetOptions().MarshalOptions.IncludeLocation.AnnotationsRef {
if err := jsonv2.WriteField(e, "location", ar.Location); err != nil {
return err
}
}
if err := jsonv2.WriteField(e, "path", ar.Path); err != nil {
return err
}
return e.WriteToken(jsontext.EndObject)
}
func (ar *AnnotationsRef) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(ar)
}
func (s *SchemaAnnotation) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(s)
}
func (s *SchemaAnnotation) MarshalJSONTo(e *jsontext.Encoder) error {
// Token write errors are unchecked: an unbalanced value fails at the closing
// token. A marshaller can fail having written a balanced value, so is checked.
e.WriteToken(jsontext.BeginObject)
// Path has no omitempty tag, so it's always written. A nil ref is written
// as null, matching encoding/json v1's treatment of a nil slice.
e.WriteToken(jsontext.String("path"))
if s.Path == nil {
e.WriteToken(jsontext.Null)
} else {
e.WriteToken(jsontext.BeginArray)
for _, t := range s.Path {
// The location is omitted: path terms are parsed on their own from
// the annotation's YAML key, so their locations are offsets into that
// key (always row 1) rather than positions in the module.
e.WriteToken(jsontext.BeginObject)
e.WriteToken(jsontext.String("type"))
e.WriteToken(jsontext.String(ValueName(t.Value)))
e.WriteToken(jsontext.String("value"))
if err := marshalValueTo(e, t.Value); err != nil {
return fmt.Errorf("failed to marshal schema path term of %s: %w", ValueName(t.Value), err)
}
e.WriteToken(jsontext.EndObject)
}
e.WriteToken(jsontext.EndArray)
}
if len(s.Schema) > 0 {
if err := jsonv2.WriteField(e, "schema", s.Schema); err != nil {
return err
}
}
if s.Definition != nil {
if err := jsonv2.WriteFieldValue(e, "definition", s.Definition); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (rr *RelatedResourceAnnotation) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(rr)
}
func (rr *RelatedResourceAnnotation) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
e.WriteToken(jsontext.String("ref"))
e.WriteToken(jsontext.String(rr.Ref.String()))
if len(rr.Description) > 0 {
e.WriteToken(jsontext.String("description"))
e.WriteToken(jsontext.String(rr.Description))
}
return e.WriteToken(jsontext.EndObject)
}
+25
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"maps"
"runtime"
"strings"
"testing"
"weak"
)
@@ -1347,3 +1348,27 @@ allow if true
t.Fatal("AnnotationSet was not garbage-collected: mergedLabels cache likely holds a retaining cycle")
}
}
func TestAnnotations_StringDeterministic(t *testing.T) {
a := &Annotations{
Scope: "rule",
Description: "<b>&</b>",
Custom: map[string]any{
"zeta": 1, "alpha": 2, "mu": 3, "beta": 4, "omega": 5,
},
}
exp := a.String()
for i := range 10 {
if got := a.String(); got != exp {
t.Fatalf("String() is not deterministic across calls:\ncall 0: %s\ncall %d: %s", exp, i+1, got)
}
}
if raw := "<b>&</b>"; strings.Contains(exp, raw) {
t.Fatalf("expected HTML characters to be escaped, but found raw %s in %s", raw, exp)
}
if escaped := `\u003cb\u003e\u0026\u003c/b\u003e`; !strings.Contains(exp, escaped) {
t.Fatalf("expected HTML characters to be escaped as %s, got %s", escaped, exp)
}
}
+21
View File
@@ -0,0 +1,21 @@
// 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.
//go:build !go1.27
package ast
import (
"bytes"
"testing"
)
// assertJsonEqual fails the test unless exp and got are byte-for-byte equal.
func assertJsonEqual[A, B string | []byte](t *testing.T, exp A, got B) {
t.Helper()
if !bytes.Equal([]byte(exp), []byte(got)) {
t.Errorf("expected JSON to be equal:\n%s\n%s", exp, got)
}
}
+30
View File
@@ -0,0 +1,30 @@
// 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.
//go:build go1.27
package ast
import (
"bytes"
"encoding/json/jsontext"
"testing"
)
// assertJsonEqual fails the test unless the canonical JSON encoding of exp
// and got are equal, meaning that they are compared without regard to
// things like whitespace, key order, etc. For more details, see
// [jsontext.Value.Canonicalize].
func assertJsonEqual[A, B string | []byte](t *testing.T, exp A, got B) {
t.Helper()
expVal, gotVal := jsontext.Value(exp), jsontext.Value(got)
expVal.Canonicalize()
gotVal.Canonicalize()
if !bytes.Equal(expVal, gotVal) {
t.Errorf("expected JSON to be equal:\n%s\n%s", expVal, gotVal)
}
}
-40
View File
@@ -3,12 +3,10 @@ package location
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"unicode/utf8"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
"github.com/open-policy-agent/opa/v1/util"
)
@@ -150,41 +148,3 @@ func (loc *Location) Compare(other *Location) int {
}
return 0
}
func (loc *Location) MarshalJSON() ([]byte, error) {
// structs are used here to preserve the field ordering of the original Location struct
jsonOptions := astJSON.GetOptions().MarshalOptions
if jsonOptions.ExcludeLocationFile {
data := struct {
Row int `json:"row"`
Col int `json:"col"`
Text []byte `json:"text,omitempty"`
}{
Row: loc.Row,
Col: loc.Col,
}
if jsonOptions.IncludeLocationText {
data.Text = loc.Text
}
return json.Marshal(data)
}
data := struct {
File string `json:"file"`
Row int `json:"row"`
Col int `json:"col"`
Text []byte `json:"text,omitempty"`
}{
Row: loc.Row,
Col: loc.Col,
File: loc.File,
}
if jsonOptions.IncludeLocationText {
data.Text = loc.Text
}
return json.Marshal(data)
}
+47
View File
@@ -0,0 +1,47 @@
//go:build !go1.27
package location
import (
"encoding/json"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
)
func (loc *Location) MarshalJSON() ([]byte, error) {
// structs are used here to preserve the field ordering of the original Location struct
jsonOptions := astJSON.GetOptions().MarshalOptions
if jsonOptions.ExcludeLocationFile {
data := struct {
Row int `json:"row"`
Col int `json:"col"`
Text []byte `json:"text,omitempty"`
}{
Row: loc.Row,
Col: loc.Col,
}
if jsonOptions.IncludeLocationText {
data.Text = loc.Text
}
return json.Marshal(data)
}
data := struct {
File string `json:"file"`
Row int `json:"row"`
Col int `json:"col"`
Text []byte `json:"text,omitempty"`
}{
Row: loc.Row,
Col: loc.Col,
File: loc.File,
}
if jsonOptions.IncludeLocationText {
data.Text = loc.Text
}
return json.Marshal(data)
}
+50
View File
@@ -0,0 +1,50 @@
// 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.
//go:build go1.27
package location
import (
"encoding/base64"
"encoding/json/jsontext"
"encoding/json/v2"
"github.com/open-policy-agent/opa/internal/jsonv2"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
)
// Location is an exported type, so losing MarshalJSON here would be a
// breaking API change even though callers should go through json.Marshal,
// not this method directly.
var _ json.Marshaler = &Location{}
// MarshalJSON returns the JSON encoding of loc.
func (loc *Location) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(loc)
}
func (loc *Location) MarshalJSONTo(e *jsontext.Encoder) (err error) {
e.WriteToken(jsontext.BeginObject)
jsonOptions := astJSON.GetOptions().MarshalOptions
if !jsonOptions.ExcludeLocationFile {
e.WriteToken(jsontext.String("file"))
e.WriteToken(jsontext.String(loc.File))
}
e.WriteToken(jsontext.String("row"))
e.WriteToken(jsontext.Int(int64(loc.Row)))
e.WriteToken(jsontext.String("col"))
e.WriteToken(jsontext.Int(int64(loc.Col)))
// NOTE: len check to match the `json:"text,omitempty"` behaviour of the
// pre-go1.27 marshaller.
if jsonOptions.IncludeLocationText && len(loc.Text) > 0 {
e.WriteToken(jsontext.String("text"))
e.WriteToken(jsontext.String(base64.StdEncoding.EncodeToString(loc.Text)))
}
return e.WriteToken(jsontext.EndObject)
}
+41
View File
@@ -126,6 +126,19 @@ func TestLocationMarshal(t *testing.T) {
},
exp: `{"file":"file","row":1,"col":1,"text":"dGV4dA=="}`,
},
"including text, but no text present": {
loc: &Location{
File: "file",
Row: 1,
Col: 1,
},
options: astJSON.Options{
MarshalOptions: astJSON.MarshalOptions{
IncludeLocationText: true,
},
},
exp: `{"file":"file","row":1,"col":1}`,
},
"excluding file": {
loc: &Location{
File: "file",
@@ -157,6 +170,34 @@ func TestLocationMarshal(t *testing.T) {
}
}
func TestLocationUnmarshal(t *testing.T) {
// Location has no custom unmarshaller on any Go version: decoding goes
// through the struct tags, which means the ignored ("-") fields are not
// populated and unknown keys are tolerated.
in := `{"file":"p.rego","row":1,"col":2,"text":"dGVzdA==","tabs":[1],"unexpected":true}`
var loc Location
if err := util.UnmarshalJSON([]byte(in), &loc); err != nil {
t.Fatal(err)
}
if exp, act := "p.rego", loc.File; exp != act {
t.Errorf("Expected file %q but got %q", exp, act)
}
if exp, act := 1, loc.Row; exp != act {
t.Errorf("Expected row %v but got %v", exp, act)
}
if exp, act := 2, loc.Col; exp != act {
t.Errorf("Expected col %v but got %v", exp, act)
}
if loc.Text != nil {
t.Errorf("Expected no text but got %q", string(loc.Text))
}
if loc.Tabs != nil {
t.Errorf("Expected no tabs but got %v", loc.Tabs)
}
}
func TestLocationString(t *testing.T) {
tests := []struct {
loc *Location
File diff suppressed because it is too large Load Diff
+326
View File
@@ -1,3 +1,5 @@
//go:build !go1.27
package ast
import (
@@ -121,6 +123,10 @@ func TestTerm_MarshalJSON(t *testing.T) {
}(),
ExpectedJSON: `{"type":"string","value":"example"}`,
},
"ref with no parts": {
Term: RefTerm(),
ExpectedJSON: `{"type":"ref","value":null}`,
},
"location excluded": {
Term: func() *Term {
v, _ := InterfaceToValue("example")
@@ -259,6 +265,19 @@ func TestPackage_MarshalJSON(t *testing.T) {
},
ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"path":[]}`,
},
"location included, but nil": {
Package: &Package{
Path: EmptyRef(),
},
Options: astJSON.Options{
MarshalOptions: astJSON.MarshalOptions{
IncludeLocation: astJSON.NodeToggle{
Package: true,
},
},
},
ExpectedJSON: `{"path":[]}`,
},
}
for name, data := range testCases {
@@ -277,6 +296,23 @@ func TestPackage_MarshalJSON(t *testing.T) {
}
}
// TestModule_MarshalJSON_PackageScopedAnnotations asserts that package-scoped
// annotations are only emitted in the module's annotations list, and never
// nested under the package object.
func TestModule_MarshalJSON_PackageScopedAnnotations(t *testing.T) {
module := &Module{
Package: MustParsePackage("package foo"),
Annotations: []*Annotations{{Scope: "package", Title: "pkg"}},
}
exp := `{"package":{"path":[{"type":"var","value":"data"},{"type":"string","value":"foo"}]},` +
`"annotations":[{"scope":"package","title":"pkg"}]}`
if got := string(util.MustMarshalJSON(module)); got != exp {
t.Fatalf("expected:\n%s got\n%s", exp, got)
}
}
// TODO: Comment has inconsistent JSON field names starting with an upper case letter. Comment Location is
// also always included for legacy reasons
func TestComment_MarshalJSON(t *testing.T) {
@@ -622,6 +658,10 @@ func TestExpr_MarshalJSON(t *testing.T) {
Expr: expr,
ExpectedJSON: `{"index":0,"terms":{"type":"boolean","value":true}}`,
},
"nil terms slice": {
Expr: &Expr{Terms: []*Term(nil)},
ExpectedJSON: `{"index":0,"terms":null}`,
},
"location excluded": {
Expr: expr,
Options: astJSON.Options{
@@ -718,6 +758,34 @@ func TestExpr_UnmarshalJSON(t *testing.T) {
}
}
func TestCall_MarshalJSON(t *testing.T) {
testCases := map[string]struct {
Call Call
ExpectedJSON string
}{
"base case": {
Call: Call{VarTerm("eq"), NumberTerm("1")},
ExpectedJSON: `[{"type":"var","value":"eq"},{"type":"number","value":1}]`,
},
"nil call": {
Call: Call(nil),
ExpectedJSON: `null`,
},
}
for name, data := range testCases {
t.Run(name, func(t *testing.T) {
bs := util.MustMarshalJSON(data.Call)
got := string(bs)
exp := data.ExpectedJSON
if got != exp {
t.Fatalf("expected:\n%s got\n%s", exp, got)
}
})
}
}
func TestSomeDecl_MarshalJSON(t *testing.T) {
v, _ := InterfaceToValue("example")
term := &Term{
@@ -737,6 +805,10 @@ func TestSomeDecl_MarshalJSON(t *testing.T) {
},
ExpectedJSON: `{"symbols":[{"type":"string","value":"example"}]}`,
},
"nil symbols": {
SomeDecl: &SomeDecl{},
ExpectedJSON: `{"symbols":null}`,
},
"location excluded": {
SomeDecl: &SomeDecl{
Symbols: []*Term{term},
@@ -1051,6 +1123,18 @@ func TestAnnotationsRef_MarshalJSON(t *testing.T) {
},
ExpectedJSON: `{"annotations":{"scope":""},"location":{"file":"example.rego","row":1,"col":4},"path":[]}`,
},
"no annotations, location included": {
AnnotationsRef: &AnnotationsRef{
Path: []*Term{},
Location: NewLocation([]byte{}, "example.rego", 1, 4),
},
Options: astJSON.Options{
MarshalOptions: astJSON.MarshalOptions{
IncludeLocation: astJSON.NodeToggle{AnnotationsRef: true},
},
},
ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":4},"path":[]}`,
},
}
for name, data := range testCases {
@@ -1431,3 +1515,245 @@ func TestNot_UnmarshalJSON_Errors(t *testing.T) {
})
}
}
func TestArgs_MarshalJSON(t *testing.T) {
x := VarTerm("x").SetLocation(NewLocation([]byte("x"), "example.rego", 1, 2))
testCases := map[string]struct {
Args Args
Options astJSON.Options
ExpectedJSON string
}{
"nil": {
Args: nil,
ExpectedJSON: `null`,
},
"empty": {
Args: Args{},
ExpectedJSON: `[]`,
},
"base case": {
Args: Args{x, VarTerm("y")},
ExpectedJSON: `[{"type":"var","value":"x"},{"type":"var","value":"y"}]`,
},
"term location included": {
Args: Args{x},
Options: astJSON.Options{
MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Term: true}},
},
ExpectedJSON: `[{"location":{"file":"example.rego","row":1,"col":2},"type":"var","value":"x"}]`,
},
}
for name, data := range testCases {
t.Run(name, func(t *testing.T) {
astJSON.SetOptions(data.Options)
t.Cleanup(resetJSONOptions)
bs := util.MustMarshalJSON(data.Args)
got := string(bs)
exp := data.ExpectedJSON
if got != exp {
t.Fatalf("expected:\n%s got\n%s", exp, got)
}
})
}
}
func TestVar_MarshalJSON(t *testing.T) {
testCases := map[string]struct {
Var Var
ExpectedJSON string
}{
"base case": {
Var: Var("x"),
ExpectedJSON: `"x"`,
},
"empty": {
Var: Var(""),
ExpectedJSON: `""`,
},
"wildcard": {
Var: Var("$01"),
ExpectedJSON: `"$01"`,
},
}
for name, data := range testCases {
t.Run(name, func(t *testing.T) {
bs := util.MustMarshalJSON(data.Var)
got := string(bs)
exp := data.ExpectedJSON
if got != exp {
t.Fatalf("expected:\n%s got\n%s", exp, got)
}
})
}
}
func TestAuthorAnnotation_MarshalJSON(t *testing.T) {
testCases := map[string]struct {
Author *AuthorAnnotation
ExpectedJSON string
}{
"base case": {
Author: &AuthorAnnotation{Name: "John Doe", Email: "john@example.com"},
ExpectedJSON: `{"name":"John Doe","email":"john@example.com"}`,
},
"no email": {
Author: &AuthorAnnotation{Name: "John Doe"},
ExpectedJSON: `{"name":"John Doe"}`,
},
"empty": {
Author: &AuthorAnnotation{},
ExpectedJSON: `{"name":""}`,
},
}
for name, data := range testCases {
t.Run(name, func(t *testing.T) {
bs := util.MustMarshalJSON(data.Author)
got := string(bs)
exp := data.ExpectedJSON
if got != exp {
t.Fatalf("expected:\n%s got\n%s", exp, got)
}
})
}
}
func TestSchemaAnnotation_MarshalJSON(t *testing.T) {
loc := NewLocation([]byte("input"), "example.rego", 1, 2)
path := Ref{VarTerm("input").SetLocation(loc), StringTerm("foo").SetLocation(loc)}
definition := any(map[string]any{"type": "boolean"})
testCases := map[string]struct {
Schema *SchemaAnnotation
Options astJSON.Options
ExpectedJSON string
}{
"empty": {
Schema: &SchemaAnnotation{},
ExpectedJSON: `{"path":null}`,
},
"path and schema": {
Schema: &SchemaAnnotation{Path: path, Schema: MustParseRef("schema.foo")},
ExpectedJSON: `{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}],"schema":[{"type":"var","value":"schema"},{"type":"string","value":"foo"}]}`,
},
"path and definition": {
Schema: &SchemaAnnotation{Path: path, Definition: &definition},
ExpectedJSON: `{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}],"definition":{"type":"boolean"}}`,
},
"term location included": {
Schema: &SchemaAnnotation{Path: path},
Options: astJSON.Options{
MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Term: true}},
},
ExpectedJSON: `{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}]}`,
},
}
for name, data := range testCases {
t.Run(name, func(t *testing.T) {
astJSON.SetOptions(data.Options)
t.Cleanup(resetJSONOptions)
bs := util.MustMarshalJSON(data.Schema)
got := string(bs)
exp := data.ExpectedJSON
if got != exp {
t.Fatalf("expected:\n%s got\n%s", exp, got)
}
})
}
t.Run("path terms are not mutated", func(t *testing.T) {
astJSON.SetOptions(astJSON.Options{
MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Term: true, AnnotationsRef: true}},
})
t.Cleanup(resetJSONOptions)
s := &SchemaAnnotation{Path: Ref{VarTerm("input").SetLocation(loc)}}
util.MustMarshalJSON(s)
if s.Path[0].Location != loc {
t.Fatalf("expected path term location to be left alone, got %v", s.Path[0].Location)
}
})
}
func TestModule_UnmarshalJSON(t *testing.T) {
mod := MustParseModule(`package test
p if { q }
q := 1
r := 2 if { input.x } else := 3 if { input.y }
`)
bs := util.MustMarshalJSON(mod)
var roundtrip Module
if err := util.UnmarshalJSON(bs, &roundtrip); err != nil {
t.Fatal(err)
}
if exp, got := len(mod.Rules), len(roundtrip.Rules); exp != got {
t.Fatalf("expected %d rules, got %d", exp, got)
}
WalkRules(&roundtrip, func(rule *Rule) bool {
if rule.Module != &roundtrip {
t.Errorf("rule %v: expected module pointer to be set, got %v", rule.Head, rule.Module)
}
return false
})
}
func TestTemplateString_MarshalJSON(t *testing.T) {
testCases := map[string]struct {
TemplateString *TemplateString
ExpectedJSON string
}{
"nil parts": {
TemplateString: &TemplateString{},
ExpectedJSON: `{"parts":null,"multi_line":false}`,
},
"empty parts": {
TemplateString: &TemplateString{Parts: []Node{}},
ExpectedJSON: `{"parts":[],"multi_line":false}`,
},
"base case": {
TemplateString: &TemplateString{Parts: []Node{StringTerm("foo"), VarTerm("x")}, MultiLine: true},
ExpectedJSON: `{"parts":[{"type":"string","value":"foo"},{"type":"var","value":"x"}],"multi_line":true}`,
},
}
for name, data := range testCases {
t.Run(name, func(t *testing.T) {
bs := util.MustMarshalJSON(data.TemplateString)
got := string(bs)
exp := data.ExpectedJSON
if got != exp {
t.Fatalf("expected:\n%s got\n%s", exp, got)
}
})
}
}
func TestSchemaAnnotation_MarshalJSON_InvalidDefinition(t *testing.T) {
definition := any(func() {})
_, err := json.Marshal(&SchemaAnnotation{Path: MustParseRef("input.x"), Definition: &definition})
if err == nil {
t.Fatal("expected error")
}
// The encoder's wording differs between encoding/json v1 and v2.
if exp := "func()"; !strings.Contains(err.Error(), exp) {
t.Fatalf("expected error containing %q, got: %v", exp, err)
}
}
-321
View File
@@ -6,7 +6,6 @@ package ast
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
@@ -409,26 +408,6 @@ func (mod *Module) RuleSet(name Var) RuleSet {
return rs
}
// UnmarshalJSON parses bs and stores the result in mod. The rules in the module
// will have their module pointer set to mod.
func (mod *Module) UnmarshalJSON(bs []byte) error {
// Declare a new type and use a type conversion to avoid recursively calling
// Module#UnmarshalJSON.
type module Module
if err := util.UnmarshalJSON(bs, (*module)(mod)); err != nil {
return err
}
WalkRules(mod, func(rule *Rule) bool {
rule.Module = mod
return false
})
return nil
}
func (mod *Module) regoV1Compatible() bool {
return mod.regoVersion == RegoV1 || mod.regoVersion == RegoV0CompatV1
}
@@ -519,20 +498,6 @@ func (pkg *Package) String() string {
return util.ByteSliceToString(buf)
}
func (pkg *Package) MarshalJSON() ([]byte, error) {
data := map[string]any{
"path": pkg.Path,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Package {
if pkg.Location != nil {
data["location"] = pkg.Location
}
}
return json.Marshal(data)
}
// IsValidImportPath returns an error indicating if the import path is invalid.
// If the import path is valid, err is nil.
func IsValidImportPath(v Value) (err error) {
@@ -623,24 +588,6 @@ func (imp *Import) String() string {
return util.ByteSliceToString(buf)
}
func (imp *Import) MarshalJSON() ([]byte, error) {
data := map[string]any{
"path": imp.Path,
}
if len(imp.Alias) != 0 {
data["alias"] = imp.Alias
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Import {
if imp.Location != nil {
data["location"] = imp.Location
}
}
return json.Marshal(data)
}
// Compare returns an integer indicating whether rule is less than, equal to,
// or greater than other.
func (rule *Rule) Compare(other *Rule) int {
@@ -754,42 +701,6 @@ func (rule *Rule) isFunction() bool {
return len(rule.Head.Args) > 0
}
// ruleJSON is used for JSON serialization of Rule to avoid map allocation overhead.
// Field order is alphabetical to match previous map-based output.
type ruleJSON struct {
Annotations []*Annotations `json:"annotations,omitempty"`
Body Body `json:"body"`
Default bool `json:"default,omitempty"`
Else *Rule `json:"else,omitempty"`
Head *Head `json:"head"`
Location *Location `json:"location,omitempty"`
}
func (rule *Rule) MarshalJSON() ([]byte, error) {
data := ruleJSON{
Head: rule.Head,
Body: rule.Body,
}
if rule.Default {
data.Default = true
}
if rule.Else != nil {
data.Else = rule.Else
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Rule {
data.Location = rule.Location
}
if len(rule.Annotations) != 0 {
data.Annotations = rule.Annotations
}
return json.Marshal(data)
}
// NewHead returns a new Head object. If args are provided, the first will be
// used for the key and the second will be used for the value.
func NewHead(name Var, args ...*Term) *Head {
@@ -952,27 +863,6 @@ func (head *Head) stringWithOpts(opts toStringOpts) string {
return util.ByteSliceToString(buf)
}
func (head *Head) MarshalJSON() ([]byte, error) {
var loc *Location
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Head && head.Location != nil {
loc = head.Location
}
// NOTE(sr): we do this to override the rendering of `head.Reference`.
// It's still what'll be used via the default means of encoding/json
// for unmarshaling a json object into a Head struct!
type h Head
return json.Marshal(struct {
h
Ref Ref `json:"ref"`
Location *Location `json:"location,omitempty"`
}{
h: h(*head),
Ref: head.Ref(),
Location: loc,
})
}
// Vars returns a set of vars found in the head.
func (head *Head) Vars() VarSet {
vis := NewVarVisitor()
@@ -1051,17 +941,6 @@ func NewBody(exprs ...*Expr) Body {
return Body(exprs)
}
// MarshalJSON returns JSON encoded bytes representing body.
func (body Body) MarshalJSON() ([]byte, error) {
// Serialize empty Body to empty array. This handles both the empty case and the
// nil case (whereas by default the result would be null if body was nil.)
if len(body) == 0 {
return []byte(`[]`), nil
}
ret, err := json.Marshal([]*Expr(body))
return ret, err
}
// Append adds the expr to the body and updates the expr's index accordingly.
func (body *Body) Append(expr *Expr) {
n := len(*body)
@@ -1142,10 +1021,6 @@ func (body Body) String() string {
return util.ByteSliceToString(buf)
}
func (body Body) AppendText(buf []byte) ([]byte, error) {
return AppendDelimeted(buf, body, "; ")
}
// Vars returns a VarSet containing variables in body. The params can be set to
// control which vars are included.
func (body Body) Vars(params VarVisitorParams) VarSet {
@@ -1521,51 +1396,6 @@ func (expr *Expr) String() string {
return util.ByteSliceToString(buf)
}
// exprJSON is used for JSON serialization of Expr to avoid map allocation overhead.
// Field order is alphabetical to match previous map-based output.
type exprJSON struct {
Generated bool `json:"generated,omitempty"`
Index int `json:"index"`
Location *Location `json:"location,omitempty"`
Negated bool `json:"negated,omitempty"`
Terms any `json:"terms"`
With []*With `json:"with,omitempty"`
}
func (expr *Expr) MarshalJSON() ([]byte, error) {
data := exprJSON{
Index: expr.Index,
Terms: expr.Terms,
}
if len(expr.With) > 0 {
data.With = expr.With
}
if expr.Generated {
data.Generated = true
}
if expr.Negated {
data.Negated = true
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Expr {
data.Location = expr.Location
}
return json.Marshal(data)
}
// UnmarshalJSON parses the byte array and stores the result in expr.
func (expr *Expr) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalExpr(expr, v)
}
// Vars returns a VarSet containing variables in expr. The params can be set to
// control which vars are included.
func (expr *Expr) Vars(params VarVisitorParams) VarSet {
@@ -1654,20 +1484,6 @@ func (d *SomeDecl) Hash() int {
return termSliceHash(d.Symbols)
}
func (d *SomeDecl) MarshalJSON() ([]byte, error) {
data := map[string]any{
"symbols": d.Symbols,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.SomeDecl {
if d.Location != nil {
data["location"] = d.Location
}
}
return json.Marshal(data)
}
func (q *Every) String() string {
if q.Key != nil {
return fmt.Sprintf("every %s, %s in %s { %s }",
@@ -1724,23 +1540,6 @@ func (q *Every) KeyValueVars() VarSet {
return vis.vars
}
func (q *Every) MarshalJSON() ([]byte, error) {
data := map[string]any{
"key": q.Key,
"value": q.Value,
"domain": q.Domain,
"body": q.Body,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Every {
if q.Location != nil {
data["location"] = q.Location
}
}
return json.Marshal(data)
}
func (a *LogicalAnd) String() string {
return formatBinaryLogical("and", a.Lhs, a.Rhs, a.ExplicitLhs, a.ExplicitRhs)
}
@@ -1774,36 +1573,6 @@ func (a *LogicalAnd) Hash() int {
return a.Lhs.Hash() + a.Rhs.Hash()
}
func (a *LogicalAnd) MarshalJSON() ([]byte, error) {
data := map[string]any{
"type": "and",
"lhs": a.Lhs,
"rhs": a.Rhs,
}
if a.ExplicitLhs {
data["explicit_lhs"] = true
}
if a.ExplicitRhs {
data["explicit_rhs"] = true
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.And {
if a.Location != nil {
data["location"] = a.Location
}
}
return json.Marshal(data)
}
func (a *LogicalAnd) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalLogical("and", &a.Lhs, &a.Rhs, &a.ExplicitLhs, &a.ExplicitRhs, v)
}
func (o *LogicalOr) String() string {
return formatBinaryLogical("or", o.Lhs, o.Rhs, o.ExplicitLhs, o.ExplicitRhs)
}
@@ -1837,75 +1606,6 @@ func (o *LogicalOr) Hash() int {
return o.Lhs.Hash() + o.Rhs.Hash()
}
func (o *LogicalOr) MarshalJSON() ([]byte, error) {
data := map[string]any{
"type": "or",
"lhs": o.Lhs,
"rhs": o.Rhs,
}
if o.ExplicitLhs {
data["explicit_lhs"] = true
}
if o.ExplicitRhs {
data["explicit_rhs"] = true
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Or {
if o.Location != nil {
data["location"] = o.Location
}
}
return json.Marshal(data)
}
func (o *LogicalOr) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalLogical("or", &o.Lhs, &o.Rhs, &o.ExplicitLhs, &o.ExplicitRhs, v)
}
func unmarshalLogical(typeName string, lhs, rhs *Body, explicitLhs, explicitRhs *bool, v map[string]any) error {
lhsRaw, ok := v["lhs"].([]any)
if !ok {
return fmt.Errorf("ast: unable to unmarshal %s, invalid lhs field type: %T (expected list)", typeName, v["lhs"])
}
l, err := unmarshalBody(lhsRaw)
if err != nil {
return fmt.Errorf("ast: unable to unmarshal %s lhs: %w", typeName, err)
}
*lhs = l
rhsRaw, ok := v["rhs"].([]any)
if !ok {
return fmt.Errorf("ast: unable to unmarshal %s, invalid rhs field type: %T (expected list)", typeName, v["rhs"])
}
r, err := unmarshalBody(rhsRaw)
if err != nil {
return fmt.Errorf("ast: unable to unmarshal %s rhs: %w", typeName, err)
}
*rhs = r
if x, ok := v["explicit_lhs"]; ok {
b, ok := x.(bool)
if !ok {
return fmt.Errorf("ast: unable to unmarshal %s explicit_lhs field with type: %T (expected true or false)", typeName, x)
}
*explicitLhs = b
}
if x, ok := v["explicit_rhs"]; ok {
b, ok := x.(bool)
if !ok {
return fmt.Errorf("ast: unable to unmarshal %s explicit_rhs field with type: %T (expected true or false)", typeName, x)
}
*explicitRhs = b
}
return nil
}
func formatBinaryLogical(op string, lhs, rhs Body, explicitLhs, explicitRhs bool) string {
return formatLogicalOperand(lhs, explicitLhs, op, false) + " " + op + " " + formatLogicalOperand(rhs, explicitRhs, op, true)
}
@@ -2023,27 +1723,6 @@ func (w *With) SetLoc(loc *Location) {
w.Location = loc
}
// withJSON is used for JSON serialization of With to avoid map allocation overhead.
// Field order is alphabetical to match previous map-based output.
type withJSON struct {
Location *Location `json:"location,omitempty"`
Target *Term `json:"target"`
Value *Term `json:"value"`
}
func (w *With) MarshalJSON() ([]byte, error) {
data := withJSON{
Target: w.Target,
Value: w.Value,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.With {
data.Location = w.Location
}
return json.Marshal(data)
}
// Copy returns a deep copy of the AST node x. If x is not an AST node, x is returned unmodified.
func Copy(x any) any {
switch x := x.(type) {
+4
View File
@@ -223,6 +223,10 @@ func (a Args) AppendText(buf []byte) ([]byte, error) {
return append(buf, ')'), nil
}
func (body Body) AppendText(buf []byte) ([]byte, error) {
return AppendDelimeted(buf, body, "; ")
}
func (expr *Expr) AppendText(buf []byte) ([]byte, error) {
if expr.Negated {
buf = append(buf, "not "...)
+28
View File
@@ -253,3 +253,31 @@ func BenchmarkNoNodeTypeAllocatesOnAppend(b *testing.B) {
})
}
}
// TestModuleStringAnnotationsDeterministic guards against a regression where
// Module#String, which renders "# METADATA" comments by calling
// Annotations#String (see Module#AppendText), produced a different rendering
// of a metadata annotation's custom/labels map on every call, because the
// underlying marshaler didn't fix the map key order. That would make repeated
// formatting of the same module non-idempotent.
func TestModuleStringAnnotationsDeterministic(t *testing.T) {
module := ast.MustParseModuleWithOpts(`# METADATA
# title: p
# custom:
# zeta: 1
# alpha: 2
# mu: 3
# beta: 4
# omega: 5
package p
r = true`,
ast.ParserOptions{ProcessAnnotation: true})
exp := module.String()
for i := range 10 {
if got := module.String(); got != exp {
t.Fatalf("Module#String is not deterministic across calls:\ncall 0: %s\ncall %d: %s", exp, i+1, got)
}
}
}
+289
View File
@@ -0,0 +1,289 @@
//go:build !go1.27
package ast
import (
"encoding/json"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
"github.com/open-policy-agent/opa/v1/util"
)
// ruleJSON is used for JSON serialization of Rule to avoid map allocation overhead.
// Field order is alphabetical to match previous map-based output.
type ruleJSON struct {
Annotations []*Annotations `json:"annotations,omitempty"`
Body Body `json:"body"`
Default bool `json:"default,omitempty"`
Else *Rule `json:"else,omitempty"`
Head *Head `json:"head"`
Location *Location `json:"location,omitempty"`
}
// exprJSON is used for JSON serialization of Expr to avoid map allocation overhead.
// Field order is alphabetical to match previous map-based output.
type exprJSON struct {
Generated bool `json:"generated,omitempty"`
Index int `json:"index"`
Location *Location `json:"location,omitempty"`
Negated bool `json:"negated,omitempty"`
Terms any `json:"terms"`
With []*With `json:"with,omitempty"`
}
// withJSON is used for JSON serialization of With to avoid map allocation overhead.
// Field order is alphabetical to match previous map-based output.
type withJSON struct {
Location *Location `json:"location,omitempty"`
Target *Term `json:"target"`
Value *Term `json:"value"`
}
// UnmarshalJSON parses bs and stores the result in mod. The rules in the module
// will have their module pointer set to mod.
func (mod *Module) UnmarshalJSON(bs []byte) error {
// Declare a new type and use a type conversion to avoid recursively calling
// Module#UnmarshalJSON.
type module Module
if err := util.UnmarshalJSON(bs, (*module)(mod)); err != nil {
return err
}
// The decoded rules have no module pointer, as it isn't part of the JSON
// representation; without this, an unmarshalled module can't be compiled.
WalkRules(mod, func(rule *Rule) bool {
rule.Module = mod
return false
})
return nil
}
func (d *SomeDecl) MarshalJSON() ([]byte, error) {
data := map[string]any{
"symbols": d.Symbols,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.SomeDecl {
if d.Location != nil {
data["location"] = d.Location
}
}
return json.Marshal(data)
}
func (q *Every) MarshalJSON() ([]byte, error) {
data := map[string]any{
"key": q.Key,
"value": q.Value,
"domain": q.Domain,
"body": q.Body,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Every {
if q.Location != nil {
data["location"] = q.Location
}
}
return json.Marshal(data)
}
func (a *LogicalAnd) MarshalJSON() ([]byte, error) {
data := map[string]any{
"type": "and",
"lhs": a.Lhs,
"rhs": a.Rhs,
}
if a.ExplicitLhs {
data["explicit_lhs"] = true
}
if a.ExplicitRhs {
data["explicit_rhs"] = true
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.And {
if a.Location != nil {
data["location"] = a.Location
}
}
return json.Marshal(data)
}
func (a *LogicalAnd) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalLogical("and", &a.Lhs, &a.Rhs, &a.ExplicitLhs, &a.ExplicitRhs, v)
}
func (o *LogicalOr) MarshalJSON() ([]byte, error) {
data := map[string]any{
"type": "or",
"lhs": o.Lhs,
"rhs": o.Rhs,
}
if o.ExplicitLhs {
data["explicit_lhs"] = true
}
if o.ExplicitRhs {
data["explicit_rhs"] = true
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Or {
if o.Location != nil {
data["location"] = o.Location
}
}
return json.Marshal(data)
}
func (o *LogicalOr) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalLogical("or", &o.Lhs, &o.Rhs, &o.ExplicitLhs, &o.ExplicitRhs, v)
}
// UnmarshalJSON parses the byte array and stores the result in expr.
func (expr *Expr) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalExpr(expr, v)
}
func (expr *Expr) MarshalJSON() ([]byte, error) {
data := exprJSON{
Index: expr.Index,
Terms: expr.Terms,
}
if len(expr.With) > 0 {
data.With = expr.With
}
if expr.Generated {
data.Generated = true
}
if expr.Negated {
data.Negated = true
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Expr {
data.Location = expr.Location
}
return json.Marshal(data)
}
func (w *With) MarshalJSON() ([]byte, error) {
data := withJSON{
Target: w.Target,
Value: w.Value,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.With {
data.Location = w.Location
}
return json.Marshal(data)
}
func (pkg *Package) MarshalJSON() ([]byte, error) {
data := map[string]any{
"path": pkg.Path,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Package {
if pkg.Location != nil {
data["location"] = pkg.Location
}
}
return json.Marshal(data)
}
func (imp *Import) MarshalJSON() ([]byte, error) {
data := map[string]any{
"path": imp.Path,
}
if len(imp.Alias) != 0 {
data["alias"] = imp.Alias
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Import {
if imp.Location != nil {
data["location"] = imp.Location
}
}
return json.Marshal(data)
}
func (rule *Rule) MarshalJSON() ([]byte, error) {
data := ruleJSON{
Head: rule.Head,
Body: rule.Body,
}
if rule.Default {
data.Default = true
}
if rule.Else != nil {
data.Else = rule.Else
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Rule {
data.Location = rule.Location
}
if len(rule.Annotations) != 0 {
data.Annotations = rule.Annotations
}
return json.Marshal(data)
}
func (head *Head) MarshalJSON() ([]byte, error) {
var loc *Location
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Head && head.Location != nil {
loc = head.Location
}
// NOTE(sr): we do this to override the rendering of `head.Reference`.
// It's still what'll be used via the default means of encoding/json
// for unmarshaling a json object into a Head struct!
type h Head
return json.Marshal(struct {
h
Ref Ref `json:"ref"`
Location *Location `json:"location,omitempty"`
}{
h: h(*head),
Ref: head.Ref(),
Location: loc,
})
}
// MarshalJSON returns JSON encoded bytes representing body.
func (body Body) MarshalJSON() ([]byte, error) {
// Serialize empty Body to empty array. This handles both the empty case and the
// nil case (whereas by default the result would be null if body was nil.)
if len(body) == 0 {
return []byte(`[]`), nil
}
ret, err := json.Marshal([]*Expr(body))
return ret, err
}
+524
View File
@@ -0,0 +1,524 @@
//go:build go1.27
package ast
import (
"encoding/base64"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"github.com/open-policy-agent/opa/internal/jsonv2"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
"github.com/open-policy-agent/opa/v1/util"
)
var (
_ json.Unmarshaler = &Module{}
// These are exported types, so losing MarshalJSON here would be a breaking
// API change even though callers should go through json.Marshal, not this
// method directly.
_ json.Marshaler = Body{}
_ json.Marshaler = &Expr{}
_ json.Marshaler = &Package{}
_ json.Marshaler = &Import{}
_ json.Marshaler = &Rule{}
_ json.Marshaler = &Head{}
_ json.Marshaler = &With{}
_ json.Marshaler = &SomeDecl{}
_ json.Marshaler = &Every{}
_ json.Marshaler = &LogicalAnd{}
_ json.Marshaler = &LogicalOr{}
)
// UnmarshalJSON parses bs and stores the result in mod. The rules in the module
// will have their module pointer set to mod.
func (mod *Module) UnmarshalJSON(bs []byte) error {
// Declare a new type and use a type conversion to avoid recursively calling
// Module#UnmarshalJSON.
type module Module
if err := util.UnmarshalJSON(bs, (*module)(mod)); err != nil {
return err
}
// The decoded rules have no module pointer, as it isn't part of the JSON
// representation; without this, an unmarshalled module can't be compiled.
WalkRules(mod, func(rule *Rule) bool {
rule.Module = mod
return false
})
return nil
}
// MarshalJSONTo is here to ensure that we do not fall down to TextAppender,
// which Go 1.27's encoding/json would otherwise use, encoding args as the Rego
// representation of the argument list rather than as a JSON array.
func (a Args) MarshalJSONTo(e *jsontext.Encoder) error {
return jsonv2.WriteMarshalerToArrayOrNull(e, a)
}
// MarshalJSONTo is here to ensure that we do not fall down to TextAppender,
// which Go 1.27's encoding/json would otherwise use, encoding the module as
// Rego source rather than as JSON. Module's own fields are fully described by
// their struct tags, so the encoding is left to them, as it is pre-1.27. The
// field types provide their own MarshalJSONTo where one is needed.
func (m *Module) MarshalJSONTo(e *jsontext.Encoder) error {
// Declare a new type and use a type conversion to avoid recursively calling
// Module#MarshalJSONTo. It's the highest precedence marshaller, so there is
// nothing below it to fall to, and the new type has no methods of its own.
type module Module
return json.MarshalEncode(e, (*module)(m))
}
func (pkg *Package) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Package && pkg.Location != nil {
if err := jsonv2.WriteField(e, "location", pkg.Location); err != nil {
return err
}
}
if err := jsonv2.WriteField(e, "path", pkg.Path); err != nil {
return err
}
return e.WriteToken(jsontext.EndObject)
}
func (i *Import) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if err := jsonv2.WriteField(e, "path", i.Path); err != nil {
return err
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Import && i.Location != nil {
if err := jsonv2.WriteField(e, "location", i.Location); err != nil {
return err
}
}
if len(i.Alias) > 0 {
e.WriteToken(jsontext.String("alias"))
e.WriteToken(jsontext.String(string(i.Alias)))
}
return e.WriteToken(jsontext.EndObject)
}
func (r *Rule) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if r.Default {
e.WriteToken(jsontext.String("default"))
e.WriteToken(jsontext.True)
}
if r.Else != nil {
if err := jsonv2.WriteField(e, "else", r.Else); err != nil {
return err
}
}
if err := jsonv2.WriteField(e, "head", r.Head); err != nil {
return err
}
if err := jsonv2.WriteField(e, "body", r.Body); err != nil {
return err
}
if len(r.Annotations) > 0 {
if err := jsonv2.WriteFieldArray(e, "annotations", r.Annotations); err != nil {
return err
}
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Rule && r.Location != nil {
if err := jsonv2.WriteField(e, "location", r.Location); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (h *Head) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if h.Name != "" {
e.WriteToken(jsontext.String("name"))
e.WriteToken(jsontext.String(string(h.Name)))
}
if err := jsonv2.WriteField(e, "ref", h.Ref()); err != nil {
return err
}
if len(h.Args) > 0 {
if err := jsonv2.WriteFieldArray(e, "args", h.Args); err != nil {
return err
}
}
if h.Key != nil {
if err := jsonv2.WriteField(e, "key", h.Key); err != nil {
return err
}
}
if h.Value != nil {
if err := jsonv2.WriteField(e, "value", h.Value); err != nil {
return err
}
}
if h.Assign {
e.WriteToken(jsontext.String("assign"))
e.WriteToken(jsontext.True)
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Head && h.Location != nil {
if err := jsonv2.WriteField(e, "location", h.Location); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (c Call) MarshalJSONTo(e *jsontext.Encoder) (err error) {
return jsonv2.WriteMarshalerToArrayOrNull(e, c)
}
func (c *Comment) MarshalJSONTo(e *jsontext.Encoder) error {
// Token write errors are unchecked: an unbalanced value fails at the closing
// token. A marshaller can fail having written a balanced value, so is checked.
e.WriteToken(jsontext.BeginObject)
// Comment has no JSON tags, hence the capitalised keys, the base64 encoded
// text, and the location being written even when it's nil.
e.WriteToken(jsontext.String("Text"))
buf := make([]byte, base64.StdEncoding.EncodedLen(len(c.Text)))
base64.StdEncoding.Encode(buf, c.Text)
e.WriteValue(append(append(append(e.AvailableBuffer(), '"'), buf...), '"'))
e.WriteToken(jsontext.String("Location"))
if c.Location != nil {
if err := c.Location.MarshalJSONTo(e); err != nil {
return err
}
} else {
e.WriteToken(jsontext.Null)
}
return e.WriteToken(jsontext.EndObject)
}
func (q *Every) MarshalJSONTo(e *jsontext.Encoder) error {
// Token write errors are unchecked: an unbalanced value fails at the closing
// token. A marshaller can fail having written a balanced value, so is checked.
e.WriteToken(jsontext.BeginObject)
e.WriteToken(jsontext.String("key"))
if q.Key == nil {
e.WriteToken(jsontext.Null)
} else {
if err := q.Key.MarshalJSONTo(e); err != nil {
return err
}
}
if err := jsonv2.WriteField(e, "value", q.Value); err != nil {
return err
}
if err := jsonv2.WriteField(e, "domain", q.Domain); err != nil {
return err
}
if err := jsonv2.WriteField(e, "body", q.Body); err != nil {
return err
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Every && q.Location != nil {
if err := jsonv2.WriteField(e, "location", q.Location); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (b Body) MarshalJSONTo(e *jsontext.Encoder) error {
return jsonv2.WriteMarshalerToArray(e, b)
}
// MarshalJSON returns JSON encoded bytes representing body.
func (body Body) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(body)
}
func (expr *Expr) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(expr)
}
// UnmarshalJSON parses the byte array and stores the result in expr.
func (expr *Expr) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalExpr(expr, v)
}
func (e *Expr) MarshalJSONTo(enc *jsontext.Encoder) error {
enc.WriteToken(jsontext.BeginObject)
enc.WriteToken(jsontext.String("index"))
enc.WriteToken(jsontext.Int(int64(e.Index)))
includeLocation := astJSON.GetOptions().MarshalOptions.IncludeLocation
if e.Location != nil && includeLocation.Expr {
if err := jsonv2.WriteField(enc, "location", e.Location); err != nil {
return err
}
}
if e.Negated {
enc.WriteToken(jsontext.String("negated"))
enc.WriteToken(jsontext.True)
}
if e.Generated {
enc.WriteToken(jsontext.String("generated"))
enc.WriteToken(jsontext.True)
}
enc.WriteToken(jsontext.String("terms"))
var err error
switch t := e.Terms.(type) {
case []*Term:
err = jsonv2.WriteMarshalerToArrayOrNull(enc, t)
case json.MarshalerTo:
err = t.MarshalJSONTo(enc)
default:
return fmt.Errorf("unsupported expr terms type: %T", e.Terms)
}
if err != nil {
return fmt.Errorf("failed to marshal expr terms: %w", err)
}
if len(e.With) > 0 {
if err := jsonv2.WriteFieldArray(enc, "with", e.With); err != nil {
return err
}
}
return enc.WriteToken(jsontext.EndObject)
}
func (a *LogicalAnd) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
e.WriteToken(jsontext.String("type"))
e.WriteToken(jsontext.String("and"))
if err := jsonv2.WriteField(e, "lhs", a.Lhs); err != nil {
return err
}
if err := jsonv2.WriteField(e, "rhs", a.Rhs); err != nil {
return err
}
if a.ExplicitLhs {
e.WriteToken(jsontext.String("explicit_lhs"))
e.WriteToken(jsontext.True)
}
if a.ExplicitRhs {
e.WriteToken(jsontext.String("explicit_rhs"))
e.WriteToken(jsontext.True)
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.And && a.Location != nil {
if err := jsonv2.WriteField(e, "location", a.Location); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (a *LogicalAnd) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalLogical("and", &a.Lhs, &a.Rhs, &a.ExplicitLhs, &a.ExplicitRhs, v)
}
func (o *LogicalOr) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
e.WriteToken(jsontext.String("type"))
e.WriteToken(jsontext.String("or"))
if err := jsonv2.WriteField(e, "lhs", o.Lhs); err != nil {
return err
}
if err := jsonv2.WriteField(e, "rhs", o.Rhs); err != nil {
return err
}
if o.ExplicitLhs {
e.WriteToken(jsontext.String("explicit_lhs"))
e.WriteToken(jsontext.True)
}
if o.ExplicitRhs {
e.WriteToken(jsontext.String("explicit_rhs"))
e.WriteToken(jsontext.True)
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Or && o.Location != nil {
if err := jsonv2.WriteField(e, "location", o.Location); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (o *LogicalOr) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalLogical("or", &o.Lhs, &o.Rhs, &o.ExplicitLhs, &o.ExplicitRhs, v)
}
func (w *With) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if err := jsonv2.WriteField(e, "target", w.Target); err != nil {
return err
}
if err := jsonv2.WriteField(e, "value", w.Value); err != nil {
return err
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.With && w.Location != nil {
if err := jsonv2.WriteField(e, "location", w.Location); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (d *SomeDecl) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
e.WriteToken(jsontext.String("symbols"))
if err := jsonv2.WriteMarshalerToArrayOrNull(e, d.Symbols); err != nil {
return err
}
if d.Location != nil && astJSON.GetOptions().MarshalOptions.IncludeLocation.SomeDecl {
if err := jsonv2.WriteField(e, "location", d.Location); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (ac *ArrayComprehension) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if err := jsonv2.WriteField(e, "term", ac.Term); err != nil {
return err
}
if err := jsonv2.WriteField(e, "body", ac.Body); err != nil {
return err
}
return e.WriteToken(jsontext.EndObject)
}
func (sc *SetComprehension) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if err := jsonv2.WriteField(e, "term", sc.Term); err != nil {
return err
}
if err := jsonv2.WriteField(e, "body", sc.Body); err != nil {
return err
}
return e.WriteToken(jsontext.EndObject)
}
func (oc *ObjectComprehension) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
if err := jsonv2.WriteField(e, "key", oc.Key); err != nil {
return err
}
if err := jsonv2.WriteField(e, "value", oc.Value); err != nil {
return err
}
if err := jsonv2.WriteField(e, "body", oc.Body); err != nil {
return err
}
return e.WriteToken(jsontext.EndObject)
}
func (pkg *Package) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(pkg)
}
func (imp *Import) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(imp)
}
func (rule *Rule) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(rule)
}
func (head *Head) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(head)
}
func (w *With) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(w)
}
func (d *SomeDecl) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(d)
}
func (q *Every) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(q)
}
func (a *LogicalAnd) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(a)
}
func (o *LogicalOr) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(o)
}
+4 -8
View File
@@ -498,10 +498,8 @@ func TestLogicalAnd_MarshalJSON(t *testing.T) {
astJSON.SetOptions(tc.options)
t.Cleanup(resetJSONOptions)
got := string(util.MustMarshalJSON(tc.node))
if got != tc.want {
t.Fatalf("MarshalJSON:\nwant: %s\ngot: %s", tc.want, got)
}
got := util.MustMarshalJSON(tc.node)
assertJsonEqual(t, tc.want, got)
})
}
}
@@ -568,10 +566,8 @@ func TestLogicalOr_MarshalJSON(t *testing.T) {
astJSON.SetOptions(tc.options)
t.Cleanup(resetJSONOptions)
got := string(util.MustMarshalJSON(tc.node))
if got != tc.want {
t.Fatalf("MarshalJSON:\nwant: %s\ngot: %s", tc.want, got)
}
got := util.MustMarshalJSON(tc.node)
assertJsonEqual(t, tc.want, got)
})
}
}
+5 -10
View File
@@ -454,9 +454,8 @@ func TestRuleHeadJSON(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if exp, act := `{"body":[],"head":{"name":"allow","ref":[{"type":"var","value":"allow"}]}}`, string(bs); act != exp {
t.Errorf("expected %q, got %q", exp, act)
}
exp := []byte(`{"body":[],"head":{"name":"allow","ref":[{"type":"var","value":"allow"}]}}`)
assertJsonEqual(t, exp, bs)
var readRule Rule
if err := json.Unmarshal(bs, &readRule); err != nil {
@@ -469,9 +468,8 @@ func TestRuleHeadJSON(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if exp, act := string(bs), string(bs0); exp != act {
t.Errorf("expected json repr to match %q, got %q", exp, act)
}
assertJsonEqual(t, bs, bs0)
var readAgainRule Rule
if err := json.Unmarshal(bs, &readAgainRule); err != nil {
@@ -928,10 +926,7 @@ func TestAnnotationsString(t *testing.T) {
// NOTE(tsandall): for now, annotations are represented as JSON objects
// which are a subset of YAML. We could improve this in the future.
exp := `{"authors":[{"name":"John Doe","email":"john@example.com"},{"name":"Jane Doe"}],"custom":{"flag":true,"list":[1,2,3],"map":{"one":1,"two":{"3":"three"}}},"description":"baz","organizations":["mi","fa"],"related_resources":[{"ref":"https://example.com"},{"description":"Some resource","ref":"https://example.com/2"}],"schemas":[{"path":[{"type":"var","value":"data"},{"type":"string","value":"bar"}],"schema":[{"type":"var","value":"schema"},{"type":"string","value":"baz"}]}],"scope":"foo","title":"bar"}`
if got := a.String(); exp != got {
t.Fatalf("expected\n%s\nbut got\n%s", exp, got)
}
assertJsonEqual(t, exp, a.String())
}
func mustParseURL(str string) url.URL {
+87 -130
View File
@@ -19,7 +19,6 @@ import (
"unicode"
"github.com/cespare/xxhash/v2"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
"github.com/open-policy-agent/opa/v1/ast/location"
"github.com/open-policy-agent/opa/v1/util"
)
@@ -420,55 +419,10 @@ func (term *Term) IsGround() bool {
return term.Value.IsGround()
}
// termJSON is used to serialize Term to JSON without map allocation.
type termJSON struct {
Location *Location `json:"location,omitempty"`
Type string `json:"type"`
Value Value `json:"value"`
}
// MarshalJSON returns the JSON encoding of the term.
//
// Specialized marshalling logic is required to include a type hint for Value.
func (term *Term) MarshalJSON() ([]byte, error) {
d := termJSON{
Type: ValueName(term.Value),
Value: term.Value,
}
jsonOptions := astJSON.GetOptions().MarshalOptions
if jsonOptions.IncludeLocation.Term {
d.Location = term.Location
}
return json.Marshal(d)
}
func (term *Term) String() string {
return term.Value.String()
}
// UnmarshalJSON parses the byte array and stores the result in term.
// Specialized unmarshalling is required to handle Value and Location.
func (term *Term) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
val, err := unmarshalValue(v)
if err != nil {
return err
}
term.Value = val
if loc, ok := v["location"].(map[string]any); ok {
term.Location = &Location{}
err := unmarshalLocation(term.Location, loc)
if err != nil {
return err
}
}
return nil
}
// Vars returns a VarSet with variables contained in this term.
func (term *Term) Vars() VarSet {
vis := NewVarVisitor()
@@ -660,56 +614,6 @@ func (n *Not) String() string {
return "not {" + n.Body.String() + "}"
}
func (n *Not) MarshalJSON() ([]byte, error) {
data := map[string]any{
"type": "not",
"body": n.Body,
"explicit_body": n.ExplicitBody,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Not {
if n.Location != nil {
data["location"] = n.Location
}
}
return json.Marshal(data)
}
func (n *Not) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalNot(n, v)
}
func unmarshalNot(n *Not, v map[string]any) error {
var eb bool
if x, ok := v["explicit_body"]; ok {
eb, ok = x.(bool)
if !ok {
return fmt.Errorf("ast: unable to unmarshal explicit_body field with type: %T (expected true or false)", v["explicit_body"])
}
}
b, ok := v["body"].([]any)
if !ok {
return fmt.Errorf("ast: unable to unmarshal not, invalid body field type: %T (expected list)", v["body"])
}
body, err := unmarshalBody(b)
if err != nil {
return fmt.Errorf("ast: unable to unmarshal not body: %w", err)
}
n.ExplicitBody = eb
n.Body = body
return nil
}
// Null represents the null value defined by JSON.
type Null struct{}
@@ -909,11 +813,6 @@ func (Number) IsGround() bool {
return true
}
// MarshalJSON returns JSON encoded bytes representing num.
func (num Number) MarshalJSON() ([]byte, error) {
return json.Marshal(json.Number(num))
}
func (num Number) String() string {
return string(num)
}
@@ -1692,14 +1591,6 @@ func (arr *Array) IsGround() bool {
return arr.ground
}
// MarshalJSON returns JSON encoded bytes representing arr.
func (arr *Array) MarshalJSON() ([]byte, error) {
if len(arr.elems) == 0 {
return []byte(`[]`), nil
}
return json.Marshal(arr.elems)
}
func (arr *Array) String() string {
buf, _ := arr.AppendText(make([]byte, 0, arr.StringLength()))
return util.ByteSliceToString(buf)
@@ -2048,14 +1939,6 @@ func (s *set) Len() int {
return len(s.keys)
}
// MarshalJSON returns JSON encoded bytes representing s.
func (s *set) MarshalJSON() ([]byte, error) {
if s.keys == nil {
return []byte(`[]`), nil
}
return json.Marshal(s.sortedKeys())
}
// Sorted returns an Array that contains the sorted elements of s.
func (s *set) Sorted() *Array {
cpy := make([]*Term, len(s.keys))
@@ -2224,10 +2107,6 @@ func (l *lazyObj) Map(f func(*Term, *Term) (*Term, *Term, error)) (Object, error
return l.force().Map(f)
}
func (l *lazyObj) MarshalJSON() ([]byte, error) {
return l.force().(*object).MarshalJSON()
}
func (l *lazyObj) Merge(other Object) (Object, bool) {
return l.force().Merge(other)
}
@@ -2605,15 +2484,6 @@ func (obj *object) KeysIterator() ObjectKeysIterator {
return newobjectKeysIterator(obj)
}
// MarshalJSON returns JSON encoded bytes representing obj.
func (obj *object) MarshalJSON() ([]byte, error) {
sl := make([][2]*Term, obj.Len())
for i, node := range obj.sortedKeys() {
sl[i] = Item(node.key, node.value)
}
return json.Marshal(sl)
}
// Merge returns a new Object containing the non-overlapping keys of obj and other. If there are
// overlapping keys between obj and other, the values of associated with the keys are merged. Only
// objects can be merged with other objects. If the values cannot be merged, the second turn value
@@ -3187,6 +3057,29 @@ func isControlOrBackslash(r rune) bool {
// on the happy path and treats all errors the same. If better error
// reporting is needed, the error paths will need to be fleshed out.
// UnmarshalJSON parses the byte array and stores the result in term.
// Specialized unmarshalling is required to handle Value and Location.
func (term *Term) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
val, err := unmarshalValue(v)
if err != nil {
return err
}
term.Value = val
if loc, ok := v["location"].(map[string]any); ok {
term.Location = &Location{}
err := unmarshalLocation(term.Location, loc)
if err != nil {
return err
}
}
return nil
}
func unmarshalBody(b []any) (Body, error) {
buf := Body{}
for _, e := range b {
@@ -3391,6 +3284,45 @@ func unmarshalWith(i any) (*With, error) {
return nil, errors.New(`ast: unable to unmarshal with modifier (expected {"target": {...}, "value": {...}})`)
}
func unmarshalLogical(typeName string, lhs, rhs *Body, explicitLhs, explicitRhs *bool, v map[string]any) error {
lhsRaw, ok := v["lhs"].([]any)
if !ok {
return fmt.Errorf("ast: unable to unmarshal %s, invalid lhs field type: %T (expected list)", typeName, v["lhs"])
}
l, err := unmarshalBody(lhsRaw)
if err != nil {
return fmt.Errorf("ast: unable to unmarshal %s lhs: %w", typeName, err)
}
*lhs = l
rhsRaw, ok := v["rhs"].([]any)
if !ok {
return fmt.Errorf("ast: unable to unmarshal %s, invalid rhs field type: %T (expected list)", typeName, v["rhs"])
}
r, err := unmarshalBody(rhsRaw)
if err != nil {
return fmt.Errorf("ast: unable to unmarshal %s rhs: %w", typeName, err)
}
*rhs = r
if x, ok := v["explicit_lhs"]; ok {
b, ok := x.(bool)
if !ok {
return fmt.Errorf("ast: unable to unmarshal %s explicit_lhs field with type: %T (expected true or false)", typeName, x)
}
*explicitLhs = b
}
if x, ok := v["explicit_rhs"]; ok {
b, ok := x.(bool)
if !ok {
return fmt.Errorf("ast: unable to unmarshal %s explicit_rhs field with type: %T (expected true or false)", typeName, x)
}
*explicitRhs = b
}
return nil
}
func unmarshalValue(d map[string]any) (Value, error) {
v := d["value"]
switch d["type"] {
@@ -3508,3 +3440,28 @@ func unmarshalValue(d map[string]any) (Value, error) {
unmarshal_error:
return nil, errors.New("ast: unable to unmarshal term")
}
func unmarshalNot(n *Not, v map[string]any) error {
var eb bool
if x, ok := v["explicit_body"]; ok {
eb, ok = x.(bool)
if !ok {
return fmt.Errorf("ast: unable to unmarshal explicit_body field with type: %T (expected true or false)", v["explicit_body"])
}
}
b, ok := v["body"].([]any)
if !ok {
return fmt.Errorf("ast: unable to unmarshal not, invalid body field type: %T (expected list)", v["body"])
}
body, err := unmarshalBody(b)
if err != nil {
return fmt.Errorf("ast: unable to unmarshal not body: %w", err)
}
n.ExplicitBody = eb
n.Body = body
return nil
}
+95
View File
@@ -0,0 +1,95 @@
// 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.
//go:build !go1.27
package ast
import (
"encoding/json"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
"github.com/open-policy-agent/opa/v1/util"
)
// termJSON is used to serialize Term to JSON without map allocation.
type termJSON struct {
Location *Location `json:"location,omitempty"`
Type string `json:"type"`
Value Value `json:"value"`
}
// MarshalJSON returns the JSON encoding of the term.
//
// Specialized marshalling logic is required to include a type hint for Value.
func (term *Term) MarshalJSON() ([]byte, error) {
d := termJSON{
Type: ValueName(term.Value),
Value: term.Value,
}
jsonOptions := astJSON.GetOptions().MarshalOptions
if jsonOptions.IncludeLocation.Term {
d.Location = term.Location
}
return json.Marshal(d)
}
// MarshalJSON returns JSON encoded bytes representing arr.
func (arr *Array) MarshalJSON() ([]byte, error) {
if len(arr.elems) == 0 {
return []byte(`[]`), nil
}
return json.Marshal(arr.elems)
}
// MarshalJSON returns JSON encoded bytes representing num.
func (num Number) MarshalJSON() ([]byte, error) {
return json.Marshal(json.Number(num))
}
// MarshalJSON returns JSON encoded bytes representing obj.
func (obj *object) MarshalJSON() ([]byte, error) {
sl := make([][2]*Term, obj.Len())
for i, node := range obj.sortedKeys() {
sl[i] = Item(node.key, node.value)
}
return json.Marshal(sl)
}
// MarshalJSON returns JSON encoded bytes representing s.
func (s *set) MarshalJSON() ([]byte, error) {
if s.keys == nil {
return []byte(`[]`), nil
}
return json.Marshal(s.sortedKeys())
}
func (l *lazyObj) MarshalJSON() ([]byte, error) {
return l.force().(*object).MarshalJSON()
}
func (n *Not) MarshalJSON() ([]byte, error) {
data := map[string]any{
"type": "not",
"body": n.Body,
"explicit_body": n.ExplicitBody,
}
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Not {
if n.Location != nil {
data["location"] = n.Location
}
}
return json.Marshal(data)
}
func (n *Not) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalNot(n, v)
}
+251
View File
@@ -0,0 +1,251 @@
//go:build go1.27
package ast
import (
"encoding"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"github.com/open-policy-agent/opa/internal/jsonv2"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
"github.com/open-policy-agent/opa/v1/util"
)
var (
_ json.MarshalerTo = &Term{}
_ json.Unmarshaler = &LogicalOr{}
_ json.MarshalerTo = &LogicalOr{}
_ json.MarshalerTo = &Not{}
_ json.MarshalerTo = &Array{}
_ json.MarshalerTo = &set{}
_ json.MarshalerTo = &object{}
_ json.MarshalerTo = &TemplateString{}
_ json.MarshalerTo = &Ref{}
_ json.MarshalerTo = &lazyObj{}
_ json.MarshalerTo = Args{}
_ json.MarshalerTo = Boolean(false)
_ json.MarshalerTo = Null{}
_ json.MarshalerTo = Number("")
_ json.MarshalerTo = String("")
_ json.MarshalerTo = Var("")
_ json.Unmarshaler = &Not{}
// These are exported types, so losing MarshalJSON here would be a breaking
// API change even though callers should go through json.Marshal, not this
// method directly.
_ json.Marshaler = Number("")
_ json.Marshaler = &Term{}
_ json.Marshaler = &Not{}
_ json.Marshaler = &lazyObj{}
_ json.Marshaler = &object{}
_ json.Marshaler = &Array{}
_ json.Marshaler = &set{}
)
// These are here to ensure that we do not fall down to TextAppender, which
// Go 1.27's encoding/json would otherwise use, encoding these as JSON strings.
func (b Boolean) MarshalJSONTo(e *jsontext.Encoder) error {
return e.WriteToken(jsontext.Bool(bool(b)))
}
func (Null) MarshalJSONTo(e *jsontext.Encoder) error {
// Encoded as an empty object rather than null, as that's the representation
// callers have come to expect. See also [marshalValueTo].
return e.WriteValue([]byte("{}"))
}
func (v Var) MarshalJSONTo(e *jsontext.Encoder) error {
// Must produce the var name as a JSON string, wildcard vars included: that's
// what encoding/json v1 does for a type whose underlying kind is string.
return e.WriteToken(jsontext.String(string(v)))
}
func (num Number) MarshalJSONTo(e *jsontext.Encoder) error {
if num == "" {
// Matches encoding/json v1, which encodes an empty json.Number as 0.
return e.WriteToken(jsontext.Int(0))
}
return e.WriteValue(jsontext.Value(num))
}
// MarshalJSON returns JSON encoded bytes representing num.
func (num Number) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(num)
}
func (str String) MarshalJSONTo(e *jsontext.Encoder) error {
return e.WriteToken(jsontext.String(string(str)))
}
func (t *Term) MarshalJSONTo(e *jsontext.Encoder) (err error) {
// Token write errors are unchecked: an unbalanced value fails at the closing
// token. A marshaller can fail having written a balanced value, so is checked.
e.WriteToken(jsontext.BeginObject)
includeLocation := astJSON.GetOptions().MarshalOptions.IncludeLocation
if t.Location != nil && includeLocation.Term {
if err := jsonv2.WriteField(e, "location", t.Location); err != nil {
return err
}
}
e.WriteToken(jsontext.String("type"))
e.WriteToken(jsontext.String(ValueName(t.Value)))
e.WriteToken(jsontext.String("value"))
if err = marshalValueTo(e, t.Value); err != nil {
return fmt.Errorf("failed to marshal term of %s: %w", ValueName(t.Value), err)
}
return e.WriteToken(jsontext.EndObject)
}
// MarshalJSON returns the JSON encoding of the term.
func (term *Term) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(term)
}
func (r Ref) MarshalJSONTo(e *jsontext.Encoder) (err error) {
return jsonv2.WriteMarshalerToArrayOrNull(e, r)
}
func (t *TemplateString) MarshalJSONTo(e *jsontext.Encoder) (err error) {
// Token write errors are unchecked: an unbalanced value fails at the closing
// token. A marshaller can fail having written a balanced value, so is checked.
e.WriteToken(jsontext.BeginObject)
e.WriteToken(jsontext.String("parts"))
if t.Parts == nil {
// Parts has no omitempty tag, so it's always written. Matches
// encoding/json v1, which encodes a nil slice as null rather than as an
// empty array.
e.WriteToken(jsontext.Null)
} else {
e.WriteToken(jsontext.BeginArray)
for _, p := range t.Parts {
switch v := p.(type) {
case *Expr:
if err := v.MarshalJSONTo(e); err != nil {
return err
}
case *Term:
if err := v.MarshalJSONTo(e); err != nil {
return err
}
}
}
e.WriteToken(jsontext.EndArray)
}
e.WriteToken(jsontext.String("multi_line"))
e.WriteToken(jsontext.Bool(t.MultiLine))
return e.WriteToken(jsontext.EndObject)
}
func (n *Not) MarshalJSONTo(e *jsontext.Encoder) error {
e.WriteToken(jsontext.BeginObject)
e.WriteToken(jsontext.String("type"))
e.WriteToken(jsontext.String("not"))
if err := jsonv2.WriteField(e, "body", n.Body); err != nil {
return err
}
e.WriteToken(jsontext.String("explicit_body"))
e.WriteToken(jsontext.Bool(n.ExplicitBody))
if astJSON.GetOptions().MarshalOptions.IncludeLocation.Not && n.Location != nil {
if err := jsonv2.WriteField(e, "location", n.Location); err != nil {
return err
}
}
return e.WriteToken(jsontext.EndObject)
}
func (n *Not) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(n)
}
func (n *Not) UnmarshalJSON(bs []byte) error {
v := map[string]any{}
if err := util.UnmarshalJSON(bs, &v); err != nil {
return err
}
return unmarshalNot(n, v)
}
func (obj *object) MarshalJSONTo(e *jsontext.Encoder) error {
// Token write errors are unchecked: an unbalanced value fails at the closing
// token. A marshaller can fail having written a balanced value, so is checked.
e.WriteToken(jsontext.BeginArray)
for _, node := range obj.sortedKeys() {
e.WriteToken(jsontext.BeginArray)
if err := node.key.MarshalJSONTo(e); err != nil {
return err
}
if err := node.value.MarshalJSONTo(e); err != nil {
return err
}
e.WriteToken(jsontext.EndArray)
}
return e.WriteToken(jsontext.EndArray)
}
func (l *lazyObj) MarshalJSONTo(e *jsontext.Encoder) error {
return l.force().(*object).MarshalJSONTo(e)
}
func (l *lazyObj) MarshalJSON() ([]byte, error) {
return l.force().(*object).MarshalJSON()
}
// MarshalJSON returns JSON encoded bytes representing obj.
func (obj *object) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(obj)
}
func (a *Array) MarshalJSONTo(e *jsontext.Encoder) error {
return jsonv2.WriteMarshalerToArray(e, a.elems)
}
// MarshalJSON returns JSON encoded bytes representing arr.
func (arr *Array) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(arr)
}
func (s *set) MarshalJSONTo(e *jsontext.Encoder) error {
return jsonv2.WriteMarshalerToArray(e, s.sortedKeys())
}
// MarshalJSON returns JSON encoded bytes representing s.
func (s *set) MarshalJSON() ([]byte, error) {
return jsonv2.MarshalMarshalerTo(s)
}
func marshalValueTo(e *jsontext.Encoder, val Value) (err error) {
switch v := val.(type) {
case json.MarshalerTo:
err = v.MarshalJSONTo(e)
case encoding.TextAppender:
var text []byte
if text, err = v.AppendText(nil); err != nil {
return err
}
if text, err = jsontext.AppendQuote(e.AvailableBuffer(), text); err != nil {
return err
}
err = e.WriteValue(text)
default:
err = json.MarshalEncode(e, v)
}
return err
}
+67
View File
@@ -0,0 +1,67 @@
// 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.
//go:build go1.27
package ast
import (
"bytes"
"encoding/json/jsontext"
"errors"
"strings"
"testing"
)
// textAppenderValue is a minimal Value that only implements
// encoding.TextAppender (not json.MarshalerTo), to exercise the
// TextAppender fallback branch of marshalValueTo.
type textAppenderValue struct {
text string
err error
}
func (textAppenderValue) Compare(Value) int { return 0 }
func (textAppenderValue) Find(Ref) (Value, error) { return nil, nil }
func (textAppenderValue) Hash() int { return 0 }
func (textAppenderValue) IsGround() bool { return true }
func (v textAppenderValue) String() string { return v.text }
func (v textAppenderValue) StringLength() int { return len(v.text) }
func (v textAppenderValue) AppendText(buf []byte) ([]byte, error) {
if v.err != nil {
return buf, v.err
}
return append(buf, v.text...), nil
}
func TestMarshalValueToTextAppenderError(t *testing.T) {
wantErr := errors.New("boom")
v := textAppenderValue{err: wantErr}
enc := jsontext.NewEncoder(new(bytes.Buffer))
err := marshalValueTo(enc, v)
if err == nil {
t.Fatalf("expected error from AppendText to be propagated, got nil")
}
if !errors.Is(err, wantErr) {
t.Fatalf("expected wrapped error %v, got %v", wantErr, err)
}
}
func TestMarshalValueToTextAppenderQuoting(t *testing.T) {
v := textAppenderValue{text: "2026-01-01T00:00:00Z"}
var sb bytes.Buffer
enc := jsontext.NewEncoder(&sb)
if err := marshalValueTo(enc, v); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := strings.TrimSpace(sb.String())
want := `"2026-01-01T00:00:00Z"`
if got != want {
t.Fatalf("expected quoted JSON string %q, got %q", want, got)
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ func TestInterfaceToValueStructs(t *testing.T) {
var m brokenMarshaller
_, err = InterfaceToValue(m)
if err == nil || err.Error() != "ast: interface conversion: json: error calling MarshalJSON for type ast.brokenMarshaller: broken" {
if err == nil || !strings.Contains(err.Error(), "ast: interface conversion: json: error calling MarshalJSON for type") {
t.Fatal("expected error but got:", err)
}
}
+9 -3
View File
@@ -6,6 +6,7 @@ import (
"maps"
"os"
"path/filepath"
"strings"
"testing"
"github.com/open-policy-agent/opa/v1/util/test"
@@ -56,7 +57,7 @@ func TestParseKeysConfig(t *testing.T) {
"invalid_raw_config": {
`[1,2,3]`,
nil,
true, errors.New("json: cannot unmarshal array into Go value of type map[string]json.RawMessage"),
true, errors.New("json: cannot unmarshal array into Go value of type"),
},
}
@@ -68,8 +69,13 @@ func TestParseKeysConfig(t *testing.T) {
t.Fatal("Expected error but got nil")
}
if tc.err != nil && tc.err.Error() != err.Error() {
t.Fatalf("Expected error message %v but got %v", tc.err.Error(), err.Error())
if tc.err != nil {
exp := tc.err.Error()
got := err.Error()
if !strings.HasPrefix(got, exp) {
t.Fatalf("Expected error message %v but got %v", exp, got)
}
}
} else if err != nil {
t.Fatalf("Unexpected error %v", err)
+28
View File
@@ -53,6 +53,10 @@ type chunkEncoder struct {
uncompressedLimit int64
uncompressedLimitScaleUpExponent float64
uncompressedLimitScaleDownExponent float64
// scalingDown records that a scaleDown is already in progress further up the
// stack, so a nested one that cannot lower the limit knows it would cycle
scalingDown bool
}
func newChunkEncoder(limit int64) *chunkEncoder {
@@ -286,6 +290,8 @@ func (enc *chunkEncoder) Encode(event EventV1, eventBytes []byte) ([][]byte, err
}
func (enc *chunkEncoder) scaleDown(events []EventV1) ([][]byte, error) {
reduced := false
if enc.uncompressedLimit > enc.limit {
enc.incrMetric(encUncompressedLimitScaleDownCounterName)
enc.incrMetric(encSoftLimitScaleDownCounterName)
@@ -300,11 +306,23 @@ func (enc *chunkEncoder) scaleDown(events []EventV1) ([][]byte, error) {
if enc.uncompressedLimitScaleUpExponent > 0 {
enc.uncompressedLimitScaleUpExponent -= uncompressedLimitExponentScaleFactor
}
reduced = true
}
// The uncompressed limit has grown too large the events need to be split up into multiple chunks
enc.initialize()
// A nested call that can't lower the limit further would re-encode the same
// events into the same branch, recursing until the stack is exhausted.
// Closing the chunk per event avoids it, as Encode never then reaches that
// branch. Surfaced by Go 1.27's compress/flate sizes, but not specific to it.
oneChunkPerEvent := enc.scalingDown && !reduced
wasScalingDown := enc.scalingDown
enc.scalingDown = true
defer func() { enc.scalingDown = wasScalingDown }()
// split the events into multiple chunks
var result [][]byte
for i := range events {
@@ -322,6 +340,16 @@ func (enc *chunkEncoder) scaleDown(events []EventV1) ([][]byte, error) {
if chunks != nil {
result = append(result, chunks...)
}
if oneChunkPerEvent {
chunk, err := enc.reset()
if err != nil {
return nil, err
}
if chunk != nil {
result = append(result, chunk)
}
}
}
return result, nil
+9 -8
View File
@@ -215,7 +215,10 @@ func TestChunkEncoder(t *testing.T) {
func TestChunkEncoderSizeLimit(t *testing.T) {
t.Parallel()
enc := newChunkEncoder(90).WithMetrics(metrics.New())
// The limit has to sit just above the compressed size of the smallest event
// (87 bytes on Go 1.26, 95 on Go 1.27) for this test to exercise the
// encoder's equilibrium path on every Go version.
enc := newChunkEncoder(96).WithMetrics(metrics.New())
var result any = false
var expInput any = map[string]any{"method": "GET"}
ts, err := time.Parse(time.RFC3339Nano, "2018-01-01T12:00:00.123456Z")
@@ -243,9 +246,9 @@ func TestChunkEncoderSizeLimit(t *testing.T) {
t.Fatal(err)
}
// expect the event to be written because it fits the minimum event size
expectedBufferSize := 78 // the compressed size of an absurd small event
if enc.buf.Len() != expectedBufferSize {
t.Errorf("Expected %v buffer size but got: %v", expectedBufferSize, enc.buf.Len())
// No exact compressed size here: gzip output differs between Go versions.
if enc.buf.Len() == 0 {
t.Error("Expected the event to have been written to the buffer")
}
expectedBytesWritten := 69 // the uncompressed size of the event
if enc.bytesWritten != expectedBytesWritten {
@@ -293,10 +296,8 @@ func TestChunkEncoderSizeLimit(t *testing.T) {
if err := enc.w.Flush(); err != nil {
t.Fatal(err)
}
expectedBufferSize = 15
if enc.buf.Len() != expectedBufferSize {
t.Errorf("Expected %v buffer size but got: %v", expectedBufferSize, enc.buf.Len())
}
// Nothing was written to the fresh buffer, so only the gzip header is in it.
// Its exact size isn't asserted, see above.
expectedBytesWritten = 0
if enc.bytesWritten != expectedBytesWritten {
t.Errorf("Expected %v bytes written but got: %v", expectedBytesWritten, enc.bytesWritten)
+8 -2
View File
@@ -231,9 +231,15 @@ func TestEventBuffer_Upload(t *testing.T) {
numberOfEvents: 4,
uploadSizeLimitBytes: 196, // Each test event is 195 bytes
handleFunc: func(w http.ResponseWriter, r *http.Request) {
// No. of events that fit in a chunk depends on how gzip packs
// them, which varies between Go versions. So here we confirm
// the len and that we get at least one event.
if r.ContentLength > 196 {
t.Errorf("uploaded chunk of %d bytes exceeds the limit of 196", r.ContentLength)
}
events := decodeLogEvent(t, r.Body)
if len(events) != 1 {
t.Errorf("expected 1 events, got %d", len(events))
if len(events) == 0 {
t.Error("expected a chunk to hold at least one event")
}
allEvents = append(allEvents, events...)
w.WriteHeader(http.StatusOK)
+4 -1
View File
@@ -154,7 +154,10 @@ func TestHandlerOnEndpointsWithoutCompression(t *testing.T) {
func zipString(input string) []byte {
var b bytes.Buffer
gz := gzip.NewWriter(&b)
gz, err := gzip.NewWriterLevel(&b, defaultCompressionLevel)
if err != nil {
log.Fatal(err)
}
if _, err := gz.Write([]byte(input)); err != nil {
log.Fatal(err)
}
+1 -1
View File
@@ -5722,7 +5722,7 @@ func newStreamedReqUnversioned(method string, path string, body io.Reader) *http
func mustUnmarshalTrace(t types.TraceV1) (trace types.TraceV1Raw) {
if err := json.Unmarshal(t, &trace); err != nil {
panic("not reached")
panic(err)
}
return trace
}
+24
View File
@@ -0,0 +1,24 @@
//go:build go1.27
package topdown
import (
"encoding/json/jsontext"
"errors"
"github.com/open-policy-agent/opa/internal/jsonv2"
)
func (e *Error) MarshalJSONTo(enc *jsontext.Encoder) (err error) {
enc.WriteToken(jsontext.BeginObject)
enc.WriteToken(jsontext.String("code"))
enc.WriteToken(jsontext.String(e.Code))
enc.WriteToken(jsontext.String("message"))
enc.WriteToken(jsontext.String(e.Message))
if e.Location != nil {
err = jsonv2.WriteField(enc, "location", e.Location)
}
return errors.Join(err, enc.WriteToken(jsontext.EndObject))
}
+17 -3
View File
@@ -169,12 +169,26 @@ func TestHTTPSendRetryRequest(t *testing.T) {
}
}))
defer ts.Close()
t.Cleanup(ts.Close)
// delay server start to exercise retry logic
// delay server start to exercise retry logic. Guarded because a
// subtest can finish, and so close the server, before the delay
// elapses: starting a closed server panics on Go 1.27+. Cleanups
// run LIFO, so this marks the server closed before ts.Close runs.
var mu sync.Mutex
closed := false
t.Cleanup(func() {
mu.Lock()
defer mu.Unlock()
closed = true
})
go func() {
time.Sleep(time.Second * 5)
ts.Start()
mu.Lock()
defer mu.Unlock()
if !closed {
ts.Start()
}
}()
ctx := context.Background()