From 34351ebec45d5bd4921e6b534a8d214b1904d716 Mon Sep 17 00:00:00 2001 From: Shashank-MP Date: Mon, 13 Jul 2026 22:22:39 -0500 Subject: [PATCH 1/3] feat: implement uri.is_valid builtin Signed-off-by: Shashank-MP --- .../opa/ast/builtin/impls/UriBuiltins.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java new file mode 100644 index 00000000..6fa303cc --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java @@ -0,0 +1,43 @@ +package io.github.open_policy_agent.opa.ast.builtin.impls; + +import io.github.open_policy_agent.opa.ast.builtin.OpaBuiltin; +import io.github.open_policy_agent.opa.ast.builtin.OpaType; +import io.github.open_policy_agent.opa.ast.types.RegoBoolean; +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.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.function.BiFunction; + +public class UriBuiltins { + public static Map> builtins() { + UriBuiltins instance = new UriBuiltins(); + + return Map.of("uri.is_valid", instance::isValid); + } + + @OpaBuiltin( + name = "uri.is_valid", + description = "Returns true if the input string is a valid URI", + categories = {"encoding"}, + args = {@OpaType(name = "x", description = "URI string to validate")}, + result = @OpaType(name = "result", description = "true if x is a valid URI")) + public RegoBoolean isValid(EvaluationContext ctx, RegoValue[] args) { + if (!(args[0] instanceof RegoString input)) { + return RegoBoolean.FALSE; + } + + if (input.getValue().isEmpty()) { + return RegoBoolean.FALSE; + } + + try { + new URI(input.getValue()); + return RegoBoolean.TRUE; + } catch (URISyntaxException e) { + return RegoBoolean.FALSE; + } + } +} From 686becf9d787acda417024ef80e3bac2f45607d3 Mon Sep 17 00:00:00 2001 From: Shashank-MP Date: Mon, 13 Jul 2026 22:50:26 -0500 Subject: [PATCH 2/3] feat: implement uri.parse builtin Signed-off-by: Shashank-MP --- .../opa/ast/builtin/BuiltinRegistry.java | 1 + .../opa/ast/builtin/impls/UriBuiltins.java | 67 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) 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 17a055b2..0fff5cdb 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 @@ -27,6 +27,7 @@ public class BuiltinRegistry { ArrayBuiltins.class, EncodingBuiltins.class, HexBuiltins.class, + UriBuiltins.class, StringBuiltins.class, PrintBuiltins.class, }; diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java index 6fa303cc..a9fba51d 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java @@ -1,21 +1,28 @@ package io.github.open_policy_agent.opa.ast.builtin.impls; +import io.github.open_policy_agent.opa.ast.builtin.BuiltinError; import io.github.open_policy_agent.opa.ast.builtin.OpaBuiltin; import io.github.open_policy_agent.opa.ast.builtin.OpaType; import io.github.open_policy_agent.opa.ast.types.RegoBoolean; +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.ast.types.RegoValue; import io.github.open_policy_agent.opa.rego.EvaluationContext; + import java.net.URI; import java.net.URISyntaxException; import java.util.Map; import java.util.function.BiFunction; +import static io.github.open_policy_agent.opa.ast.builtin.impls.utils.ArgHelper.getArg; + public class UriBuiltins { public static Map> builtins() { UriBuiltins instance = new UriBuiltins(); - return Map.of("uri.is_valid", instance::isValid); + return Map.of( + "uri.parse", instance::parse, + "uri.is_valid", instance::isValid); } @OpaBuiltin( @@ -40,4 +47,62 @@ public RegoBoolean isValid(EvaluationContext ctx, RegoValue[] args) { return RegoBoolean.FALSE; } } + + @OpaBuiltin( + name = "uri.parse", + description = "Parses an input URI and returns its components.", + categories = {"encoding"}, + args = {@OpaType(name = "x", description = "URI string to parse")}, + result = @OpaType(name = "result", description = "Parsed URI components")) + public RegoObject parse(EvaluationContext ctx, RegoValue[] args) { + String input = getArg(args, 0, RegoString.class).getValue(); + + try { + URI uri = new URI(input); + RegoObject result = new RegoObject(); + + if (uri.getScheme() != null && !uri.getScheme().isEmpty()) { + result.setProperty("scheme", new RegoString(uri.getScheme())); + } + + String hostname = uri.getHost(); + if (hostname != null && !hostname.isEmpty()) { + if (hostname.startsWith("[") && hostname.endsWith("]")) { + hostname = hostname.substring(1, hostname.length() - 1); + } + + result.setProperty("hostname", new RegoString(hostname)); + } + + if (uri.getPort() != -1) { + result.setProperty("port", new RegoString(Integer.toString(uri.getPort()))); + } + + String path = uri.getPath(); + if (path != null && !path.isEmpty()) { + result.setProperty("path", new RegoString(path)); + + String rawPath = uri.getRawPath(); + if (rawPath == null || rawPath.isEmpty()) { + rawPath = path; + } + + result.setProperty("raw_path", new RegoString(rawPath)); + } + + String rawQuery = uri.getRawQuery(); + if (rawQuery != null && !rawQuery.isEmpty()) { + result.setProperty("raw_query", new RegoString(rawQuery)); + } + + String fragment = uri.getFragment(); + if (fragment != null && !fragment.isEmpty()) { + result.setProperty("fragment", new RegoString(fragment)); + } + + return result; + } catch (URISyntaxException e) { + throw new BuiltinError(e.getMessage()); + } + } } From 36097bb1b4df9208696d593183ed765aef72d79d Mon Sep 17 00:00:00 2001 From: Shashank-MP Date: Thu, 16 Jul 2026 17:39:12 -0500 Subject: [PATCH 3/3] fix: align URI parsing with OPA semantics Signed-off-by: Shashank-MP --- .../opa/ast/builtin/impls/UriBuiltins.java | 259 ++++++++++++++++-- .../ast/builtin/impls/UriBuiltinsTest.java | 91 ++++++ 2 files changed, 322 insertions(+), 28 deletions(-) create mode 100644 opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltinsTest.java diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java index a9fba51d..f578341c 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltins.java @@ -11,6 +11,9 @@ import java.net.URI; import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Locale; import java.util.Map; import java.util.function.BiFunction; @@ -41,7 +44,7 @@ public RegoBoolean isValid(EvaluationContext ctx, RegoValue[] args) { } try { - new URI(input.getValue()); + parseGoCompatibleUri(input.getValue()); return RegoBoolean.TRUE; } catch (URISyntaxException e) { return RegoBoolean.FALSE; @@ -58,51 +61,251 @@ public RegoObject parse(EvaluationContext ctx, RegoValue[] args) { String input = getArg(args, 0, RegoString.class).getValue(); try { - URI uri = new URI(input); + ParsedUri uri = parseGoCompatibleUri(input); RegoObject result = new RegoObject(); - if (uri.getScheme() != null && !uri.getScheme().isEmpty()) { - result.setProperty("scheme", new RegoString(uri.getScheme())); + if (uri.scheme() != null) { + result.setProperty("scheme", new RegoString(uri.scheme())); } - String hostname = uri.getHost(); - if (hostname != null && !hostname.isEmpty()) { - if (hostname.startsWith("[") && hostname.endsWith("]")) { - hostname = hostname.substring(1, hostname.length() - 1); - } + if (uri.hostname() != null) { + result.setProperty("hostname", new RegoString(uri.hostname())); + } + + if (uri.port() != null) { + result.setProperty("port", new RegoString(uri.port())); + } + + if (uri.path() != null) { + result.setProperty("path", new RegoString(uri.path())); + result.setProperty("raw_path", new RegoString(uri.rawPath())); + } - result.setProperty("hostname", new RegoString(hostname)); + if (uri.rawQuery() != null) { + result.setProperty("raw_query", new RegoString(uri.rawQuery())); } - if (uri.getPort() != -1) { - result.setProperty("port", new RegoString(Integer.toString(uri.getPort()))); + if (uri.fragment() != null) { + result.setProperty("fragment", new RegoString(uri.fragment())); } - String path = uri.getPath(); - if (path != null && !path.isEmpty()) { - result.setProperty("path", new RegoString(path)); + return result; + } catch (URISyntaxException e) { + throw new BuiltinError(e.getMessage()); + } + } + + private ParsedUri parseGoCompatibleUri(String input) throws URISyntaxException { + if (containsControlCharacter(input)) { + throw new URISyntaxException(input, "invalid control character in URI"); + } + + String remaining = input; + String fragment = null; + + int fragmentSeparator = remaining.indexOf('#'); + if (fragmentSeparator != -1) { + String rawFragment = remaining.substring(fragmentSeparator + 1); + remaining = remaining.substring(0, fragmentSeparator); + + if (!rawFragment.isEmpty()) { + fragment = decodeComponent(rawFragment, input); + } + } + + int schemeSeparator = findSchemeSeparator(remaining); + String scheme = null; + + if (schemeSeparator != -1) { + scheme = remaining.substring(0, schemeSeparator).toLowerCase(Locale.ROOT); + remaining = remaining.substring(schemeSeparator + 1); + } + + String rawQuery = null; + int querySeparator = remaining.indexOf('?'); + + if (querySeparator != -1) { + rawQuery = remaining.substring(querySeparator + 1); + remaining = remaining.substring(0, querySeparator); + + if (rawQuery.isEmpty()) { + rawQuery = null; + } + } + + // Go treats scheme:value as an opaque URI. OPA does not expose the opaque value. + if (scheme != null && !remaining.startsWith("/")) { + return new ParsedUri(scheme, null, null, null, null, rawQuery, fragment); + } + + String hostname = null; + String port = null; + + boolean hasAuthority = + remaining.startsWith("//") + && (scheme != null || !remaining.startsWith("///")); + + if (hasAuthority) { + int pathStart = remaining.indexOf('/', 2); + String authority; + + if (pathStart == -1) { + authority = remaining.substring(2); + remaining = ""; + } else { + authority = remaining.substring(2, pathStart); + remaining = remaining.substring(pathStart); + } + + ParsedAuthority parsedAuthority = parseAuthority(authority, input); + hostname = parsedAuthority.hostname(); + port = parsedAuthority.port(); + + if (hostname.isEmpty()) { + hostname = null; + } + + if (port != null && port.isEmpty()) { + port = null; + } + } else if (scheme == null && !remaining.startsWith("/")) { + int firstSlash = remaining.indexOf('/'); + String firstSegment = + firstSlash == -1 ? remaining : remaining.substring(0, firstSlash); + + if (firstSegment.contains(":")) { + throw new URISyntaxException( + input, "first path segment in URI cannot contain colon"); + } + } - String rawPath = uri.getRawPath(); - if (rawPath == null || rawPath.isEmpty()) { - rawPath = path; + String path = null; + String rawPath = null; + + if (!remaining.isEmpty()) { + path = decodeComponent(remaining, input); + + // Reproduce Go's cmp.Or(parsed.RawPath, parsed.Path) behavior. + String defaultRawPath = new URI(null, null, path, null).getRawPath(); + rawPath = remaining.equals(defaultRawPath) ? path : remaining; + } + + return new ParsedUri( + scheme, + hostname, + port, + path, + rawPath, + rawQuery, + fragment); + } + + private boolean containsControlCharacter(String input) { + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (c <= 31 || c == 127) { + return true; + } + } + + return false; + } + + private int findSchemeSeparator(String input) throws URISyntaxException { + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + + if (c == ':') { + if (i == 0) { + throw new URISyntaxException(input, "missing protocol scheme"); } - result.setProperty("raw_path", new RegoString(rawPath)); + return i; + } + + boolean letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + boolean digit = c >= '0' && c <= '9'; + boolean valid = letter || (i > 0 && (digit || c == '+' || c == '-' || c == '.')); + + if (!valid) { + return -1; + } + } + + return -1; + } + + private String decodeComponent(String value, String input) throws URISyntaxException { + try { + // URLDecoder normally converts '+' to a space. Go path decoding preserves '+'. + return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new URISyntaxException(input, e.getMessage()); + } + } + + private ParsedAuthority parseAuthority(String authority, String input) + throws URISyntaxException { + int userInfoEnd = authority.lastIndexOf('@'); + String hostAndPort = + userInfoEnd == -1 ? authority : authority.substring(userInfoEnd + 1); + + String hostname; + String port = null; + + if (hostAndPort.startsWith("[")) { + int closingBracket = hostAndPort.indexOf(']'); + if (closingBracket == -1) { + throw new URISyntaxException(input, "missing closing bracket in IPv6 address"); } - String rawQuery = uri.getRawQuery(); - if (rawQuery != null && !rawQuery.isEmpty()) { - result.setProperty("raw_query", new RegoString(rawQuery)); + hostname = hostAndPort.substring(1, closingBracket); + String remainder = hostAndPort.substring(closingBracket + 1); + + if (!remainder.isEmpty()) { + if (!remainder.startsWith(":")) { + throw new URISyntaxException(input, "invalid IPv6 authority"); + } + + port = remainder.substring(1); + } + } else { + if (hostAndPort.contains("[") || hostAndPort.contains("]")) { + throw new URISyntaxException(input, "invalid bracket in host"); } - String fragment = uri.getFragment(); - if (fragment != null && !fragment.isEmpty()) { - result.setProperty("fragment", new RegoString(fragment)); + int portSeparator = hostAndPort.lastIndexOf(':'); + if (portSeparator == -1) { + hostname = hostAndPort; + } else { + hostname = hostAndPort.substring(0, portSeparator); + port = hostAndPort.substring(portSeparator + 1); + + if (hostname.contains(":")) { + throw new URISyntaxException(input, "IPv6 address must use brackets"); + } } + } - return result; - } catch (URISyntaxException e) { - throw new BuiltinError(e.getMessage()); + if (port != null + && !port.isEmpty() + && !port.chars().allMatch(Character::isDigit)) { + throw new URISyntaxException(input, "invalid port"); } + + hostname = decodeComponent(hostname, input); + + return new ParsedAuthority(hostname, port); } + + private record ParsedUri( + String scheme, + String hostname, + String port, + String path, + String rawPath, + String rawQuery, + String fragment) {} + + private record ParsedAuthority(String hostname, String port) {} } diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltinsTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltinsTest.java new file mode 100644 index 00000000..8d23e5f9 --- /dev/null +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/impls/UriBuiltinsTest.java @@ -0,0 +1,91 @@ +package io.github.open_policy_agent.opa.ast.builtin.impls; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.github.open_policy_agent.opa.ast.types.RegoBoolean; +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.ast.types.RegoValue; +import io.github.open_policy_agent.opa.rego.EvaluationContext; +import org.junit.jupiter.api.Test; + +public class UriBuiltinsTest { + + private final UriBuiltins uriBuiltins = new UriBuiltins(); + private final EvaluationContext context = new EvaluationContext.Builder().build(); + + @Test + public void parseLowercasesScheme() { + RegoValue[] args = {new RegoString("HTTP://EXAMPLE.COM")}; + + RegoObject result = uriBuiltins.parse(context, args); + + assertEquals(new RegoString("http"), result.getProperty("scheme")); + } + + @Test + public void parsePreservesRegistryBasedHostnameAndPort() { + RegoValue[] args = {new RegoString("http://my_host:8080/path")}; + + RegoObject result = uriBuiltins.parse(context, args); + + assertEquals(new RegoString("my_host"), result.getProperty("hostname")); + assertEquals(new RegoString("8080"), result.getProperty("port")); + } + + @Test + public void parseAcceptsSpaceInPath() { + RegoValue[] args = {new RegoString("https://example.com/path with spaces")}; + + RegoObject result = uriBuiltins.parse(context, args); + + assertEquals(new RegoString("/path with spaces"), result.getProperty("path")); + assertEquals(new RegoString("/path with spaces"), result.getProperty("raw_path")); + } + + @Test + public void parseAcceptsPipeInPath() { + RegoValue[] args = {new RegoString("http://example.com/p|pe")}; + + RegoObject result = uriBuiltins.parse(context, args); + + assertEquals(new RegoString("/p|pe"), result.getProperty("path")); + assertEquals(new RegoString("/p|pe"), result.getProperty("raw_path")); + } + + @Test + public void isValidAcceptsSpaceInPath() { + RegoValue[] args = {new RegoString("https://example.com/path with spaces")}; + + RegoBoolean result = uriBuiltins.isValid(context, args); + + assertEquals(RegoBoolean.TRUE, result); + } + + @Test + public void isValidAcceptsPipeInPath() { + RegoValue[] args = {new RegoString("http://example.com/p|pe")}; + + RegoBoolean result = uriBuiltins.isValid(context, args); + + assertEquals(RegoBoolean.TRUE, result); + } + + @Test + public void isValidRejectsControlCharacters() { + RegoValue[] args = {new RegoString("https://example.com/path\nwith-newline")}; + + RegoBoolean result = uriBuiltins.isValid(context, args); + + assertEquals(RegoBoolean.FALSE, result); + } + + @Test + public void isValidRejectsInvalidPercentEscape() { + RegoValue[] args = {new RegoString("https://example.com/path%ZZ")}; + + RegoBoolean result = uriBuiltins.isValid(context, args); + + assertEquals(RegoBoolean.FALSE, result); + } +}