diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index f1322251..471e92bf 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -248,6 +248,36 @@ jobs: - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - run: ./gradlew :opa-slf4j:test + verify-proto-vendor: + name: Verify vendored proto schemas match go.mod + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + with: + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: tools/generate-compliance-tests/go.mod + # The vendored .proto files in opa-proto must always match the OPA version + # pinned in tools/generate-compliance-tests/go.mod (the source of truth). + # Re-vendor via the Gradle task and fail if it produces any change — i.e. the + # OPA version was bumped, or a .proto was hand-edited, without re-vendoring. + - name: Re-vendor and check for drift + run: | + set -e + ./gradlew --rerun-tasks :opa-proto:vendorProtoSchemas + if ! git diff --quiet -- opa-proto/src/main/proto; then + echo "::error::Vendored proto schemas are out of sync with the OPA version in tools/generate-compliance-tests/go.mod. Run ./gradlew :opa-proto:vendorProtoSchemas and commit the result." + git --no-pager diff -- opa-proto/src/main/proto + exit 1 + fi + echo "Vendored proto schemas are in sync with the pinned OPA version." + pr-check-summary: name: PR Check Summary runs-on: ubuntu-latest @@ -256,6 +286,7 @@ jobs: - gh-actions-lint - lint - validate-pom + - verify-proto-vendor - build - test-opa-evaluator - test-opa-jackson diff --git a/.gitignore b/.gitignore index 84168191..452690c3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ *.tar.gz *.rar +# …but keep bundle fixtures checked in as test resources +!**/src/test/resources/**/*.tar.gz + # Build directories build/ target/ @@ -60,3 +63,6 @@ nb-configuration.xml .DS_Store Thumbs.db +# Go — compiled helper tool binary (built by `go build` in the compliance-test tool) +/tools/generate-compliance-tests/generate-compliance-tests + diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 93975ad2..1b558fbf 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -15,6 +15,9 @@ dependencies { runtimeOnly(project(":opa-builtins")) runtimeOnly(project(":opa-jackson")) + // Enables the CLI to read proto-format bundles (opa build --format=proto). opa-jackson still + // handles data.json and JSON-format plans/manifests; opa-proto decodes plan.pb/.manifest.pb. + runtimeOnly(project(":opa-proto")) testImplementation("org.junit.jupiter:junit-jupiter:6.1.1") testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.1.1") diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java index bcbd24dd..280e48b3 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java @@ -3,13 +3,11 @@ import io.github.open_policy_agent.opa.ast.types.RegoObject; import io.github.open_policy_agent.opa.ast.types.RegoString; import io.github.open_policy_agent.opa.ir.PolicyReader; +import io.github.open_policy_agent.opa.spi.Services; import io.github.open_policy_agent.opa.storage.Store; import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.ServiceLoader; /** * Processes individual bundle files and assembles them into a {@link Bundle}. @@ -38,39 +36,53 @@ public class BundleAssembler { static final BundleParser BUNDLE_PARSER = loadSingleton(BundleParser.class); + /** + * Holds the optional proto decoder, resolved lazily on first proto-format load. + * + *

Deferring resolution (rather than a {@code static final} field on {@link BundleAssembler}) + * keeps a proto-specific misconfiguration — no {@code opa-proto} module, two providers, or a + * provider that fails to instantiate — from running in {@code BundleAssembler}'s own class + * initializer, where it would break all bundle loading, including JSON-only bundles that + * never touch proto. The holder class initializes only when {@link #requireProtoDecoder} first + * references it, so any such failure surfaces only when a proto bundle is actually read. + */ + private static final class ProtoDecoderHolder { + static final ProtoBundleDecoder INSTANCE = loadOptional(ProtoBundleDecoder.class); + } + /** * Loads exactly one implementation of the given SPI from the classpath. Throws if zero or more * than one implementation is registered, since either case produces an ambiguous runtime. */ private static T loadSingleton(Class spi) { - List impls = new ArrayList<>(); - for (T impl : ServiceLoader.load(spi)) { - impls.add(impl); - } - if (impls.isEmpty()) { - throw new IllegalStateException( - "No " - + spi.getSimpleName() - + " implementation found on the classpath. Add a module that provides " - + spi.getSimpleName() - + " (e.g. opa-jackson)."); - } - if (impls.size() > 1) { - StringBuilder names = new StringBuilder(); - for (int i = 0; i < impls.size(); i++) { - if (i > 0) { - names.append(", "); - } - names.append(impls.get(i).getClass().getName()); - } + return Services.loadAtMostOne(spi) + .orElseThrow( + () -> + new IllegalStateException( + "No " + + spi.getSimpleName() + + " implementation found on the classpath. Add a module that provides " + + spi.getSimpleName() + + " (e.g. opa-jackson).")); + } + + /** + * Loads an optional SPI: returns {@code null} when no implementation is registered, the single + * implementation when exactly one is, and throws when more than one is registered. + */ + private static T loadOptional(Class spi) { + return Services.loadAtMostOne(spi).orElse(null); + } + + private static ProtoBundleDecoder requireProtoDecoder() { + ProtoBundleDecoder decoder = ProtoDecoderHolder.INSTANCE; + if (decoder == null) { throw new IllegalStateException( - "Multiple " - + spi.getSimpleName() - + " implementations found on the classpath: " - + names - + ". Only one provider may be registered."); + "No ProtoBundleDecoder implementation found on the classpath, but the bundle is in proto" + + " format (plan.pb / .manifest.pb). Add the opa-proto module to read proto-format" + + " bundles."); } - return impls.get(0); + return decoder; } private final Bundle.Builder builder = new Bundle.Builder(); @@ -83,6 +95,16 @@ public void loadPlan(InputStream in) throws IOException { hasContent = true; } + /** + * Load a compiled IR policy from a protobuf {@code plan.pb} stream. + * + * @throws IllegalStateException if no {@link ProtoBundleDecoder} is registered (add opa-proto) + */ + public void loadPlanProto(InputStream in) throws IOException { + builder.withIrPolicy(requireProtoDecoder().decodePlan(in)); + hasContent = true; + } + /** * Load data from a {@code data.json} at the given path within the bundle. * @@ -132,6 +154,63 @@ public void loadManifest(InputStream in) throws IOException { builder.withManifest(BUNDLE_PARSER.parseManifest(in)); } + /** + * Load bundle metadata from a protobuf {@code .manifest.pb} stream. + * + * @throws IllegalStateException if no {@link ProtoBundleDecoder} is registered (add opa-proto) + */ + public void loadManifestProto(InputStream in) throws IOException { + builder.withManifest(requireProtoDecoder().decodeManifest(in)); + } + + /** + * Detect the bundle's wire format from which plan/manifest artifacts are present, reject + * mixed-format bundles, and load the plan and manifest in the correct format. + * + *

