From db035b09fc8b4b7f1b37558c4fde033209e17cc8 Mon Sep 17 00:00:00 2001 From: Anders Eknert Date: Thu, 30 Jul 2026 18:41:28 +0200 Subject: [PATCH] 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 Signed-off-by: Charlie Egan Co-authored-by: Charlie Egan --- .github/workflows/pull-request.yaml | 42 + cmd/bench_jsonv2_test.go | 1570 ++++++++++ cmd/bench_test.go | 2 + cmd/build_jsonv2_test.go | 3484 +++++++++++++++++++++ cmd/build_test.go | 67 + cmd/capabilities_jsonv2_test.go | 218 ++ cmd/capabilities_test.go | 69 + cmd/check_jsonv2_test.go | 1386 ++++++++ cmd/check_test.go | 46 + cmd/deps_jsonv2_test.go | 634 ++++ cmd/deps_test.go | 61 + cmd/eval_jsonv2_test.go | 3985 ++++++++++++++++++++++++ cmd/eval_test.go | 37 + cmd/exec_jsonv2_test.go | 1603 ++++++++++ cmd/exec_test.go | 53 + cmd/inspect_jsonv2_test.go | 2299 ++++++++++++++ cmd/inspect_test.go | 59 + cmd/oracle_jsonv2_test.go | 244 ++ cmd/oracle_test.go | 49 + cmd/parse_jsonv2_test.go | 1531 +++++++++ cmd/parse_test.go | 83 +- cmd/run_jsonv2_test.go | 510 +++ cmd/run_test.go | 39 + cmd/sign_jsonv2_test.go | 207 ++ cmd/sign_test.go | 23 + cmd/test_jsonv2_test.go | 3918 +++++++++++++++++++++++ cmd/test_test.go | 51 + internal/jsonv2/jsonv2.go | 79 + internal/jsonv2/jsonv2_test.go | 58 + internal/prometheus/prometheus_test.go | 6 +- internal/semver/semver.go | 6 +- internal/semver/semver_test.go | 8 +- v1/ast/annotations.go | 99 - v1/ast/annotations_json.go | 124 + v1/ast/annotations_jsonv2.go | 195 ++ v1/ast/annotations_test.go | 25 + v1/ast/jsonv1_test.go | 21 + v1/ast/jsonv2_test.go | 30 + v1/ast/location/location.go | 40 - v1/ast/location/location_json.go | 47 + v1/ast/location/location_jsonv2.go | 50 + v1/ast/location/location_test.go | 41 + v1/ast/marshal_jsonv2_test.go | 1627 ++++++++++ v1/ast/marshal_test.go | 326 ++ v1/ast/policy.go | 321 -- v1/ast/policy_appenders.go | 4 + v1/ast/policy_appenders_test.go | 28 + v1/ast/policy_json.go | 289 ++ v1/ast/policy_jsonv2.go | 524 ++++ v1/ast/policy_logical_test.go | 12 +- v1/ast/policy_test.go | 15 +- v1/ast/term.go | 217 +- v1/ast/term_json.go | 95 + v1/ast/term_jsonv2.go | 251 ++ v1/ast/term_jsonv2_test.go | 67 + v1/ast/term_test.go | 2 +- v1/keys/keys_test.go | 12 +- v1/plugins/logs/encoder.go | 28 + v1/plugins/logs/encoder_test.go | 17 +- v1/plugins/logs/eventBuffer_test.go | 10 +- v1/server/handlers/compress_test.go | 5 +- v1/server/server_test.go | 2 +- v1/topdown/errors_jsonv2.go | 24 + v1/topdown/http_slow_test.go | 20 +- 64 files changed, 26296 insertions(+), 699 deletions(-) create mode 100644 cmd/bench_jsonv2_test.go create mode 100644 cmd/build_jsonv2_test.go create mode 100644 cmd/capabilities_jsonv2_test.go create mode 100644 cmd/check_jsonv2_test.go create mode 100644 cmd/deps_jsonv2_test.go create mode 100644 cmd/eval_jsonv2_test.go mode change 100755 => 100644 cmd/eval_test.go create mode 100644 cmd/exec_jsonv2_test.go create mode 100644 cmd/inspect_jsonv2_test.go create mode 100644 cmd/oracle_jsonv2_test.go create mode 100644 cmd/parse_jsonv2_test.go create mode 100644 cmd/run_jsonv2_test.go create mode 100644 cmd/sign_jsonv2_test.go create mode 100644 cmd/test_jsonv2_test.go create mode 100644 internal/jsonv2/jsonv2.go create mode 100644 internal/jsonv2/jsonv2_test.go create mode 100644 v1/ast/annotations_json.go create mode 100644 v1/ast/annotations_jsonv2.go create mode 100644 v1/ast/jsonv1_test.go create mode 100644 v1/ast/jsonv2_test.go create mode 100644 v1/ast/location/location_json.go create mode 100644 v1/ast/location/location_jsonv2.go create mode 100644 v1/ast/marshal_jsonv2_test.go create mode 100644 v1/ast/policy_json.go create mode 100644 v1/ast/policy_jsonv2.go create mode 100644 v1/ast/term_json.go create mode 100644 v1/ast/term_jsonv2.go create mode 100644 v1/ast/term_jsonv2_test.go create mode 100644 v1/topdown/errors_jsonv2.go diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index c189b5efe7..8d1c291c2c 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -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, diff --git a/cmd/bench_jsonv2_test.go b/cmd/bench_jsonv2_test.go new file mode 100644 index 0000000000..a38d50475e --- /dev/null +++ b/cmd/bench_jsonv2_test.go @@ -0,0 +1,1570 @@ +//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" + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-policy-agent/opa/cmd/formats" + "github.com/open-policy-agent/opa/internal/presentation" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/bundle" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/util" + "github.com/open-policy-agent/opa/v1/util/test" +) + +// Minimize the number of tests that *actually* run the benchmarks, they are pretty slow. +// Have one test that exercises the whole flow. +func TestRunBenchmark(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + + args := []string{"1 + 1"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_query_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain histogram_timer_rego_query_eval_ns_count, got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_query_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_query_eval_ns_count' to be equal to N") + } +} + +func TestRunBenchmarkWithQueryImport(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + // We add the rego.v1 import .. + params.imports = newrepeatedStringFlag([]string{"rego.v1"}) + + // .. which provides the 'in' keyword + args := []string{`"a" in ["a", "b", "c"]`} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_query_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain histogram_timer_rego_query_eval_ns_count, got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_query_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_query_eval_ns_count' to be equal to N") + } +} + +func TestRunBenchmarkE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + + args := []string{"1 + 1"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_query_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_rego_query_eval_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_query_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_query_eval_ns_count' to be equal to N") + } + + if _, ok := br.Extra["histogram_timer_server_handler_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_server_handler_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_server_handler_ns_count"] { + t.Fatalf("Expected 'histogram_timer_server_handler_ns_count' to be equal to N") + } +} + +func TestRunBenchmarkE2EWithOPAConfigFile(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + fs := map[string]string{ + "/config.yaml": `{"decision_logs": {"console": true}}`, + } + + test.WithTempFS(fs, func(testDirRoot string) { + + params := testBenchParams() + params.e2e = true + params.configFile = filepath.Join(testDirRoot, "config.yaml") + + args := []string{"1 + 1"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_query_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_rego_query_eval_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_query_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_query_eval_ns_count' to be equal to N") + } + + if _, ok := br.Extra["histogram_timer_server_handler_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_server_handler_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_server_handler_ns_count"] { + t.Fatalf("Expected 'histogram_timer_server_handler_ns_count' to be equal to N") + } + }) +} + +func TestRunBenchmarkFailFastE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.fail = true // configured to fail on undefined results + params.e2e = true + + args := []string{"a := 1; a > 2"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var pr presentation.Output + err = util.UnmarshalJSON(buf.Bytes(), &pr) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if len(pr.Errors) != 1 { + t.Fatalf("Expected 1 error in result, got:\n\n%s\n", buf.String()) + } +} + +func TestBenchPartialE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.partial = true + params.fail = true + params.e2e = true + params.unknowns = []string{"input"} + args := []string{"input.x > 0"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + var br testing.BenchmarkResult + err = util.UnmarshalJSON(buf.Bytes(), &br) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if br.N == 0 || br.T == 0 || br.MemAllocs == 0 || br.MemBytes == 0 { + t.Fatalf("Expected benchmark results to be non-zero, got: %+v", br) + } + + if _, ok := br.Extra["histogram_timer_rego_partial_eval_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_rego_partial_eval_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_rego_partial_eval_ns_count"] { + t.Fatalf("Expected 'histogram_timer_rego_partial_eval_ns_count' to be equal to N") + } + + if _, ok := br.Extra["histogram_timer_server_handler_ns_count"]; !ok { + t.Fatalf("Expected benchmark results to contain 'histogram_timer_server_handler_ns_count', got: %+v", br) + } + + if float64(br.N) != br.Extra["histogram_timer_server_handler_ns_count"] { + t.Fatalf("Expected 'histogram_timer_server_handler_ns_count' to be equal to N") + } +} + +func TestRunBenchmarkPartialFailFastE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.partial = true + params.unknowns = []string{} + params.fail = true + params.e2e = true + args := []string{"1 == 2"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + + actual := buf.String() + expected := `{ + "errors": [ + { + "message": "undefined result" + } + ] +} +` + + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRunBenchmarkFailFast(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.fail = true // configured to fail on undefined results + + args := []string{"a := 1; a > 2"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + + // Expect a json serialized benchmark result with histogram fields + var pr presentation.Output + err = util.UnmarshalJSON(buf.Bytes(), &pr) + if err != nil { + t.Fatalf("Unexpected error unmarshalling output: %s", err) + } + + if len(pr.Errors) != 1 { + t.Fatalf("Expected 1 error in result, got:\n\n%s\n", buf.String()) + } +} + +// mockBenchRunner lets us test the bench CLI operations without having to wait ~10 seconds +// while the actual benchmark runner does its thing. +type mockBenchRunner struct { + onRun func(ctx context.Context, ectx *evalContext, params benchmarkCommandParams, f func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) +} + +func (r *mockBenchRunner) run(ctx context.Context, ectx *evalContext, params benchmarkCommandParams, f func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + if r.onRun != nil { + return r.onRun(ctx, ectx, params, f) + } + return testing.BenchmarkResult{}, nil +} + +func TestBenchPartial(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.partial = true + params.fail = true + args := []string{"input=1"} + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &mockBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } +} + +func TestBenchMainErrPreparing(t *testing.T) { + t.Parallel() + + params := testBenchParams() + args := []string{"???"} // query compile error + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &mockBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } +} + +func TestBenchMainErrRunningBenchmark(t *testing.T) { + t.Parallel() + + params := testBenchParams() + args := []string{"1+1"} + var buf bytes.Buffer + + mockRunner := &mockBenchRunner{} + mockRunner.onRun = func(_ context.Context, _ *evalContext, _ benchmarkCommandParams, _ func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + return testing.BenchmarkResult{}, errors.New("error error error") + } + + rc, err := benchMain(args, params, &buf, nil, mockRunner) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } +} + +func TestBenchMainWithCount(t *testing.T) { + t.Parallel() + + params := testBenchParams() + args := []string{"1+1"} + var buf bytes.Buffer + + mockRunner := &mockBenchRunner{} + + params.count = 25 + actualCount := 0 + mockRunner.onRun = func(_ context.Context, _ *evalContext, _ benchmarkCommandParams, _ func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + actualCount++ + return testing.BenchmarkResult{}, nil + } + + rc, err := benchMain(args, params, &buf, nil, mockRunner) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + if actualCount != params.count { + t.Fatalf("Expected benchmark to be run %d times, only ran %d", params.count, actualCount) + } +} + +func TestBenchMainWithNegativeCount(t *testing.T) { + t.Parallel() + + params := testBenchParams() + args := []string{"1+1"} + var buf bytes.Buffer + + mockRunner := &mockBenchRunner{} + + params.count = -1 + actualCount := 0 + mockRunner.onRun = func(_ context.Context, _ *evalContext, _ benchmarkCommandParams, _ func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + actualCount++ + return testing.BenchmarkResult{}, nil + } + + rc, err := benchMain(args, params, &buf, nil, mockRunner) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + + if actualCount != 0 { + t.Fatalf("Expected benchmark to not be run, instead ran %d times", actualCount) + } +} + +func validateBenchMainPrep(t *testing.T, args []string, params benchmarkCommandParams) { + t.Helper() + + var buf bytes.Buffer + + mockRunner := &mockBenchRunner{} + + mockRunner.onRun = func(ctx context.Context, ectx *evalContext, _ benchmarkCommandParams, _ func(context.Context, ...rego.EvalOption) error) (testing.BenchmarkResult, error) { + + // cheat and use the ectx to evalute the query to ensure the input setup on it was valid + r := rego.New(ectx.regoArgs...) + pq, err := r.PrepareForEval(ctx) + if err != nil { + return testing.BenchmarkResult{}, err + } + + rs, err := pq.Eval(ctx, ectx.evalArgs...) + if err != nil { + return testing.BenchmarkResult{}, err + } + + if len(rs) == 0 { + return testing.BenchmarkResult{}, errors.New("expected result, got none") + } + + return testing.BenchmarkResult{}, nil + } + + rc, err := benchMain(args, params, &buf, nil, mockRunner) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } +} + +func TestBenchMainWithJSONInputFile(t *testing.T) { + t.Parallel() + + params := testBenchParams() + files := map[string]string{ + "/input.json": `{"x": 42}`, + } + args := []string{"input.x == 42"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.json") + + validateBenchMainPrep(t, args, params) + }) +} + +func TestBenchMainWithYAMLInputFile(t *testing.T) { + t.Parallel() + + params := testBenchParams() + files := map[string]string{ + "/input.yaml": `x: 42`, + } + args := []string{"input.x == 42"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.yaml") + + validateBenchMainPrep(t, args, params) + }) +} + +func TestBenchMainInvalidInputFile(t *testing.T) { + t.Parallel() + + params := testBenchParams() + files := map[string]string{ + "/input.yaml": `x: 42`, + } + args := []string{"1+1"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "definitely", "not", "input.yaml") + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &mockBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + }) +} + +func TestBenchMainWithJSONInputFileE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + files := map[string]string{ + "/input.json": `{"x": 42}`, + } + args := []string{"input.x == 42"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.json") + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + }) +} + +func TestBenchMainWithYAMLInputFileE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + files := map[string]string{ + "/input.yaml": `x: 42`, + } + args := []string{"input.x == 42"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.yaml") + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + }) +} + +func TestBenchMainInvalidInputFileE2E(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.e2e = true + files := map[string]string{ + "/input.yaml": `x: 42`, + } + args := []string{"1+1"} + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "definitely", "not", "input.yaml") + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } + }) +} + +func TestBenchMainWithBundleData(t *testing.T) { + t.Parallel() + + params := testBenchParams() + + b := testBundle() + + files := map[string]string{ + "bundle.tar.gz": "", + } + + test.WithTempFS(files, func(path string) { + bundlePath := filepath.Join(path, "bundle.tar.gz") + f, err := os.OpenFile(bundlePath, os.O_WRONLY, os.ModePerm) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = bundle.Write(f, b) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = params.bundlePaths.Set(bundlePath) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + args := []string{"data.a.b.x"} + + validateBenchMainPrep(t, args, params) + }) +} + +func TestBenchMainWithBundleDataE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + + b := testBundle() + + files := map[string]string{ + "bundle.tar.gz": "", + } + + test.WithTempFS(files, func(path string) { + bundlePath := filepath.Join(path, "bundle.tar.gz") + f, err := os.OpenFile(bundlePath, os.O_WRONLY, os.ModePerm) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = bundle.Write(f, b) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = params.bundlePaths.Set(bundlePath) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + args := []string{"data.a.b.x"} + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + }) +} + +func TestBenchMainWithDataE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + + mod := `package a.b + import rego.v1 + + x if { + data.a.b.c == 42 + } + ` + + files := map[string]string{ + "p.rego": mod, + } + + test.WithTempFS(files, func(path string) { + err := params.dataPaths.Set(filepath.Join(path, "p.rego")) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + args := []string{"data.a.b.x"} + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + }) +} + +func TestBenchMainBadQueryE2E(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + params := testBenchParams() + params.e2e = true + args := []string{"foo.bar"} + + var buf bytes.Buffer + + rc, err := benchMain(args, params, &buf, nil, &goBenchRunner{}) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if rc != 1 { + t.Fatalf("Unexpected return code %d, expected 1", rc) + } +} + +func TestBenchMain_DefaultRegoVersion(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + tests := []struct { + note string + module string + query string + expErrs []string + }{ + // These tests are slow, so we're not being completely exhaustive here. + { + note: "v0 module", + module: `package test +a[x] { + x := 42 +}`, + query: `data.test.a`, + expErrs: []string{ + "mod.rego:2: rego_parse_error: `if` keyword is required before rule body", + "mod.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`, + }, + } + + modes := []struct { + name string + e2e bool + }{ + { + name: "run", + }, + { + name: "e2e", + e2e: true, + }, + } + + for _, mode := range modes { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s", tc.note, mode.name), func(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "mod.rego": tc.module, + } + + test.WithTempFS(files, func(path string) { + params := testBenchParams() + _ = params.outputFormat.Set(formats.Pretty) + params.e2e = mode.e2e + + for n := range files { + err := params.dataPaths.Set(filepath.Join(path, n)) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + } + + args := []string{tc.query} + + var buf, errBuf bytes.Buffer + rc, err := benchMain(args, params, &buf, &errBuf, &goBenchRunner{}) + + if len(tc.expErrs) > 0 { + if rc == 0 { + t.Fatalf("Expected non-zero return code") + } + + output := errBuf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(output, expErr) { + t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, output) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + } + }) + }) + } + } +} + +func TestBenchMainCompatibleFlags(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + module string + query string + expErrs []string + }{ + // These tests are slow, so we're not being completely exhaustive here. + { + note: "v0, keywords not used", + v0Compatible: true, + module: `package test +a[4] { + 1 == 1 +}`, + query: `data.test.a`, + }, + { + note: "v0, no keywords imported", + v0Compatible: true, + module: `package test +a contains 4 if { + 1 == 1 +}`, + query: `data.test.a`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + { + note: "v1, keywords not used", + v1Compatible: true, + module: `package test +a[4] { + 1 == 1 +}`, + query: `data.test.a`, + 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 keywords imported", + v1Compatible: true, + module: `package test +a contains 4 if { + 1 == 1 +}`, + query: `data.test.a`, + }, + { + note: "v0+v1, keywords not used (v0 takes precedence)", + v0Compatible: true, + v1Compatible: true, + module: `package test +a[4] { + 1 == 1 +}`, + query: `data.test.a`, + }, + } + + modes := []struct { + name string + e2e bool + }{ + { + name: "run", + }, + { + name: "e2e", + e2e: true, + }, + } + + for _, mode := range modes { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s", tc.note, mode.name), func(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "mod.rego": tc.module, + } + + test.WithTempFS(files, func(path string) { + params := testBenchParams() + _ = params.outputFormat.Set(formats.Pretty) + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + params.e2e = mode.e2e + + for n := range files { + err := params.dataPaths.Set(filepath.Join(path, n)) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + } + + args := []string{tc.query} + + var buf, errBuf bytes.Buffer + rc, err := benchMain(args, params, &buf, &errBuf, &goBenchRunner{}) + + if len(tc.expErrs) > 0 { + if rc == 0 { + t.Fatalf("Expected non-zero return code") + } + + output := errBuf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(output, expErr) { + t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, output) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + } + }) + }) + } + } +} + +func TestBenchMainWithBundleRegoVersion(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + tests := []struct { + note string + bundleRegoVersion int + bundleFileRegoVersions map[string]int + modules map[string]string + query string + expErrs []string + }{ + // These tests are slow, so we're not being completely exhaustive here. + { + note: "v0 bundle", + bundleRegoVersion: 0, + modules: map[string]string{ + "test.rego": `package test +a[4] { + 1 == 1 +}`, + }, + query: `data.test.a`, + }, + { + note: "v0 bundle, no keywords imported", + bundleRegoVersion: 0, + modules: map[string]string{ + "test.rego": `package test +a contains 4 if { + 1 == 1 +}`, + }, + query: `data.test.a`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + }, + }, + { + note: "v0 bundle, v1 per-file override", + bundleRegoVersion: 0, + bundleFileRegoVersions: map[string]int{ + "*/test2.rego": 1, + }, + modules: map[string]string{ + "test1.rego": `package test +a[4] { + 1 == 1 +}`, + "test2.rego": `package test +b contains 4 if { + 1 == 1 +}`, + }, + query: `data.test.a`, + }, + { + note: "v1 bundle, keywords not used", + bundleRegoVersion: 1, + modules: map[string]string{ + "test.rego": `package test +a[4] { + 1 == 1 +}`, + }, + query: `data.test.a`, + 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 keywords imported", + bundleRegoVersion: 1, + modules: map[string]string{ + "test.rego": `package test +a contains 4 if { + 1 == 1 +}`, + }, + query: `data.test.a`, + }, + } + + bundleTypeCases := []struct { + note string + tar bool + }{ + { + "bundle dir", false, + }, + { + "bundle tar", true, + }, + } + + modes := []struct { + name string + e2e bool + }{ + { + name: "run", + }, + { + name: "e2e", + e2e: true, + }, + } + + for _, bundleType := range bundleTypeCases { + for _, mode := range modes { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, tc.note, mode.name), func(t *testing.T) { + t.Parallel() + + files := map[string]string{} + + if bundleType.tar { + files["bundle.tar.gz"] = "" + } else { + maps.Copy(files, tc.modules) + + manifest := bundle.Manifest{ + RegoVersion: &tc.bundleRegoVersion, + FileRegoVersions: tc.bundleFileRegoVersions, + } + manifest.Init() + if b, err := json.Marshal(manifest); err != nil { + t.Fatalf("Unexpected error: %s", err) + } else { + files[".manifest"] = string(b) + } + } + + test.WithTempFS(files, func(root string) { + p := root + if bundleType.tar { + b := bundle.Bundle{ + Manifest: bundle.Manifest{ + RegoVersion: &tc.bundleRegoVersion, + FileRegoVersions: tc.bundleFileRegoVersions, + }, + Data: map[string]any{}, + } + for k, v := range tc.modules { + b.Modules = append(b.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + p = filepath.Join(root, "bundle.tar.gz") + f, err := os.OpenFile(p, os.O_WRONLY, os.ModePerm) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + err = bundle.Write(f, b) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + } + + params := testBenchParams() + _ = params.outputFormat.Set(formats.Pretty) + + params.e2e = mode.e2e + err := params.bundlePaths.Set(p) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + args := []string{tc.query} + + var buf, errBuf bytes.Buffer + rc, err := benchMain(args, params, &buf, &errBuf, &goBenchRunner{}) + + if len(tc.expErrs) > 0 { + if rc == 0 { + t.Fatalf("Expected non-zero return code") + } + + output := errBuf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(output, expErr) { + t.Fatalf("Expected error:\n\n%s\n\ngot:\n\n%s", expErr, output) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if rc != 0 { + t.Fatalf("Unexpected return code %d, expected 0", rc) + } + } + }) + }) + } + } + } +} + +func TestRenderBenchmarkResultJSONOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + err := params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + br := fakeBenchResults() + + var buf bytes.Buffer + renderBenchmarkResult(params, br, &buf) + + actual := buf.String() + + expected := `{ + "N": 134844, + "T": 1088294120, + "Bytes": 0, + "MemAllocs": 8360721, + "MemBytes": 449906736, + "Extra": { + "histogram_timer_rego_query_eval_ns_75%": 4953.75, + "histogram_timer_rego_query_eval_ns_90%": 6309.6, + "histogram_timer_rego_query_eval_ns_95%": 7872.55, + "histogram_timer_rego_query_eval_ns_99%": 14947.34000000001, + "histogram_timer_rego_query_eval_ns_99.9%": 174377.08200000023, + "histogram_timer_rego_query_eval_ns_99.99%": 176301, + "histogram_timer_rego_query_eval_ns_count": 134844, + "histogram_timer_rego_query_eval_ns_max": 176301, + "histogram_timer_rego_query_eval_ns_mean": 5118.3706225680935, + "histogram_timer_rego_query_eval_ns_median": 4312, + "histogram_timer_rego_query_eval_ns_min": 3553, + "histogram_timer_rego_query_eval_ns_stddev": 6587.830963916497 + } +} +` + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRenderBenchmarkResultPrettyOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.benchMem = false + err := params.outputFormat.Set(formats.Pretty) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + br := fakeBenchResults() + + var buf bytes.Buffer + renderBenchmarkResult(params, br, &buf) + + actual := buf.String() + + expected := `┌───────────────────────────────────────────┬────────┐ +│ samples │ 134844 │ +│ ns/op │ 8071 │ +│ histogram_timer_rego_query_eval_ns_75% │ 4954 │ +│ histogram_timer_rego_query_eval_ns_90% │ 6310 │ +│ histogram_timer_rego_query_eval_ns_95% │ 7873 │ +│ histogram_timer_rego_query_eval_ns_99% │ 14947 │ +│ histogram_timer_rego_query_eval_ns_99.9% │ 174377 │ +│ histogram_timer_rego_query_eval_ns_99.99% │ 176301 │ +│ histogram_timer_rego_query_eval_ns_count │ 134844 │ +│ histogram_timer_rego_query_eval_ns_max │ 176301 │ +│ histogram_timer_rego_query_eval_ns_mean │ 5118 │ +│ histogram_timer_rego_query_eval_ns_median │ 4312 │ +│ histogram_timer_rego_query_eval_ns_min │ 3553 │ +│ histogram_timer_rego_query_eval_ns_stddev │ 6588 │ +└───────────────────────────────────────────┴────────┘ +` + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRenderBenchmarkResultPrettyOutputShowAllocs(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.benchMem = true + err := params.outputFormat.Set(formats.Pretty) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + br := fakeBenchResults() + + var buf bytes.Buffer + renderBenchmarkResult(params, br, &buf) + + actual := buf.String() + + expected := `┌───────────────────────────────────────────┬────────┐ +│ samples │ 134844 │ +│ ns/op │ 8071 │ +│ B/op │ 3336 │ +│ allocs/op │ 62 │ +│ histogram_timer_rego_query_eval_ns_75% │ 4954 │ +│ histogram_timer_rego_query_eval_ns_90% │ 6310 │ +│ histogram_timer_rego_query_eval_ns_95% │ 7873 │ +│ histogram_timer_rego_query_eval_ns_99% │ 14947 │ +│ histogram_timer_rego_query_eval_ns_99.9% │ 174377 │ +│ histogram_timer_rego_query_eval_ns_99.99% │ 176301 │ +│ histogram_timer_rego_query_eval_ns_count │ 134844 │ +│ histogram_timer_rego_query_eval_ns_max │ 176301 │ +│ histogram_timer_rego_query_eval_ns_mean │ 5118 │ +│ histogram_timer_rego_query_eval_ns_median │ 4312 │ +│ histogram_timer_rego_query_eval_ns_min │ 3553 │ +│ histogram_timer_rego_query_eval_ns_stddev │ 6588 │ +└───────────────────────────────────────────┴────────┘ +` + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRenderBenchmarkResultGoBenchOutputShowAllocs(t *testing.T) { + t.Parallel() + + params := testBenchParams() + params.benchMem = true + err := params.outputFormat.Set(formats.GoBench) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + br := fakeBenchResults() + + var buf bytes.Buffer + renderBenchmarkResult(params, br, &buf) + + actual := buf.String() + + if !strings.HasPrefix(actual, "Benchmark") { + t.Fatalf("Expected line output to start with 'Benchmark', got: \n\n%s\n", actual) + } + + if len(strings.Split(strings.TrimSpace(actual), "\n")) != 1 { + t.Fatalf("Expected only a single line of output") + } +} + +func TestRenderBenchmarkErrorJSONOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + err := params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + var buf bytes.Buffer + + _, err = ast.ParseBody("???") + + err = renderBenchmarkError(params, err, &buf, nil) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + actual := buf.String() + expected := `{ + "errors": [ + { + "message": "illegal token", + "code": "rego_parse_error", + "location": { + "file": "", + "row": 1, + "col": 1 + }, + "details": { + "line": "???", + "idx": 0 + } + } + ] +} +` + + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func TestRenderBenchmarkErrorPrettyOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + err := params.outputFormat.Set(formats.Pretty) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + testPrettyBenchmarkOutput(t, params) +} + +func TestRenderBenchmarkErrorGoBenchOutput(t *testing.T) { + t.Parallel() + + params := testBenchParams() + err := params.outputFormat.Set(formats.GoBench) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + testPrettyBenchmarkOutput(t, params) +} + +func testPrettyBenchmarkOutput(t *testing.T, params benchmarkCommandParams) { + var buf bytes.Buffer + + _, err := ast.ParseBody("???") + + err = renderBenchmarkError(params, err, &buf, &buf) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + actual := buf.String() + expected := `1 error occurred: 1:1: rego_parse_error: illegal token + ??? + ^ +` + if actual != expected { + t.Fatalf("\nExpected:\n%s\n\nGot:\n%s\n", expected, actual) + } +} + +func testBenchParams() benchmarkCommandParams { + params := newBenchmarkEvalParams() + params.benchMem = true + params.metrics = true + _ = params.outputFormat.Set(formats.JSON) + params.count = 1 + return params +} + +func fakeBenchResults() testing.BenchmarkResult { + return testing.BenchmarkResult{ + N: 134844, + T: 1088294120, + Bytes: 0, + MemAllocs: 8360721, + MemBytes: 449906736, + Extra: map[string]float64{ + "histogram_timer_rego_query_eval_ns_75%": 4953.75, + "histogram_timer_rego_query_eval_ns_90%": 6309.6, + "histogram_timer_rego_query_eval_ns_95%": 7872.55, + "histogram_timer_rego_query_eval_ns_99%": 14947.34000000001, + "histogram_timer_rego_query_eval_ns_99.9%": 174377.08200000023, + "histogram_timer_rego_query_eval_ns_99.99%": 176301, + "histogram_timer_rego_query_eval_ns_count": 134844, + "histogram_timer_rego_query_eval_ns_max": 176301, + "histogram_timer_rego_query_eval_ns_mean": 5118.3706225680935, + "histogram_timer_rego_query_eval_ns_median": 4312, + "histogram_timer_rego_query_eval_ns_min": 3553, + "histogram_timer_rego_query_eval_ns_stddev": 6587.830963916497, + }, + } +} + +func testBundle() bundle.Bundle { + mod := `package a.b + import rego.v1 + + x if { + data.a.b.c == 42 + } + ` + + return bundle.Bundle{ + Manifest: bundle.Manifest{}, + Data: map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "c": 42, + }, + }, + }, + Modules: []bundle.ModuleFile{ + { + Path: "/a/b/policy.rego", + Raw: []byte(mod), + Parsed: ast.MustParseModule(mod), + }, + }, + } +} diff --git a/cmd/bench_test.go b/cmd/bench_test.go index 7c27ef4585..7682f58385 100644 --- a/cmd/bench_test.go +++ b/cmd/bench_test.go @@ -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. diff --git a/cmd/build_jsonv2_test.go b/cmd/build_jsonv2_test.go new file mode 100644 index 0000000000..2b9f3f0f07 --- /dev/null +++ b/cmd/build_jsonv2_test.go @@ -0,0 +1,3484 @@ +//go:build go1.27 + +package cmd + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "reflect" + "slices" + "strconv" + "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" + "github.com/open-policy-agent/opa/v1/util" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestBuildProducesBundle(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + p = 1 + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + 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) + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + if f.Name == "/.manifest" || f.Name == "/data.json" || strings.HasSuffix(f.Name, "/test.rego") { + continue + } + t.Fatal("unexpected file:", f.Name) + } + }) +} + +func TestBuildRespectsCapabilities(t *testing.T) { + //nolint:prealloc // test slice is extended dynamically, initial values are clearer as slice literal + tests := []struct { + note string + caps string + policy string + err string + bundleMode bool // build with "-b" flag + }{ + { + note: "builtin defined in caps", + caps: `{ + "builtins": [ + { + "name": "is_foo", + "decl": { + "args": [ + { + "type": "string" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + } + } + ] + }`, + policy: `package test +p { is_foo("bar") }`, + }, + { + note: "future kw NOT defined in caps", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in"} + c.Features = []string{} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + err: "rego_parse_error: unexpected keyword, must be one of [in]", + }, + { + note: "future kw NOT defined in caps, rego-v1 feature", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in"} + c.Features = []string{ast.FeatureRegoV1} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + }, + { + note: "future kw are defined in caps", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in", "if"} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + }, + { + note: "rego.v1 imported AND defined in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{ast.FeatureRegoV1Import} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + }, + { + note: "rego.v1 imported AND rego-v1 in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{ast.FeatureRegoV1} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + }, + } + + // add same tests for bundle-mode == true: + for i := range tests { + tc := tests[i] + tc.bundleMode = true + tc.note += " (as bundle)" + tests = append(tests, tc) + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "capabilities.json": tc.caps, + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + caps := newCapabilitiesFlag() + if err := caps.Set(path.Join(root, "capabilities.json")); err != nil { + t.Fatal(err) + } + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.capabilities = caps + params.bundleMode = tc.bundleMode + // Test capabilities are all pre-v1 + params.v0Compatible = true + + err := dobuild(params, []string{root}) + switch { + case err != nil && tc.err != "": + if !strings.Contains(err.Error(), tc.err) { + t.Fatalf("expected err %v, got %v", tc.err, err) + } + return // don't read back bundle below + case err != nil && tc.err == "": + t.Fatalf("unexpected error: %v", err) + case err == nil && tc.err != "": + t.Fatalf("expected error %v, got nil", tc.err) + } + + // check that the resulting bundle is readable + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + }) + }) + } +} + +func TestBuildFilesystemModeIgnoresTarGz(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + p = 1 + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Just run the build again to simulate the user doing back-to-back builds. + err = dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + }) +} + +func TestBuildErrorDoesNotWriteFile(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + + p if { p } + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + exp := fmt.Sprintf("1 error occurred: %s/test.rego:4: rego_recursion_error: rule data.test.p is recursive: data.test.p -> data.test.p", + root) + if err == nil || err.Error() != exp { + t.Fatalf("expected recursion error %q but got: %q", exp, err) + } + + if _, err := os.Stat(params.outputFile); !os.IsNotExist(err) { + t.Fatalf("expected stat \"not found\" error, got %v", err) + } + }) +} + +func TestBuildErrorVerifyNonBundle(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + + p if { p } + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.pubKey = "secret" + + err := dobuild(params, []string{root}) + if err == nil { + t.Fatal("expected error but got nil") + } + + exp := "enable bundle mode (ie. --bundle) to verify or sign bundle files or directories" + if err.Error() != exp { + t.Fatalf("expected error message %v but got %v", exp, err.Error()) + } + }) +} + +func TestBuildVerificationConfigError(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("cannot be run as root") + } + + files := map[string]string{ + "public.pem": "foo", + } + + test.WithTempFS(files, func(rootDir string) { + // simulate error while reading file + err := os.Chmod(filepath.Join(rootDir, "public.pem"), 0111) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + _, err = buildVerificationConfig(filepath.Join(rootDir, "public.pem"), "default", "", "", nil) + if err == nil { + t.Fatal("Expected error but got nil") + } + }) +} + +func TestBuildSigningConfigError(t *testing.T) { + tests := []struct { + note string + key, plugin, claimsFile string + expErr bool + }{ + { + note: "key+plugin+claimsFile unset", + }, + { + note: "key+claimsFile unset", + plugin: "plugin", + expErr: true, + }, + { + note: "key+plugin unset", + claimsFile: "claims", + expErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + _, err := buildSigningConfig(tc.key, defaultTokenSigningAlg, tc.claimsFile, tc.plugin) + switch { + case tc.expErr && err == nil: + t.Fatal("Expected error but got nil") + case !tc.expErr && err != nil: + t.Fatalf("Expected no error but got %v", err) + } + }) + } +} + +func TestBuildPlanWithPruneUnused(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + + p contains 1 + + f(x) if { p[x] } + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + if err := params.target.Set("plan"); err != nil { + t.Fatal(err) + } + params.pruneUnused = true + params.entrypoints.v = []string{"test"} + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + 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) + + found := false // for plan.json + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + switch { + case f.Name == "/plan.json": + found = true + case f.Name == "/.manifest" || f.Name == "/data.json" || strings.HasSuffix(f.Name, "/test.rego"): // expected + default: + t.Errorf("unexpected file: %s", f.Name) + } + } + if !found { + t.Error("plan.json not found") + } + }) +} + +func TestBuildPlanProtoFormat(t *testing.T) { + files := map[string]string{ + "test.rego": ` + package test + default p := false + p if input.user == "alice" + `, + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + if err := params.target.Set("plan"); err != nil { + t.Fatal(err) + } + if err := params.planFormat.Set("proto"); err != nil { + t.Fatal(err) + } + params.entrypoints.v = []string{"test"} + params.outputFile = path.Join(root, "bundle.tar.gz") + + if err := dobuild(params, []string{root}); err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(gr) + + var foundPlan, foundManifest bool + var sawJSONPlan, sawJSONManifest bool + + for { + h, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + switch h.Name { + case "/plan.pb": + foundPlan = true + case "/.manifest.pb": + foundManifest = true + case "/plan.json": + sawJSONPlan = true + case "/.manifest": + sawJSONManifest = true + } + } + + if !foundPlan { + t.Error("plan.pb not found in bundle") + } + if !foundManifest { + t.Error("/.manifest.pb not found in bundle") + } + if sawJSONPlan { + t.Error("plan.json should not be present when --format=proto") + } + if sawJSONManifest { + t.Error("/.manifest should not be present when --format=proto") + } + + loaded, err := loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatalf("load proto bundle: %v", err) + } + if len(loaded.PlanModules) != 1 { + t.Fatalf("expected 1 plan module, got %d", len(loaded.PlanModules)) + } + if filepath.Base(loaded.PlanModules[0].Path) != "plan.pb" { + t.Errorf("plan module path should end in plan.pb, got %q", loaded.PlanModules[0].Path) + } + if loaded.Manifest.Empty() { + t.Error("manifest should not be empty after loading proto bundle") + } + }) +} + +func TestBuildFormatRequiresPlanTarget(t *testing.T) { + files := map[string]string{ + "test.rego": `package test`, + } + test.WithTempFS(files, func(root string) { + params := newBuildParams() + if err := params.planFormat.Set("proto"); err != nil { + t.Fatal(err) + } + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "plan") { + t.Fatalf("error should mention plan target, got: %v", err) + } + }) +} + +func TestBuildPlanWithPrintStatements(t *testing.T) { + + files := map[string]string{ + "test.rego": ` + package test + + p if { print("hello") } + `, + } + + 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") + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if 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 found bool + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + if f.Name == "/plan.json" { + found = true + plan, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(plan), "internal.print") { + t.Error("expected plan.json to contain reference to internal.print built-in function") + } + } + } + + if !found { + t.Error("plan.json not found") + } + }) +} + +func TestBuildPlanWithRegoEntrypointAnnotations(t *testing.T) { + + tests := []struct { + note string + files map[string]string + err error + v0Compatible bool + }{ + { + note: "annotated entrypoint", + files: map[string]string{ + "test.rego": ` +# METADATA +# entrypoint: true +package test + +p contains 1 + +f(x) if { p[x] } + `, + }, + err: nil, + }, + { + note: "set generation with annotated entrypoint (v0)", + v0Compatible: true, + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p[x] { + {"a", "b"}[x] +} + `, + }, + err: nil, + }, + { + note: "set generation with annotated entrypoint (contains if)", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p contains x if { + {"a", "b"}[x] +} + `, + }, + err: nil, + }, + { + note: "object generation with annotated entrypoint (v0)", + v0Compatible: true, + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p[i] := x { + x := ["a", "b"][i] +} + `, + }, + err: nil, + }, + { + note: "object generation with annotated entrypoint (if)", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p[i] if { + {"a", "b"}[i] +} + `, + }, + err: nil, + }, + { + note: "dots in head with annotated entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p.a.b if { + true +} + `, + }, + err: nil, + }, + { + note: "dots in head object generation with annotated entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# entrypoint: true +p.a.b[i] := x if { + x := ["a", "b"][i] +} + `, + }, + err: nil, + }, + { + note: "no annotated entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +p contains 1 + +f(x) if { p[x] } +`, + }, + err: errors.New("plan compilation requires at least one entrypoint"), + }, + } + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + if err := params.target.Set("plan"); err != nil { + t.Fatal(err) + } + params.pruneUnused = true + params.outputFile = path.Join(root, "bundle.tar.gz") + params.v0Compatible = tc.v0Compatible + + // Build should fail if entrypoint is not discovered from annotations. + err := dobuild(params, []string{root}) + if err != nil { + if tc.err == nil || tc.err.Error() != err.Error() { + t.Fatal(err) + } + return // Bail out if this was an expected test failure. + } + + // Attempt to load up the built bundle. + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + }) + }) + } +} + +func TestBuildWasmWithAnnotations(t *testing.T) { + tests := []struct { + note string + files map[string]string + entrypoints []string + manifest string + }{ + { + note: "last rule is annotated entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +# entrypoint: true +p2 := 2 +`, + }, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test/p2", + "module":"/policy.wasm", + "annotations":[{ + "scope":"document", + "title":"P2", + "entrypoint":true + }] + }] +} +`, + }, + { + note: "last rule is (not annotated) entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +p2 := 2 +`, + }, + entrypoints: []string{"test/p2"}, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test/p2", + "module":"/policy.wasm", + "annotations":[{ + "scope":"rule", + "title":"P2" + }] + }] +} +`, + }, + { + note: "rules in multiple files are entrypoints", + files: map[string]string{ + "test1.rego": ` +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +# entrypoint: true +p2 := 2 +`, + "test2.rego": ` +package test + +# METADATA +# title: P3 +p3 := 3 + +# METADATA +# title: P4 +p4 := 4 +`, + "test3.rego": ` +package test.foo + +# METADATA +# title: BAR +# entrypoint: true +bar := "baz" +`, + }, + entrypoints: []string{"test/p3"}, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test/p3", + "annotations":[{"scope":"rule","title":"P3"}], + "module":"/policy.wasm" + },{ + "entrypoint":"test/foo/bar", + "module":"/policy.wasm", + "annotations":[{ + "scope":"document", + "title":"BAR", + "entrypoint":true + }] + },{ + "entrypoint":"test/p2", + "module":"/policy.wasm", + "annotations":[{ + "scope":"document", + "title":"P2", + "entrypoint":true + }] + }] +} +`, + }, + { + note: "rule with multiple metadata blocks", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# title: P doc +# scope: document +# entrypoint: true + +# METADATA +# title: P +p := 1 +`, + }, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test/p", + "module":"/policy.wasm", + "annotations":[{ + "scope":"document", + "title":"P doc", + "entrypoint":true + },{ + "scope":"rule", + "title":"P" + }] + }] +} +`, + }, + + // Package annotations are not injected into manifest, as package definition is always retained in Rego source. + { + note: "package is annotated entrypoint", + files: map[string]string{ + "test.rego": ` +# METADATA +# title: PKG +# entrypoint: true +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +p2 := 2 +`, + }, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test", + "module":"/policy.wasm" + }] +} +`, + }, + { + note: "package is (not annotated) entrypoint", + files: map[string]string{ + "test.rego": ` +package test + +# METADATA +# title: P1 +p1 := 1 + +# METADATA +# title: P2 +p2 := 2 +`, + }, + entrypoints: []string{"test"}, + manifest: ` +{ + "revision":"", + "rego_version": %REGO_VERSION%, + "roots":[""], + "wasm":[{ + "entrypoint":"test", + "module":"/policy.wasm" + }] +} +`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + if err := params.target.Set("wasm"); err != nil { + t.Fatal(err) + } + params.pruneUnused = true + params.outputFile = path.Join(root, "bundle.tar.gz") + params.entrypoints.v = tc.entrypoints + + // Build should fail if entrypoint is not discovered from annotations. + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest has expected content + 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) + + expManifest := strings.ReplaceAll(tc.manifest, "%REGO_VERSION%", + strconv.Itoa(ast.DefaultRegoVersion.Int())) + + found := false + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + if f.Name == "/.manifest" { + found = true + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + manifest := util.MustUnmarshalJSON(data) + if !reflect.DeepEqual(manifest, util.MustUnmarshalJSON([]byte(expManifest))) { + t.Fatalf("expected manifest\n\n%v\n\nbut got\n\n%v", expManifest, string(util.MustMarshalJSON(manifest))) + } + break + } + } + + if !found { + t.Fatal("no manifest found in bundle") + } + }) + }) + } +} + +func TestBuildBundleModeIgnoreFlag(t *testing.T) { + + files := map[string]string{ + "/a/b/d/data.json": `{"e": "f"}`, + "/policy.rego": "package foo\n p = 1", + "/policy_test.rego": "package foo\n test_p { p }", + "/roles/policy.rego": "package bar\n p = 1", + "/roles/policy_test.rego": "package bar\n test_p { p }", + "/deeper/dir/path/than/others/policy.rego": "package baz\n p = 1", + "/deeper/dir/path/than/others/policy_test.rego": "package baz\n test_p { p }", + } + + test.WithTempFS(files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.bundleMode = true + params.ignore = []string{"*_test.rego"} + + err := dobuild(params, []string{root}) + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that test files are not included in the output bundle + 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) + + files := []string{} + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + + files = append(files, filepath.Base(f.Name)) + } + + // We additionally expect a manifest file + expected := 5 + if len(files) != expected { + t.Fatalf("expected %v files but got %v", expected, len(files)) + } + }) +} + +func TestBuildBundleModeWithManifestRegoVersion(t *testing.T) { + tests := []struct { + note string + roots []string + files map[string]string + expManifest string + expErrs []string + v0Compatible bool + v1Compatible bool + capabilities *ast.Capabilities + }{ + { + note: "v0 bundle rego-version", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "test.rego": `package test + +p[42] { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":[""],"rego_version":0}`, + }, + { + note: "v1 bundle rego-version", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "test.rego": `package test + +p contains 42 if { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":[""],"rego_version":1}`, + }, + { + note: "v0 bundle rego-version, v1 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "test1.rego": `package test + +p[1] { + input.x == 1 +}`, + "test2.rego": `package test + +p contains 2 if { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":[""],"rego_version":0,"file_rego_versions":{"%ROOT%/test2.rego":1}}`, + }, + { + note: "v0 bundle rego-version, v1 per-file override, missing v1 keywords in v1 file", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "test1.rego": `package test + +p[1] { + input.x == 1 +}`, + "test2.rego": `package test + +p[2] { + 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: "v0 bundle rego-version, v1 per-file override, v1 keywords but no v1 imports in v0 file", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "test1.rego": `package test + +p contains 1 if { + input.x == 1 +}`, + "test2.rego": `package test + +p contains 2 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", + }, + }, + { + note: "multiple bundles with different rego-versions, v0-compatible", + v0Compatible: true, + roots: []string{"bundle1", "bundle2"}, + files: map[string]string{ + "bundle1/.manifest": `{ + "roots": ["test1"], + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "bundle1/test1.rego": `package test1 +p[1] { + input.x == 1 +}`, + "bundle1/test2.rego": `package test1 +p contains 2 if { + input.x == 1 +}`, + "bundle2/.manifest": `{ + "roots": ["test2"], + "rego_version": 1, + "file_rego_versions": { + "*/test4.rego": 0 + } +}`, + "bundle2/test3.rego": `package test2 +p contains 3 if { + input.x == 1 +}`, + "bundle2/test4.rego": `package test2 +p[4] { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":["test1","test2"],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle1/test2.rego":1,"%ROOT%/bundle2/test3.rego":1}}`, + }, + { + note: "multiple bundles with different rego-versions, v0-compatible, no rego_v1 capabilities feature", + v0Compatible: true, + roots: []string{"bundle1", "bundle2"}, + files: map[string]string{ + "bundle1/.manifest": `{ + "roots": ["test1"], + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "bundle1/test1.rego": `package test1 +p[1] { + input.x == 1 +}`, + "bundle1/test2.rego": `package test1 +p contains 2 if { + input.x == 1 +}`, + "bundle2/.manifest": `{ + "roots": ["test2"], + "rego_version": 1, + "file_rego_versions": { + "*/test4.rego": 0 + } +}`, + "bundle2/test3.rego": `package test2 +p contains 3 if { + input.x == 1 +}`, + "bundle2/test4.rego": `package test2 +p[4] { + input.x == 1 +}`, + }, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + expErrs: []string{ + // capabilities doesn't include rego_v1 feature, which must be respected + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "multiple bundles with different rego-versions, v0-compatible, rego_v1 capabilities feature", + v0Compatible: true, + roots: []string{"bundle1", "bundle2"}, + files: map[string]string{ + "bundle1/.manifest": `{ + "roots": ["test1"], + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "bundle1/test1.rego": `package test1 +p[1] { + input.x == 1 +}`, + "bundle1/test2.rego": `package test1 +p contains 2 if { + input.x == 1 +}`, + "bundle2/.manifest": `{ + "roots": ["test2"], + "rego_version": 1, + "file_rego_versions": { + "*/test4.rego": 0 + } +}`, + "bundle2/test3.rego": `package test2 +p contains 3 if { + input.x == 1 +}`, + "bundle2/test4.rego": `package test2 +p[4] { + input.x == 1 +}`, + "capabilities.json": func() string { + caps := ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)) + caps.Features = append(caps.Features, ast.FeatureRegoV1) + bs, err := json.Marshal(caps) + if err != nil { + t.Fatal(err) + } + return string(bs) + }(), + }, + expManifest: `{"revision":"","roots":["test1","test2"],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle1/test2.rego":1,"%ROOT%/bundle2/test3.rego":1}}`, + }, + { + note: "multiple bundles with different rego-versions, v1-compatible", + v1Compatible: true, + roots: []string{"bundle1", "bundle2"}, + files: map[string]string{ + "bundle1/.manifest": `{ + "roots": ["test1"], + "rego_version": 0, + "file_rego_versions": { + "*/test2.rego": 1 + } +}`, + "bundle1/test1.rego": `package test1 +p[1] { + input.x == 1 +}`, + "bundle1/test2.rego": `package test1 +p contains 2 if { + input.x == 1 +}`, + "bundle2/.manifest": `{ + "roots": ["test2"], + "rego_version": 1, + "file_rego_versions": { + "*/test4.rego": 0 + } +}`, + "bundle2/test3.rego": `package test2 +p contains 3 if { + input.x == 1 +}`, + "bundle2/test4.rego": `package test2 +p[4] { + input.x == 1 +}`, + }, + expManifest: `{"revision":"","roots":["test1","test2"],"rego_version":1,"file_rego_versions":{"%ROOT%/bundle1/test1.rego":0,"%ROOT%/bundle2/test4.rego":0}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.bundleMode = true + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + if tc.capabilities != nil { + params.capabilities = newCapabilitiesFlag() + params.capabilities.C = tc.capabilities + } + + if _, ok := tc.files["capabilities.json"]; ok { + _ = params.capabilities.Set(path.Join(root, "capabilities.json")) + } + + var roots []string + if len(tc.roots) == 0 { + roots = []string{root} + } else { + for _, r := range tc.roots { + roots = append(roots, path.Join(root, r)) + } + } + err := dobuild(params, roots) + if tc.expErrs != nil { + if err == nil { + t.Fatal("expected error but got none") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected error:\n\n%q\n\nbut got:\n\n%v", expErr, err) + } + } + } else { + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = f.Close() + }() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + + if f.Name == "/.manifest" { + b, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + expManifest := strings.ReplaceAll(tc.expManifest, "%ROOT%", root) + if !strings.Contains(string(b), expManifest) { + t.Fatalf("expected manifest:\n\n%v\n\nbut got:\n\n%v", expManifest, string(b)) + } + } + } + } + }) + }) + } +} + +func capsWithoutFeat(regoVersion ast.RegoVersion, feat ...string) *ast.Capabilities { + caps := ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(regoVersion)) + + feats := make([]string, 0, len(caps.Features)) + for _, f := range caps.Features { + skip := slices.Contains(feat, f) + if !skip { + feats = append(feats, f) + } + } + caps.Features = feats + + return caps +} + +func TestBuildBundleFromOtherBundles(t *testing.T) { + type bundleInfo map[string]string + + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + capabilities *ast.Capabilities + bundles map[string]bundleInfo + expBundle bundleInfo + expErrs []string + }{ + { + note: "single bundle", + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + "policy.rego": `package test + +p := input.x == 1 +`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":%DEFAULT_REGO_VERSION%} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p := input.x == 1 +`, + }, + }, + { + note: "single bundle, --v1-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + "policy.rego": `package test +p if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p if { + input.x == 1 +} +`, + }, + }, + { + note: "single v0 bundle", + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p { + input.x == 1 +} +`, + }, + }, + { + note: "single v0 bundle, --v1-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p { + input.x == 1 +}`, + }, + }, + // We don't expect parse/compile errors, as the bundle rego-version is 0, which overrides the --v1-compatible flag. + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p { + input.x == 1 +} +`, + }, + }, + { + note: "single v1 bundle, --v0-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p if { + input.x == 1 +}`, + }, + }, + // We don't expect parse/compile errors, as the bundle rego-version is 0, which overrides the --v1-compatible flag. + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "%ROOT%/bundle.tar.gz/policy.rego": `package test + +p if { + input.x == 1 +} +`, + }, + }, + { + note: "single v0 bundle, v1 per-file override", + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy_1.rego": 1 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle.tar.gz/policy_1.rego":1}} +`, + "%ROOT%/bundle.tar.gz/policy_0.rego": `package test + +p { + input.x == 1 +} +`, + "%ROOT%/bundle.tar.gz/policy_1.rego": `package test + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "single v0 bundle, v1 per-file override, --v1-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy_1.rego": 1 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle.tar.gz/policy_1.rego":1}} +`, + "%ROOT%/bundle.tar.gz/policy_0.rego": `package test + +p { + input.x == 1 +} +`, + "%ROOT%/bundle.tar.gz/policy_1.rego": `package test + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "single v1 bundle, v0 per-file override", + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy_0.rego": 0 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":1,"file_rego_versions":{"%ROOT%/bundle.tar.gz/policy_0.rego":0}} +`, + "%ROOT%/bundle.tar.gz/policy_0.rego": `package test + +p { + input.x == 1 +} +`, + "%ROOT%/bundle.tar.gz/policy_1.rego": `package test + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "single v1 bundle, v0 per-file override, --v0-compatible", + v0Compatible: true, + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy_0.rego": 0 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":[""],"rego_version":1,"file_rego_versions":{"%ROOT%/bundle.tar.gz/policy_0.rego":0}} +`, + "%ROOT%/bundle.tar.gz/policy_0.rego": `package test + +p { + input.x == 1 +} +`, + "%ROOT%/bundle.tar.gz/policy_1.rego": `package test + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "single v1 bundle, v0 per-file override, --v0-compatible, no rego_v1 capabilities feature", + v0Compatible: true, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + bundles: map[string]bundleInfo{ + "bundle.tar.gz": { + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy_0.rego": 0 + } +}`, + "policy_0.rego": `package test +p { + input.x == 1 +}`, + "policy_1.rego": `package test +q contains 1 if { + input.x == 1 +}`, + }, + }, + expErrs: []string{ + // capabilities doesn't include rego_v1 feature, which must be respected + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 bundle + v1 bundle, --v0-compatible", + v0Compatible: true, + bundles: map[string]bundleInfo{ + "bundle_v0.tar.gz": { + ".manifest": `{"roots": ["test1"], "rego_version": 0}`, + "policy.rego": `package test1 +p { + input.x == 1 +}`, + }, + "bundle_v1.tar.gz": { + ".manifest": `{"roots": ["test2"], "rego_version": 1}`, + "policy.rego": `package test2 +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + "/.manifest": `{"revision":"","roots":["test1","test2"],"rego_version":0,"file_rego_versions":{"%ROOT%/bundle_v1.tar.gz/policy.rego":1}} +`, + "%ROOT%/bundle_v0.tar.gz/policy.rego": `package test1 + +p { + input.x == 1 +} +`, + "%ROOT%/bundle_v1.tar.gz/policy.rego": `package test2 + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + { + note: "v0 bundle + v1 bundle, --v0-compatible, no rego_v1 capabilities feature", + v0Compatible: true, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + bundles: map[string]bundleInfo{ + "bundle_v0.tar.gz": { + ".manifest": `{"roots": ["test1"], "rego_version": 0}`, + "policy.rego": `package test1 +p { + input.x == 1 +}`, + }, + "bundle_v1.tar.gz": { + ".manifest": `{"roots": ["test2"], "rego_version": 1}`, + "policy.rego": `package test2 +q contains 1 if { + input.x == 1 +}`, + }, + }, + expErrs: []string{ + // capabilities inferred from --v0-compatible doesn't include rego_v1 feature, which must be respected + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 bundle + v1 bundle, --v1-compatible", + v1Compatible: true, + bundles: map[string]bundleInfo{ + "bundle_v0.tar.gz": { + ".manifest": `{"roots": ["test1"], "rego_version": 0}`, + "policy.rego": `package test1 +p { + input.x == 1 +}`, + }, + "bundle_v1.tar.gz": { + ".manifest": `{"roots": ["test2"], "rego_version": 1}`, + "policy.rego": `package test2 +q contains 1 if { + input.x == 1 +}`, + }, + }, + expBundle: bundleInfo{ + "/data.json": `{} +`, + // We get a v1 bundle with a v0 per-file override + "/.manifest": `{"revision":"","roots":["test1","test2"],"rego_version":1,"file_rego_versions":{"%ROOT%/bundle_v0.tar.gz/policy.rego":0}} +`, + "%ROOT%/bundle_v0.tar.gz/policy.rego": `package test1 + +p { + input.x == 1 +} +`, + "%ROOT%/bundle_v1.tar.gz/policy.rego": `package test2 + +q contains 1 if { + input.x == 1 +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + root := t.TempDir() + var roots []string + for name, files := range tc.bundles { + p := filepath.Join(root, name) + roots = append(roots, p) + filePairs := make([][2]string, 0, len(files)) + for k, v := range files { + filePairs = append(filePairs, [2]string{k, v}) + } + buf := archive.MustWriteTarGz(filePairs) + 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 := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.bundleMode = true + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + if tc.capabilities != nil { + params.capabilities.C = tc.capabilities + } + + err := dobuild(params, roots) + if tc.expErrs != nil { + if err == nil { + t.Fatal("expected error but got none") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected error:\n\n%q\n\nbut got:\n\n%v", expErr, err) + } + } + } else { + if err != nil { + t.Fatal(err) + } + + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + f, err := os.Open(params.outputFile) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = f.Close() + }() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + tr := tar.NewReader(gr) + + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + + found := false + for expName, expVal := range tc.expBundle { + expName = strings.ReplaceAll(expName, "%ROOT%", root) + if f.Name == expName { + found = true + b, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + expVal = strings.ReplaceAll(expVal, "%ROOT%", root) + expVal = strings.ReplaceAll(expVal, "%DEFAULT_REGO_VERSION%", + strconv.Itoa(ast.DefaultRegoVersion.Int())) + if string(b) != expVal { + t.Fatalf("expected %v:\n\n%v\n\nbut got:\n\n%v", expName, expVal, string(b)) + } + break + } + } + if !found { + t.Fatalf("unexpected file in bundle: %v", f.Name) + } + } + } + }) + } +} + +func TestBuild_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + expFiles map[string]string + expErrs []string + }{ + { + note: "v0 module", + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + 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", + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +p contains x if { + x := 42 +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + + err := dobuild(params, []string{root}) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatal("expected error but got nil") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + } else { + if err != nil { + t.Fatal(err) + } + + fl := loader.NewFileLoader() + _, err = fl.AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + 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) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[path.Base(f.Name)] = struct{}{} + expectedFile := tc.expFiles[path.Base(f.Name)] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + } + }) + }) + } +} + +func TestBuildWithRegoV1Capability(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + capabilities *ast.Capabilities + files map[string]string + expFiles map[string]string + expErrs []string + }{ + { + note: "v0 module, v0-compatible, no capabilities", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +p[x] { + x := 42 +} +`, + }, + }, + { + note: "v0 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +p[x] { + x := 42 +} +`, + }, + }, + { + note: "v0 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +p[x] { + x := 42 +} +`, + }, + }, + + { + note: "v0 module, not v0-compatible, no capabilities", + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + 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: "v0 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + 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: "v0 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "test.rego": `package test + p[x] { + x := 42 + }`, + }, + 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, v0-compatible, no capabilities", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:3: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:3: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expErrs: []string{ + "test.rego:3: rego_parse_error: var cannot be used for rule name", + }, + }, + + { + note: "v1 module, not v0-compatible, no capabilities", + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +p contains x if { + x := 42 +} +`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v1 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "test.rego": `package test + + p contains x if { + x := 42 + }`, + }, + expFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +p contains x if { + x := 42 +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.v0Compatible = tc.v0Compatible + params.capabilities.C = tc.capabilities + + err := dobuild(params, []string{root}) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatal("expected error but got nil") + } + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + } else { + if err != nil { + t.Fatal(err) + } + + fl := loader.NewFileLoader() + _, err = fl.AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + 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) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[path.Base(f.Name)] = struct{}{} + expectedFile := tc.expFiles[path.Base(f.Name)] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + } + }) + }) + } +} + +func TestBuildWithCompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + files map[string]string + expectedFiles map[string]string + expectedErr string + }{ + { + note: "v0 compatibility: policy with no rego.v1 or future.keywords imports", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expectedErr: "rego_parse_error", + }, + { + note: "v0 compatibility: policy with rego.v1 imports", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + // Imports are preserved + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +import rego.v1 + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v0 compatibility: policy with future.keywords imports", + v0Compatible: true, + files: map[string]string{ + "test.rego": `package test + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + // Imports are preserved + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +import future.keywords.if + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: policy with no rego.v1 or future.keywords imports", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + // Imports are not added in + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: policy with rego.v1 import", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + // the rego.v1 import is kept to maximize compatibility surface + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +import rego.v1 + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: policy with future.keywords import", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + // future.keywords imports are kept to maximize compatibility surface + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +import future.keywords.if + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: policy with rego.v1 and future.keywords imports", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import rego.v1 + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + // future.keywords are dropped as these are covered by rego.v1 + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "test.rego": `package test + +import rego.v1 + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v1 compatibility: missing keywords", + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + allow[1] { + 1 < 2 + }`, + }, + expectedErr: "rego_parse_error", + }, + // v0 takes precedence over v1 + { + note: "v0+v1 compatibility: policy with no rego.v1 or future.keywords imports", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expectedErr: "rego_parse_error", + }, + { + note: "v0+v1 compatibility: policy with rego.v1 imports", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + // Imports are preserved + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +import rego.v1 + +allow if { + 1 < 2 +} +`, + }, + }, + { + note: "v0+v1 compatibility: policy with future.keywords imports", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "test.rego": `package test + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + // Imports are preserved + expectedFiles: map[string]string{ + ".manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + "test.rego": `package test + +import future.keywords.if + +allow if { + 1 < 2 +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + err := dobuild(params, []string{root}) + + if tc.expectedErr != "" { + if err == nil { + t.Fatal("expected error but got nil") + } + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Fatalf("expected error %v, got %v", tc.expectedErr, err) + } + } else { + if err != nil { + t.Fatal(err) + } + + fl := loader.NewFileLoader() + if tc.v1Compatible { + fl = fl.WithRegoVersion(ast.RegoV1) + } + _, err = fl.AsBundle(params.outputFile) + if err != nil { + t.Fatal(err) + } + + // Check that manifest is not written given no input manifest and no other flags + 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) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[path.Base(f.Name)] = struct{}{} + expectedFile := tc.expectedFiles[path.Base(f.Name)] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expectedFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + } + }) + }) + } +} + +func TestBuildOptimizedLevel2(t *testing.T) { + tests := []struct { + note string + files map[string]string + expectedFiles map[string]string + }{ + { + note: "Parse package path correctly", + files: map[string]string{ + "test.rego": `package demo["test-policy"] + +# METADATA +# entrypoint: true +default allow := false + +allow if { + some value in input +} +`, + }, + expectedFiles: map[string]string{ + "/optimized/demo/test-policy.rego": `package demo["test-policy"] + +default allow := false + +allow if __local2__1 = input[__local1__1] +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.optimizationLevel = 2 + + err := dobuild(params, []string{root}) + + if 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) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[f.Name] = struct{}{} + expectedFile := tc.expectedFiles[f.Name] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expectedFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + }) + }) + } +} + +func TestBuildOptimizedWithRegoVersion(t *testing.T) { + tests := []struct { + note string + v1Compatible bool + regoV1ImportCapable bool + files map[string]string + expectedFiles map[string]string + }{ + { + note: "v0, no future keywords", + v1Compatible: false, + regoV1ImportCapable: true, + files: map[string]string{ + "test.rego": `package test +# METADATA +# entrypoint: true +p[v] { + v := input.v +} +`, + }, + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + // rego.v1 import added to optimized support module + "/optimized/test.rego": `package test + +import rego.v1 + +p contains __local0__1 if { + __local0__1 = input.v +} +`, + }, + }, + { + note: "v0, No future keywords, not rego.v1 import capable", + v1Compatible: false, + regoV1ImportCapable: false, + files: map[string]string{ + "test.rego": `package test +# METADATA +# entrypoint: true +p[v] { + v := input.v +} +`, + }, + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":0} +`, + // rego.v1 import NOT added to optimized support module + "/optimized/test.rego": `package test + +p[__local0__1] { + __local0__1 = input.v +} +`, + }, + }, + { + note: "v1, No imports", + v1Compatible: true, + regoV1ImportCapable: true, + files: map[string]string{ + "test.rego": `package test +# METADATA +# entrypoint: true +p[k] contains v if { + k := "foo" + v := input.v +} +`, + }, + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "/optimized/test/p.rego": `package test.p + +foo contains __local1__1 if { + __local1__1 = input.v +} +`, + }, + }, + { + note: "v1, rego.v1 imported", + v1Compatible: true, + regoV1ImportCapable: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 +# METADATA +# entrypoint: true +p[k] contains v if { + k := "foo" + v := input.v +} +`, + }, + // Note: the rego.v1 import isn't added to the optimized module. + // This is ok, as the bundle was built with the --v1-compatible flag, + // and is tagged with a rego-version to inform the consumer. + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "/optimized/test/p.rego": `package test.p + +foo contains __local1__1 if { + __local1__1 = input.v +} +`, + }, + }, + { + note: "v1, future.keywords imported", + v1Compatible: true, + regoV1ImportCapable: true, + files: map[string]string{ + "test.rego": `package test +import future.keywords +# METADATA +# entrypoint: true +p[k] contains v if { + k := "foo" + v := input.v +} +`, + }, + expectedFiles: map[string]string{ + "/.manifest": `{"revision":"","roots":[""],"rego_version":1} +`, + "/optimized/test/p.rego": `package test.p + +foo contains __local1__1 if { + __local1__1 = input.v +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "bundle.tar.gz") + params.v0Compatible = !tc.v1Compatible + params.v1Compatible = tc.v1Compatible + params.optimizationLevel = 1 + + if !tc.regoV1ImportCapable { + caps := newCapabilitiesFlag() + caps.C = ast.CapabilitiesForThisVersion() + caps.C.Features = []string{ + ast.FeatureRefHeadStringPrefixes, + ast.FeatureRefHeads, + } + params.capabilities = caps + } + + err := dobuild(params, []string{root}) + + if 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) + + foundFiles := map[string]struct{}{} + for { + f, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + foundFiles[f.Name] = struct{}{} + expectedFile := tc.expectedFiles[f.Name] + if expectedFile != "" { + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + actualFile := string(data) + if actualFile != expectedFile { + t.Fatalf("expected file %s to be:\n\n%v\n\ngot:\n\n%v", f.Name, expectedFile, actualFile) + } + } + } + + for expectedFile := range tc.expectedFiles { + if _, ok := foundFiles[expectedFile]; !ok { + t.Fatalf("expected file %s not found in bundle, got: %v", expectedFile, foundFiles) + } + } + }) + }) + } +} + +// TestBuildWithFollowSymlinks tests that the build command follows symlinks when building a bundle. +// This test uses a local tmp filesystem to create a directory with a symlink to a file in it's root +// and a local file in the bundle directory, and verifies that the built bundle contains both the symlink +// and the regular file. +// There's probably some common utilities that could be extracted at some point but for now this code is +// local to the test until we need to reuse it elsewhere. +func TestBuildWithFollowSymlinks(t *testing.T) { + rootDir := t.TempDir() + bundleDir := path.Join(rootDir, "bundle") + err := os.Mkdir(bundleDir, 0777) + if err != nil { + t.Fatal(err) + } + + // create a regular file in our temp bundle directory + err = os.WriteFile(filepath.Join(bundleDir, "foo.rego"), []byte("package foo\none = 1"), 0777) + if err != nil { + t.Fatal(err) + } + + // create a regular file in the root directory of our tmp directory that we will symlink into the bundle directory later + err = os.WriteFile(filepath.Join(rootDir, "bar.rego"), []byte("package foo\ntwo = 2"), 0777) + if err != nil { + t.Fatal(err) + } + + // create a symlink in the bundle directory to the file in the root directory + err = os.Symlink(filepath.Join(rootDir, "bar.rego"), filepath.Join(bundleDir, "bar.rego")) + if err != nil { + t.Fatal(err) + } + + params := newBuildParams() + params.outputFile = path.Join(rootDir, "test.tar.gz") + params.bundleMode = true + params.followSymlinks = true + params.v1Compatible = true + + err = dobuild(params, []string{bundleDir}) + if err != nil { + t.Fatal(err) + } + + // verify that the bundle is a loadable bundle + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if 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) + + // map of file name -> file content + expectedFiles := map[string]string{ + bundleDir + "/foo.rego": "package foo\n\none := 1", + bundleDir + "/bar.rego": "package foo\n\ntwo := 2", + "/.manifest": `{"revision":"","roots":[""],"rego_version":1}`, + "/data.json": "{}", + } + + foundFiles := make(map[string]string, 4) + for f, err := tr.Next(); err != io.EOF; f, err = tr.Next() { + if err != nil { + t.Fatal(err) + } + + // ensure that all the files are regular files in the bundle + // and that no symlinks were copied + if mode := f.FileInfo().Mode(); !mode.IsRegular() { + t.Fatalf("expected regular file for file %s but got %s", f.FileInfo().Name(), mode.String()) + } + // read the file content + data, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("failed to read file %s: %v", f.FileInfo().Name(), err) + } + foundFiles[f.Name] = string(data) + } + + if len(foundFiles) != 4 { + t.Fatalf("expected four files in bundle but got %d", len(foundFiles)) + } + + for name, contents := range foundFiles { + // trim added whitespace because it's annoying and makes the test less readable + contents := strings.Trim(contents, "\n") + // check that the file content matches the expected content + expectedContent, ok := expectedFiles[name] + if !ok { + t.Fatalf("unexpected file %s in bundle", name) + } + + if contents != expectedContent { + t.Fatalf("expected file %s to contain:\n\n%v\n\ngot:\n\n%v", name, expectedContent, contents) + } + } +} + +// TestBuildWithFollowSymlinksEntireDir tests that the build command can build a bundle from a symlinked directory. +// This test uses a local tmp filesystem to create a directory with a local file in the bundle directory, and +// verifies that the built bundle contains the files from the symlinked directory. +func TestBuildWithFollowSymlinksEntireDir(t *testing.T) { + rootDir := t.TempDir() + defer func() { + if err := os.RemoveAll(rootDir); err != nil { + t.Fatal(err) + } + }() + bundleDir := path.Join(rootDir, "src") + err := os.Mkdir(bundleDir, 0777) + if err != nil { + t.Fatal(err) + } + + // create a regular file in our temp bundle directory + err = os.WriteFile(filepath.Join(bundleDir, "foo.rego"), []byte("package foo\none = 1"), 0777) + if err != nil { + t.Fatal(err) + } + + symlinkDir := path.Join(rootDir, "symlink") + err = os.Mkdir(symlinkDir, 0777) + if err != nil { + t.Fatal(err) + } + + // create a symlink in the symlink directory to the src directory + err = os.Symlink(bundleDir, filepath.Join(symlinkDir, "linked")) + if err != nil { + t.Fatal(err) + } + + params := newBuildParams() + params.outputFile = path.Join(rootDir, "test.tar.gz") + params.bundleMode = true + params.followSymlinks = true + params.v1Compatible = true + + err = dobuild(params, []string{symlinkDir + "/linked/"}) + if err != nil { + t.Fatal(err) + } + + // verify that the bundle is a loadable bundle + _, err = loader.NewFileLoader().AsBundle(params.outputFile) + if 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) + + // map of file name -> file content + expectedFiles := map[string]string{ + path.Join(symlinkDir, "linked", "foo.rego"): "package foo\n\none := 1", + "/.manifest": `{"revision":"","roots":[""],"rego_version":1}`, + "/data.json": "{}", + } + + foundFiles := make(map[string]string, 3) + for f, err := tr.Next(); err != io.EOF; f, err = tr.Next() { + if err != nil { + t.Fatal(err) + } + + // ensure that all the files are regular files in the bundle + // and that no symlinks were copied + if mode := f.FileInfo().Mode(); !mode.IsRegular() { + t.Fatalf("expected regular file for file %s but got %s", f.FileInfo().Name(), mode.String()) + } + // read the file content + data, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("failed to read file %s: %v", f.FileInfo().Name(), err) + } + foundFiles[f.Name] = string(data) + } + + if len(foundFiles) != 3 { + t.Fatalf("expected three files in bundle but got %d", len(foundFiles)) + } + + for name, contents := range foundFiles { + // trim added whitespace because it's annoying and makes the test less readable + contents := strings.Trim(contents, "\n") + // check that the file content matches the expected content + expectedContent, ok := expectedFiles[name] + if !ok { + t.Fatalf("unexpected file %s in bundle", name) + } + + if contents != expectedContent { + t.Fatalf("expected file %s to contain:\n\n%v\n\ngot:\n\n%v", name, expectedContent, contents) + } + } +} + +func TestBuildManifestWarning(t *testing.T) { + testCases := map[string]struct { + files map[string]string + bundleMode bool + buildArgs []string + expectedStderr func(root string) string + }{ + "warns when manifest ignored": { + files: map[string]string{ + "bundle/.manifest": `{"revision":"1.0.0","roots":["foo"]}`, + "bundle/data.json": `{"data": "value"}`, + }, + bundleMode: false, + buildArgs: []string{"bundle"}, + expectedStderr: func(root string) string { + return fmt.Sprintf("Warning: .manifest file found in %q but -b flag not specified. Manifest will be ignored.\n", path.Join(root, "bundle")) + }, + }, + "no warning when bundle mode enabled": { + files: map[string]string{ + "bundle/.manifest": `{"revision":"1.0.0","roots":["foo"]}`, + "bundle/foo/data.json": `{"data": "value"}`, + }, + bundleMode: true, + buildArgs: []string{"bundle"}, + expectedStderr: func(root string) string { + return "" + }, + }, + "no warning when no manifest exists": { + files: map[string]string{ + "bundle/data.json": `{"data": "value"}`, + }, + bundleMode: false, + buildArgs: []string{"bundle"}, + expectedStderr: func(root string) string { + return "" + }, + }, + "warns for multiple bundles with manifests": { + files: map[string]string{ + "bundle1/.manifest": `{"revision":"1.0.0","roots":["foo"]}`, + "bundle1/foo/data.json": `{"data1": "value1"}`, + "bundle2/.manifest": `{"revision":"2.0.0","roots":["bar"]}`, + "bundle2/bar/data.json": `{"data2": "value2"}`, + "bundle3/baz/data.json": `{"data3": "value3"}`, + }, + bundleMode: false, + buildArgs: []string{"bundle1", "bundle2", "bundle3"}, + expectedStderr: func(root string) string { + return fmt.Sprintf(`Warning: .manifest file found in %q but -b flag not specified. Manifest will be ignored. +Warning: .manifest file found in %q but -b flag not specified. Manifest will be ignored. +`, + path.Join(root, "bundle1"), path.Join(root, "bundle2")) + }, + }, + "warns when proto manifest ignored": { + files: map[string]string{ + "bundle/.manifest.pb": "ignored-content", + "bundle/data.json": `{"data": "value"}`, + }, + bundleMode: false, + buildArgs: []string{"bundle"}, + expectedStderr: func(root string) string { + return fmt.Sprintf("Warning: .manifest.pb file found in %q but -b flag not specified. Manifest will be ignored.\n", path.Join(root, "bundle")) + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + var stderr bytes.Buffer + test.WithTempFS(tc.files, func(root string) { + params := newBuildParams() + params.outputFile = path.Join(root, "output.tar.gz") + params.bundleMode = tc.bundleMode + params.stderr = &stderr + + args := make([]string, 0, len(tc.buildArgs)) + for _, arg := range tc.buildArgs { + args = append(args, path.Join(root, arg)) + } + + err := dobuild(params, args) + if err != nil { + t.Fatal(err) + } + + stderrOutput := stderr.String() + expectedStderr := tc.expectedStderr(root) + + if stderrOutput != expectedStderr { + t.Fatalf("Expected stderr:\n%q\nGot:\n%q", expectedStderr, stderrOutput) + } + }) + }) + } +} + +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) + } + }) +} diff --git a/cmd/build_test.go b/cmd/build_test.go index e49f971629..5edcc2046f 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -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) + } + }) +} diff --git a/cmd/capabilities_jsonv2_test.go b/cmd/capabilities_jsonv2_test.go new file mode 100644 index 0000000000..b5397e00da --- /dev/null +++ b/cmd/capabilities_jsonv2_test.go @@ -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) + } + }) + } +} diff --git a/cmd/capabilities_test.go b/cmd/capabilities_test.go index cc3b9dfda2..4dc715f414 100644 --- a/cmd/capabilities_test.go +++ b/cmd/capabilities_test.go @@ -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 diff --git a/cmd/check_jsonv2_test.go b/cmd/check_jsonv2_test.go new file mode 100644 index 0000000000..b9f148a4ff --- /dev/null +++ b/cmd/check_jsonv2_test.go @@ -0,0 +1,1386 @@ +//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" + "encoding/json" + "fmt" + "maps" + "os" + "path" + "path/filepath" + "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" +) + +func TestCheckRespectsCapabilities(t *testing.T) { + //nolint:prealloc // test slice is extended dynamically, initial values are clearer as slice literal + tests := []struct { + note string + caps string + policy string + err string + bundleMode bool // check with "-b" flag + }{ + { + note: "builtin defined in caps", + caps: `{ + "builtins": [ + { + "name": "is_foo", + "decl": { + "args": [ + { + "type": "string" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + } + } + ] + }`, + policy: `package test +p { is_foo("bar") }`, + }, + { + note: "future kw NOT defined in caps", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in"} + c.Features = []string{} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + err: "rego_parse_error: unexpected keyword, must be one of [in]", + }, + { + note: "future kw NOT defined in caps, rego-v1 feature", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in"} + c.Features = []string{ast.FeatureRegoV1} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + }, + { + note: "future kw are defined in caps", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.FutureKeywords = []string{"in", "if"} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import future.keywords.if +import future.keywords.in +p if "opa" in input.tools`, + }, + { + note: "rego.v1 imported but NOT defined in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + err: "rego_parse_error: invalid import, `rego.v1` is not supported by current capabilities", + }, + { + note: "rego.v1 imported AND defined in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{ast.FeatureRegoV1Import} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + }, + { + note: "rego.v1 imported AND rego-v1 in capabilities", + caps: func() string { + c := ast.CapabilitiesForThisVersion() + c.Features = []string{ast.FeatureRegoV1} + j, err := json.Marshal(c) + if err != nil { + panic(err) + } + return string(j) + }(), + policy: `package test +import rego.v1`, + }, + } + + // add same tests for bundle-mode == true: + for i := range tests { + tc := tests[i] + tc.bundleMode = true + tc.note += " (as bundle)" + tests = append(tests, tc) + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "capabilities.json": tc.caps, + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + caps := newCapabilitiesFlag() + if err := caps.Set(path.Join(root, "capabilities.json")); err != nil { + t.Fatal(err) + } + params := newCheckParams() + params.capabilities = caps + params.bundleMode = tc.bundleMode + // Capabilities in test cases is pre v1 + params.v0Compatible = true + + err := checkModules(params, []string{root}) + switch { + case err != nil && tc.err != "": + if !strings.Contains(err.Error(), tc.err) { + t.Fatalf("expected err %v, got %v", tc.err, err) + } + return // don't read back bundle below + case err != nil && tc.err == "": + t.Fatalf("unexpected error: %v", err) + case err == nil && tc.err != "": + t.Fatalf("expected error %v, got nil", tc.err) + } + }) + }) + } +} + +func testCheckWithSchemasAnnotationButNoSchemaFlag(policy string) error { + files := map[string]string{ + "test.rego": policy, + } + + var err error + test.WithTempFS(files, func(path string) { + params := newCheckParams() + + err = checkModules(params, []string{path}) + }) + + return err +} + +func TestCheckIgnoresNonRegoFiles(t *testing.T) { + files := map[string]string{ + "test.rego": `package test`, + "test.json": `{"foo": "bar"}`, + "test.yaml": `foo: bar`, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + err := checkModules(params, []string{root}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestCheckIgnoreBundleMode(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "ignore.rego": `invalid rego`, + "include.rego": `package valid`, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + params.ignore = []string{"ignore.rego"} + params.bundleMode = true + + err := checkModules(params, []string{root}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestCheckBundleReportsPolicyVsDataConflict(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "policy.rego": "package p\nallow := false\n", + "data.json": `{"p":{"allow":false}}`, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + // Bundle mode required as the check command *should* ignore data entirely otherwise + params.bundleMode = true + + err := checkModules(params, []string{root}) + if err == nil { + t.Fatal("expected error but received none") + } + + exp := fmt.Sprintf( + "1 error occurred: %s:2: rego_compile_error: conflicting rule for data path p/allow found", + filepath.Join(root, "policy.rego"), + ) + if err.Error() != exp { + t.Fatalf("expected error %q, got %q", exp, err.Error()) + } + }) +} + +func TestCheckFailsOnInvalidRego(t *testing.T) { + files := map[string]string{ + "test.rego": `package test +{}`, + "test.json": `{"foo": "bar"}`, + } + expectedError := "rego_parse_error: object cannot be used for rule name" + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + err := checkModules(params, []string{root}) + if err == nil { + t.Fatalf("expected error %v but received none", expectedError) + } + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("expected error %v but received %v", expectedError, err) + } + }) +} + +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{` +package test +import rego.v1 +# METADATA +# schemas: +# - input: schema["input"] +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should be ignored +}`, + ` +package p + +# METADATA +# schemas: +# - data.p.x: schema["nope"] +bug := data.p.x +`} + + for i, pol := range policiesWithSchemaRef { + err := testCheckWithSchemasAnnotationButNoSchemaFlag(pol) + if err != nil { + t.Fatalf("unexpected error from eval policy %d with schema ref: %v", i, err) + } + } + + policyWithInlinedSchema := ` +package test +import rego.v1 +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should be ignored +}` + + err := testCheckWithSchemasAnnotationButNoSchemaFlag(policyWithInlinedSchema) + // We expect an error here, as inlined schemas are always used for type checking + if !strings.Contains(err.Error(), "rego_type_error: match error") { + t.Fatalf("unexpected error from eval with inlined schema, got: %v", err) + } +} + +func TestCheckRegoV1(t *testing.T) { + cases := []struct { + note string + policy string + expErrs []string + }{ + { + note: "rego.v1 imported, v1 compliant", + policy: `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "rego.v1 imported, NOT v1 compliant (parser)", + policy: `package test +import rego.v1 +p contains x { + x := [1,2,3] +} + +q.r`, + expErrs: []string{ + "test.rego:3: rego_parse_error: `if` keyword is required before rule body", + "test.rego:7: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "rego.v1 imported, NOT v1 compliant (compiler)", + policy: `package test +import rego.v1 + +import data.foo +import data.bar as foo +`, + expErrs: []string{ + "test.rego:5: rego_compile_error: import must not shadow import data.foo", + }, + }, + { + note: "keywords imported, v1 compliant", + policy: `package test +import future.keywords.if +import future.keywords.contains +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "keywords imported, NOT v1 compliant", + policy: `package test +import future.keywords.contains +p contains x { + x := [1,2,3] +} + +q.r`, + expErrs: []string{ + "test.rego:3: rego_parse_error: `if` keyword is required before rule body", + "test.rego:7: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "keywords imported, NOT v1 compliant (compiler)", + policy: `package test +import future.keywords.if + +input := 1 if { + 1 == 2 +}`, + expErrs: []string{ + "test.rego:4: rego_compile_error: rules must not shadow input (use a different rule name)", + }, + }, + { + note: "no imports, v1 compliant", + policy: `package test +p := 1 +`, + }, + { + note: "no imports, NOT v1 compliant but v0 compliant (compiler)", + policy: `package test +p.x`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "no imports, v1 compliant but NOT v0 compliant", + policy: `package test +p contains x if { + x := [1,2,3] +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", // This error actually appears three times: once for 'p'; once for 'contains'; and once for 'x'. All are interpreted as [invalid] rule declarations with no value and body. + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + params.regoV1 = true + + err := checkModules(params, []string{root}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } +} + +func TestCheck_DefaultRegoVersion(t *testing.T) { + cases := []struct { + note string + policy string + expErrs []string + }{ + { + note: "v0 module", + policy: `package test +a[x] { + x := 42 +}`, + 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", + policy: `package test +a contains x if { + x := 42 +}`, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + + err := checkModules(params, []string{root}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } +} + +func TestCheckWithRegoV1Capability(t *testing.T) { + cases := []struct { + note string + v0Compatible bool + capabilities *ast.Capabilities + policy string + expErrs []string + }{ + { + note: "v0 module, v0-compatible, no capabilities", + v0Compatible: true, + policy: `package test +a[x] { + x := 42 +}`, + }, + { + note: "v0 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + policy: `package test +a[x] { + x := 42 +}`, + }, + { + note: "v0 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + policy: `package test +a[x] { + x := 42 +}`, + }, + + { + note: "v0 module, not v0-compatible, no capabilities", + policy: `package test +a[x] { + x := 42 +}`, + 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: "v0 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + policy: `package test +a[x] { + x := 42 +}`, + 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: "v0 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + policy: `package test +a[x] { + x := 42 +}`, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + policy: `package test +a[x] { + x := 42 +}`, + 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, v0-compatible, no capabilities", + v0Compatible: true, + policy: `package test +a contains x if { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + policy: `package test +a contains x if { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + policy: `package test +a contains x if { + x := 42 +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + + { + note: "v1 module, not v0-compatible, no capabilities", + policy: `package test +a contains x if { + x := 42 +}`, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + policy: `package test +a contains x if { + x := 42 +}`, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + policy: `package test +a contains x if { + x := 42 +}`, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v1 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + policy: `package test +a contains x if { + x := 42 +}`, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + params.v0Compatible = tc.v0Compatible + params.capabilities.C = tc.capabilities + + err := checkModules(params, []string{root}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } +} + +func TestCheckCompatibleFlags(t *testing.T) { + cases := []struct { + note string + v0Compatible bool + v1Compatible bool + policy string + expErrs []string + }{ + { + note: "v0, no illegal keywords", + v0Compatible: true, + policy: `package test +p[x] { + x := [1,2,3] +}`, + }, + { + note: "v0, illegal keywords", + v0Compatible: true, + policy: `package test +p contains x if { + x := [1,2,3] +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0, future.keywords imported", + v0Compatible: true, + policy: `package test +import future.keywords +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v0, rego.v1 imported", + v0Compatible: true, + policy: `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v1, rego.v1 imported, v1 compliant", + v1Compatible: true, + policy: `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v1, rego.v1 imported, NOT v1 compliant (parser)", + v1Compatible: true, + policy: `package test +import rego.v1 +p contains x { + x := [1,2,3] +} + +q.r`, + expErrs: []string{ + "test.rego:3: rego_parse_error: `if` keyword is required before rule body", + "test.rego:7: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, rego.v1 imported, NOT v1 compliant (compiler)", + v1Compatible: true, + policy: `package test +import rego.v1 + +import data.foo +import data.bar as foo +`, + expErrs: []string{ + "test.rego:5: rego_compile_error: import must not shadow import data.foo", + }, + }, + { + note: "v1, keywords imported, v1 compliant", + v1Compatible: true, + policy: `package test +import future.keywords.if +import future.keywords.contains +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v1, keywords imported, NOT v1 compliant", + v1Compatible: true, + policy: `package test +import future.keywords.contains +p contains x { + x := [1,2,3] +} + +q.r`, + expErrs: []string{ + "test.rego:3: rego_parse_error: `if` keyword is required before rule body", + "test.rego:7: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, keywords imported, NOT v1 compliant (compiler)", + v1Compatible: true, + policy: `package test +import future.keywords.if + +input := 1 if { + 1 == 2 +}`, + expErrs: []string{ + "test.rego:4: rego_compile_error: rules must not shadow input (use a different rule name)", + }, + }, + { + note: "v1, no imports, v1 compliant", + v1Compatible: true, + policy: `package test +p := 1 +`, + }, + { + note: "v1, no imports, NOT v1 compliant but v0 compliant (compiler)", + v1Compatible: true, + policy: `package test +p.x`, + expErrs: []string{ + "test.rego:2: rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, no imports, v1 compliant but NOT v0 compliant", + v1Compatible: true, + policy: `package test +p contains x if { + x := [1,2,3] +}`, + }, + // v0 takes precedence over v1 + { + note: "v0+v1, no illegal keywords", + v0Compatible: true, + v1Compatible: true, + policy: `package test +p[x] { + x := [1,2,3] +}`, + }, + { + note: "v0+v1, illegal keywords", + v0Compatible: true, + v1Compatible: true, + policy: `package test +p contains x if { + x := [1,2,3] +}`, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0+v1, future.keywords imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +import future.keywords +p contains x if { + x := [1,2,3] +}`, + }, + { + note: "v0+v1, rego.v1 imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(root string) { + params := newCheckParams() + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + err := checkModules(params, []string{root}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } +} + +func TestCheckWithBundleRegoVersion(t *testing.T) { + cases := []struct { + note string + files map[string]string + expErrs []string + }{ + { + note: "v0.x bundle, illegal keywords", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p contains x if { + x := [1,2,3] +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0.x bundle, rego.v1 imported, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v0.x bundle, rego.v1 imported, NOT v1 compliant (parser)", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import rego.v1 +p contains x { + x := [1,2,3] +} + +q.r`, + }, + expErrs: []string{ + "rego_parse_error: `if` keyword is required before rule body", + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v0.x bundle, rego.v1 imported, NOT v1 compliant (compiler)", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import rego.v1 + +import data.foo +import data.bar as foo +`, + }, + expErrs: []string{ + "rego_compile_error: import must not shadow import data.foo", + }, + }, + { + note: "v0.x bundle, keywords imported, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import future.keywords.if +import future.keywords.contains +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v0.x bundle, no imports, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p := 1 +`, + }, + }, + { + note: "v0 bundle, v1 per-file overrides, compliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v0 bundle, v1 per-file overrides (glob), compliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v0 bundle, v1 per-file overrides, incompliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q[x] { + x := [1,2,3] +}`, + }, + 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, keywords used but not imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported, NOT v1 compliant (parser)", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 +p contains x { + x := [1,2,3] +} + +q.r`, + }, + 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, rego.v1 imported, NOT v1 compliant (compiler)", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 + +import data.foo +import data.bar as foo +`, + }, + expErrs: []string{ + "rego_compile_error: import must not shadow import data.foo", + }, + }, + { + note: "v1.0 bundle, keywords imported, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords.if +import future.keywords.contains +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1.0 bundle, keywords imported, NOT v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords.contains +p contains x { + x := [1,2,3] +} + +q.r`, + }, + 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, keywords imported, NOT v1 compliant (compiler)", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords.if + +input := 1 if { + 1 == 2 +}`, + }, + expErrs: []string{ + "rego_compile_error: rules must not shadow input (use a different rule name)", + }, + }, + { + note: "v1.0 bundle, no imports, v1 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p := 1 +`, + }, + }, + { + note: "v1.0 bundle, no imports, NOT v1 compliant but v0 compliant (compiler)", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p.x`, + }, + expErrs: []string{ + "rego_parse_error: `contains` keyword is required for partial set rules", + }, + }, + { + note: "v1.0 bundle, no imports, v1 compliant but NOT v0 compliant", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1 bundle, v0 per-file overrides, compliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1 bundle, v0 per-file overrides (glob), compliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "*/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p[x] { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + }, + { + note: "v1 bundle, v0 per-file overrides, incompliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p contains x if { + x := [1,2,3] +}`, + "policy2.rego": `package test +q contains x if { + x := [1,2,3] +}`, + }, + expErrs: []string{ + "rego_parse_error: var 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 cases { + 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 := newCheckParams() + params.bundleMode = true + params.v1Compatible = v1CompatibleFlag.used + + err := checkModules(params, []string{p}) + switch { + case err != nil && len(tc.expErrs) > 0: + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("expected err:\n\n%v\n\ngot:\n\n%v", expErr, err) + } + } + return // don't read back bundle below + case err != nil && len(tc.expErrs) == 0: + t.Fatalf("unexpected error: %v", err) + case err == nil && len(tc.expErrs) > 0: + t.Fatalf("expected error:\n\n%v\n\ngot: none", tc.expErrs) + } + }) + }) + } + } + } +} diff --git a/cmd/check_test.go b/cmd/check_test.go index 8bcf87863f..fd84ca8df4 100644 --- a/cmd/check_test.go +++ b/cmd/check_test.go @@ -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{` diff --git a/cmd/deps_jsonv2_test.go b/cmd/deps_jsonv2_test.go new file mode 100644 index 0000000000..86827f6ab9 --- /dev/null +++ b/cmd/deps_jsonv2_test.go @@ -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) + } + }) + }) + } + } + } +} diff --git a/cmd/deps_test.go b/cmd/deps_test.go index bfde943a16..a41bcaecaf 100644 --- a/cmd/deps_test.go +++ b/cmd/deps_test.go @@ -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 diff --git a/cmd/eval_jsonv2_test.go b/cmd/eval_jsonv2_test.go new file mode 100644 index 0000000000..7bcb1edcf4 --- /dev/null +++ b/cmd/eval_jsonv2_test.go @@ -0,0 +1,3985 @@ +//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. + +// nolint: goconst // string duplication is for test readability. +package cmd + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "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/internal/presentation" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/loader" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/util" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestEvalWithIllegalUnknownArgs(t *testing.T) { + + tests := []struct { + name string + unknowns string + expectedErr error + }{ + { + name: "happy path: passing input ref as unknown", + unknowns: "input", + expectedErr: nil, + }, + { + name: "happy path: passing input.users ref as unknown", + unknowns: "input.users", + expectedErr: nil, + }, + { + name: "passing multiple refs with ; separated", + unknowns: "input;input.users", + expectedErr: errors.New("expected exactly one term but got: input; input.users"), + }, + { + name: "passing array as unknown", + unknowns: "[input, data.posts]", + expectedErr: errIllegalUnknownsArg, + }, + { + name: "passing set as unknown", + unknowns: "{input, data.posts}", + expectedErr: errIllegalUnknownsArg, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := newEvalCommandParams() + params.unknowns = []string{tt.unknowns} + params.partial = true + + err := validateEvalParams(¶ms, []string{"data"}) + + if tt.expectedErr != nil && !strings.EqualFold(err.Error(), tt.expectedErr.Error()) { + t.Errorf("expected %s; got %s", errIllegalUnknownsArg.Error(), err.Error()) + } + }) + } +} + +func TestEvalExitCode(t *testing.T) { + params := newEvalCommandParams() + params.fail = true + + tests := []struct { + note string + query string + wantDefined bool + wantErr bool + }{ + {"defined result", "true=true", true, false}, + {"undefined result", "true = false", false, false}, + {"on error", `{k: v | k = ["a", "a"][_]; v = [0,1][_]}`, false, true}, + } + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + defined, err := eval([]string{tc.query}, params, writer, nil) + if tc.wantErr && err == nil { + t.Fatal("wanted error but got success") + } else if !tc.wantErr && err != nil { + t.Fatal("wanted success but got error:", err) + } else if (tc.wantDefined && !defined) || (!tc.wantDefined && defined) { + t.Fatalf("wanted defined %v but got defined %v", tc.wantDefined, defined) + } + }) + } +} + +func TestEvalWithShowBuiltinErrors(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + +p if { + 1/0 +} + +q if { + 1/0 +}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.showBuiltinErrors = true + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.x"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if len(output.Errors) != 2 { + t.Fatalf("Expected 2 errors in result, got:%v", len(output.Errors)) + } + + expectedCode := "eval_builtin_error" + expectedMessage := "div: divide by zero" + + if code := output.Errors[0].Code; code != expectedCode { + t.Fatalf("expected code '%v', got '%v'", expectedCode, code) + } + if msg := output.Errors[0].Message; msg != expectedMessage { + t.Fatalf("expected message '%v', got '%v'", expectedMessage, msg) + } + + if code := output.Errors[1].Code; code != expectedCode { + t.Fatalf("expected code '%v', got '%v'", expectedCode, code) + } + if msg := output.Errors[1].Message; msg != expectedMessage { + t.Fatalf("expected message '%v', got '%v'", expectedMessage, msg) + } + + loc1 := output.Errors[0].Location + if loc1 == nil { + t.Fatal("unexpected nil location") + } + + loc2 := output.Errors[1].Location + if loc2 == nil { + t.Fatal("unexpected nil location") + } + + if loc1.Row == loc2.Row { + t.Fatal("expected 2 distinct error occurrences in policy") + } + }) +} + +func TestEvalWithProfiler(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + +p if { + a := 1 + b := 2 + c := 3 + x = a + b * c +}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.profile = true + params.profileCriteria = newrepeatedStringFlag([]string{"line"}) + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if len(output.Profile) == 0 { + t.Fatal("Expected profile output to be non-empty") + } + + expectedNumEval := []int{3, 1, 1, 1, 1} + expectedNumRedo := []int{3, 1, 1, 1, 1} + expectedRow := []int{7, 6, 5, 4, 1} + expectedNumGenExpr := []int{3, 1, 1, 1, 1} + + for idx, actualExprStat := range output.Profile { + if actualExprStat.NumEval != expectedNumEval[idx] { + t.Fatalf("Index %v: Expected number of evals %v but got %v", idx, expectedNumEval[idx], actualExprStat.NumEval) + } + + if actualExprStat.NumRedo != expectedNumRedo[idx] { + t.Fatalf("Index %v: Expected number of redos %v but got %v", idx, expectedNumRedo[idx], actualExprStat.NumRedo) + } + + if actualExprStat.Location.Row != expectedRow[idx] { + t.Fatalf("Index %v: Expected row %v but got %v", idx, expectedRow[idx], actualExprStat.Location.Row) + } + + if actualExprStat.NumGenExpr != expectedNumGenExpr[idx] { + t.Fatalf("Index %v: Expected number of generated expressions %v but got %v", idx, expectedNumGenExpr[idx], actualExprStat.NumGenExpr) + } + } + }) +} + +func TestEvalWithCoverage(t *testing.T) { + + files := map[string]string{ + "x.rego": `package x + +p = 1`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.coverage = true + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if output.Coverage == nil || output.Coverage.Coverage != 100.0 { + t.Fatalf("Expected coverage in output but got: %v", buf.String()) + } + }) +} + +func TestEvalWithOptimizeErrors(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + +p = 1`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + + err := validateEvalParams(¶ms, []string{"data"}) + if err == nil { + t.Fatal("Expected error but got nil") + } + + expected := "specify either --data or --bundle flag with optimization level greater than 0" + if err.Error() != expected { + t.Fatalf("Expected error %v but got %v", expected, err.Error()) + } + + params = newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + + _, err = eval([]string{"data.test"}, params, &buf, nil) + if err == nil { + t.Fatal("Expected error but got nil") + } + + expected = "bundle optimizations require at least one entrypoint" + if err.Error() != expected { + t.Fatalf("Expected error %v but got %v", expected, err.Error()) + } + }) +} + +func TestEvalWithOptimize(t *testing.T) { + files := map[string]string{ + "test.rego": ` + package test + + default p = false + p if { q } + q if { input.x = data.foo }`, + "data.json": ` + {"foo": 1}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"test/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +// Ensure that entrypoint annotations don't cause panics when using +// higher levels of optimization. +// Reference: https://github.com/open-policy-agent/opa/issues/5368 +func TestEvalIssue5368(t *testing.T) { + files := map[string]string{ + "test.rego": ` +package system + +object_key_exists(object, key) if { + _ = object[key] +} + +default main = false + +# METADATA +# entrypoint: true +main := results if { + object_key_exists(input, "queries") + results := {key: result | + result := input.queries[key] + } +}`, + "input.json": `{}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 2 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.inputPath = filepath.Join(path, "input.json") + + var buf bytes.Buffer + + defined, err := eval([]string{"data.system.main"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestEvalWithOptimizeBundleData(t *testing.T) { + files := map[string]string{ + "test.rego": ` + package test + + default p = false + p if { q } + q if { input.x = data.foo }`, + "data.json": ` + {"foo": 1}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + params.entrypoints = newrepeatedStringFlag([]string{"test/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func testEvalWithInputFile(t *testing.T, input string, query string, params evalCommandParams) error { + files := map[string]string{ + "input.json": input, + } + + var err error + test.WithTempFS(files, func(path string) { + + params.inputPath = filepath.Join(path, "input.json") + + var buf bytes.Buffer + var defined bool + defined, err = eval([]string{query}, params, &buf, nil) + if !defined || err != nil { + err = fmt.Errorf("Unexpected error or undefined from evaluation: %v", err) + return + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + rs := output.Result + if exp, act := true, rs.Allowed(); exp != act { + t.Errorf("expected %v, got %v", exp, act) + } + }) + + return err +} + +func TestEvalWithInvalidInputFile(t *testing.T) { + input := `{badjson` + query := "input.b[0].a == 1" + err := testEvalWithInputFile(t, input, query, newEvalCommandParams()) + if err == nil { + t.Fatalf("expected error but err == nil") + } +} + +func testEvalWithSchemaFile(t *testing.T, input string, query string, schema string, policy string, expTypeErr bool) error { + files := map[string]string{ + "input.json": input, + "schema.json": schema, + } + + policyFilePresent := policy != "" + if policyFilePresent { + files["policy.rego"] = policy + } + + var err error + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + if policyFilePresent { + params.dataPaths = newrepeatedStringFlag([]string{path}) + } + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + + var buf bytes.Buffer + defined, evalErr := eval([]string{query}, params, &buf, nil) + if !expTypeErr && (!defined || evalErr != nil) { + err = fmt.Errorf("unexpected error or undefined from evaluation: %v", evalErr) + return + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if expTypeErr { + if len(output.Errors) != 1 || output.Errors[0].Code != "rego_type_error" { + err = fmt.Errorf("expected type conflict, got %v", output.Errors) + } + return + } + + rs := output.Result + if exp, act := true, rs.Allowed(); exp != act { + t.Errorf("expected %v, got %v", exp, act) + } + }) + + return err +} + +func testEvalWithInvalidSchemaFile(input string, query string, schema string) error { + files := map[string]string{ + "input.json": input, + "schema.json": schema, + } + + var err error + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schemaBad.json")} + + var buf bytes.Buffer + var defined bool + defined, err = eval([]string{query}, params, &buf, nil) + if !defined || err != nil { + err = fmt.Errorf("Unexpected error or undefined from evaluation: %v", err) + return + } + }) + + return err +} + +func testEvalWithSchemasAnnotationButNoSchemaFlag(policy string) error { + query := "data.test.p" + + files := map[string]string{ + "input.json": `{ + "foo": 42 + }`, + "test.rego": policy, + } + + var err error + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.dataPaths = newrepeatedStringFlag([]string{path}) + + var buf bytes.Buffer + var defined bool + defined, err = eval([]string{query}, params, &buf, nil) + if !defined || err != nil { + err = errors.New(buf.String()) + } + }) + + return err +} + +// Assert that 'schemas' annotations with schema refs are only informing the type checker when the --schema flag is used +func TestEvalWithSchemasAnnotationButNoSchemaFlag(t *testing.T) { + policyWithSchemaRef := ` +package test + +# METADATA +# schemas: +# - input: schema["input"] +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should be ignored +}` + + err := testEvalWithSchemasAnnotationButNoSchemaFlag(policyWithSchemaRef) + if err != nil { + t.Fatalf("unexpected error from eval with schema ref: %v", err) + } + + policyWithInlinedSchema := ` +package test + +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should NOT be ignored since it is an inlined schema format +}` + + err = testEvalWithSchemasAnnotationButNoSchemaFlag(policyWithInlinedSchema) + // We expect an error here, as inlined schemas are always used for type checking + if !strings.Contains(err.Error(), `"code": "rego_type_error"`) { + t.Fatalf("unexpected error from eval with inlined schema, got: %v", err) + } +} + +func testReadParamWithSchemaDir(input string, inputSchema string) error { + files := map[string]string{ + "input.json": input, + "schemas/input.json": inputSchema, + "schemas/kubernetes/data-schema.json": inputSchema, + } + + var err error + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schemas")} + + // Don't assign over "err" or "err =" does nothing. + schemaSet, errSchema := loader.Schemas(params.schema.path) + if errSchema != nil { + err = fmt.Errorf("Unexpected error or undefined from evaluation: %v", errSchema) + return + } + + if schemaSet == nil { + err = errors.New("Schema set is empty") + return + } + + if schemaSet.Get(ast.MustParseRef("schema.input")) == nil { + err = errors.New("Expected schema for input in schemaSet but got none") + return + } + + if schemaSet.Get(ast.MustParseRef(`schema.kubernetes["data-schema"]`)) == nil { + err = errors.New("Expected schemas for data in schemaSet but got none") + return + } + + }) + + return err +} + +func TestEvalWithRecursiveJSONSchema(t *testing.T) { + tests := []struct { + note string + input string + query string + schema string + policy string + expTypeErr bool + }{ + { + note: "recursive object ref - valid usage", + input: `{"foo": {"foo": {}}}`, + query: "data.p.allow", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: `package p + +allow if input.foo`, + }, + { + note: "recursive object ref - type mismatch at top level", + input: `{"foo": {"foo": {}}}`, + query: "data.test.p", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.foo == 42 +}`, + expTypeErr: true, + }, + { + note: "recursive object ref - nested type mismatch", + input: `{"foo": {"foo": {}}}`, + query: "data.test.p", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.foo.foo == "hello" +}`, + expTypeErr: true, + }, + { + note: "recursive object ref - inlined schema type mismatch", + input: `{"foo": {"foo": {}}}`, + query: "data.test.p", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + input.foo == 42 +}`, + expTypeErr: true, + }, + { + note: "recursive object ref - valid with schema annotation", + input: `{"foo": {"foo": {}}}`, + query: "data.test.p", + schema: `{ + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "object", + "properties": { + "foo": { + "$ref": "#/$defs/foo" + } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.foo +}`, + }, + { + note: "recursive array ref - valid usage", + input: `{"tree": [[[]]]}`, + query: "data.p.allow", + schema: `{ + "type": "object", + "properties": { + "tree": { + "$ref": "#/$defs/tree" + } + }, + "$defs": { + "tree": { + "type": "array", + "items": { + "$ref": "#/$defs/tree" + } + } + } +}`, + policy: `package p + +allow if input.tree`, + }, + { + note: "recursive array ref - type mismatch on element", + input: `{"tree": [[[]]]}`, + query: "data.test.p", + schema: `{ + "type": "object", + "properties": { + "tree": { + "$ref": "#/$defs/tree" + } + }, + "$defs": { + "tree": { + "type": "array", + "items": { + "$ref": "#/$defs/tree" + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.tree[0] == "hello" +}`, + expTypeErr: true, + }, + { + note: "recursive anyOf ref - valid usage", + input: `{"node": ["hello", ["world"]]}`, + query: "data.p.allow", + schema: `{ + "type": "object", + "properties": { + "node": { + "$ref": "#/$defs/node" + } + }, + "$defs": { + "node": { + "anyOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "$ref": "#/$defs/node" } + } + ] + } + } +}`, + policy: `package p + +allow if input.node`, + }, + { + note: "non-recursive ref - valid usage", + input: `{"addr": {"street": "Main St", "city": "Springfield"}}`, + query: "data.p.allow", + schema: `{ + "type": "object", + "properties": { + "addr": { + "$ref": "#/$defs/address" + } + }, + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + } + } + } +}`, + policy: `package p + +allow if input.addr.street`, + }, + { + note: "non-recursive ref - type mismatch", + input: `{"addr": {"street": "Main St", "city": "Springfield"}}`, + query: "data.test.p", + schema: `{ + "type": "object", + "properties": { + "addr": { + "$ref": "#/$defs/address" + } + }, + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + } + } + } +}`, + policy: ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.addr.street == 42 +}`, + expTypeErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + err := testEvalWithSchemaFile(t, tc.input, tc.query, tc.schema, tc.policy, tc.expTypeErr) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + }) + } +} + +func TestEvalWithJSONSchema(t *testing.T) { + + input := `{ + "foo": "a", + "b": [ + { + "a": 1, + "b": [1, 2, 3], + "c": null + } + ] +}` + + schema := `{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "http://example.com/example.json", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "required": [ + "foo", + "b" + ], + "properties": { + "foo": { + "$id": "#/properties/foo", + "type": "string", + "title": "The foo schema", + "description": "An explanation about the purpose of this instance." + }, + "b": { + "$id": "#/properties/b", + "type": "array", + "title": "The b schema", + "description": "An explanation about the purpose of this instance.", + "additionalItems": false, + "items": { + "$id": "#/properties/b/items", + "type": "object", + "title": "The items schema", + "description": "An explanation about the purpose of this instance.", + "required": [ + "a", + "b", + "c" + ], + "properties": { + "a": { + "$id": "#/properties/b/items/properties/a", + "type": "integer", + "title": "The a schema", + "description": "An explanation about the purpose of this instance." + }, + "b": { + "$id": "#/properties/b/items/properties/b", + "type": "array", + "title": "The b schema", + "description": "An explanation about the purpose of this instance.", + "additionalItems": false, + "items": { + "$id": "#/properties/b/items/properties/b/items", + "type": "integer", + "title": "The items schema", + "description": "An explanation about the purpose of this instance." + } + }, + "c": { + "$id": "#/properties/b/items/properties/c", + "type": "null", + "title": "The c schema", + "description": "An explanation about the purpose of this instance." + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }` + + query := "input.b[0].a == 1" + err := testEvalWithSchemaFile(t, input, query, schema, "", false) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + policyWithSchemasAnnotation := ` +package test + +# METADATA +# schemas: +# - input: schema +p if { + input.foo == 42 # type mismatch +}` + err = testEvalWithSchemaFile(t, input, query, schema, policyWithSchemasAnnotation, true) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + policyWithInlinedSchemasAnnotation := ` +package test + +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + input.foo == 42 # type mismatch +}` + err = testEvalWithSchemaFile(t, input, query, schema, policyWithInlinedSchemasAnnotation, true) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + err = testReadParamWithSchemaDir(input, schema) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } +} + +func TestEvalWithInvalidSchemaFile(t *testing.T) { + + input := `{ + "foo": "a", + "b": [ + { + "a": 1, + "b": [1, 2, 3], + "c": null + } + ] + }` + + schema := `{badjson` + + query := "input.b[0].a == 1" + err := testEvalWithSchemaFile(t, input, query, schema, "", false) + if err == nil { + t.Fatalf("expected error but err == nil") + } + + err = testEvalWithInvalidSchemaFile(input, query, schema) + if err == nil { + t.Fatalf("expected error but err == nil") + } +} + +func TestEvalWithSchemaFileWithRemoteRef(t *testing.T) { + + input := `{"metadata": {"clusterName": "NAME"}}` + schemaFmt := `{ + "type": "object", + "properties": { + "metadata": { + "$ref": "%s/v1.14.0/_definitions.json#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + "description": "Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata" + } + } +}` + ts := kubeSchemaServer(t) + t.Cleanup(ts.Close) + + query := "data.p.r" + files := map[string]string{ + "input.json": input, + "schema.json": fmt.Sprintf(schemaFmt, ts.URL), + "p.rego": `package p + +r if { + input.metadata.clusterName == "NAME" +}`, + } + + t.Run("all remote refs disabled", func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + params.capabilities.C = ast.CapabilitiesForThisVersion() + params.capabilities.C.AllowNet = []string{} + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + _, err := eval([]string{query}, params, &buf, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + if exp, act := 1, len(output.Errors); exp != act { + t.Fatalf("expected %d errors, got %d", exp, act) + } + if exp, act := "rego_type_error", output.Errors[0].Code; exp != act { + t.Errorf("expected code %v, got %v", exp, act) + } + }) + }) + + t.Run("all remote refs enabled", func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + defined, err := eval([]string{query}, params, &buf, nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if exp, act := true, defined; exp != act { + t.Errorf("expected defined %v, got %v", exp, act) + } + }) + }) + + t.Run("required remote ref host not enabled", func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + params.capabilities.C = ast.CapabilitiesForThisVersion() + params.capabilities.C.AllowNet = []string{"something.else"} + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + _, err := eval([]string{query}, params, &buf, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + if exp, act := 1, len(output.Errors); exp != act { + t.Fatalf("expected %d errors, got %d", exp, act) + } + if exp, act := "rego_type_error", output.Errors[0].Code; exp != act { + t.Errorf("expected code %v, got %v", exp, act) + } + }) + }) + + t.Run("only required remote ref host enabled", func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.inputPath = filepath.Join(path, "input.json") + params.schema = &schemaFlags{path: filepath.Join(path, "schema.json")} + params.capabilities.C = ast.CapabilitiesForThisVersion() + params.capabilities.C.AllowNet = []string{"127.0.0.1"} + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + defined, err := eval([]string{query}, params, &buf, nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if exp, act := true, defined; exp != act { + t.Errorf("expected defined %v, got %v", exp, act) + } + }) + }) +} + +func TestBuiltinsCapabilities(t *testing.T) { + tests := []struct { + note string + policy string + query string + ruleName string + expectedCode string + expectedMessage string + }{ + { + note: "rego.metadata.chain() not allowed", + policy: "package p\n r := rego.metadata.chain()", + query: "data.p", + ruleName: "rego.metadata.chain", + expectedCode: "rego_type_error", + expectedMessage: "undefined function rego.metadata.chain", + }, + { + note: "rego.metadata.rule() not allowed", + policy: "package p\n r := rego.metadata.rule()", + query: "data.p", + ruleName: "rego.metadata.rule", + expectedCode: "rego_type_error", + expectedMessage: "undefined function rego.metadata.rule", + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "p.rego": tc.policy, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.capabilities.C = ast.CapabilitiesForThisVersion() + params.capabilities.C.Builtins = removeBuiltin(params.capabilities.C.Builtins, tc.ruleName) + + _ = params.dataPaths.Set(filepath.Join(path, "p.rego")) + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + if exp, act := 1, len(output.Errors); exp != act { + t.Fatalf("expected %d errors, got %d", exp, act) + } + if code := output.Errors[0].Code; code != tc.expectedCode { + t.Errorf("expected code '%v', got '%v'", tc.expectedCode, code) + } + if msg := output.Errors[0].Message; msg != tc.expectedMessage { + t.Errorf("expected message '%v', got '%v'", tc.expectedMessage, msg) + } + }) + }) + } +} + +func removeBuiltin(builtins []*ast.Builtin, name string) []*ast.Builtin { + var cpy []*ast.Builtin + for _, builtin := range builtins { + if builtin.Name != name { + cpy = append(cpy, builtin) + } + } + return cpy +} + +// Nearly identical to TestEvalWithOptimizeBundleData, but uses +// Rego entrypoint annotations instead of explicitly providing +// the entrypoints as CLI arguments. +func TestEvalWithRegoEntrypointAnnotations(t *testing.T) { + files := map[string]string{ + "test.rego": ` +package test + +default p = false +# METADATA +# entrypoint: true +p if { q } +q if { input.x = data.foo }`, + "data.json": ` +{"foo": 1}`, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestEvalReturnsRegoError(t *testing.T) { + buf := new(bytes.Buffer) + _, err := eval([]string{`{k: v | k = ["a", "a"][_]; v = [0,1][_]}`}, newEvalCommandParams(), buf, nil) + if _, ok := err.(regoError); !ok { + t.Fatal("expected regoError but got:", err) + } +} + +func TestEvalBundlePathWithIgnoreFlag(t *testing.T) { + files := map[string]string{ + "good_policy.rego": ` + package example + p1 if { data.foo }`, + "bad_policy.rego": ` + package example + var `, + "data.json": ` + {"foo": true, "bar": false}`, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatalf("Unable to set bundle path: %v", err) + } + params.ignore = []string{"bad_policy.rego"} + + var buf bytes.Buffer + + // Evaluate policies + defined, err := eval([]string{"data.example.p1"}, params, &buf, &buf) + + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error for p1: %v", err) + } + }) +} + +func TestEvalWithBundleData(t *testing.T) { + files := map[string]string{ + "x/x.rego": "package x\np = 1", + "x/data.json": `{"b": "bar"}`, + "other/not-data.json": `{"ignored": "data"}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{"data"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + assertResultSet(t, output.Result, `[[{"x": {"p": 1, "b": "bar"}}]]`) + }) +} + +func TestEvalWithBundleDuplicateFileNames(t *testing.T) { + files := map[string]string{ + // bundle a + "a/policy.rego": "package a\np = 1", + "a/.manifest": `{"roots":["a"]}`, + + // bundle b + "b/policy.rego": "package b\nq = 1", + "b/.manifest": `{"roots":["b"]}`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + if err := params.bundlePaths.Set(filepath.Join(path, "a")); err != nil { + t.Fatal(err) + } + if err := params.bundlePaths.Set(filepath.Join(path, "b")); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{"data"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + + var output presentation.Output + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + assertResultSet(t, output.Result, `[[{"a":{"p":1},"b":{"q":1}}]]`) + }) +} + +func TestEvalWithReadASTValuesFromStore(t *testing.T) { + // Note: This test is a bit of a hack. It's difficult to discern whether AST values were actually read from the store. + // This just ensures that we don't get any unexpected errors when enabling the flag. + + tests := []struct { + note string + readAst bool + }{ + { + note: "read raw data from store", + readAst: false, + }, + { + note: "read AST values from store", + readAst: true, + }, + } + + files := map[string]string{ + "test.rego": ` + package test + p = 1`, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.ReadAstValuesFromStore = tc.readAst + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) + }) + } +} + +func TestEvalWithStrictBuiltinErrors(t *testing.T) { + params := newEvalCommandParams() + params.strictBuiltinErrors = true + + var buf bytes.Buffer + _, err := eval([]string{"1/0"}, params, &buf, nil) + if err == nil { + t.Fatal("expected error") + } + + params.strictBuiltinErrors = false + buf.Reset() + + _, err = eval([]string{"1/0"}, params, &buf, nil) + if err != nil { + t.Fatal("unexpected error:", err) + } + + if buf.String() != "{}\n" { + t.Fatal("expected undefined output but got:", buf.String()) + } +} + +func assertResultSet(t *testing.T, rs rego.ResultSet, expected string) { + t.Helper() + result := make([]any, 0, len(rs)) + + for i := range rs { + values := make([]any, 0, len(rs[i].Expressions)) + for j := range rs[i].Expressions { + values = append(values, rs[i].Expressions[j].Value) + } + result = append(result, values) + } + + parsedExpected := util.MustUnmarshalJSON([]byte(expected)) + if !reflect.DeepEqual(result, parsedExpected) { + t.Fatalf("Expected:\n\n%v\n\nGot:\n\n%v", parsedExpected, result) + } +} + +func TestEvalErrorJSONOutput(t *testing.T) { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{"{1,2,3} == {1,x,3}"}, params, &buf, nil) + if defined && err == nil { + t.Fatalf("Expected an error") + } + + // Only check that it *can* be loaded as valid JSON, and that the errors + // are populated. + var output map[string]any + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if output["errors"] == nil { + t.Fatalf("Expected error to be non-nil") + } +} + +func TestEvalDebugTraceJSONOutput(t *testing.T) { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = params.explain.Set(explainModeFull) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + params.disableIndexing = true + + mod := `package x + + p contains a if { + a := input.z + a == 1 + } + + p contains b if { + b := input.y + b == 1 + } + ` + + input := `{"z": 1}` + + files := map[string]string{ + "policy.rego": mod, + "input.json": input, + } + + var buf bytes.Buffer + var policyFile string + + test.WithTempFS(files, func(path string) { + params.inputPath = filepath.Join(path, "input.json") + policyFile = filepath.Join(path, "policy.rego") + err := params.dataPaths.Set(policyFile) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + _, err = eval([]string{"data.x.p"}, params, &buf, nil) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + }) + + var output struct { + Explanation []struct { + Op string `json:"Op"` + Node any `json:"Node"` + Location *ast.Location `json:"Location"` + Locals []map[string]any `json:"Locals"` + LocalMetadata map[string]struct { + Name string `json:"name"` + } `json:"LocalMetadata"` + } + } + + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + if len(output.Explanation) == 0 { + t.Fatalf("Expected explanations to be non-nil") + } + + type locationAndVars struct { + location *ast.Location + varBindings map[string]string + } + + var evals []locationAndVars + for _, e := range output.Explanation { + if e.Op == string(topdown.EvalOp) { + bindings := map[string]string{} + for k, v := range e.LocalMetadata { + bindings[k] = v.Name + } + + evals = append(evals, locationAndVars{location: e.Location, varBindings: bindings}) + } + } + + expectedEvalLocationsAndVars := []locationAndVars{ + { + location: ast.NewLocation(nil, policyFile, 4, 3), // a := input.z + varBindings: map[string]string{"__local0__": "a"}, + }, + { + location: ast.NewLocation(nil, policyFile, 5, 3), // a == 1 + varBindings: map[string]string{"__local0__": "a"}, + }, + { + location: ast.NewLocation(nil, policyFile, 9, 3), // b := input.y + varBindings: map[string]string{"__local1__": "b"}, + }, + } + + for _, expected := range expectedEvalLocationsAndVars { + found := false + for _, actual := range evals { + if expected.location.Compare(actual.location) == 0 { + found = true + if !maps.Equal(expected.varBindings, actual.varBindings) { + t.Errorf("Expected var bindings:\n\n\t%+v\n\nGot\n\n\t%+v\n\n", expected.varBindings, actual.varBindings) + } + } + } + if !found { + t.Fatalf("Missing expected eval node in trace: %+v\nGot: %+v\n", expected, evals) + } + } +} + +func TestEvalPrettyTrace(t *testing.T) { + tests := []struct { + note string + query string + includeVars bool + files map[string]string + expected string + }{ + { + note: "simple without vars", + query: "data.test.p", + includeVars: false, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + x := 1 + y := 2 + z := 3 + x == z - y +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ +query:1 %.*% | Eval data.test.p = _ +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) +%.*%/test.rego:4 | Enter data.test.p +%.*%/test.rego:5 | | Eval x = 1 +%.*%/test.rego:6 | | Eval y = 2 +%.*%/test.rego:7 | | Eval z = 3 +%.*%/test.rego:8 | | Eval minus(z, y, __local3__) +%.*%/test.rego:8 | | Eval x = __local3__ +%.*%/test.rego:4 | | Exit data.test.p early +query:1 %.*% | Exit data.test.p = _ +query:1 %.*% Redo data.test.p = _ +query:1 %.*% | Redo data.test.p = _ +%.*%/test.rego:4 | Redo data.test.p +%.*%/test.rego:8 | | Redo x = __local3__ +%.*%/test.rego:8 | | Redo minus(z, y, __local3__) +%.*%/test.rego:7 | | Redo z = 3 +%.*%/test.rego:6 | | Redo y = 2 +%.*%/test.rego:5 | | Redo x = 1 +true +`, + }, + { + note: "simple with vars", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + x := 1 + y := 2 + z := 3 + x == z - y +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval x = 1 {} +%.*%/test.rego:6 | | Eval y = 2 {} +%.*%/test.rego:7 | | Eval z = 3 {} +%.*%/test.rego:8 | | Eval minus(z, y, __local3__) {y: 2, z: 3} +%.*%/test.rego:8 | | Eval x = __local3__ {__local3__: 1, x: 1} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:8 | | Redo x = __local3__ {__local3__: 1, x: 1} +%.*%/test.rego:8 | | Redo minus(z, y, __local3__) {__local3__: 1, y: 2, z: 3} +%.*%/test.rego:7 | | Redo z = 3 {z: 3} +%.*%/test.rego:6 | | Redo y = 2 {y: 2} +%.*%/test.rego:5 | | Redo x = 1 {x: 1} +true +`, + }, + { + note: "large var", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +v := { + "foo": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + "bar": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + "baz": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + "qux": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + } + +p if { + x := v + + x.foo[_] == "a" +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:11 | Enter data.test.p {} +%.*%/test.rego:12 | | Eval x = data.test.v {} +%.*%/test.rego:12 | | Index data.test.v (matched 1 rule, early exit) {} +%.*%/test.rego:4 | | Enter data.test.v {} +%.*%/test.rego:4 | | | Eval true {} +%.*%/test.rego:4 | | | Exit data.test.v early {} +%.*%/test.rego:14 | | Eval x.foo[_] = "a" {x: {"bar": ["a", "b", "c", "d", ...} +%.*%/test.rego:11 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:11 | Redo data.test.p {} +%.*%/test.rego:14 | | Redo x.foo[_] = "a" {_: 0, x: {"bar": ["a", "b", "c", "d", ...} +%.*%/test.rego:12 | | Redo x = data.test.v {data.test.v: {"bar": ["a", "b", "c", "d", ..., x: {"bar": ["a", "b", "c", "d", ...} +%.*%/test.rego:4 | | | Redo true {} +true +`, + }, + { + note: "func call", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + x := 1 + y := 2 + z := 3 + z == f(x, y) +} + +f(a, b) := c if { + c := a + b +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval x = 1 {} +%.*%/test.rego:6 | | Eval y = 2 {} +%.*%/test.rego:7 | | Eval z = 3 {} +%.*%/test.rego:8 | | Eval data.test.f(x, y, __local6__) {x: 1, y: 2} +%.*%/test.rego:8 | | Index data.test.f (matched 1 rule) {x: 1, y: 2} +%.*%/test.rego:11 | | Enter data.test.f {} +%.*%/test.rego:12 | | | Eval plus(a, b, __local7__) {a: 1, b: 2} +%.*%/test.rego:12 | | | Eval c = __local7__ {__local7__: 3} +%.*%/test.rego:11 | | | Exit data.test.f {a: 1, b: 2, c: 3} +%.*%/test.rego:8 | | Eval z = __local6__ {__local6__: 3, z: 3} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:8 | | Redo z = __local6__ {__local6__: 3, z: 3} +%.*%/test.rego:8 | | Redo data.test.f(x, y, __local6__) {__local6__: 3, x: 1, y: 2} +%.*%/test.rego:12 | | | Redo c = __local7__ {__local7__: 3, c: 3} +%.*%/test.rego:12 | | | Redo plus(a, b, __local7__) {__local7__: 3, a: 1, b: 2} +%.*%/test.rego:7 | | Redo z = 3 {z: 3} +%.*%/test.rego:6 | | Redo y = 2 {y: 2} +%.*%/test.rego:5 | | Redo x = 1 {x: 1} +true +`, + }, + { + note: "every", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + l := ["a", "b", "c"] + every x in l { + count(x) == 1 + } +} + +f(a, b) := c if { + c := a + b +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval l = ["a", "b", "c"] {} +%.*%/test.rego:6 | | Eval __local6__ = l {l: ["a", "b", "c"]} +%.*%/test.rego:6 | | Eval every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local6__: ["a", "b", "c"]} +%.*%/test.rego:6 | | Enter every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local6__: ["a", "b", "c"]} +%.*%/test.rego:6 | | | Eval __local6__[__local1__] = x {__local6__: ["a", "b", "c"]} +%.*%/test.rego:7 | | | Enter count(x, __local7__); __local7__ = 1 {x: "a"} +%.*%/test.rego:7 | | | | Eval count(x, __local7__) {x: "a"} +%.*%/test.rego:7 | | | | Eval __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Exit count(x, __local7__); __local7__ = 1 early {__local7__: 1, x: "a"} +%.*%/test.rego:7 | | | Redo count(x, __local7__); __local7__ = 1 {__local7__: 1, x: "a"} +%.*%/test.rego:7 | | | | Redo __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Redo count(x, __local7__) {__local7__: 1, x: "a"} +%.*%/test.rego:6 | | | Redo every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local1__: 0, __local6__: ["a", "b", "c"], x: "a"} +%.*%/test.rego:6 | | | Redo __local6__[__local1__] = x {__local1__: 0, __local6__: ["a", "b", "c"], x: "a"} +%.*%/test.rego:7 | | | Enter count(x, __local7__); __local7__ = 1 {x: "b"} +%.*%/test.rego:7 | | | | Eval count(x, __local7__) {x: "b"} +%.*%/test.rego:7 | | | | Eval __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Exit count(x, __local7__); __local7__ = 1 early {__local7__: 1, x: "b"} +%.*%/test.rego:7 | | | Redo count(x, __local7__); __local7__ = 1 {__local7__: 1, x: "b"} +%.*%/test.rego:7 | | | | Redo __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Redo count(x, __local7__) {__local7__: 1, x: "b"} +%.*%/test.rego:6 | | | Redo every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local1__: 1, __local6__: ["a", "b", "c"], x: "b"} +%.*%/test.rego:6 | | | Redo __local6__[__local1__] = x {__local1__: 1, __local6__: ["a", "b", "c"], x: "b"} +%.*%/test.rego:7 | | | Enter count(x, __local7__); __local7__ = 1 {x: "c"} +%.*%/test.rego:7 | | | | Eval count(x, __local7__) {x: "c"} +%.*%/test.rego:7 | | | | Eval __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Exit count(x, __local7__); __local7__ = 1 early {__local7__: 1, x: "c"} +%.*%/test.rego:7 | | | Redo count(x, __local7__); __local7__ = 1 {__local7__: 1, x: "c"} +%.*%/test.rego:7 | | | | Redo __local7__ = 1 {__local7__: 1} +%.*%/test.rego:7 | | | | Redo count(x, __local7__) {__local7__: 1, x: "c"} +%.*%/test.rego:6 | | | Redo every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local1__: 2, __local6__: ["a", "b", "c"], x: "c"} +%.*%/test.rego:6 | | | Redo __local6__[__local1__] = x {__local1__: 2, __local6__: ["a", "b", "c"], x: "c"} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:6 | | Redo every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local6__: ["a", "b", "c"]} +%.*%/test.rego:6 | | | Exit every x in __local6__ { count(x, __local7__); __local7__ = 1 } {__local6__: ["a", "b", "c"]} +%.*%/test.rego:6 | | Redo __local6__ = l {__local6__: ["a", "b", "c"], l: ["a", "b", "c"]} +%.*%/test.rego:5 | | Redo l = ["a", "b", "c"] {l: ["a", "b", "c"]} +true +`, + }, + { + note: "rule value", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +a := 1 + +p if { + a + 1 == 2 + a + 2 == 3 +} +`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:6 | Enter data.test.p {} +%.*%/test.rego:7 | | Eval __local2__ = data.test.a {} +%.*%/test.rego:7 | | Index data.test.a (matched 1 rule, early exit) {} +%.*%/test.rego:4 | | Enter data.test.a {} +%.*%/test.rego:4 | | | Eval true {} +%.*%/test.rego:4 | | | Exit data.test.a early {} +%.*%/test.rego:7 | | Eval plus(__local2__, 1, __local0__) {__local2__: 1} +%.*%/test.rego:7 | | Eval __local0__ = 2 {__local0__: 2} +%.*%/test.rego:8 | | Eval __local3__ = data.test.a {data.test.a: 1} +%.*%/test.rego:8 | | Index data.test.a (matched 1 rule, early exit) {data.test.a: 1} +%.*%/test.rego:8 | | Eval plus(__local3__, 2, __local1__) {__local3__: 1} +%.*%/test.rego:8 | | Eval __local1__ = 3 {__local1__: 3} +%.*%/test.rego:6 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:6 | Redo data.test.p {} +%.*%/test.rego:8 | | Redo __local1__ = 3 {__local1__: 3} +%.*%/test.rego:8 | | Redo plus(__local3__, 2, __local1__) {__local1__: 3, __local3__: 1} +%.*%/test.rego:8 | | Redo __local3__ = data.test.a {__local3__: 1, data.test.a: 1} +%.*%/test.rego:7 | | Redo __local0__ = 2 {__local0__: 2} +%.*%/test.rego:7 | | Redo plus(__local2__, 1, __local0__) {__local0__: 2, __local2__: 1} +%.*%/test.rego:7 | | Redo __local2__ = data.test.a {__local2__: 1, data.test.a: 1} +%.*%/test.rego:4 | | | Redo true {} +true +`, + }, + { + note: "input values", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + input.x == 1 + input.x + input.y == input.z +} +`, + "input.json": `{ + "x": 1, + "y": 2, + "z": 3 +}`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval input.x = 1 {} +%.*%/test.rego:6 | | Eval __local1__ = input.x {} +%.*%/test.rego:6 | | Eval __local2__ = input.y {} +%.*%/test.rego:6 | | Eval plus(__local1__, __local2__, __local0__) {__local1__: 1, __local2__: 2} +%.*%/test.rego:6 | | Eval __local0__ = input.z {__local0__: 3} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:6 | | Redo __local0__ = input.z {__local0__: 3} +%.*%/test.rego:6 | | Redo plus(__local1__, __local2__, __local0__) {__local0__: 3, __local1__: 1, __local2__: 2} +%.*%/test.rego:6 | | Redo __local2__ = input.y {__local2__: 2} +%.*%/test.rego:6 | | Redo __local1__ = input.x {__local1__: 1} +%.*%/test.rego:5 | | Redo input.x = 1 {} +true +`, + }, + { + note: "data values", + query: "data.test.p", + includeVars: true, + files: map[string]string{ + "test.rego": `package test +import rego.v1 + +p if { + data.x == 1 + data.x + data.y == data.z +} +`, + "data.json": `{ + "x": 1, + "y": 2, + "z": 3 +}`, + }, + expected: `%SKIP_LINE% +query:1 %.*% Enter data.test.p = _ {} +query:1 %.*% | Eval data.test.p = _ {} +query:1 %.*% | Index data.test.p (matched 1 rule, early exit) {} +%.*%/test.rego:4 | Enter data.test.p {} +%.*%/test.rego:5 | | Eval data.x = 1 {} +%.*%/test.rego:6 | | Eval __local1__ = data.x {} +%.*%/test.rego:6 | | Eval __local2__ = data.y {} +%.*%/test.rego:6 | | Eval plus(__local1__, __local2__, __local0__) {__local1__: 1, __local2__: 2} +%.*%/test.rego:6 | | Eval __local0__ = data.z {__local0__: 3} +%.*%/test.rego:4 | | Exit data.test.p early {} +query:1 %.*% | Exit data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% Redo data.test.p = _ {_: true, data.test.p: true} +query:1 %.*% | Redo data.test.p = _ {_: true, data.test.p: true} +%.*%/test.rego:4 | Redo data.test.p {} +%.*%/test.rego:6 | | Redo __local0__ = data.z {__local0__: 3} +%.*%/test.rego:6 | | Redo plus(__local1__, __local2__, __local0__) {__local0__: 3, __local1__: 1, __local2__: 2} +%.*%/test.rego:6 | | Redo __local2__ = data.y {__local2__: 2} +%.*%/test.rego:6 | | Redo __local1__ = data.x {__local1__: 1} +%.*%/test.rego:5 | | Redo data.x = 1 {} +true +`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + var buf bytes.Buffer + + test.WithTempFS(tc.files, func(path string) { + params := newEvalCommandParams() + _ = params.bundlePaths.Set(path) + inputFile := filepath.Join(path, "input.json") + if _, err := os.Stat(inputFile); err == nil { + params.inputPath = inputFile + } + _ = params.outputFormat.Set(formats.Pretty) + _ = params.explain.Set(explainModeFull) + params.traceVarValues = tc.includeVars + params.disableIndexing = true + _ = params.bundlePaths.Set(path) + + _, err := eval([]string{tc.query}, params, &buf, nil) + if err != nil { + t.Fatalf("Unexpected error: %s\n\n%s", err, buf.String()) + } + }) + + actual := buf.String() + if !stringsMatch(t, tc.expected, actual) { + t.Fatalf("Expected:\n\n%v\n\nGot:\n\n%v", tc.expected, actual) + } + }) + } +} + +func stringsMatch(t *testing.T, expected, actual string) bool { + t.Helper() + + var expectedLines []string + for l := range strings.SplitSeq(expected, "\n") { + if !strings.Contains(l, "%SKIP_LINE%") { + expectedLines = append(expectedLines, l) + } + } + + actualLines := strings.Split(actual, "\n") + + if len(expectedLines) != len(actualLines) { + t.Errorf("Expected %d lines but got %d", len(expectedLines), len(actualLines)) + return false + } + + for i, expectedLine := range expectedLines { + actualLine := actualLines[i] + + expectedParts := strings.Split(expectedLine, "%.*%") + if len(expectedParts) == 1 { + if expectedLine != actualLine { + t.Errorf("Mismatch on line %d. Expected:\n\n%s\n\nGot:\n\n%s", i, expectedLine, actualLine) + return false + } + } else if len(expectedParts) == 2 { + if !strings.HasPrefix(actualLine, expectedParts[0]) { + t.Errorf("Expected line %d to start with:\n\n%s\n\nbut got:\n\n%s", i, expectedParts[0], actualLine) + return false + } + if !strings.HasSuffix(actualLine, expectedParts[1]) { + t.Errorf("Expected line %d to end with:\n\n%s\n\nbut got:\n\n%s", i, expectedParts[1], actualLine) + return false + } + } else { + t.Fatalf("At most one .* is allowed per line but found %d on line %d:\n\n%s", len(expectedParts)-1, i, expectedLine) + return false + } + } + + return true +} + +func TestResetExprLocations(t *testing.T) { + + // Make sure no panic if passed nil. + resetExprLocations(nil) + + // Run partial evaluation on this fake module and check results. + // The content of the module is not very important it just has to generate + // support and cases where the locaiton is unset. The default causes support + // and exprs with no location information. + pq, err := rego.New(rego.Query("data.test.p = x"), rego.Module("test.rego", ` + package test + + default p = false + + p if { + input.x = q[_] + } + + q contains 1 + q contains 2 + `)).Partial(t.Context()) + + if err != nil { + t.Fatal(err) + } + + resetExprLocations(pq) + + var exp int + + vis := ast.NewGenericVisitor(func(x any) bool { + if expr, ok := x.(*ast.Expr); ok { + if expr.Location.Row != exp { + t.Fatalf("Expected %v to have row %v but got %v", expr, exp, expr.Location.Row) + } + exp++ + } + return false + }) + + for i := range pq.Queries { + vis.Walk(pq.Queries[i]) + } + + for i := range pq.Support { + vis.Walk(pq.Support[i]) + } + +} +func kubeSchemaServer(t *testing.T) *httptest.Server { + t.Helper() + bs, err := os.ReadFile("../v1/ast/testdata/_definitions.json") + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write(bs) + if err != nil { + panic(err) + } + })) + return ts +} + +func TestEvalPartialFormattedOutput(t *testing.T) { + + query := `time.clock(input.x) == time.clock(input.y)` + tests := []struct { + format, expected string + }{ + { + format: formats.Pretty, + expected: `┌─────────┬──────────────────────────────────────────┐ +│ Query 1 │ time.clock(input.y, time.clock(input.x)) │ +└─────────┴──────────────────────────────────────────┘ +`}, + { + format: formats.Source, + expected: `# Query 1 +time.clock(input.y, time.clock(input.x)) + +`}, + } + + for _, tc := range tests { + t.Run(tc.format, func(t *testing.T) { + buf := new(bytes.Buffer) + params := newEvalCommandParams() + params.partial = true + _ = params.outputFormat.Set(tc.format) + _, err := eval([]string{query}, params, buf, nil) + if err != nil { + t.Fatal("unexpected error:", err) + } + if diff := cmp.Diff(buf.String(), tc.expected); diff != "" { + t.Error("output mismatch (-want +got):\n", diff) + } + }) + } +} + +func TestEvalPartialOutput_RegoVersion(t *testing.T) { + tests := []struct { + note string + regoV1ImportCapable bool + v0Compatible bool + query string + module string + expected map[string]string + }{ + { + note: "v0, no future keywords", + v0Compatible: true, + regoV1ImportCapable: true, + query: "data.test.p", + module: `package test + +p[v] { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +import rego.v1 + +p contains __local0__1 if __local0__1 = input.v +`, + formats.Pretty: `┌───────────┬─────────────────────────────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ import rego.v1 │ +│ │ │ +│ │ p contains __local0__1 if __local0__1 = input.v │ +└───────────┴─────────────────────────────────────────────────┘ +`, + }, + }, + { + note: "v0, no future keywords, not rego.v1 import capable", + v0Compatible: true, + regoV1ImportCapable: false, + query: "data.test.p", + module: `package test + +p[v] { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +p[__local0__1] { + __local0__1 = input.v +} +`, + formats.Pretty: `┌───────────┬─────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ p[__local0__1] { │ +│ │ __local0__1 = input.v │ +│ │ } │ +└───────────┴─────────────────────────┘ +`, + }, + }, + { + note: "v0, future keywords", + v0Compatible: true, + regoV1ImportCapable: true, + query: "data.test.p", + module: `package test + +import rego.v1 + +p contains v if { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +import rego.v1 + +p contains __local0__1 if __local0__1 = input.v +`, + formats.Pretty: `┌───────────┬─────────────────────────────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ import rego.v1 │ +│ │ │ +│ │ p contains __local0__1 if __local0__1 = input.v │ +└───────────┴─────────────────────────────────────────────────┘ +`, + }, + }, + { + note: "v1", + regoV1ImportCapable: true, + v0Compatible: false, + query: "data.test.p", + module: `package test + +p contains v if { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +p contains __local0__1 if __local0__1 = input.v +`, + formats.Pretty: `┌───────────┬─────────────────────────────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ p contains __local0__1 if __local0__1 = input.v │ +└───────────┴─────────────────────────────────────────────────┘ +`, + }, + }, + { + note: "v1, rego.v1 import", + regoV1ImportCapable: true, + v0Compatible: false, + query: "data.test.p", + module: `package test + +import rego.v1 + +p contains v if { + v := input.v +} +`, + expected: map[string]string{ + formats.Source: `# Query 1 +data.partial.test.p + +# Module 1 +package partial.test + +p contains __local0__1 if __local0__1 = input.v +`, + formats.Pretty: `┌───────────┬─────────────────────────────────────────────────┐ +│ Query 1 │ data.partial.test.p │ +├───────────┼─────────────────────────────────────────────────┤ +│ Support 1 │ package partial.test │ +│ │ │ +│ │ p contains __local0__1 if __local0__1 = input.v │ +└───────────┴─────────────────────────────────────────────────┘ +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + for format, expected := range tc.expected { + t.Run(format, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.module, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + _ = params.dataPaths.Set(filepath.Join(path, "test.rego")) + params.partial = true + params.v0Compatible = tc.v0Compatible + _ = params.outputFormat.Set(format) + + if !tc.regoV1ImportCapable { + caps := newCapabilitiesFlag() + caps.C = ast.CapabilitiesForThisVersion() + caps.C.Features = []string{ + ast.FeatureRefHeadStringPrefixes, + ast.FeatureRefHeads, + } + params.capabilities = caps + } + + buf := new(bytes.Buffer) + _, err := eval([]string{tc.query}, params, buf, nil) + if err != nil { + t.Fatal("unexpected error:", err) + } + + if diff := cmp.Diff(buf.String(), expected); diff != "" { + t.Error("output mismatch (-want +got):\n", diff) + } + }) + }) + } + }) + } +} + +func TestEvalDiscardOutput(t *testing.T) { + tests := map[string]struct { + query, format, expected string + params evalCommandParams + }{ + "success example": { + query: "1*2+3", + params: func() evalCommandParams { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.Discard) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + return params + }(), + expected: `{ + "result": "discarded" +} +`}, + "error example": { + query: "1/0", + params: func() evalCommandParams { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.Discard) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + return params + }(), + expected: `{} +`}, + "error example show built-in-errors": { + query: "1/0", + params: func() evalCommandParams { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.Discard) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + params.showBuiltinErrors = true + return params + }(), + expected: `{ + "errors": [ + { + "code": "eval_builtin_error", + "location": { + "col": 1, + "file": "", + "row": 1 + }, + "message": "div: divide by zero" + } + ] +} +`}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + _, err := eval([]string{tc.query}, tc.params, &buf, nil) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if diff := cmp.Diff(buf.String(), tc.expected); diff != "" { + t.Error("output mismatch (-want +got):\n", diff) + } + }) + } +} + +func TestEvalDiscardProfilerOutput(t *testing.T) { + params := newEvalCommandParams() + err := params.outputFormat.Set(formats.Discard) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + params.profile = true + + query := "1*2+3" + + var buf bytes.Buffer + _, err = eval([]string{query}, params, &buf, nil) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + var output map[string]any + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + // assert that the result is set to discarded + result, ok := output["result"].(string) + if !ok { + t.Fatal("error extracting result as string from output") + } + + if result != "discarded" { + t.Fatal("Expected result field to be set to 'discarded'") + } + + // assert that profile is still set + _, ok = output["profile"] + if !ok { + t.Fatal("error in parsing profile output") + } +} + +func TestPolicyWithStrictFlag(t *testing.T) { + testsShouldError := []struct { + note string + v0Compatible bool + policy string + query string + expectedCode string + expectedMessage string + }{ + { + note: "strict mode should error on unused imports", + policy: `package x + import future.keywords.if + import data.foo + foo = 2`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import data.foo unused", + }, + { + note: "v0 compat, strict mode should error on duplicate imports", + v0Compatible: true, + policy: `package x + import data.bar + import data.bar + foo = bar`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import must not shadow import data.bar", + }, + { + note: "v0 compat, strict mode should error on unused imports", + v0Compatible: true, + policy: `package x + import future.keywords.if + import data.foo + foo = 2`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import data.foo unused", + }, + { + note: "v0 compat, strict mode should error when reserved vars data or input is used", + v0Compatible: true, + policy: `package x + data { x = 1}`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "rules must not shadow data (use a different rule name)", + }, + } + + for _, tc := range testsShouldError { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(path string) { + for _, strict := range []bool{true, false} { + params := newEvalCommandParams() + params.strict = strict + params.v0Compatible = tc.v0Compatible + + _ = params.dataPaths.Set(filepath.Join(path, "test.rego")) + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + + if strict { + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if code := output.Errors[0].Code; code != tc.expectedCode { + t.Errorf("expected code '%v', got '%v'", tc.expectedCode, code) + } + if msg := output.Errors[0].Message; msg != tc.expectedMessage { + t.Errorf("expected message '%v', got '%v'", tc.expectedMessage, msg) + } + } else if err != nil { + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + t.Fatal("unexpected error when non-strict:", output) + } + } + }) + }) + } + + testsShouldPass := []struct { + note string + policy string + query string + }{ + { + note: "This should not error as it is valid", + policy: `package x + import future.keywords.if + foo = 2`, + query: "data.foo", + }, + { + note: "Strict mode should not validate the query, only the policy, this should not error", + policy: `package x + import future.keywords.if + foo = 2`, + query: "x := data.x.foo", + }, + } + for _, tc := range testsShouldPass { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(_ string) { + params := newEvalCommandParams() + params.strict = true + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + if err != nil { + t.Errorf("Should not error, got error: '%v'", err) + } + }) + }) + } + +} + +func TestBundleWithStrictFlag(t *testing.T) { + testsShouldError := []struct { + note string + v0Compatible bool + policy string + query string + expectedCode string + expectedMessage string + }{ + { + note: "strict mode should error on unused imports in this bundle", + policy: `package x + import data.foo + foo = 2`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import data.foo unused", + }, + { + note: "v0 compat, strict mode should error on duplicate imports in this bundle", + v0Compatible: true, + policy: `package x + import data.bar + import data.bar + foo = bar`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import must not shadow import data.bar", + }, + { + note: "v0 compat, strict mode should error on unused imports in this bundle", + v0Compatible: true, + policy: `package x + import data.foo + foo = 2`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "import data.foo unused", + }, + { + note: "v0 compat, strict mode should error when reserved vars data or input is used in this bundle", + v0Compatible: true, + policy: `package x + data { x = 1}`, + query: "data.foo", + expectedCode: "rego_compile_error", + expectedMessage: "rules must not shadow data (use a different rule name)", + }, + } + + for _, tc := range testsShouldError { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(path string) { + for _, strict := range []bool{true, false} { + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + params.strict = strict + params.v0Compatible = tc.v0Compatible + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + + if strict { + if err == nil { + t.Fatal("expected error, got nil") + } + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + + if code := output.Errors[0].Code; code != tc.expectedCode { + t.Errorf("expected code '%v', got '%v'", tc.expectedCode, code) + } + if msg := output.Errors[0].Message; msg != tc.expectedMessage { + t.Errorf("expected message '%v', got '%v'", tc.expectedMessage, msg) + } + } else if err != nil { + var output presentation.Output + if err := util.NewJSONDecoder(&buf).Decode(&output); err != nil { + t.Fatal(err) + } + t.Fatal("unexpected error when non-strict:", output) + } + } + }) + }) + } + + testsShouldPass := []struct { + note string + policy string + query string + }{ + { + note: "This bundle should not error as it is valid", + policy: `package x + import future.keywords.if + foo = 2`, + query: "data.foo", + }, + { + note: "Strict mode should not validate the query, only the policy, this bundle should not error", + policy: `package x + import future.keywords.if + foo = 2`, + query: "x := data.x.foo", + }, + } + for _, tc := range testsShouldPass { + t.Run(tc.note, func(t *testing.T) { + + files := map[string]string{ + "test.rego": tc.policy, + } + + test.WithTempFS(files, func(path string) { + params := newEvalCommandParams() + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + params.strict = true + + var buf bytes.Buffer + _, err := eval([]string{tc.query}, params, &buf, nil) + if err != nil { + t.Errorf("Should not error, got error: '%v'", err) + } + }) + }) + } + +} + +func TestIfElseIfElseNoBrace(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := 1 if false + else := 2`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestIfElseIfElseBrace(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := 1 if { false } + else := 2`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestIfElse(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := 1 `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +// TestElseNoIfV0 only applies to v0 Rego +func TestElseNoIfV0(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + import future.keywords.if + p if false + else = x { + x=2 + } `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + params.v0Compatible = true + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestElseIf(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := x if { + x=2 + } `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +// TestElseIfElseV0 only applies to v0 Rego +func TestElseIfElseV0(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + import future.keywords.if + p if false + else := x if { + x=2 + 1==2 + } else =x { + x=3 + }`, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + params.v0Compatible = true + + var buf bytes.Buffer + + defined, err := eval([]string{"data.bug.p"}, params, &buf, nil) + if !defined || err != nil { + t.Fatalf("Unexpected undefined or error: %v", err) + } + }) +} + +func TestUnexpectedElseIfElseErr(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + p if false + else := x if { + x=2 + 1==2 + } else + x=3 + `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + _, err := eval([]string{"data.bug.p"}, params, &buf, nil) + + // Check if there was an error + if err == nil { + t.Fatalf("expected an error, but got nil") + } + + // Check the error message + errorMessage := err.Error() + expectedErrorMessage := "rego_parse_error: unexpected identifier token: expected else value term or rule body" + if !strings.Contains(errorMessage, expectedErrorMessage) { + t.Fatalf("expected error message to contain '%s', but got '%s'", expectedErrorMessage, errorMessage) + } + }) +} + +func TestUnexpectedElseIfErr(t *testing.T) { + files := map[string]string{ + "bug.rego": `package bug + + q := 1 if false + else := 2 if + `, + } + + test.WithTempFS(files, func(path string) { + + params := newEvalCommandParams() + params.optimizationLevel = 1 + params.dataPaths = newrepeatedStringFlag([]string{path}) + params.entrypoints = newrepeatedStringFlag([]string{"bug/p"}) + + var buf bytes.Buffer + + _, err := eval([]string{"data.bug.p"}, params, &buf, nil) + + // Check if there was an error + if err == nil { + t.Fatalf("expected an error, but got nil") + } + + // Check the error message + errorMessage := err.Error() + expectedErrorMessage := "rego_parse_error: unexpected eof token: rule body expected" + if !strings.Contains(errorMessage, expectedErrorMessage) { + t.Fatalf("expected error message to contain '%s', but got '%s'", expectedErrorMessage, errorMessage) + } + }) +} + +func TestEval_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + modules map[string]string + query string + expErrs []string + }{ + { + note: "v0 module", + modules: map[string]string{ + "test.rego": `package test +a[x] { + x := 42 +}`, + }, + query: `data.test.a`, + 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", + modules: map[string]string{ + "test.rego": `package test +a contains x if { + x := 42 +}`, + }, + query: `data.test.a`, + }, + } + + setup := []struct { + name string + commandParams func(params *evalCommandParams, path string) + }{ + { + name: "Files", + commandParams: func(params *evalCommandParams, path string) { + params.dataPaths = newrepeatedStringFlag([]string{path}) + }, + }, + { + name: "Bundle", + commandParams: func(params *evalCommandParams, path string) { + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + }, + }, + } + + for _, s := range setup { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s: %s", s.name, tc.note), func(t *testing.T) { + test.WithTempFS(tc.modules, func(path string) { + params := newEvalCommandParams() + _ = params.outputFormat.Set(formats.Pretty) + s.commandParams(¶ms, path) + + var buf bytes.Buffer + + defined, err := eval([]string{tc.query}, params, &buf, &buf) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(actual, expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", expErr, actual) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } else if !defined { + t.Fatal("expected result to be defined") + } + } + }) + }) + } + } +} + +func TestEvalPolicyWithCompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + modules map[string]string + query string + expectedErr string + }{ + { + note: "v0 compatibility: policy with no rego.v1 or future.keywords imports", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + expectedErr: "rego_parse_error", + }, + { + note: "v0 compatibility: policy with rego.v1 import", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v0 compatibility: policy with future.keywords import", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import future.keywords + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v1 compatibility: policy with no rego.v1 or future.keywords imports", + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v1 compatibility: policy with rego.v1 import", + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v1 compatibility: policy with future.keywords import", + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import future.keywords.if + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v0 + v1 compatibility: policy with no rego.v1 or future.keywords imports", + v0Compatible: true, + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + expectedErr: "rego_parse_error", + }, + { + note: "v0 + v1 compatibility: policy with rego.v1 import", + v0Compatible: true, + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import rego.v1 + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v0 + v1 compatibility: policy with future.keywords import", + v0Compatible: true, + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + import future.keywords + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + { + note: "v1 compatibility: policy with no rego.v1 or future.keywords imports", + v1Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + query: "data.test.allow", + }, + } + + setup := []struct { + name string + commandParams func(params *evalCommandParams, path string) + }{ + { + name: "Files", + commandParams: func(params *evalCommandParams, path string) { + params.dataPaths = newrepeatedStringFlag([]string{path}) + }, + }, + { + name: "Bundle", + commandParams: func(params *evalCommandParams, path string) { + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + }, + }, + } + + for _, s := range setup { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s: %s", s.name, tc.note), func(t *testing.T) { + test.WithTempFS(tc.modules, func(path string) { + params := newEvalCommandParams() + s.commandParams(¶ms, path) + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + var buf bytes.Buffer + + defined, err := eval([]string{tc.query}, params, &buf, nil) + + if tc.expectedErr == "" { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } else if !defined { + t.Fatal("expected result to be defined") + } + } else { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + if !strings.Contains(actual, tc.expectedErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", tc.expectedErr, actual) + } + } + }) + }) + } + } +} + +func TestEvalPolicyWithRegoV1Capability(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + capabilities *ast.Capabilities + modules map[string]string + expErrs []string + }{ + { + note: "v0 module, v0-compatible, no capabilities", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + }, + { + note: "v0 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + }, + { + note: "v0 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + }, + { + note: "v0 module, not v0-compatible, no capabilities", + v0Compatible: false, + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities", + v0Compatible: false, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities without rego_v1 feature", + v0Compatible: false, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 module, not v0-compatible, v1 capabilities", + v0Compatible: false, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + modules: map[string]string{ + "test.rego": `package test + allow { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: `if` keyword is required before rule body", + }, + }, + + { + note: "v1 module, v0-compatible, no capabilities", + v0Compatible: true, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expErrs: []string{ + "test.rego:2: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, not v0-compatible, no capabilities", + v0Compatible: false, + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities", + v0Compatible: false, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities without rego_v1 feature", + v0Compatible: false, + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v1 module, not v0-compatible, v1 capabilities", + v0Compatible: false, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + modules: map[string]string{ + "test.rego": `package test + allow if { + 1 < 2 + }`, + }, + }, + } + + setup := []struct { + name string + commandParams func(params *evalCommandParams, path string) + }{ + { + name: "Files", + commandParams: func(params *evalCommandParams, path string) { + params.dataPaths = newrepeatedStringFlag([]string{path}) + }, + }, + { + name: "Bundle", + commandParams: func(params *evalCommandParams, path string) { + if err := params.bundlePaths.Set(path); err != nil { + t.Fatal(err) + } + }, + }, + } + + for _, s := range setup { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s: %s", s.name, tc.note), func(t *testing.T) { + test.WithTempFS(tc.modules, func(path string) { + params := newEvalCommandParams() + s.commandParams(¶ms, path) + _ = params.outputFormat.Set(formats.Pretty) + params.v0Compatible = tc.v0Compatible + params.capabilities.C = tc.capabilities + + var buf bytes.Buffer + + defined, err := eval([]string{"data.test.allow"}, params, &buf, &buf) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(actual, expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", expErr, actual) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } else if !defined { + t.Fatal("expected result to be defined") + } + } + }) + }) + } + } +} + +func TestEvalPolicyWithBundleRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + query string + expectedErr string + }{ + { + note: "v0.x bundle, no rego.v1 or future.keywords imports", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +allow if { + 1 < 2 +}`, + }, + query: "data.test.allow", + expectedErr: "rego_parse_error", + }, + { + 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[1] { + 1 < 2 +} +`, + "policy2.rego": `package test +p contains 2 if { + 1 < 2 +} +`, + }, + query: "data.test.p", + }, + { + 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[1] { + 1 < 2 +} +`, + "bar/policy1.rego": `package test +p contains 2 if { + 1 < 2 +} +`, + "bar/policy2.rego": `package test +p contains 3 if { + 1 < 2 +} +`, + }, + query: "data.test.p", + }, + { + 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[1] { + 1 < 2 +} +`, + "policy2.rego": `package test +p[2] { + 1 < 2 +} +`, + }, + query: "data.test.p", + expectedErr: "rego_parse_error", + }, + + { + note: "v1.0 bundle, no rego.v1 or future.keywords imports", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +allow if { + 1 < 2 +}`, + }, + query: "data.test.allow", + }, + { + note: "v1.0 bundle, policy with rego.v1 import", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 +allow if { + 1 < 2 +}`, + }, + query: "data.test.allow", + }, + { + note: "v1.0 bundle, future.keywords import", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords.if +allow if { + 1 < 2 +}`, + }, + query: "data.test.allow", + }, + { + note: "v1.0 bundle, keywords not used", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +allow { + 1 < 2 +}`, + }, + query: "data.test.allow", + expectedErr: "rego_parse_error", + }, + { + 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[1] { + 1 < 2 +} +`, + "policy2.rego": `package test +p contains 2 if { + 1 < 2 +} +`, + }, + query: "data.test.p", + }, + { + 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[1] { + 1 < 2 +} +`, + "foo/policy2.rego": `package test +p[2] { + 1 < 2 +} +`, + "bar/policy1.rego": `package test +p contains 3 if { + 1 < 2 +} +`, + }, + query: "data.test.p", + }, + { + note: "v1 bundle, v0 per-file override, incompliant", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "*/policy2.rego": 0 + } +}`, + "policy1.rego": `package test +p contains 1 if { + input.x == 1 +} +`, + "policy2.rego": `package test +p contains 2 if { + input.x == 1 +} +`, + }, + query: "data.test.p", + expectedErr: "rego_parse_error", + }, + } + + bundleTypeCases := []struct { + note string + tar bool + }{ + { + "bundle dir", false, + }, + { + "bundle tar", true, + }, + } + + v0CompatibleFlagCases := []struct { + note string + used bool + }{ + { + "no --v0-compatible", false, + }, + { + "--v0-compatible", true, + }, + } + + for _, bundleType := range bundleTypeCases { + for _, v0CompatibleFlag := range v0CompatibleFlagCases { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s, %s, %s", bundleType.note, v0CompatibleFlag.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 := newEvalCommandParams() + params.v0Compatible = v0CompatibleFlag.used + if err := params.bundlePaths.Set(p); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + defined, err := eval([]string{tc.query}, params, &buf, nil) + + if tc.expectedErr == "" { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } else if !defined { + t.Fatal("expected result to be defined") + } + } else { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + if !strings.Contains(actual, tc.expectedErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", tc.expectedErr, actual) + } + } + }) + }) + } + } + } +} + +func TestWithQueryImports(t *testing.T) { + tests := []struct { + note string + query string + imports []string + v0Compatible bool + v1Compatible bool + exp string + expErrs []string + }{ + { + note: "no imports, none required", + query: "1 + 2", + exp: "3\n", + }, + { + note: "future keyword used, future.keywords imported", + query: `"b" in ["a", "b", "c"]`, + imports: []string{"future.keywords.in"}, + exp: "true\n", + }, + { + note: "future keyword used, rego.v1 imported", + query: `"b" in ["a", "b", "c"]`, + imports: []string{"rego.v1"}, + exp: "true\n", + }, + { + note: "future keyword used, invalid rego.v2 imported", + v0Compatible: true, + query: `"b" in ["a", "b", "c"]`, + imports: []string{"rego.v2"}, + expErrs: []string{ + "1:8: rego_parse_error: invalid import `rego.v2`, must be `rego.v1`", + }, + }, + { + note: "future keyword used, no imports (v0)", + v0Compatible: true, + query: `"b" in ["a", "b", "c"]`, + expErrs: []string{ + "1:5: rego_unsafe_var_error: var in is unsafe (hint: `import future.keywords.in` to import a future keyword)", + }, + }, + { + note: "future keyword used, no imports (v1)", + v1Compatible: true, + query: `"b" in ["a", "b", "c"]`, + exp: "true\n", + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + params := newEvalCommandParams() + _ = params.outputFormat.Set(formats.Pretty) + params.imports = newrepeatedStringFlag(tc.imports) + params.v0Compatible = tc.v0Compatible + params.v1Compatible = tc.v1Compatible + + var buf bytes.Buffer + + defined, err := eval([]string{tc.query}, params, &buf, &buf) + + if len(tc.expErrs) == 0 { + if err != nil { + t.Fatalf("Unexpected error: %v, buf: %s", err, buf.String()) + } + + if !defined { + t.Fatal("expected result to be defined") + } + + if buf.String() != tc.exp { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", tc.exp, buf.String()) + } + } else { + if err == nil { + t.Fatal("expected error, got none") + } + + actual := buf.String() + for _, expErr := range tc.expErrs { + if !strings.Contains(actual, expErr) { + t.Fatalf("expected error:\n\n%v\n\ngot\n\n%v", expErr, actual) + } + } + } + }) + } +} + +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) + } +} diff --git a/cmd/eval_test.go b/cmd/eval_test.go old mode 100755 new mode 100644 index 372fbdcee0..73fb9159ff --- a/cmd/eval_test.go +++ b/cmd/eval_test.go @@ -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) + } +} diff --git a/cmd/exec_jsonv2_test.go b/cmd/exec_jsonv2_test.go new file mode 100644 index 0000000000..e954fd6248 --- /dev/null +++ b/cmd/exec_jsonv2_test.go @@ -0,0 +1,1603 @@ +//go:build go1.27 + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "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" + "github.com/open-policy-agent/opa/v1/plugins" + "github.com/open-policy-agent/opa/v1/sdk" + sdk_test "github.com/open-policy-agent/opa/v1/sdk/test" + "github.com/open-policy-agent/opa/v1/util/test" +) + +type execOutput struct { + Result []execResultItem `json:"result"` +} + +type execResultItem struct { + DecisionID string `json:"decision_id,omitempty"` + Path string `json:"path"` + Error execResultItemError `json:"error"` + Result *any `json:"result,omitempty"` +} + +type execResultItemError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (r execResultItemError) isEmpty() bool { + return r.Code == "" && r.Message == "" +} + +func toAnyPtr(a any) *any { + return &a +} + +func toStringSlice(a *any) []string { + switch a := (*a).(type) { + case []string: + return a + case []any: + strSlice := make([]string, len(a)) + for i := range a { + strSlice[i] = a[i].(string) + } + return strSlice + } + + return nil +} + +func resultSliceEquals(t *testing.T, expected, output []execResultItem) { + t.Helper() + + if len(expected) != len(output) { + t.Fatalf("Expected %d results but got %d", len(expected), len(output)) + } + + for i := range output { + if expected[i].Path != output[i].Path { + t.Fatalf("Expected path %v but got %v", expected[i].Path, output[i].Path) + } + + if expected[i].Error.isEmpty() { + if !output[i].Error.isEmpty() { + t.Fatalf("Expected no error but got %v", output[i].Error) + } + + if !slices.Equal(toStringSlice(expected[i].Result), toStringSlice(output[i].Result)) { + t.Fatalf("Expected result %v but got %v", expected[i].Result, output[i].Result) + } + + if !uuidPattern.MatchString(output[i].DecisionID) { + t.Fatalf("Expected decision ID to be a UUID but got %v", output[i].DecisionID) + } + } else { + if expected[i].Error.Code != output[i].Error.Code { + t.Fatalf("Expected error code %v but got %v", expected[i].Error.Code, output[i].Error.Code) + } + + if expected[i].Error.Message != output[i].Error.Message { + t.Fatalf("Expected error message %v but got %v", expected[i].Error.Message, output[i].Error.Message) + } + + if output[i].DecisionID != "" { + t.Fatalf("Expected no decision ID but got %v", output[i].DecisionID) + } + } + } +} + +var uuidPattern = regexp.MustCompile(`^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$`) + +func TestExecBasic(t *testing.T) { + files := map[string]string{ + "test.json": `{"foo": 7}`, + "test2.yaml": `bar: 8`, + "test3.yml": `baz: 9`, + "ignore": `garbage`, // do not recognize this filetype + } + + test.WithTempFS(files, func(dir string) { + s := sdk_test.MustNewServer(sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{ + "test.rego": ` + package system + main contains "hello" + `, + })) + + defer s.Stop() + + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + params.Paths = append(params.Paths, dir) + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + { + Path: "/test2.yaml", + Result: toAnyPtr([]string{"hello"}), + }, + { + Path: "/test3.yml", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + }) +} + +func TestExecDecisionOption(t *testing.T) { + files := map[string]string{ + "test.json": `{"foo": 7}`, + } + + test.WithTempFS(files, func(dir string) { + s := sdk_test.MustNewServer(sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{ + "test.rego": ` + package foo + + main contains "hello" + `, + })) + + defer s.Stop() + + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.Decision = "foo/main" + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + params.Paths = append(params.Paths, dir) + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + }) +} + +func TestExecBundleFlag(t *testing.T) { + files := map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + + main contains "hello"`, + } + + 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/") + + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/files/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + }) +} + +func TestExec_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + module string + expErrs []string + }{ + { + note: "v0, module", + module: `package system +main["hello"] { + input.foo == "bar" +}`, + 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 system +main contains "hello" if { + input.foo == "bar" +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.json": `{"foo": "bar"}`, + } + + test.WithTempFS(files, func(dir string) { + s := sdk_test.MustNewServer( + sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{"test.rego": tc.module}), + sdk_test.RawBundles(true), + ) + + defer s.Stop() + + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + params.Paths = append(params.Paths, dir) + + if len(tc.expErrs) > 0 { + testLogger := loggingtest.New() + params.Logger = testLogger + + // Wait for bundle server to be ready + bundleURL := s.URL() + "/bundles/bundle.tar.gz" + test.EventuallyOrFatal(t, 1*time.Second, func() bool { + resp, err := http.Get(bundleURL) + if resp != nil { + defer resp.Body.Close() + } + return err == nil && resp.StatusCode == 200 + }) + + _ = runExec(params) + + // Check logged errors + for _, expErr := range tc.expErrs { + found := false + for _, e := range testLogger.Entries() { + if strings.Contains(e.Message, expErr) { + found = true + break + } + } + if !found { + t.Errorf("Could not find expected logged error: %s in %v", expErr, testLogger.Entries()) + } + } + } else { + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + } + }) + }) + } +} + +func TestExecCompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + module string + expErrs []string + }{ + { + note: "v0, no keywords used", + v0Compatible: true, + module: `package system +main["hello"] { + input.foo == "bar" +}`, + }, + { + note: "v0, no keywords imported", + v0Compatible: true, + module: `package system +main contains "hello" if { + input.foo == "bar" +}`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: string cannot be used for rule name", + }, + }, + { + note: "v0, keywords imported", + v0Compatible: true, + module: `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + { + note: "v0, rego.v1 imported", + v0Compatible: true, + module: `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + + { + note: "v1, no keywords used", + v1Compatible: true, + module: `package system +main["hello"] { + input.foo == "bar" +}`, + 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 keywords imported", + v1Compatible: true, + module: `package system +main contains "hello" if { + input.foo == "bar" +}`, + }, + { + note: "v1, keywords imported", + v1Compatible: true, + module: `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + { + note: "v1, rego.v1 imported", + v1Compatible: true, + module: `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + + // v0 takes precedence over v1 + { + note: "v0+v1, no keywords used", + v0Compatible: true, + v1Compatible: true, + module: `package system +main["hello"] { + input.foo == "bar" +}`, + }, + { + note: "v0+v1, no keywords imported", + v0Compatible: true, + v1Compatible: true, + module: `package system +main contains "hello" if { + input.foo == "bar" +}`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: string cannot be used for rule name", + }, + }, + { + note: "v0+v1, keywords imported", + v0Compatible: true, + v1Compatible: true, + module: `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + { + note: "v0+v1, rego.v1 imported", + v0Compatible: true, + v1Compatible: true, + module: `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.json": `{"foo": "bar"}`, + } + + test.WithTempFS(files, func(dir string) { + s := sdk_test.MustNewServer( + sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{"test.rego": tc.module}), + sdk_test.RawBundles(true), + ) + + defer s.Stop() + + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.V0Compatible = tc.v0Compatible + params.V1Compatible = tc.v1Compatible + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + params.Paths = append(params.Paths, dir) + + if len(tc.expErrs) > 0 { + testLogger := loggingtest.New() + params.Logger = testLogger + + // Wait for bundle server to be ready + bundleURL := s.URL() + "/bundles/bundle.tar.gz" + test.EventuallyOrFatal(t, 1*time.Second, func() bool { + resp, err := http.Get(bundleURL) + if resp != nil { + defer resp.Body.Close() + } + return err == nil && resp.StatusCode == 200 + }) + + _ = runExec(params) + + // Check logged errors + for _, expErr := range tc.expErrs { + found := false + for _, e := range testLogger.Entries() { + if strings.Contains(e.Message, expErr) { + found = true + break + } + } + if !found { + t.Errorf("Could not find expected logged error: %s in %v", expErr, testLogger.Entries()) + } + } + } else { + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + } + }) + }) + } +} + +func TestExecWithBundleRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + expErrs []string + }{ + { + note: "v0.x bundle, no keywords used", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package system +main["hello"] { + input.foo == "bar" +}`, + }, + }, + { + note: "v0.x bundle, no keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package system +main contains "hello" if { + input.foo == "bar" +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: string cannot be used for rule name", + }, + }, + { + note: "v0.x bundle, keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v0.x bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/policy2.rego": 1 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override, incompatible", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main["hello"] { + p[_] == 42 +}`, + }, + 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 used", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package system +main["hello"] { + input.foo == "bar" +}`, + }, + 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 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package system +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v1.0 bundle, keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package system +import future.keywords +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package system +import rego.v1 +main contains "hello" if { + input.foo == "bar" +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "*/policy1.rego": 0 + } +}`, + "policy1.rego": `package system +p[42] { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override, incompatible", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package system +p contains 42 { + input.foo == "bar" +}`, + "policy2.rego": `package system +main contains "hello" if { + 42 in p +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + "rego_parse_error: number cannot be used for rule name", + "rego_parse_error: set 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{ + "files/test.json": `{"foo": "bar"}`, + } + 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) + } + } + + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.Paths = append(params.Paths, root+"/files/") + params.BundlePaths = []string{p} + params.V1Compatible = v1CompatibleFlag.used + _ = params.OutputFormat.Set("json") + + if len(tc.expErrs) > 0 { + testLogger := loggingtest.New() + params.Logger = testLogger + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + // when bundles fail to parse, OPA never signals the ready channel, causing + // runExec to hang indefinitely. WithContext allows us to cancel the context + // when we have the required errors logged. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + err := runExecWithContext(ctx, params) + // we cancelled the context, so we expect that error + if err != nil && err.Error() != "context canceled" { + // make sure it isn't an expected error + for _, msg := range tc.expErrs { + if strings.Contains(err.Error(), msg) { + return + } + } + + t.Error(err) + return + } + }() + + test.EventuallyOrFatal(t, 5*time.Second, func() bool { + for _, expErr := range tc.expErrs { + found := false + for _, e := range testLogger.Entries() { + if strings.Contains(e.Message, expErr) { + found = true + break + } + } + if !found { + return false + } + } + return true + }) + + cancel() + wg.Wait() + } else { + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(root), nil), &output); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, []execResultItem{ + { + Path: "/files/test.json", + Result: toAnyPtr([]string{"hello"}), + }, + }, output.Result) + } + }) + }) + } + } + } +} + +func TestInvalidConfig(t *testing.T) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.Fail = true + params.FailDefined = true + + err := exec.Exec(t.Context(), nil, params) + if err == nil || err.Error() != "specify --fail or --fail-defined but not both" { + t.Fatalf("Expected error '%s' but got '%s'", "specify --fail or --fail-defined but not both", err.Error()) + } +} + +func TestInvalidConfigAllThree(t *testing.T) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.Fail = true + params.FailDefined = true + params.FailNonEmpty = true + + err := exec.Exec(t.Context(), nil, params) + if err == nil || err.Error() != "specify --fail or --fail-defined but not both" { + t.Fatalf("Expected error '%s' but got '%s'", "specify --fail or --fail-defined but not both", err.Error()) + } +} + +func TestInvalidConfigNonEmptyAndFail(t *testing.T) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.FailNonEmpty = true + params.Fail = true + + err := exec.Exec(t.Context(), nil, params) + if err == nil || err.Error() != "specify --fail-non-empty or --fail but not both" { + t.Fatalf("Expected error '%s' but got '%s'", "specify --fail-non-empty or --fail but not both", err.Error()) + } +} + +func TestInvalidConfigNonEmptyAndFailDefined(t *testing.T) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.FailNonEmpty = true + params.FailDefined = true + + err := exec.Exec(t.Context(), nil, params) + if err == nil || err.Error() != "specify --fail-non-empty or --fail-defined but not both" { + t.Fatalf("Expected error '%s' but got '%s'", "specify --fail-non-empty or --fail-defined but not both", err.Error()) + } +} + +func TestFailFlagCases(t *testing.T) { + tests := []struct { + description string + files map[string]string + decision string + expectError bool + expected []byte + fail bool + failDefined bool + failNonEmpty bool + }{ + { + description: "--fail-defined with undefined result", + 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 + }`, + }, + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "error": { + "code": "opa_undefined_error", + "message": "/system/main decision was undefined" + } + }]}`), + failDefined: true, + }, + { + description: "--fail-defined with populated result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + main contains "hello"`, + }, + decision: "", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": ["hello"] + }]}`), + failDefined: true, + }, + { + description: "--fail-defined with true boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.defined.flag + import rego.v1 + + some_function if { + input.foo == 7 + } + + default fail_test := false + fail_test if { + some_function + }`, + }, + decision: "fail/defined/flag/fail_test", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": true + }]}`), + failDefined: true, + }, + { + description: "--fail-defined with false boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.defined.flag + import rego.v1 + + default fail_test := false + fail_test if { + false + }`, + }, + decision: "fail/defined/flag/fail_test", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": false + }]}`), + failDefined: true, + }, + { + description: "--fail with undefined result", + 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 + }`, + }, + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "error": { + "code": "opa_undefined_error", + "message": "/system/main decision was undefined" + } + }]}`), + fail: true, + }, + { + description: "--fail with populated result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + main contains "hello"`, + }, + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": ["hello"] + }]}`), + fail: true, + }, + { + description: "--fail with true boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.defined.flag + import rego.v1 + + some_function if { + input.foo == 7 + } + + default fail_test := false + fail_test if { + some_function + }`, + }, + decision: "fail/defined/flag/fail_test", + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": true + }]}`), + fail: true, + }, + { + description: "--fail with false boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.defined.flag + import rego.v1 + + default fail_test := false + fail_test if { + false + }`, + }, + decision: "fail/defined/flag/fail_test", + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": false + }]}`), + fail: true, + }, + { + description: "--fail-non-empty with undefined result", + 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 + }`, + }, + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "error": { + "code": "opa_undefined_error", + "message": "/system/main decision was undefined" + } + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty with populated result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + import rego.v1 + + main contains "hello"`, + }, + decision: "", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": ["hello"] + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty with true boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.non.empty.flag + import rego.v1 + + some_function if { + input.foo == 7 + } + + default fail_test := false + fail_test if { + some_function + }`, + }, + decision: "fail/non/empty/flag/fail_test", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": true + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty with false boolean result", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.non.empty.flag + import rego.v1 + + default fail_test := false + fail_test if { + false + }`, + }, + decision: "fail/non/empty/flag/fail_test", + expectError: true, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": false + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty with an empty array", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.non.empty.flag + import rego.v1 + + default fail_test := ["something", "hello"] + fail_test := [] if { + input.foo == 7 + }`, + }, + decision: "fail/non/empty/flag/fail_test", + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": [] + }]}`), + failNonEmpty: true, + }, + { + description: "--fail-non-empty for an empty set coming from a partial rule", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package fail.non.empty.flag + import rego.v1 + + fail_test contains message if { + false + message := "not gonna happen" + }`, + }, + decision: "fail/non/empty/flag/fail_test", + expectError: false, + expected: []byte(`{"result": [{ + "path": "/files/test.json", + "result": [] + }]}`), + failNonEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + test.WithTempFS(tt.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/") + if tt.decision != "" { + params.Decision = tt.decision + } + params.FailDefined = tt.failDefined + params.Fail = tt.fail + params.FailNonEmpty = tt.failNonEmpty + + err := runExec(params) + if err != nil && !tt.expectError { + t.Fatal("unexpected error in test") + } + if err == nil && tt.expectError { + t.Fatal("expected error, but none occurred in test") + } + + var output execOutput + if err := json.Unmarshal(bytes.ReplaceAll(buf.Bytes(), []byte(dir), nil), &output); err != nil { + t.Fatal(err) + } + + var expected execOutput + if err := json.Unmarshal(tt.expected, &expected); err != nil { + t.Fatal(err) + } + + resultSliceEquals(t, expected.Result, output.Result) + }) + }) + } +} + +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 + files map[string]string + stdIn bool + input string + expectError bool + expected string + }{ + { + description: "path passed in as arg should not raise error", + 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 + }`, + }, + expectError: false, + expected: "", + }, + { + description: "no paths passed in as args should raise error if --stdin-input flag not set", + files: map[string]string{ + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + }, + expectError: true, + expected: "requires at least 1 path arg, or the --stdin-input flag", + }, + { + description: "should not raise error if --stdin-input flag is set when no paths passed in as args", + files: map[string]string{ + "bundle/x.rego": `package system + import rego.v1 + + test_fun := x if { + x = false + x + } + + undefined_test if { + test_fun + }`, + }, + stdIn: true, + input: `{"foo": 7}`, + expectError: false, + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + test.WithTempFS(tt.files, func(dir string) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.BundlePaths = []string{dir + "/bundle/"} + if tt.stdIn { + params.StdIn = true + tempFile, err := os.CreateTemp(t.TempDir(), "test") + if err != nil { + t.Fatalf("unexpected error creating temp file: %q", err.Error()) + } + if _, err := tempFile.WriteString(tt.input); err != nil { + t.Fatalf("unexpeced error when writing to temp file: %q", err.Error()) + } + if _, err := tempFile.Seek(0, 0); err != nil { + t.Fatalf("unexpected error when rewinding temp file: %q", err.Error()) + } + oldStdin := os.Stdin + defer func() { + os.Stdin = oldStdin + os.Remove(tempFile.Name()) + }() + os.Stdin = tempFile + } else { + if _, ok := tt.files["files/test.json"]; ok { + params.Paths = append(params.Paths, dir+"/files/") + } + } + + err := runExec(params) + if err != nil && !tt.expectError { + t.Fatalf("unexpected error in test: %q", err.Error()) + } + if err == nil && tt.expectError { + t.Fatalf("expected error %q, but none occurred in test", tt.expected) + } + if err != nil && err.Error() != tt.expected { + t.Fatalf("expected error %q, but got %q", tt.expected, err.Error()) + } + }) + }) + } +} + +func TestExecMalformedRemoteBundle(t *testing.T) { + bundlePath := "/bundles/bundle.tar.gz" + // Note(philipc): We add the "raw bundles" flag so that we can stuff a + // malformed bundle into the mock bundle server. Otherwise, the server + // will just return 503 errors forever, because it won't be able to + // build the bundle on its end. + s := sdk_test.MustNewServer( + sdk_test.RawBundles(true), + sdk_test.MockBundle(bundlePath, map[string]string{ + "example.rego": ` + package example + + p := bits.sand(42, 43) # typo of bits.and + `, + })) + + defer s.Stop() + + // Wait for the bundle server to be ready before running exec + bundleURL := s.URL() + bundlePath + test.EventuallyOrFatal(t, 1*time.Second, func() bool { + resp, err := http.Get(bundleURL) + if resp != nil { + defer resp.Body.Close() + } + return err == nil && resp.StatusCode == 200 + }) + + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=" + bundlePath, + } + + // Note(philipc): We can set this timeout almost arbitrarily high or + // low-- the test will time out before it ever succeeds, due to the + // faulty bundle. + params.Timeout = time.Millisecond * 50 + + params.Paths = append(params.Paths, t.TempDir()) + err := runExec(params) + if err == nil { + t.Fatalf("Expected error, got nil instead.") + } + + exp := "runtime error: Bundle name: test, Code: bundle_error, HTTPCode: -1, Message: 1 error occurred: /example.rego:4: rego_type_error: undefined function bits.sand" + if !strings.HasPrefix(err.Error(), exp) { + t.Fatalf("Expected error: %s, got %s", exp, err.Error()) + } +} + +func TestExecStopsPlugins(t *testing.T) { + fact := &factory{} + + sdk.SetDefaultOptions(sdk.Options{ + Plugins: map[string]plugins.Factory{ + "test_plugin": fact, + }, + }) + + s := sdk_test.MustNewServer(sdk_test.MockBundle("/bundles/bundle.tar.gz", map[string]string{ + "test.rego": ` + package system + main contains "hello" + `, + })) + defer s.Stop() + + cfg := filepath.Join(t.TempDir(), "opa.yaml") + if err := os.WriteFile(cfg, []byte(` +plugins: + test_plugin: {} +`), 0x777); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + params := exec.NewParams(&buf) + params.ConfigFile = cfg + _ = params.OutputFormat.Set("json") + params.ConfigOverrides = []string{ + "services.test.url=" + s.URL(), + "bundles.test.resource=/bundles/bundle.tar.gz", + } + + dir := t.TempDir() + params.Paths = append(params.Paths, dir) + + err := runExec(params) + if err != nil { + t.Fatal(err) + } + + if !fact.stopped { + t.Errorf("expected plugin to be stopped") + } +} + +type factory struct { + stopped bool + m *plugins.Manager +} + +func (f *factory) New(m *plugins.Manager, _ any) plugins.Plugin { + f.m = m + return f +} + +func (*factory) Validate(*plugins.Manager, []byte) (any, error) { + return nil, nil +} + +func (f *factory) Start(context.Context) error { + f.m.UpdatePluginStatus("test_plugin", &plugins.Status{State: plugins.StateOK}) + return nil +} + +func (f *factory) Stop(context.Context) { + f.stopped = true +} + +func (*factory) Reconfigure(context.Context, any) { +} diff --git a/cmd/exec_test.go b/cmd/exec_test.go index b36198b7ea..53fa30e901 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -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 diff --git a/cmd/inspect_jsonv2_test.go b/cmd/inspect_jsonv2_test.go new file mode 100644 index 0000000000..faf82aace7 --- /dev/null +++ b/cmd/inspect_jsonv2_test.go @@ -0,0 +1,2299 @@ +//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. + +package cmd + +import ( + "bytes" + "fmt" + "maps" + "os" + "path/filepath" + "reflect" + "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" + + "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"]}`}, + {"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`}, + {"/example/foo.rego", `package foo`}, + } + + buf := archive.MustWriteTarGz(files) + + rootDir := t.TempDir() + bundleFile := filepath.Join(rootDir, "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + err = params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + res := `{ + "capabilities": {"features": ["rego_v1"]}, + "manifest": {"revision": "rev", "roots": ["foo", "bar", "fuz", "baz", "a", "x"]}, + "signatures_config": {}, + "namespaces": {"data": ["/data.json"], "data.foo": ["/example/foo.rego"]} + }` + + exp := util.MustUnmarshalJSON([]byte(res)) + result := util.MustUnmarshalJSON(out.Bytes()) + if !reflect.DeepEqual(exp, result) { + t.Fatalf("expected inspect output to be:\n\n%v\n\ngot:\n\n%v", exp, result) + } +} + +func TestDoInspectPretty(t *testing.T) { + + root := fmt.Sprintf("metadata/%v/features", strings.Repeat("foobar", 20)) + + manifest := fmt.Sprintf(`{"revision": "%s", +"roots": ["foo", "bar", "fuz", "http", "a", "x", "%s"], +"metadata": {"hello": "%s"}, +"wasm": [{"entrypoint": "http/example/authz", "module": "/policy.wasm"}, {"entrypoint": "http/example/foo/allow", "module": "/example/policy.wasm"}]}`, strings.Repeat("foobar", 10), root, strings.Repeat("world", 100)) + + files := [][2]string{ + {"/.manifest", manifest}, + {"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`}, + {"/http/example/authz/foo.rego", `package http.example.authz`}, + {"/http/example/authz/data.json", `{"faz": "baz"}`}, + {"/example/foo.rego", `package foo`}, + {"/a/b/y/foo.rego", `package a.b.y`}, + {"/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego", `package a.b.y`}, + {"/example/policy.wasm", `modules-compiled-as-wasm-binary`}, + {"/http/example/policy.wasm", `modules-compiled-as-wasm-binary`}, + {"/policy.wasm", `modules-compiled-as-wasm-binary`}, + } + + buf := archive.MustWriteTarGz(files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + err = doInspect(newInspectCommandParams(), bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + output := strings.TrimSpace(out.String()) + expected := strings.TrimSpace(` + MANIFEST: +┌──────────┬────────────────────────────────────────────────────┐ +│ FIELD │ VALUE │ +├──────────┼────────────────────────────────────────────────────┤ +│ Revision │ foobarfoobarfoobarfoobarfoobarfoobarfoobarfooba... │ +│ Roots │ a │ +│ │ bar │ +│ │ foo │ +│ │ fuz │ +│ │ http │ +│ │ metadata/...oobarfoobarfoobarfoobarfoobar/features │ +│ │ x │ +│ Metadata │ {"hello":"worldworldworldworldworldworldworldwo... │ +└──────────┴────────────────────────────────────────────────────┘ +NAMESPACES: +┌─────────────────────────────┬────────────────────────────────────────────────────┐ +│ NAMESPACE │ FILE │ +├─────────────────────────────┼────────────────────────────────────────────────────┤ +│ data │ /data.json │ +│ data.a.b.y │ /a/b/y/foo.rego │ +│ │ /a/...xxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego │ +│ data.foo │ /example/foo.rego │ +│ data.http.example.authz │ /http/example/authz/foo.rego │ +│ │ /http/example/authz/data.json │ +│ │ /policy.wasm │ +│ data.http.example.foo.allow │ /example/policy.wasm │ +└─────────────────────────────┴────────────────────────────────────────────────────┘ +`) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%v\n\nGot:\n\n%v", expected, output) + } +} + +func TestDoInspectPrettyManifestOnlySingleRoot(t *testing.T) { + root := fmt.Sprintf("metadata/%v/features", strings.Repeat("foobar", 6)) + + manifest := fmt.Sprintf(`{"roots": ["%s"], +"metadata": {"hello": "world"}}`, root) + + files := [][2]string{ + {"/.manifest", manifest}, + } + + buf := archive.MustWriteTarGz(files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + err = doInspect(newInspectCommandParams(), bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + output := strings.TrimSpace(out.String()) + expected := strings.TrimSpace(` +MANIFEST: +┌──────────┬────────────────────────────────────────────────────┐ +│ FIELD │ VALUE │ +├──────────┼────────────────────────────────────────────────────┤ +│ Roots │ metadata/...oobarfoobarfoobarfoobarfoobar/features │ +│ Metadata │ {"hello":"world"} │ +└──────────┴────────────────────────────────────────────────────┘ +`) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%v\n\nGot:\n\n%v", expected, output) + } +} + +func TestInspectMultiBundleError(t *testing.T) { + params := newInspectCommandParams() + err := validateInspectParams(¶ms, []string{"foo", "bar"}) + if err == nil { + t.Fatal("Expected error but got nil") + } + + exp := "specify exactly one OPA bundle or path" + if err.Error() != exp { + t.Fatalf("Expected error %v but got %v", exp, err.Error()) + } +} + +func TestDoInspectWithAnnotations(t *testing.T) { + + files := map[string]string{ + "x.rego": `# METADATA +# title: pkg-title +# description: pkg-descr +# organizations: +# - pkg-org +# related_resources: +# - https://pkg +# - ref: https://pkg +# description: rr-pkg-note +# authors: +# - pkg-author +# schemas: +# - input: {"type": "boolean"} +# custom: +# pkg: pkg-custom +package test + +# METADATA +# scope: document +# title: doc-title +# description: doc-descr +# organizations: +# - doc-org +# related_resources: +# - https://doc +# - ref: https://doc +# description: rr-doc-note +# authors: +# - doc-author +# schemas: +# - input: {"type": "integer"} +# custom: +# doc: doc-custom + +# METADATA +# title: rule-title +# description: rule-title +# organizations: +# - rule-org +# related_resources: +# - https://rule +# - ref: https://rule +# description: rr-rule-note +# authors: +# - rule-author +# schemas: +# - input: {"type": "string"} +# custom: +# rule: rule-custom +p = 1`, + } + + t.Run("pretty", func(t *testing.T) { + test.WithTempFS(files, func(rootDir string) { + ps := newInspectCommandParams() + ps.listAnnotations = true + var out bytes.Buffer + err := doInspect(ps, rootDir, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + idx := bytes.Index(bs, []byte(`ANNOTATIONS`)) // skip NAMESPACE box + output := strings.TrimSpace(string(bs[idx:])) + expected := strings.TrimSpace(fmt.Sprintf(` +ANNOTATIONS: +pkg-title +========= + +pkg-descr + +Package: test +Location: %[1]s/x.rego:16 +Scope: package + +Organizations: + pkg-org + +Authors: + pkg-author + +Schemas: + input: {"type":"boolean"} + +Related Resources: + https://pkg + https://pkg rr-pkg-note + +Custom: + pkg: "pkg-custom" + +doc-title +========= + +doc-descr + +Package: test +Rule: p +Location: %[1]s/x.rego:50 +Scope: document + +Organizations: + doc-org + +Authors: + doc-author + +Schemas: + input: {"type":"integer"} + +Related Resources: + https://doc + https://doc rr-doc-note + +Custom: + doc: "doc-custom" + +rule-title +========== + +rule-title + +Package: test +Rule: p +Location: %[1]s/x.rego:50 +Scope: rule + +Organizations: + rule-org + +Authors: + rule-author + +Schemas: + input: {"type":"string"} + +Related Resources: + https://rule + https://rule rr-rule-note + +Custom: + rule: "rule-custom"`, rootDir)) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%q\n\nGot:\n\n%q", expected, output) + } + }) + }) + + t.Run("json", func(t *testing.T) { + test.WithTempFS(files, func(rootDir string) { + ps := newInspectCommandParams() + ps.listAnnotations = true + err := ps.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + var out bytes.Buffer + err = doInspect(ps, rootDir, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + expected := strings.TrimSpace(fmt.Sprintf(`{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "%[1]s/x.rego" + ] + }, + "annotations": [ + { + "annotations": { + "authors": [ + { + "name": "pkg-author" + } + ], + "custom": { + "pkg": "pkg-custom" + }, + "description": "pkg-descr", + "organizations": [ + "pkg-org" + ], + "related_resources": [ + { + "ref": "https://pkg" + }, + { + "description": "rr-pkg-note", + "ref": "https://pkg" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "boolean" + } + } + ], + "scope": "package", + "title": "pkg-title" + }, + "location": { + "file": "%[1]s/x.rego", + "row": 16, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + } + ] + }, + { + "annotations": { + "authors": [ + { + "name": "doc-author" + } + ], + "custom": { + "doc": "doc-custom" + }, + "description": "doc-descr", + "organizations": [ + "doc-org" + ], + "related_resources": [ + { + "ref": "https://doc" + }, + { + "description": "rr-doc-note", + "ref": "https://doc" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "integer" + } + } + ], + "scope": "document", + "title": "doc-title" + }, + "location": { + "file": "%[1]s/x.rego", + "row": 50, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + }, + { + "annotations": { + "authors": [ + { + "name": "rule-author" + } + ], + "custom": { + "rule": "rule-custom" + }, + "description": "rule-title", + "organizations": [ + "rule-org" + ], + "related_resources": [ + { + "ref": "https://rule" + }, + { + "description": "rr-rule-note", + "ref": "https://rule" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "string" + } + } + ], + "scope": "rule", + "title": "rule-title" + }, + "location": { + "file": "%[1]s/x.rego", + "row": 50, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + } + ], + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, rootDir)) + + exp := util.MustUnmarshalJSON([]byte(expected)) + result := util.MustUnmarshalJSON(bs) + if !reflect.DeepEqual(exp, result) { + t.Fatalf("expected inspect output to be:\n\n%v\n\ngot:\n\n%v", exp, result) + } + }) + }) +} + +func TestDoInspectTarballWithAnnotations(t *testing.T) { + + files := [][2]string{ + {"x.rego", `# METADATA +# title: pkg-title +# description: pkg-descr +# organizations: +# - pkg-org +# related_resources: +# - https://pkg +# - ref: https://pkg +# description: rr-pkg-note +# authors: +# - pkg-author +# schemas: +# - input: {"type": "boolean"} +# custom: +# pkg: pkg-custom +package test + +# METADATA +# scope: document +# title: doc-title +# description: doc-descr +# organizations: +# - doc-org +# related_resources: +# - https://doc +# - ref: https://doc +# description: rr-doc-note +# authors: +# - doc-author +# schemas: +# - input: {"type": "integer"} +# custom: +# doc: doc-custom + +# METADATA +# title: rule-title +# description: rule-title +# organizations: +# - rule-org +# related_resources: +# - https://rule +# - ref: https://rule +# description: rr-rule-note +# authors: +# - rule-author +# schemas: +# - input: {"type": "string"} +# custom: +# rule: rule-custom +p = 1`}, + {".manifest", ` +{ + "revision": "", + "roots": [ + "" + ], + "wasm": [ + { + "entrypoint": "test/a", + "module": "/policy.wasm" + }, + { + "entrypoint": "test/b", + "module": "/policy.wasm", + "annotations": [ + { + "scope": "rule", + "title": "WASM RULE B", + "entrypoint": true + } + ] + } + ] +}`}, + {"policy.wasm", ""}, + } + + buf := archive.MustWriteTarGz(files) + + t.Run("pretty", func(t *testing.T) { + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + ps := newInspectCommandParams() + ps.listAnnotations = true + var out bytes.Buffer + + err = doInspect(ps, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + idx := bytes.Index(bs, []byte(`ANNOTATIONS`)) // skip NAMESPACE box + output := strings.TrimSpace(string(bs[idx:])) + expected := strings.TrimSpace(` +ANNOTATIONS: +pkg-title +========= + +pkg-descr + +Package: test +Location: /x.rego:16 +Scope: package + +Organizations: + pkg-org + +Authors: + pkg-author + +Schemas: + input: {"type":"boolean"} + +Related Resources: + https://pkg + https://pkg rr-pkg-note + +Custom: + pkg: "pkg-custom" + +WASM RULE B +=========== + +Location: /policy.wasm:0 +Scope: rule +Entrypoint: true + +doc-title +========= + +doc-descr + +Package: test +Rule: p +Location: /x.rego:50 +Scope: document + +Organizations: + doc-org + +Authors: + doc-author + +Schemas: + input: {"type":"integer"} + +Related Resources: + https://doc + https://doc rr-doc-note + +Custom: + doc: "doc-custom" + +rule-title +========== + +rule-title + +Package: test +Rule: p +Location: /x.rego:50 +Scope: rule + +Organizations: + rule-org + +Authors: + rule-author + +Schemas: + input: {"type":"string"} + +Related Resources: + https://rule + https://rule rr-rule-note + +Custom: + rule: "rule-custom"`) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%q\n\nGot:\n\n%q", expected, output) + } + + }) + + t.Run("json", func(t *testing.T) { + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + ps := newInspectCommandParams() + ps.listAnnotations = true + err = ps.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + var out bytes.Buffer + + err = doInspect(ps, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + expected := strings.TrimSpace(fmt.Sprintf(`{ + "manifest": { + "revision": "", + "roots": [ + "" + ], + "wasm": [ + { + "entrypoint": "test/a", + "module": "/policy.wasm" + }, + { + "entrypoint": "test/b", + "module": "/policy.wasm", + "annotations": [ + { + "entrypoint": true, + "scope": "rule", + "title": "WASM RULE B" + } + ] + } + ] + }, + "signatures_config": {}, + "wasm_modules": [ + { + "entrypoints": [ + "data.test.a", + "data.test.b" + ], + "path": "/policy.wasm", + "url": "%[1]s/policy.wasm" + } + ], + "namespaces": { + "data.test": [ + "/x.rego" + ], + "data.test.a": [ + "/policy.wasm" + ], + "data.test.b": [ + "/policy.wasm" + ] + }, + "annotations": [ + { + "annotations": { + "authors": [ + { + "name": "pkg-author" + } + ], + "custom": { + "pkg": "pkg-custom" + }, + "description": "pkg-descr", + "organizations": [ + "pkg-org" + ], + "related_resources": [ + { + "ref": "https://pkg" + }, + { + "description": "rr-pkg-note", + "ref": "https://pkg" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "boolean" + } + } + ], + "scope": "package", + "title": "pkg-title" + }, + "location": { + "file": "/x.rego", + "row": 16, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + } + ] + }, + { + "annotations": { + "entrypoint": true, + "scope": "rule", + "title": "WASM RULE B" + }, + "location": { + "file": "/policy.wasm", + "row": 0, + "col": 0 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "b" + } + ] + }, + { + "annotations": { + "authors": [ + { + "name": "doc-author" + } + ], + "custom": { + "doc": "doc-custom" + }, + "description": "doc-descr", + "organizations": [ + "doc-org" + ], + "related_resources": [ + { + "ref": "https://doc" + }, + { + "description": "rr-doc-note", + "ref": "https://doc" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "integer" + } + } + ], + "scope": "document", + "title": "doc-title" + }, + "location": { + "file": "/x.rego", + "row": 50, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + }, + { + "annotations": { + "authors": [ + { + "name": "rule-author" + } + ], + "custom": { + "rule": "rule-custom" + }, + "description": "rule-title", + "organizations": [ + "rule-org" + ], + "related_resources": [ + { + "ref": "https://rule" + }, + { + "description": "rr-rule-note", + "ref": "https://rule" + } + ], + "schemas": [ + { + "path": [ + { + "type": "var", + "value": "input" + } + ], + "definition": { + "type": "string" + } + } + ], + "scope": "rule", + "title": "rule-title" + }, + "location": { + "file": "/x.rego", + "row": 50, + "col": 1 + }, + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + }, + { + "type": "string", + "value": "p" + } + ] + } + ], + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, bundleFile)) + exp := util.MustUnmarshalJSON([]byte(expected)) + + result := util.MustUnmarshalJSON(out.Bytes()) + if !reflect.DeepEqual(exp, result) { + t.Fatalf("expected inspect output to be:\n\n%v\n\ngot:\n\n%v", exp, result) + } + }) +} + +func TestDoInspect_V0Compatible(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + module string + expErrs []string + }{ + { + note: "v0, keywords not used", + v0Compatible: true, + module: `package test +p[v] { + v := input.x +}`, + }, + { + note: "v0, no keywords imported, but used", + v0Compatible: true, + module: `package test +p contains v if { + v := input.x +}`, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0, keywords imported", + module: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v0, rego.v1 imported", + module: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + { + note: "v1, keywords not used", + module: `package test +p[v] { + v := input.x +}`, + 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 keywords imported", + module: `package test +p contains v if { + v := input.x +}`, + }, + { + note: "v1, keywords imported", + module: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v1, rego.v1 imported", + module: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + buf := archive.MustWriteTarGz([][2]string{{"/policy.rego", tc.module}}) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + params.v0Compatible = tc.v0Compatible + err = params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, bundleFile, &out) + 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%v\n\nbut got:\n\n%v", expErr, err.Error()) + } + } + } else if err != nil { + t.Fatalf("Unexpected error %v", err) + } + }) + } +} + +func TestDoInspectWithBundleRegoVersion(t *testing.T) { + tests := []struct { + note string + bundleRegoVersion int + files map[string]string + expErrs []string + }{ + { + note: "v0.x bundle, keywords not used", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p[v] { + v := input.x +}`, + }, + }, + { + note: "v0.x bundle, no keywords imported, but used", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +p contains v if { + v := input.x +}`, + }, + expErrs: []string{ + "rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v0.x bundle, keywords imported", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v0.x bundle, rego.v1 imported", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[1] { + v := input.x +}`, + "policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override (glob)", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/bar/*.rego": 1 + } +}`, + "foo/policy1.rego": `package test +p[1] { + v := input.x +}`, + "bar/policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override, incompatible", + bundleRegoVersion: 0, + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +p[1] { + v := input.x +}`, + "policy2.rego": `package test +p[2] { + v := input.x +}`, + }, + 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, keywords not used", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p[v] { + v := input.x +}`, + }, + 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 imported", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v1.0 bundle, keywords imported", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p[1] { + v := input.x +}`, + "policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/foo/*.rego": 0 + } +}`, + "foo/policy1.rego": `package test +p[1] { + v := input.x +}`, + "bar/policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override, incompatible", + bundleRegoVersion: 1, + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +p contains 1 if { + v := input.x +}`, + "policy2.rego": `package test +p contains 2 if { + v := input.x +}`, + }, + 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) + } + } + + var out bytes.Buffer + params := newInspectCommandParams() + params.v1Compatible = v1CompatibleFlag.used + err := params.outputFormat.Set(formats.Pretty) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, p, &out) + + if len(tc.expErrs) > 0 { + if err == nil { + t.Fatalf("Expected error but got output: %s", out.String()) + } + + for _, expErr := range tc.expErrs { + if !strings.Contains(err.Error(), expErr) { + t.Fatalf("Expected error:\n\n%v\n\nbut got:\n\n%v", expErr, err.Error()) + } + } + } else { + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + expOut := fmt.Sprintf(`MANIFEST: +┌──────────────┬───────┐ +│ FIELD │ VALUE │ +├──────────────┼───────┤ +│ Rego Version │ %d │ +└──────────────┴───────┘`, + tc.bundleRegoVersion) + if !strings.Contains(out.String(), expOut) { + t.Fatalf("Expected output to contain:\n\n%s\n\nbut got:\n\n%s", expOut, out.String()) + } + } + }) + }) + } + } + } +} + +func TestUnknownRefs(t *testing.T) { + tests := []struct { + note string + files [][2]string + expected string + }{ + { + note: "unknown built-in func call", + files: [][2]string{ + { + "/policy.rego", `package test +p if { + foo.bar(42) + contains("foo", "o") +}`, + }, + }, + // Note: unknown foo.bar() built-in doesn't appear in the output, but also didn't cause an error. + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "builtins": [ + { + "name": "contains", + "decl": { + "args": [ + { + "type": "string" + }, + { + "type": "string" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + } + } + ], + "features": [ + "rego_v1" + ] + } +}`, + }, + { + // Happy path + note: "known ref replaced inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +foo.bar(_) := false + +p if { + foo.bar(42) +} + +mock(_) := true + +test_p if { + p with data.test.foo.bar as mock +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "unknown ref replaced inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + data.foo.bar(42) +} + +mock(_) := true + +test_p if { + p with data.foo.bar as mock +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "unknown built-in (var) replaced inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo(42) +} + +mock(_) := true + +test_p if { + p with foo as mock +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "unknown built-in (ref) replaced inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo.bar(42) +} + +mock(_) := true + +test_p if { + p with foo.bar as mock +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "call replaced by unknown data ref inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo(42) +} + +foo(_) := false + +test_p if { + p with foo as data.bar +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "builtins": [ + { + "name": "eq", + "decl": { + "args": [ + { + "type": "any" + }, + { + "type": "any" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + }, + "infix": "=" + } + ], + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "call replaced by unknown built-in (var) inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo(42) +} + +foo(_) := false + +test_p if { + # bar is unknown built-in + p with foo as bar +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "features": [ + "rego_v1" + ] + } +}`, + }, + { + note: "call replaced by unknown built-in (ref) inside 'with' stmt", + files: [][2]string{ + {"/policy.rego", `package test + +p if { + foo(42) +} + +foo(_) := false + +test_p if { + # bar.baz is unknown built-in + p with foo as bar.baz +}`}, + }, + expected: `{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "builtins": [ + { + "name": "eq", + "decl": { + "args": [ + { + "type": "any" + }, + { + "type": "any" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + }, + "infix": "=" + } + ], + "features": [ + "rego_v1" + ] + } +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + buf := archive.MustWriteTarGz(tc.files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + err = params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + output := strings.TrimSpace(string(bs)) + if output != tc.expected { + t.Fatalf("Unexpected output. Expected:\n\n%s\n\nGot:\n\n%s", tc.expected, output) + } + }) + } +} + +func TestCallToUnknownRegoFunction(t *testing.T) { + files := [][2]string{ + {"/policy.rego", `package test +import data.x.y + +p if { + y(1) == true +} + `}, + } + + buf := archive.MustWriteTarGz(files) + bundleFile := filepath.Join(t.TempDir(), "bundle.tar.gz") + + bf, err := os.Create(bundleFile) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + _, err = bf.Write(buf.Bytes()) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var out bytes.Buffer + params := newInspectCommandParams() + err = params.outputFormat.Set(formats.JSON) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + err = doInspect(params, bundleFile, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + bs := out.Bytes() + output := strings.TrimSpace(string(bs)) + // Note: unknown data.x.y() function doesn't appear in the output, but also didn't cause an error. + expected := strings.TrimSpace(`{ + "manifest": { + "revision": "", + "roots": [ + "" + ] + }, + "signatures_config": {}, + "namespaces": { + "data.test": [ + "/policy.rego" + ] + }, + "capabilities": { + "builtins": [ + { + "name": "eq", + "decl": { + "args": [ + { + "type": "any" + }, + { + "type": "any" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + }, + "infix": "=" + }, + { + "name": "equal", + "decl": { + "args": [ + { + "type": "any" + }, + { + "type": "any" + } + ], + "result": { + "type": "boolean" + }, + "type": "function" + }, + "infix": "==" + } + ], + "features": [ + "rego_v1" + ] + } +}`) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%s\n\nGot:\n\n%s", expected, output) + } +} + +func TestDoInspectSingleFileWithAnnotations(t *testing.T) { + files := map[string]string{ + "/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego": `# METADATA +# title: pkg-title +# description: pkg-descr +# organizations: +# - pkg-org +# related_resources: +# - https://pkg +# - ref: https://pkg +# description: rr-pkg-note +# authors: +# - pkg-author +# schemas: +# - input: {"type": "boolean"} +# custom: +# pkg: pkg-custom +package test + +# METADATA +# scope: document +# title: doc-title +# description: doc-descr +# organizations: +# - doc-org +# related_resources: +# - https://doc +# - ref: https://doc +# description: rr-doc-note +# authors: +# - doc-author +# schemas: +# - input: {"type": "integer"} +# custom: +# doc: doc-custom + +# METADATA +# title: rule-title +# description: rule-title +# organizations: +# - rule-org +# related_resources: +# - https://rule +# - ref: https://rule +# description: rr-rule-note +# authors: +# - rule-author +# schemas: +# - input: {"type": "string"} +# custom: +# rule: rule-custom +p = 1`, + } + + test.WithTempFS(files, func(rootDir string) { + fileName := rootDir + "/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego" + ps := newInspectCommandParams() + ps.listAnnotations = true + var out bytes.Buffer + err := doInspect(ps, fileName, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + shortFileName := truncateFileName(fileName) + output := strings.TrimSpace(out.String()) + expected := strings.TrimSpace(fmt.Sprintf(` +NAMESPACES: +┌───────────┬────────────────────────────────────────────────────┐ +│ NAMESPACE │ FILE │ +├───────────┼────────────────────────────────────────────────────┤ +│ data.test │ %[1]s │ +└───────────┴────────────────────────────────────────────────────┘ +ANNOTATIONS: +pkg-title +========= + +pkg-descr + +Package: test +Location: %[2]s:16 +Scope: package + +Organizations: + pkg-org + +Authors: + pkg-author + +Schemas: + input: {"type":"boolean"} + +Related Resources: + https://pkg + https://pkg rr-pkg-note + +Custom: + pkg: "pkg-custom" + +doc-title +========= + +doc-descr + +Package: test +Rule: p +Location: %[2]s:50 +Scope: document + +Organizations: + doc-org + +Authors: + doc-author + +Schemas: + input: {"type":"integer"} + +Related Resources: + https://doc + https://doc rr-doc-note + +Custom: + doc: "doc-custom" + +rule-title +========== + +rule-title + +Package: test +Rule: p +Location: %[2]s:50 +Scope: rule + +Organizations: + rule-org + +Authors: + rule-author + +Schemas: + input: {"type":"string"} + +Related Resources: + https://rule + https://rule rr-rule-note + +Custom: + rule: "rule-custom"`, shortFileName, fileName)) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n\n%q\n\nGot:\n\n%q", expected, output) + } + }) +} + +func TestDoInspectSingleFile(t *testing.T) { + files := map[string]string{ + "/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego": `# METADATA +# title: pkg-title +# description: pkg-descr +# organizations: +# - pkg-org +# related_resources: +# - https://pkg +# - ref: https://pkg +# description: rr-pkg-note +# authors: +# - pkg-author +# schemas: +# - input: {"type": "boolean"} +# custom: +# pkg: pkg-custom +package test + +# METADATA +# scope: document +# title: doc-title +# description: doc-descr +# organizations: +# - doc-org +# related_resources: +# - https://doc +# - ref: https://doc +# description: rr-doc-note +# authors: +# - doc-author +# schemas: +# - input: {"type": "integer"} +# custom: +# doc: doc-custom + +# METADATA +# title: rule-title +# description: rule-title +# organizations: +# - rule-org +# related_resources: +# - https://rule +# - ref: https://rule +# description: rr-rule-note +# authors: +# - rule-author +# schemas: +# - input: {"type": "string"} +# custom: +# rule: rule-custom +p = 1`, + } + + test.WithTempFS(files, func(rootDir string) { + fileName := rootDir + "/a/xxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyy/foo.rego" + ps := newInspectCommandParams() + var out bytes.Buffer + err := doInspect(ps, fileName, &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + shortFileName := truncateFileName(fileName) + output := strings.TrimSpace(out.String()) + expected := strings.TrimSpace(fmt.Sprintf(` +NAMESPACES: +┌───────────┬────────────────────────────────────────────────────┐ +│ NAMESPACE │ FILE │ +├───────────┼────────────────────────────────────────────────────┤ +│ data.test │ %v │ +└───────────┴────────────────────────────────────────────────────┘ +`, shortFileName)) + + if output != expected { + t.Fatalf("Unexpected output. Expected:\n%v\nGot:\n%v", expected, output) + } + }) +} diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go index 35d355bb8f..df46960ee8 100644 --- a/cmd/inspect_test.go +++ b/cmd/inspect_test.go @@ -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"]}`}, diff --git a/cmd/oracle_jsonv2_test.go b/cmd/oracle_jsonv2_test.go new file mode 100644 index 0000000000..0a757492e6 --- /dev/null +++ b/cmd/oracle_jsonv2_test.go @@ -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 : 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) + } + }) + } + +} diff --git a/cmd/oracle_test.go b/cmd/oracle_test.go index 4f90a2a61b..f3e12ef651 100644 --- a/cmd/oracle_test.go +++ b/cmd/oracle_test.go @@ -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 { diff --git a/cmd/parse_jsonv2_test.go b/cmd/parse_jsonv2_test.go new file mode 100644 index 0000000000..ef71481a0d --- /dev/null +++ b/cmd/parse_jsonv2_test.go @@ -0,0 +1,1531 @@ +//go:build go1.27 + +package cmd + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/cmd/formats" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestParseExit0(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + + p = 1 + `, + } + errc, stdout, stderr, _ := testParse(t, files, &configuredParseParams) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := `module + package + ref + data + "x" + rule + head + ref + p + 1 + body + expr index=0 + true +` + + if got, want := string(stdout), expectedOutput; got != want { + t.Fatalf("Expected output\n%v\n, got\n%v", want, got) + } +} + +func TestParseExit1(t *testing.T) { + + files := map[string]string{ + "x.rego": `???`, + } + errc, _, stderr, _ := testParse(t, files, &configuredParseParams) + if errc != 1 { + t.Fatalf("Expected exit code 1, got %v", errc) + } + if len(stderr) == 0 { + t.Fatalf("Expected output in stderr") + } +} + +func TestParseJSONOutput(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + + p = 1 + `, + } + errc, stdout, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := `{ + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "x" + } + ] + }, + "rules": [ + { + "head": { + "name": "p", + "ref": [ + { + "type": "var", + "value": "p" + } + ], + "value": { + "type": "number", + "value": 1 + } + }, + "body": [ + { + "index": 0, + "terms": { + "type": "boolean", + "value": true + } + } + ] + } + ] +} +` + + 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 + +p = 1 +`, + } + errc, stdout, stderr, tempDirPath := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + jsonInclude: "locations", + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := strings.ReplaceAll(`{ + "package": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 1, + "text": "cGFja2FnZQ==" + }, + "path": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "var", + "value": "data" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "string", + "value": "x" + } + ] + }, + "rules": [ + { + "head": { + "name": "p", + "ref": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "cA==" + }, + "type": "var", + "value": "p" + } + ], + "value": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 5, + "text": "MQ==" + }, + "type": "number", + "value": 1 + }, + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "cCA9IDE=" + } + }, + "body": [ + { + "index": 0, + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 5, + "text": "MQ==" + }, + "terms": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 5, + "text": "MQ==" + }, + "type": "boolean", + "value": true + } + } + ], + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "cCA9IDE=" + } + } + ] +} +`, "TEMPDIR", tempDirPath) + + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} + +func TestParseOutputWithNotImport(t *testing.T) { + cases := []struct { + format string + exp string + }{ + { + format: formats.JSON, + exp: `{ + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "test" + } + ] + }, + "imports": [ + { + "path": { + "type": "ref", + "value": [ + { + "type": "var", + "value": "future" + }, + { + "type": "string", + "value": "keywords" + }, + { + "type": "string", + "value": "not" + } + ] + } + } + ], + "rules": [ + { + "head": { + "name": "implicit_body", + "ref": [ + { + "type": "var", + "value": "implicit_body" + } + ], + "value": { + "type": "boolean", + "value": true + } + }, + "body": [ + { + "index": 0, + "terms": { + "type": "not", + "body": [ + { + "index": 0, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "equal" + } + ] + }, + { + "type": "call", + "value": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "plus" + } + ] + }, + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "x" + } + ] + }, + { + "type": "number", + "value": 2 + } + ] + }, + { + "type": "number", + "value": 42 + } + ] + } + ], + "explicit_body": false + } + } + ] + }, + { + "head": { + "name": "explicit_body", + "ref": [ + { + "type": "var", + "value": "explicit_body" + } + ], + "value": { + "type": "boolean", + "value": true + } + }, + "body": [ + { + "index": 0, + "terms": { + "type": "not", + "body": [ + { + "index": 0, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "assign" + } + ] + }, + { + "type": "var", + "value": "x" + }, + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "x" + } + ] + } + ] + }, + { + "index": 1, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "assign" + } + ] + }, + { + "type": "var", + "value": "y" + }, + { + "type": "number", + "value": 2 + } + ] + }, + { + "index": 2, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "assign" + } + ] + }, + { + "type": "var", + "value": "z" + }, + { + "type": "call", + "value": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "plus" + } + ] + }, + { + "type": "var", + "value": "x" + }, + { + "type": "var", + "value": "y" + } + ] + } + ] + }, + { + "index": 3, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "equal" + } + ] + }, + { + "type": "var", + "value": "z" + }, + { + "type": "number", + "value": 42 + } + ] + } + ], + "explicit_body": true + } + } + ] + } + ] +} +`, + }, + { + format: formats.Pretty, + exp: `module + package + ref + data + "test" + import + ref + future + "keywords" + "not" + + rule + head + ref + implicit_body + true + body + expr index=0 + not + body + expr index=0 + ref + equal + call + ref + plus + ref + input + "x" + 2 + 42 + rule + head + ref + explicit_body + true + body + expr index=0 + not + body + expr index=0 + ref + assign + x + ref + input + "x" + expr index=1 + ref + assign + y + 2 + expr index=2 + ref + assign + z + call + ref + plus + x + y + expr index=3 + ref + equal + z + 42 +`, + }, + } + + files := map[string]string{ + "x.rego": `package test + + import future.keywords.not + + implicit_body if { + not input.x + 2 == 42 + } + + explicit_body if { + not { + x := input.x + y := 2 + z := x + y + z == 42 + } + } + `, + } + + for _, tc := range cases { + t.Run(tc.format, func(t *testing.T) { + errc, stdout, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(tc.format), + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + if diff := cmp.Diff(tc.exp, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } + }) + } +} + +func TestParseRefsJSONOutput(t *testing.T) { + files := map[string]string{ + "x.rego": `package x + + a.b.c := true + `, + } + errc, stdout, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := `{ + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "x" + } + ] + }, + "rules": [ + { + "head": { + "ref": [ + { + "type": "var", + "value": "a" + }, + { + "type": "string", + "value": "b" + }, + { + "type": "string", + "value": "c" + } + ], + "value": { + "type": "boolean", + "value": true + }, + "assign": true + }, + "body": [ + { + "index": 0, + "terms": { + "type": "boolean", + "value": true + } + } + ] + } + ] +} +` + + 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 + +a.b.c := true +`, + } + errc, stdout, stderr, tempDirPath := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + jsonInclude: "locations", + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := strings.ReplaceAll(`{ + "package": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 1, + "text": "cGFja2FnZQ==" + }, + "path": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "var", + "value": "data" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "string", + "value": "x" + } + ] + }, + "rules": [ + { + "head": { + "ref": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "YQ==" + }, + "type": "var", + "value": "a" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 3, + "text": "Yg==" + }, + "type": "string", + "value": "b" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 5, + "text": "Yw==" + }, + "type": "string", + "value": "c" + } + ], + "value": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 10, + "text": "dHJ1ZQ==" + }, + "type": "boolean", + "value": true + }, + "assign": true, + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "YS5iLmMgOj0gdHJ1ZQ==" + } + }, + "body": [ + { + "index": 0, + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 10, + "text": "dHJ1ZQ==" + }, + "terms": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 10, + "text": "dHJ1ZQ==" + }, + "type": "boolean", + "value": true + } + } + ], + "location": { + "file": "TEMPDIR/x.rego", + "row": 3, + "col": 1, + "text": "YS5iLmMgOj0gdHJ1ZQ==" + } + } + ] +} +`, "TEMPDIR", tempDirPath) + + if diff := cmp.Diff(expectedOutput, string(stdout)); diff != "" { + t.Errorf("unexpected result (-want, +got):\n%s", diff) + } +} +func TestParseRulesBlockJSONOutputWithLocations(t *testing.T) { + + files := map[string]string{ + "x.rego": `package x +import rego.v1 + +default allow = false +allow = true if { + input.method == "GET" + input.path = ["getUser", user] + input.user == user +} +`, + } + errc, stdout, stderr, tempDirPath := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + jsonInclude: "locations", + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedOutput := strings.ReplaceAll(`{ + "package": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 1, + "text": "cGFja2FnZQ==" + }, + "path": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "var", + "value": "data" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 1, + "col": 9, + "text": "eA==" + }, + "type": "string", + "value": "x" + } + ] + }, + "imports": [ + { + "path": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 2, + "col": 8, + "text": "cmVnby52MQ==" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 2, + "col": 8, + "text": "cmVnbw==" + }, + "type": "var", + "value": "rego" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 2, + "col": 13, + "text": "djE=" + }, + "type": "string", + "value": "v1" + } + ] + }, + "location": { + "file": "TEMPDIR/x.rego", + "row": 2, + "col": 1, + "text": "aW1wb3J0" + } + } + ], + "rules": [ + { + "default": true, + "head": { + "name": "allow", + "ref": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 9, + "text": "YWxsb3c=" + }, + "type": "var", + "value": "allow" + } + ], + "value": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 17, + "text": "ZmFsc2U=" + }, + "type": "boolean", + "value": false + }, + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 9, + "text": "YWxsb3cgPSBmYWxzZQ==" + } + }, + "body": [ + { + "index": 0, + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 1, + "text": "ZGVmYXVsdA==" + }, + "terms": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 1, + "text": "ZGVmYXVsdA==" + }, + "type": "boolean", + "value": true + } + } + ], + "location": { + "file": "TEMPDIR/x.rego", + "row": 4, + "col": 1, + "text": "ZGVmYXVsdA==" + } + }, + { + "head": { + "name": "allow", + "ref": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 5, + "col": 1, + "text": "YWxsb3c=" + }, + "type": "var", + "value": "allow" + } + ], + "value": { + "location": { + "file": "TEMPDIR/x.rego", + "row": 5, + "col": 9, + "text": "dHJ1ZQ==" + }, + "type": "boolean", + "value": true + }, + "location": { + "file": "TEMPDIR/x.rego", + "row": 5, + "col": 1, + "text": "YWxsb3cgPSB0cnVl" + } + }, + "body": [ + { + "index": 0, + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 3, + "text": "aW5wdXQubWV0aG9kID09ICJHRVQi" + }, + "terms": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 16, + "text": "PT0=" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 16, + "text": "PT0=" + }, + "type": "var", + "value": "equal" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 3, + "text": "aW5wdXQubWV0aG9k" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 3, + "text": "aW5wdXQ=" + }, + "type": "var", + "value": "input" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 9, + "text": "bWV0aG9k" + }, + "type": "string", + "value": "method" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 6, + "col": 19, + "text": "IkdFVCI=" + }, + "type": "string", + "value": "GET" + } + ] + }, + { + "index": 1, + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 3, + "text": "aW5wdXQucGF0aCA9IFsiZ2V0VXNlciIsIHVzZXJd" + }, + "terms": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 14, + "text": "PQ==" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 14, + "text": "PQ==" + }, + "type": "var", + "value": "eq" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 3, + "text": "aW5wdXQucGF0aA==" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 3, + "text": "aW5wdXQ=" + }, + "type": "var", + "value": "input" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 9, + "text": "cGF0aA==" + }, + "type": "string", + "value": "path" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 16, + "text": "WyJnZXRVc2VyIiwgdXNlcl0=" + }, + "type": "array", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 17, + "text": "ImdldFVzZXIi" + }, + "type": "string", + "value": "getUser" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 7, + "col": 28, + "text": "dXNlcg==" + }, + "type": "var", + "value": "user" + } + ] + } + ] + }, + { + "index": 2, + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 3, + "text": "aW5wdXQudXNlciA9PSB1c2Vy" + }, + "terms": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 14, + "text": "PT0=" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 14, + "text": "PT0=" + }, + "type": "var", + "value": "equal" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 3, + "text": "aW5wdXQudXNlcg==" + }, + "type": "ref", + "value": [ + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 3, + "text": "aW5wdXQ=" + }, + "type": "var", + "value": "input" + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 9, + "text": "dXNlcg==" + }, + "type": "string", + "value": "user" + } + ] + }, + { + "location": { + "file": "TEMPDIR/x.rego", + "row": 8, + "col": 17, + "text": "dXNlcg==" + }, + "type": "var", + "value": "user" + } + ] + } + ], + "location": { + "file": "TEMPDIR/x.rego", + "row": 5, + "col": 1, + "text": "YWxsb3cgPSB0cnVlIGlmIHsKICBpbnB1dC5tZXRob2QgPT0gIkdFVCIKICBpbnB1dC5wYXRoID0gWyJnZXRVc2VyIiwgdXNlcl0KICBpbnB1dC51c2VyID09IHVzZXIKfQ==" + } + } + ] +} +`, "TEMPDIR", tempDirPath) + + 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 + + # comment + p = 1 + `, + } + errc, stdout, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.JSON, formats.Pretty), + jsonInclude: "comments", + }) + if errc != 0 { + t.Fatalf("Expected exit code 0, got %v", errc) + } + if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + + expectedCommentTextValue := "IGNvbW1lbnQ=" + + if !strings.Contains(string(stdout), expectedCommentTextValue) { + t.Fatalf("Comment text value %q missing in output: %s", expectedCommentTextValue, string(stdout)) + } +} + +func TestParse_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + module string + expErrs []string + }{ + { + note: "v0 module", + module: `package test +a[x] { + x := 42 +}`, + expErrs: []string{ + "`if` keyword is required before rule body", + "`contains` keyword is required for partial set rules", + }, + }, + { + note: "v1 module", + module: `package test +a contains x if { + x := 42 +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "test.rego": tc.module, + } + + _, _, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.Pretty, formats.JSON), + }) + + if len(tc.expErrs) > 0 { + errs := string(stderr) + for _, expErr := range tc.expErrs { + if !strings.Contains(errs, expErr) { + t.Fatalf("Expected error:\n\n%q\n\ngot:\n\n%s", expErr, errs) + } + } + } else if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + }) + } +} + +func TestParseCompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + policy string + expErrs []string + }{ + { + note: "v0, keywords not used", + v0Compatible: true, + policy: `package test +p[v] { + v := input.x +}`, + }, + { + note: "v0, keywords not imported", + v0Compatible: true, + policy: `package test +p contains v if { + v := input.x +}`, + expErrs: []string{ + "var cannot be used for rule name", + }, + }, + { + note: "v0, keywords imported", + v0Compatible: true, + policy: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v0, rego.v1 imported", + v0Compatible: true, + policy: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + + { + note: "v1, keywords not used", + v1Compatible: true, + policy: `package test +p[v] { + v := input.x +}`, + expErrs: []string{ + "`if` keyword is required before rule body", + "`contains` keyword is required for partial set rules", + }, + }, + { + note: "v1, keywords not imported", + v1Compatible: true, + policy: `package test +p contains v if { + v := input.x +}`, + }, + { + note: "v1, keywords imported", + v1Compatible: true, + policy: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v1, rego.v1 imported", + v1Compatible: true, + policy: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + + // v0 takes precedence over v1 + { + note: "v0+v1, keywords not used", + v0Compatible: true, + v1Compatible: true, + policy: `package test +p[v] { + v := input.x +}`, + }, + { + note: "v0+v1, keywords not imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +p contains v if { + v := input.x +}`, + expErrs: []string{ + "var cannot be used for rule name", + }, + }, + { + note: "v0+1, keywords imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +import future.keywords +p contains v if { + v := input.x +}`, + }, + { + note: "v0+v1, rego.v1 imported", + v0Compatible: true, + v1Compatible: true, + policy: `package test +import rego.v1 +p contains v if { + v := input.x +}`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + files := map[string]string{ + "policy.rego": tc.policy, + } + + _, _, stderr, _ := testParse(t, files, &parseParams{ + format: formats.Flag(formats.Pretty, formats.JSON), + v0Compatible: tc.v0Compatible, + v1Compatible: tc.v1Compatible, + }) + + if len(tc.expErrs) > 0 { + errs := string(stderr) + for _, expErr := range tc.expErrs { + if !strings.Contains(errs, expErr) { + t.Fatalf("Expected error:\n\n%q\n\ngot:\n\n%s", expErr, errs) + } + } + } else if len(stderr) > 0 { + t.Fatalf("Expected no stderr output, got:\n%s\n", string(stderr)) + } + }) + } +} + +// Runs parse and returns the exit code, stdout, and stderr contents +func testParse(t *testing.T, files map[string]string, params *parseParams) (int, []byte, []byte, string) { + t.Helper() + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + var errc int + + var tempDirUsed string + path := test.TempDir(t, files) + + 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 +} diff --git a/cmd/parse_test.go b/cmd/parse_test.go index a3da2e1484..a82052e06a 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -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 } diff --git a/cmd/run_jsonv2_test.go b/cmd/run_jsonv2_test.go new file mode 100644 index 0000000000..74625c7e9a --- /dev/null +++ b/cmd/run_jsonv2_test.go @@ -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) + } +} diff --git a/cmd/run_test.go b/cmd/run_test.go index b8355cb0f4..bc0b305772 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -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()) diff --git a/cmd/sign_jsonv2_test.go b/cmd/sign_jsonv2_test.go new file mode 100644 index 0000000000..840a19cc04 --- /dev/null +++ b/cmd/sign_jsonv2_test.go @@ -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) + } + }) + } +} diff --git a/cmd/sign_test.go b/cmd/sign_test.go index 38025350cb..2751cdd649 100644 --- a/cmd/sign_test.go +++ b/cmd/sign_test.go @@ -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}`, diff --git a/cmd/test_jsonv2_test.go b/cmd/test_jsonv2_test.go new file mode 100644 index 0000000000..03bd2eff42 --- /dev/null +++ b/cmd/test_jsonv2_test.go @@ -0,0 +1,3918 @@ +//go:build go1.27 + +package cmd + +import ( + "bytes" + "context" + "fmt" + "io" + "maps" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "syscall" + "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" + "github.com/open-policy-agent/opa/v1/ir" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/repl" + "github.com/open-policy-agent/opa/v1/storage/inmem" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/util/test" +) + +func TestFilterTraceDefault(t *testing.T) { + p := newTestCommandParams() + p.verbose = false + expected := `Enter data.testing.test_p = _ +| Enter data.testing.test_p +| | Enter data.testing.p +| | | Enter data.testing.q +| | | | Enter data.testing.r +| | | | | Fail x = data.x +| | | | Fail data.testing.r[x] +| | | Fail data.testing.q.foo +| | Fail data.testing.p with data.x as "bar" +| Fail data.testing.test_p = _ +` + verifyFilteredTrace(t, &p, expected) +} + +func TestFilterTraceVerbose(t *testing.T) { + p := newTestCommandParams() + p.verbose = true + expected := `Enter data.testing.test_p = _ +| Enter data.testing.test_p +| | Enter data.testing.p +| | | Note "test test" +| | | Enter data.testing.q +| | | | Note "got this far" +| | | | Enter data.testing.r +| | | | | Note "got this far2" +| | | | | Fail x = data.x +| | | | Fail data.testing.r[x] +| | | Fail data.testing.q.foo +| | Fail data.testing.p with data.x as "bar" +| Fail data.testing.test_p = _ +` + verifyFilteredTrace(t, &p, expected) +} + +func TestFilterTraceExplainFails(t *testing.T) { + p := newTestCommandParams() + err := p.explain.Set(explainModeFails) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + expected := `Enter data.testing.test_p = _ +| Enter data.testing.test_p +| | Enter data.testing.p +| | | Enter data.testing.q +| | | | Enter data.testing.r +| | | | | Fail x = data.x +| | | | Fail data.testing.r[x] +| | | Fail data.testing.q.foo +| | Fail data.testing.p with data.x as "bar" +| Fail data.testing.test_p = _ +` + verifyFilteredTrace(t, &p, expected) +} + +func TestFilterTraceExplainNotes(t *testing.T) { + p := newTestCommandParams() + err := p.explain.Set(explainModeNotes) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + expected := `Enter data.testing.test_p = _ +| Enter data.testing.test_p +| | Enter data.testing.p +| | | Note "test test" +| | | Enter data.testing.q +| | | | Note "got this far" +| | | | Enter data.testing.r +| | | | | Note "got this far2" +` + verifyFilteredTrace(t, &p, expected) +} + +func TestFilterTraceExplainFull(t *testing.T) { + p := newTestCommandParams() + err := p.explain.Set(explainModeFull) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + expected := `Enter data.testing.test_p = _ +| Eval data.testing.test_p = _ +| Index data.testing.test_p (matched 1 rule, early exit) +| Enter data.testing.test_p +| | Eval data.testing.p with data.x as "bar" +| | Index data.testing.p (matched 1 rule, early exit) +| | Enter data.testing.p +| | | Eval data.testing.x +| | | Index data.testing.x (matched 1 rule, early exit) +| | | Enter data.testing.x +| | | | Eval data.testing.y +| | | | Index data.testing.y (matched 1 rule, early exit) +| | | | Enter data.testing.y +| | | | | Eval true +| | | | | Exit data.testing.y early +| | | | Exit data.testing.x early +| | | Eval trace("test test") +| | | Note "test test" +| | | Eval data.testing.q.foo +| | | Index data.testing.q (matched 1 rule) +| | | Enter data.testing.q +| | | | Eval trace("got this far") +| | | | Note "got this far" +| | | | Eval data.testing.r[x] +| | | | Index data.testing.r (matched 1 rule) +| | | | Enter data.testing.r +| | | | | Eval trace("got this far2") +| | | | | Note "got this far2" +| | | | | Eval x = data.x +| | | | | Fail x = data.x +| | | | | Redo trace("got this far2") +| | | | Fail data.testing.r[x] +| | | | Redo trace("got this far") +| | | Fail data.testing.q.foo +| | | Redo trace("test test") +| | | Redo data.testing.x +| | | Redo data.testing.x +| | | | Redo data.testing.y +| | | | | Redo true +| | Fail data.testing.p with data.x as "bar" +| Fail data.testing.test_p = _ +` + verifyFilteredTrace(t, &p, expected) +} + +func TestThresholdRange(t *testing.T) { + thresholds := []float64{-1, 101} + for _, threshold := range thresholds { + if isThresholdValid(threshold) { + t.Fatalf("invalid threshold %2f shoul be reported", threshold) + } + } +} + +func verifyFilteredTrace(t *testing.T, params *testCommandParams, expected string) { + filtered := filterTrace(params, failTrace(t)) + + var buff bytes.Buffer + topdown.PrettyTrace(&buff, filtered) + actual := buff.String() + + if actual != expected { + t.Fatalf("Expected:\n\n%s\n\nGot:\n\n%s\n\n", expected, actual) + } +} + +func failTrace(t *testing.T) []*topdown.Event { + t.Helper() + mod := ` + package testing + + p if { + x # Always true + trace("test test") + q["foo"] + } + + x if { + y + } + + y if { + true + } + + q contains x if { + some x + trace("got this far") + r[x] + trace("got this far1") + } + + r contains x if { + trace("got this far2") + x := data.x + } + + test_p if { + p with data.x as "bar" + } + ` + + tracer := topdown.NewBufferTracer() + + _, err := rego.New( + rego.Module("test.rego", mod), + rego.Trace(true), + rego.QueryTracer(tracer), + rego.Query("data.testing.test_p"), + ).Eval(t.Context()) + + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + return *tracer +} + +func TestPrettyTraceWithLocalVars(t *testing.T) { + tests := []struct { + note string + includeVars bool + files map[string]string + expected string + }{ + { + note: "without vars", + includeVars: false, + files: map[string]string{ + "test.rego": `package test + +test_p if { + x := 1 + y := 2 + z := 3 + x == z + y +} +`, + }, + expected: `%.*%/test.rego:3: +data.test.test_p: FAIL (%.*%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%.*%) + + query:1 %.*% Enter data.test.test_p = _ + query:1 %.*% | Eval data.test.test_p = _ + query:1 %.*% | Index data.test.test_p (matched 1 rule, early exit) + %.*%/test.rego:3 | Enter data.test.test_p + %.*%/test.rego:4 | | Eval x = 1 + %.*%/test.rego:5 | | Eval y = 2 + %.*%/test.rego:6 | | Eval z = 3 + %.*%/test.rego:7 | | Eval plus(z, y, __local3__) + %.*%/test.rego:7 | | Eval x = __local3__ + %.*%/test.rego:7 | | Fail x = __local3__ + %.*%/test.rego:7 | | Redo plus(z, y, __local3__) + %.*%/test.rego:6 | | Redo z = 3 + %.*%/test.rego:5 | | Redo y = 2 + %.*%/test.rego:4 | | Redo x = 1 + query:1 %.*% | Fail data.test.test_p = _ + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "with vars", + includeVars: true, + files: map[string]string{ + "test.rego": `package test + +test_p if { + x := 1 + y := 2 + z := 3 + x == z + y +} +`, + }, + expected: `%.*%/test.rego:3: +data.test.test_p: FAIL (%.*%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%.*%) + + query:1 %.*% Enter data.test.test_p = _ {} + query:1 %.*% | Eval data.test.test_p = _ {} + query:1 %.*% | Index data.test.test_p (matched 1 rule, early exit) {} + %.*%/test.rego:3 | Enter data.test.test_p {} + %.*%/test.rego:4 | | Eval x = 1 {} + %.*%/test.rego:5 | | Eval y = 2 {} + %.*%/test.rego:6 | | Eval z = 3 {} + %.*%/test.rego:7 | | Eval plus(z, y, __local3__) {y: 2, z: 3} + %.*%/test.rego:7 | | Eval x = __local3__ {__local3__: 5, x: 1} + %.*%/test.rego:7 | | Fail x = __local3__ {__local3__: 5, x: 1} + %.*%/test.rego:7 | | Redo plus(z, y, __local3__) {__local3__: 5, y: 2, z: 3} + %.*%/test.rego:6 | | Redo z = 3 {z: 3} + %.*%/test.rego:5 | | Redo y = 2 {y: 2} + %.*%/test.rego:4 | | Redo x = 1 {x: 1} + query:1 %.*% | Fail data.test.test_p = _ {} + + %.*%/test.rego:7: + x == z + y + | | | + | | 2 + | z + y: 5 + | z: 3 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + buf := new(bytes.Buffer) + testParams := newTestCommandParams() + testParams.count = 1 + testParams.output = buf + testParams.errOutput = io.Discard + testParams.bundleMode = true + testParams.verbose = true + testParams.varValues = tc.includeVars + _ = testParams.explain.Set(explainModeFull) + + errorCode := opaTest([]string{root}, testParams) + if errorCode != 2 { + t.Fatalf("Unexpected error code: %d", errorCode) + } + + actual := buf.String() + if !stringsMatch(t, tc.expected, actual) { + t.Fatalf("Expected:\n\n%v\n\nGot:\n\n%v", tc.expected, actual) + } + }) + }) + } +} + +func TestFailVarValues(t *testing.T) { + tests := []struct { + note string + files map[string]string + expected string + }{ + { + note: "simple", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := 2 + z := 3 + x == y + z +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + x == y + z + | | | + | | 3 + | y + z: 5 + | y: 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "simple (not)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 5 + y := 2 + z := 3 + not x == y + z +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + not x == y + z + | | | + | | 3 + | y + z: 5 + | y: 2 + 5 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := [1, 2, 3] + z := 3 + x == y[2] + z +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + x == y[2] + z + | | | + | | 3 + | y[2] + z: 6 + | y[2]: 3 + | y: [1, 2, 3] + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array, var key", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := [1, 2, 3] + z := 3 + i := 2 + x == y[i] + z +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:8: + x == y[i] + z + | | | | + | | | 3 + | | 2 + | y[i] + z: 6 + | y[i]: 3 + | y: [1, 2, 3] + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array containing vars", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := 2 + z := 3 + [x, y, z] == [4, 5, 6] +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + [x, y, z] == [4, 5, 6] + | | | + | | 3 + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array containing refs", + files: map[string]string{ + "/test.rego": `package test + +a := 1 + +b := 2 + +test_foo if { + [a, data.test.b, data.c] == [4, 5, 6] +} +`, + "data.json": `{"c": 3}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:8: + [a, data.test.b, data.c] == [4, 5, 6] + | | | + | | 3 + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "array containing refs, undefined", + files: map[string]string{ + "/test.rego": `package test + +a := 1 + +b := data.b + +test_foo if { + [a, b, data.c] == [4, 5, 6] +} +`, + "data.json": `{"c": 3}`, + }, + // Note: each dynamic array element is broken out into a separate "co-expression" by the compiler. + // Since we failed on the 2nd element (b), we don't have value for the 3rd element (data.c). + expected: `%ROOT%/test.rego:7: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:8: + [a, b, data.c] == [4, 5, 6] + | | + | undefined + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "nested collections containing vars", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + x := 1 + y := 2 + z := 3 + [x, {y, {"a": z}}] == [4, {5, {"a": 6}}] +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + [x, {y, {"a": z}}] == [4, {5, {"a": 6}}] + | | | + | | 3 + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "single line expression containing tabs", + files: map[string]string{ + "/test.rego": `package test + + test_foo if { + x := 1 + y := 2 + z := 3 + x == y + z + } +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + x == y + z + | | | + | | 3 + | y + z: 5 + | y: 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "single line expression containing tabs #2", + files: map[string]string{ + "/test.rego": `package test + + test_foo if { + x := 1 + y := 2 + z := 3 + x == y + z + } +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + x == y + z + | | | + | | 3 + | y + z: 5 + | y: 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "multi-line expression containing tabs", + files: map[string]string{ + "/test.rego": `package test + + test_foo if { + x := 1 + y := 2 + z := 3 + obj := { + "foo_": 1, + "bar__": 42, + "baz": 3, + } + obj == { + "foo_": x, + "bar__": y, + "baz": z, + } + } +`, + }, + // We can't deal with tabs in a consistent manner when they occur on multiple lines + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:12: + obj == { + "foo_": x, + "bar__": y, + "baz": z, + } + + Where: + + obj: {"bar__": 42, "baz": 3, "foo_": 1} + x: 1 + y: 2 + z: 3 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "composite rule", + files: map[string]string{ + "/test.rego": `package test + +p contains v if { + some v in numbers.range(1, 3) +} + +test_p if { + p == {4, 5, 6} +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p == {4, 5, 6} + | + {1, 2, 3} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "composite rule with ref-head", + files: map[string]string{ + "/test.rego": `package test + +p.q contains v if { + some v in numbers.range(1, 3) +} + +test_p if { + p.q == {4, 5, 6} +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p.q == {4, 5, 6} + | + {1, 2, 3} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "composite rule with ref-head, partial ref", + files: map[string]string{ + "/test.rego": `package test + +p.q contains v if { + some v in numbers.range(1, 3) +} + +test_p if { + p == { + "q": {4, 5, 6} + } +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p == { + "q": {4, 5, 6} + } + | + {"q": {1, 2, 3}} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "composite rules with ref-head, composite value", + files: map[string]string{ + "/test.rego": `package test + +p.q contains v if { + some v in numbers.range(1, 3) +} + +p.r := "foo" + +test_p if { + p == { + "q": {4, 5, 6}, + "r": "bar" + } +}`, + }, + expected: `%ROOT%/test.rego:9: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:10: + p == { + "q": {4, 5, 6}, + "r": "bar" + } + | + {"q": {1, 2, 3}, "r": "foo"} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "refs in different compiled sub-expressions", + files: map[string]string{ + "/test.rego": `package test + +a := 1 +b := 2 +c := 3 + +test_p if { + # This expression is split into multiple final expressions by the compiler, each containing a rule ref + a == b + c +} +`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:9: + a == b + c + | | | + | | 3 + | b + c: 5 + | b: 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "rule not defined", + files: map[string]string{ + "/test.rego": `package test + +p if { + input.x == 1 +} + +test_p if { + p with input.x as 2 +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p with input.x as 2 + | + undefined + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "rule defined (not)", + files: map[string]string{ + "/test.rego": `package test + +p if { + input.x == 1 +} + +test_p if { + not p with input.x as 1 +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + not p with input.x as 1 + | + true + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "data ref", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + y := 1 + data.x == y +} +`, + "data.json": `{"x": 2}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + data.x == y + | | + | 1 + 2 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "data + virtual extent ref", + files: map[string]string{ + "/test.rego": `package test + +foo.x := 1 + +test_foo if { + y := {"x": 1, "y": 42} + foo == y +} +`, + "data.json": `{"test": {"foo": {"y": 2}}}`, + }, + expected: `%ROOT%/test.rego:5: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + foo == y + | | + | {"x": 1, "y": 42} + {"x": 1, "y": 2} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "in (array)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := ["a", "b", "c"] + x := "q" + x in l +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + x in l + | | + | ["a", "b", "c"] + "q" + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "in (set)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := {"a", "b", "c"} + x := "q" + x in l +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + x in l + | | + | {"a", "b", "c"} + "q" + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "comprehension (array)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := ["a", "b", "c"] + [x | x := l[_]] == ["d", "e", "f"] +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + [x | x := l[_]] == ["d", "e", "f"] + | + ["a", "b", "c"] + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "comprehension (set)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := ["a"] + {x | x := l[_]} == {"b"} +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + {x | x := l[_]} == {"b"} + | + {"a"} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "comprehension (object)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := ["a", "b", "c"] + {k: x | x := l[k]} == {3: "d", 4: "e", 5: "f"} +} +`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + {k: x | x := l[k]} == {3: "d", 4: "e", 5: "f"} + | + {0: "a", 1: "b", 2: "c"} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "every", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := [1, 2, 3] + every x in l { + x == 1 + } +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + x == 1 + | + 2 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "comprehension inside every", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := [1, 2, 3] + every x in l { + [v | v := x] == [42] + } +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + [v | v := x] == [42] + | + [1] + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "nested every", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := [[1, 2], [3, 4], [5, 6]] + every x in l { + every y in x { + y < 4 + } + } +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + y < 4 + | + 4 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "nested every with comprehension", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + l := [[1, 2], [3, 4], [5, 6]] + every x in l { + every y in x { + [v | v := y] == [42] + } + } +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + [v | v := y] == [42] + | + [1] + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "ref equality", + files: map[string]string{ + "/test.rego": `package test + +a := 1 +b := 2 + +test_foo if { + a == b +}`, + }, + expected: `%ROOT%/test.rego:6: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:7: + a == b + | | + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "ref equality (data)", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + data.a == data.b +}`, + "data.json": `{"a": 1, "b": 2}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:4: + data.a == data.b + | | + | 2 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "object ref selection from local var", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + tc := {"data": false, "expected": {"data": true}} + tc.data == tc.expected.data +}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + tc.data == tc.expected.data + | | + | true + tc.data: false + tc: {"data": false, "expected": {"data": true}} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "object ref selection alongside function call", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + tc := {"content": "big string value", "inp": {"foo": false}, "outp": {"foo": true}} + foo(tc.inp) == tc.outp +} + +foo(inp) := {"foo": inp.foo}`, + }, + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:5: + foo(tc.inp) == tc.outp + | | | + | | {"foo": true} + | tc.inp: {"foo": false} + | tc: {"content": "big string value", "inp": {"foo": false}, "outp"... + {"foo": false} + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "with, containing local vars", + files: map[string]string{ + "/test.rego": `package test + +p := input.x + +test_p if { + a := 1 + p == 2 with input.x as a +}`, + }, + expected: `%ROOT%/test.rego:5: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:7: + p == 2 with input.x as a + | | + | 1 + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "with, containing ref", + files: map[string]string{ + "/test.rego": `package test + +p := input.x + +testInput := {"x": 1} + +test_p if { + p == 2 with input as testInput +}`, + }, + expected: `%ROOT%/test.rego:7: +data.test.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_p: FAIL (%TIME%) + + %ROOT%/test.rego:8: + p == 2 with input as testInput + | | + | {"x": 1} + 1 + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "negated rule ref", + files: map[string]string{ + "/test.rego": `package test + +a if {true} + +test_foo if { + not a +}`, + "data.json": `{"a": true}`, + }, + expected: `%ROOT%/test.rego:5: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:6: + not a + | + true + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + { + note: "negated data ref", + files: map[string]string{ + "/test.rego": `package test + +test_foo if { + not data.a +}`, + "data.json": `{"a": true}`, + }, + // Because of the negated expr, the compiler will have opted out of rewriting the expression to + // capture the value of data.a in a local variable, and since data.a isn't in the local bindings + // or in the virtual cache, we don't know if it's undefined or unknown, and therefore can't report + // on a value. + expected: `%ROOT%/test.rego:3: +data.test.test_foo: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAILURES +-------------------------------------------------------------------------------- +data.test.test_foo: FAIL (%TIME%) + + %ROOT%/test.rego:4: + not data.a + +-------------------------------------------------------------------------------- +FAIL: 1/1 +`, + }, + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.files, func(root string) { + buf := new(bytes.Buffer) + testParams := newTestCommandParams() + testParams.count = 1 + testParams.output = buf + testParams.errOutput = io.Discard + testParams.bundleMode = true + testParams.varValues = true + _ = testParams.explain.Set(explainModeFull) + + exitCode := opaTest([]string{root}, testParams) + if exitCode != 2 { + t.Fatalf("Unexpected error code: %d", exitCode) + } + + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(tc.expected, "%ROOT%", root) + + if !stringsMatch(t, expected, actual) { + t.Fatalf("Expected output to be:\n\n%s\n\ngot:\n\n%s", expected, actual) + } + }) + }) + } +} + +// Assert that ignore flag is correctly used when the bundle flag is activated +func TestIgnoreFlag(t *testing.T) { + files := map[string]string{ + "/test.rego": `package test + +p := input.foo == 42 +test_p if { + p with input.foo as 42 +}`, + "/broken.rego": "package foo\n bar {", + } + + var exitCode int + test.WithTempFS(files, func(root string) { + testParams := newTestCommandParams() + testParams.count = 1 + testParams.errOutput = io.Discard + testParams.bundleMode = false + testParams.ignore = []string{"broken.rego"} + + exitCode = opaTest([]string{root}, testParams) + }) + + if exitCode > 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } +} + +// Assert that ignore flag is correctly used when the bundle flag is activated +func TestIgnoreFlagWithBundleFlag(t *testing.T) { + files := map[string]string{ + "/test.rego": `package test + +p := input.foo == 42 +test_p if { + p with input.foo as 42 +}`, + "/broken.rego": "package foo\n bar {", + } + + var exitCode int + test.WithTempFS(files, func(root string) { + testParams := newTestCommandParams() + testParams.count = 1 + testParams.errOutput = io.Discard + testParams.bundleMode = true + testParams.ignore = []string{"broken.rego"} + exitCode = opaTest([]string{root}, testParams) + }) + + if exitCode > 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } +} + +func testSchemasAnnotation(rego string) (int, []byte) { + + files := map[string]string{ + "test.rego": rego, + } + + var exitCode int + var buf bytes.Buffer + test.WithTempFS(files, func(path string) { + regoFilePath := filepath.Join(path, "test.rego") + + testParams := newTestCommandParams() + testParams.count = 1 + testParams.errOutput = &buf + + exitCode = opaTest([]string{regoFilePath}, testParams) + }) + return exitCode, buf.Bytes() +} + +// Assert that 'schemas' annotations with schema ref are ignored, but not inlined schemas +func TestSchemasAnnotation(t *testing.T) { + policyWithSchemaRef := ` +package test + +# METADATA +# schemas: +# - input: schema["input"] +p if { + rego.metadata.rule() # presence of rego.metadata.* calls must not trigger unwanted schema evaluation + input.foo == 42 # type mismatch with schema that should be ignored +} + +test_p if { + p with input.foo as 42 +}` + + exitCode, _ := testSchemasAnnotation(policyWithSchemaRef) + if exitCode > 0 { + t.Fatalf("unexpected error when schema ref is present") + } +} +func TestSchemasAnnotationInline(t *testing.T) { + policyWithInlinedSchema := ` +package test + +# METADATA +# schemas: +# - input.foo: {"type": "boolean"} +p if { + input.foo == 42 # type mismatch with schema that should NOT be ignored since it is an inlined schema format +} + +test_p if { + p with input.foo as 42 +}` + + exitCode, errOutput := testSchemasAnnotation(policyWithInlinedSchema) + // We expect an error here, as inlined schemas are always used for type checking + + if exitCode == 0 { + t.Fatalf("didn't get expected error when inlined schema is present") + } + + if !strings.Contains(string(errOutput), "rego_type_error: match error") { + t.Fatalf("didn't get expected %s error when inlined schema is present; got: %v", ast.TypeErr, string(errOutput)) + } +} + +func testSchemasAnnotationWithJSONFile(rego string, schema string) (int, []byte) { + + files := map[string]string{ + "test.rego": rego, + "demo_schema.json": schema, + } + + var exitCode int + var buf bytes.Buffer + test.WithTempFS(files, func(path string) { + regoFilePath := filepath.Join(path, "test.rego") + + testParams := newTestCommandParams() + testParams.count = 1 + testParams.schema.path = path + testParams.errOutput = &buf + + exitCode = opaTest([]string{regoFilePath}, testParams) + }) + + return exitCode, buf.Bytes() +} +func TestJSONSchemaSuccess(t *testing.T) { + + regoContents := `package test + +# METADATA +# schemas: +# - input: schema.demo_schema +p if { + input.foo == 42 +} + +test_p if { + p with input.foo as 42 +}` + + schema := `{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "schema", + "type": "object", + "description": "The root schema comprises the entire JSON document.", + "required": [ + "foo" + ], + "properties": { + "foo": { + "$id": "#/properties/foo", + "type": "number", + "description": "foo" + } + }, + "additionalProperties": false + }` + + errorCode, _ := testSchemasAnnotationWithJSONFile(regoContents, schema) + if errorCode != 0 { + t.Fatalf("unexpected error code: %d", errorCode) + } +} + +func TestJSONSchemaFail(t *testing.T) { + + regoContents := `package test + +# METADATA +# schemas: +# - input: schema.demo_schema +p if { + input.foo == 42 +} + +test_p if { + p with input.foo as 42 +}` + + schema := `{ +"$schema": "http://json-schema.org/draft-07/schema", +"$id": "schema", +"type": "object", +"description": "The root schema comprises the entire JSON document.", +"required": [ + "foo" +], +"properties": { + "foo": { + "$id": "#/properties/foo", + "type": "boolean", + "description": "foo" + } +}, +"additionalProperties": false +}` + + exitCode, errOutput := testSchemasAnnotationWithJSONFile(regoContents, schema) + if exitCode == 0 { + t.Fatalf("didn't get expected error when schema is present and is defining a different type than being used.") + } + + if !strings.Contains(string(errOutput), "rego_type_error: match error") { + t.Fatalf("didn't get expected %s error when schema is defining a different type than being used; got: %v", ast.TypeErr, string(errOutput)) + } +} + +func TestWatchMode(t *testing.T) { + + files := map[string]string{ + "/policy.rego": `package foo +p := 1`, + "/policy_test.rego": `package foo + +test_p if { + p == 1 +}`, + } + + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the test + f, _ := os.OpenFile(path.Join(root, "policy_test.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err := f.WriteString("package foo\n test_p if { p == 2 }") + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + expected = `%ROOT%/policy_test.rego:2: +data.foo.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAIL: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(actual, expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update policy so test passes + f, _ = os.OpenFile(path.Join(root, "policy.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString("package foo\n p := 2") + if err != nil { + t.Fatal(err) + } + + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = `PASS: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // add new policy and test + if err := os.WriteFile(path.Join(root, "policy2.rego"), []byte("package bar\n q := \"hello\""), 0644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path.Join(root, "policy2_test.rego"), []byte("package bar\n test_q if { q == \"hello\" }"), 0644); err != nil { + t.Fatal(err) + } + + expected = `PASS: 2/2 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) +} + +func TestWatchMode_v0(t *testing.T) { + + files := map[string]string{ + "/policy.rego": `package foo +p := 1`, + "/policy_test.rego": `package foo + +test_p { + p == 1 +}`, + } + + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + testParams.v0Compatible = true + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the test + f, _ := os.OpenFile(path.Join(root, "policy_test.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err := f.WriteString("package foo\n test_p { p == 2 }") + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + expected = `%ROOT%/policy_test.rego:2: +data.foo.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAIL: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(actual, expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update policy so test passes + f, _ = os.OpenFile(path.Join(root, "policy.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString("package foo\n p := 2") + if err != nil { + t.Fatal(err) + } + + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = `PASS: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // add new policy and test + if err := os.WriteFile(path.Join(root, "policy2.rego"), []byte("package bar\n q := \"hello\""), 0644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path.Join(root, "policy2_test.rego"), []byte("package bar\n test_q { q == \"hello\" }"), 0644); err != nil { + t.Fatal(err) + } + + expected = `PASS: 2/2 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) +} + +func TestWatchModeWithDataFile(t *testing.T) { + + files := map[string]string{ + "/policy.rego": `package foo + +test_p if { + data.y == 1 +}`, + "/data.json": `{"y": 1}`, + } + + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the data + f, _ := os.OpenFile(path.Join(root, "data.json"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err := f.WriteString(`{"y": 2}`) + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + expected = `%ROOT%/policy.rego:3: +data.foo.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAIL: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(actual, expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update policy so test passes + f, _ = os.OpenFile(path.Join(root, "policy.rego"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString("package foo\n test_p if { data.y == 2 }") + if err != nil { + t.Fatal(err) + } + + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = `PASS: 1/1 +******************************************************************************** +Watching for changes ... +` + + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) +} + +func TestWatchModeWhenDataFileRemoved(t *testing.T) { + files := map[string]string{ + "/policy.rego": `package foo + +test_p if { + data.y == 1 +}`, + "/data.json": `{"y": 1}`, + } + + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the data + f, _ := os.OpenFile(path.Join(root, "data.json"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err := f.WriteString(`{"y": 2}`) + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + r := regexp.MustCompile(`FAIL \(.*s\)`) + expected = `%ROOT%/policy.rego:3: +data.foo.test_p: FAIL (%TIME%) +-------------------------------------------------------------------------------- +FAIL: 1/1 +******************************************************************************** +Watching for changes ... +` + if !test.Eventually(t, 2*time.Second, func() bool { + actual := r.ReplaceAllString(buf.String(), "FAIL (%TIME%)") + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(actual, expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // update the data back to the original state, so the opa test passes + f, _ = os.OpenFile(path.Join(root, "data.json"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString(`{"y": 1}`) + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = `PASS: 1/1 +******************************************************************************** +Watching for changes ... +` + + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // remove the data file, check that test fails afterward + err = os.Remove(path.Join(root, "data.json")) + if err != nil { + t.Fatal(err) + } + + time.Sleep(500 * time.Millisecond) + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) +} + +func TestWatchModeBrokenFileRecovery(t *testing.T) { + + tests := []struct { + note string + fileName string + brokenFile string + fixedFile string + expectedOutput string + }{ + { + note: "empty data file (EOF read by watcher)", + fileName: "data.json", + fixedFile: `{"foo": "bar"}`, + expectedOutput: `1 error occurred during loading: %ROOT%/data.json: EOF +******************************************************************************** +Watching for changes ...`, + }, + { + note: "broken policy", + fileName: "broken_policy.rego", + brokenFile: "package foo\n bar {", + fixedFile: "package foo\n bar if {true}", + expectedOutput: `1 error occurred during loading: %ROOT%/broken_policy.rego:2: rego_parse_error: unexpected eof token + bar { + ^ +******************************************************************************** +Watching for changes ...`, + }, + } + + files := map[string]string{ + "/policy.rego": `package foo +p := 1`, + "/policy_test.rego": `package foo + +test_p if { + p == 1 +}`, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(files, func(root string) { + buf := test.BlockingWriter{} + + testParams := newTestCommandParams() + testParams.output = &buf + testParams.watch = true + testParams.count = 1 + testParams.errOutput = io.Discard + + done := make(chan struct{}) + go func() { + _ = opaTest([]string{root}, testParams) + <-done + }() + + expected := "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + // create broken (possibly empty) file + f, _ := os.OpenFile(path.Join(root, tc.fileName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if len(tc.brokenFile) > 0 { + _, err := f.WriteString(tc.brokenFile) + if err != nil { + t.Fatal(err) + } + } + err := f.Close() + if err != nil { + t.Fatal(err) + } + + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(tc.expectedOutput, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", tc.expectedOutput, buf.String()) + } + buf.Reset() + + // write data to empty file + f, _ = os.OpenFile(path.Join(root, tc.fileName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + _, err = f.WriteString(tc.fixedFile) + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + expected = "Watching for changes ..." + if !test.Eventually(t, 2*time.Second, func() bool { + expected := strings.ReplaceAll(expected, "%ROOT%", root) + return strings.Contains(buf.String(), expected) + }) { + t.Fatalf("expected:\n\n%q\n\ngot:\n\n%q", expected, buf.String()) + } + buf.Reset() + + testParams.stopChan <- syscall.SIGINT + done <- struct{}{} + }) + }) + } +} + +func testExitCode(rego string, skipExitZero bool) int { + files := map[string]string{ + "test.rego": rego, + } + + var exitCode int + test.WithTempFS(files, func(path string) { + regoFilePath := filepath.Join(path, "test.rego") + + testParams := newTestCommandParams() + testParams.count = 1 + testParams.skipExitZero = skipExitZero + testParams.errOutput = io.Discard + testParams.output = io.Discard + + exitCode = opaTest([]string{regoFilePath}, testParams) + }) + return exitCode +} + +func testExitCodeWithFailOnEmpty(rego string, failOnEmpty bool) int { + files := map[string]string{ + "test.rego": rego, + } + + var exitCode int + test.WithTempFS(files, func(path string) { + regoFilePath := filepath.Join(path, "test.rego") + + testParams := newTestCommandParams() + testParams.count = 1 + testParams.failOnEmpty = failOnEmpty + testParams.errOutput = io.Discard + testParams.output = io.Discard + + exitCode = opaTest([]string{regoFilePath}, testParams) + }) + return exitCode +} + +func TestExitCode(t *testing.T) { + testCases := map[string]struct { + Test string + ExitZeroOnSkipped bool + ExpectedExitCode int + }{ + "pass when no failed or skipped tests": { + Test: `package foo + + test_pass if { true } + `, + ExitZeroOnSkipped: false, + ExpectedExitCode: 0, + }, + "fail when failed tests": { + Test: `package foo + + test_pass if { true } + test_fail if { false } + `, + ExitZeroOnSkipped: false, + ExpectedExitCode: 2, + }, + "fail when skipped tests": { + Test: `package foo + + test_pass if { true } + todo_test_skip if { true } + `, + ExitZeroOnSkipped: false, + ExpectedExitCode: 2, + }, + "fail when failed tests and skipped tests": { + Test: `package foo + + test_pass if { true } + test_fail if { false } + todo_test_skip if { true } + `, + ExitZeroOnSkipped: false, + ExpectedExitCode: 2, + }, + "pass when skipped tests and exit zero on skipped": { + Test: `package foo + + test_pass if { true } + todo_test_skip if { true } + `, + ExitZeroOnSkipped: true, + ExpectedExitCode: 0, + }, + "fail when failed tests and exit zero on skipped": { + Test: `package foo + + test_pass if { true } + test_fail if { false } + `, + ExitZeroOnSkipped: true, + ExpectedExitCode: 2, + }, + "fail when failed tests, skipped tests and exit zero on skipped": { + Test: `package foo + + test_pass if { true } + test_fail if { false } + todo_test_skip if { true } + `, + ExitZeroOnSkipped: true, + ExpectedExitCode: 2, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + exitCode := testExitCode(tc.Test, tc.ExitZeroOnSkipped) + + if exitCode != tc.ExpectedExitCode { + t.Errorf("Expected exit code to be %d but got %d", tc.ExpectedExitCode, exitCode) + } + }) + } +} + +func TestCoverageThreshold(t *testing.T) { + testCases := []struct { + note string + modules map[string]string + threshold float64 + verbose bool + expectedErrOutput string + expectedExitCode int + }{ + { + note: "coverage threshold met", + modules: map[string]string{ + "test.rego": `package test + + p := 1 + test_p if { p == 1 }`, + }, + expectedExitCode: 0, + }, + { + note: "coverage threshold not met", + modules: map[string]string{ + "test.rego": `package test + + p := 1 if { + 1 == 1 + } + q := 2 + r := 3 + test_q if { q == 2 }`, + }, + threshold: 100, + expectedExitCode: 2, + expectedErrOutput: "Code coverage threshold not met: got 40.00 instead of 100.00\n", + }, + { + note: "coverage threshold not met (verbose)", + modules: map[string]string{ + "test.rego": `package test + + p := 1 if { + 1 == 1 + } + q := 2 + r := 3 + test_q if { q == 2 }`, + }, + threshold: 100, + expectedExitCode: 2, + verbose: true, + expectedErrOutput: `Code coverage threshold not met: got 40.00 instead of 100.00 +Lines not covered: + %ROOT%/test.rego:3-4 + %ROOT%/test.rego:7 +`, + }, + { + note: "coverage threshold not met (verbose, multiple files)", + modules: map[string]string{ + "policy1.rego": `package test + + p := 1 if { + 1 == 1 + } + q := 2 + r := 3`, + "policy2.rego": `package test + + s := 4 if { + 1 == 1 + 2 == 2 + } + t := 5 + u := 6 + v := 7`, + "test.rego": `package test + + test_q if { q == 2 } + test_t if { t == 5 }`, + }, + threshold: 100, + expectedExitCode: 2, + verbose: true, + expectedErrOutput: `Code coverage threshold not met: got 33.33 instead of 100.00 +Lines not covered: + %ROOT%/policy1.rego:3-4 + %ROOT%/policy1.rego:7 + %ROOT%/policy2.rego:3-5 + %ROOT%/policy2.rego:8-9 +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.note, func(t *testing.T) { + test.WithTempFS(tc.modules, func(root string) { + var buf bytes.Buffer + + testParams := newTestCommandParams() + testParams.threshold = tc.threshold + testParams.verbose = tc.verbose + testParams.count = 1 + testParams.errOutput = &buf + + exitCode := opaTest([]string{root}, testParams) + if exitCode != tc.expectedExitCode { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if len(tc.expectedErrOutput) == 0 && buf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expectedErrOutput := strings.ReplaceAll(tc.expectedErrOutput, "%ROOT%", root) + if buf.String() != expectedErrOutput { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", expectedErrOutput, buf.String()) + } + }) + }) + } +} + +func TestCoverageThresholdWithBundles(t *testing.T) { + modules := map[string]string{ + "authz/policy.rego": `package authz + +import rego.v1 + +allow if { + input.path == ["users"] + input.method == "POST" +} + +allow if { + some profile_id + input.path = ["users", profile_id] + input.method == "GET" + profile_id == input.user_id +} +`, + "authz/policy_test.rego": `package authz + +import rego.v1 + +test_post_allowed if { + allow with input as {"path": ["users"], "method": "POST"} +} + +test_get_anonymous_denied if { + not allow with input as {"path": ["users"], "method": "GET"} +} +test_get_another_user_denied if { + not allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "alice"} +} +`, + } + + for _, loadType := range []loadType{loadBundle, loadTarball} { + t.Run(loadType.String(), func(t *testing.T) { + var files map[string]string + if loadType != loadTarball { + files = modules + } + test.WithTempFS(files, func(root string) { + if loadType == loadTarball { + f, err := os.Create(filepath.Join(root, "bundle.tar.gz")) + if err != nil { + t.Fatal(err) + } + + testBundle := bundle.Bundle{ + Data: map[string]any{}, + } + for k, v := range modules { + testBundle.Modules = append(testBundle.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + + if err := bundle.Write(f, testBundle); err != nil { + t.Fatal(err) + } + } + + var buf bytes.Buffer + + testParams := newTestCommandParams() + testParams.threshold = 100 + testParams.verbose = true + testParams.count = 1 + testParams.bundleMode = true + testParams.errOutput = &buf + testParams.coverage = true + + var paths []string + if loadType == loadTarball { + paths = []string{filepath.Join(root, "bundle.tar.gz")} + } else { + paths = []string{root} + } + + exitCode := opaTest(paths, testParams) + // Coverage should NOT be 100% since the second allow rule is never exercised + if exitCode != 2 { + t.Fatalf("expected exit code 2 (threshold not met), got %d\noutput: %s", exitCode, buf.String()) + } + + if !strings.Contains(buf.String(), "Code coverage threshold not met: got 92.31 instead of 100.00") { + t.Fatalf("expected coverage of 92.31, got:\n\n%q", buf.String()) + } + }) + }) + } +} + +type loadType int + +const ( + loadFile loadType = iota + loadBundle + loadTarball +) + +func (t loadType) String() string { + return [...]string{"file", "bundle", "bundle tarball"}[t] +} + +func TestRun_DefaultRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + expErrs []string + }{ + { + note: "v0 module", + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: `if` keyword is required before rule body", + "test.rego:4: rego_parse_error: `contains` keyword is required for partial set rules", + "test.rego:8: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v1 module", + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + } + + loadTypes := []loadType{loadFile, loadBundle, loadTarball} + + for _, tc := range tests { + for _, loadType := range loadTypes { + t.Run(fmt.Sprintf("%s (%s)", tc.note, loadType), func(t *testing.T) { + var files map[string]string + if loadType != loadTarball { + files = tc.files + } + test.WithTempFS(files, func(root string) { + if loadType == loadTarball { + f, err := os.Create(filepath.Join(root, "bundle.tar.gz")) + if err != nil { + t.Fatal(err) + } + + testBundle := bundle.Bundle{ + Data: map[string]any{}, + } + for k, v := range tc.files { + testBundle.Modules = append(testBundle.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + + if err := bundle.Write(f, testBundle); err != nil { + t.Fatal(err) + } + } + + var buf bytes.Buffer + var errBuf bytes.Buffer + + testParams := newTestCommandParams() + testParams.bundleMode = loadType == loadBundle + testParams.count = 1 + testParams.output = &buf + testParams.errOutput = &errBuf + + var paths []string + if loadType == loadTarball { + paths = []string{filepath.Join(root, "bundle.tar.gz")} + } else { + paths = []string{root} + } + + exitCode := opaTest(paths, testParams) + if len(tc.expErrs) > 0 { + if exitCode == 0 { + t.Fatalf("expected non-zero exit code") + } + + for _, expErr := range tc.expErrs { + if actual := errBuf.String(); !strings.Contains(actual, expErr) { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", expErr, actual) + } + } + } else { + if exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if errBuf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expected := "PASS: 1/1" + if actual := buf.String(); !strings.Contains(actual, expected) { + t.Fatalf("expected output to contain:\n\n%s\n\nbut got:\n\n%q", expected, actual) + } + } + }) + }) + } + } +} + +func TestRunWithRegoV1Capability(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + capabilities *ast.Capabilities + files map[string]string + expErrs []string + }{ + { + note: "v0 module, v0-compatible, no capabilities", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + }, + { + note: "v0 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + }, + { + note: "v0 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + }, + + { + note: "v0 module, not v0-compatible, no capabilities", + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: `if` keyword is required before rule body", + "test.rego:4: rego_parse_error: `contains` keyword is required for partial set rules", + "test.rego:8: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: `if` keyword is required before rule body", + "test.rego:4: rego_parse_error: `contains` keyword is required for partial set rules", + "test.rego:8: rego_parse_error: `if` keyword is required before rule body", + }, + }, + { + note: "v0 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v0 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +} + +test_l { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: `if` keyword is required before rule body", + "test.rego:4: rego_parse_error: `contains` keyword is required for partial set rules", + "test.rego:8: rego_parse_error: `if` keyword is required before rule body", + }, + }, + + { + note: "v1 module, v0-compatible, no capabilities", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v0 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: var cannot be used for rule name", + }, + }, + { + note: "v1 module, v0-compatible, v1 capabilities", + v0Compatible: true, + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErrs: []string{ + "test.rego:4: rego_parse_error: var cannot be used for rule name", + }, + }, + + { + note: "v1 module, not v0-compatible, no capabilities", + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV0)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 module, not v0-compatible, v0 capabilities without rego_v1 feature", + capabilities: capsWithoutFeat(ast.RegoV0, ast.FeatureRegoV1), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErrs: []string{ + "rego_parse_error: illegal capabilities: rego_v1 feature required for parsing v1 Rego", + }, + }, + { + note: "v1 module, not v0-compatible, v1 capabilities", + capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(ast.RegoV1)), + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + } + + loadTypes := []loadType{loadFile, loadBundle, loadTarball} + + for _, tc := range tests { + for _, loadType := range loadTypes { + t.Run(fmt.Sprintf("%s (%s)", tc.note, loadType), func(t *testing.T) { + var files map[string]string + if loadType != loadTarball { + files = tc.files + } + test.WithTempFS(files, func(root string) { + if loadType == loadTarball { + f, err := os.Create(filepath.Join(root, "bundle.tar.gz")) + if err != nil { + t.Fatal(err) + } + + testBundle := bundle.Bundle{ + Data: map[string]any{}, + } + for k, v := range tc.files { + testBundle.Modules = append(testBundle.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + + if err := bundle.Write(f, testBundle); err != nil { + t.Fatal(err) + } + } + + var buf bytes.Buffer + var errBuf bytes.Buffer + + testParams := newTestCommandParams() + testParams.bundleMode = loadType == loadBundle + testParams.count = 1 + testParams.output = &buf + testParams.errOutput = &errBuf + testParams.v0Compatible = tc.v0Compatible + testParams.capabilities.C = tc.capabilities + + var paths []string + if loadType == loadTarball { + paths = []string{filepath.Join(root, "bundle.tar.gz")} + } else { + paths = []string{root} + } + + exitCode := opaTest(paths, testParams) + if len(tc.expErrs) > 0 { + if exitCode == 0 { + t.Fatalf("expected non-zero exit code") + } + + for _, expErr := range tc.expErrs { + if actual := errBuf.String(); !strings.Contains(actual, expErr) { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", expErr, actual) + } + } + } else { + if exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if errBuf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expected := "PASS: 1/1" + if actual := buf.String(); !strings.Contains(actual, expected) { + t.Fatalf("expected output to contain:\n\n%s\n\nbut got:\n\n%q", expected, actual) + } + } + }) + }) + } + } +} + +func TestRun_CompatibleFlags(t *testing.T) { + tests := []struct { + note string + v0Compatible bool + v1Compatible bool + files map[string]string + expErr string + }{ + { + note: "v0 module, no imports", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + { + note: "v0 module, rego.v1 imported", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0 module, future.keywords imported", + v0Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + + { + note: "v1 compatible module, no imports", + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 compatible module, rego.v1 imported", + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 compatible module, future.keywords imported", + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + + // v0 takes precedence over v1 + { + note: "v0+v1 module, no imports", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + { + note: "v0+v1 module, rego.v1 imported", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0+v1 module, future.keywords imported", + v0Compatible: true, + v1Compatible: true, + files: map[string]string{ + "/test.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + } + + loadTypes := []loadType{loadFile, loadBundle, loadTarball} + + for _, tc := range tests { + for _, loadType := range loadTypes { + t.Run(fmt.Sprintf("%s (%s)", tc.note, loadType), func(t *testing.T) { + var files map[string]string + if loadType != loadTarball { + files = tc.files + } + test.WithTempFS(files, func(root string) { + if loadType == loadTarball { + f, err := os.Create(filepath.Join(root, "bundle.tar.gz")) + if err != nil { + t.Fatal(err) + } + + testBundle := bundle.Bundle{ + Data: map[string]any{}, + } + for k, v := range tc.files { + testBundle.Modules = append(testBundle.Modules, bundle.ModuleFile{ + Path: k, + Raw: []byte(v), + }) + } + + if err := bundle.Write(f, testBundle); err != nil { + t.Fatal(err) + } + } + + var buf bytes.Buffer + var errBuf bytes.Buffer + + testParams := newTestCommandParams() + testParams.v0Compatible = tc.v0Compatible + testParams.v1Compatible = tc.v1Compatible + testParams.bundleMode = loadType == loadBundle + testParams.count = 1 + testParams.output = &buf + testParams.errOutput = &errBuf + + var paths []string + if loadType == loadTarball { + paths = []string{filepath.Join(root, "bundle.tar.gz")} + } else { + paths = []string{root} + } + + exitCode := opaTest(paths, testParams) + if tc.expErr != "" { + if exitCode == 0 { + t.Fatalf("expected non-zero exit code") + } + + if actual := errBuf.String(); !strings.Contains(actual, tc.expErr) { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", tc.expErr, actual) + } + } else { + if exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if errBuf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expected := "PASS: 1/1" + if actual := buf.String(); !strings.Contains(actual, expected) { + t.Fatalf("expected output to contain:\n\n%s\n\nbut got:\n\n%q", expected, actual) + } + } + }) + }) + } + } +} + +func TestWithBundleRegoVersion(t *testing.T) { + tests := []struct { + note string + files map[string]string + expErr string + }{ + { + note: "v0.x bundle, no imports", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + { + note: "v0.x bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0.x bundle, future.keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 0}`, + "policy.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + 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 +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "*/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v0 bundle, v1 per-file override, incompatible", + files: map[string]string{ + ".manifest": `{ + "rego_version": 0, + "file_rego_versions": { + "/policy2.rego": 1 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + + { + note: "v1.0 bundle, no imports", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1.0 bundle, rego.v1 imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test + +import rego.v1 + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1.0 bundle, future.keywords imported", + files: map[string]string{ + ".manifest": `{"rego_version": 1}`, + "policy.rego": `package test + +import future.keywords + +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +} + +test_l if { + l1 == l2 +}`, + }, + }, + { + 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 +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override (glob)", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "*/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2[v] { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + }, + { + note: "v1 bundle, v0 per-file override, incompatible", + files: map[string]string{ + ".manifest": `{ + "rego_version": 1, + "file_rego_versions": { + "/policy1.rego": 0 + } +}`, + "policy1.rego": `package test +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`, + }, + expErr: "rego_parse_error", + }, + } + + 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) + } + } + + var buf bytes.Buffer + var errBuf bytes.Buffer + + testParams := newTestCommandParams() + testParams.v1Compatible = v1CompatibleFlag.used + testParams.bundleMode = true + testParams.count = 1 + testParams.output = &buf + testParams.errOutput = &errBuf + + exitCode := opaTest([]string{p}, testParams) + if tc.expErr != "" { + if exitCode == 0 { + t.Fatalf("expected non-zero exit code") + } + + if actual := errBuf.String(); !strings.Contains(actual, tc.expErr) { + t.Fatalf("expected error output to contain:\n\n%q\n\nbut got:\n\n%q", tc.expErr, actual) + } + } else { + if exitCode != 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + + if errBuf.Len() > 0 { + t.Fatalf("expected no error output but got:\n\n%q", buf.String()) + } + + expected := "PASS: 1/1" + if actual := buf.String(); !strings.Contains(actual, expected) { + t.Fatalf("expected output to contain:\n\n%s\n\nbut got:\n\n%q", expected, actual) + } + } + }) + }) + } + } + } +} + +// Assert that a failing test doesn't cause a panic. +// https://github.com/open-policy-agent/opa/issues/7205 +func TestTestBenchFailingTest(t *testing.T) { + files := map[string]string{ + "test.rego": `package test + test_fail if false`, + } + + test.WithTempFS(files, func(path string) { + fp := filepath.Join(path, "test.rego") + tp := newTestCommandParams() + tp.benchmark = true + tp.count = 1 + + exitCode := opaTest([]string{fp}, tp) + if exitCode == 0 { + t.Fatalf("Expected exit code != 0, got %d", exitCode) + } + }) +} + +func TestTestRunParallel(t *testing.T) { + tests := []struct { + note string + parallel int + }{ + { + note: "default workers", + parallel: 0, + }, + { + note: "1 workers", + parallel: 1, + }, + { + note: "2 workers", + parallel: 2, + }, + { + note: "100 workers", + parallel: 100, + }, + } + + for _, tc := range tests { + testParams := newTestCommandParams() + testParams.parallel = tc.parallel + + files := map[string]string{ + "policy1.rego": `package test +l1 := {1, 3, 5} +l2 contains v if { + v := l1[_] +}`, + "policy2.rego": `package test +test_l if { + l1 == l2 +}`} + + var exitCode int + test.WithTempFS(files, func(root string) { + exitCode = opaTest([]string{root}, testParams) + }) + + if exitCode > 0 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + } +} + +func TestWithDefaultRegoPlugin(t *testing.T) { + // We're injecting a default rego plugin that always returns true. + // If it's picked as a default (as intended), the tests run here will also + // yield true. If it wasn't picked, we'd use topdown, and would see a failing + // test. + tp := &testPlugin{} + rego.RegisterPlugin(targetPlugin, tp) + t.Cleanup(func() { tp.target = targetPlugin }) + + t.Run("test", func(t *testing.T) { + test.WithTempFS(map[string]string{"test.rego": "package test\ntest_true if false"}, func(path string) { + fp := filepath.Join(path, "test.rego") + tp := newTestCommandParams() + tp.output = io.Discard + tp.count = 1 + + exitCode := opaTest([]string{fp}, tp) + if exitCode != 0 { + t.Fatalf("Expected exit code 0, got %d", exitCode) + } + }) + }) + t.Run("eval", func(t *testing.T) { + params := newEvalCommandParams() + params.fail = true + query := "2+2 = 5" // unification will fail ("2+2 == 5" would be false, but defined) + + defined, err := eval([]string{query}, params, io.Discard, nil) + if err != nil { + t.Fatal("unexpected error", err) + } + if !defined { + t.Errorf("expected defined result") + } + }) + + t.Run("repl", func(t *testing.T) { + ctx := t.Context() + store := inmem.New() + var buffer bytes.Buffer + repl := repl.New(store, "", &buffer, "", 0, "") + if err := repl.OneShot(ctx, "2+2==5"); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + result := buffer.String() + if result != "true\n" { + t.Errorf("Expected result to be false but got: %v", result) + } + }) +} + +func TestFailOnEmpty(t *testing.T) { + testCases := map[string]struct { + Test string + FailOnEmpty bool + ExpectedExitCode int + }{ + "pass when no tests and fail-on-empty disabled": { + Test: `package foo + + p := 1 + `, + FailOnEmpty: false, + ExpectedExitCode: 0, + }, + "fail when no tests and fail-on-empty enabled": { + Test: `package foo + + p := 1 + `, + FailOnEmpty: true, + ExpectedExitCode: 1, + }, + "pass when tests exist and fail-on-empty enabled": { + Test: `package foo + + test_pass if { true } + `, + FailOnEmpty: true, + ExpectedExitCode: 0, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + exitCode := testExitCodeWithFailOnEmpty(tc.Test, tc.FailOnEmpty) + + if exitCode != tc.ExpectedExitCode { + t.Errorf("Expected exit code to be %d but got %d", tc.ExpectedExitCode, exitCode) + } + }) + } +} + +type testPlugin struct { + target string +} + +func (t *testPlugin) IsTarget(tgt string) bool { + return tgt == t.target // t == "" makes it the global default +} + +func (*testPlugin) PrepareForEval(context.Context, *ir.Policy, ...rego.PrepareOption) (rego.TargetPluginEval, error) { + return &testPlugin{}, nil +} + +func (*testPlugin) Eval(context.Context, *rego.EvalContext, ast.Value) (ast.Value, error) { + return ast.NewSet(ast.NewTerm(ast.NewObject([2]*ast.Term{ast.StringTerm("^term1"), ast.BooleanTerm(true)}))), nil +} + +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) + } +} diff --git a/cmd/test_test.go b/cmd/test_test.go index 4199603d14..a481bd7eda 100644 --- a/cmd/test_test.go +++ b/cmd/test_test.go @@ -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) + } +} diff --git a/internal/jsonv2/jsonv2.go b/internal/jsonv2/jsonv2.go new file mode 100644 index 0000000000..95ccf9476b --- /dev/null +++ b/internal/jsonv2/jsonv2.go @@ -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()) +} diff --git a/internal/jsonv2/jsonv2_test.go b/internal/jsonv2/jsonv2_test.go new file mode 100644 index 0000000000..92aed5dcb0 --- /dev/null +++ b/internal/jsonv2/jsonv2_test.go @@ -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) + } +} diff --git a/internal/prometheus/prometheus_test.go b/internal/prometheus/prometheus_test.go index 9fbbb6ca93..2f66fbbd81 100644 --- a/internal/prometheus/prometheus_test.go +++ b/internal/prometheus/prometheus_test.go @@ -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 diff --git a/internal/semver/semver.go b/internal/semver/semver.go index 725f86318a..d46f80aeb8 100644 --- a/internal/semver/semver.go +++ b/internal/semver/semver.go @@ -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) } diff --git a/internal/semver/semver_test.go b/internal/semver/semver_test.go index fbf0268c89..718390f0ae 100644 --- a/internal/semver/semver_test.go +++ b/internal/semver/semver_test.go @@ -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" { diff --git a/v1/ast/annotations.go b/v1/ast/annotations.go index df2c3e9e98..2b6a83eeeb 100644 --- a/v1/ast/annotations.go +++ b/v1/ast/annotations.go @@ -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 diff --git a/v1/ast/annotations_json.go b/v1/ast/annotations_json.go new file mode 100644 index 0000000000..420a9f8093 --- /dev/null +++ b/v1/ast/annotations_json.go @@ -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) +} diff --git a/v1/ast/annotations_jsonv2.go b/v1/ast/annotations_jsonv2.go new file mode 100644 index 0000000000..72e28caec4 --- /dev/null +++ b/v1/ast/annotations_jsonv2.go @@ -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) +} diff --git a/v1/ast/annotations_test.go b/v1/ast/annotations_test.go index a7160355ab..6e934cdf79 100644 --- a/v1/ast/annotations_test.go +++ b/v1/ast/annotations_test.go @@ -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: "&", + 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 := "&"; 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) + } +} diff --git a/v1/ast/jsonv1_test.go b/v1/ast/jsonv1_test.go new file mode 100644 index 0000000000..d9653d02f2 --- /dev/null +++ b/v1/ast/jsonv1_test.go @@ -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) + } +} diff --git a/v1/ast/jsonv2_test.go b/v1/ast/jsonv2_test.go new file mode 100644 index 0000000000..20ffa3cb74 --- /dev/null +++ b/v1/ast/jsonv2_test.go @@ -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) + } +} diff --git a/v1/ast/location/location.go b/v1/ast/location/location.go index 4e3a080bed..e08088cff1 100644 --- a/v1/ast/location/location.go +++ b/v1/ast/location/location.go @@ -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) -} diff --git a/v1/ast/location/location_json.go b/v1/ast/location/location_json.go new file mode 100644 index 0000000000..441ea86e46 --- /dev/null +++ b/v1/ast/location/location_json.go @@ -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) +} diff --git a/v1/ast/location/location_jsonv2.go b/v1/ast/location/location_jsonv2.go new file mode 100644 index 0000000000..1897538a14 --- /dev/null +++ b/v1/ast/location/location_jsonv2.go @@ -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) +} diff --git a/v1/ast/location/location_test.go b/v1/ast/location/location_test.go index 269894b997..59f7e572f7 100644 --- a/v1/ast/location/location_test.go +++ b/v1/ast/location/location_test.go @@ -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 diff --git a/v1/ast/marshal_jsonv2_test.go b/v1/ast/marshal_jsonv2_test.go new file mode 100644 index 0000000000..0974a5ae41 --- /dev/null +++ b/v1/ast/marshal_jsonv2_test.go @@ -0,0 +1,1627 @@ +// 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/v2" + "strings" + "testing" + + astJSON "github.com/open-policy-agent/opa/v1/ast/json" + "github.com/open-policy-agent/opa/v1/util" +) + +func resetJSONOptions() { + astJSON.SetOptions(astJSON.Defaults()) +} + +func TestGeneric_MarshalWithLocationJSONOptions(t *testing.T) { + testCases := map[string]struct { + Term *Term + Options astJSON.Options + ExpectedJSON string + }{ + "base case, no location options set": { + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + ExpectedJSON: `{"type":"string","value":"example"}`, + }, + "location included, location text excluded": { + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + }, + IncludeLocationText: false, + }, + }, + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"type":"string","value":"example"}`, + }, + "location included, location text also included": { + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + }, + IncludeLocationText: true, + }, + }, + Term: func() *Term { + v, _ := InterfaceToValue("example") + t := &Term{ + Value: v, + Location: NewLocation([]byte("things"), "example.rego", 1, 2), + } + return t + }(), + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2,"text":"dGhpbmdz"},"type":"string","value":"example"}`, + }, + "location included, location text included, file excluded": { + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + }, + IncludeLocationText: true, + ExcludeLocationFile: true, + }, + }, + Term: func() *Term { + v, _ := InterfaceToValue("example") + t := &Term{ + Value: v, + Location: NewLocation([]byte("things"), "example.rego", 1, 2), + } + return t + }(), + ExpectedJSON: `{"location":{"row":1,"col":2,"text":"dGhpbmdz"},"type":"string","value":"example"}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Term)) + }) + } +} + +func TestTerm_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Term *Term + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + ExpectedJSON: `{"type":"string","value":"example"}`, + }, + "ref with no parts": { + Term: RefTerm(), + ExpectedJSON: `{"type":"ref","value":null}`, + }, + "location excluded": { + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: false, + }, + }, + }, + ExpectedJSON: `{"type":"string","value":"example"}`, + }, + "location included": { + Term: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + }, + }, + }, + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"type":"string","value":"example"}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Term)) + }) + } +} + +func TestTerm_UnmarshalJSON(t *testing.T) { + testCases := map[string]struct { + JSON string + ExpectedTerm *Term + }{ + "base case": { + JSON: `{"type":"string","value":"example"}`, + ExpectedTerm: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + } + }(), + }, + "location case": { + JSON: `{"location":{"file":"example.rego","row":1,"col":2},"type":"string","value":"example"}`, + ExpectedTerm: func() *Term { + v, _ := InterfaceToValue("example") + return &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + var term Term + err := json.Unmarshal([]byte(data.JSON), &term) + if err != nil { + t.Fatal(err) + } + + if !term.Equal(data.ExpectedTerm) { + t.Fatalf("expected:\n%#v got\n%#v", data.ExpectedTerm, term) + } + if data.ExpectedTerm.Location != nil { + if !term.Location.Equal(data.ExpectedTerm.Location) { + t.Fatalf("expected location:\n%#v got\n%#v", data.ExpectedTerm, term) + } + } + }) + } +} + +func TestPackage_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Package *Package + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Package: &Package{ + Path: EmptyRef(), + }, + ExpectedJSON: `{"path":[]}`, + }, + "location excluded": { + Package: &Package{ + Path: EmptyRef(), + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Package: false, + }, + }, + }, + ExpectedJSON: `{"path":[]}`, + }, + "location included": { + Package: &Package{ + Path: EmptyRef(), + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Package: true, + }, + }, + }, + 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 { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Package)) + }) + } +} + +// 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"}]}` + + assertJsonEqual(t, exp, util.MustMarshalJSON(module)) +} + +// 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) { + testCases := map[string]struct { + Comment *Comment + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Comment: &Comment{ + Text: []byte("comment"), + }, + ExpectedJSON: `{"Text":"Y29tbWVudA==","Location":null}`, + }, + "location excluded, still included for legacy reasons": { + Comment: &Comment{ + Text: []byte("comment"), + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Comment: false, // ignored + }, + }, + }, + ExpectedJSON: `{"Text":"Y29tbWVudA==","Location":{"file":"example.rego","row":1,"col":2}}`, + }, + "location included": { + Comment: &Comment{ + Text: []byte("comment"), + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Comment: true, // ignored + }, + }, + }, + ExpectedJSON: `{"Text":"Y29tbWVudA==","Location":{"file":"example.rego","row":1,"col":2}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Comment)) + }) + } +} + +func TestImport_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Import *Import + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Import: func() *Import { + v, _ := InterfaceToValue("example") + term := Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + return &Import{Path: &term} + }(), + ExpectedJSON: `{"path":{"type":"string","value":"example"}}`, + }, + "location excluded": { + Import: func() *Import { + v, _ := InterfaceToValue("example") + term := Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + return &Import{ + Path: &term, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Import: false, + }, + }, + }, + ExpectedJSON: `{"path":{"type":"string","value":"example"}}`, + }, + "location included": { + Import: func() *Import { + v, _ := InterfaceToValue("example") + term := Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + return &Import{ + Path: &term, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + }(), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Import: true, + }, + }, + }, + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"path":{"type":"string","value":"example"}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Import)) + }) + } +} + +func TestRule_MarshalJSON(t *testing.T) { + rawModule := ` + package foo + + # comment + + allow if { true } + ` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + rule := module.Rules[0] + + testCases := map[string]struct { + Rule *Rule + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Rule: rule, + ExpectedJSON: `{"body":[{"index":0,"terms":{"type":"boolean","value":true}}],"head":{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}}`, + }, + "location excluded": { + Rule: rule, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Rule: false, + }, + }, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":{"type":"boolean","value":true}}],"head":{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}}`, + }, + "location included": { + Rule: rule, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Rule: true, + }, + }, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":{"type":"boolean","value":true}}],"head":{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]},"location":{"file":"example.rego","row":6,"col":2}}`, + }, + "annotations included": { + Rule: func() *Rule { + r := rule.Copy() + r.Annotations = []*Annotations{{ + Scope: "rule", + Title: "My rule", + Entrypoint: true, + Organizations: []string{"org1"}, + Description: "My desc", + Custom: map[string]any{ + "foo": "bar", + }}} + return r + }(), + ExpectedJSON: `{"annotations":[{"custom":{"foo":"bar"},"description":"My desc","entrypoint":true,"organizations":["org1"],"scope":"rule","title":"My rule"}],"body":[{"index":0,"terms":{"type":"boolean","value":true}}],"head":{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Rule)) + }) + } +} + +func TestHead_MarshalJSON(t *testing.T) { + rawModule := ` + package foo + + # comment + + allow if { true } + ` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + head := module.Rules[0].Head + + testCases := map[string]struct { + Head *Head + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Head: head.Copy(), + ExpectedJSON: `{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}`, + }, + "location excluded": { + Head: head, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Head: false, + }, + }, + }, + ExpectedJSON: `{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}]}`, + }, + "location included": { + Head: head, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Head: true, + }, + }, + }, + ExpectedJSON: `{"name":"allow","value":{"type":"boolean","value":true},"ref":[{"type":"var","value":"allow"}],"location":{"file":"example.rego","row":6,"col":2}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Head)) + }) + } +} + +func TestRuleHeadRefWithTermLocations_MarshalJSON(t *testing.T) { + policy := `package test + +import rego.v1 + +ref.head[rule].test contains "value" if { + rule := "rule" +}` + + astJSON.SetOptions(astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Head: true, + Term: true, + }, + }, + }) + t.Cleanup(resetJSONOptions) + + module, err := ParseModuleWithOpts("test.rego", policy, ParserOptions{}) + if err != nil { + t.Fatal(err) + } + + bs, err := json.Marshal(module.Rules[0].Head) + if err != nil { + t.Fatal(err) + } + + // Ensure marshalled JSON includes location for any term + expectedJSON := `{"key":{"location":{"file":"test.rego","row":5,"col":30},"type":"string","value":"value"},"ref":[{"location":{"file":"test.rego","row":5,"col":1},"type":"var","value":"ref"},{"location":{"file":"test.rego","row":5,"col":5},"type":"string","value":"head"},{"location":{"file":"test.rego","row":5,"col":10},"type":"var","value":"rule"},{"location":{"file":"test.rego","row":5,"col":16},"type":"string","value":"test"}],"location":{"file":"test.rego","row":5,"col":1}}` + + assertJsonEqual(t, expectedJSON, bs) +} + +func TestExpr_MarshalJSON(t *testing.T) { + rawModule := ` + package foo + + # comment + + allow if { true } + ` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + expr := module.Rules[0].Body[0] + + testCases := map[string]struct { + Expr *Expr + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + 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{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Expr: false, + }, + }, + }, + ExpectedJSON: `{"index":0,"terms":{"type":"boolean","value":true}}`, + }, + "location included": { + Expr: expr, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Expr: true, + }, + }, + }, + ExpectedJSON: `{"index":0,"location":{"file":"example.rego","row":6,"col":13},"terms":{"type":"boolean","value":true}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Expr)) + }) + } +} + +func TestExpr_UnmarshalJSON(t *testing.T) { + rawModule := ` + package foo + + # comment + + allow if { true } + ` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + expr := module.Rules[0].Body[0] + // text is not marshalled to JSON so we just drop it in our examples + expr.Location.Text = nil + + testCases := map[string]struct { + JSON string + ExpectedExpr *Expr + }{ + "base case": { + JSON: `{"index":0,"terms":{"type":"boolean","value":true}}`, + ExpectedExpr: func() *Expr { + e := expr.Copy() + e.Location = nil + return e + }(), + }, + "location case": { + JSON: `{"index":0,"location":{"file":"example.rego","row":6,"col":13},"terms":{"type":"boolean","value":true}}`, + ExpectedExpr: expr, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + var expr Expr + err := json.Unmarshal([]byte(data.JSON), &expr) + if err != nil { + t.Fatal(err) + } + + if !expr.Equal(data.ExpectedExpr) { + t.Fatalf("expected:\n%#v got\n%#v", data.ExpectedExpr, expr) + } + if data.ExpectedExpr.Location != nil { + if !expr.Location.Equal(data.ExpectedExpr.Location) { + t.Fatalf("expected location:\n%#v got\n%#v", data.ExpectedExpr.Location, expr.Location) + } + } + }) + } +} + +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) { + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Call)) + }) + } +} + +func TestSomeDecl_MarshalJSON(t *testing.T) { + v, _ := InterfaceToValue("example") + term := &Term{ + Value: v, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + } + + testCases := map[string]struct { + SomeDecl *SomeDecl + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + SomeDecl: &SomeDecl{ + Symbols: []*Term{term}, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + ExpectedJSON: `{"symbols":[{"type":"string","value":"example"}]}`, + }, + "nil symbols": { + SomeDecl: &SomeDecl{}, + ExpectedJSON: `{"symbols":null}`, + }, + "location excluded": { + SomeDecl: &SomeDecl{ + Symbols: []*Term{term}, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{SomeDecl: false}}, + }, + ExpectedJSON: `{"symbols":[{"type":"string","value":"example"}]}`, + }, + "location included": { + SomeDecl: &SomeDecl{ + Symbols: []*Term{term}, + Location: NewLocation([]byte{}, "example.rego", 1, 2), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{SomeDecl: true}}, + }, + ExpectedJSON: `{"location":{"file":"example.rego","row":1,"col":2},"symbols":[{"type":"string","value":"example"}]}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.SomeDecl)) + }) + } +} + +func TestEvery_MarshalJSON(t *testing.T) { + + rawModule := ` +package foo + +allow if { + every e in [1,2,3] { + e == 1 + } +} +` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + every, ok := module.Rules[0].Body[0].Terms.(*Every) + if !ok { + t.Fatal("expected every term") + } + + testCases := map[string]struct { + Every *Every + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Every: every, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"e"},{"type":"number","value":1}]}],"domain":{"type":"array","value":[{"type":"number","value":1},{"type":"number","value":2},{"type":"number","value":3}]},"key":null,"value":{"type":"var","value":"e"}}`, + }, + "location excluded": { + Every: every, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Every: false}}, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"e"},{"type":"number","value":1}]}],"domain":{"type":"array","value":[{"type":"number","value":1},{"type":"number","value":2},{"type":"number","value":3}]},"key":null,"value":{"type":"var","value":"e"}}`, + }, + "location included": { + Every: every, + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Every: true}}}, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"e"},{"type":"number","value":1}]}],"domain":{"type":"array","value":[{"type":"number","value":1},{"type":"number","value":2},{"type":"number","value":3}]},"key":null,"location":{"file":"example.rego","row":5,"col":2},"value":{"type":"var","value":"e"}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Every)) + }) + } +} + +func TestWith_MarshalJSON(t *testing.T) { + + rawModule := ` +package foo + +a if {input} + +b if { + a with input as 1 +} +` + + module, err := ParseModuleWithOpts("example.rego", rawModule, ParserOptions{AllFutureKeywords: true}) + if err != nil { + t.Fatal(err) + } + + with := module.Rules[1].Body[0].With[0] + + testCases := map[string]struct { + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + ExpectedJSON: `{"target":{"type":"ref","value":[{"type":"var","value":"input"}]},"value":{"type":"number","value":1}}`, + }, + "location excluded": { + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{With: false}}}, + ExpectedJSON: `{"target":{"type":"ref","value":[{"type":"var","value":"input"}]},"value":{"type":"number","value":1}}`, + }, + "location included": { + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{With: true}}}, + ExpectedJSON: `{"location":{"file":"example.rego","row":7,"col":4},"target":{"type":"ref","value":[{"type":"var","value":"input"}]},"value":{"type":"number","value":1}}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(with)) + }) + } +} + +func TestAnnotations_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + Annotations *Annotations + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + Annotations: &Annotations{ + Scope: "rule", + Title: "My rule", + Entrypoint: true, + Organizations: []string{"org1"}, + Description: "My desc", + Custom: map[string]any{ + "foo": "bar", + }, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + ExpectedJSON: `{"custom":{"foo":"bar"},"description":"My desc","entrypoint":true,"organizations":["org1"],"scope":"rule","title":"My rule"}`, + }, + "location excluded": { + Annotations: &Annotations{ + Scope: "rule", + Title: "My rule", + Entrypoint: true, + Organizations: []string{"org1"}, + Description: "My desc", + Custom: map[string]any{ + "foo": "bar", + }, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{Annotations: false}, + }, + }, + ExpectedJSON: `{"custom":{"foo":"bar"},"description":"My desc","entrypoint":true,"organizations":["org1"],"scope":"rule","title":"My rule"}`, + }, + "location included": { + Annotations: &Annotations{ + Scope: "rule", + Title: "My rule", + Entrypoint: true, + Organizations: []string{"org1"}, + Description: "My desc", + Custom: map[string]any{ + "foo": "bar", + }, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{Annotations: true}, + }, + }, + ExpectedJSON: `{"custom":{"foo":"bar"},"description":"My desc","entrypoint":true,"location":{"file":"example.rego","row":1,"col":4},"organizations":["org1"],"scope":"rule","title":"My rule"}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Annotations)) + }) + } +} + +func TestAnnotations_MarshalJSON_Compile(t *testing.T) { + // Regression: Annotations.MarshalJSON used to silently drop the + // `Compile` field even though the struct tag is `compile,omitempty`. + // Default-reflection unmarshal still reads `compile`, so the round-trip + // was asymmetric until this was fixed. + a := &Annotations{ + Scope: "rule", + Compile: &CompileAnnotation{ + Unknowns: []Ref{MustParseRef("input.x"), MustParseRef("input.y")}, + MaskRule: MustParseRef("data.policy.mask"), + }, + } + + bs, err := json.Marshal(a) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(bs, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + compile, ok := got["compile"].(map[string]any) + if !ok { + t.Fatalf("expected `compile` key in marshaled output, got: %s", bs) + } + if _, ok := compile["unknowns"].([]any); !ok { + t.Errorf("expected compile.unknowns to be a JSON array, got: %v", compile["unknowns"]) + } + if _, ok := compile["mask_rule"].([]any); !ok { + t.Errorf("expected compile.mask_rule to be a JSON array (Ref), got: %v", compile["mask_rule"]) + } + + // nil Compile should not emit the key (`omitempty` semantics). + a.Compile = nil + bs, err = json.Marshal(a) + if err != nil { + t.Fatalf("marshal nil compile: %v", err) + } + if strings.Contains(string(bs), "compile") { + t.Errorf("expected nil Compile to be omitted, got: %s", bs) + } +} + +func TestAnnotationsRef_MarshalJSON(t *testing.T) { + testCases := map[string]struct { + AnnotationsRef *AnnotationsRef + Options astJSON.Options + ExpectedJSON string + }{ + "base case": { + AnnotationsRef: &AnnotationsRef{ + Path: []*Term{}, + // using an empty annotations object here since Annotations marshalling is tested separately + Annotations: &Annotations{}, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + ExpectedJSON: `{"annotations":{"scope":""},"path":[]}`, + }, + "location excluded": { + AnnotationsRef: &AnnotationsRef{ + Path: []*Term{}, + Annotations: &Annotations{}, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{AnnotationsRef: false}, + }, + }, + ExpectedJSON: `{"annotations":{"scope":""},"path":[]}`, + }, + "location included": { + AnnotationsRef: &AnnotationsRef{ + Path: []*Term{}, + Annotations: &Annotations{}, + Location: NewLocation([]byte{}, "example.rego", 1, 4), + }, + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{AnnotationsRef: true}, + }, + }, + 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 { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.AnnotationsRef)) + }) + } +} + +func TestNewAnnotationsRef_JSONOptions(t *testing.T) { + tests := []struct { + note string + module string + expected []string + options ParserOptions + jsonOptions astJSON.Options + }{ + { + note: "all JSON marshaller options set to true", + module: `# METADATA +# title: pkg +# description: pkg +# organizations: +# - pkg +# related_resources: +# - https://pkg +# authors: +# - pkg +# schemas: +# - input.foo: {"type": "boolean"} +# custom: +# pkg: pkg +package test + +# METADATA +# scope: document +# title: doc +# description: doc +# organizations: +# - doc +# related_resources: +# - https://doc +# authors: +# - doc +# schemas: +# - input.bar: {"type": "integer"} +# custom: +# doc: doc + +# METADATA +# title: rule +# description: rule +# organizations: +# - rule +# related_resources: +# - https://rule +# authors: +# - rule +# schemas: +# - input.baz: {"type": "string"} +# custom: +# rule: rule +p = 1`, + options: ParserOptions{ + ProcessAnnotation: true, + }, + jsonOptions: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{ + IncludeLocation: astJSON.NodeToggle{ + Term: true, + Package: true, + Comment: true, + Import: true, + Rule: true, + Head: true, + Expr: true, + SomeDecl: true, + Every: true, + With: true, + Annotations: true, + AnnotationsRef: true, + }, + }, + }, + expected: []string{ + `{"annotations":{"authors":[{"name":"pkg"}],"custom":{"pkg":"pkg"},"description":"pkg","location":{"file":"","row":1,"col":1},"organizations":["pkg"],"related_resources":[{"ref":"https://pkg"}],"schemas":[{"path":[{"type":"var","value":"input"},{"type":"string","value":"foo"}],"definition":{"type":"boolean"}}],"scope":"package","title":"pkg"},"location":{"file":"","row":14,"col":1},"path":[{"location":{"file":"","row":14,"col":9},"type":"var","value":"data"},{"location":{"file":"","row":14,"col":9},"type":"string","value":"test"}]}`, + `{"annotations":{"authors":[{"name":"doc"}],"custom":{"doc":"doc"},"description":"doc","location":{"file":"","row":16,"col":1},"organizations":["doc"],"related_resources":[{"ref":"https://doc"}],"schemas":[{"path":[{"type":"var","value":"input"},{"type":"string","value":"bar"}],"definition":{"type":"integer"}}],"scope":"document","title":"doc"},"location":{"file":"","row":44,"col":1},"path":[{"location":{"file":"","row":14,"col":9},"type":"var","value":"data"},{"location":{"file":"","row":14,"col":9},"type":"string","value":"test"},{"location":{"file":"","row":44,"col":1},"type":"string","value":"p"}]}`, + `{"annotations":{"authors":[{"name":"rule"}],"custom":{"rule":"rule"},"description":"rule","location":{"file":"","row":31,"col":1},"organizations":["rule"],"related_resources":[{"ref":"https://rule"}],"schemas":[{"path":[{"type":"var","value":"input"},{"type":"string","value":"baz"}],"definition":{"type":"string"}}],"scope":"rule","title":"rule"},"location":{"file":"","row":44,"col":1},"path":[{"location":{"file":"","row":14,"col":9},"type":"var","value":"data"},{"location":{"file":"","row":14,"col":9},"type":"string","value":"test"},{"location":{"file":"","row":44,"col":1},"type":"string","value":"p"}]}`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + astJSON.SetOptions(tc.jsonOptions) + t.Cleanup(resetJSONOptions) + + module := MustParseModuleWithOpts(tc.module, tc.options) + + if len(tc.expected) != len(module.Annotations) { + t.Fatalf("expected %d annotations got %d", len(tc.expected), len(module.Annotations)) + } + + for i, a := range module.Annotations { + assertJsonEqual(t, + tc.expected[i], + util.MustMarshalJSON(NewAnnotationsRef(a)), + ) + } + + }) + } +} + +func TestNot_MarshalJSON(t *testing.T) { + rawModule := ` + package test + + import future.keywords.not + + implicit_body if { + not input.x + 2 == 42 + } + + explicit_body if { + not { + x := input.x + y := 2 + z := x + y + z == 42 + } + } + ` + + module, err := ParseModule("example.rego", rawModule) + if err != nil { + t.Fatal(err) + } + + testCases := map[string]struct { + Not *Not + Options astJSON.Options + ExpectedJSON string + }{ + "implicit body: base case": { + Not: module.Rules[0].Body[0].Terms.(*Not), + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]},{"type":"number","value":2}]},{"type":"number","value":42}]}],"explicit_body":false,"type":"not"}`, + }, + "explicit body: base case": { + Not: module.Rules[1].Body[0].Terms.(*Not), + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"type":"not"}`, + }, + "implicit body: location excluded": { + Not: module.Rules[0].Body[0].Terms.(*Not), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: false}}, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]},{"type":"number","value":2}]},{"type":"number","value":42}]}],"explicit_body":false,"type":"not"}`, + }, + "explicit body: location excluded": { + Not: module.Rules[1].Body[0].Terms.(*Not), + Options: astJSON.Options{ + MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: false}}, + }, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"type":"not"}`, + }, + "implicit body: location included": { + Not: module.Rules[0].Body[0].Terms.(*Not), + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: true}}}, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]},{"type":"number","value":2}]},{"type":"number","value":42}]}],"explicit_body":false,"location":{"file":"example.rego","row":7,"col":4},"type":"not"}`, + }, + "explicit body: location included": { + Not: module.Rules[1].Body[0].Terms.(*Not), + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: true}}}, + ExpectedJSON: `{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"location":{"file":"example.rego","row":11,"col":4},"type":"not"}`, + }, + "explicit body: location included, also for nested expressions": { + Not: module.Rules[1].Body[0].Terms.(*Not), + Options: astJSON.Options{MarshalOptions: astJSON.MarshalOptions{IncludeLocation: astJSON.NodeToggle{Not: true, Expr: true}}}, + ExpectedJSON: `{"body":[{"index":0,"location":{"file":"example.rego","row":12,"col":5},"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"location":{"file":"example.rego","row":13,"col":5},"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"location":{"file":"example.rego","row":14,"col":5},"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"location":{"file":"example.rego","row":15,"col":5},"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"location":{"file":"example.rego","row":11,"col":4},"type":"not"}`, + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + astJSON.SetOptions(data.Options) + t.Cleanup(resetJSONOptions) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Not)) + }) + } +} + +func TestNot_UnmarshalJSON(t *testing.T) { + rawModule := ` + package test + + import future.keywords.not + + implicit_body if { + not input.x + 2 == 42 + } + + explicit_body if { + not { + x := input.x + y := 2 + z := x + y + z == 42 + } + } + ` + + module, err := ParseModule("example.rego", rawModule) + if err != nil { + t.Fatal(err) + } + + implicitBodyExpr := module.Rules[0].Body[0] + // text is not marshalled to JSON so we just drop it in our examples + implicitBodyExpr.Location.Text = nil + + explicitBodyExpr := module.Rules[1].Body[0] + explicitBodyExpr.Location.Text = nil + + testCases := map[string]struct { + JSON string + ExpectedExpr *Expr + }{ + "implicit body": { + JSON: `{"index":0,"terms":{"type":"not","body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]},{"type":"number","value":2}]},{"type":"number","value":42}]}],"explicit_body":false}}`, + ExpectedExpr: func() *Expr { + e := implicitBodyExpr.Copy() + e.Location = nil + return e + }(), + }, + "explicit body": { + JSON: `{"index":0,"terms":{"body":[{"index":0,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"x"},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"x"}]}]},{"index":1,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"y"},{"type":"number","value":2}]},{"index":2,"terms":[{"type":"ref","value":[{"type":"var","value":"assign"}]},{"type":"var","value":"z"},{"type":"call","value":[{"type":"ref","value":[{"type":"var","value":"plus"}]},{"type":"var","value":"x"},{"type":"var","value":"y"}]}]},{"index":3,"terms":[{"type":"ref","value":[{"type":"var","value":"equal"}]},{"type":"var","value":"z"},{"type":"number","value":42}]}],"explicit_body":true,"type":"not"}}`, + ExpectedExpr: func() *Expr { + e := explicitBodyExpr.Copy() + e.Location = nil + return e + }(), + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + var expr Expr + err := json.Unmarshal([]byte(data.JSON), &expr) + if err != nil { + t.Fatal(err) + } + + if !expr.Equal(data.ExpectedExpr) { + t.Fatalf("expected:\n%#v got\n%#v", data.ExpectedExpr, expr) + } + if data.ExpectedExpr.Location != nil { + if !expr.Location.Equal(data.ExpectedExpr.Location) { + t.Fatalf("expected location:\n%#v got\n%#v", data.ExpectedExpr.Location, expr.Location) + } + } + }) + } +} + +func TestNot_MarshalUnmarshalRoundTrip(t *testing.T) { + rawModule := ` + package test + + import future.keywords.not + + implicit_body if { + not input.x + 2 == 42 + } + + explicit_body if { + not { + x := input.x + y := 2 + z := x + y + z == 42 + } + } + ` + + module, err := ParseModule("example.rego", rawModule) + if err != nil { + t.Fatal(err) + } + + testCases := map[string]struct { + Expr *Expr + }{ + "implicit body": { + Expr: module.Rules[0].Body[0], + }, + "explicit body": { + Expr: module.Rules[1].Body[0], + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + bs := util.MustMarshalJSON(data.Expr) + + var expr Expr + err := json.Unmarshal(bs, &expr) + if err != nil { + t.Fatalf("unmarshal failed: %v\njson: %s", err, string(bs)) + } + + if !expr.Equal(data.Expr) { + t.Fatalf("round-trip mismatch\noriginal: %#v\ngot: %#v", data.Expr, &expr) + } + }) + } +} + +func TestNot_UnmarshalJSON_Errors(t *testing.T) { + testCases := map[string]struct { + JSON string + expErr string + }{ + "body is not an array": { + JSON: `{"index":0,"terms":{"type":"not","body":"invalid","explicit_body":false}}`, + expErr: "invalid body field type", + }, + "body is missing": { + JSON: `{"index":0,"terms":{"type":"not","explicit_body":false}}`, + expErr: "invalid body field type", + }, + "explicit_body is not a bool": { + JSON: `{"index":0,"terms":{"type":"not","body":[],"explicit_body":"yes"}}`, + expErr: "unable to unmarshal explicit_body field", + }, + "body contains invalid expression": { + JSON: `{"index":0,"terms":{"type":"not","body":[{"index":0,"terms":"bad"}],"explicit_body":false}}`, + expErr: "unable to unmarshal not body", + }, + } + + for name, data := range testCases { + t.Run(name, func(t *testing.T) { + var expr Expr + err := json.Unmarshal([]byte(data.JSON), &expr) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), data.expErr) { + t.Fatalf("expected error containing %q, got: %v", data.expErr, err) + } + }) + } +} + +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) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Args)) + }) + } +} + +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) { + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Var)) + }) + } +} + +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) { + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Author)) + }) + } +} + +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) + + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.Schema)) + }) + } + + 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) { + assertJsonEqual(t, data.ExpectedJSON, util.MustMarshalJSON(data.TemplateString)) + }) + } +} + +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) + } +} diff --git a/v1/ast/marshal_test.go b/v1/ast/marshal_test.go index e6022c99e3..84248cd40d 100644 --- a/v1/ast/marshal_test.go +++ b/v1/ast/marshal_test.go @@ -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) + } +} diff --git a/v1/ast/policy.go b/v1/ast/policy.go index 4de585bbb7..c5592d99b8 100644 --- a/v1/ast/policy.go +++ b/v1/ast/policy.go @@ -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) { diff --git a/v1/ast/policy_appenders.go b/v1/ast/policy_appenders.go index 2a45e2e2eb..63b260e0a5 100644 --- a/v1/ast/policy_appenders.go +++ b/v1/ast/policy_appenders.go @@ -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 "...) diff --git a/v1/ast/policy_appenders_test.go b/v1/ast/policy_appenders_test.go index 572ed62227..aa55565479 100644 --- a/v1/ast/policy_appenders_test.go +++ b/v1/ast/policy_appenders_test.go @@ -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) + } + } +} diff --git a/v1/ast/policy_json.go b/v1/ast/policy_json.go new file mode 100644 index 0000000000..b63c035949 --- /dev/null +++ b/v1/ast/policy_json.go @@ -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 +} diff --git a/v1/ast/policy_jsonv2.go b/v1/ast/policy_jsonv2.go new file mode 100644 index 0000000000..faf1b55df7 --- /dev/null +++ b/v1/ast/policy_jsonv2.go @@ -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) +} diff --git a/v1/ast/policy_logical_test.go b/v1/ast/policy_logical_test.go index 809ebc13df..47f11a1a22 100644 --- a/v1/ast/policy_logical_test.go +++ b/v1/ast/policy_logical_test.go @@ -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) }) } } diff --git a/v1/ast/policy_test.go b/v1/ast/policy_test.go index a1c2181885..f5161a6acf 100644 --- a/v1/ast/policy_test.go +++ b/v1/ast/policy_test.go @@ -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 { diff --git a/v1/ast/term.go b/v1/ast/term.go index 7952f028f1..23820f13bb 100644 --- a/v1/ast/term.go +++ b/v1/ast/term.go @@ -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 +} diff --git a/v1/ast/term_json.go b/v1/ast/term_json.go new file mode 100644 index 0000000000..685801b66d --- /dev/null +++ b/v1/ast/term_json.go @@ -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) +} diff --git a/v1/ast/term_jsonv2.go b/v1/ast/term_jsonv2.go new file mode 100644 index 0000000000..62189c3729 --- /dev/null +++ b/v1/ast/term_jsonv2.go @@ -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 +} diff --git a/v1/ast/term_jsonv2_test.go b/v1/ast/term_jsonv2_test.go new file mode 100644 index 0000000000..ba7c50ade5 --- /dev/null +++ b/v1/ast/term_jsonv2_test.go @@ -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) + } +} diff --git a/v1/ast/term_test.go b/v1/ast/term_test.go index 1f074b3342..30c918db5f 100644 --- a/v1/ast/term_test.go +++ b/v1/ast/term_test.go @@ -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) } } diff --git a/v1/keys/keys_test.go b/v1/keys/keys_test.go index 1b2b712c06..45f254d77e 100644 --- a/v1/keys/keys_test.go +++ b/v1/keys/keys_test.go @@ -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) diff --git a/v1/plugins/logs/encoder.go b/v1/plugins/logs/encoder.go index 13bda6e950..37964b95b1 100644 --- a/v1/plugins/logs/encoder.go +++ b/v1/plugins/logs/encoder.go @@ -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 diff --git a/v1/plugins/logs/encoder_test.go b/v1/plugins/logs/encoder_test.go index 943957314c..4d5df1f706 100644 --- a/v1/plugins/logs/encoder_test.go +++ b/v1/plugins/logs/encoder_test.go @@ -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) diff --git a/v1/plugins/logs/eventBuffer_test.go b/v1/plugins/logs/eventBuffer_test.go index c87e35883d..02bce5e5fb 100644 --- a/v1/plugins/logs/eventBuffer_test.go +++ b/v1/plugins/logs/eventBuffer_test.go @@ -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) diff --git a/v1/server/handlers/compress_test.go b/v1/server/handlers/compress_test.go index 525c97957b..bd2e566cd3 100644 --- a/v1/server/handlers/compress_test.go +++ b/v1/server/handlers/compress_test.go @@ -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) } diff --git a/v1/server/server_test.go b/v1/server/server_test.go index 9418ee3a7e..aa3ad0fa3e 100644 --- a/v1/server/server_test.go +++ b/v1/server/server_test.go @@ -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 } diff --git a/v1/topdown/errors_jsonv2.go b/v1/topdown/errors_jsonv2.go new file mode 100644 index 0000000000..29b6f3761e --- /dev/null +++ b/v1/topdown/errors_jsonv2.go @@ -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)) +} diff --git a/v1/topdown/http_slow_test.go b/v1/topdown/http_slow_test.go index 6df27658d6..640dd95853 100644 --- a/v1/topdown/http_slow_test.go +++ b/v1/topdown/http_slow_test.go @@ -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()