From f5a526054993602ac568f21b6ad72c4406804a25 Mon Sep 17 00:00:00 2001 From: Dang Zitou Date: Thu, 13 Aug 2026 03:21:53 +0800 Subject: [PATCH 1/2] feat(evaluator): implement graph builtins Port graph reachability and path traversal semantics from OPA so JVM evaluations can execute policies that use graph.reachable and graph.reachable_paths. Fixes: #132 Signed-off-by: Dang Zitou --- .../opa/ast/builtin/BuiltinRegistry.java | 2 + .../opa/ast/builtin/impls/GraphBuiltins.java | 178 ++++++++++++++++++ .../ast/builtin/impls/GraphBuiltinsTest.java | 72 +++++++ .../opa/ir/ComplianceTest.java | 36 +++- .../compliance/known-missing-builtins.txt | 3 - 5 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltins.java create mode 100644 opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltinsTest.java diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/BuiltinRegistry.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/BuiltinRegistry.java index 191fcaea..d8830d5d 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/BuiltinRegistry.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/BuiltinRegistry.java @@ -14,6 +14,7 @@ import io.github.open_policy_agent.opa.ast.builtin.impls.CastBuiltins; import io.github.open_policy_agent.opa.ast.builtin.impls.ComparisonBuiltins; import io.github.open_policy_agent.opa.ast.builtin.impls.EncodingBuiltins; +import io.github.open_policy_agent.opa.ast.builtin.impls.GraphBuiltins; import io.github.open_policy_agent.opa.ast.builtin.impls.HexBuiltins; import io.github.open_policy_agent.opa.ast.builtin.impls.ObjectBuiltins; import io.github.open_policy_agent.opa.ast.builtin.impls.OpaBuiltins; @@ -40,6 +41,7 @@ public class BuiltinRegistry { ArithmeticBuiltins.class, ArrayBuiltins.class, EncodingBuiltins.class, + GraphBuiltins.class, HexBuiltins.class, UriBuiltins.class, StringBuiltins.class, diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltins.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltins.java new file mode 100644 index 00000000..d15d3bc5 --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltins.java @@ -0,0 +1,178 @@ +package io.github.open_policy_agent.opa.ast.builtin.impls; + +import static io.github.open_policy_agent.opa.ast.builtin.impls.utils.ArgHelper.getArg; + +import io.github.open_policy_agent.opa.ast.builtin.OpaBuiltin; +import io.github.open_policy_agent.opa.ast.builtin.OpaDynamic; +import io.github.open_policy_agent.opa.ast.builtin.OpaType; +import io.github.open_policy_agent.opa.ast.builtin.OpaVal; +import io.github.open_policy_agent.opa.ast.types.RegoArray; +import io.github.open_policy_agent.opa.ast.types.RegoCollection; +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import io.github.open_policy_agent.opa.ast.types.RegoSet; +import io.github.open_policy_agent.opa.ast.types.RegoValue; +import io.github.open_policy_agent.opa.rego.EvaluationContext; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; + +public class GraphBuiltins { + + public static Map> builtins() { + GraphBuiltins instance = new GraphBuiltins(); + return Map.of( + "graph.reachable", instance::reachable, + "graph.reachable_paths", instance::reachablePaths); + } + + @OpaBuiltin( + name = "graph.reachable", + description = + "Computes the set of reachable nodes in the graph from a set of starting nodes.", + categories = {"graphs"}, + args = { + @OpaType( + type = "object", + name = "graph", + description = "object containing a set or array of neighboring vertices", + dynamic = @OpaDynamic(keyType = "any", valueType = "any")), + @OpaType( + name = "initial", + description = "set or array of root vertices", + of = {@OpaVal("set"), @OpaVal("array")}) + }, + result = + @OpaType( + type = "set", + name = "output", + description = "vertices reachable from the initial vertices in the directed graph", + dynamic = @OpaDynamic(type = "any"))) + public RegoSet reachable(EvaluationContext ctx, RegoValue[] args) { + RegoObject graph = getArg(args, 0, RegoObject.class); + RegoCollection initial = getArg(args, 1, RegoCollection.class); + Deque queue = new ArrayDeque<>(initial.valueStream().toList()); + RegoSet reached = new RegoSet(false); + + while (!queue.isEmpty()) { + RegoValue node = queue.removeFirst(); + RegoValue edges = graph.getProperty(node); + if (edges == null) { + continue; + } + for (RegoValue neighbor : collectionValues(edges)) { + if (!reached.contains(neighbor)) { + queue.addLast(neighbor); + } + } + reached.addValue(node); + } + + return new RegoSet(ctx.sortSets, reached.getValue()); + } + + @OpaBuiltin( + name = "graph.reachable_paths", + description = + "Computes the set of reachable paths in the graph from a set of starting nodes.", + categories = {"graphs"}, + args = { + @OpaType( + type = "object", + name = "graph", + description = "object containing a set or array of neighboring vertices", + dynamic = @OpaDynamic(keyType = "any", valueType = "any")), + @OpaType( + name = "initial", + description = "set or array of root vertices", + of = {@OpaVal("set"), @OpaVal("array")}) + }, + result = + @OpaType( + type = "set", + name = "output", + description = "paths reachable from the initial vertices in the directed graph", + dynamic = @OpaDynamic(type = "array"))) + public RegoSet reachablePaths(EvaluationContext ctx, RegoValue[] args) { + RegoObject graph = getArg(args, 0, RegoObject.class); + RegoCollection initial = getArg(args, 1, RegoCollection.class); + RegoSet paths = new RegoSet(false); + + initial + .valueStream() + .forEach( + node -> { + RegoValue edges = graph.getProperty(node); + if (edges == null) { + return; + } + List neighbors = collectionValues(edges); + if (neighbors.isEmpty()) { + paths.addValue(new RegoArray(List.of(node))); + return; + } + for (RegoValue neighbor : neighbors) { + buildPaths( + graph, + neighbor, + new ArrayList<>(List.of(node)), + paths, + new HashSet<>(Set.of(node))); + } + }); + + if (!ctx.sortSets) { + return paths; + } + List sortedPaths = new ArrayList<>(paths.getValue()); + sortedPaths.sort(RegoValue::compareTo); + return new RegoSet(false, new LinkedHashSet<>(sortedPaths)); + } + + private static void buildPaths( + RegoObject graph, + RegoValue root, + List path, + RegoSet paths, + Set reached) { + RegoValue edges = graph.getProperty(root); + if (edges == null) { + paths.addValue(new RegoArray(path)); + return; + } + + path.add(root); + List neighbors = collectionValues(edges); + if (neighbors.isEmpty()) { + paths.addValue(new RegoArray(path)); + return; + } + + Set nextReached = new HashSet<>(reached); + nextReached.add(root); + for (RegoValue neighbor : neighbors) { + if (nextReached.contains(neighbor)) { + paths.addValue(new RegoArray(path)); + } else { + buildPaths( + graph, + neighbor, + new ArrayList<>(path), + paths, + new HashSet<>(nextReached)); + } + } + } + + private static List collectionValues(RegoValue value) { + if (value instanceof RegoCollection) { + return ((RegoCollection) value).valueStream().toList(); + } + return List.of(); + } +} diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltinsTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltinsTest.java new file mode 100644 index 00000000..e7724480 --- /dev/null +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltinsTest.java @@ -0,0 +1,72 @@ +package io.github.open_policy_agent.opa.ast.builtin.impls; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.github.open_policy_agent.opa.ast.builtin.BuiltinRegistry; +import io.github.open_policy_agent.opa.ast.types.RegoArray; +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import io.github.open_policy_agent.opa.ast.types.RegoSet; +import io.github.open_policy_agent.opa.ast.types.RegoString; +import io.github.open_policy_agent.opa.ast.types.RegoValue; +import io.github.open_policy_agent.opa.rego.EvaluationContext; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import org.junit.jupiter.api.Test; + +class GraphBuiltinsTest { + + private static final EvaluationContext CONTEXT = new EvaluationContext.Builder().build(); + + @Test + void reachableTraversesArrayAndSetEdges() { + RegoString a = new RegoString("a"); + RegoString b = new RegoString("b"); + RegoString c = new RegoString("c"); + RegoString d = new RegoString("d"); + RegoObject graph = + new RegoObject( + Map.of( + a, new RegoArray(List.of(b, c)), + b, setOf(d), + c, new RegoArray(List.of(d)), + d, setOf())); + + RegoValue result = call("graph.reachable", graph, setOf(a, new RegoString("missing"))); + + assertEquals(setOf(a, b, c, d), result); + } + + @Test + void reachablePathsStopsAtCyclesAndMissingNodes() { + RegoString a = new RegoString("a"); + RegoString b = new RegoString("b"); + RegoString missing = new RegoString("missing"); + RegoObject graph = + new RegoObject( + Map.of( + a, new RegoArray(List.of(b, missing)), + b, setOf(a))); + + RegoValue result = call("graph.reachable_paths", graph, setOf(a)); + + assertEquals( + setOf(new RegoArray(List.of(a, b)), new RegoArray(List.of(a))), result); + } + + private static RegoValue call(String name, RegoValue... args) { + BiFunction builtin = + BuiltinRegistry.AllBuiltIns.get(name); + assertNotNull(builtin, name + " should be registered"); + return builtin.apply(CONTEXT, args); + } + + private static RegoSet setOf(RegoValue... values) { + RegoSet set = new RegoSet(false); + for (RegoValue value : values) { + set.addValue(value); + } + return set; + } +} diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ComplianceTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ComplianceTest.java index 285359d1..6cd2422b 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ComplianceTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ComplianceTest.java @@ -286,12 +286,14 @@ private static String decodeBase64(String encoded) { /** * Converts Rego set syntax to array syntax for JSON parsing. Transforms {{1}} to [[1]], {1, 2} to - * [1, 2], etc. Leaves JSON objects with key:value pairs unchanged. + * [1, 2], and set() to []. Leaves JSON objects unchanged and removes Rego-compatible trailing + * commas before JSON parsing. */ private static String convertRegoSetsToArrays(String regoStr) { StringBuilder result = new StringBuilder(); boolean inString = false; char prevChar = '\0'; + int charactersToSkip = 0; // Track brace positions and their content to determine if they're sets or objects Stack braceStarts = new Stack<>(); @@ -300,21 +302,33 @@ private static String convertRegoSetsToArrays(String regoStr) { for (int i = 0; i < regoStr.length(); i++) { char c = regoStr.charAt(i); + if (charactersToSkip > 0) { + charactersToSkip--; + prevChar = c; + continue; + } + // Track if we're inside a string if (c == '"' && prevChar != '\\') { inString = !inString; } if (!inString) { - if (c == '{') { + if (regoStr.startsWith("set()", i) + && (i == 0 || !Character.isJavaIdentifierPart(regoStr.charAt(i - 1)))) { + result.append("[]"); + charactersToSkip = 4; + } else if (c == '{') { braceStarts.push(result.length()); isObject.push(false); // Assume set until we find a ':' result.append('['); // Tentatively convert to array } else if (c == '}') { if (!braceStarts.isEmpty()) { + removeTrailingComma(result); boolean wasObject = isObject.pop(); int startPos = braceStarts.pop(); - if (wasObject) { + boolean wasEmpty = result.substring(startPos + 1).trim().isEmpty(); + if (wasObject || wasEmpty) { // This was actually an object, convert back result.setCharAt(startPos, '{'); result.append('}'); @@ -325,6 +339,9 @@ private static String convertRegoSetsToArrays(String regoStr) { } else { result.append(c); } + } else if (c == ']') { + removeTrailingComma(result); + result.append(c); } else if (c == ':' && !isObject.isEmpty()) { // Found a colon, mark current brace level as object isObject.set(isObject.size() - 1, true); @@ -342,6 +359,19 @@ private static String convertRegoSetsToArrays(String regoStr) { return result.toString(); } + private static void removeTrailingComma(StringBuilder value) { + for (int i = value.length() - 1; i >= 0; i--) { + char c = value.charAt(i); + if (c == ',') { + value.deleteCharAt(i); + return; + } + if (!Character.isWhitespace(c)) { + return; + } + } + } + private static RegoValue jsonNodeToRegoValue(JsonNode node, ObjectMapper mapper) throws IOException { return jsonNodeToRegoValue(node, mapper, false); diff --git a/opa-evaluator/src/test/resources/compliance/known-missing-builtins.txt b/opa-evaluator/src/test/resources/compliance/known-missing-builtins.txt index 7362d8ca..0e33fd8c 100644 --- a/opa-evaluator/src/test/resources/compliance/known-missing-builtins.txt +++ b/opa-evaluator/src/test/resources/compliance/known-missing-builtins.txt @@ -40,9 +40,6 @@ crypto.x509.parse_rsa_private_key glob.match glob.quote_meta -graph.reachable -graph.reachable_paths - graphql.is_valid graphql.parse graphql.parse_and_verify From f96cf6a1e031bd1db49be3ea746262a42a9f3699 Mon Sep 17 00:00:00 2001 From: Dang Zitou Date: Fri, 14 Aug 2026 22:57:20 +0800 Subject: [PATCH 2/2] fix(evaluator): avoid recursive graph path traversal Signed-off-by: Dang Zitou --- opa-builtins/README.md | 4 +- .../opa/ast/builtin/impls/GraphBuiltins.java | 102 ++++++++++-------- .../ast/builtin/impls/GraphBuiltinsTest.java | 21 ++++ 3 files changed, 82 insertions(+), 45 deletions(-) diff --git a/opa-builtins/README.md b/opa-builtins/README.md index dabbcb53..347024d0 100644 --- a/opa-builtins/README.md +++ b/opa-builtins/README.md @@ -94,6 +94,8 @@ Note: String builtins (`contains`, `concat`, `split`, `sprintf`, `trim`, etc.) a | `net.cidr_contains`, `net.cidr_contains_matches` | Yes | | `net.cidr_intersects`, `net.cidr_expand`, `net.cidr_merge` | Yes | | `net.cidr_is_valid`, `net.lookup_ip_addr` | Yes | +| **Graphs** (opa-evaluator) | | +| `graph.reachable`, `graph.reachable_paths` | Yes | | **Semantic Versions** (opa-builtins-semver) | | | `semver.compare`, `semver.is_valid` | Yes | | **Providers** (opa-builtins-providers-aws) | | @@ -103,7 +105,7 @@ Note: String builtins (`contains`, `concat`, `split`, `sprintf`, `trim`, etc.) a | **Comparison** | | | `equal`, `neq`, `lt`, `lte`, `gt`, `gte` | Yes | | **Not Yet Implemented** | | -| `bits.*`, `graph.*`, `units.*`, `http.send` | No | +| `bits.*`, `units.*`, `http.send` | No | | `uuid.*`, `graphql.*`, `rego.*` | No | ## Adding Custom Builtins diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltins.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltins.java index d15d3bc5..df21ba72 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltins.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltins.java @@ -103,28 +103,9 @@ public RegoSet reachablePaths(EvaluationContext ctx, RegoValue[] args) { RegoCollection initial = getArg(args, 1, RegoCollection.class); RegoSet paths = new RegoSet(false); - initial - .valueStream() - .forEach( - node -> { - RegoValue edges = graph.getProperty(node); - if (edges == null) { - return; - } - List neighbors = collectionValues(edges); - if (neighbors.isEmpty()) { - paths.addValue(new RegoArray(List.of(node))); - return; - } - for (RegoValue neighbor : neighbors) { - buildPaths( - graph, - neighbor, - new ArrayList<>(List.of(node)), - paths, - new HashSet<>(Set.of(node))); - } - }); + for (RegoValue node : initial.valueStream().toList()) { + collectPaths(graph, node, paths); + } if (!ctx.sortSets) { return paths; @@ -134,38 +115,71 @@ public RegoSet reachablePaths(EvaluationContext ctx, RegoValue[] args) { return new RegoSet(false, new LinkedHashSet<>(sortedPaths)); } - private static void buildPaths( - RegoObject graph, - RegoValue root, - List path, - RegoSet paths, - Set reached) { - RegoValue edges = graph.getProperty(root); + private static void collectPaths(RegoObject graph, RegoValue initial, RegoSet paths) { + RegoValue edges = graph.getProperty(initial); if (edges == null) { - paths.addValue(new RegoArray(path)); return; } - path.add(root); List neighbors = collectionValues(edges); if (neighbors.isEmpty()) { - paths.addValue(new RegoArray(path)); + addPath(paths, List.of(initial)); return; } - Set nextReached = new HashSet<>(reached); - nextReached.add(root); - for (RegoValue neighbor : neighbors) { - if (nextReached.contains(neighbor)) { - paths.addValue(new RegoArray(path)); - } else { - buildPaths( - graph, - neighbor, - new ArrayList<>(path), - paths, - new HashSet<>(nextReached)); + List path = new ArrayList<>(List.of(initial)); + Set reached = new HashSet<>(Set.of(initial)); + Deque pending = new ArrayDeque<>(); + pending.addLast(new PathFrame(null, neighbors)); + + while (!pending.isEmpty()) { + PathFrame frame = pending.peekLast(); + if (frame.nextNeighborIndex == frame.neighbors.size()) { + pending.removeLast(); + if (frame.node != null) { + path.remove(path.size() - 1); + reached.remove(frame.node); + } + continue; + } + + RegoValue neighbor = frame.neighbors.get(frame.nextNeighborIndex++); + if (reached.contains(neighbor)) { + addPath(paths, path); + continue; + } + + RegoValue neighborEdges = graph.getProperty(neighbor); + if (neighborEdges == null) { + addPath(paths, path); + continue; } + + path.add(neighbor); + reached.add(neighbor); + List neighborValues = collectionValues(neighborEdges); + if (neighborValues.isEmpty()) { + addPath(paths, path); + path.remove(path.size() - 1); + reached.remove(neighbor); + continue; + } + pending.addLast(new PathFrame(neighbor, neighborValues)); + } + } + + private static void addPath(RegoSet paths, List path) { + paths.addValue(new RegoArray(new ArrayList<>(path))); + } + + private static final class PathFrame { + private final RegoValue node; + private final List neighbors; + private int nextNeighborIndex; + + private PathFrame(RegoValue node, List neighbors) { + this.node = node; + this.neighbors = neighbors; } } diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltinsTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltinsTest.java index e7724480..27b6ca3f 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltinsTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/GraphBuiltinsTest.java @@ -10,6 +10,8 @@ import io.github.open_policy_agent.opa.ast.types.RegoString; import io.github.open_policy_agent.opa.ast.types.RegoValue; import io.github.open_policy_agent.opa.rego.EvaluationContext; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.BiFunction; @@ -55,6 +57,25 @@ a, new RegoArray(List.of(b, missing)), setOf(new RegoArray(List.of(a, b)), new RegoArray(List.of(a))), result); } + @Test + void reachablePathsHandlesDeepGraphsWithoutRecursion() { + int nodeCount = 10_000; + List nodes = new ArrayList<>(nodeCount); + Map graph = new LinkedHashMap<>(); + + for (int index = 0; index < nodeCount; index++) { + nodes.add(new RegoString("node-" + index)); + } + for (int index = 0; index < nodeCount - 1; index++) { + graph.put(nodes.get(index), new RegoArray(List.of(nodes.get(index + 1)))); + } + graph.put(nodes.get(nodeCount - 1), setOf()); + + RegoValue result = call("graph.reachable_paths", new RegoObject(graph), setOf(nodes.get(0))); + + assertEquals(setOf(new RegoArray(nodes)), result); + } + private static RegoValue call(String name, RegoValue... args) { BiFunction builtin = BuiltinRegistry.AllBuiltIns.get(name);