From f472c63706aaac84a9253fd14c61ff8ed938529b Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Tue, 3 Feb 2026 15:40:56 +0530 Subject: [PATCH 01/12] xds: Implementation of Unified Matcher and CEL Integration --- gradle/libs.versions.toml | 2 + xds/BUILD.bazel | 1 + xds/build.gradle | 10 + .../io/grpc/xds/internal/MatcherParser.java | 2 +- .../java/io/grpc/xds/internal/Matchers.java | 13 +- .../grpc/xds/internal/matcher/CelCommon.java | 66 + .../grpc/xds/internal/matcher/CelMatcher.java | 86 + .../internal/matcher/CelStringExtractor.java | 84 + .../internal/matcher/GrpcCelEnvironment.java | 139 ++ .../xds/internal/matcher/HeadersWrapper.java | 129 ++ .../grpc/xds/internal/matcher/MatchInput.java | 32 + .../xds/internal/matcher/MatchResult.java | 49 + .../xds/internal/matcher/MatcherList.java | 103 ++ .../xds/internal/matcher/MatcherRunner.java | 58 + .../xds/internal/matcher/MatcherTree.java | 156 ++ .../io/grpc/xds/internal/matcher/OnMatch.java | 55 + .../internal/matcher/PredicateEvaluator.java | 228 +++ .../xds/internal/matcher/UnifiedMatcher.java | 204 +++ .../internal/matcher/CelEnvironmentTest.java | 283 ++++ .../matcher/CelStringExtractorTest.java | 82 + .../internal/matcher/UnifiedMatcherTest.java | 1482 +++++++++++++++++ 21 files changed, 3260 insertions(+), 4 deletions(-) create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3806fa3404e..6d554676370 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,8 @@ checkstyle = "com.puppycrawl.tools:checkstyle:10.26.1" # checkstyle 10.0+ requires Java 11+ # See https://checkstyle.sourceforge.io/releasenotes_old_8-35_10-26.html#Release_10.0 # checkForUpdates: checkstylejava8:9.+ +cel-runtime = "dev.cel:runtime:0.11.1" +cel-compiler = "dev.cel:compiler:0.11.1" checkstylejava8 = "com.puppycrawl.tools:checkstyle:9.3" commons-math3 = "org.apache.commons:commons-math3:3.6.1" conscrypt = "org.conscrypt:conscrypt-openjdk-uber:2.5.2" diff --git a/xds/BUILD.bazel b/xds/BUILD.bazel index 7f2c5f02338..54259847e2c 100644 --- a/xds/BUILD.bazel +++ b/xds/BUILD.bazel @@ -41,6 +41,7 @@ java_library( artifact("com.google.errorprone:error_prone_annotations"), artifact("com.google.guava:guava"), artifact("com.google.re2j:re2j"), + artifact("dev.cel:cel-bundle"), artifact("io.netty:netty-buffer"), artifact("io.netty:netty-codec"), artifact("io.netty:netty-common"), diff --git a/xds/build.gradle b/xds/build.gradle index 8394fe12f6b..a89adf72672 100644 --- a/xds/build.gradle +++ b/xds/build.gradle @@ -56,6 +56,12 @@ dependencies { libraries.re2j, libraries.auto.value.annotations, libraries.protobuf.java.util + implementation(libraries.cel.runtime) { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } + implementation(libraries.cel.compiler) { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } def nettyDependency = implementation project(':grpc-netty') testImplementation project(':grpc-api') @@ -175,6 +181,7 @@ tasks.named("javadoc").configure { exclude 'io/grpc/xds/XdsNameResolverProvider.java' exclude 'io/grpc/xds/internal/**' exclude 'io/grpc/xds/Internal*' + exclude 'dev/cel/**' } def prefixName = 'io.grpc.xds' @@ -182,6 +189,7 @@ tasks.named("shadowJar").configure { archiveClassifier = null dependencies { include(project(':grpc-xds')) + include(dependency('dev.cel:.*')) } // Relocated packages commonly need exclusions in jacocoTestReport and javadoc // Keep in sync with BUILD.bazel's JAR_JAR_RULES @@ -198,6 +206,8 @@ tasks.named("shadowJar").configure { // TODO: missing java_package option in .proto relocate 'udpa.annotations', "${prefixName}.shaded.udpa.annotations" relocate 'xds.annotations', "${prefixName}.shaded.xds.annotations" + relocate 'dev.cel', "${prefixName}.shaded.dev.cel" + relocate 'cel', "${prefixName}.shaded.cel" exclude "**/*.proto" } diff --git a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java index fb291efc461..cd3cc19c3ea 100644 --- a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java +++ b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java @@ -90,7 +90,7 @@ public static Matchers.StringMatcher parseStringMatcher( return Matchers.StringMatcher.forSafeRegEx( Pattern.compile(proto.getSafeRegex().getRegex())); case CONTAINS: - return Matchers.StringMatcher.forContains(proto.getContains()); + return Matchers.StringMatcher.forContains(proto.getContains(), proto.getIgnoreCase()); case MATCHPATTERN_NOT_SET: default: throw new IllegalArgumentException( diff --git a/xds/src/main/java/io/grpc/xds/internal/Matchers.java b/xds/src/main/java/io/grpc/xds/internal/Matchers.java index 228b20cfcd7..4b0babdfca6 100644 --- a/xds/src/main/java/io/grpc/xds/internal/Matchers.java +++ b/xds/src/main/java/io/grpc/xds/internal/Matchers.java @@ -257,10 +257,15 @@ public static StringMatcher forSafeRegEx(Pattern regEx) { } /** The input string should contain this substring. */ - public static StringMatcher forContains(String contains) { + public static StringMatcher forContains(String contains, boolean ignoreCase) { checkNotNull(contains, "contains"); return StringMatcher.create(null, null, null, null, contains, - false/* doesn't matter */); + ignoreCase); + } + + /** The input string should contain this substring. */ + public static StringMatcher forContains(String contains) { + return forContains(contains, false); } /** Returns the matching result for this string. */ @@ -281,7 +286,9 @@ public boolean matches(String args) { ? args.toLowerCase(Locale.ROOT).endsWith(suffix().toLowerCase(Locale.ROOT)) : args.endsWith(suffix()); } else if (contains() != null) { - return args.contains(contains()); + return ignoreCase() + ? args.toLowerCase(Locale.ROOT).contains(contains().toLowerCase(Locale.ROOT)) + : args.contains(contains()); } return regEx().matches(args); } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java new file mode 100644 index 00000000000..0f6dc84d7b5 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOptions; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelRuntimeFactory; + +/** + * Shared utilities for CEL-based matchers and extractors. + */ +final class CelCommon { + private static final CelOptions CEL_OPTIONS = CelOptions.newBuilder() + .enableComprehension(false) + .enableStringConversion(false) + .enableStringConcatenation(false) + .enableListConcatenation(false) + .maxRegexProgramSize(100) + .build(); + + static final CelCompiler COMPILER = CelCompilerFactory.standardCelCompilerBuilder() + .addVar("request", SimpleType.DYN) + .setOptions(CEL_OPTIONS) + .build(); + + static final CelRuntime RUNTIME = CelRuntimeFactory.standardCelRuntimeBuilder() + .setOptions(CEL_OPTIONS) + .build(); + + private CelCommon() {} + + static void checkAllowedVariables(CelAbstractSyntaxTree ast) { + // Basic validation to ensure only supported variables (request) are used. + // This iterates over the reference map generated by the type checker. + for (java.util.Map.Entry entry : + ast.getReferenceMap().entrySet()) { + dev.cel.common.ast.CelReference ref = entry.getValue(); + // If overload_id is empty, it's a variable reference or type name. + // We only support "request". + if (ref.value() == null && ref.overloadIds().isEmpty()) { + if (!"request".equals(ref.name())) { + throw new IllegalArgumentException( + "CEL expression references unknown variable: " + ref.name()); + } + } + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java new file mode 100644 index 00000000000..e400d4e7c50 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.google.common.annotations.VisibleForTesting; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelValidationException; +import dev.cel.common.types.SimpleType; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; + + + +/** + * Executes compiled CEL expressions. + */ +public final class CelMatcher { + + + private final CelRuntime.Program program; + + private CelMatcher(CelRuntime.Program program) { + this.program = program; + } + + /** + * Compiles the AST into a CelMatcher. + */ + public static CelMatcher compile(CelAbstractSyntaxTree ast) + throws CelValidationException, CelEvaluationException { + if (ast.getResultType() != SimpleType.BOOL) { + throw new IllegalArgumentException( + "CEL expression must evaluate to boolean, got: " + ast.getResultType()); + } + CelCommon.checkAllowedVariables(ast); + CelRuntime.Program program = CelCommon.RUNTIME.createProgram(ast); + return new CelMatcher(program); + } + + /** + * Compiles the CEL expression string into a CelMatcher. + */ + @VisibleForTesting + public static CelMatcher compile(String expression) + throws CelValidationException, CelEvaluationException { + CelAbstractSyntaxTree ast = CelCommon.COMPILER.compile(expression).getAst(); + return compile(ast); + } + + /** + * Evaluates the CEL expression against the input activation. + */ + public boolean match(Object input) throws CelEvaluationException { + Object result; + if (input instanceof dev.cel.runtime.CelVariableResolver) { + result = program.eval((dev.cel.runtime.CelVariableResolver) input); + } else if (input instanceof java.util.Map) { + @SuppressWarnings("unchecked") + java.util.Map mapInput = (java.util.Map) input; + result = program.eval(mapInput); + } else { + throw new CelEvaluationException( + "Unsupported input type for CEL evaluation: " + input.getClass().getName()); + } + // Validated to be boolean during compile check ideally, or we cast safely + if (result instanceof Boolean) { + return (Boolean) result; + } + throw new CelEvaluationException( + "CEL expression must evaluate to boolean, got: " + result.getClass().getName()); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java new file mode 100644 index 00000000000..55f3e58cd54 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java @@ -0,0 +1,84 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelValidationException; +import dev.cel.common.types.SimpleType; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; + + +/** + * Executes compiled CEL expressions that extract a string. + */ +public final class CelStringExtractor { + + + private final CelRuntime.Program program; + + private CelStringExtractor(CelRuntime.Program program) { + this.program = program; + } + + /** + * Compiles the AST into a CelStringExtractor. + */ + public static CelStringExtractor compile(CelAbstractSyntaxTree ast) + throws CelValidationException, CelEvaluationException { + if (ast.getResultType() != SimpleType.STRING && ast.getResultType() != SimpleType.DYN) { + throw new IllegalArgumentException( + "CEL expression must evaluate to string, got: " + ast.getResultType()); + } + CelCommon.checkAllowedVariables(ast); + CelRuntime.Program program = CelCommon.RUNTIME.createProgram(ast); + return new CelStringExtractor(program); + } + + /** + * Compiles the CEL expression string into a CelStringExtractor. + */ + public static CelStringExtractor compile(String expression) + throws CelValidationException, CelEvaluationException { + CelAbstractSyntaxTree ast = CelCommon.COMPILER.compile(expression).getAst(); + return compile(ast); + } + + /** + * Evaluates the CEL expression against the input activation and returns the string result. + * Returns null if the result is not a string. + */ + public String extract(Object input) throws CelEvaluationException { + Object result; + if (input instanceof dev.cel.runtime.CelVariableResolver) { + result = program.eval((dev.cel.runtime.CelVariableResolver) input); + } else if (input instanceof java.util.Map) { + @SuppressWarnings("unchecked") + java.util.Map mapInput = (java.util.Map) input; + result = program.eval(mapInput); + } else { + throw new CelEvaluationException( + "Unsupported input type for CEL evaluation: " + input.getClass().getName()); + } + + if (result instanceof String) { + return (String) result; + } + // Return null key for non-string results (which will likely match nothing or be handled) + return null; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java new file mode 100644 index 00000000000..fa2d57a67a3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java @@ -0,0 +1,139 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.google.common.base.Splitter; +import dev.cel.runtime.CelVariableResolver; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; + +/** + * CEL Environment for gRPC xDS matching. + */ +final class GrpcCelEnvironment implements CelVariableResolver { + private static final Splitter SPLITTER = Splitter.on('.').limit(2); + private final MatchContext context; + + GrpcCelEnvironment(MatchContext context) { + this.context = context; + } + + @Override + public Optional find(String name) { + if (name.equals("request")) { + return Optional.of(new LazyRequestMap(this)); + } + List components = SPLITTER.splitToList(name); + if (components.size() == 2 && components.get(0).equals("request")) { + return Optional.ofNullable(getRequestField(components.get(1))); + } + return Optional.empty(); + } + + @Nullable + private Object getRequestField(String requestField) { + switch (requestField) { + case "headers": return new HeadersWrapper(context); + case "host": return orEmpty(context.getHost()); + case "id": return orEmpty(context.getId()); + case "method": return or(context.getMethod(), "POST"); + case "path": + case "url_path": + return orEmpty(context.getPath()); + case "query": return ""; + case "scheme": return ""; + case "protocol": return ""; + case "time": return null; // Supported but not set in gRPC + case "referer": return getHeader("referer"); + case "useragent": return getHeader("user-agent"); + + default: + return null; + } + } + + private String getHeader(String key) { + io.grpc.Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll( + io.grpc.Metadata.Key.of(key, io.grpc.Metadata.ASCII_STRING_MARSHALLER)); + if (values == null) { + return ""; + } + return String.join(",", values); + } + + private static String orEmpty(@Nullable String s) { + return s == null ? "" : s; + } + + private static String or(@Nullable String s, String def) { + return s == null ? def : s; + } + + private static final class LazyRequestMap extends java.util.AbstractMap { + private static final java.util.Set KNOWN_KEYS = + com.google.common.collect.ImmutableSet.of( + "headers", "host", "id", "method", "path", "url_path", "query", "scheme", "protocol", + "referer", "useragent", "time" + ); + private final GrpcCelEnvironment resolver; + + LazyRequestMap(GrpcCelEnvironment resolver) { + this.resolver = resolver; + } + + @Override + public Object get(Object key) { + if (key instanceof String) { + Object val = resolver.getRequestField((String) key); + if (val == null) { + return null; + } + return val; + } + return null; + } + + @Override + public boolean containsKey(Object key) { + boolean contains = key instanceof String && KNOWN_KEYS.contains(key); + return contains; + } + + @Override + public java.util.Set keySet() { + return KNOWN_KEYS; + } + + @Override + public int size() { + return KNOWN_KEYS.size(); + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public java.util.Set> entrySet() { + throw new UnsupportedOperationException("LazyRequestMap does not support entrySet"); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java new file mode 100644 index 00000000000..472864556a8 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.google.common.collect.ImmutableSet; +import com.google.errorprone.annotations.DoNotCall; +import io.grpc.Metadata; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.AbstractMap; +import java.util.Set; +import javax.annotation.Nullable; + +/** + * A Map view over Metadata and MatchContext for CEL attribute resolution. + * Supports efficient lookup of headers and pseudo-headers without unnecessary copying. + */ +final class HeadersWrapper extends AbstractMap { + private static final ImmutableSet PSEUDO_HEADERS = + ImmutableSet.of(":method", ":authority", ":path"); + private final MatchContext context; + + HeadersWrapper(MatchContext context) { + this.context = context; + } + + @Override + @Nullable + public String get(Object key) { + if (!(key instanceof String)) { + return null; + } + String headerName = ((String) key).toLowerCase(java.util.Locale.ROOT); + if ("te".equals(headerName)) { + return null; + } + switch (headerName) { + case ":method": return context.getMethod(); + case ":authority": return context.getHost(); + case "host": return context.getHost(); + case ":path": return context.getPath(); + default: return getHeader(headerName); + } + } + + @Nullable + private String getHeader(String headerName) { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Iterable values = context.getMetadata().getAll( + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER)); + if (values == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (byte[] value : values) { + if (!first) { + sb.append(","); + } + first = false; + sb.append(com.google.common.io.BaseEncoding.base64().encode(value)); + } + return sb.toString(); + } + Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER)); + if (values == null) { + return null; + } + return String.join(",", values); + } + + @Override + public boolean containsKey(Object key) { + if (!(key instanceof String)) { + return false; + } + String headerName = ((String) key).toLowerCase(java.util.Locale.ROOT); + if ("te".equals(headerName)) { + return false; + } + if (PSEUDO_HEADERS.contains(headerName)) { + return true; + } + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + return context.getMetadata().containsKey( + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER)); + } + return context.getMetadata().containsKey( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER)); + } + + @Override + public Set keySet() { + return ImmutableSet.builder() + .addAll(context.getMetadata().keys()) + .addAll(PSEUDO_HEADERS) + .build(); + } + + @Override + public int size() { + // Metadata.keys() returns a Set of unique keys, so we can just add the sizes. + // Note: This counts the number of unique header names, which is consistent with + // keySet().size(). + return context.getMetadata().keys().size() + PSEUDO_HEADERS.size(); + } + + @Override + @DoNotCall("Always throws UnsupportedOperationException") + public Set> entrySet() { + throw new UnsupportedOperationException( + "Should not be called to prevent resolving all header values."); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java new file mode 100644 index 00000000000..0c0037974c0 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java @@ -0,0 +1,32 @@ +/* + * Copyright 2021 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import javax.annotation.Nullable; + +/** + * Interface for extracting values from a match context (e.g. HTTP headers). + */ +public interface MatchInput { + /** + * Extracts the value from the context. + * @param context the context (e.g. Metadata, Attributes) + * @return the extracted value, or null if not found. + */ + @Nullable + Object apply(T context); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java new file mode 100644 index 00000000000..43b0f115f35 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.github.xds.core.v3.TypedExtensionConfig; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Result of a matching operation. + */ +public final class MatchResult { + public final List actions; + public final boolean matched; + + private MatchResult(List actions, boolean matched) { + this.actions = checkNotNull(actions, "actions"); + this.matched = matched; + } + + public static MatchResult create(TypedExtensionConfig action) { + return new MatchResult(Collections.singletonList(action), true); + } + + public static MatchResult create(List actions) { + return new MatchResult(new ArrayList<>(actions), true); + } + + public static MatchResult noMatch() { + return new MatchResult(Collections.emptyList(), false); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java new file mode 100644 index 00000000000..a3992370775 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; + +final class MatcherList extends UnifiedMatcher { + private final List matchers; + @Nullable private final OnMatch onNoMatch; + + MatcherList(Matcher.MatcherList proto, @Nullable Matcher.OnMatch onNoMatchProto, + java.util.function.Predicate actionValidator) { + if (proto.getMatchersCount() == 0) { + throw new IllegalArgumentException("MatcherList must contain at least one FieldMatcher"); + } + this.matchers = new ArrayList<>(proto.getMatchersCount()); + for (Matcher.MatcherList.FieldMatcher fm : proto.getMatchersList()) { + matchers.add(new FieldMatcher(fm, actionValidator)); + } + if (onNoMatchProto != null) { + this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); + } else { + this.onNoMatch = null; + } + } + + @Override + public MatchResult match(MatchContext context, int depth) { + if (depth > MAX_RECURSION_DEPTH) { + return MatchResult.noMatch(); + } + + List accumulated = new ArrayList<>(); + boolean matchedAtLeastOnce = false; + + + for (FieldMatcher matcher : matchers) { + if (matcher.matches(context)) { + MatchResult result = matcher.onMatch.evaluate(context, depth); + if (result.matched) { + accumulated.addAll(result.actions); + matchedAtLeastOnce = true; + } + + if (!matcher.onMatch.keepMatching) { + if (!matchedAtLeastOnce) { + return MatchResult.noMatch(); + } + break; + } + } + } + + if (!matchedAtLeastOnce) { + if (onNoMatch != null) { + MatchResult noMatchResult = onNoMatch.evaluate(context, depth); + if (noMatchResult.matched) { + accumulated.addAll(noMatchResult.actions); + matchedAtLeastOnce = true; + } + } + } + + if (matchedAtLeastOnce) { + return MatchResult.create(accumulated); + } + return MatchResult.noMatch(); + } + + private static final class FieldMatcher { + private final PredicateEvaluator predicate; + private final OnMatch onMatch; + + FieldMatcher(Matcher.MatcherList.FieldMatcher proto, + java.util.function.Predicate actionValidator) { + this.predicate = PredicateEvaluator.fromProto(proto.getPredicate()); + this.onMatch = new OnMatch(proto.getOnMatch(), actionValidator); + } + + boolean matches(MatchContext context) { + return predicate.evaluate(context); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java new file mode 100644 index 00000000000..60569655d35 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import io.grpc.Metadata; + +/** + * Executes a UnifiedMatcher against a request. + */ +public final class MatcherRunner { + private MatcherRunner() {} + + /** + * runs the matcher. + */ + @javax.annotation.Nullable + public static java.util.List checkMatch( + com.github.xds.type.matcher.v3.Matcher proto, MatchContext context) { + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(context, 0); + if (!result.matched || result.actions.isEmpty()) { + return null; + } + return result.actions; + } + + public interface MatchContext { + // Basic context usually involves Metadata (headers) and potentially other attributes. + Metadata getMetadata(); + + @javax.annotation.Nullable + String getPath(); + + @javax.annotation.Nullable + String getHost(); + + @javax.annotation.Nullable + String getMethod(); + + @javax.annotation.Nullable + String getId(); // x-request-id + + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java new file mode 100644 index 00000000000..95822aa7ad1 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java @@ -0,0 +1,156 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.github.xds.type.matcher.v3.Matcher; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +final class MatcherTree extends UnifiedMatcher { + private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + private final MatchInput input; + @Nullable private final Map exactMatchMap; + @Nullable private final Map prefixMatchMap; + @Nullable private final OnMatch onNoMatch; + + MatcherTree(Matcher.MatcherTree proto, @Nullable Matcher.OnMatch onNoMatchProto, + java.util.function.Predicate actionValidator) { + if (!proto.hasInput()) { + throw new IllegalArgumentException("MatcherTree must have input"); + } + this.input = UnifiedMatcher.resolveInput(proto.getInput()); + if (proto.getInput().getTypedConfig().getTypeUrl() + .equals(TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT)) { + throw new IllegalArgumentException( + "HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + + if (proto.hasCustomMatch()) { + throw new IllegalArgumentException("MatcherTree does not support custom_match"); + } + + if (proto.hasExactMatchMap()) { + Matcher.MatcherTree.MatchMap matchMap = proto.getExactMatchMap(); + if (matchMap.getMapCount() == 0) { + throw new IllegalArgumentException( + "MatcherTree exact_match_map must contain at least one entry"); + } + this.exactMatchMap = new HashMap<>(); + for (Map.Entry entry : + matchMap.getMapMap().entrySet()) { + this.exactMatchMap.put(entry.getKey(), + new OnMatch(entry.getValue(), actionValidator)); + } + this.prefixMatchMap = null; + } else if (proto.hasPrefixMatchMap()) { + Matcher.MatcherTree.MatchMap matchMap = proto.getPrefixMatchMap(); + if (matchMap.getMapCount() == 0) { + throw new IllegalArgumentException( + "MatcherTree prefix_match_map must contain at least one entry"); + } + this.prefixMatchMap = new HashMap<>(); + for (Map.Entry entry : + matchMap.getMapMap().entrySet()) { + this.prefixMatchMap.put(entry.getKey(), + new OnMatch(entry.getValue(), actionValidator)); + } + this.exactMatchMap = null; + } else { + throw new IllegalArgumentException( + "MatcherTree must have either exact_match_map or prefix_match_map"); + } + if (onNoMatchProto != null) { + this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); + } else { + this.onNoMatch = null; + } + } + + @Override + public MatchResult match(MatchContext context, int depth) { + if (depth > MAX_RECURSION_DEPTH) { + return MatchResult.noMatch(); + } + + Object valueObj = input.apply(context); + if (!(valueObj instanceof String)) { + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } + String value = (String) valueObj; + if (value == null) { + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } + + if (exactMatchMap != null) { + OnMatch match = exactMatchMap.get(value); + if (match != null) { + return match.evaluate(context, depth); + } + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } else if (prefixMatchMap != null) { + java.util.List matchingPrefixes = new java.util.ArrayList<>(); + for (String prefix : prefixMatchMap.keySet()) { + if (value.startsWith(prefix)) { + matchingPrefixes.add(prefix); + } + } + + if (matchingPrefixes.isEmpty()) { + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } + + // Sort by length descending (longest first) + java.util.Collections.sort(matchingPrefixes, new java.util.Comparator() { + @Override + public int compare(String s1, String s2) { + return Integer.compare(s2.length(), s1.length()); + } + }); + + boolean matchedAtLeastOnce = false; + java.util.List accumulatedActions = + new java.util.ArrayList<>(); + + for (String prefix : matchingPrefixes) { + OnMatch onMatch = prefixMatchMap.get(prefix); + MatchResult result = onMatch.evaluate(context, depth); + if (result.matched) { + matchedAtLeastOnce = true; + accumulatedActions.addAll(result.actions); + if (!onMatch.keepMatching) { + return MatchResult.create(accumulatedActions); + } + } + } + + if (matchedAtLeastOnce) { + return MatchResult.create(accumulatedActions); + } + // If we found matching prefixes but none of them resulted in a match (nested logic failed), + // we still "found a key" in the tree structure. + // According to the test "matcherTree_exactMatch_shouldNotFallBackToOnNoMatch_ifKeyFound", + // finding a key prevents onNoMatch. + // So we return noMatch() here, NOT onNoMatch.evaluate(). + return MatchResult.noMatch(); + } + + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java b/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java new file mode 100644 index 00000000000..03e2eac00b2 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import javax.annotation.Nullable; + +/** + * Handles the action to take upon a match (recurse or return action). + */ +final class OnMatch { + @Nullable private final UnifiedMatcher nestedMatcher; + @Nullable private final TypedExtensionConfig action; + final boolean keepMatching; + + OnMatch(Matcher.OnMatch proto, java.util.function.Predicate actionValidator) { + this.keepMatching = proto.getKeepMatching(); + if (proto.hasMatcher()) { + this.nestedMatcher = UnifiedMatcher.fromProto(proto.getMatcher(), actionValidator); + this.action = null; + } else if (proto.hasAction()) { + this.nestedMatcher = null; + this.action = proto.getAction(); + String typeUrl = this.action.getTypedConfig().getTypeUrl(); + if (!actionValidator.test(typeUrl)) { + throw new IllegalArgumentException("Unsupported action type: " + typeUrl); + } + } else { + throw new IllegalArgumentException("OnMatch must have either matcher or action"); + } + } + + MatchResult evaluate(MatchContext context, int depth) { + if (nestedMatcher != null) { + return nestedMatcher.match(context, depth + 1); + } + return MatchResult.create(action); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java new file mode 100644 index 00000000000..ba5e3b1c424 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher.MatcherList.Predicate; +import com.google.protobuf.InvalidProtocolBufferException; +import dev.cel.common.CelValidationException; +import dev.cel.runtime.CelEvaluationException; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; + +abstract class PredicateEvaluator { + private static final String TYPE_URL_CEL_MATCHER = + "type.googleapis.com/xds.type.matcher.v3.CelMatcher"; + private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + + abstract boolean evaluate(MatchContext context); + + static PredicateEvaluator fromProto(Predicate proto) { + if (proto.hasSinglePredicate()) { + return new SinglePredicateEvaluator(proto.getSinglePredicate()); + } else if (proto.hasOrMatcher()) { + return new OrMatcherEvaluator(proto.getOrMatcher()); + } else if (proto.hasAndMatcher()) { + return new AndMatcherEvaluator(proto.getAndMatcher()); + } else if (proto.hasNotMatcher()) { + return new NotMatcherEvaluator(proto.getNotMatcher()); + } + throw new IllegalArgumentException( + "Predicate must have one of: single_predicate, or_matcher, and_matcher, not_matcher"); + } + + private static final class SinglePredicateEvaluator extends PredicateEvaluator { + private final MatchInput input; + @Nullable private final Matchers.StringMatcher stringMatcher; + @Nullable private final CelMatcher celMatcher; + + SinglePredicateEvaluator(Predicate.SinglePredicate proto) { + if (!proto.hasInput()) { + throw new IllegalArgumentException("SinglePredicate must have input"); + } + this.input = UnifiedMatcher.resolveInput(proto.getInput()); + + if (proto.hasValueMatch()) { + if (proto.getInput().getTypedConfig().getTypeUrl() + .equals(TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT)) { + throw new IllegalArgumentException( + "HttpAttributesCelMatchInput cannot be used with StringMatcher"); + } + this.stringMatcher = fromStringMatcherProto(proto.getValueMatch()); + this.celMatcher = null; + } else if (proto.hasCustomMatch()) { + this.stringMatcher = null; + TypedExtensionConfig customConfig = proto.getCustomMatch(); + if (customConfig.getTypedConfig().getTypeUrl().equals(TYPE_URL_CEL_MATCHER)) { + try { + com.github.xds.type.matcher.v3.CelMatcher celProto = customConfig.getTypedConfig() + .unpack(com.github.xds.type.matcher.v3.CelMatcher.class); + if (celProto.hasExprMatch()) { + com.github.xds.type.v3.CelExpression expr = celProto.getExprMatch(); + if (expr.hasCelExprChecked()) { + dev.cel.common.CelAbstractSyntaxTree ast = + dev.cel.common.CelProtoAbstractSyntaxTree.fromCheckedExpr( + expr.getCelExprChecked()).getAst(); + this.celMatcher = CelMatcher.compile(ast); + } else { + throw new IllegalArgumentException( + "CelMatcher must have cel_expr_checked"); + } + } else { + throw new IllegalArgumentException("CelMatcher must have expr_match"); + } + } catch (InvalidProtocolBufferException | CelValidationException + | CelEvaluationException e) { + throw new IllegalArgumentException("Invalid CelMatcher config", e); + } + } else { + throw new IllegalArgumentException("Unsupported custom_match matcher: " + + customConfig.getTypedConfig().getTypeUrl()); + } + if (this.celMatcher != null && !proto.getInput().getTypedConfig().getTypeUrl() + .equals(TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT)) { + throw new IllegalArgumentException( + "CelMatcher can only be used with HttpAttributesCelMatchInput"); + } + } else { + throw new IllegalArgumentException( + "SinglePredicate must have either value_match or custom_match"); + } + } + + @Override boolean evaluate(MatchContext context) { + Object value = input.apply(context); + if (stringMatcher != null) { + if (value instanceof String) { + return stringMatcher.matches((String) value); + } + return false; + } + if (celMatcher != null) { + try { + return celMatcher.match(value); + } catch (CelEvaluationException e) { + return false; + } + } + return false; + } + + private static Matchers.StringMatcher fromStringMatcherProto( + com.github.xds.type.matcher.v3.StringMatcher proto) { + if (proto.hasExact()) { + return Matchers.StringMatcher.forExact(proto.getExact(), proto.getIgnoreCase()); + } + if (proto.hasPrefix()) { + String prefix = proto.getPrefix(); + if (prefix.isEmpty()) { + throw new IllegalArgumentException( + "StringMatcher prefix (match_pattern) must be non-empty"); + } + return Matchers.StringMatcher.forPrefix(prefix, proto.getIgnoreCase()); + } + if (proto.hasSuffix()) { + String suffix = proto.getSuffix(); + if (suffix.isEmpty()) { + throw new IllegalArgumentException( + "StringMatcher suffix (match_pattern) must be non-empty"); + } + return Matchers.StringMatcher.forSuffix(suffix, proto.getIgnoreCase()); + } + if (proto.hasContains()) { + String contains = proto.getContains(); + if (contains.isEmpty()) { + throw new IllegalArgumentException( + "StringMatcher contains (match_pattern) must be non-empty"); + } + return Matchers.StringMatcher.forContains(contains, proto.getIgnoreCase()); + } + if (proto.hasSafeRegex()) { + String regex = proto.getSafeRegex().getRegex(); + if (regex.isEmpty()) { + throw new IllegalArgumentException( + "StringMatcher regex (match_pattern) must be non-empty"); + } + return Matchers.StringMatcher.forSafeRegEx( + com.google.re2j.Pattern.compile(regex)); + } + throw new IllegalArgumentException("Unknown StringMatcher match pattern"); + } + } + + private static final class OrMatcherEvaluator extends PredicateEvaluator { + private final List evaluators; + + OrMatcherEvaluator(Predicate.PredicateList proto) { + if (proto.getPredicateCount() < 2) { + throw new IllegalArgumentException("OrMatcher must have at least 2 predicates"); + } + this.evaluators = new ArrayList<>(proto.getPredicateCount()); + for (Predicate p : proto.getPredicateList()) { + evaluators.add(PredicateEvaluator.fromProto(p)); + } + } + + @Override boolean evaluate(MatchContext context) { + for (PredicateEvaluator e : evaluators) { + if (e.evaluate(context)) { + return true; + } + } + return false; + } + } + + private static final class AndMatcherEvaluator extends PredicateEvaluator { + private final List evaluators; + + AndMatcherEvaluator(Predicate.PredicateList proto) { + if (proto.getPredicateCount() < 2) { + throw new IllegalArgumentException("AndMatcher must have at least 2 predicates"); + } + this.evaluators = new ArrayList<>(proto.getPredicateCount()); + for (Predicate p : proto.getPredicateList()) { + evaluators.add(PredicateEvaluator.fromProto(p)); + } + } + + @Override boolean evaluate(MatchContext context) { + for (PredicateEvaluator e : evaluators) { + if (!e.evaluate(context)) { + return false; + } + } + return true; + } + } + + private static final class NotMatcherEvaluator extends PredicateEvaluator { + private final PredicateEvaluator evaluator; + + NotMatcherEvaluator(Predicate proto) { + this.evaluator = PredicateEvaluator.fromProto(proto); + } + + @Override boolean evaluate(MatchContext context) { + return !evaluator.evaluate(context); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java new file mode 100644 index 00000000000..6ec4800e4df --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -0,0 +1,204 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import com.google.protobuf.InvalidProtocolBufferException; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; +import io.grpc.Metadata; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import javax.annotation.Nullable; + +/** + * Represents a compiled xDS Matcher. + */ +public abstract class UnifiedMatcher { + + // Supported Extension Type URLs + private static final String TYPE_URL_HTTP_HEADER_INPUT = + "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; + private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + + + static final int MAX_RECURSION_DEPTH = 16; + + @Nullable + public abstract MatchResult match(MatchContext context, int depth); + + static MatchInput resolveInput(TypedExtensionConfig config) { + String typeUrl = config.getTypedConfig().getTypeUrl(); + try { + if (typeUrl.equals(TYPE_URL_HTTP_HEADER_INPUT)) { + HttpRequestHeaderMatchInput proto = config.getTypedConfig() + .unpack(HttpRequestHeaderMatchInput.class); + return new HeaderMatchInput(proto.getHeaderName()); + } else if (typeUrl.equals(TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT)) { + return new MatchInput() { + @Override + public Object apply(MatchContext context) { + return new GrpcCelEnvironment(context); + } + }; + } + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException("Invalid input config: " + typeUrl, e); + } + throw new IllegalArgumentException("Unsupported input type: " + typeUrl); + } + + + + private static final class HeaderMatchInput implements MatchInput { + private final String headerName; + + HeaderMatchInput(String headerName) { + this.headerName = checkNotNull(headerName, "headerName"); + if (headerName.isEmpty() || headerName.length() >= 16384) { + throw new IllegalArgumentException( + "Header name length must be in range [1, 16384): " + headerName.length()); + } + if (!headerName.equals(headerName.toLowerCase(java.util.Locale.ROOT))) { + throw new IllegalArgumentException("Header name must be lowercase: " + headerName); + } + try { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER); + } else { + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid header name: " + headerName, e); + } + } + + @Override + public String apply(MatchContext context) { + if ("te".equals(headerName)) { + return null; + } + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Iterable values = context.getMetadata().getAll( + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER)); + if (values == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (byte[] value : values) { + if (!first) { + sb.append(","); + } + first = false; + sb.append(com.google.common.io.BaseEncoding.base64().encode(value)); + } + return sb.toString(); + } + Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER)); + if (values == null) { + return null; + } + return String.join(",", values); + } + } + + /** + * Parses a proto Matcher into a UnifiedMatcher. + * + * @param proto the proto matcher + * @param actionValidator a predicate that returns true if the action type URL is supported + */ + public static UnifiedMatcher fromProto(Matcher proto, + java.util.function.Predicate actionValidator) { + checkRecursionDepth(proto, 0); + Matcher.OnMatch onNoMatch = proto.hasOnNoMatch() ? proto.getOnNoMatch() : null; + if (proto.hasMatcherList()) { + return new MatcherList(proto.getMatcherList(), onNoMatch, actionValidator); + } else if (proto.hasMatcherTree()) { + return new MatcherTree(proto.getMatcherTree(), onNoMatch, actionValidator); + } + return new NoOpMatcher(onNoMatch, actionValidator); + } + + /** + * Parses a proto Matcher into a UnifiedMatcher, allowing all actions. + */ + public static UnifiedMatcher fromProto(Matcher proto) { + return fromProto(proto, (typeUrl) -> true); + } + + private static void checkRecursionDepth(Matcher proto, int currentDepth) { + if (currentDepth > MAX_RECURSION_DEPTH) { + throw new IllegalArgumentException( + "Matcher tree depth exceeds limit of " + MAX_RECURSION_DEPTH); + } + if (proto.hasMatcherList()) { + for (Matcher.MatcherList.FieldMatcher fm : proto.getMatcherList().getMatchersList()) { + if (fm.hasOnMatch() && fm.getOnMatch().hasMatcher()) { + checkRecursionDepth(fm.getOnMatch().getMatcher(), currentDepth + 1); + } + } + } else if (proto.hasMatcherTree()) { + Matcher.MatcherTree tree = proto.getMatcherTree(); + if (tree.hasExactMatchMap()) { + for (Matcher.OnMatch onMatch : tree.getExactMatchMap().getMapMap().values()) { + if (onMatch.hasMatcher()) { + checkRecursionDepth(onMatch.getMatcher(), currentDepth + 1); + } + } + } else if (tree.hasPrefixMatchMap()) { + for (Matcher.OnMatch onMatch : tree.getPrefixMatchMap().getMapMap().values()) { + if (onMatch.hasMatcher()) { + checkRecursionDepth(onMatch.getMatcher(), currentDepth + 1); + } + } + } + } + if (proto.hasOnNoMatch() && proto.getOnNoMatch().hasMatcher()) { + checkRecursionDepth(proto.getOnNoMatch().getMatcher(), currentDepth + 1); + } + } + + private static final class NoOpMatcher extends UnifiedMatcher { + @Nullable private final OnMatch onNoMatch; + + NoOpMatcher(@Nullable Matcher.OnMatch onNoMatchProto, + java.util.function.Predicate actionValidator) { + if (onNoMatchProto != null) { + this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); + } else { + this.onNoMatch = null; + } + } + + @Override + public MatchResult match(MatchContext context, int depth) { + if (depth > MAX_RECURSION_DEPTH) { + return MatchResult.noMatch(); + } + if (onNoMatch != null) { + return onNoMatch.evaluate(context, depth); + } + return MatchResult.noMatch(); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java new file mode 100644 index 00000000000..3bf0896d3b8 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -0,0 +1,283 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.grpc.Metadata; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.Map; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelEnvironmentTest { + + @Test + public void headersWrapper_resolvesPseudoHeaders() { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + when(context.getPath()).thenReturn("/path"); + when(context.getMethod()).thenReturn("POST"); + when(context.getHost()).thenReturn("example.com"); + + Map headers = new HeadersWrapper(context); + + assertThat(headers.get(":path")).isEqualTo("/path"); + assertThat(headers.get(":method")).isEqualTo("POST"); + assertThat(headers.get(":authority")).isEqualTo("example.com"); + } + + @Test + public void headersWrapper_resolvesStandardHeaders() { + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("custom-key", Metadata.ASCII_STRING_MARSHALLER), "custom-val"); + when(context.getMetadata()).thenReturn(metadata); + + Map headers = new HeadersWrapper(context); + + assertThat(headers.get("custom-key")).isEqualTo("custom-val"); + assertThat(headers.containsKey("custom-key")).isTrue(); + } + + @Test + @SuppressWarnings("DoNotCall") + public void headersWrapper_entrySet_unsupported() { + MatchContext context = mock(MatchContext.class); + Map headers = new HeadersWrapper(context); + + try { + headers.entrySet(); + fail("Should throw UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + assertThat(e).hasMessageThat().contains("Should not be called"); + } + } + + @Test + public void celEnvironment_resolvesRequestField() { + MatchContext context = mock(MatchContext.class); + when(context.getPath()).thenReturn("/foo"); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request.path"); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isEqualTo("/foo"); + } + + @Test + public void headers_caseInsensitive() { + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("User-Agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-java"); + when(context.getMetadata()).thenReturn(metadata); + + Map headers = new HeadersWrapper(context); + + // CEL lookup with different casing + assertThat(headers.get("user-agent")).isEqualTo("grpc-java"); + assertThat(headers.get("USER-AGENT")).isEqualTo("grpc-java"); + assertThat(headers.containsKey("User-Agent")).isTrue(); + assertThat(headers.containsKey("user-agent")).isTrue(); + } + + @Test + public void headers_ignoreTe() { + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("te", Metadata.ASCII_STRING_MARSHALLER), "trailers"); + when(context.getMetadata()).thenReturn(metadata); + + Map headers = new HeadersWrapper(context); + + // "te" should be hidden + assertThat(headers.get("te")).isNull(); + assertThat(headers.containsKey("te")).isFalse(); + // Case insensitive check for "TE" logic too + assertThat(headers.get("TE")).isNull(); + assertThat(headers.containsKey("TE")).isFalse(); + } + + @Test + public void headers_hostAliasing() { + MatchContext context = mock(MatchContext.class); + when(context.getHost()).thenReturn("example.com"); + // Metadata might have "Host" or not, but logic should use context.getHost() + when(context.getMetadata()).thenReturn(new Metadata()); + + Map headers = new HeadersWrapper(context); + + assertThat(headers.get("host")).isEqualTo("example.com"); + assertThat(headers.get("HOST")).isEqualTo("example.com"); + assertThat(headers.get(":authority")).isEqualTo("example.com"); + } + + @Test + public void headers_binaryHeader() { + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + byte[] bytes = new byte[] { 0, 1, 2, 3 }; + metadata.put(Metadata.Key.of("test-bin", Metadata.BINARY_BYTE_MARSHALLER), bytes); + when(context.getMetadata()).thenReturn(metadata); + + Map headers = new HeadersWrapper(context); + + // Expect Base64 encoded string + String expected = com.google.common.io.BaseEncoding.base64().encode(bytes); + assertThat(headers.get("test-bin")).isEqualTo(expected); + assertThat(headers.containsKey("test-bin")).isTrue(); + } + + @Test + public void celEnvironment_method_fallback() { + MatchContext context = mock(MatchContext.class); + when(context.getMethod()).thenReturn(null); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request.method"); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isEqualTo("POST"); + } + + @Test + public void celEnvironment_resolvesLazyRequestMap() { + MatchContext context = mock(MatchContext.class); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isInstanceOf(Map.class); + + Map map = (Map) result.get(); + assertThat(map.containsKey("path")).isTrue(); + assertThat(map.size()).isAtLeast(1); + + try { + map.entrySet(); + fail("Should throw UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected + } + } + + @Test + public void celEnvironment_timeField_supportedButNull() { + MatchContext context = mock(MatchContext.class); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + // find() returns empty for null values + Optional result = env.find("request.time"); + assertThat(result.isPresent()).isFalse(); + + // But it should be present in the map key set + Map requestMap = (Map) env.find("request").get(); + assertThat(requestMap.containsKey("time")).isTrue(); + assertThat(requestMap.get("time")).isNull(); + } + + @Test + public void headersWrapper_size() { + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("k1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + metadata.put(Metadata.Key.of("k2", Metadata.ASCII_STRING_MARSHALLER), "v2"); + when(context.getMetadata()).thenReturn(metadata); + + HeadersWrapper headers = new HeadersWrapper(context); + + // 2 custom headers + 3 pseudo headers (:method, :authority, :path) = 5 + assertThat(headers.size()).isEqualTo(5); + } + + @Test + public void celEnvironment_accessAllFields() { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + when(context.getHost()).thenReturn("host"); + when(context.getId()).thenReturn("id"); + when(context.getMethod()).thenReturn("GET"); + + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + assertThat(env.find("request.host").get()).isEqualTo("host"); + assertThat(env.find("request.id").get()).isEqualTo("id"); + assertThat(env.find("request.method").get()).isEqualTo("GET"); + assertThat(env.find("request.scheme").get()).isEqualTo(""); + assertThat(env.find("request.protocol").get()).isEqualTo(""); + assertThat(env.find("request.query").get()) + .isEqualTo(""); + assertThat(env.find("request.headers").get()).isInstanceOf(HeadersWrapper.class); + } + + @Test + public void celEnvironment_find_unknownField() { + MatchContext context = mock(MatchContext.class); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + assertThat(env.find("request.unknown").isPresent()).isFalse(); + assertThat(env.find("other").isPresent()).isFalse(); + } + + @Test + public void lazyRequestMap_unknownKey_returnsNull() { + MatchContext context = mock(MatchContext.class); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + Map map = (Map) env.find("request").get(); + + assertThat(map.get("unknown")).isNull(); + assertThat(map.get(123)).isNull(); + assertThat(map.containsKey(123)).isFalse(); + } + + @Test + public void celMatcher_match_mapInput() throws Exception { + CelMatcher matcher = CelMatcher.compile("request == 'bar'"); + Map input = java.util.Collections.singletonMap("request", "bar"); + + assertThat(matcher.match(input)).isTrue(); + } + + @Test + public void celMatcher_match_invalidInputType_throws() throws Exception { + CelMatcher matcher = CelMatcher.compile("true"); + try { + matcher.match("invalid-input"); + fail("Should throw CelEvaluationException"); + } catch (dev.cel.runtime.CelEvaluationException e) { + assertThat(e).hasMessageThat().contains("Unsupported input type"); + } + } + + @Test + public void celMatcher_match_nonBooleanResult_throws() throws Exception { + try { + CelMatcher.compile("'not-boolean'"); + fail("Should throw IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must evaluate to boolean"); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java new file mode 100644 index 00000000000..63eecd6f23a --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import java.util.Collections; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelStringExtractorTest { + + @Test + public void extract_simpleString() throws Exception { + CelStringExtractor extractor = CelStringExtractor.compile("'foo'"); + String result = extractor.extract(Collections.emptyMap()); + assertThat(result).isEqualTo("foo"); + } + + @Test + public void extract_fromMap() throws Exception { + CelStringExtractor extractor = CelStringExtractor.compile("request['key']"); + Map input = Collections.singletonMap("key", "value"); + // "request" is the variable name used in CelStringExtractor + Map activation = Collections.singletonMap("request", input); + + String result = extractor.extract(activation); + assertThat(result).isEqualTo("value"); + } + + @Test + public void extract_nonStringResult_returnsNull() throws Exception { + // Expression returns DYN (compile time), but Integer at runtime + CelStringExtractor extractor = CelStringExtractor.compile("request"); + // "request" is an integer + Map activation = Collections.singletonMap("request", 123); + + String result = extractor.extract(activation); + assertThat(result).isNull(); + } + + @Test + public void extract_evaluationError_throws() throws Exception { + // "request.bad" on a string -> Runtime error (no such field/property) + CelStringExtractor extractor = CelStringExtractor.compile("request.bad"); + + try { + extractor.extract(Collections.singletonMap("request", "foo")); + fail("Should throw CelEvaluationException"); + } catch (dev.cel.runtime.CelEvaluationException e) { + // Expected + } + } + + @Test + public void compile_invalidSyntax_throws() { + try { + CelStringExtractor.compile("invalid syntax ???"); + fail("Should throw CelValidationException"); + } catch (Exception e) { + // Expected (CelValidationException or similar) + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java new file mode 100644 index 00000000000..1fe4b5871e3 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -0,0 +1,1482 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.CelMatcher; +import com.github.xds.type.matcher.v3.Matcher; +import com.github.xds.type.v3.CelExpression; +import com.github.xds.type.v3.CelExtractString; +import com.google.protobuf.Any; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import io.grpc.Metadata; +import io.grpc.xds.internal.matcher.MatchResult; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class UnifiedMatcherTest { + + private static CelCompiler COMPILER; + + @Test + public void verifyCelExtractStringInputNotSupported() { + CelExtractString proto = CelExtractString.getDefaultInstance(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unsupported input type"); + } + } + + @BeforeClass + public static void setupCompiler() { + COMPILER = CelCompilerFactory.standardCelCompilerBuilder() + .addVar("request", SimpleType.DYN) + .build(); + } + + private static CelMatcher createCelMatcher(String expression) { + try { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + CelProtoAbstractSyntaxTree protoAst = CelProtoAbstractSyntaxTree.fromCelAst(ast); + return CelMatcher.newBuilder() + .setExprMatch(CelExpression.newBuilder() + .setCelExprChecked(protoAst.toCheckedExpr()) + .build()) + .build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create CelMatcher for test", e); + } + } + + @Test + public void celMatcher_match() { + // Construct CelMatcher with checked expression + CelMatcher celMatcher = createCelMatcher("request.path == '/good'"); + // Predicate with HttpAttributesCelMatchInput + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + when(context.getPath()).thenReturn("/good"); + when(context.getMetadata()).thenReturn(new Metadata()); + + when(context.getId()).thenReturn("123"); + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + TypedExtensionConfig action = result.actions.get(0); + assertThat(action.getName()).isEqualTo("action1"); + + // Test mismatch + when(context.getPath()).thenReturn("/bad"); + result = matcher.match(context, 0); + TypedExtensionConfig noMatchAction = result.actions.get(0); + assertThat(noMatchAction.getName()).isEqualTo("no-match"); + } + + @Test + public void celMatcher_throwsIfReturnsString() { + try { + io.grpc.xds.internal.matcher.CelMatcher.compile("'should be bool'"); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must evaluate to boolean"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void celStringExtractor_throwsIfReturnsBool() { + try { + io.grpc.xds.internal.matcher.CelStringExtractor.compile("true"); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must evaluate to string"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void celMatcher_headers() { + CelMatcher celMatcher = createCelMatcher("request.headers['x-test'] == 'value'"); + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + when(context.getPath()).thenReturn("/"); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-test", Metadata.ASCII_STRING_MARSHALLER), "value"); + when(context.getMetadata()).thenReturn(headers); + when(context.getId()).thenReturn("123"); + + MatchResult result = matcher.match(context, 0); + TypedExtensionConfig action = result.actions.get(0); + assertThat(action.getName()).isEqualTo("matched"); + } + + @Test + public void matcherList_keepMatching() { + // Matcher 1: matches path '/multi', action 'action1', keep_matching = true + // Matcher 2: matches path '/multi', action 'action2', keep_matching = false + Matcher.MatcherList.Predicate predicate1 = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("/multi"))) + .build(); + Matcher.MatcherList.FieldMatcher matcher1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(predicate1) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher matcher2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(predicate1) // Same predicate + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(matcher1) + .addMatchers(matcher2)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + // Mock header "path" + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/multi"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); + assertThat(result.actions.get(0).getName()).isEqualTo("action1"); + assertThat(result.actions.get(1).getName()).isEqualTo("action2"); + } + + @Test + public void onNoMatchShouldNotExecuteWhenKeepMatchingTrueAndMatchFound() { + Matcher.MatcherList.FieldMatcher matcher1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("/test")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched")) + .setKeepMatching(true)) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(matcher1)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("should-not-run"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/test"); + when(context.getMetadata()).thenReturn(metadata); + MatchResult result = matcher.match(context, 0); + + boolean bugExists = result.actions.stream() + .anyMatch(a -> a.getName().equals("should-not-run")); + assertThat(bugExists).isFalse(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void actionValidation_acceptsSupportedType() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance())))) + .build(); + // Type URL of HttpAttributesCelMatchInput + String supportedType = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + + // Should not throw + UnifiedMatcher.fromProto(proto, (type) -> type.equals(supportedType)); + } + + @Test + public void actionValidation_rejectsUnsupportedType() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance())))) + .build(); + + try { + UnifiedMatcher.fromProto(proto, (type) -> false); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unsupported action type"); + assertThat(e).hasMessageThat().contains( + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"); + } + } + + @Test + public void recursionLimit_validation_should_fail_at_parse_time() { + Matcher current = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("leaf"))) + .build(); + + for (int i = 0; i < 20; i++) { + Matcher wrapper = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setCustomMatch(TypedExtensionConfig.newBuilder().setName("dummy")))) + .setOnMatch(Matcher.OnMatch.newBuilder().setMatcher(current)))) + .build(); + current = wrapper; + } + + try { + UnifiedMatcher.fromProto(current); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for depth > 16"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + private static Matcher.MatcherList.Predicate createHeaderMatchPredicate( + String header, String value) { + return Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(header).build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact(value))) + .build(); + } + + @Test + public void andMatcher_allTrue_matches() { + Matcher.MatcherList.Predicate p1 = createHeaderMatchPredicate("h1", "v1"); + Matcher.MatcherList.Predicate p2 = createHeaderMatchPredicate("h2", "v2"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(p1).addPredicate(p2))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v2"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void andMatcher_oneFalse_fails() { + Matcher.MatcherList.Predicate p1 = createHeaderMatchPredicate("h1", "v1"); + Matcher.MatcherList.Predicate p2 = createHeaderMatchPredicate("h2", "v2"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(p1).addPredicate(p2))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + // h2 is missing + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); // matched by onNoMatch + assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + } + + @Test + public void orMatcher_oneTrue_matches() { + Matcher.MatcherList.Predicate p1 = createHeaderMatchPredicate("h1", "v1"); + Matcher.MatcherList.Predicate p2 = createHeaderMatchPredicate("h2", "v2"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setOrMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(p1).addPredicate(p2))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v2"); + // h1 is missing + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void notMatcher_invert() { + Matcher.MatcherList.Predicate p1 = createHeaderMatchPredicate("h1", "v1"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setNotMatcher(p1)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + // h1 is missing, so inner p1 is false. NOT(false) -> True. + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void matcherTree_keepMatching_aggregate() { + // MatcherTree: Input = 'path'. + // Map: '/prefix' -> Action 'A1', KeepMatching=True + // OnNoMatch -> Action 'A2' + + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true) + .build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/prefix/something"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // Correct behavior per gRFC A106: onNoMatch is ONLY for when no match is found. + // Since we found "A1", onNoMatch ("A2") should NOT be executed. + // keepMatching=true simply means we return with matched=true and let the parent decide. + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + } + + @Test + public void requestUrlPath_available() { + CelMatcher celMatcher = createCelMatcher("request.url_path == '/path/without/query'"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + when(context.getPath()).thenReturn("/path/without/query"); + when(context.getMetadata()).thenReturn(new Metadata()); + when(context.getId()).thenReturn("123"); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void matchInput_headerName_invalidLength() { + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("") + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + + // Empty invalid + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("range [1, 16384)"); + } + + // Too long invalid + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 16384; i++) { + sb.append("a"); + } + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput longProto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(sb.toString()) + .build(); + TypedExtensionConfig longConfig = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(longProto)) + .build(); + + try { + UnifiedMatcher.resolveInput(longConfig); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("range [1, 16384)"); + } + } + + @Test + public void matchInput_headerName_invalidChars_throws() { + // Uppercase not allowed in HTTP/2 validation by Metadata.Key + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("UpperCase") + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for invalid header name"); + } catch (IllegalArgumentException e) { + // Expected + } + } + + @Test + public void invalidInputCombination_stringMatcherWithCelInput_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("any")))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with StringMatcher"); + } + } + + @Test + public void invalidInputCombination_matcherTreeWithCelInput_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("key", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build()))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + } + + @Test + public void matchInput_headerName_binary() { + String headerName = "test-bin"; + byte[] binaryValue = new byte[] {1, 2, 3}; + String expectedBase64 = com.google.common.io.BaseEncoding.base64().encode(binaryValue); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), binaryValue); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadata); + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(headerName) + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + + MatchInput input = UnifiedMatcher.resolveInput(config); + + assertThat(input.apply(context)).isEqualTo(expectedBase64); + } + + @Test + public void matchInput_headerName_te_returnsNull() { + String headerName = "te"; + Metadata metadata = new Metadata(); + // "te" is technically ASCII. + metadata.put( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER), "trailers"); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadata); + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(headerName) + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + + MatchInput input = UnifiedMatcher.resolveInput(config); + + assertThat(input.apply(context)).isNull(); + } + + @Test + public void matcherTree_customMatch_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setName("custom"))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("does not support custom_match"); + } + } + + @Test + public void matcherTree_noMap_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build())))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("must have either exact_match_map or prefix_match_map"); + } + } + + @Test + public void matcherTree_exactMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("foo", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_foo")).build()) + .putMap("bar", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_bar")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-key", Metadata.ASCII_STRING_MARSHALLER), "foo"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched_foo"); + } + + @Test + public void matcherTree_prefixMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/api", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("api")).build()) + .putMap("/api/v1", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("apiv1")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/api/v1/users"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // Longest prefix wins + assertThat(result.actions.get(0).getName()).isEqualTo("apiv1"); + } + + @Test + public void requestHeaders_equalityCheck_failsSafely() { + // request.headers == {'a':'1','b':'2','c':'3'} requires entrySet(), + // which throws UnsupportedOperationException + // We want to ensure this is caught and treated as a mismatch or error, not a crash. + // HeadersWrapper has 3 pseudo headers by default, so size is 3. + // We match size to force entrySet check. + CelMatcher celMatcher = createCelMatcher( + "request.headers == {'a':'1','b':'2','c':'3'}"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + // Should NOT throw exception + MatchResult result = matcher.match(context, 0); + + // Predicate should evaluate to false (due to error or mismatch), + // so it falls through to onNoMatch. onNoMatch has an action, so matched=true + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + } + + @Test + public void noOpMatcher_delegatesToOnNoMatch() { + // Matcher with no list and no tree -> NoOpMatcher + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + // Verify it is indeed NoOpMatcher + assertThat(matcher.getClass().getSimpleName()).isEqualTo("NoOpMatcher"); + + MatchContext context = mock(MatchContext.class); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("fallback"); + } + + @Test + public void matcherRunner_checkMatch_returnsActions() { + Matcher validProto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v1")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("runner-action"))))) + .build(); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + when(context.getMetadata()).thenReturn(metadata); + java.util.List results = + io.grpc.xds.internal.matcher.MatcherRunner.checkMatch(validProto, context); + + assertThat(results).isNotNull(); + assertThat(results).hasSize(1); + assertThat(results.get(0).getName()).isEqualTo("runner-action"); + } + + @Test + public void matcherRunner_checkMatch_returnsNullOnNoMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v1")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("runner-action"))))) + .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); // Empty headers + java.util.List results = + io.grpc.xds.internal.matcher.MatcherRunner.checkMatch(proto, context); + + assertThat(results).isNull(); + } + + @Test + public void matcherList_firstMatchWins_evenIfNestedNoMatch() { + Matcher.MatcherList.Predicate predicate = createHeaderMatchPredicate("path", "/common"); + Matcher.MatcherList.FieldMatcher matcher1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(predicate) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder())) + .build(); + Matcher.MatcherList.FieldMatcher matcher2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(predicate) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("should-not-run"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(matcher1) + .addMatchers(matcher2)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match-global"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/common"); + when(context.getMetadata()).thenReturn(metadata); + MatchResult result = matcher.match(context, 0); + + if (result.matched) { + for (TypedExtensionConfig action : result.actions) { + assertThat(action.getName()).isNotEqualTo("should-not-run"); + } + } + } + + @Test + public void matcherTree_exactMatch_shouldNotFallBackToOnNoMatch_ifKeyFound() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("foo", Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder()) // Empty matcher = No Match + .build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("x-key", Metadata.ASCII_STRING_MARSHALLER), "foo"); + when(context.getMetadata()).thenReturn(metadata); + MatchResult result = matcher.match(context, 0); + + if (result.matched) { + for (TypedExtensionConfig action : result.actions) { + assertThat(action.getName()).isNotEqualTo("fallback"); + } + } + } + + @Test + public void stringMatcher_contains_ignoreCase() { + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-test").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setContains("bar") + .setIgnoreCase(true)) + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("x-test", Metadata.ASCII_STRING_MARSHALLER), "FooBaR"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void matcherTree_emptyMap_throws() { + // Empty exact match map + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder())) // Empty map + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exact_match_map must contain at least one entry"); + } + + // Empty prefix match map + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder())) // Empty map + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("prefix_match_map must contain at least one entry"); + } + } + + @Test + public void stringMatcher_emptyPatterns_throws() { + // Empty Prefix + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setPrefix("")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for empty prefix"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("prefix (match_pattern) must be non-empty"); + } + + // Empty Suffix + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSuffix("")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for empty suffix"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("suffix (match_pattern) must be non-empty"); + } + + // Empty Contains + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setContains("")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for empty contains"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("contains (match_pattern) must be non-empty"); + } + + // Empty Regex + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSafeRegex(com.github.xds.type.matcher.v3.RegexMatcher.newBuilder() + .setRegex(""))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for empty regex"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("regex (match_pattern) must be non-empty"); + } + } + + @Test + public void celMatcher_wrongInput_throws() { + // Attempt to use CelMatcher with HeaderMatchInput (invalid) + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput inputProto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("test").build(); + com.github.xds.type.matcher.v3.CelMatcher celMatcherProto = createCelMatcher("true"); + + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(inputProto))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcherProto)) + .setName("cel_matcher")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for incompatible input"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("CelMatcher can only be used with HttpAttributesCelMatchInput"); + } + } + + @Test + public void celMatcher_withoutCelExprChecked_throws() { + com.github.xds.type.matcher.v3.CelMatcher celMatcherParsed = + com.github.xds.type.matcher.v3.CelMatcher.newBuilder() + .setExprMatch(com.github.xds.type.v3.CelExpression.newBuilder() + .setCelExprParsed(dev.cel.expr.ParsedExpr.getDefaultInstance())) + .build(); + + try { + UnifiedMatcher.fromProto(wrapInMatcher(celMatcherParsed)); + org.junit.Assert.fail( + "Should have thrown IllegalArgumentException for missing cel_expr_checked"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("CelMatcher must have cel_expr_checked"); + } + } + + @Test + public void celMatcher_withCelExprString_throws() { + // Create a dummy ParsedExpr using the correct type for xds.type.v3.CelExpression + dev.cel.expr.ParsedExpr parsedExpr = + dev.cel.expr.ParsedExpr.getDefaultInstance(); + com.github.xds.type.matcher.v3.CelMatcher celMatcherParsed = + com.github.xds.type.matcher.v3.CelMatcher.newBuilder() + .setExprMatch(com.github.xds.type.v3.CelExpression.newBuilder() + .setCelExprParsed(parsedExpr) + .build()) + .build(); + Matcher proto = wrapInMatcher(celMatcherParsed); + + try { + UnifiedMatcher.fromProto(proto); + org.junit.Assert.fail( + "Should have thrown IllegalArgumentException for using cel_expr_parsed"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("CelMatcher must have cel_expr_checked"); + } + } + + private Matcher wrapInMatcher(com.github.xds.type.matcher.v3.CelMatcher celMatcher) { + return Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher)) + .setName("cel_matcher")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build(); + } + + @Test + public void matcherList_keepMatching_verification() { + // M1: matches, action A1, keep matching + // M2: matches, action A2, stop matching + // M3: matches, action A3 (should be ignored) + Matcher.MatcherList.FieldMatcher m1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("h").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("val")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher m2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("h").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("val")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2")) + .setKeepMatching(false)) + .build(); + Matcher.MatcherList.FieldMatcher m3 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("h").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("val")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A3"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(m1) + .addMatchers(m2) + .addMatchers(m3)) + .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("h", "val")); + io.grpc.xds.internal.matcher.UnifiedMatcher matcher = + io.grpc.xds.internal.matcher.UnifiedMatcher.fromProto(proto, (t) -> true); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + assertThat(result.actions.get(1).getName()).isEqualTo("A2"); + } + + @Test + public void matcherTree_keepMatching_longestPrefixFirst() { + // Prefix /abc: A1, keep=true + // Prefix /ab: A2, keep=true + // Prefix /a: A3, keep=FALSE + + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/abc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true).build()) + .putMap("/ab", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2")) + .setKeepMatching(true).build()) + .putMap("/a", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A3")) + .setKeepMatching(false).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(map)) + .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("path", "/abc")); + io.grpc.xds.internal.matcher.UnifiedMatcher matcher = + io.grpc.xds.internal.matcher.UnifiedMatcher.fromProto(proto, (t) -> true); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + // Implementation sorts longest to shortest: /abc, /ab, /a + // 1. /abc matches -> A1. keep=true. + // 2. /ab matches -> A2. keep=true. + // 3. /a matches -> A3. keep=false -> STOP. + assertThat(result.actions).hasSize(3); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + assertThat(result.actions.get(1).getName()).isEqualTo("A2"); + assertThat(result.actions.get(2).getName()).isEqualTo("A3"); + } + + private Metadata metadataWith(String key, String value) { + Metadata m = new Metadata(); + m.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + return m; + } + + // Below are the 4 Unified Matcher: Evaluation Examples from gRFC A106: https://github.com/grpc/proposal/pull/520 + @Test + public void matcherList_example1_simpleLinearMatch() { + // Matcher 1: x-user-segment == "premium" -> "route_to_premium_cluster" + // Matcher 2: x-user-segment prefix "standard-" -> "route_to_standard_cluster" + // On No Match: -> "route_to_default_cluster" + + Matcher.MatcherList.FieldMatcher matcher1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-user-segment").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("premium")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("route_to_premium_cluster"))) + .build(); + Matcher.MatcherList.FieldMatcher matcher2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-user-segment").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setPrefix("standard-")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("route_to_standard_cluster"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(matcher1) + .addMatchers(matcher2)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("route_to_default_cluster"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + + // Scenario 1: Matches second matcher (standard user) + MatchContext context1 = mock(MatchContext.class); + when(context1.getMetadata()).thenReturn(metadataWith("x-user-segment", "standard-user-1")); + + MatchResult result1 = matcher.match(context1, 0); + assertThat(result1.matched).isTrue(); + assertThat(result1.actions).hasSize(1); + assertThat(result1.actions.get(0).getName()).isEqualTo("route_to_standard_cluster"); + + // Scenario 2: Matches first matcher (premium user) + MatchContext context2 = mock(MatchContext.class); + when(context2.getMetadata()).thenReturn(metadataWith("x-user-segment", "premium")); + + MatchResult result2 = matcher.match(context2, 0); + assertThat(result2.matched).isTrue(); + assertThat(result2.actions).hasSize(1); + assertThat(result2.actions.get(0).getName()).isEqualTo("route_to_premium_cluster"); + + // Scenario 3: Matches neither (fallback to default) - "Request Input 2" from user + MatchContext context3 = mock(MatchContext.class); + when(context3.getMetadata()).thenReturn(metadataWith("x-user-segment", "guest")); + + MatchResult result3 = matcher.match(context3, 0); + assertThat(result3.matched).isTrue(); + // onNoMatch logic returns matched=true when it executes successfully + assertThat(result3.actions).hasSize(1); + assertThat(result3.actions.get(0).getName()).isEqualTo("route_to_default_cluster"); + } + + @Test + public void matcherList_example2_keepMatching() { + TypedExtensionConfig celInput = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance())) + .build(); + Matcher.MatcherList.FieldMatcher m1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("true"))).setName("match1")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action_1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher m2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("false"))).setName("match2")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action_2"))) + .build(); + Matcher.MatcherList.FieldMatcher m3 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("true"))).setName("match3")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action_3"))) + .build(); + Matcher.MatcherList.FieldMatcher m4 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("false"))).setName("match4")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action_4"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(m1) + .addMatchers(m2) + .addMatchers(m3) + .addMatchers(m4)) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + MatchResult result = matcher.match(mock(MatchContext.class), 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); + assertThat(result.actions.get(0).getName()).isEqualTo("action_1"); + assertThat(result.actions.get(1).getName()).isEqualTo("action_3"); + } + + @Test + public void matcherList_example3_nestedMatcher() { + TypedExtensionConfig celInput = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance())) + .build(); + + // Inner Matcher 1: False -> inner_matcher_1 + // Inner Matcher 2: True -> inner_matcher_2 + Matcher innerProto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("false")))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("inner_matcher_1")))) + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("true")))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("inner_matcher_2"))))) + .build(); + // Outer Matcher: True -> Nested Matcher + Matcher outerProto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("true")))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setMatcher(innerProto)))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(outerProto, (t) -> true); + MatchResult result = matcher.match(mock(MatchContext.class), 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("inner_matcher_2"); + } + + @Test + public void matcherTree_example4_prefixMap() { + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("grpc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("shorter_prefix")).build()) + .putMap("grpc.channelz", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("longer_prefix")).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-user-segment").build()))) + .setPrefixMatchMap(map)) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn( + metadataWith("x-user-segment", "grpc.channelz.v1.Channelz/GetTopChannels")); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("longer_prefix"); + } +} From 99aeef15192c1ca04b46fd6e191429e4b90d4649 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Tue, 3 Feb 2026 16:01:04 +0530 Subject: [PATCH 02/12] fix dependency issue --- MODULE.bazel | 2 ++ repositories.bzl | 2 ++ xds/BUILD.bazel | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/MODULE.bazel b/MODULE.bazel index 81d80ed7e1a..19399add674 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -21,6 +21,8 @@ IO_GRPC_GRPC_JAVA_ARTIFACTS = [ "com.google.re2j:re2j:1.8", "com.google.s2a.proto.v2:s2a-proto:0.1.3", "com.google.truth:truth:1.4.5", + "dev.cel:runtime:0.11.1", + "dev.cel:compiler:0.11.1", "com.squareup.okhttp:okhttp:2.7.5", "com.squareup.okio:okio:2.10.0", # 3.0+ needs swapping to -jvm; need work to avoid flag-day "io.netty:netty-buffer:4.1.130.Final", diff --git a/repositories.bzl b/repositories.bzl index c89e1bdff32..9d72fa525bc 100644 --- a/repositories.bzl +++ b/repositories.bzl @@ -25,6 +25,8 @@ IO_GRPC_GRPC_JAVA_ARTIFACTS = [ "com.google.re2j:re2j:1.8", "com.google.s2a.proto.v2:s2a-proto:0.1.3", "com.google.truth:truth:1.4.5", + "dev.cel:runtime:0.11.1", + "dev.cel:compiler:0.11.1", "com.squareup.okhttp:okhttp:2.7.5", "com.squareup.okio:okio:2.10.0", # 3.0+ needs swapping to -jvm; need work to avoid flag-day "io.netty:netty-buffer:4.1.130.Final", diff --git a/xds/BUILD.bazel b/xds/BUILD.bazel index 54259847e2c..6a7586ee533 100644 --- a/xds/BUILD.bazel +++ b/xds/BUILD.bazel @@ -41,7 +41,8 @@ java_library( artifact("com.google.errorprone:error_prone_annotations"), artifact("com.google.guava:guava"), artifact("com.google.re2j:re2j"), - artifact("dev.cel:cel-bundle"), + artifact("dev.cel:runtime"), + artifact("dev.cel:compiler"), artifact("io.netty:netty-buffer"), artifact("io.netty:netty-codec"), artifact("io.netty:netty-common"), From db4d88577d45fb9928d40cc984c5bcc4ea9456c9 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Tue, 3 Feb 2026 16:55:35 +0530 Subject: [PATCH 03/12] add some unit tests to increase coverage --- .../internal/matcher/CelEnvironmentTest.java | 66 +++++++- .../internal/matcher/UnifiedMatcherTest.java | 153 ++++++++++++++++++ 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java index 3bf0896d3b8..5cd1f4eada5 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -248,8 +248,8 @@ public void lazyRequestMap_unknownKey_returnsNull() { Map map = (Map) env.find("request").get(); assertThat(map.get("unknown")).isNull(); - assertThat(map.get(123)).isNull(); - assertThat(map.containsKey(123)).isFalse(); + assertThat(map.get(new Object())).isNull(); + assertThat(map.containsKey(new Object())).isFalse(); } @Test @@ -280,4 +280,66 @@ public void celMatcher_match_nonBooleanResult_throws() throws Exception { assertThat(e).hasMessageThat().contains("must evaluate to boolean"); } } + + @Test + public void headersWrapper_get_nonStringKey_returnsNull() { + MatchContext context = mock(MatchContext.class); + Map headers = new HeadersWrapper(context); + assertThat(headers.get(new Object())).isNull(); + } + + @Test + public void headersWrapper_getHeader_binary_multipleValues() { + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + byte[] val1 = new byte[] { 1, 2, 3 }; + byte[] val2 = new byte[] { 4, 5, 6 }; + Metadata.Key key = Metadata.Key.of("bin-header-bin", Metadata.BINARY_BYTE_MARSHALLER); + metadata.put(key, val1); + metadata.put(key, val2); + when(context.getMetadata()).thenReturn(metadata); + + Map headers = new HeadersWrapper(context); + String expected = com.google.common.io.BaseEncoding.base64().encode(val1) + "," + + com.google.common.io.BaseEncoding.base64().encode(val2); + assertThat(headers.get("bin-header-bin")).isEqualTo(expected); + } + + @Test + public void headersWrapper_containsKey_nonStringKey_returnsFalse() { + MatchContext context = mock(MatchContext.class); + Map headers = new HeadersWrapper(context); + assertThat(headers.containsKey(new Object())).isFalse(); + } + + @Test + public void headersWrapper_containsKey_pseudoHeader_returnsTrue() { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + Map headers = new HeadersWrapper(context); + assertThat(headers.containsKey(":method")).isTrue(); + assertThat(headers.containsKey(":path")).isTrue(); + assertThat(headers.containsKey(":authority")).isTrue(); + } + + @Test + public void headersWrapper_keySet_containsExpectedKeys() { + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("custom-key", Metadata.ASCII_STRING_MARSHALLER), "val"); + when(context.getMetadata()).thenReturn(metadata); + + Map headers = new HeadersWrapper(context); + java.util.Set keys = headers.keySet(); + + assertThat(keys).containsAtLeast("custom-key", ":method", ":path", ":authority"); + } + + @Test + public void headersWrapper_getHeader_missingBinaryHeader_returnsNull() { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + Map headers = new HeadersWrapper(context); + assertThat(headers.get("missing-bin")).isNull(); + } } diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 1fe4b5871e3..4ca597d41f6 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -133,6 +133,42 @@ public void celMatcher_throwsIfReturnsString() { } } + @Test + public void celMatcher_evaluationError_returnsFalse() { + CelMatcher celMatcher = createCelMatcher("int(request.path) == 0"); + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + when(context.getPath()).thenReturn("not-an-int"); + // Ensure metadata access (if any by environment) checks out + when(context.getMetadata()).thenReturn(new Metadata()); + when(context.getId()).thenReturn("1"); + + MatchResult result = matcher.match(context, 0); + // Should return false for match, so it falls through to no-match + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + } + @Test public void celStringExtractor_throwsIfReturnsBool() { try { @@ -852,6 +888,123 @@ public void matcherRunner_checkMatch_returnsNullOnNoMatch() { assertThat(results).isNull(); } + @Test + public void predicate_missingType_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.getDefaultInstance(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Predicate must have one of"); + } + } + + @Test + public void celMatcher_missingExprMatch_throws() { + com.github.xds.type.matcher.v3.CelMatcher celProto = + com.github.xds.type.matcher.v3.CelMatcher.getDefaultInstance(); + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack(celProto)))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("CelMatcher must have expr_match"); + } + } + + @Test + public void singlePredicate_unsupportedCustomMatcher_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(com.google.protobuf.Empty.getDefaultInstance())))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unsupported custom_match matcher"); + } + } + + @Test + public void singlePredicate_missingInput_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("foo"))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("SinglePredicate must have input"); + } + } + + @Test + public void singlePredicate_missingMatcher_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance())))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "SinglePredicate must have either value_match or custom_match"); + } + } + + @Test + public void orMatcher_tooFewPredicates_throws() { + Matcher.MatcherList.Predicate.PredicateList protoList = + Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setName("i")) + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("v")))) + .build(); + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setOrMatcher(protoList) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); + } + } + + @Test + public void andMatcher_tooFewPredicates_throws() { + Matcher.MatcherList.Predicate.PredicateList proto = + Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setName("i")) + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("v")))) + .build(); + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(proto).build()); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); + } + } + @Test public void matcherList_firstMatchWins_evenIfNestedNoMatch() { Matcher.MatcherList.Predicate predicate = createHeaderMatchPredicate("path", "/common"); From 33eff2168faac209ad195bf3252d59aa37c3a899 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 13:28:09 +0530 Subject: [PATCH 04/12] add some more unit tests --- .../internal/matcher/CelEnvironmentTest.java | 64 +++ .../matcher/CelStringExtractorTest.java | 23 +- .../xds/internal/matcher/MatcherTreeTest.java | 415 ++++++++++++++++++ .../internal/matcher/UnifiedMatcherTest.java | 210 +-------- 4 files changed, 502 insertions(+), 210 deletions(-) create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java index 5cd1f4eada5..97905850f57 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -342,4 +342,68 @@ public void headersWrapper_getHeader_missingBinaryHeader_returnsNull() { Map headers = new HeadersWrapper(context); assertThat(headers.get("missing-bin")).isNull(); } + + @Test + public void celEnvironment_resolvesRefererAndUserAgent() { + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("referer", Metadata.ASCII_STRING_MARSHALLER), "http://example.com"); + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-test"); + when(context.getMetadata()).thenReturn(metadata); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + assertThat(env.find("request.referer").get()).isEqualTo("http://example.com"); + assertThat(env.find("request.useragent").get()).isEqualTo("grpc-test"); + } + + @Test + public void celEnvironment_joinsMultipleHeaderValues() { + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + Metadata.Key key = Metadata.Key.of("referer", Metadata.ASCII_STRING_MARSHALLER); + metadata.put(key, "v1"); + metadata.put(key, "v2"); + when(context.getMetadata()).thenReturn(metadata); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + // Tests the String.join logic in getHeader + assertThat(env.find("request.referer").get()).isEqualTo("v1,v2"); + } + + @Test + public void celEnvironment_find_invalidFormat() { + MatchContext context = mock(MatchContext.class); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + // Name not starting with "request" + assertThat(env.find("other.path").isPresent()).isFalse(); + + // Name with too many components (though Splitter limit is 2, the second part will be "a.b") + // getRequestField("a.b") will hit the default case and return null. + assertThat(env.find("request.a.b").isPresent()).isFalse(); + } + + @Test + public void lazyRequestMap_additionalMethods() { + MatchContext context = mock(MatchContext.class); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + Map map = (Map) env.find("request").get(); + + assertThat(map.isEmpty()).isFalse(); + + // Test getting a known key that returns null (e.g., time) + // This covers the 'if (val == null)' branch in LazyRequestMap.get + assertThat(map.get("time")).isNull(); + } + + @Test + public void celEnvironment_missingHeader_returnsEmptyString() { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + assertThat(env.find("request.referer").get()).isEqualTo(""); + } } diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java index 63eecd6f23a..a6adefd8704 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java @@ -39,7 +39,6 @@ public void extract_simpleString() throws Exception { public void extract_fromMap() throws Exception { CelStringExtractor extractor = CelStringExtractor.compile("request['key']"); Map input = Collections.singletonMap("key", "value"); - // "request" is the variable name used in CelStringExtractor Map activation = Collections.singletonMap("request", input); String result = extractor.extract(activation); @@ -54,6 +53,7 @@ public void extract_nonStringResult_returnsNull() throws Exception { Map activation = Collections.singletonMap("request", 123); String result = extractor.extract(activation); + // Since 123 is not a String, it returns null assertThat(result).isNull(); } @@ -79,4 +79,25 @@ public void compile_invalidSyntax_throws() { // Expected (CelValidationException or similar) } } + + @Test + public void extract_withCelVariableResolver() throws Exception { + CelStringExtractor extractor = CelStringExtractor.compile("'val'"); + dev.cel.runtime.CelVariableResolver resolver = name -> java.util.Optional.empty(); + + // Exercises the CelVariableResolver branch + assertThat(extractor.extract(resolver)).isEqualTo("val"); + } + + @Test + public void extract_unsupportedInputType_throws() throws Exception { + CelStringExtractor extractor = CelStringExtractor.compile("'foo'"); + try { + // Pass a String instead of a Map or Resolver + extractor.extract("not-a-map"); + fail("Should have thrown CelEvaluationException"); + } catch (dev.cel.runtime.CelEvaluationException e) { + assertThat(e).hasMessageThat().contains("Unsupported input type"); + } + } } diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java new file mode 100644 index 00000000000..08476870a19 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java @@ -0,0 +1,415 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import com.google.protobuf.Any; +import io.grpc.Metadata; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class MatcherTreeTest { + + @Test + public void matcherTree_missingInput_throws() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder().build(); + try { + new MatcherTree(proto, null, s -> true); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("MatcherTree must have input"); + } + } + + @Test + public void matcherTree_unsupportedCelInput_throws() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .build(); + try { + new MatcherTree(proto, null, s -> true); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + } + + @Test + public void matcherTree_emptyMaps_throws() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.getDefaultInstance()) + .build(); + try { + new MatcherTree(proto, null, s -> true); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("MatcherTree exact_match_map must contain at least one entry"); + } + } + + @Test + public void matcherTree_maxRecursionDepth_returnsNoMatch() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build())) + .build(); + + MatcherTree tree = new MatcherTree(proto, null, s -> true); + MatchContext context = mock(MatchContext.class); + MatchResult result = tree.match(context, 17); + assertThat(result.matched).isFalse(); + } + + @Test + public void matcherTree_nonStringInput_fallsBack() { + + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build())) + .build(); + + MatcherTree tree = new MatcherTree(proto, + Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback")).build(), + s -> true); + + MatchContext context = mock(MatchContext.class); + + when(context.getMetadata()).thenReturn(new io.grpc.Metadata()); // No headers + + MatchResult result = tree.match(context, 0); + assertThat(result.matched).isTrue(); // onNoMatch matched + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("fallback"); + } + + @Test + public void matcherTree_noExactMatch_fallsBackToOnNoMatch() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build())) + .build(); + + Matcher.OnMatch onNoMatch = Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback")) + .build(); + MatcherTree tree = new MatcherTree(proto, onNoMatch, s -> true); + + MatchContext context = mock(MatchContext.class); + io.grpc.Metadata metadata = new io.grpc.Metadata(); + metadata.put(io.grpc.Metadata.Key.of("foo", io.grpc.Metadata.ASCII_STRING_MARSHALLER), "other"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = tree.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("fallback"); + } + + @Test + public void matcherTree_prefixFoundButNestedFailed_returnNoMatch_notOnNoMatch() { + Matcher.MatcherTree nestedProto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("bar").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("baz", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("n/a")).build())) + .build(); + + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder().setMatcherTree(nestedProto)) + .build())) + .build(); + + Matcher.OnMatch onNoMatch = Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("should-not-be-called")) + .build(); + + MatcherTree tree = new MatcherTree(proto, onNoMatch, s -> true); + + MatchContext context = mock(MatchContext.class); + io.grpc.Metadata metadata = new io.grpc.Metadata(); + metadata.put(io.grpc.Metadata.Key.of("path", io.grpc.Metadata.ASCII_STRING_MARSHALLER), + "/prefix/foo"); + metadata.put(io.grpc.Metadata.Key.of("bar", io.grpc.Metadata.ASCII_STRING_MARSHALLER), "wrong"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = tree.match(context, 0); + + assertThat(result.matched).isFalse(); + // Verify it isn't the onNoMatch action + if (!result.actions.isEmpty()) { + assertThat(result.actions.get(0).getName()).isNotEqualTo("should-not-be-called"); + } + } + + @Test + public void matcherTree_noMap_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build())))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("must have either exact_match_map or prefix_match_map"); + } + } + + @Test + public void matcherTree_customMatch_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setName("custom"))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("does not support custom_match"); + } + } + + @Test + public void matcherTree_exactMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("foo", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_foo")).build()) + .putMap("bar", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_bar")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-key", Metadata.ASCII_STRING_MARSHALLER), "foo"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched_foo"); + } + + @Test + public void matcherTree_prefixMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/api", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("api")).build()) + .putMap("/api/v1", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("apiv1")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/api/v1/users"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // Longest prefix wins + assertThat(result.actions.get(0).getName()).isEqualTo("apiv1"); + } + + @Test + public void matcherTree_keepMatching_aggregate() { + // MatcherTree: Input = 'path'. + // Map: '/prefix' -> Action 'A1', KeepMatching=True + // OnNoMatch -> Action 'A2' + + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true) + .build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/prefix/something"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // Correct behavior per gRFC A106: onNoMatch is ONLY for when no match is found. + // Since we found "A1", onNoMatch ("A2") should NOT be executed. + // keepMatching=true simply means we return with matched=true and let the parent decide. + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + } + + @Test + public void matcherTree_keepMatching_longestPrefixFirst() { + // Prefix /abc: A1, keep=true + // Prefix /ab: A2, keep=true + // Prefix /a: A3, keep=FALSE + + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/abc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true).build()) + .putMap("/ab", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2")) + .setKeepMatching(true).build()) + .putMap("/a", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A3")) + .setKeepMatching(false).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(map)) + .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("path", "/abc")); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + // Implementation sorts longest to shortest: /abc, /ab, /a + // 1. /abc matches -> A1. keep=true. + // 2. /ab matches -> A2. keep=true. + // 3. /a matches -> A3. keep=false -> STOP. + assertThat(result.actions).hasSize(3); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + assertThat(result.actions.get(1).getName()).isEqualTo("A2"); + assertThat(result.actions.get(2).getName()).isEqualTo("A3"); + } + + @Test + public void matcherTree_example4_prefixMap() { + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("grpc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("shorter_prefix")).build()) + .putMap("grpc.channelz", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("longer_prefix")).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-user-segment").build()))) + .setPrefixMatchMap(map)) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn( + metadataWith("x-user-segment", "grpc.channelz.v1.Channelz/GetTopChannels")); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("longer_prefix"); + } + + @Test + public void invalidInputCombination_matcherTreeWithCelInput_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("key", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build()))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + } + + private Metadata metadataWith(String key, String value) { + Metadata m = new Metadata(); + m.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + return m; + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 4ca597d41f6..540c2e25bef 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -488,41 +488,7 @@ public void notMatcher_invert() { assertThat(result.actions.get(0).getName()).isEqualTo("matched"); } - @Test - public void matcherTree_keepMatching_aggregate() { - // MatcherTree: Input = 'path'. - // Map: '/prefix' -> Action 'A1', KeepMatching=True - // OnNoMatch -> Action 'A2' - - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("path").build()))) - .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("/prefix", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A1")) - .setKeepMatching(true) - .build()))) - .setOnNoMatch(Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) - .build(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - - MatchContext context = mock(MatchContext.class); - Metadata metadata = new Metadata(); - metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/prefix/something"); - when(context.getMetadata()).thenReturn(metadata); - MatchResult result = matcher.match(context, 0); - assertThat(result.matched).isTrue(); - // Correct behavior per gRFC A106: onNoMatch is ONLY for when no match is found. - // Since we found "A1", onNoMatch ("A2") should NOT be executed. - // keepMatching=true simply means we return with matched=true and let the parent decide. - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("A1"); - } @Test public void requestUrlPath_available() { @@ -635,25 +601,7 @@ public void invalidInputCombination_stringMatcherWithCelInput_throws() { } } - @Test - public void invalidInputCombination_matcherTreeWithCelInput_throws() { - try { - UnifiedMatcher.fromProto(Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput - .getDefaultInstance()))) - .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("key", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("action")).build()))) - .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat() - .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); - } - } + @Test public void matchInput_headerName_binary() { @@ -699,96 +647,9 @@ public void matchInput_headerName_te_returnsNull() { assertThat(input.apply(context)).isNull(); } - @Test - public void matcherTree_customMatch_throws() { - try { - UnifiedMatcher.fromProto(Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("key").build()))) - .setCustomMatch(TypedExtensionConfig.newBuilder().setName("custom"))) - .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("does not support custom_match"); - } - } - @Test - public void matcherTree_noMap_throws() { - try { - UnifiedMatcher.fromProto(Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("key").build())))) - .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat() - .contains("must have either exact_match_map or prefix_match_map"); - } - } - @Test - public void matcherTree_exactMatch() { - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("x-key").build()))) - .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("foo", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("matched_foo")).build()) - .putMap("bar", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("matched_bar")).build()))) - .setOnNoMatch(Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) - .build(); - - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - Metadata headers = new Metadata(); - headers.put(Metadata.Key.of("x-key", Metadata.ASCII_STRING_MARSHALLER), "foo"); - when(context.getMetadata()).thenReturn(headers); - - MatchResult result = matcher.match(context, 0); - assertThat(result.matched).isTrue(); - assertThat(result.actions.get(0).getName()).isEqualTo("matched_foo"); - } - - @Test - public void matcherTree_prefixMatch() { - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("path").build()))) - .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("/api", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("api")).build()) - .putMap("/api/v1", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("apiv1")).build()))) - .setOnNoMatch(Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) - .build(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - Metadata headers = new Metadata(); - headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/api/v1/users"); - when(context.getMetadata()).thenReturn(headers); - - MatchResult result = matcher.match(context, 0); - assertThat(result.matched).isTrue(); - // Longest prefix wins - assertThat(result.actions.get(0).getName()).isEqualTo("apiv1"); - } @Test public void requestHeaders_equalityCheck_failsSafely() { @@ -1378,48 +1239,7 @@ public void matcherList_keepMatching_verification() { assertThat(result.actions.get(1).getName()).isEqualTo("A2"); } - @Test - public void matcherTree_keepMatching_longestPrefixFirst() { - // Prefix /abc: A1, keep=true - // Prefix /ab: A2, keep=true - // Prefix /a: A3, keep=FALSE - Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("/abc", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A1")) - .setKeepMatching(true).build()) - .putMap("/ab", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A2")) - .setKeepMatching(true).build()) - .putMap("/a", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A3")) - .setKeepMatching(false).build()) - .build(); - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("path").build()))) - .setPrefixMatchMap(map)) - .build(); - - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("path", "/abc")); - io.grpc.xds.internal.matcher.UnifiedMatcher matcher = - io.grpc.xds.internal.matcher.UnifiedMatcher.fromProto(proto, (t) -> true); - MatchResult result = matcher.match(context, 0); - - assertThat(result.matched).isTrue(); - // Implementation sorts longest to shortest: /abc, /ab, /a - // 1. /abc matches -> A1. keep=true. - // 2. /ab matches -> A2. keep=true. - // 3. /a matches -> A3. keep=false -> STOP. - assertThat(result.actions).hasSize(3); - assertThat(result.actions.get(0).getName()).isEqualTo("A1"); - assertThat(result.actions.get(1).getName()).isEqualTo("A2"); - assertThat(result.actions.get(2).getName()).isEqualTo("A3"); - } private Metadata metadataWith(String key, String value) { Metadata m = new Metadata(); @@ -1604,32 +1424,4 @@ public void matcherList_example3_nestedMatcher() { assertThat(result.actions).hasSize(1); assertThat(result.actions.get(0).getName()).isEqualTo("inner_matcher_2"); } - - @Test - public void matcherTree_example4_prefixMap() { - Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("grpc", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("shorter_prefix")).build()) - .putMap("grpc.channelz", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("longer_prefix")).build()) - .build(); - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("x-user-segment").build()))) - .setPrefixMatchMap(map)) - .build(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); - - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn( - metadataWith("x-user-segment", "grpc.channelz.v1.Channelz/GetTopChannels")); - MatchResult result = matcher.match(context, 0); - - assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("longer_prefix"); - } } From e28c8f5b301f4df610d4c95f8496e5f2392d2a45 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 13:50:46 +0530 Subject: [PATCH 05/12] fix unchecked issue --- .../xds/internal/matcher/CelEnvironmentTest.java | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java index 97905850f57..e7c3baaef58 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -188,7 +188,6 @@ public void celEnvironment_timeField_supportedButNull() { MatchContext context = mock(MatchContext.class); GrpcCelEnvironment env = new GrpcCelEnvironment(context); - // find() returns empty for null values Optional result = env.find("request.time"); assertThat(result.isPresent()).isFalse(); @@ -350,7 +349,6 @@ public void celEnvironment_resolvesRefererAndUserAgent() { metadata.put(Metadata.Key.of("referer", Metadata.ASCII_STRING_MARSHALLER), "http://example.com"); metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-test"); when(context.getMetadata()).thenReturn(metadata); - GrpcCelEnvironment env = new GrpcCelEnvironment(context); assertThat(env.find("request.referer").get()).isEqualTo("http://example.com"); @@ -365,7 +363,6 @@ public void celEnvironment_joinsMultipleHeaderValues() { metadata.put(key, "v1"); metadata.put(key, "v2"); when(context.getMetadata()).thenReturn(metadata); - GrpcCelEnvironment env = new GrpcCelEnvironment(context); // Tests the String.join logic in getHeader @@ -377,11 +374,7 @@ public void celEnvironment_find_invalidFormat() { MatchContext context = mock(MatchContext.class); GrpcCelEnvironment env = new GrpcCelEnvironment(context); - // Name not starting with "request" assertThat(env.find("other.path").isPresent()).isFalse(); - - // Name with too many components (though Splitter limit is 2, the second part will be "a.b") - // getRequestField("a.b") will hit the default case and return null. assertThat(env.find("request.a.b").isPresent()).isFalse(); } @@ -389,12 +382,9 @@ public void celEnvironment_find_invalidFormat() { public void lazyRequestMap_additionalMethods() { MatchContext context = mock(MatchContext.class); GrpcCelEnvironment env = new GrpcCelEnvironment(context); - Map map = (Map) env.find("request").get(); + Map map = (Map) env.find("request").get(); assertThat(map.isEmpty()).isFalse(); - - // Test getting a known key that returns null (e.g., time) - // This covers the 'if (val == null)' branch in LazyRequestMap.get assertThat(map.get("time")).isNull(); } From 78895db0ec40d4e165966b1e9a3eabbef5540bbe Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 14:36:11 +0530 Subject: [PATCH 06/12] add some more unit tests --- .../internal/matcher/UnifiedMatcherTest.java | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 540c2e25bef..fe9a8b061a5 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1424,4 +1424,162 @@ public void matcherList_example3_nestedMatcher() { assertThat(result.actions).hasSize(1); assertThat(result.actions.get(0).getName()).isEqualTo("inner_matcher_2"); } + + @Test + public void resolveInput_malformedProto_throws() { + // Create a config with correct typeUrl but corrupted/invalid bytes + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput") + .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid-proto-data")) + .build()) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid input config"); + } + } + + @Test + public void matchInput_headerName_invalidCharacters_throws() { + // A lowercase header name that is still invalid for gRPC Metadata keys + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("invalid$header") + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid header name"); + } + } + + @Test + public void matchInput_headerName_binary_missing() { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("test-bin") // Binary suffix + .build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + + assertThat(input.apply(context)).isNull(); + } + + @Test + public void noOpMatcher_noOnNoMatch_returnsNoMatch() { + // An empty Matcher proto defaults to a NoOpMatcher with no onNoMatch action + Matcher proto = Matcher.getDefaultInstance(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchResult result = matcher.match(mock(MatchContext.class), 0); + assertThat(result.matched).isFalse(); + } + + @Test + public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { + Matcher proto = Matcher.getDefaultInstance(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + // Manually trigger the runtime recursion check in NoOpMatcher + MatchResult result = matcher.match(mock(MatchContext.class), 17); + assertThat(result.matched).isFalse(); + } + + @Test + public void singlePredicate_celInputWithStringMatcher_throws() { + // Tests the explicit check that blocks CEL input for StringMatchers + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("foo")) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "HttpAttributesCelMatchInput cannot be used with StringMatcher"); + } + } + + @Test + public void singlePredicate_headerInputWithCelMatcher_throws() { + // Tests the inverse check: CelMatcher must use HttpAttributesCelMatchInput + com.github.xds.type.matcher.v3.CelMatcher celMatcher = createCelMatcher("true"); + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(celMatcher))) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "CelMatcher can only be used with HttpAttributesCelMatchInput"); + } + } + + @Test + public void singlePredicate_noMatcher_throws() { + // Triggers: "SinglePredicate must have either value_match or custom_match" + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(com.google.protobuf.Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must have either value_match or custom_match"); + } + } + + @Test + public void compoundMatchers_tooFewPredicates_throws() { + // Coverage for OrMatcher and AndMatcher minimum predicate requirements + Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); + Matcher.MatcherList.Predicate.PredicateList list = + Matcher.MatcherList.Predicate.PredicateList.newBuilder().addPredicate(p).build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setOrMatcher(list).build()); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); + } + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(list).build()); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); + } + } } From 19989ebe7bbfced57f198110164aa0949f220a66 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 15:52:20 +0530 Subject: [PATCH 07/12] fix/add tests --- .../internal/matcher/UnifiedMatcherTest.java | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index fe9a8b061a5..b1ea0e3cd9a 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1582,4 +1582,133 @@ public void compoundMatchers_tooFewPredicates_throws() { assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); } } + + @Test + public void singlePredicate_invalidCelMatcherProto_throws() { + // Triggers "Invalid CelMatcher config" + // Create a CEL matcher config with invalid bytes to trigger InvalidProtocolBufferException + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.newBuilder() + .setTypeUrl("type.googleapis.com/xds.type.matcher.v3.CelMatcher") + .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid")) + .build()) + .build(); + + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(config) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); + } + } + + @Test + public void singlePredicate_celEvalError_returnsFalse() { + // Triggers "return false" in "catch (CelEvaluationException e)" + // Expression: [][0] -> Index out of bounds, runtime error + // Type check passes (list access). + // We need a CheckedExpr. + + // We rely on a runtime failure (division by zero) to trigger CelEvaluationException. + + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("1/0 == 0")))) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + // Eval should return false (caught exception) + assertThat(evaluator.evaluate(mock(MatchContext.class))).isFalse(); + } + + @Test + public void stringMatcher_emptySuffix_throws() { + // Triggers "StringMatcher suffix ... must be non-empty" + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setSuffix("")) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "StringMatcher suffix (match_pattern) must be non-empty"); + } + } + + @Test + public void stringMatcher_unknownPattern_throws() { + // Triggers "Unknown StringMatcher match pattern" + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.getDefaultInstance()) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unknown StringMatcher match pattern"); + } + } + + + @Test + public void singlePredicate_stringMatcher_safeRegex_matches() { + // Verifies valid safe_regex config + com.github.xds.type.matcher.v3.RegexMatcher regexMatcher = + com.github.xds.type.matcher.v3.RegexMatcher.newBuilder() + .setRegex("f.*o") + .build(); + + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSafeRegex(regexMatcher)) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + // host header not present -> null -> false + assertThat(evaluator.evaluate(context)).isFalse(); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("host", Metadata.ASCII_STRING_MARSHALLER), "foo"); + when(context.getMetadata()).thenReturn(headers); + assertThat(evaluator.evaluate(context)).isTrue(); + } } From 118d7afa95ac11418255633d5c7835156ebffc12 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 15:55:30 +0530 Subject: [PATCH 08/12] remove not required tests --- .../internal/matcher/UnifiedMatcherTest.java | 164 ------------------ 1 file changed, 164 deletions(-) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index b1ea0e3cd9a..25b3aba5563 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1425,164 +1425,6 @@ public void matcherList_example3_nestedMatcher() { assertThat(result.actions.get(0).getName()).isEqualTo("inner_matcher_2"); } - @Test - public void resolveInput_malformedProto_throws() { - // Create a config with correct typeUrl but corrupted/invalid bytes - TypedExtensionConfig config = TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.newBuilder() - .setTypeUrl("type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput") - .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid-proto-data")) - .build()) - .build(); - try { - UnifiedMatcher.resolveInput(config); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("Invalid input config"); - } - } - - @Test - public void matchInput_headerName_invalidCharacters_throws() { - // A lowercase header name that is still invalid for gRPC Metadata keys - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("invalid$header") - .build(); - TypedExtensionConfig config = TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack(proto)) - .build(); - try { - UnifiedMatcher.resolveInput(config); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("Invalid header name"); - } - } - - @Test - public void matchInput_headerName_binary_missing() { - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(new Metadata()); - - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("test-bin") // Binary suffix - .build(); - MatchInput input = UnifiedMatcher.resolveInput( - TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); - - assertThat(input.apply(context)).isNull(); - } - - @Test - public void noOpMatcher_noOnNoMatch_returnsNoMatch() { - // An empty Matcher proto defaults to a NoOpMatcher with no onNoMatch action - Matcher proto = Matcher.getDefaultInstance(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - - MatchResult result = matcher.match(mock(MatchContext.class), 0); - assertThat(result.matched).isFalse(); - } - - @Test - public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { - Matcher proto = Matcher.getDefaultInstance(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - - // Manually trigger the runtime recursion check in NoOpMatcher - MatchResult result = matcher.match(mock(MatchContext.class), 17); - assertThat(result.matched).isFalse(); - } - - @Test - public void singlePredicate_celInputWithStringMatcher_throws() { - // Tests the explicit check that blocks CEL input for StringMatchers - Matcher.MatcherList.Predicate.SinglePredicate predicate = - Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("foo")) - .build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains( - "HttpAttributesCelMatchInput cannot be used with StringMatcher"); - } - } - - @Test - public void singlePredicate_headerInputWithCelMatcher_throws() { - // Tests the inverse check: CelMatcher must use HttpAttributesCelMatchInput - com.github.xds.type.matcher.v3.CelMatcher celMatcher = createCelMatcher("true"); - Matcher.MatcherList.Predicate.SinglePredicate predicate = - Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("host").build()))) - .setCustomMatch(TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack(celMatcher))) - .build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains( - "CelMatcher can only be used with HttpAttributesCelMatchInput"); - } - } - - @Test - public void singlePredicate_noMatcher_throws() { - // Triggers: "SinglePredicate must have either value_match or custom_match" - Matcher.MatcherList.Predicate.SinglePredicate predicate = - Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() - .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(com.google.protobuf.Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) - .build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - org.junit.Assert.fail("Should have thrown"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("must have either value_match or custom_match"); - } - } - - @Test - public void compoundMatchers_tooFewPredicates_throws() { - // Coverage for OrMatcher and AndMatcher minimum predicate requirements - Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); - Matcher.MatcherList.Predicate.PredicateList list = - Matcher.MatcherList.Predicate.PredicateList.newBuilder().addPredicate(p).build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setOrMatcher(list).build()); - org.junit.Assert.fail(); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); - } - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(list).build()); - org.junit.Assert.fail(); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); - } - } - @Test public void singlePredicate_invalidCelMatcherProto_throws() { // Triggers "Invalid CelMatcher config" @@ -1613,13 +1455,7 @@ public void singlePredicate_invalidCelMatcherProto_throws() { @Test public void singlePredicate_celEvalError_returnsFalse() { - // Triggers "return false" in "catch (CelEvaluationException e)" - // Expression: [][0] -> Index out of bounds, runtime error - // Type check passes (list access). - // We need a CheckedExpr. - // We rely on a runtime failure (division by zero) to trigger CelEvaluationException. - Matcher.MatcherList.Predicate.SinglePredicate predicate = Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() From 77b01b94ae58a710bcb4c10d85586aa80ccf81d4 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 16:25:16 +0530 Subject: [PATCH 09/12] add tests --- .../internal/matcher/UnifiedMatcherTest.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 25b3aba5563..6c7787f6109 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1547,4 +1547,119 @@ public void singlePredicate_stringMatcher_safeRegex_matches() { when(context.getMetadata()).thenReturn(headers); assertThat(evaluator.evaluate(context)).isTrue(); } + + + + @Test + public void compoundMatchers_tooFewPredicates_throws() { + // Tests OrMatcher/AndMatcher minimum size (2 predicates) + Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); + Matcher.MatcherList.Predicate.PredicateList list = + Matcher.MatcherList.Predicate.PredicateList.newBuilder().addPredicate(p).build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setOrMatcher(list).build()); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); + } + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(list).build()); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); + } + } + + @Test + public void notMatcher_invertsResult() { + // Tests NotMatcher coverage + Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setNotMatcher(p).build()); + + // h:v matches -> Not(True) -> False + assertThat(eval.evaluate(mockContextWith("h", "v"))).isFalse(); + // h:wrong doesn't match -> Not(False) -> True + assertThat(eval.evaluate(mockContextWith("h", "wrong"))).isTrue(); + } + + private MatchContext mockContextWith(String key, String value) { + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + when(context.getMetadata()).thenReturn(headers); + return context; + } + + + @Test + public void singlePredicate_stringMatcher_suffix_matches() { + // Verifies valid suffix config + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSuffix("bar")) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + // "foobar" ends with "bar" -> true + assertThat(evaluator.evaluate(mockContextWith("host", "foobar"))).isTrue(); + // "foobaz" does not end with "bar" -> false + assertThat(evaluator.evaluate(mockContextWith("host", "foobaz"))).isFalse(); + } + + @Test + public void singlePredicate_stringMatcher_prefix_matches() { + // Verifies valid prefix config + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setPrefix("foo")) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + // "foobar" starts with "foo" -> true + assertThat(evaluator.evaluate(mockContextWith("host", "foobar"))).isTrue(); + // "barfoo" does not start with "foo" -> false + assertThat(evaluator.evaluate(mockContextWith("host", "barfoo"))).isFalse(); + } + + @Test + public void singlePredicate_stringMatcher_contains_matches() { + // Verifies valid contains config + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setContains("oba")) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + // "foobar" contains "oba" -> true + assertThat(evaluator.evaluate(mockContextWith("host", "foobar"))).isTrue(); + // "fobar" does not contain "oba" -> false + assertThat(evaluator.evaluate(mockContextWith("host", "none"))).isFalse(); + } } + From 310677d15d7c0134fb5c383fa382fa44943b78ec Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 22:05:20 +0530 Subject: [PATCH 10/12] add tests --- .../grpc/xds/internal/matcher/CelCommon.java | 4 - .../grpc/xds/internal/matcher/CelMatcher.java | 6 +- .../internal/matcher/CelStringExtractor.java | 4 - .../internal/matcher/GrpcCelEnvironment.java | 2 +- .../xds/internal/matcher/MatcherList.java | 3 - .../xds/internal/matcher/MatcherRunner.java | 5 +- .../xds/internal/matcher/UnifiedMatcher.java | 5 +- .../matcher/CelStringExtractorTest.java | 2 - .../internal/matcher/UnifiedMatcherTest.java | 73 ------------------- 9 files changed, 5 insertions(+), 99 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java index 0f6dc84d7b5..1c6e4cb532e 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java @@ -35,12 +35,10 @@ final class CelCommon { .enableListConcatenation(false) .maxRegexProgramSize(100) .build(); - static final CelCompiler COMPILER = CelCompilerFactory.standardCelCompilerBuilder() .addVar("request", SimpleType.DYN) .setOptions(CEL_OPTIONS) .build(); - static final CelRuntime RUNTIME = CelRuntimeFactory.standardCelRuntimeBuilder() .setOptions(CEL_OPTIONS) .build(); @@ -48,8 +46,6 @@ final class CelCommon { private CelCommon() {} static void checkAllowedVariables(CelAbstractSyntaxTree ast) { - // Basic validation to ensure only supported variables (request) are used. - // This iterates over the reference map generated by the type checker. for (java.util.Map.Entry entry : ast.getReferenceMap().entrySet()) { dev.cel.common.ast.CelReference ref = entry.getValue(); diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java index e400d4e7c50..14c42a49de3 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java @@ -23,14 +23,10 @@ import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelRuntime; - - /** * Executes compiled CEL expressions. */ public final class CelMatcher { - - private final CelRuntime.Program program; private CelMatcher(CelRuntime.Program program) { @@ -76,7 +72,7 @@ public boolean match(Object input) throws CelEvaluationException { throw new CelEvaluationException( "Unsupported input type for CEL evaluation: " + input.getClass().getName()); } - // Validated to be boolean during compile check ideally, or we cast safely + if (result instanceof Boolean) { return (Boolean) result; } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java index 55f3e58cd54..96bab867c9b 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java @@ -22,13 +22,10 @@ import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelRuntime; - /** * Executes compiled CEL expressions that extract a string. */ public final class CelStringExtractor { - - private final CelRuntime.Program program; private CelStringExtractor(CelRuntime.Program program) { @@ -78,7 +75,6 @@ public String extract(Object input) throws CelEvaluationException { if (result instanceof String) { return (String) result; } - // Return null key for non-string results (which will likely match nothing or be handled) return null; } } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java index fa2d57a67a3..fe7d4228f86 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java @@ -59,7 +59,7 @@ private Object getRequestField(String requestField) { case "query": return ""; case "scheme": return ""; case "protocol": return ""; - case "time": return null; // Supported but not set in gRPC + case "time": return null; case "referer": return getHeader("referer"); case "useragent": return getHeader("user-agent"); diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java index a3992370775..4f10840b69d 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java @@ -51,8 +51,6 @@ public MatchResult match(MatchContext context, int depth) { List accumulated = new ArrayList<>(); boolean matchedAtLeastOnce = false; - - for (FieldMatcher matcher : matchers) { if (matcher.matches(context)) { MatchResult result = matcher.onMatch.evaluate(context, depth); @@ -79,7 +77,6 @@ public MatchResult match(MatchContext context, int depth) { } } } - if (matchedAtLeastOnce) { return MatchResult.create(accumulated); } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java index 60569655d35..07c4128ba27 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 The gRPC Authors + * Copyright 2026 The gRPC Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,7 +39,6 @@ public static java.util.List checkM } public interface MatchContext { - // Basic context usually involves Metadata (headers) and potentially other attributes. Metadata getMetadata(); @javax.annotation.Nullable @@ -52,7 +51,7 @@ public interface MatchContext { String getMethod(); @javax.annotation.Nullable - String getId(); // x-request-id + String getId(); } } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java index 6ec4800e4df..cfeb64954bd 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -31,13 +31,12 @@ */ public abstract class UnifiedMatcher { - // Supported Extension Type URLs + // Supported Extension Type URLs per gRFC A106 private static final String TYPE_URL_HTTP_HEADER_INPUT = "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; - static final int MAX_RECURSION_DEPTH = 16; @Nullable @@ -64,8 +63,6 @@ public Object apply(MatchContext context) { throw new IllegalArgumentException("Unsupported input type: " + typeUrl); } - - private static final class HeaderMatchInput implements MatchInput { private final String headerName; diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java index a6adefd8704..8b5a71cd665 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java @@ -85,7 +85,6 @@ public void extract_withCelVariableResolver() throws Exception { CelStringExtractor extractor = CelStringExtractor.compile("'val'"); dev.cel.runtime.CelVariableResolver resolver = name -> java.util.Optional.empty(); - // Exercises the CelVariableResolver branch assertThat(extractor.extract(resolver)).isEqualTo("val"); } @@ -93,7 +92,6 @@ public void extract_withCelVariableResolver() throws Exception { public void extract_unsupportedInputType_throws() throws Exception { CelStringExtractor extractor = CelStringExtractor.compile("'foo'"); try { - // Pass a String instead of a Map or Resolver extractor.extract("not-a-map"); fail("Should have thrown CelEvaluationException"); } catch (dev.cel.runtime.CelEvaluationException e) { diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 6c7787f6109..bede00b37f8 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1548,57 +1548,8 @@ public void singlePredicate_stringMatcher_safeRegex_matches() { assertThat(evaluator.evaluate(context)).isTrue(); } - - - @Test - public void compoundMatchers_tooFewPredicates_throws() { - // Tests OrMatcher/AndMatcher minimum size (2 predicates) - Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); - Matcher.MatcherList.Predicate.PredicateList list = - Matcher.MatcherList.Predicate.PredicateList.newBuilder().addPredicate(p).build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setOrMatcher(list).build()); - org.junit.Assert.fail(); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); - } - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(list).build()); - org.junit.Assert.fail(); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); - } - } - - @Test - public void notMatcher_invertsResult() { - // Tests NotMatcher coverage - Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); - PredicateEvaluator eval = PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setNotMatcher(p).build()); - - // h:v matches -> Not(True) -> False - assertThat(eval.evaluate(mockContextWith("h", "v"))).isFalse(); - // h:wrong doesn't match -> Not(False) -> True - assertThat(eval.evaluate(mockContextWith("h", "wrong"))).isTrue(); - } - - private MatchContext mockContextWith(String key, String value) { - MatchContext context = mock(MatchContext.class); - Metadata headers = new Metadata(); - headers.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); - when(context.getMetadata()).thenReturn(headers); - return context; - } - - @Test public void singlePredicate_stringMatcher_suffix_matches() { - // Verifies valid suffix config Matcher.MatcherList.Predicate.SinglePredicate predicate = Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() @@ -1620,7 +1571,6 @@ public void singlePredicate_stringMatcher_suffix_matches() { @Test public void singlePredicate_stringMatcher_prefix_matches() { - // Verifies valid prefix config Matcher.MatcherList.Predicate.SinglePredicate predicate = Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() @@ -1639,27 +1589,4 @@ public void singlePredicate_stringMatcher_prefix_matches() { // "barfoo" does not start with "foo" -> false assertThat(evaluator.evaluate(mockContextWith("host", "barfoo"))).isFalse(); } - - @Test - public void singlePredicate_stringMatcher_contains_matches() { - // Verifies valid contains config - Matcher.MatcherList.Predicate.SinglePredicate predicate = - Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("host").build()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() - .setContains("oba")) - .build(); - - PredicateEvaluator evaluator = PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - - // "foobar" contains "oba" -> true - assertThat(evaluator.evaluate(mockContextWith("host", "foobar"))).isTrue(); - // "fobar" does not contain "oba" -> false - assertThat(evaluator.evaluate(mockContextWith("host", "none"))).isFalse(); - } } - From 49031b493a7dc315da355857fe7af3a827d5ae28 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 22:25:20 +0530 Subject: [PATCH 11/12] add tests --- .../xds/internal/matcher/UnifiedMatcher.java | 1 - .../internal/matcher/UnifiedMatcherTest.java | 127 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java index cfeb64954bd..30674a73718 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -36,7 +36,6 @@ public abstract class UnifiedMatcher { "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; - static final int MAX_RECURSION_DEPTH = 16; @Nullable diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index bede00b37f8..4b45cd4a508 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1589,4 +1589,131 @@ public void singlePredicate_stringMatcher_prefix_matches() { // "barfoo" does not start with "foo" -> false assertThat(evaluator.evaluate(mockContextWith("host", "barfoo"))).isFalse(); } + + private MatchContext mockContextWith(String key, String value) { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith(key, value)); + return context; + } + + @Test + public void resolveInput_malformedProto_throws() { + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput") + .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid-bytes")) + .build()) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid input config"); + } + } + + @Test + public void matchInput_headerName_invalidCharacters_throws() { + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("invalid$header") + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid header name"); + } + } + + @Test + public void matchInput_headerName_binary_aggregation() { + String headerName = "test-bin"; + byte[] v1 = new byte[] {1, 2, 3}; + byte[] v2 = new byte[] {4, 5, 6}; + // Expected: comma-separated base64 values + String expected = com.google.common.io.BaseEncoding.base64().encode(v1) + "," + + com.google.common.io.BaseEncoding.base64().encode(v2); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), v1); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), v2); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadata); + + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(headerName).build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + + assertThat(input.apply(context)).isEqualTo(expected); + } + + @Test + public void matchInput_headerName_binary_missing() { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("missing-bin").build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + + assertThat(input.apply(context)).isNull(); + } + + @Test + public void checkRecursionDepth_nestedInTree_throws() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 17; i++) { + current = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("key", Matcher.OnMatch.newBuilder().setMatcher(current).build()))) + .build(); + } + try { + UnifiedMatcher.fromProto(current); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + @Test + public void checkRecursionDepth_nestedInOnNoMatch_throws() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 17; i++) { + current = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder().setMatcher(current)) + .build(); + } + try { + UnifiedMatcher.fromProto(current); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + @Test + public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { + Matcher proto = Matcher.getDefaultInstance(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + // Manually calling with depth > 16 + MatchResult result = matcher.match(mock(MatchContext.class), 17); + assertThat(result.matched).isFalse(); + } } From 5a31706a06f83f296662b85e4ab02d85940a1134 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Thu, 5 Feb 2026 11:52:10 +0530 Subject: [PATCH 12/12] add some tests --- .../grpc/xds/internal/matcher/CelCommon.java | 2 +- .../xds/internal/matcher/MatcherTree.java | 4 -- .../internal/matcher/CelEnvironmentTest.java | 52 ++++++++++++++++++- .../xds/internal/matcher/MatcherTreeTest.java | 27 ++++++++++ .../internal/matcher/UnifiedMatcherTest.java | 50 ++++++++++++++++++ 5 files changed, 129 insertions(+), 6 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java index 1c6e4cb532e..6d363e2349e 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java @@ -51,7 +51,7 @@ static void checkAllowedVariables(CelAbstractSyntaxTree ast) { dev.cel.common.ast.CelReference ref = entry.getValue(); // If overload_id is empty, it's a variable reference or type name. // We only support "request". - if (ref.value() == null && ref.overloadIds().isEmpty()) { + if (!ref.value().isPresent() && ref.overloadIds().isEmpty()) { if (!"request".equals(ref.name())) { throw new IllegalArgumentException( "CEL expression references unknown variable: " + ref.name()); diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java index 95822aa7ad1..d791e5ab40b 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java @@ -94,10 +94,6 @@ public MatchResult match(MatchContext context, int depth) { return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); } String value = (String) valueObj; - if (value == null) { - return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); - } - if (exactMatchMap != null) { OnMatch match = exactMatchMap.get(value); if (match != null) { diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java index e7c3baaef58..9436aa6a1a4 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -271,7 +271,7 @@ public void celMatcher_match_invalidInputType_throws() throws Exception { } @Test - public void celMatcher_match_nonBooleanResult_throws() throws Exception { + public void celMatcher_compile_nonBooleanAst_throws() throws Exception { try { CelMatcher.compile("'not-boolean'"); fail("Should throw IllegalArgumentException"); @@ -280,6 +280,32 @@ public void celMatcher_match_nonBooleanResult_throws() throws Exception { } } + @Test + public void celMatcher_match_runtimeNonBooleanResult_throws() throws Exception { + // 1. We create an AST that claims to be a BOOL but returns a STRING at runtime. + // We use a custom compiler where 'request' is defined as a BOOL. + dev.cel.compiler.CelCompiler liarCompiler = + dev.cel.compiler.CelCompilerFactory.standardCelCompilerBuilder() + .addVar("request", dev.cel.common.types.SimpleType.BOOL) + .build(); + dev.cel.common.CelAbstractSyntaxTree ast = liarCompiler.compile("request").getAst(); + + // 2. This passes CelMatcher.compile() because its static result type is BOOL. + CelMatcher matcher = CelMatcher.compile(ast); + + // 3. At runtime, we provide a String. The CEL engine resolves 'request' to this String. + java.util.Map input = + java.util.Collections.singletonMap("request", "i-am-a-string"); + + try { + matcher.match(input); + org.junit.Assert.fail("Should have thrown CelEvaluationException"); + } catch (dev.cel.runtime.CelEvaluationException e) { + assertThat(e).hasMessageThat() + .contains("CEL expression must evaluate to boolean, got: java.lang.String"); + } + } + @Test public void headersWrapper_get_nonStringKey_returnsNull() { MatchContext context = mock(MatchContext.class); @@ -396,4 +422,28 @@ public void celEnvironment_missingHeader_returnsEmptyString() { assertThat(env.find("request.referer").get()).isEqualTo(""); } + + @Test + public void checkAllowedVariables_unknownVariable_throws() throws Exception { + // 1. Create a different compiler that allows a variable other than "request" + dev.cel.compiler.CelCompiler otherCompiler = + dev.cel.compiler.CelCompilerFactory.standardCelCompilerBuilder() + .addVar("unknown_var", dev.cel.common.types.SimpleType.STRING) + .build(); + + // 2. Compile an expression to get an AST containing the forbidden variable + // We use a boolean expression so it passes the AST result type check in CelMatcher.compile + dev.cel.common.CelAbstractSyntaxTree ast = + otherCompiler.compile("unknown_var == 'foo'").getAst(); + + // 3. Pass the AST to the gRPC CelMatcher. This bypasses the gRPC compiler + // but triggers the checkAllowedVariables validation. + try { + CelMatcher.compile(ast); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("CEL expression references unknown variable: unknown_var"); + } + } } diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java index 08476870a19..d2645c107ab 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java @@ -407,6 +407,33 @@ public void invalidInputCombination_matcherTreeWithCelInput_throws() { } } + @Test + public void matcherTree_prefixMap_noMatch_shouldFallbackToOnNoMatch() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/a", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("actionA").build()) + .build())) + .build(); + Matcher.OnMatch onNoMatch = Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("actionB")).build(); + MatcherTree tree = new MatcherTree(proto, onNoMatch, s -> true); + + MatchContext context = mock(MatchContext.class); + io.grpc.Metadata metadata = new io.grpc.Metadata(); + metadata.put( + io.grpc.Metadata.Key.of("path", io.grpc.Metadata.ASCII_STRING_MARSHALLER), "/b"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = tree.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("actionB"); + } + private Metadata metadataWith(String key, String value) { Metadata m = new Metadata(); m.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 4b45cd4a508..b49c8f766b7 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1716,4 +1716,54 @@ public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { MatchResult result = matcher.match(mock(MatchContext.class), 17); assertThat(result.matched).isFalse(); } + + @Test + public void onMatch_empty_throws() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder()) // Neither .setMatcher() nor .setAction() called + .build(); + + try { + UnifiedMatcher.fromProto(proto); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OnMatch must have either matcher or action"); + } + } + + @Test + public void matcherList_empty_throws() { + // We create a MatcherList with no FieldMatchers added. + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder()) + .build(); + + try { + UnifiedMatcher.fromProto(proto); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("MatcherList must contain at least one FieldMatcher"); + } + } + + @Test + public void matcherList_maxRecursionDepth_returnsNoMatch() { + // We construct a valid MatcherList but call it with a depth value that exceeds the limit. + Matcher.MatcherList.FieldMatcher matcher = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(matcher)) + .build(); + + UnifiedMatcher matcherList = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + + // Manually pass depth 17 to trigger the 'depth > MAX_RECURSION_DEPTH' check + MatchResult result = matcherList.match(context, 17); + assertThat(result.matched).isFalse(); + } + }