This centralizes the format-detection and routing policy so {@link BundleLoader} + * implementations only need to locate the four possible artifacts and expose each as an {@link + * InputStreamSource} (or {@code null} when absent), regardless of whether the bytes come from a + * directory, a tarball, or elsewhere. Proto takes precedence over JSON for each artifact, matching + * OPA; a proto plan cannot be paired with a JSON manifest and vice versa (see {@link + * BundleFormat#validate}). + * + * @param planJson source for {@code plan.json}, or {@code null} if absent + * @param planProto source for {@code plan.pb}, or {@code null} if absent + * @param manifestJson source for {@code .manifest}, or {@code null} if absent + * @param manifestProto source for {@code .manifest.pb}, or {@code null} if absent + * @throws IllegalArgumentException if the bundle mixes wire formats + * @throws IOException if a source cannot be opened or its contents cannot be parsed + */ + public void loadPlanAndManifest( + InputStreamSource planJson, + InputStreamSource planProto, + InputStreamSource manifestJson, + InputStreamSource manifestProto) + throws IOException { + BundleFormat.validate( + planJson != null, planProto != null, manifestJson != null, manifestProto != null); + + if (planProto != null) { + try (InputStream in = planProto.open()) { + loadPlanProto(in); + } + } else if (planJson != null) { + try (InputStream in = planJson.open()) { + loadPlan(in); + } + } + + if (manifestProto != null) { + try (InputStream in = manifestProto.open()) { + loadManifestProto(in); + } + } else if (manifestJson != null) { + try (InputStream in = manifestJson.open()) { + loadManifest(in); + } + } + } + /** Add a Rego source file by its relative path. */ public void addRego(String path, String content) { builder.withRego(path, content); diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleFormat.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleFormat.java new file mode 100644 index 00000000..2daabe51 --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleFormat.java @@ -0,0 +1,86 @@ +package io.github.open_policy_agent.opa.bundle; + +/** + * Constants and validation for the two on-disk bundle wire formats. + * + *

OPA's {@code opa build} emits plan bundles in either JSON (the default) or protobuf form. The + * two forms use distinct filenames for the plan and manifest: + * + * + * + * + * + * + *
Format-specific filenames
ArtifactJSONProto
Plan{@code plan.json}{@code plan.pb}
Manifest{@code .manifest}{@code .manifest.pb}
+ * + *

Data files ({@code data.json}) are JSON in both forms; only the plan and manifest change. + * + *

OPA rejects bundles whose plan and manifest formats disagree (e.g. {@code plan.pb} paired with + * a JSON {@code .manifest}). {@link BundleLoader} implementations call {@link #validate} to enforce + * the same rule and to reject bundles that ambiguously contain both formats of the same artifact. + */ +public final class BundleFormat { + + /** Filename of a JSON-format IR plan. */ + public static final String PLAN_JSON = "plan.json"; + + /** Filename of a protobuf-format IR plan. */ + public static final String PLAN_PROTO = "plan.pb"; + + /** Filename of a JSON-format bundle manifest. */ + public static final String MANIFEST_JSON = ".manifest"; + + /** Filename of a protobuf-format bundle manifest. */ + public static final String MANIFEST_PROTO = ".manifest.pb"; + + private BundleFormat() {} + + /** + * Reject bundles that mix the two wire formats. + * + *

Fails if a bundle contains both formats of the same artifact (e.g. both {@code plan.json} and + * {@code plan.pb}), or if a plan and a manifest are present in disagreeing formats (e.g. a proto + * plan with a JSON manifest), matching OPA's own auto-detection semantics. A bundle carrying only + * a plan or only a manifest is always accepted. + * + * @param hasPlanJson whether a {@code plan.json} is present + * @param hasPlanProto whether a {@code plan.pb} is present + * @param hasManifestJson whether a {@code .manifest} is present + * @param hasManifestProto whether a {@code .manifest.pb} is present + * @throws IllegalArgumentException if the bundle mixes formats + */ + public static void validate( + boolean hasPlanJson, + boolean hasPlanProto, + boolean hasManifestJson, + boolean hasManifestProto) { + if (hasPlanJson && hasPlanProto) { + throw new IllegalArgumentException( + "Bundle contains both " + PLAN_JSON + " and " + PLAN_PROTO + "; plan format is ambiguous"); + } + if (hasManifestJson && hasManifestProto) { + throw new IllegalArgumentException( + "Bundle contains both " + + MANIFEST_JSON + + " and " + + MANIFEST_PROTO + + "; manifest format is ambiguous"); + } + if (hasPlanProto && hasManifestJson) { + throw new IllegalArgumentException( + "Bundle mixes plan and manifest formats: proto plan (" + + PLAN_PROTO + + ") with JSON manifest (" + + MANIFEST_JSON + + ")"); + } + if (hasPlanJson && hasManifestProto) { + throw new IllegalArgumentException( + "Bundle mixes plan and manifest formats: JSON plan (" + + PLAN_JSON + + ") with proto manifest (" + + MANIFEST_PROTO + + ")"); + } + } +} diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoader.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoader.java index 1c5db932..9cbb4994 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoader.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoader.java @@ -31,19 +31,14 @@ public Bundle load(Store store) { BundleAssembler assembler = new BundleAssembler(); try { - Path planPath = directory.resolve("plan.json"); - if (Files.isRegularFile(planPath)) { - try (InputStream in = Files.newInputStream(planPath)) { - assembler.loadPlan(in); - } - } - - Path manifestPath = directory.resolve(".manifest"); - if (Files.isRegularFile(manifestPath)) { - try (InputStream in = Files.newInputStream(manifestPath)) { - assembler.loadManifest(in); - } - } + // OPA emits plan/manifest in either JSON (plan.json/.manifest) or proto + // (plan.pb/.manifest.pb). BundleAssembler detects the format, rejects mixed-format bundles, + // and loads each artifact; we only supply a stream for the files that exist. + assembler.loadPlanAndManifest( + fileSource(directory.resolve(BundleFormat.PLAN_JSON)), + fileSource(directory.resolve(BundleFormat.PLAN_PROTO)), + fileSource(directory.resolve(BundleFormat.MANIFEST_JSON)), + fileSource(directory.resolve(BundleFormat.MANIFEST_PROTO))); try (Stream walk = Files.walk(directory)) { walk.filter(Files::isRegularFile) @@ -81,4 +76,9 @@ public Bundle load(Store store) { "Error reading bundle directory: " + e.getMessage(), e); } } + + /** An {@link InputStreamSource} that opens {@code path}, or {@code null} if it is not a file. */ + private static InputStreamSource fileSource(Path path) { + return Files.isRegularFile(path) ? () -> Files.newInputStream(path) : null; + } } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/InputStreamSource.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/InputStreamSource.java new file mode 100644 index 00000000..17b8a54a --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/InputStreamSource.java @@ -0,0 +1,23 @@ +package io.github.open_policy_agent.opa.bundle; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Supplies a fresh {@link InputStream} for a single bundle artifact. + * + *

Lets {@link BundleLoader} implementations hand {@link BundleAssembler} a lazily-opened stream + * (e.g. a file opened on demand, or an in-memory buffer) without exposing how the bytes are + * sourced. Unlike {@link java.util.function.Supplier}, {@link #open()} may throw {@link IOException}. + */ +@FunctionalInterface +public interface InputStreamSource { + + /** + * Opens a new stream over the artifact's bytes. The caller closes it. + * + * @return the opened stream + * @throws IOException if the stream cannot be opened + */ + InputStream open() throws IOException; +} diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/ProtoBundleDecoder.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/ProtoBundleDecoder.java new file mode 100644 index 00000000..00f58003 --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/ProtoBundleDecoder.java @@ -0,0 +1,54 @@ +package io.github.open_policy_agent.opa.bundle; + +import io.github.open_policy_agent.opa.ir.policy.Policy; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +/** + * Optional SPI for decoding protobuf-format bundle files produced by + * {@code opa build --format=proto}. + * + *

OPA emits IR plan bundles in two wire formats. The default JSON form stores the plan at + * {@code /plan.json} and the manifest at {@code /.manifest}; the proto form stores them at + * {@code /plan.pb} and {@code /.manifest.pb} respectively. This SPI decodes the proto form into the + * same in-memory model the JSON {@link io.github.open_policy_agent.opa.ir.PolicyReader} produces, so + * evaluation is identical regardless of the on-disk format. + * + *

Unlike {@link io.github.open_policy_agent.opa.ir.PolicyReader} and {@link BundleParser} — of + * which exactly one implementation must be registered — this decoder is optional. The core + * {@code opa-evaluator} carries no protobuf dependency; a {@link BundleLoader} only requires a + * decoder when it actually encounters proto-format files. Zero registered implementations is a valid + * runtime (JSON-only bundles work without it); at most one may be registered. + * + *

Register implementations via {@link java.util.ServiceLoader} or, when using JPMS, via a + * {@code provides} declaration in {@code module-info.java}. The {@code opa-proto} module provides + * one. + * + *

Note that a proto-format bundle still stores data files as JSON ({@code data.json}); only the + * plan and manifest change format. Data parsing therefore continues to flow through + * {@link BundleParser}. + */ +public interface ProtoBundleDecoder { + + /** + * Decode a compiled IR policy from a {@code plan.pb} stream. + * + * @param in the {@code plan.pb} input stream + * @return the decoded policy + * @throws IOException if the stream cannot be read or the bytes are not a valid proto plan + */ + Policy decodePlan(InputStream in) throws IOException; + + /** + * Decode bundle metadata from a {@code .manifest.pb} stream. + * + *

The returned map mirrors the shape produced by {@link BundleParser#parseManifest} for the + * JSON {@code .manifest}, so downstream consumers do not need to distinguish the two formats. + * + * @param in the {@code .manifest.pb} input stream + * @return the decoded manifest as a map + * @throws IOException if the stream cannot be read or the bytes are not a valid proto manifest + */ + Map decodeManifest(InputStream in) throws IOException; +} diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java index 4e33faf9..e694bf3b 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java @@ -1,14 +1,13 @@ package io.github.open_policy_agent.opa.mapper; -import java.util.ArrayList; -import java.util.List; -import java.util.ServiceLoader; +import io.github.open_policy_agent.opa.spi.Services; /** * Static accessor for the active {@link AnnotationIntrospector}. Discovers an implementation via - * {@link ServiceLoader}; if none is registered, falls back to {@link DefaultAnnotationIntrospector} - * (which performs no annotation lookups and so honors only JavaBean conventions). Throws if more - * than one implementation is registered, to avoid an ambiguous runtime. + * {@link java.util.ServiceLoader}; if none is registered, falls back to {@link + * DefaultAnnotationIntrospector} (which performs no annotation lookups and so honors only JavaBean + * conventions). Throws if more than one implementation is registered, to avoid an ambiguous + * runtime. */ final class AnnotationIntrospectors { @@ -21,26 +20,7 @@ static AnnotationIntrospector get() { } private static AnnotationIntrospector load() { - List impls = new ArrayList<>(); - for (AnnotationIntrospector impl : ServiceLoader.load(AnnotationIntrospector.class)) { - impls.add(impl); - } - if (impls.isEmpty()) { - return new DefaultAnnotationIntrospector(); - } - if (impls.size() > 1) { - StringBuilder names = new StringBuilder(); - for (int i = 0; i < impls.size(); i++) { - if (i > 0) { - names.append(", "); - } - names.append(impls.get(i).getClass().getName()); - } - throw new IllegalStateException( - "Multiple AnnotationIntrospector implementations found on the classpath: " - + names - + ". Only one provider may be registered."); - } - return impls.get(0); + return Services.loadAtMostOne(AnnotationIntrospector.class) + .orElseGet(DefaultAnnotationIntrospector::new); } } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/spi/Services.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/spi/Services.java new file mode 100644 index 00000000..af25e85c --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/spi/Services.java @@ -0,0 +1,51 @@ +package io.github.open_policy_agent.opa.spi; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.ServiceLoader; + +/** + * Helpers for loading SPI implementations via {@link ServiceLoader} with a consistent + * "at most one provider" policy and error message. + * + *

Several SPIs in this SDK ({@code PolicyReader}, {@code BundleParser}, {@code + * ProtoBundleDecoder}, {@code AnnotationIntrospector}) permit only a single registered + * implementation. They differ only in how they treat the zero-implementations case — hard error, + * {@code null}, or a fallback — which callers express by mapping the returned {@link Optional}. + */ +public final class Services { + + private Services() {} + + /** + * Loads the single registered implementation of {@code spi}, if any. + * + * @param spi the service interface to load + * @param the service type + * @return the sole implementation, or {@link Optional#empty()} if none is registered + * @throws IllegalStateException if more than one implementation is registered + */ + public static Optional loadAtMostOne(Class spi) { + List impls = new ArrayList<>(); + for (T impl : ServiceLoader.load(spi)) { + impls.add(impl); + } + if (impls.size() > 1) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < impls.size(); i++) { + if (i > 0) { + names.append(", "); + } + names.append(impls.get(i).getClass().getName()); + } + throw new IllegalStateException( + "Multiple " + + spi.getSimpleName() + + " implementations found on the classpath: " + + names + + ". Only one provider may be registered."); + } + return impls.isEmpty() ? Optional.empty() : Optional.of(impls.get(0)); + } +} diff --git a/opa-proto/build.gradle.kts b/opa-proto/build.gradle.kts new file mode 100644 index 00000000..4d8a200b --- /dev/null +++ b/opa-proto/build.gradle.kts @@ -0,0 +1,160 @@ +import com.google.protobuf.gradle.id +import java.io.ByteArrayOutputStream +import javax.inject.Inject +import org.gradle.process.ExecOperations + +plugins { + `java-library` + id("com.google.protobuf") version "0.9.5" +} + +repositories { + mavenCentral() +} + +// protobuf-java 4.x is required: the vendored schemas use edition 2023, which +// needs protoc >= 27 (the 4.27+ line) and a matching runtime. +val protobufVersion = "4.29.3" + +dependencies { + api(project(":opa-evaluator")) + api("com.google.protobuf:protobuf-java:$protobufVersion") + + testImplementation(project(":opa-jackson")) + testImplementation("org.junit.jupiter:junit-jupiter:6.1.0") + testImplementation("org.junit.jupiter:junit-jupiter-params:6.1.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.1") + testImplementation("org.assertj:assertj-core:3.27.7") +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:$protobufVersion" + } +} + +// Re-vendors the OPA .proto schemas from the OPA version pinned in +// tools/generate-compliance-tests/go.mod (the source of truth). Wired ahead of +// protobuf codegen and keyed on go.mod/go.sum, so simply building the module +// re-vendors automatically whenever the pinned OPA version changes — and stays +// UP-TO-DATE (no network) when it hasn't. Requires the Go toolchain; when Go is +// unavailable the task is skipped and the committed schemas are used as-is. +// +// Implemented natively (via the injected ExecOperations service) rather than by +// shelling out, so it is OS-agnostic and configuration-cache compatible. +abstract class VendorProtoSchemas @Inject constructor(private val exec: ExecOperations) : + DefaultTask() { + + @get:InputFile + abstract val goMod: RegularFileProperty + + @get:InputFile + abstract val goSum: RegularFileProperty + + // Absolute path to the resolved go/go.exe, or empty when Go is not installed. + @get:Input + abstract val goExecutable: Property + + // The protobuf source root the schemas are copied into. Not declared as an @OutputDirectory + // because it doubles as protobuf codegen input; the stamp below carries up-to-date state. + @get:Internal + abstract val protoRoot: DirectoryProperty + + @get:OutputFile + abstract val stamp: RegularFileProperty + + @TaskAction + fun vendor() { + val goDir = goMod.get().asFile.parentFile + + val version = capture("list", "-m", "-f", "{{.Version}}", MODULE).trim() + exec.exec { + commandLine(go(), "mod", "download", MODULE) + workingDir = goDir + // The temporary commit pin isn't in sum.golang.org yet; go.sum still enforces integrity. + // Scope the bypass to the OPA module (rather than GOSUMDB=off) so the checksum database + // stays enabled for everything else — notably the on-demand `golang.org/toolchain` + // download the `go 1.26` directive triggers on runners with an older Go, which fails + // outright when the sumdb is globally off. Remove once pinned to a tagged OPA release. + environment("GONOSUMDB", MODULE) + } + val moduleCache = capture("env", "GOMODCACHE").trim() + + val src = File(moduleCache, "github.com/open-policy-agent/opa@$version") + val root = protoRoot.get().asFile + for (rel in listOf("v1/ir/plan.proto", "v1/bundle/manifest.proto")) { + val dest = File(root, rel) + File(src, rel).copyTo(dest, overwrite = true) + dest.setWritable(true) // module cache files are read-only + logger.lifecycle("vendored $rel from opa@$version") + } + stamp.get().asFile.writeText("re-vendored github.com/open-policy-agent/opa@$version\n") + } + + private fun go() = goExecutable.get() + + private fun capture(vararg args: String): String { + val out = ByteArrayOutputStream() + exec.exec { + commandLine(listOf(go()) + args) + workingDir = goMod.get().asFile.parentFile + environment("GONOSUMDB", MODULE) + standardOutput = out + } + return out.toString() + } + + companion object { + const val MODULE = "github.com/open-policy-agent/opa" + } +} + +// Resolve the Go executable at configuration time (OS-aware), or "" when absent. +val resolvedGoExecutable = + providers.environmentVariable("PATH").zip(providers.systemProperty("os.name")) { path, os -> + val names = if (os.lowercase().contains("win")) listOf("go.exe", "go") else listOf("go") + path.split(File.pathSeparator) + .asSequence() + .flatMap { dir -> names.asSequence().map { File(dir, it) } } + .firstOrNull { it.canExecute() } + ?.absolutePath + ?: "" + } + +val vendorProtoSchemas by tasks.registering(VendorProtoSchemas::class) { + description = "Re-vendor OPA .proto schemas from the version pinned in go.mod" + group = "build" + + goMod.set(rootProject.layout.projectDirectory.file("tools/generate-compliance-tests/go.mod")) + goSum.set(rootProject.layout.projectDirectory.file("tools/generate-compliance-tests/go.sum")) + goExecutable.set(resolvedGoExecutable) + protoRoot.set(layout.projectDirectory.dir("src/main/proto")) + stamp.set(layout.buildDirectory.file("vendor-protos.stamp")) + + // Skip (leaving the committed schemas as-is) when Go isn't installed, so pure-Java builds work. + onlyIf { goExecutable.get().isNotEmpty() } +} + +tasks.named("generateProto") { + dependsOn(vendorProtoSchemas) +} + +// Generated protobuf sources live under opa.ir.v1 / opa.bundle.v1 (from the +// vendored schemas). They are machine-generated, so exempt them from the code +// quality gates applied to hand-written sources. +tasks.withType().configureEach { + exclude("**/opa/ir/v1/**", "**/opa/bundle/v1/**") +} +tasks.withType().configureEach { + exclude("**/opa/ir/v1/**", "**/opa/bundle/v1/**") +} + +tasks.test { + useJUnitPlatform() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} diff --git a/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/ManifestMapper.java b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/ManifestMapper.java new file mode 100644 index 00000000..62d9a6b3 --- /dev/null +++ b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/ManifestMapper.java @@ -0,0 +1,149 @@ +package io.github.open_policy_agent.opa.proto; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import opa.bundle.v1.Annotations; +import opa.bundle.v1.AuthorAnnotation; +import opa.bundle.v1.CompileAnnotation; +import opa.bundle.v1.Manifest; +import opa.bundle.v1.RelatedResourceAnnotation; +import opa.bundle.v1.SchemaAnnotation; +import opa.bundle.v1.WasmResolver; + +/** + * Maps a decoded proto {@link Manifest} into the {@code Map} shape produced for the + * JSON {@code .manifest} by the {@code BundleParser}, so downstream consumers see identical metadata + * regardless of the bundle's wire format. + * + *

Field presence mirrors OPA's JSON marshaling exactly: fields tagged {@code omitempty} (or + * emitted conditionally by a custom {@code MarshalJSON}, as {@code ast.Annotations} does) are + * included only when non-empty/non-default, so the proto path never emits a key the JSON reader + * would drop. Annotation {@code location} is intentionally never emitted: OPA gates it behind a + * marshal option that is off by default, so standard {@code opa build} manifests omit it. + */ +final class ManifestMapper { + + private ManifestMapper() {} + + static Map toMap(Manifest m) { + Map out = new LinkedHashMap<>(); + + // Revision has no omitempty in OPA's JSON tags — always present. + out.put("revision", m.getRevision()); + + // Roots is a pointer in Go (omitempty); roots_set distinguishes an explicitly-set list + // (possibly empty) from an absent one. + if (m.getRootsSet()) { + out.put("roots", new ArrayList<>(m.getRootsList())); + } + + if (m.getWasmCount() > 0) { + List wasm = new ArrayList<>(m.getWasmCount()); + for (WasmResolver w : m.getWasmList()) { + wasm.add(wasmResolver(w)); + } + out.put("wasm", wasm); + } + + if (m.hasRegoVersion()) { + out.put("rego_version", m.getRegoVersion()); + } + + if (m.getFileRegoVersionsCount() > 0) { + out.put("file_rego_versions", new LinkedHashMap<>(m.getFileRegoVersionsMap())); + } + + if (m.hasMetadata()) { + out.put("metadata", StructConverter.toMap(m.getMetadata())); + } + + return out; + } + + private static Map wasmResolver(WasmResolver w) { + // bundle.WasmResolver: entrypoint, module, annotations are all omitempty. + Map out = new LinkedHashMap<>(); + putIfNotEmpty(out, "entrypoint", w.getEntrypoint()); + putIfNotEmpty(out, "module", w.getModule()); + if (w.getAnnotationsCount() > 0) { + List annotations = new ArrayList<>(w.getAnnotationsCount()); + for (Annotations a : w.getAnnotationsList()) { + annotations.add(annotations(a)); + } + out.put("annotations", annotations); + } + return out; + } + + private static Map annotations(Annotations a) { + // Mirrors ast.Annotations.MarshalJSON: scope is always present; the rest are emitted only when + // non-empty/true. + Map out = new LinkedHashMap<>(); + out.put("scope", a.getScope()); + putIfNotEmpty(out, "title", a.getTitle()); + putIfNotEmpty(out, "description", a.getDescription()); + if (a.getEntrypoint()) { + out.put("entrypoint", true); + } + if (a.getOrganizationsCount() > 0) { + out.put("organizations", new ArrayList<>(a.getOrganizationsList())); + } + if (a.getRelatedResourcesCount() > 0) { + List related = new ArrayList<>(a.getRelatedResourcesCount()); + for (RelatedResourceAnnotation r : a.getRelatedResourcesList()) { + Map rm = new LinkedHashMap<>(); + rm.put("ref", r.getRef()); + putIfNotEmpty(rm, "description", r.getDescription()); + related.add(rm); + } + out.put("related_resources", related); + } + if (a.getAuthorsCount() > 0) { + List authors = new ArrayList<>(a.getAuthorsCount()); + for (AuthorAnnotation author : a.getAuthorsList()) { + Map am = new LinkedHashMap<>(); + am.put("name", author.getName()); + putIfNotEmpty(am, "email", author.getEmail()); + authors.add(am); + } + out.put("authors", authors); + } + if (a.getSchemasCount() > 0) { + List schemas = new ArrayList<>(a.getSchemasCount()); + for (SchemaAnnotation s : a.getSchemasList()) { + Map sm = new LinkedHashMap<>(); + sm.put("path", s.getPath()); + putIfNotEmpty(sm, "schema", s.getSchema()); + if (s.hasDefinition()) { + sm.put("definition", StructConverter.toObject(s.getDefinition())); + } + schemas.add(sm); + } + out.put("schemas", schemas); + } + if (a.hasCompile()) { + CompileAnnotation c = a.getCompile(); + Map cm = new LinkedHashMap<>(); + if (c.getUnknownsCount() > 0) { + cm.put("unknowns", new ArrayList<>(c.getUnknownsList())); + } + putIfNotEmpty(cm, "mask_rule", c.getMaskRule()); + out.put("compile", cm); + } + if (a.hasCustom()) { + out.put("custom", StructConverter.toMap(a.getCustom())); + } + if (a.hasLabels()) { + out.put("labels", StructConverter.toMap(a.getLabels())); + } + return out; + } + + private static void putIfNotEmpty(Map out, String key, String value) { + if (!value.isEmpty()) { + out.put(key, value); + } + } +} diff --git a/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/PlanMapper.java b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/PlanMapper.java new file mode 100644 index 00000000..d0be0846 --- /dev/null +++ b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/PlanMapper.java @@ -0,0 +1,308 @@ +package io.github.open_policy_agent.opa.proto; + +import io.github.open_policy_agent.opa.ir.Operand; +import io.github.open_policy_agent.opa.ir.policy.Block; +import io.github.open_policy_agent.opa.ir.policy.BuiltinFunc; +import io.github.open_policy_agent.opa.ir.policy.Func; +import io.github.open_policy_agent.opa.ir.policy.Funcs; +import io.github.open_policy_agent.opa.ir.policy.Plan; +import io.github.open_policy_agent.opa.ir.policy.Plans; +import io.github.open_policy_agent.opa.ir.policy.Policy; +import io.github.open_policy_agent.opa.ir.policy.Static; +import io.github.open_policy_agent.opa.ir.policy.StringConst; +import io.github.open_policy_agent.opa.ir.stmts.ArrayAppendStmt; +import io.github.open_policy_agent.opa.ir.stmts.AssignIntStmt; +import io.github.open_policy_agent.opa.ir.stmts.AssignVarOnceStmt; +import io.github.open_policy_agent.opa.ir.stmts.AssignVarStmt; +import io.github.open_policy_agent.opa.ir.stmts.BaseStmt; +import io.github.open_policy_agent.opa.ir.stmts.BlockStmt; +import io.github.open_policy_agent.opa.ir.stmts.BreakStmt; +import io.github.open_policy_agent.opa.ir.stmts.CallDynamicStmt; +import io.github.open_policy_agent.opa.ir.stmts.CallStmt; +import io.github.open_policy_agent.opa.ir.stmts.DotStmt; +import io.github.open_policy_agent.opa.ir.stmts.EqualStmt; +import io.github.open_policy_agent.opa.ir.stmts.IsArrayStmt; +import io.github.open_policy_agent.opa.ir.stmts.IsDefinedStmt; +import io.github.open_policy_agent.opa.ir.stmts.IsObjectStmt; +import io.github.open_policy_agent.opa.ir.stmts.IsSetStmt; +import io.github.open_policy_agent.opa.ir.stmts.IsUndefinedStmt; +import io.github.open_policy_agent.opa.ir.stmts.LenStmt; +import io.github.open_policy_agent.opa.ir.stmts.MakeArrayStmt; +import io.github.open_policy_agent.opa.ir.stmts.MakeNullStmt; +import io.github.open_policy_agent.opa.ir.stmts.MakeNumberIntStmt; +import io.github.open_policy_agent.opa.ir.stmts.MakeNumberRefStmt; +import io.github.open_policy_agent.opa.ir.stmts.MakeObjectStmt; +import io.github.open_policy_agent.opa.ir.stmts.MakeSetStmt; +import io.github.open_policy_agent.opa.ir.stmts.NopStmt; +import io.github.open_policy_agent.opa.ir.stmts.NotEqualStmt; +import io.github.open_policy_agent.opa.ir.stmts.NotStmt; +import io.github.open_policy_agent.opa.ir.stmts.ObjectInsertOnceStmt; +import io.github.open_policy_agent.opa.ir.stmts.ObjectInsertStmt; +import io.github.open_policy_agent.opa.ir.stmts.ObjectMergeStmt; +import io.github.open_policy_agent.opa.ir.stmts.ResetLocalStmt; +import io.github.open_policy_agent.opa.ir.stmts.ResultSetAddStmt; +import io.github.open_policy_agent.opa.ir.stmts.ReturnLocalStmt; +import io.github.open_policy_agent.opa.ir.stmts.ScanStmt; +import io.github.open_policy_agent.opa.ir.stmts.SetAddStmt; +import io.github.open_policy_agent.opa.ir.stmts.Stmt; +import io.github.open_policy_agent.opa.ir.stmts.WithStmt; +import io.github.open_policy_agent.opa.ir.vals.BoolVal; +import io.github.open_policy_agent.opa.ir.vals.LocalVal; +import io.github.open_policy_agent.opa.ir.vals.StringIndexVal; +import io.github.open_policy_agent.opa.ir.vals.Val; +import java.util.ArrayList; +import java.util.List; + +/** + * Maps a decoded proto {@code opa.ir.v1.Policy} (from {@code plan.pb}) into the SDK's IR {@link + * Policy} model — the same model the JSON {@code PolicyReader} produces — so the evaluator runs + * identically regardless of the plan's wire format. + * + *

The proto types (generated under {@code opa.ir.v1}) collide by simple name with several SDK + * model types ({@code Policy}, {@code Static}, {@code Block}, {@code Stmt}, {@code Operand}, {@code + * Val}). To keep the SDK types imported and readable, proto payloads are bound with {@code var} + * rather than named explicitly. + */ +final class PlanMapper { + + private PlanMapper() {} + + static Policy toPolicy(opa.ir.v1.Policy proto) { + Static staticField = toStatic(proto.getStatic()); + Plans plans = toPlans(proto.getPlans()); + Funcs funcs = toFuncs(proto.getFuncs()); + return new Policy(staticField, plans, funcs); + } + + private static Static toStatic(opa.ir.v1.Static proto) { + List strings = new ArrayList<>(proto.getStringsCount()); + for (var s : proto.getStringsList()) { + strings.add(new StringConst(s.getValue())); + } + List builtinFuncs = new ArrayList<>(proto.getBuiltinFuncsCount()); + for (var b : proto.getBuiltinFuncsList()) { + // The proto intentionally omits the builtin's type signature (Decl); consumers dispatch + // builtins through their own registry. Leave decl null, matching a JSON plan without a decl. + builtinFuncs.add(new BuiltinFunc(b.getName(), null)); + } + List files = new ArrayList<>(proto.getFilesCount()); + for (var f : proto.getFilesList()) { + files.add(new StringConst(f.getValue())); + } + return new Static(strings, builtinFuncs, files); + } + + private static Plans toPlans(opa.ir.v1.Plans proto) { + List plans = new ArrayList<>(proto.getPlansCount()); + for (var p : proto.getPlansList()) { + plans.add(new Plan(p.getName(), toBlocks(p.getBlocksList()))); + } + return new Plans(plans); + } + + private static Funcs toFuncs(opa.ir.v1.Funcs proto) { + List funcs = new ArrayList<>(proto.getFuncsCount()); + for (var f : proto.getFuncsList()) { + funcs.add( + new Func( + f.getName(), + copyInts(f.getParamsList()), + f.getResult(), + toBlocks(f.getBlocksList()), + new ArrayList<>(f.getPathList()))); + } + return new Funcs(funcs); + } + + private static List toBlocks(List protoBlocks) { + List blocks = new ArrayList<>(protoBlocks.size()); + for (var b : protoBlocks) { + blocks.add(toBlock(b)); + } + return blocks; + } + + private static Block toBlock(opa.ir.v1.Block proto) { + List stmts = new ArrayList<>(proto.getStmtsCount()); + for (var s : proto.getStmtsList()) { + stmts.add(toStmt(s)); + } + return new Block(stmts); + } + + private static Stmt toStmt(opa.ir.v1.Stmt s) { + Stmt out; + switch (s.getKindCase()) { + case ARRAY_APPEND_STMT -> { + var x = s.getArrayAppendStmt(); + out = new ArrayAppendStmt(operand(x.getValue()), x.getArray()); + } + case ASSIGN_INT_STMT -> { + var x = s.getAssignIntStmt(); + out = new AssignIntStmt(x.getValue(), x.getTarget()); + } + case ASSIGN_VAR_ONCE_STMT -> { + var x = s.getAssignVarOnceStmt(); + out = new AssignVarOnceStmt(operand(x.getSource()), x.getTarget()); + } + case ASSIGN_VAR_STMT -> { + var x = s.getAssignVarStmt(); + out = new AssignVarStmt(operand(x.getSource()), x.getTarget()); + } + case BLOCK_STMT -> { + var x = s.getBlockStmt(); + out = new BlockStmt(toBlocks(x.getBlocksList())); + } + case BREAK_STMT -> { + var x = s.getBreakStmt(); + out = new BreakStmt(x.getIndex()); + } + case CALL_DYNAMIC_STMT -> { + var x = s.getCallDynamicStmt(); + out = new CallDynamicStmt(operands(x.getPathList()), copyInts(x.getArgsList()), x.getResult()); + } + case CALL_STMT -> { + var x = s.getCallStmt(); + out = new CallStmt(x.getFunction(), operands(x.getArgsList()), x.getResult()); + } + case DOT_STMT -> { + var x = s.getDotStmt(); + out = new DotStmt(operand(x.getSource()), operand(x.getKey()), x.getTarget()); + } + case EQUAL_STMT -> { + var x = s.getEqualStmt(); + out = new EqualStmt(operand(x.getA()), operand(x.getB())); + } + case IS_ARRAY_STMT -> { + var x = s.getIsArrayStmt(); + out = new IsArrayStmt(operand(x.getSource())); + } + case IS_DEFINED_STMT -> { + var x = s.getIsDefinedStmt(); + out = new IsDefinedStmt(x.getSource()); + } + case IS_OBJECT_STMT -> { + var x = s.getIsObjectStmt(); + out = new IsObjectStmt(operand(x.getSource())); + } + case IS_SET_STMT -> { + var x = s.getIsSetStmt(); + out = new IsSetStmt(operand(x.getSource())); + } + case IS_UNDEFINED_STMT -> { + var x = s.getIsUndefinedStmt(); + out = new IsUndefinedStmt(x.getSource()); + } + case LEN_STMT -> { + var x = s.getLenStmt(); + out = new LenStmt(operand(x.getSource()), x.getTarget()); + } + case MAKE_ARRAY_STMT -> { + var x = s.getMakeArrayStmt(); + out = new MakeArrayStmt(x.getCapacity(), x.getTarget()); + } + case MAKE_NULL_STMT -> { + var x = s.getMakeNullStmt(); + out = new MakeNullStmt(x.getTarget()); + } + case MAKE_NUMBER_INT_STMT -> { + var x = s.getMakeNumberIntStmt(); + out = new MakeNumberIntStmt(x.getValue(), x.getTarget()); + } + case MAKE_NUMBER_REF_STMT -> { + var x = s.getMakeNumberRefStmt(); + out = new MakeNumberRefStmt(x.getIndex(), x.getTarget()); + } + case MAKE_OBJECT_STMT -> { + var x = s.getMakeObjectStmt(); + out = new MakeObjectStmt(x.getTarget()); + } + case MAKE_SET_STMT -> { + var x = s.getMakeSetStmt(); + out = new MakeSetStmt(x.getTarget()); + } + case NOP_STMT -> out = new NopStmt(); + case NOT_EQUAL_STMT -> { + var x = s.getNotEqualStmt(); + out = new NotEqualStmt(operand(x.getA()), operand(x.getB())); + } + case NOT_STMT -> { + var x = s.getNotStmt(); + out = new NotStmt(toBlock(x.getBlock())); + } + case OBJECT_INSERT_ONCE_STMT -> { + var x = s.getObjectInsertOnceStmt(); + out = new ObjectInsertOnceStmt(operand(x.getKey()), operand(x.getValue()), x.getObject()); + } + case OBJECT_INSERT_STMT -> { + var x = s.getObjectInsertStmt(); + out = new ObjectInsertStmt(operand(x.getKey()), operand(x.getValue()), x.getObject()); + } + case OBJECT_MERGE_STMT -> { + var x = s.getObjectMergeStmt(); + out = new ObjectMergeStmt(x.getA(), x.getB(), x.getTarget()); + } + case RESET_LOCAL_STMT -> { + var x = s.getResetLocalStmt(); + out = new ResetLocalStmt(x.getTarget()); + } + case RESULT_SET_ADD_STMT -> { + var x = s.getResultSetAddStmt(); + out = new ResultSetAddStmt(x.getValue()); + } + case RETURN_LOCAL_STMT -> { + var x = s.getReturnLocalStmt(); + out = new ReturnLocalStmt(x.getSource()); + } + case SCAN_STMT -> { + var x = s.getScanStmt(); + out = new ScanStmt(x.getSource(), x.getKey(), x.getValue(), toBlock(x.getBlock())); + } + case SET_ADD_STMT -> { + var x = s.getSetAddStmt(); + out = new SetAddStmt(operand(x.getValue()), x.getSet()); + } + case WITH_STMT -> { + var x = s.getWithStmt(); + out = + new WithStmt( + x.getLocal(), copyInts(x.getPathList()), operand(x.getValue()), toBlock(x.getBlock())); + } + case KIND_NOT_SET -> throw new IllegalArgumentException("statement has no kind set"); + default -> throw new IllegalArgumentException("unsupported statement kind: " + s.getKindCase()); + } + + // The source-location triple lives on the proto Stmt envelope; the SDK carries it on BaseStmt. + if (out instanceof BaseStmt base) { + base.setFile(s.getFile()); + base.setCol(s.getCol()); + base.setRow(s.getRow()); + } + return out; + } + + private static List operands(List protoOperands) { + List out = new ArrayList<>(protoOperands.size()); + for (var o : protoOperands) { + out.add(operand(o)); + } + return out; + } + + private static Operand operand(opa.ir.v1.Operand proto) { + return new Operand(val(proto.getValue())); + } + + private static Val val(opa.ir.v1.Val v) { + return switch (v.getKindCase()) { + case BOOL -> new BoolVal(v.getBool()); + case LOCAL -> new LocalVal(v.getLocal()); + case STRING_INDEX -> new StringIndexVal(v.getStringIndex()); + case KIND_NOT_SET -> throw new IllegalArgumentException("operand value has no kind set"); + }; + } + + private static List copyInts(List ints) { + return new ArrayList<>(ints); + } +} diff --git a/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/ProtoBundleReader.java b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/ProtoBundleReader.java new file mode 100644 index 00000000..7bb455e6 --- /dev/null +++ b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/ProtoBundleReader.java @@ -0,0 +1,38 @@ +package io.github.open_policy_agent.opa.proto; + +import io.github.open_policy_agent.opa.bundle.ProtoBundleDecoder; +import io.github.open_policy_agent.opa.ir.policy.Policy; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +/** + * Protobuf-based {@link ProtoBundleDecoder} implementation. + * + *

Decodes the {@code plan.pb} and {@code .manifest.pb} files of a proto-format bundle (produced + * by {@code opa build --format=proto}) into the same in-memory model the JSON readers produce, per + * OPA's {@code v1/ir/plan.proto} and {@code v1/bundle/manifest.proto} schemas. + * + *

This class is discovered automatically via {@link java.util.ServiceLoader} — consumers only + * need {@code opa-proto} on the classpath, they do not reference it directly. + */ +public final class ProtoBundleReader implements ProtoBundleDecoder { + + @Override + public Policy decodePlan(InputStream in) throws IOException { + opa.ir.v1.Policy proto = opa.ir.v1.Policy.parseFrom(in); + try { + return PlanMapper.toPolicy(proto); + } catch (IllegalArgumentException e) { + // PlanMapper rejects malformed plans (e.g. an unset operand value or an unknown statement + // kind) with IllegalArgumentException; surface these as IOException so callers see one + // consistent "unreadable plan.pb" contract alongside protobuf's own parse failures. + throw new IOException("malformed proto plan (plan.pb): " + e.getMessage(), e); + } + } + + @Override + public Map decodeManifest(InputStream in) throws IOException { + return ManifestMapper.toMap(opa.bundle.v1.Manifest.parseFrom(in)); + } +} diff --git a/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/StructConverter.java b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/StructConverter.java new file mode 100644 index 00000000..6a388b4e --- /dev/null +++ b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/StructConverter.java @@ -0,0 +1,78 @@ +package io.github.open_policy_agent.opa.proto; + +import com.google.protobuf.ListValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Converts protobuf {@code google.protobuf.Struct}/{@code Value} trees (used for the free-form + * {@code metadata}, {@code custom}, and {@code labels} fields of the proto manifest) into plain Java + * objects, mirroring how the equivalent JSON would deserialize. + * + *

Objects become {@link LinkedHashMap} (preserving field order), arrays become {@link ArrayList}, + * numbers become {@link Double}, and null/string/bool map to their obvious Java counterparts. + */ +final class StructConverter { + + private StructConverter() {} + + static Map toMap(Struct struct) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : struct.getFieldsMap().entrySet()) { + out.put(e.getKey(), toObject(e.getValue())); + } + return out; + } + + static Object toObject(Value value) { + switch (value.getKindCase()) { + case NULL_VALUE: + return null; + case NUMBER_VALUE: + return number(value.getNumberValue()); + case STRING_VALUE: + return value.getStringValue(); + case BOOL_VALUE: + return value.getBoolValue(); + case STRUCT_VALUE: + return toMap(value.getStructValue()); + case LIST_VALUE: + return toList(value.getListValue()); + case KIND_NOT_SET: + default: + return null; + } + } + + /** + * Maps a protobuf Struct number (always a double) to the Java type the JSON {@code .manifest} + * reader would produce for the same value: an integral value becomes {@link Integer} (or {@link + * Long} when it overflows {@code int}), and a fractional value stays a {@link Double}. Without + * this, {@code {"version": 3}} would decode as {@code 3.0} from a proto bundle but {@code 3} from + * a JSON bundle, breaking metadata parity between the two formats. + */ + private static Object number(double d) { + if (d == Math.rint(d) && !Double.isInfinite(d)) { + long asLong = (long) d; + if (asLong == d) { + if (asLong >= Integer.MIN_VALUE && asLong <= Integer.MAX_VALUE) { + return (int) asLong; + } + return asLong; + } + } + return d; + } + + private static List toList(ListValue listValue) { + List out = new ArrayList<>(listValue.getValuesCount()); + for (Value v : listValue.getValuesList()) { + out.add(toObject(v)); + } + return out; + } +} diff --git a/opa-proto/src/main/proto/README.md b/opa-proto/src/main/proto/README.md new file mode 100644 index 00000000..04ea8bf5 --- /dev/null +++ b/opa-proto/src/main/proto/README.md @@ -0,0 +1,55 @@ +# Vendored OPA protobuf schemas + +These `.proto` files are copied verbatim from Open Policy Agent and describe the +wire format of proto-format plan bundles produced by `opa build --format=proto`. + +| File | Upstream path | +|------|---------------| +| `v1/ir/plan.proto` | `v1/ir/plan.proto` | +| `v1/bundle/manifest.proto` | `v1/bundle/manifest.proto` | + +## Source of truth + +The OPA revision these schemas are vendored from is **not pinned here** — it is +the OPA version required by +[`tools/generate-compliance-tests/go.mod`](../../../../tools/generate-compliance-tests/go.mod), +which is the single place this SDK declares which OPA revision it targets. + +At the time of writing that is a temporary commit pin +(`v1.18.2-0.20260706175033-f6092b9ce472`, i.e. commit `f6092b9ce` — the merge of +[open-policy-agent/opa#8825](https://github.com/open-policy-agent/opa/pull/8825), +schemas from [#8775](https://github.com/open-policy-agent/opa/pull/8775)), because +`opa build --format=proto` has not yet shipped in a tagged release. Once a release +containing the flag is available (targeting v1.19.0), bump `go.mod` to that release +and re-vendor — see below. + +## Updating + +Do not edit these files by hand. Change the OPA version in `go.mod` and the +schemas re-vendor themselves: + +- **Automatically on build.** Building the module (`./gradlew :opa-proto:build`, + `:generateProto`, etc.) runs the `vendorProtoSchemas` task, which re-vendors + from the go.mod-pinned OPA version. It is keyed on `go.mod`/`go.sum`, so it only + does work when the pin changes and stays UP-TO-DATE (no network) otherwise. If + the Go toolchain isn't installed the task is skipped and the committed schemas + are used as-is. +- **On demand**, without a full build: + + ```sh + ./gradlew :opa-proto:vendorProtoSchemas + ``` + +Either way, regenerate the bindings and run the tests afterward: + +```sh +./gradlew :opa-proto:generateProto :opa-proto:test +``` + +CI enforces this: the `verify-proto-vendor` job in `.github/workflows/pull-request.yml` +re-vendors from the go.mod-pinned version on every PR and fails if the committed +`.proto` files differ — so bumping the OPA version without re-vendoring cannot merge. + +If a schema change adds a new IR statement kind, `PlanMapper`'s exhaustive +`switch` over the statement `oneof` will fail to compile until the new kind is +mapped — a deliberate guardrail. diff --git a/opa-proto/src/main/proto/v1/bundle/manifest.proto b/opa-proto/src/main/proto/v1/bundle/manifest.proto new file mode 100644 index 00000000..ed3c1498 --- /dev/null +++ b/opa-proto/src/main/proto/v1/bundle/manifest.proto @@ -0,0 +1,116 @@ +// 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. + +edition = "2023"; + +package opa.bundle.v1; + +import "google/protobuf/struct.proto"; + +option go_package = "github.com/open-policy-agent/opa/v1/bundle/v1pb"; +option java_multiple_files = true; + +// Manifest mirrors `bundle.Manifest` in v1/bundle/bundle.go. +message Manifest { + // Bundle revision string. + string revision = 1; + + // Root paths the bundle owns. See `roots_set` for nil-vs-empty. + repeated string roots = 2; + + // Wasm resolvers attached to the bundle. JSON key is `wasm`. + repeated WasmResolver wasm = 3; + + // Global Rego version for the bundle. Currently 0 (RegoV0) or 1 (RegoV1). + int32 rego_version = 4; + + // Per-file Rego version overrides keyed by file path. + map file_rego_versions = 5; + + // Free-form metadata object. Modeled as `Struct` because the Go field + // is `map[string]any`. + google.protobuf.Struct metadata = 6; + + // True if `bundle.Manifest.Roots` was non-nil. `repeated string` can't + // distinguish nil (default to [""]) from explicit-empty (owns no paths). + bool roots_set = 7; +} + +// WasmResolver mirrors `bundle.WasmResolver` in v1/bundle/bundle.go. +message WasmResolver { + // Entrypoint policy ref this resolver targets. + string entrypoint = 1; + + // Path to the wasm module within the bundle. + string module = 2; + + // Rego annotations attached to the entrypoint. + repeated Annotations annotations = 3; +} + +// Annotations mirrors `ast.Annotations` in v1/ast/annotations.go. +message Annotations { + string scope = 1; + string title = 2; + bool entrypoint = 3; + string description = 4; + repeated string organizations = 5; + repeated RelatedResourceAnnotation related_resources = 6; + repeated AuthorAnnotation authors = 7; + repeated SchemaAnnotation schemas = 8; + CompileAnnotation compile = 9; + + // `custom` and `labels` are `map[string]any` in Go — genuinely + // free-form, so `Struct` is the right model. + google.protobuf.Struct custom = 10; + google.protobuf.Struct labels = 11; + + Location location = 12; +} + +// SchemaAnnotation mirrors `ast.SchemaAnnotation`. Path/Schema are +// `ast.Ref` in Go (a list of terms); the wire form is the canonical +// dotted ref string (e.g. `data.foo.bar`). Modeling the term tree +// faithfully would pull most of the AST into this schema and isn't +// worth the cost for annotations. +message SchemaAnnotation { + string path = 1; + string schema = 2; + // `*any` on the Go side — a parsed JSON Schema document or any + // JSON value. `Value` (not `Struct`) because the top level may be + // a scalar, list, or null, not just an object. + google.protobuf.Value definition = 3; +} + +// CompileAnnotation mirrors `ast.CompileAnnotation`. Refs are the +// canonical dotted form; see SchemaAnnotation for the trade-off. +message CompileAnnotation { + repeated string unknowns = 1; + string mask_rule = 2; +} + +// AuthorAnnotation mirrors `ast.AuthorAnnotation`. +message AuthorAnnotation { + string name = 1; + string email = 2; +} + +// RelatedResourceAnnotation mirrors `ast.RelatedResourceAnnotation`. +// `Ref` is a `url.URL` in Go, serialized to its `String()` form. +message RelatedResourceAnnotation { + string ref = 1; + string description = 2; +} + +// Location mirrors `ast.Location` (= `location.Location`). Only the +// File/Row/Col triple is wire-relevant; `Text`, `Offset`, and `Tabs` +// are tagged `json:"-"` and intentionally absent. +// +// Distinct from `ir.Location`, which is promoted onto the `Stmt` +// envelope in plan.proto. The two are independent Go types. +message Location { + string file = 1; + int32 row = 2; + int32 col = 3; +} diff --git a/opa-proto/src/main/proto/v1/ir/plan.proto b/opa-proto/src/main/proto/v1/ir/plan.proto new file mode 100644 index 00000000..9bd5541e --- /dev/null +++ b/opa-proto/src/main/proto/v1/ir/plan.proto @@ -0,0 +1,364 @@ +// 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. + +edition = "2023"; + +package opa.ir.v1; + +option go_package = "github.com/open-policy-agent/opa/v1/ir/v1pb"; +option java_multiple_files = true; + +// Policy mirrors `ir.Policy` in v1/ir/ir.go. +message Policy { + Static static = 1; + Plans plans = 2; + Funcs funcs = 3; +} + +// Static mirrors `ir.Static` in v1/ir/ir.go. +message Static { + repeated StringConst strings = 1; + repeated BuiltinFunc builtin_funcs = 2; + repeated StringConst files = 3; +} + +// BuiltinFunc mirrors `ir.BuiltinFunc` in v1/ir/ir.go. +// +// `ir.BuiltinFunc.Decl` (the function's `types.Function` signature) is +// intentionally not modeled. Consumers that execute plans need their +// own builtin registry to dispatch host-language implementations, and +// that registry is the source of truth for signatures — bundling +// `Decl` here would be redundant and prone to drift. +message BuiltinFunc { + string name = 1; +} + +// Plans mirrors `ir.Plans` in v1/ir/ir.go. +message Plans { + repeated Plan plans = 1; +} + +// Funcs mirrors `ir.Funcs` in v1/ir/ir.go. +message Funcs { + repeated Func funcs = 1; +} + +// Func mirrors `ir.Func` in v1/ir/ir.go. +// +// Each parameter and the return slot are local-variable indices; see +// `ir.Local` in v1/ir/ir.go. +message Func { + string name = 1; + repeated int32 params = 2; + // The local that holds the function's return value. Renamed from + // `return` (the Go field is `Func.Return Local`, JSON-tagged `return`) + // to avoid colliding with a reserved keyword in many target languages + // when this proto is fed to protoc plugins. The JSON wire form + // continues to use `return`; only the proto-side identifier differs. + int32 result = 3; + repeated Block blocks = 4; + repeated string path = 5; +} + +// Plan mirrors `ir.Plan` in v1/ir/ir.go. +message Plan { + string name = 1; + repeated Block blocks = 2; +} + +// Block mirrors `ir.Block` in v1/ir/ir.go. +message Block { + repeated Stmt stmts = 1; +} + +// StringConst mirrors `ir.StringConst` in v1/ir/ir.go. +message StringConst { + string value = 1; +} + +// Operand mirrors `ir.Operand` in v1/ir/ir.go. The `value` field is a +// polymorphic `Val` union; see the `Val` message below. +message Operand { + Val value = 1; +} + +// Val mirrors the `ir.Val` interface in v1/ir/ir.go. Each oneof case +// corresponds to a concrete `Val` implementation; the case names match +// the JSON discriminator strings emitted by `*Operand.MarshalJSON`. +// +// Case-number assignments are a stability commitment. New cases must +// be added with the next unused number; existing numbers must never +// be repurposed. +message Val { + oneof kind { + bool bool = 1; + int32 local = 2; + int32 string_index = 3; + } +} + +// Stmt mirrors the `ir.Stmt` interface in v1/ir/ir.go. Every Stmt carries +// the source-location triple (file, col, row) on this envelope; the body +// messages below describe only the kind-specific payload. +// +// On the Go side, `ir.Location` is embedded into every concrete Stmt +// implementation, so `encoding/json` flattens File/Col/Row into the +// emitted JSON body. The proto promotes those fields to the envelope +// because that's both more idiomatic protobuf and lets every body +// message start its own field numbering at 1. +// +// Case-number assignments (4–37) are a stability commitment. Field +// numbers 1–3 are reserved for the location triple. New cases must be +// added with the next unused number; existing numbers must never be +// repurposed. +message Stmt { + int32 file = 1; + int32 col = 2; + int32 row = 3; + oneof kind { + ArrayAppendStmt array_append_stmt = 4; + AssignIntStmt assign_int_stmt = 5; + AssignVarOnceStmt assign_var_once_stmt = 6; + AssignVarStmt assign_var_stmt = 7; + BlockStmt block_stmt = 8; + BreakStmt break_stmt = 9; + CallDynamicStmt call_dynamic_stmt = 10; + CallStmt call_stmt = 11; + DotStmt dot_stmt = 12; + EqualStmt equal_stmt = 13; + IsArrayStmt is_array_stmt = 14; + IsDefinedStmt is_defined_stmt = 15; + IsObjectStmt is_object_stmt = 16; + IsSetStmt is_set_stmt = 17; + IsUndefinedStmt is_undefined_stmt = 18; + LenStmt len_stmt = 19; + MakeArrayStmt make_array_stmt = 20; + MakeNullStmt make_null_stmt = 21; + MakeNumberIntStmt make_number_int_stmt = 22; + MakeNumberRefStmt make_number_ref_stmt = 23; + MakeObjectStmt make_object_stmt = 24; + MakeSetStmt make_set_stmt = 25; + NopStmt nop_stmt = 26; + NotEqualStmt not_equal_stmt = 27; + NotStmt not_stmt = 28; + ObjectInsertOnceStmt object_insert_once_stmt = 29; + ObjectInsertStmt object_insert_stmt = 30; + ObjectMergeStmt object_merge_stmt = 31; + ResetLocalStmt reset_local_stmt = 32; + ResultSetAddStmt result_set_add_stmt = 33; + ReturnLocalStmt return_local_stmt = 34; + ScanStmt scan_stmt = 35; + SetAddStmt set_add_stmt = 36; + WithStmt with_stmt = 37; + } +} + +// Stmt body messages start their own field numbering at 1; the +// source-location triple lives on the parent `Stmt` envelope. + +// ArrayAppendStmt mirrors `ir.ArrayAppendStmt` in v1/ir/ir.go. +message ArrayAppendStmt { + Operand value = 1; + int32 array = 2; +} + +// AssignIntStmt mirrors `ir.AssignIntStmt` in v1/ir/ir.go. +message AssignIntStmt { + int64 value = 1; + int32 target = 2; +} + +// AssignVarOnceStmt mirrors `ir.AssignVarOnceStmt` in v1/ir/ir.go. +message AssignVarOnceStmt { + Operand source = 1; + int32 target = 2; +} + +// AssignVarStmt mirrors `ir.AssignVarStmt` in v1/ir/ir.go. +message AssignVarStmt { + Operand source = 1; + int32 target = 2; +} + +// BlockStmt mirrors `ir.BlockStmt` in v1/ir/ir.go. +message BlockStmt { + repeated Block blocks = 1; +} + +// BreakStmt mirrors `ir.BreakStmt` in v1/ir/ir.go. +message BreakStmt { + uint32 index = 1; +} + +// CallDynamicStmt mirrors `ir.CallDynamicStmt` in v1/ir/ir.go. +message CallDynamicStmt { + repeated int32 args = 1; + int32 result = 2; + repeated Operand path = 3; +} + +// CallStmt mirrors `ir.CallStmt` in v1/ir/ir.go. +message CallStmt { + // Renamed from `func` (Go field `CallStmt.Func`, JSON-tagged `func`) + // to avoid colliding with reserved keywords in target languages, + // mirroring the Func.return → result rename above. + string function = 1; + repeated Operand args = 2; + int32 result = 3; +} + +// DotStmt mirrors `ir.DotStmt` in v1/ir/ir.go. +message DotStmt { + Operand source = 1; + Operand key = 2; + int32 target = 3; +} + +// EqualStmt mirrors `ir.EqualStmt` in v1/ir/ir.go. +message EqualStmt { + Operand a = 1; + Operand b = 2; +} + +// IsArrayStmt mirrors `ir.IsArrayStmt` in v1/ir/ir.go. +message IsArrayStmt { + Operand source = 1; +} + +// IsDefinedStmt mirrors `ir.IsDefinedStmt` in v1/ir/ir.go. +message IsDefinedStmt { + int32 source = 1; +} + +// IsObjectStmt mirrors `ir.IsObjectStmt` in v1/ir/ir.go. +message IsObjectStmt { + Operand source = 1; +} + +// IsSetStmt mirrors `ir.IsSetStmt` in v1/ir/ir.go. +message IsSetStmt { + Operand source = 1; +} + +// IsUndefinedStmt mirrors `ir.IsUndefinedStmt` in v1/ir/ir.go. +message IsUndefinedStmt { + int32 source = 1; +} + +// LenStmt mirrors `ir.LenStmt` in v1/ir/ir.go. +message LenStmt { + Operand source = 1; + int32 target = 2; +} + +// MakeArrayStmt mirrors `ir.MakeArrayStmt` in v1/ir/ir.go. +message MakeArrayStmt { + int32 capacity = 1; + int32 target = 2; +} + +// MakeNullStmt mirrors `ir.MakeNullStmt` in v1/ir/ir.go. +message MakeNullStmt { + int32 target = 1; +} + +// MakeNumberIntStmt mirrors `ir.MakeNumberIntStmt` in v1/ir/ir.go. +message MakeNumberIntStmt { + int64 value = 1; + int32 target = 2; +} + +// MakeNumberRefStmt mirrors `ir.MakeNumberRefStmt` in v1/ir/ir.go. +// +// The Go field is named `Index` (no `json` tag). The historical JSON +// shape emitted both `index` and `Index`; the canonical key going +// forward is `index` and the deprecated `Index` alias will be removed +// in a future major release. This proto models only the canonical +// `index` field. +message MakeNumberRefStmt { + int32 index = 1; + int32 target = 2; +} + +// MakeObjectStmt mirrors `ir.MakeObjectStmt` in v1/ir/ir.go. +message MakeObjectStmt { + int32 target = 1; +} + +// MakeSetStmt mirrors `ir.MakeSetStmt` in v1/ir/ir.go. +message MakeSetStmt { + int32 target = 1; +} + +// NopStmt mirrors `ir.NopStmt` in v1/ir/ir.go. +message NopStmt {} + +// NotEqualStmt mirrors `ir.NotEqualStmt` in v1/ir/ir.go. +message NotEqualStmt { + Operand a = 1; + Operand b = 2; +} + +// NotStmt mirrors `ir.NotStmt` in v1/ir/ir.go. +message NotStmt { + Block block = 1; +} + +// ObjectInsertOnceStmt mirrors `ir.ObjectInsertOnceStmt` in v1/ir/ir.go. +message ObjectInsertOnceStmt { + Operand key = 1; + Operand value = 2; + int32 object = 3; +} + +// ObjectInsertStmt mirrors `ir.ObjectInsertStmt` in v1/ir/ir.go. +message ObjectInsertStmt { + Operand key = 1; + Operand value = 2; + int32 object = 3; +} + +// ObjectMergeStmt mirrors `ir.ObjectMergeStmt` in v1/ir/ir.go. +message ObjectMergeStmt { + int32 a = 1; + int32 b = 2; + int32 target = 3; +} + +// ResetLocalStmt mirrors `ir.ResetLocalStmt` in v1/ir/ir.go. +message ResetLocalStmt { + int32 target = 1; +} + +// ResultSetAddStmt mirrors `ir.ResultSetAddStmt` in v1/ir/ir.go. +message ResultSetAddStmt { + int32 value = 1; +} + +// ReturnLocalStmt mirrors `ir.ReturnLocalStmt` in v1/ir/ir.go. +message ReturnLocalStmt { + int32 source = 1; +} + +// ScanStmt mirrors `ir.ScanStmt` in v1/ir/ir.go. +message ScanStmt { + int32 source = 1; + int32 key = 2; + int32 value = 3; + Block block = 4; +} + +// SetAddStmt mirrors `ir.SetAddStmt` in v1/ir/ir.go. +message SetAddStmt { + Operand value = 1; + int32 set = 2; +} + +// WithStmt mirrors `ir.WithStmt` in v1/ir/ir.go. +message WithStmt { + int32 local = 1; + repeated int32 path = 2; + Operand value = 3; + Block block = 4; +} diff --git a/opa-proto/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.bundle.ProtoBundleDecoder b/opa-proto/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.bundle.ProtoBundleDecoder new file mode 100644 index 00000000..d4af9a49 --- /dev/null +++ b/opa-proto/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.bundle.ProtoBundleDecoder @@ -0,0 +1 @@ +io.github.open_policy_agent.opa.proto.ProtoBundleReader diff --git a/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/ManifestMapperTest.java b/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/ManifestMapperTest.java new file mode 100644 index 00000000..b7404451 --- /dev/null +++ b/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/ManifestMapperTest.java @@ -0,0 +1,95 @@ +package io.github.open_policy_agent.opa.proto; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import opa.bundle.v1.Annotations; +import opa.bundle.v1.AuthorAnnotation; +import opa.bundle.v1.Location; +import opa.bundle.v1.Manifest; +import opa.bundle.v1.RelatedResourceAnnotation; +import opa.bundle.v1.WasmResolver; +import org.junit.jupiter.api.Test; + +/** + * Verifies the proto manifest decoder honors OPA's omitempty / conditional-MarshalJSON semantics, so + * it never emits keys the JSON {@code .manifest} reader would drop. + */ +class ManifestMapperTest { + + @SuppressWarnings("unchecked") + private static Map firstAnnotation(Map manifest) { + List wasm = (List) manifest.get("wasm"); + Map resolver = (Map) wasm.get(0); + List annotations = (List) resolver.get("annotations"); + return (Map) annotations.get(0); + } + + @Test + @SuppressWarnings("unchecked") + void emptyOptionalFieldsAreOmitted() { + Manifest m = + Manifest.newBuilder() + .setRevision("") + .setRootsSet(true) + .addRoots("") + .addWasm( + WasmResolver.newBuilder() + .setEntrypoint("") // omitempty -> omitted + .setModule("") // omitempty -> omitted + .addAnnotations( + Annotations.newBuilder() + .setScope("rule") + .setTitle("") // omitted + .setDescription("") // omitted + .setEntrypoint(false) // omitted + .addRelatedResources( + RelatedResourceAnnotation.newBuilder() + .setRef("https://example.com") + .setDescription("")) // omitted + .addAuthors(AuthorAnnotation.newBuilder().setName("me").setEmail("")) + // Location is set but OPA omits it by default, so it must not appear. + .setLocation(Location.newBuilder().setFile("f").setRow(1).setCol(2)))) + .build(); + + Map out = ManifestMapper.toMap(m); + + Map resolver = (Map) ((List) out.get("wasm")).get(0); + assertThat(resolver).doesNotContainKeys("entrypoint", "module"); + + Map ann = firstAnnotation(out); + assertThat(ann).containsEntry("scope", "rule"); + assertThat(ann).doesNotContainKeys("title", "description", "entrypoint", "location"); + + Map related = (Map) ((List) ann.get("related_resources")).get(0); + assertThat(related).containsEntry("ref", "https://example.com").doesNotContainKey("description"); + + Map author = (Map) ((List) ann.get("authors")).get(0); + assertThat(author).containsEntry("name", "me").doesNotContainKey("email"); + } + + @Test + void populatedOptionalFieldsArePresent() { + Manifest m = + Manifest.newBuilder() + .addWasm( + WasmResolver.newBuilder() + .setEntrypoint("entry") + .setModule("mod.wasm") + .addAnnotations( + Annotations.newBuilder() + .setScope("rule") + .setTitle("t") + .setDescription("d") + .setEntrypoint(true))) + .build(); + + Map ann = firstAnnotation(ManifestMapper.toMap(m)); + assertThat(ann) + .containsEntry("scope", "rule") + .containsEntry("title", "t") + .containsEntry("description", "d") + .containsEntry("entrypoint", true); + } +} diff --git a/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/ProtoBundleParityTest.java b/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/ProtoBundleParityTest.java new file mode 100644 index 00000000..d5f1b98d --- /dev/null +++ b/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/ProtoBundleParityTest.java @@ -0,0 +1,138 @@ +package io.github.open_policy_agent.opa.proto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.github.open_policy_agent.opa.bundle.Bundle; +import io.github.open_policy_agent.opa.bundle.FileSystemBundleLoader; +import io.github.open_policy_agent.opa.ir.PolicyReader; +import io.github.open_policy_agent.opa.ir.policy.Policy; +import io.github.open_policy_agent.opa.jackson.JacksonPolicyReader; +import io.github.open_policy_agent.opa.rego.Engine; +import io.github.open_policy_agent.opa.storage.InMem; +import io.github.open_policy_agent.opa.storage.Store; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Round-trip parity: the {@code authz-proto} and {@code authz-json} bundles are built from the same + * policy and data at the pinned OPA commit, differing only in wire format. Loading and evaluating + * either must produce identical results, proving the proto decoder is faithful to OPA's output. + */ +class ProtoBundleParityTest { + + private static Path bundle(String name) { + return Paths.get( + Objects.requireNonNull( + ProtoBundleParityTest.class.getClassLoader().getResource("bundles/" + name)) + .getPath()); + } + + private static final Path PROTO_BUNDLE = bundle("authz-proto"); + private static final Path JSON_BUNDLE = bundle("authz-json"); + + private static List eval(Path bundleDir, String entrypoint, Object input) { + Engine engine = + new Engine.Builder() + .withBundleLoader(new FileSystemBundleLoader(entrypoint, bundleDir)) + .withEntrypoint(entrypoint) + .build(); + return engine.prepareForEvaluation().build().eval(input); + } + + private static void assertParity(String entrypoint, Object input) { + List fromProto = eval(PROTO_BUNDLE, entrypoint, input); + List fromJson = eval(JSON_BUNDLE, entrypoint, input); + assertThat(fromProto) + .as("proto and JSON bundles must evaluate identically for %s with input %s", entrypoint, input) + .isEqualTo(fromJson); + } + + @Test + void allow_isIdenticalAcrossFormats() { + // reader via data.roles, admin via membership, and a denied case + assertParity("authz/allow", Map.of("method", "GET", "user", "alice")); + assertParity("authz/allow", Map.of("method", "POST", "user", "alice")); + assertParity("authz/allow", Map.of("method", "POST", "user", "bob")); + assertParity("authz/allow", Map.of("method", "GET", "user", "carol")); + } + + @Test + void summary_isIdenticalAcrossFormats() { + // exercises object/array/set construction, scan iteration, and the count/sum builtins + assertParity("authz/summary", Map.of("method", "GET", "user", "alice")); + assertParity("authz/summary", Map.of("method", "POST", "user", "bob")); + } + + @Test + void allow_actuallyDecidesAllowAndDeny() { + // Guard against a vacuous parity check: confirm the plan really computes a decision. + assertThat(eval(PROTO_BUNDLE, "authz/allow", Map.of("method", "POST", "user", "bob"))) + .containsExactly(Map.of("result", true)); + assertThat(eval(PROTO_BUNDLE, "authz/allow", Map.of("method", "GET", "user", "carol"))) + .containsExactly(Map.of("result", false)); + } + + @Test + void decodedPlanMatchesJsonDecodedPlan() throws IOException { + Policy protoPolicy; + try (InputStream in = Files.newInputStream(PROTO_BUNDLE.resolve("plan.pb"))) { + protoPolicy = new ProtoBundleReader().decodePlan(in); + } + + PolicyReader jsonReader = new JacksonPolicyReader(); + Policy jsonPolicy; + try (InputStream in = Files.newInputStream(JSON_BUNDLE.resolve("plan.json"))) { + jsonPolicy = jsonReader.read(in); + } + + // Plans and funcs carry the full statement/operand structure (locations, string indices, block + // nesting). These must match exactly — this is where a faithful decoder is proven. + assertThat(protoPolicy.getPlans()).isEqualTo(jsonPolicy.getPlans()); + assertThat(protoPolicy.getFuncs()).isEqualTo(jsonPolicy.getFuncs()); + + // The interned string constants must match too. (Builtin decls are intentionally absent from + // the proto form, so the full Static object is not compared.) + assertThat(protoPolicy.getStatic().getStrings()).isEqualTo(jsonPolicy.getStatic().getStrings()); + assertThat(protoPolicy.getStatic().getFiles()).isEqualTo(jsonPolicy.getStatic().getFiles()); + } + + @Test + void decodedManifestMatchesJsonManifest() { + Store store = new InMem(); + Bundle proto = new FileSystemBundleLoader("proto", PROTO_BUNDLE).load(store); + + assertThat(proto.manifest).containsEntry("revision", ""); + assertThat(proto.manifest).containsEntry("roots", List.of("")); + assertThat(((Number) proto.manifest.get("rego_version")).intValue()).isEqualTo(1); + } + + @Test + void rejectsMixedPlanAndManifestFormats(@TempDir Path dir) throws IOException { + // proto plan paired with a JSON manifest — OPA rejects this; so must we. + Files.copy(PROTO_BUNDLE.resolve("plan.pb"), dir.resolve("plan.pb")); + Files.copy(JSON_BUNDLE.resolve(".manifest"), dir.resolve(".manifest")); + + assertThatThrownBy(() -> new FileSystemBundleLoader("mixed", dir).load(new InMem())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mixes"); + } + + @Test + void rejectsBothPlanFormats(@TempDir Path dir) throws IOException { + Files.copy(PROTO_BUNDLE.resolve("plan.pb"), dir.resolve("plan.pb")); + Files.copy(JSON_BUNDLE.resolve("plan.json"), dir.resolve("plan.json")); + + assertThatThrownBy(() -> new FileSystemBundleLoader("dup", dir).load(new InMem())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ambiguous"); + } +} diff --git a/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/ProtoBundleReaderTest.java b/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/ProtoBundleReaderTest.java new file mode 100644 index 00000000..8c86f139 --- /dev/null +++ b/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/ProtoBundleReaderTest.java @@ -0,0 +1,45 @@ +package io.github.open_policy_agent.opa.proto; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import opa.ir.v1.AssignVarStmt; +import opa.ir.v1.Block; +import opa.ir.v1.Operand; +import opa.ir.v1.Plan; +import opa.ir.v1.Plans; +import opa.ir.v1.Policy; +import opa.ir.v1.Stmt; +import org.junit.jupiter.api.Test; + +/** Verifies decode-failure error handling for the proto plan reader. */ +class ProtoBundleReaderTest { + + @Test + void malformedPlanIsReportedAsIoException() { + // An AssignVarStmt whose source operand has no value set — a malformed plan. PlanMapper rejects + // it with IllegalArgumentException; decodePlan must surface that as IOException. + Policy proto = + Policy.newBuilder() + .setPlans( + Plans.newBuilder() + .addPlans( + Plan.newBuilder() + .setName("p") + .addBlocks( + Block.newBuilder() + .addStmts( + Stmt.newBuilder() + .setAssignVarStmt( + AssignVarStmt.newBuilder() + .setSource(Operand.newBuilder().build()) + .setTarget(0)))))) + .build(); + byte[] bytes = proto.toByteArray(); + + assertThatThrownBy(() -> new ProtoBundleReader().decodePlan(new ByteArrayInputStream(bytes))) + .isInstanceOf(IOException.class) + .hasMessageContaining("malformed proto plan"); + } +} diff --git a/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/StructConverterTest.java b/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/StructConverterTest.java new file mode 100644 index 00000000..08bc4051 --- /dev/null +++ b/opa-proto/src/test/java/io/github/open_policy_agent/opa/proto/StructConverterTest.java @@ -0,0 +1,77 @@ +package io.github.open_policy_agent.opa.proto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Verifies that protobuf Struct/Value conversion matches the Java types the JSON {@code .manifest} + * reader produces — in particular that integral numbers become Integer/Long (not Double), so + * metadata is identical across the two bundle wire formats. + */ +class StructConverterTest { + + @Test + void integralNumbersBecomeIntegerNotDouble() { + // Regression for the parity bug: proto stores all numbers as double, but the JSON reader yields + // Integer for values that fit an int. + assertThat(StructConverter.toObject(Value.newBuilder().setNumberValue(3).build())) + .isEqualTo(3) + .isInstanceOf(Integer.class); + } + + @Test + void largeIntegralNumbersBecomeLong() { + long big = 10_000_000_000L; // > Integer.MAX_VALUE + assertThat(StructConverter.toObject(Value.newBuilder().setNumberValue(big).build())) + .isEqualTo(big) + .isInstanceOf(Long.class); + } + + @Test + void fractionalNumbersStayDouble() { + assertThat(StructConverter.toObject(Value.newBuilder().setNumberValue(3.5).build())) + .isEqualTo(3.5) + .isInstanceOf(Double.class); + } + + @Test + void scalarsAndContainersConvert() { + assertThat(StructConverter.toObject(Value.newBuilder().setStringValue("x").build())).isEqualTo("x"); + assertThat(StructConverter.toObject(Value.newBuilder().setBoolValue(true).build())).isEqualTo(true); + assertThat( + StructConverter.toObject( + Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())) + .isNull(); + + Value list = + Value.newBuilder() + .setListValue( + ListValue.newBuilder() + .addValues(Value.newBuilder().setNumberValue(1).build()) + .addValues(Value.newBuilder().setStringValue("a").build())) + .build(); + assertThat(StructConverter.toObject(list)).isEqualTo(List.of(1, "a")); + } + + @Test + void nestedStructPreservesOrderAndTypes() { + Struct struct = + Struct.newBuilder() + .putFields("version", Value.newBuilder().setNumberValue(2).build()) + .putFields("ratio", Value.newBuilder().setNumberValue(0.25).build()) + .putFields("name", Value.newBuilder().setStringValue("bundle").build()) + .build(); + + Map result = StructConverter.toMap(struct); + + assertThat(result).containsEntry("version", 2).containsEntry("ratio", 0.25).containsEntry("name", "bundle"); + assertThat(result.get("version")).isInstanceOf(Integer.class); + } +} diff --git a/opa-proto/src/test/resources/bundles/authz-json/.manifest b/opa-proto/src/test/resources/bundles/authz-json/.manifest new file mode 100644 index 00000000..602dc133 --- /dev/null +++ b/opa-proto/src/test/resources/bundles/authz-json/.manifest @@ -0,0 +1 @@ +{"revision":"","roots":[""],"rego_version":1} diff --git a/opa-proto/src/test/resources/bundles/authz-json/data.json b/opa-proto/src/test/resources/bundles/authz-json/data.json new file mode 100644 index 00000000..4bb55cdd --- /dev/null +++ b/opa-proto/src/test/resources/bundles/authz-json/data.json @@ -0,0 +1 @@ +{"roles":{"alice":["reader"],"bob":["admin"]},"rules":[{"method":"GET","user":"alice"},{"method":"POST","user":"bob"}]} diff --git a/opa-proto/src/test/resources/bundles/authz-json/plan.json b/opa-proto/src/test/resources/bundles/authz-json/plan.json new file mode 100644 index 00000000..1ec8faf9 --- /dev/null +++ b/opa-proto/src/test/resources/bundles/authz-json/plan.json @@ -0,0 +1 @@ +{"static":{"strings":[{"value":"result"},{"value":"method"},{"value":"GET"},{"value":"user"},{"value":"roles"},{"value":"reader"},{"value":"admin"},{"value":"rules"},{"value":"1"},{"value":"rule_count"},{"value":"scores"},{"value":"total"}],"builtin_funcs":[{"name":"count","decl":{"args":[{"description":"the set/array/object/string to be counted","name":"collection","of":[{"type":"string"},{"dynamic":{"type":"any"},"type":"array"},{"dynamic":{"key":{"type":"any"},"value":{"type":"any"}},"type":"object"},{"of":{"type":"any"},"type":"set"}],"type":"any"}],"result":{"description":"the count of elements, key/val pairs, or characters, respectively.","name":"n","type":"number"},"type":"function"}},{"name":"internal.member_2","decl":{"args":[{"type":"any"},{"type":"any"}],"result":{"type":"boolean"},"type":"function"}},{"name":"plus","decl":{"args":[{"name":"x","type":"number"},{"name":"y","type":"number"}],"result":{"description":"the sum of `x` and `y`","name":"z","type":"number"},"type":"function"}},{"name":"sum","decl":{"args":[{"description":"the set or array of numbers to sum","name":"collection","of":[{"dynamic":{"type":"number"},"type":"array"},{"of":{"type":"number"},"type":"set"}],"type":"any"}],"result":{"description":"the sum of all elements","name":"n","type":"number"},"type":"function"}}],"files":[{"value":"policy.rego"}]},"plans":{"plans":[{"name":"authz/allow","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":2,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":3,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":3},"object":4,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":4,"file":0,"col":0,"row":0}}]}]},{"name":"authz/summary","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.summary","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":5,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":5},"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.authz.user_is_admin","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":15}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":3},"target":4,"file":0,"col":40,"row":15}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":4},"target":5,"file":0,"col":40,"row":15}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":4},"target":6,"file":0,"col":29,"row":15}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":6},"key":{"type":"local","value":5},"target":7,"file":0,"col":29,"row":15}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":29,"row":15}}]}],"file":0,"col":29,"row":15}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":29,"row":15}}]}],"file":0,"col":29,"row":15}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":7},"target":10,"file":0,"col":29,"row":15}},{"type":"CallStmt","stmt":{"func":"internal.member_2","args":[{"type":"string_index","value":6},{"type":"local","value":10}],"result":11,"file":0,"col":18,"row":15}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":11},"b":{"type":"bool","value":false},"file":0,"col":18,"row":15}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":15}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":15}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":15}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":15}}]}],"path":["g0","authz","user_is_admin"]},{"name":"g0.data.authz.allow","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":7}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":2,"row":8}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":4},"b":{"type":"string_index","value":2},"file":0,"col":2,"row":8}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":3},"target":5,"file":0,"col":26,"row":9}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":5},"target":6,"file":0,"col":26,"row":9}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":4},"target":7,"file":0,"col":2,"row":9}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":7},"key":{"type":"local","value":6},"target":8,"file":0,"col":2,"row":9}},{"type":"ScanStmt","stmt":{"source":8,"key":9,"value":10,"block":{"stmts":[{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":9},"target":11,"file":0,"col":2,"row":9}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":2,"row":9}}]}],"file":0,"col":2,"row":9}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":2,"row":9}}]}],"file":0,"col":2,"row":9}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":10},"target":14,"file":0,"col":2,"row":9}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":14},"b":{"type":"string_index","value":5},"file":0,"col":2,"row":10}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":7}}]},"file":0,"col":2,"row":9}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":7}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":7}}]},{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":13}},{"type":"CallStmt","stmt":{"func":"g0.data.authz.user_is_admin","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":4,"file":0,"col":10,"row":13}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":4},"b":{"type":"bool","value":false},"file":0,"col":10,"row":13}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":13}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":0,"col":9,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":false},"target":2,"file":0,"col":9,"row":5}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":9,"row":5}}]}],"path":["g0","authz","allow"]},{"name":"g0.data.authz.scores","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":18}},{"type":"MakeArrayStmt","stmt":{"capacity":0,"target":4,"file":0,"col":11,"row":18}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":7},"target":5,"file":0,"col":2,"row":19}},{"type":"ScanStmt","stmt":{"source":5,"key":6,"value":7,"block":{"stmts":[{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":6},"target":8,"file":0,"col":2,"row":19}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":2,"row":19}}]}],"file":0,"col":2,"row":19}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":2,"row":19}}]}],"file":0,"col":2,"row":19}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":7},"target":11,"file":0,"col":2,"row":19}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":11},"key":{"type":"string_index","value":3},"target":12,"file":0,"col":2,"row":20}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":3},"target":13,"file":0,"col":2,"row":20}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":12},"b":{"type":"local","value":13},"file":0,"col":2,"row":20}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":11},"key":{"type":"string_index","value":1},"target":14,"file":0,"col":13,"row":21}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":14},"target":15,"file":0,"col":13,"row":21}},{"type":"CallStmt","stmt":{"func":"count","args":[{"type":"local","value":15}],"result":16,"file":0,"col":7,"row":21}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":16},"target":17,"file":0,"col":7,"row":21}},{"type":"MakeNumberRefStmt","stmt":{"file":0,"col":7,"row":21,"index":8,"Index":8,"target":18}},{"type":"CallStmt","stmt":{"func":"plus","args":[{"type":"local","value":17},{"type":"local","value":18}],"result":19,"file":0,"col":7,"row":21}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":19},"target":20,"file":0,"col":7,"row":21}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":20},"target":21,"file":0,"col":2,"row":21}},{"type":"ArrayAppendStmt","stmt":{"value":{"type":"local","value":21},"array":4,"file":0,"col":11,"row":18}}]},"file":0,"col":2,"row":19}}]}],"file":0,"col":11,"row":18}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":4},"target":22,"file":0,"col":11,"row":18}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":22},"target":3,"file":0,"col":1,"row":18}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":18}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":18}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":18}}]}],"path":["g0","authz","scores"]},{"name":"g0.data.authz.summary","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":24}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":7},"target":6,"file":0,"col":22,"row":29}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":22,"row":29}}]}],"file":0,"col":22,"row":29}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":22,"row":29}}]}],"file":0,"col":22,"row":29}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":6},"target":7,"file":0,"col":22,"row":29}},{"type":"CallStmt","stmt":{"func":"count","args":[{"type":"local","value":7}],"result":8,"file":0,"col":16,"row":29}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":8},"target":9,"file":0,"col":16,"row":29}},{"type":"CallStmt","stmt":{"func":"g0.data.authz.scores","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":10,"file":0,"col":15,"row":28}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":10},"target":11,"file":0,"col":15,"row":28}},{"type":"CallStmt","stmt":{"func":"sum","args":[{"type":"local","value":11}],"result":12,"file":0,"col":11,"row":28}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":12},"target":13,"file":0,"col":11,"row":28}},{"type":"CallStmt","stmt":{"func":"g0.data.authz.user_is_admin","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":14,"file":0,"col":11,"row":26}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":14},"target":15,"file":0,"col":11,"row":26}},{"type":"CallStmt","stmt":{"func":"g0.data.authz.scores","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":16,"file":0,"col":12,"row":27}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":16},"target":17,"file":0,"col":12,"row":27}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":3},"target":18,"file":0,"col":10,"row":25}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":18},"target":19,"file":0,"col":10,"row":25}},{"type":"MakeObjectStmt","stmt":{"target":20,"file":0,"col":12,"row":24}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":6},"value":{"type":"local","value":15},"object":20,"file":0,"col":12,"row":24}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":9},"value":{"type":"local","value":9},"object":20,"file":0,"col":12,"row":24}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":10},"value":{"type":"local","value":17},"object":20,"file":0,"col":12,"row":24}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":11},"value":{"type":"local","value":13},"object":20,"file":0,"col":12,"row":24}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":3},"value":{"type":"local","value":19},"object":20,"file":0,"col":12,"row":24}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":20},"target":21,"file":0,"col":12,"row":24}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":21},"target":3,"file":0,"col":1,"row":24}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":24}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":24}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":24}}]}],"path":["g0","authz","summary"]}]}} \ No newline at end of file diff --git a/opa-proto/src/test/resources/bundles/authz-json/policy.rego b/opa-proto/src/test/resources/bundles/authz-json/policy.rego new file mode 100644 index 00000000..72f510cb --- /dev/null +++ b/opa-proto/src/test/resources/bundles/authz-json/policy.rego @@ -0,0 +1,30 @@ +package authz + +import rego.v1 + +default allow := false + +allow if { + input.method == "GET" + some role in data.roles[input.user] + role == "reader" +} + +allow if user_is_admin + +user_is_admin if "admin" in data.roles[input.user] + +# array construction + numeric ops to exercise MakeArray/ArrayAppend/MakeNumber*/arithmetic +scores := [n | + some rule in data.rules + rule.user == input.user + n := count(rule.method) + 1 +] + +summary := { + "user": input.user, + "admin": user_is_admin, + "scores": scores, + "total": sum(scores), + "rule_count": count(data.rules), +} diff --git a/opa-proto/src/test/resources/bundles/authz-proto/.manifest.pb b/opa-proto/src/test/resources/bundles/authz-proto/.manifest.pb new file mode 100644 index 00000000..787a4aa7 Binary files /dev/null and b/opa-proto/src/test/resources/bundles/authz-proto/.manifest.pb differ diff --git a/opa-proto/src/test/resources/bundles/authz-proto/data.json b/opa-proto/src/test/resources/bundles/authz-proto/data.json new file mode 100644 index 00000000..4bb55cdd --- /dev/null +++ b/opa-proto/src/test/resources/bundles/authz-proto/data.json @@ -0,0 +1 @@ +{"roles":{"alice":["reader"],"bob":["admin"]},"rules":[{"method":"GET","user":"alice"},{"method":"POST","user":"bob"}]} diff --git a/opa-proto/src/test/resources/bundles/authz-proto/plan.pb b/opa-proto/src/test/resources/bundles/authz-proto/plan.pb new file mode 100644 index 00000000..14489eae Binary files /dev/null and b/opa-proto/src/test/resources/bundles/authz-proto/plan.pb differ diff --git a/opa-proto/src/test/resources/bundles/authz-proto/policy.rego b/opa-proto/src/test/resources/bundles/authz-proto/policy.rego new file mode 100644 index 00000000..72f510cb --- /dev/null +++ b/opa-proto/src/test/resources/bundles/authz-proto/policy.rego @@ -0,0 +1,30 @@ +package authz + +import rego.v1 + +default allow := false + +allow if { + input.method == "GET" + some role in data.roles[input.user] + role == "reader" +} + +allow if user_is_admin + +user_is_admin if "admin" in data.roles[input.user] + +# array construction + numeric ops to exercise MakeArray/ArrayAppend/MakeNumber*/arithmetic +scores := [n | + some rule in data.rules + rule.user == input.user + n := count(rule.method) + 1 +] + +summary := { + "user": input.user, + "admin": user_is_admin, + "scores": scores, + "total": sum(scores), + "rule_count": count(data.rules), +} diff --git a/opa-services/build.gradle.kts b/opa-services/build.gradle.kts index 91bb37fa..1dd962f5 100644 --- a/opa-services/build.gradle.kts +++ b/opa-services/build.gradle.kts @@ -24,6 +24,8 @@ dependencies { testImplementation("org.assertj:assertj-core:3.27.7") testImplementation("org.mockito:mockito-core:5.23.0") testImplementation(project(":opa-jackson")) + // opa-proto provides the ProtoBundleDecoder SPI used to read proto-format bundles. + testImplementation(project(":opa-proto")) } tasks.test { diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/bundle/TarballBundleLoader.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/bundle/TarballBundleLoader.java index 0baea320..ff96c7ea 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/bundle/TarballBundleLoader.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/bundle/TarballBundleLoader.java @@ -71,29 +71,43 @@ public Bundle load(Store store) { private Bundle createBundleFromStream(String id, InputStream in, Store store) { BundleAssembler assembler = new BundleAssembler(); + // Plan and manifest bytes are buffered rather than loaded inline: tar entries arrive in + // arbitrary order, and the mixed-format check needs to see the whole file set before deciding + // which format to decode. Data and Rego files carry no format ambiguity, so they load inline. + byte[] planJson = null; + byte[] planProto = null; + byte[] manifestJson = null; + byte[] manifestProto = null; try (GZIPInputStream gzipIn = new GZIPInputStream(in); TarArchiveInputStream tarIn = new TarArchiveInputStream(gzipIn, true)) { org.apache.commons.compress.archivers.tar.TarArchiveEntry entry; long totalDecompressedBytes = 0; while ((entry = tarIn.getNextTarEntry()) != null) { + if (entry.isDirectory()) { + continue; + } String entryName = entry.getName().startsWith("/") ? entry.getName().substring(1) : entry.getName(); - if (!entry.isDirectory() && entryName.equals("plan.json")) { - byte[] entryBytes = readWithLimit(tarIn, maxDecompressedBytes - totalDecompressedBytes); - totalDecompressedBytes += entryBytes.length; - assembler.loadPlan(new ByteArrayInputStream(entryBytes)); - } else if (!entry.isDirectory() - && (entryName.equals("data.json") || entryName.endsWith("/data.json"))) { + if (entryName.equals(BundleFormat.PLAN_JSON)) { + planJson = readWithLimit(tarIn, maxDecompressedBytes - totalDecompressedBytes); + totalDecompressedBytes += planJson.length; + } else if (entryName.equals(BundleFormat.PLAN_PROTO)) { + planProto = readWithLimit(tarIn, maxDecompressedBytes - totalDecompressedBytes); + totalDecompressedBytes += planProto.length; + } else if (entryName.equals(BundleFormat.MANIFEST_PROTO)) { + // Check .manifest.pb before .manifest so the longer suffix wins. + manifestProto = readWithLimit(tarIn, maxDecompressedBytes - totalDecompressedBytes); + totalDecompressedBytes += manifestProto.length; + } else if (entryName.equals(BundleFormat.MANIFEST_JSON)) { + manifestJson = readWithLimit(tarIn, maxDecompressedBytes - totalDecompressedBytes); + totalDecompressedBytes += manifestJson.length; + } else if (entryName.equals("data.json") || entryName.endsWith("/data.json")) { byte[] entryBytes = readWithLimit(tarIn, maxDecompressedBytes - totalDecompressedBytes); totalDecompressedBytes += entryBytes.length; int lastSlash = entryName.lastIndexOf('/'); String dataPath = lastSlash < 0 ? "" : entryName.substring(0, lastSlash); assembler.loadData(dataPath, new ByteArrayInputStream(entryBytes)); - } else if (!entry.isDirectory() && entryName.equals(".manifest")) { - byte[] entryBytes = readWithLimit(tarIn, maxDecompressedBytes - totalDecompressedBytes); - totalDecompressedBytes += entryBytes.length; - assembler.loadManifest(new ByteArrayInputStream(entryBytes)); - } else if (!entry.isDirectory() && entryName.endsWith(".rego")) { + } else if (entryName.endsWith(".rego")) { byte[] entryBytes = readWithLimit(tarIn, maxDecompressedBytes - totalDecompressedBytes); totalDecompressedBytes += entryBytes.length; assembler.addRego(entryName, new String(entryBytes)); @@ -105,12 +119,24 @@ private Bundle createBundleFromStream(String id, InputStream in, Store store) { } } } + + assembler.loadPlanAndManifest( + bytesSource(planJson), + bytesSource(planProto), + bytesSource(manifestJson), + bytesSource(manifestProto)); + return assembler.finish(id, store); } catch (IOException e) { throw new IllegalArgumentException("Error extracting bundle: " + e.getMessage(), e); } } + /** An {@link InputStreamSource} over the buffered bytes, or {@code null} when the entry is absent. */ + private static InputStreamSource bytesSource(byte[] bytes) { + return bytes != null ? () -> new ByteArrayInputStream(bytes) : null; + } + private static byte[] readWithLimit(InputStream in, long remaining) throws IOException { if (remaining <= 0) { throw new IOException( diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/bundle/TarballProtoBundleLoaderTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/bundle/TarballProtoBundleLoaderTest.java new file mode 100644 index 00000000..8351b557 --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/bundle/TarballProtoBundleLoaderTest.java @@ -0,0 +1,108 @@ +package io.github.open_policy_agent.opa.bundle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.open_policy_agent.opa.storage.InMem; +import io.github.open_policy_agent.opa.storage.Store; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Objects; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.junit.jupiter.api.Test; + +/** + * Proto-format tarball bundles ({@code plan.pb} / {@code .manifest.pb}) load through {@link + * TarballBundleLoader} exactly like their JSON counterparts. Fixtures are real {@code opa build} + * output at the pinned OPA commit, differing only in wire format. + */ +class TarballProtoBundleLoaderTest { + + private static Path fixture(String name) { + return Paths.get( + Objects.requireNonNull( + TarballProtoBundleLoaderTest.class + .getClassLoader() + .getResource("bundles/" + name)) + .getPath()); + } + + @Test + void load_protoTarball_populatesPolicyAndManifest() throws IOException { + byte[] tarball = Files.readAllBytes(fixture("bundle-proto.tar.gz")); + + Store store = new InMem(); + Bundle bundle = new TarballBundleLoader("proto", tarball).load(store); + + assertNotNull(bundle.irPolicy, "proto plan.pb should decode into an IR policy"); + assertNotNull(bundle.irPolicy.getPlans().getPlanByName("authz/allow")); + assertEquals(1, ((Number) bundle.manifest.get("rego_version")).intValue()); + } + + @Test + void load_protoAndJsonTarballs_yieldEquivalentPolicies() throws IOException { + Bundle proto = + new TarballBundleLoader("proto", Files.readAllBytes(fixture("bundle-proto.tar.gz"))) + .load(new InMem()); + Bundle json = + new TarballBundleLoader("json", Files.readAllBytes(fixture("bundle-json.tar.gz"))) + .load(new InMem()); + + // Plans/funcs carry the full statement structure; they must match across formats. + assertEquals(json.irPolicy.getPlans(), proto.irPolicy.getPlans()); + assertEquals(json.irPolicy.getFuncs(), proto.irPolicy.getFuncs()); + } + + @Test + void load_mixedFormatTarball_rejected() throws IOException { + // proto plan bytes paired with a JSON manifest — OPA rejects this, so must the loader. + byte[] planPb = + Files.readAllBytes(fixture("bundle-proto.tar.gz")); // any bytes; only the entry name matters + byte[] tarball = + new ByteArrayBinaryTarball() + .add("plan.pb", planPb) + .add(".manifest", "{\"revision\":\"\"}".getBytes()) + .build(); + + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> new TarballBundleLoader("mixed", tarball).load(new InMem())); + assertTrue(ex.getMessage().contains("mixes"), ex.getMessage()); + } + + /** Minimal gzip+tar builder that accepts binary entry content (unlike the JSON-only helper). */ + private static final class ByteArrayBinaryTarball { + private final ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + private final java.util.zip.GZIPOutputStream gzipOut; + private final TarArchiveOutputStream tarOut; + + ByteArrayBinaryTarball() throws IOException { + gzipOut = new java.util.zip.GZIPOutputStream(byteOut); + tarOut = new TarArchiveOutputStream(gzipOut); + tarOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU); + } + + ByteArrayBinaryTarball add(String name, byte[] content) throws IOException { + TarArchiveEntry entry = new TarArchiveEntry(name); + entry.setSize(content.length); + tarOut.putArchiveEntry(entry); + tarOut.write(content); + tarOut.closeArchiveEntry(); + return this; + } + + byte[] build() throws IOException { + tarOut.finish(); + tarOut.close(); + gzipOut.close(); + return byteOut.toByteArray(); + } + } +} diff --git a/opa-services/src/test/resources/bundles/bundle-json.tar.gz b/opa-services/src/test/resources/bundles/bundle-json.tar.gz new file mode 100644 index 00000000..3de6b050 Binary files /dev/null and b/opa-services/src/test/resources/bundles/bundle-json.tar.gz differ diff --git a/opa-services/src/test/resources/bundles/bundle-proto.tar.gz b/opa-services/src/test/resources/bundles/bundle-proto.tar.gz new file mode 100644 index 00000000..fc5616b2 Binary files /dev/null and b/opa-services/src/test/resources/bundles/bundle-proto.tar.gz differ diff --git a/settings.gradle.kts b/settings.gradle.kts index 16254e0d..19e6b143 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ include("cli") include("opa-evaluator") include("opa-jackson") include("opa-gson") +include("opa-proto") include("opa-services") include("opa-builtins") include("opa-builtins:opa-builtins-time") diff --git a/tools/generate-compliance-tests/go.mod b/tools/generate-compliance-tests/go.mod index 2cbe0542..fb1ea7fe 100644 --- a/tools/generate-compliance-tests/go.mod +++ b/tools/generate-compliance-tests/go.mod @@ -2,7 +2,7 @@ module generate-compliance-tests go 1.26 -require github.com/open-policy-agent/opa v1.18.2 +require github.com/open-policy-agent/opa v1.18.2-0.20260706175033-f6092b9ce472 require ( github.com/agnivade/levenshtein v1.2.1 // indirect @@ -23,7 +23,7 @@ require ( github.com/sirupsen/logrus v1.9.4 // indirect github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/valyala/fastjson v1.6.10 // indirect - github.com/vektah/gqlparser/v2 v2.5.34 // indirect + github.com/vektah/gqlparser/v2 v2.5.35 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect @@ -32,5 +32,6 @@ require ( golang.org/x/crypto v0.52.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.45.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/tools/generate-compliance-tests/go.sum b/tools/generate-compliance-tests/go.sum index 1f7574b6..2265311d 100644 --- a/tools/generate-compliance-tests/go.sum +++ b/tools/generate-compliance-tests/go.sum @@ -4,8 +4,6 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bytecodealliance/wasmtime-go/v44 v44.0.0 h1:WRZXnLPIer/TWs5aYPaMlmVcOlzmR6Ur6wjLRIQOhTQ= -github.com/bytecodealliance/wasmtime-go/v44 v44.0.0/go.mod h1:GP93piU+39CoFVCQ5xfHrPOUtL0APlMnkbblJ2d3YY0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -63,8 +61,8 @@ github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/open-policy-agent/opa v1.18.2 h1:VBiLJpioTuk7XTW1JoQi4ILo+FVxD2/8uD8iP9/OcxY= -github.com/open-policy-agent/opa v1.18.2/go.mod h1:9GY+hER4ZEXtxPlMjftVbqJJY9xLtCD3Q0oufRCfAKo= +github.com/open-policy-agent/opa v1.18.2-0.20260706175033-f6092b9ce472 h1:JS2SxlGKPyHF3GZSZS4dq8vPKzWEZ1Y+76TnOsVsjKc= +github.com/open-policy-agent/opa v1.18.2-0.20260706175033-f6092b9ce472/go.mod h1:X19ai617Q79YABRkgYZ/7Zr+9XO2/Ykqp/d3jBRdgoU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -89,10 +87,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= -github.com/vektah/gqlparser/v2 v2.5.34 h1:MEea5P0qhdcqfBL45ghKE+qr9laidVHTMHjav5h7ckk= -github.com/vektah/gqlparser/v2 v2.5.34/go.mod h1:mFdHLGCio7OGX1fby9ZjTW6FN+qxgmbnBcRIeeScE5s= +github.com/vektah/gqlparser/v2 v2.5.35 h1:LEr/wXnTKkOqNn+4tNClYclksXN2781VoBFzzFW51Dk= +github.com/vektah/gqlparser/v2 v2.5.35/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=