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/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/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 7f2c5f02338..6a7586ee533 100644 --- a/xds/BUILD.bazel +++ b/xds/BUILD.bazel @@ -41,6 +41,8 @@ java_library( artifact("com.google.errorprone:error_prone_annotations"), artifact("com.google.guava:guava"), artifact("com.google.re2j:re2j"), + artifact("dev.cel:runtime"), + artifact("dev.cel:compiler"), 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..6d363e2349e --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java @@ -0,0 +1,62 @@ +/* + * 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) { + 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().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/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java new file mode 100644 index 00000000000..14c42a49de3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.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 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()); + } + + 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..96bab867c9b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java @@ -0,0 +1,80 @@ +/* + * 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; + } +} 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..fe7d4228f86 --- /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; + 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..4f10840b69d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java @@ -0,0 +1,100 @@ +/* + * 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..07c4128ba27 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java @@ -0,0 +1,57 @@ +/* + * 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 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 { + Metadata getMetadata(); + + @javax.annotation.Nullable + String getPath(); + + @javax.annotation.Nullable + String getHost(); + + @javax.annotation.Nullable + String getMethod(); + + @javax.annotation.Nullable + String getId(); + + } +} 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..d791e5ab40b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java @@ -0,0 +1,152 @@ +/* + * 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 (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..30674a73718 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -0,0 +1,200 @@ +/* + * 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 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 + 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..9436aa6a1a4 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -0,0 +1,449 @@ +/* + * 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); + + 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(new Object())).isNull(); + assertThat(map.containsKey(new Object())).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_compile_nonBooleanAst_throws() throws Exception { + try { + CelMatcher.compile("'not-boolean'"); + fail("Should throw IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must evaluate to boolean"); + } + } + + @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); + 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(); + } + + @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); + + assertThat(env.find("other.path").isPresent()).isFalse(); + 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(); + 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(""); + } + + @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/CelStringExtractorTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java new file mode 100644 index 00000000000..8b5a71cd665 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java @@ -0,0 +1,101 @@ +/* + * 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"); + 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); + // Since 123 is not a String, it returns null + 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) + } + } + + @Test + public void extract_withCelVariableResolver() throws Exception { + CelStringExtractor extractor = CelStringExtractor.compile("'val'"); + dev.cel.runtime.CelVariableResolver resolver = name -> java.util.Optional.empty(); + + assertThat(extractor.extract(resolver)).isEqualTo("val"); + } + + @Test + public void extract_unsupportedInputType_throws() throws Exception { + CelStringExtractor extractor = CelStringExtractor.compile("'foo'"); + try { + 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..d2645c107ab --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java @@ -0,0 +1,442 @@ +/* + * 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"); + } + } + + @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); + 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 new file mode 100644 index 00000000000..b49c8f766b7 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -0,0 +1,1769 @@ +/* + * 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 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 { + 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 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 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 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 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"); + 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"); + } + + + + 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 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() { + // 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(); + } + + @Test + public void singlePredicate_stringMatcher_suffix_matches() { + 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() { + 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(); + } + + 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(); + } + + @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(); + } + +}