From 3bcdf0906bb4eb5ffbb3165b1ef422003214453c Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Tue, 3 Feb 2026 15:40:56 +0530 Subject: [PATCH 01/23] xds: Implementation of Unified Matcher and CEL Integration --- .../io/grpc/xds/internal/MatcherParser.java | 2 +- .../java/io/grpc/xds/internal/Matchers.java | 13 +- .../grpc/xds/internal/matcher/CelMatcher.java | 86 + .../grpc/xds/internal/matcher/MatchInput.java | 32 + .../xds/internal/matcher/MatchResult.java | 49 + .../xds/internal/matcher/MatcherList.java | 103 ++ .../xds/internal/matcher/MatcherRunner.java | 58 + .../xds/internal/matcher/MatcherTree.java | 156 ++ .../io/grpc/xds/internal/matcher/OnMatch.java | 55 + .../internal/matcher/PredicateEvaluator.java | 228 +++ .../xds/internal/matcher/UnifiedMatcher.java | 204 +++ .../internal/matcher/UnifiedMatcherTest.java | 1482 +++++++++++++++++ 12 files changed, 2464 insertions(+), 4 deletions(-) create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java 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 91b77b05d01..6e4ad415174 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/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java new file mode 100644 index 00000000000..e400d4e7c50 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.google.common.annotations.VisibleForTesting; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelValidationException; +import dev.cel.common.types.SimpleType; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; + + + +/** + * Executes compiled CEL expressions. + */ +public final class CelMatcher { + + + private final CelRuntime.Program program; + + private CelMatcher(CelRuntime.Program program) { + this.program = program; + } + + /** + * Compiles the AST into a CelMatcher. + */ + public static CelMatcher compile(CelAbstractSyntaxTree ast) + throws CelValidationException, CelEvaluationException { + if (ast.getResultType() != SimpleType.BOOL) { + throw new IllegalArgumentException( + "CEL expression must evaluate to boolean, got: " + ast.getResultType()); + } + CelCommon.checkAllowedVariables(ast); + CelRuntime.Program program = CelCommon.RUNTIME.createProgram(ast); + return new CelMatcher(program); + } + + /** + * Compiles the CEL expression string into a CelMatcher. + */ + @VisibleForTesting + public static CelMatcher compile(String expression) + throws CelValidationException, CelEvaluationException { + CelAbstractSyntaxTree ast = CelCommon.COMPILER.compile(expression).getAst(); + return compile(ast); + } + + /** + * Evaluates the CEL expression against the input activation. + */ + public boolean match(Object input) throws CelEvaluationException { + Object result; + if (input instanceof dev.cel.runtime.CelVariableResolver) { + result = program.eval((dev.cel.runtime.CelVariableResolver) input); + } else if (input instanceof java.util.Map) { + @SuppressWarnings("unchecked") + java.util.Map mapInput = (java.util.Map) input; + result = program.eval(mapInput); + } else { + throw new CelEvaluationException( + "Unsupported input type for CEL evaluation: " + input.getClass().getName()); + } + // Validated to be boolean during compile check ideally, or we cast safely + if (result instanceof Boolean) { + return (Boolean) result; + } + throw new CelEvaluationException( + "CEL expression must evaluate to boolean, got: " + result.getClass().getName()); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java new file mode 100644 index 00000000000..0c0037974c0 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java @@ -0,0 +1,32 @@ +/* + * Copyright 2021 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import javax.annotation.Nullable; + +/** + * Interface for extracting values from a match context (e.g. HTTP headers). + */ +public interface MatchInput { + /** + * Extracts the value from the context. + * @param context the context (e.g. Metadata, Attributes) + * @return the extracted value, or null if not found. + */ + @Nullable + Object apply(T context); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java new file mode 100644 index 00000000000..43b0f115f35 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.github.xds.core.v3.TypedExtensionConfig; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Result of a matching operation. + */ +public final class MatchResult { + public final List actions; + public final boolean matched; + + private MatchResult(List actions, boolean matched) { + this.actions = checkNotNull(actions, "actions"); + this.matched = matched; + } + + public static MatchResult create(TypedExtensionConfig action) { + return new MatchResult(Collections.singletonList(action), true); + } + + public static MatchResult create(List actions) { + return new MatchResult(new ArrayList<>(actions), true); + } + + public static MatchResult noMatch() { + return new MatchResult(Collections.emptyList(), false); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java new file mode 100644 index 00000000000..a3992370775 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; + +final class MatcherList extends UnifiedMatcher { + private final List matchers; + @Nullable private final OnMatch onNoMatch; + + MatcherList(Matcher.MatcherList proto, @Nullable Matcher.OnMatch onNoMatchProto, + java.util.function.Predicate actionValidator) { + if (proto.getMatchersCount() == 0) { + throw new IllegalArgumentException("MatcherList must contain at least one FieldMatcher"); + } + this.matchers = new ArrayList<>(proto.getMatchersCount()); + for (Matcher.MatcherList.FieldMatcher fm : proto.getMatchersList()) { + matchers.add(new FieldMatcher(fm, actionValidator)); + } + if (onNoMatchProto != null) { + this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); + } else { + this.onNoMatch = null; + } + } + + @Override + public MatchResult match(MatchContext context, int depth) { + if (depth > MAX_RECURSION_DEPTH) { + return MatchResult.noMatch(); + } + + List accumulated = new ArrayList<>(); + boolean matchedAtLeastOnce = false; + + + for (FieldMatcher matcher : matchers) { + if (matcher.matches(context)) { + MatchResult result = matcher.onMatch.evaluate(context, depth); + if (result.matched) { + accumulated.addAll(result.actions); + matchedAtLeastOnce = true; + } + + if (!matcher.onMatch.keepMatching) { + if (!matchedAtLeastOnce) { + return MatchResult.noMatch(); + } + break; + } + } + } + + if (!matchedAtLeastOnce) { + if (onNoMatch != null) { + MatchResult noMatchResult = onNoMatch.evaluate(context, depth); + if (noMatchResult.matched) { + accumulated.addAll(noMatchResult.actions); + matchedAtLeastOnce = true; + } + } + } + + if (matchedAtLeastOnce) { + return MatchResult.create(accumulated); + } + return MatchResult.noMatch(); + } + + private static final class FieldMatcher { + private final PredicateEvaluator predicate; + private final OnMatch onMatch; + + FieldMatcher(Matcher.MatcherList.FieldMatcher proto, + java.util.function.Predicate actionValidator) { + this.predicate = PredicateEvaluator.fromProto(proto.getPredicate()); + this.onMatch = new OnMatch(proto.getOnMatch(), actionValidator); + } + + boolean matches(MatchContext context) { + return predicate.evaluate(context); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java new file mode 100644 index 00000000000..60569655d35 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import io.grpc.Metadata; + +/** + * Executes a UnifiedMatcher against a request. + */ +public final class MatcherRunner { + private MatcherRunner() {} + + /** + * runs the matcher. + */ + @javax.annotation.Nullable + public static java.util.List checkMatch( + com.github.xds.type.matcher.v3.Matcher proto, MatchContext context) { + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(context, 0); + if (!result.matched || result.actions.isEmpty()) { + return null; + } + return result.actions; + } + + public interface MatchContext { + // Basic context usually involves Metadata (headers) and potentially other attributes. + Metadata getMetadata(); + + @javax.annotation.Nullable + String getPath(); + + @javax.annotation.Nullable + String getHost(); + + @javax.annotation.Nullable + String getMethod(); + + @javax.annotation.Nullable + String getId(); // x-request-id + + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java new file mode 100644 index 00000000000..95822aa7ad1 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java @@ -0,0 +1,156 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.github.xds.type.matcher.v3.Matcher; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +final class MatcherTree extends UnifiedMatcher { + private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + private final MatchInput input; + @Nullable private final Map exactMatchMap; + @Nullable private final Map prefixMatchMap; + @Nullable private final OnMatch onNoMatch; + + MatcherTree(Matcher.MatcherTree proto, @Nullable Matcher.OnMatch onNoMatchProto, + java.util.function.Predicate actionValidator) { + if (!proto.hasInput()) { + throw new IllegalArgumentException("MatcherTree must have input"); + } + this.input = UnifiedMatcher.resolveInput(proto.getInput()); + if (proto.getInput().getTypedConfig().getTypeUrl() + .equals(TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT)) { + throw new IllegalArgumentException( + "HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + + if (proto.hasCustomMatch()) { + throw new IllegalArgumentException("MatcherTree does not support custom_match"); + } + + if (proto.hasExactMatchMap()) { + Matcher.MatcherTree.MatchMap matchMap = proto.getExactMatchMap(); + if (matchMap.getMapCount() == 0) { + throw new IllegalArgumentException( + "MatcherTree exact_match_map must contain at least one entry"); + } + this.exactMatchMap = new HashMap<>(); + for (Map.Entry entry : + matchMap.getMapMap().entrySet()) { + this.exactMatchMap.put(entry.getKey(), + new OnMatch(entry.getValue(), actionValidator)); + } + this.prefixMatchMap = null; + } else if (proto.hasPrefixMatchMap()) { + Matcher.MatcherTree.MatchMap matchMap = proto.getPrefixMatchMap(); + if (matchMap.getMapCount() == 0) { + throw new IllegalArgumentException( + "MatcherTree prefix_match_map must contain at least one entry"); + } + this.prefixMatchMap = new HashMap<>(); + for (Map.Entry entry : + matchMap.getMapMap().entrySet()) { + this.prefixMatchMap.put(entry.getKey(), + new OnMatch(entry.getValue(), actionValidator)); + } + this.exactMatchMap = null; + } else { + throw new IllegalArgumentException( + "MatcherTree must have either exact_match_map or prefix_match_map"); + } + if (onNoMatchProto != null) { + this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); + } else { + this.onNoMatch = null; + } + } + + @Override + public MatchResult match(MatchContext context, int depth) { + if (depth > MAX_RECURSION_DEPTH) { + return MatchResult.noMatch(); + } + + Object valueObj = input.apply(context); + if (!(valueObj instanceof String)) { + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } + String value = (String) valueObj; + if (value == null) { + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } + + if (exactMatchMap != null) { + OnMatch match = exactMatchMap.get(value); + if (match != null) { + return match.evaluate(context, depth); + } + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } else if (prefixMatchMap != null) { + java.util.List matchingPrefixes = new java.util.ArrayList<>(); + for (String prefix : prefixMatchMap.keySet()) { + if (value.startsWith(prefix)) { + matchingPrefixes.add(prefix); + } + } + + if (matchingPrefixes.isEmpty()) { + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } + + // Sort by length descending (longest first) + java.util.Collections.sort(matchingPrefixes, new java.util.Comparator() { + @Override + public int compare(String s1, String s2) { + return Integer.compare(s2.length(), s1.length()); + } + }); + + boolean matchedAtLeastOnce = false; + java.util.List accumulatedActions = + new java.util.ArrayList<>(); + + for (String prefix : matchingPrefixes) { + OnMatch onMatch = prefixMatchMap.get(prefix); + MatchResult result = onMatch.evaluate(context, depth); + if (result.matched) { + matchedAtLeastOnce = true; + accumulatedActions.addAll(result.actions); + if (!onMatch.keepMatching) { + return MatchResult.create(accumulatedActions); + } + } + } + + if (matchedAtLeastOnce) { + return MatchResult.create(accumulatedActions); + } + // If we found matching prefixes but none of them resulted in a match (nested logic failed), + // we still "found a key" in the tree structure. + // According to the test "matcherTree_exactMatch_shouldNotFallBackToOnNoMatch_ifKeyFound", + // finding a key prevents onNoMatch. + // So we return noMatch() here, NOT onNoMatch.evaluate(). + return MatchResult.noMatch(); + } + + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java b/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java new file mode 100644 index 00000000000..03e2eac00b2 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import javax.annotation.Nullable; + +/** + * Handles the action to take upon a match (recurse or return action). + */ +final class OnMatch { + @Nullable private final UnifiedMatcher nestedMatcher; + @Nullable private final TypedExtensionConfig action; + final boolean keepMatching; + + OnMatch(Matcher.OnMatch proto, java.util.function.Predicate actionValidator) { + this.keepMatching = proto.getKeepMatching(); + if (proto.hasMatcher()) { + this.nestedMatcher = UnifiedMatcher.fromProto(proto.getMatcher(), actionValidator); + this.action = null; + } else if (proto.hasAction()) { + this.nestedMatcher = null; + this.action = proto.getAction(); + String typeUrl = this.action.getTypedConfig().getTypeUrl(); + if (!actionValidator.test(typeUrl)) { + throw new IllegalArgumentException("Unsupported action type: " + typeUrl); + } + } else { + throw new IllegalArgumentException("OnMatch must have either matcher or action"); + } + } + + MatchResult evaluate(MatchContext context, int depth) { + if (nestedMatcher != null) { + return nestedMatcher.match(context, depth + 1); + } + return MatchResult.create(action); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java new file mode 100644 index 00000000000..ba5e3b1c424 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher.MatcherList.Predicate; +import com.google.protobuf.InvalidProtocolBufferException; +import dev.cel.common.CelValidationException; +import dev.cel.runtime.CelEvaluationException; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; + +abstract class PredicateEvaluator { + private static final String TYPE_URL_CEL_MATCHER = + "type.googleapis.com/xds.type.matcher.v3.CelMatcher"; + private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + + abstract boolean evaluate(MatchContext context); + + static PredicateEvaluator fromProto(Predicate proto) { + if (proto.hasSinglePredicate()) { + return new SinglePredicateEvaluator(proto.getSinglePredicate()); + } else if (proto.hasOrMatcher()) { + return new OrMatcherEvaluator(proto.getOrMatcher()); + } else if (proto.hasAndMatcher()) { + return new AndMatcherEvaluator(proto.getAndMatcher()); + } else if (proto.hasNotMatcher()) { + return new NotMatcherEvaluator(proto.getNotMatcher()); + } + throw new IllegalArgumentException( + "Predicate must have one of: single_predicate, or_matcher, and_matcher, not_matcher"); + } + + private static final class SinglePredicateEvaluator extends PredicateEvaluator { + private final MatchInput input; + @Nullable private final Matchers.StringMatcher stringMatcher; + @Nullable private final CelMatcher celMatcher; + + SinglePredicateEvaluator(Predicate.SinglePredicate proto) { + if (!proto.hasInput()) { + throw new IllegalArgumentException("SinglePredicate must have input"); + } + this.input = UnifiedMatcher.resolveInput(proto.getInput()); + + if (proto.hasValueMatch()) { + if (proto.getInput().getTypedConfig().getTypeUrl() + .equals(TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT)) { + throw new IllegalArgumentException( + "HttpAttributesCelMatchInput cannot be used with StringMatcher"); + } + this.stringMatcher = fromStringMatcherProto(proto.getValueMatch()); + this.celMatcher = null; + } else if (proto.hasCustomMatch()) { + this.stringMatcher = null; + TypedExtensionConfig customConfig = proto.getCustomMatch(); + if (customConfig.getTypedConfig().getTypeUrl().equals(TYPE_URL_CEL_MATCHER)) { + try { + com.github.xds.type.matcher.v3.CelMatcher celProto = customConfig.getTypedConfig() + .unpack(com.github.xds.type.matcher.v3.CelMatcher.class); + if (celProto.hasExprMatch()) { + com.github.xds.type.v3.CelExpression expr = celProto.getExprMatch(); + if (expr.hasCelExprChecked()) { + dev.cel.common.CelAbstractSyntaxTree ast = + dev.cel.common.CelProtoAbstractSyntaxTree.fromCheckedExpr( + expr.getCelExprChecked()).getAst(); + this.celMatcher = CelMatcher.compile(ast); + } else { + throw new IllegalArgumentException( + "CelMatcher must have cel_expr_checked"); + } + } else { + throw new IllegalArgumentException("CelMatcher must have expr_match"); + } + } catch (InvalidProtocolBufferException | CelValidationException + | CelEvaluationException e) { + throw new IllegalArgumentException("Invalid CelMatcher config", e); + } + } else { + throw new IllegalArgumentException("Unsupported custom_match matcher: " + + customConfig.getTypedConfig().getTypeUrl()); + } + if (this.celMatcher != null && !proto.getInput().getTypedConfig().getTypeUrl() + .equals(TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT)) { + throw new IllegalArgumentException( + "CelMatcher can only be used with HttpAttributesCelMatchInput"); + } + } else { + throw new IllegalArgumentException( + "SinglePredicate must have either value_match or custom_match"); + } + } + + @Override boolean evaluate(MatchContext context) { + Object value = input.apply(context); + if (stringMatcher != null) { + if (value instanceof String) { + return stringMatcher.matches((String) value); + } + return false; + } + if (celMatcher != null) { + try { + return celMatcher.match(value); + } catch (CelEvaluationException e) { + return false; + } + } + return false; + } + + private static Matchers.StringMatcher fromStringMatcherProto( + com.github.xds.type.matcher.v3.StringMatcher proto) { + if (proto.hasExact()) { + return Matchers.StringMatcher.forExact(proto.getExact(), proto.getIgnoreCase()); + } + if (proto.hasPrefix()) { + String prefix = proto.getPrefix(); + if (prefix.isEmpty()) { + throw new IllegalArgumentException( + "StringMatcher prefix (match_pattern) must be non-empty"); + } + return Matchers.StringMatcher.forPrefix(prefix, proto.getIgnoreCase()); + } + if (proto.hasSuffix()) { + String suffix = proto.getSuffix(); + if (suffix.isEmpty()) { + throw new IllegalArgumentException( + "StringMatcher suffix (match_pattern) must be non-empty"); + } + return Matchers.StringMatcher.forSuffix(suffix, proto.getIgnoreCase()); + } + if (proto.hasContains()) { + String contains = proto.getContains(); + if (contains.isEmpty()) { + throw new IllegalArgumentException( + "StringMatcher contains (match_pattern) must be non-empty"); + } + return Matchers.StringMatcher.forContains(contains, proto.getIgnoreCase()); + } + if (proto.hasSafeRegex()) { + String regex = proto.getSafeRegex().getRegex(); + if (regex.isEmpty()) { + throw new IllegalArgumentException( + "StringMatcher regex (match_pattern) must be non-empty"); + } + return Matchers.StringMatcher.forSafeRegEx( + com.google.re2j.Pattern.compile(regex)); + } + throw new IllegalArgumentException("Unknown StringMatcher match pattern"); + } + } + + private static final class OrMatcherEvaluator extends PredicateEvaluator { + private final List evaluators; + + OrMatcherEvaluator(Predicate.PredicateList proto) { + if (proto.getPredicateCount() < 2) { + throw new IllegalArgumentException("OrMatcher must have at least 2 predicates"); + } + this.evaluators = new ArrayList<>(proto.getPredicateCount()); + for (Predicate p : proto.getPredicateList()) { + evaluators.add(PredicateEvaluator.fromProto(p)); + } + } + + @Override boolean evaluate(MatchContext context) { + for (PredicateEvaluator e : evaluators) { + if (e.evaluate(context)) { + return true; + } + } + return false; + } + } + + private static final class AndMatcherEvaluator extends PredicateEvaluator { + private final List evaluators; + + AndMatcherEvaluator(Predicate.PredicateList proto) { + if (proto.getPredicateCount() < 2) { + throw new IllegalArgumentException("AndMatcher must have at least 2 predicates"); + } + this.evaluators = new ArrayList<>(proto.getPredicateCount()); + for (Predicate p : proto.getPredicateList()) { + evaluators.add(PredicateEvaluator.fromProto(p)); + } + } + + @Override boolean evaluate(MatchContext context) { + for (PredicateEvaluator e : evaluators) { + if (!e.evaluate(context)) { + return false; + } + } + return true; + } + } + + private static final class NotMatcherEvaluator extends PredicateEvaluator { + private final PredicateEvaluator evaluator; + + NotMatcherEvaluator(Predicate proto) { + this.evaluator = PredicateEvaluator.fromProto(proto); + } + + @Override boolean evaluate(MatchContext context) { + return !evaluator.evaluate(context); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java new file mode 100644 index 00000000000..6ec4800e4df --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -0,0 +1,204 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import com.google.protobuf.InvalidProtocolBufferException; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; +import io.grpc.Metadata; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import javax.annotation.Nullable; + +/** + * Represents a compiled xDS Matcher. + */ +public abstract class UnifiedMatcher { + + // Supported Extension Type URLs + private static final String TYPE_URL_HTTP_HEADER_INPUT = + "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; + private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + + + static final int MAX_RECURSION_DEPTH = 16; + + @Nullable + public abstract MatchResult match(MatchContext context, int depth); + + static MatchInput resolveInput(TypedExtensionConfig config) { + String typeUrl = config.getTypedConfig().getTypeUrl(); + try { + if (typeUrl.equals(TYPE_URL_HTTP_HEADER_INPUT)) { + HttpRequestHeaderMatchInput proto = config.getTypedConfig() + .unpack(HttpRequestHeaderMatchInput.class); + return new HeaderMatchInput(proto.getHeaderName()); + } else if (typeUrl.equals(TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT)) { + return new MatchInput() { + @Override + public Object apply(MatchContext context) { + return new GrpcCelEnvironment(context); + } + }; + } + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException("Invalid input config: " + typeUrl, e); + } + throw new IllegalArgumentException("Unsupported input type: " + typeUrl); + } + + + + private static final class HeaderMatchInput implements MatchInput { + private final String headerName; + + HeaderMatchInput(String headerName) { + this.headerName = checkNotNull(headerName, "headerName"); + if (headerName.isEmpty() || headerName.length() >= 16384) { + throw new IllegalArgumentException( + "Header name length must be in range [1, 16384): " + headerName.length()); + } + if (!headerName.equals(headerName.toLowerCase(java.util.Locale.ROOT))) { + throw new IllegalArgumentException("Header name must be lowercase: " + headerName); + } + try { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER); + } else { + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid header name: " + headerName, e); + } + } + + @Override + public String apply(MatchContext context) { + if ("te".equals(headerName)) { + return null; + } + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Iterable values = context.getMetadata().getAll( + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER)); + if (values == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (byte[] value : values) { + if (!first) { + sb.append(","); + } + first = false; + sb.append(com.google.common.io.BaseEncoding.base64().encode(value)); + } + return sb.toString(); + } + Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER)); + if (values == null) { + return null; + } + return String.join(",", values); + } + } + + /** + * Parses a proto Matcher into a UnifiedMatcher. + * + * @param proto the proto matcher + * @param actionValidator a predicate that returns true if the action type URL is supported + */ + public static UnifiedMatcher fromProto(Matcher proto, + java.util.function.Predicate actionValidator) { + checkRecursionDepth(proto, 0); + Matcher.OnMatch onNoMatch = proto.hasOnNoMatch() ? proto.getOnNoMatch() : null; + if (proto.hasMatcherList()) { + return new MatcherList(proto.getMatcherList(), onNoMatch, actionValidator); + } else if (proto.hasMatcherTree()) { + return new MatcherTree(proto.getMatcherTree(), onNoMatch, actionValidator); + } + return new NoOpMatcher(onNoMatch, actionValidator); + } + + /** + * Parses a proto Matcher into a UnifiedMatcher, allowing all actions. + */ + public static UnifiedMatcher fromProto(Matcher proto) { + return fromProto(proto, (typeUrl) -> true); + } + + private static void checkRecursionDepth(Matcher proto, int currentDepth) { + if (currentDepth > MAX_RECURSION_DEPTH) { + throw new IllegalArgumentException( + "Matcher tree depth exceeds limit of " + MAX_RECURSION_DEPTH); + } + if (proto.hasMatcherList()) { + for (Matcher.MatcherList.FieldMatcher fm : proto.getMatcherList().getMatchersList()) { + if (fm.hasOnMatch() && fm.getOnMatch().hasMatcher()) { + checkRecursionDepth(fm.getOnMatch().getMatcher(), currentDepth + 1); + } + } + } else if (proto.hasMatcherTree()) { + Matcher.MatcherTree tree = proto.getMatcherTree(); + if (tree.hasExactMatchMap()) { + for (Matcher.OnMatch onMatch : tree.getExactMatchMap().getMapMap().values()) { + if (onMatch.hasMatcher()) { + checkRecursionDepth(onMatch.getMatcher(), currentDepth + 1); + } + } + } else if (tree.hasPrefixMatchMap()) { + for (Matcher.OnMatch onMatch : tree.getPrefixMatchMap().getMapMap().values()) { + if (onMatch.hasMatcher()) { + checkRecursionDepth(onMatch.getMatcher(), currentDepth + 1); + } + } + } + } + if (proto.hasOnNoMatch() && proto.getOnNoMatch().hasMatcher()) { + checkRecursionDepth(proto.getOnNoMatch().getMatcher(), currentDepth + 1); + } + } + + private static final class NoOpMatcher extends UnifiedMatcher { + @Nullable private final OnMatch onNoMatch; + + NoOpMatcher(@Nullable Matcher.OnMatch onNoMatchProto, + java.util.function.Predicate actionValidator) { + if (onNoMatchProto != null) { + this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); + } else { + this.onNoMatch = null; + } + } + + @Override + public MatchResult match(MatchContext context, int depth) { + if (depth > MAX_RECURSION_DEPTH) { + return MatchResult.noMatch(); + } + if (onNoMatch != null) { + return onNoMatch.evaluate(context, depth); + } + return MatchResult.noMatch(); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java new file mode 100644 index 00000000000..1fe4b5871e3 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -0,0 +1,1482 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.CelMatcher; +import com.github.xds.type.matcher.v3.Matcher; +import com.github.xds.type.v3.CelExpression; +import com.github.xds.type.v3.CelExtractString; +import com.google.protobuf.Any; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import io.grpc.Metadata; +import io.grpc.xds.internal.matcher.MatchResult; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class UnifiedMatcherTest { + + private static CelCompiler COMPILER; + + @Test + public void verifyCelExtractStringInputNotSupported() { + CelExtractString proto = CelExtractString.getDefaultInstance(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unsupported input type"); + } + } + + @BeforeClass + public static void setupCompiler() { + COMPILER = CelCompilerFactory.standardCelCompilerBuilder() + .addVar("request", SimpleType.DYN) + .build(); + } + + private static CelMatcher createCelMatcher(String expression) { + try { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + CelProtoAbstractSyntaxTree protoAst = CelProtoAbstractSyntaxTree.fromCelAst(ast); + return CelMatcher.newBuilder() + .setExprMatch(CelExpression.newBuilder() + .setCelExprChecked(protoAst.toCheckedExpr()) + .build()) + .build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create CelMatcher for test", e); + } + } + + @Test + public void celMatcher_match() { + // Construct CelMatcher with checked expression + CelMatcher celMatcher = createCelMatcher("request.path == '/good'"); + // Predicate with HttpAttributesCelMatchInput + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + when(context.getPath()).thenReturn("/good"); + when(context.getMetadata()).thenReturn(new Metadata()); + + when(context.getId()).thenReturn("123"); + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + TypedExtensionConfig action = result.actions.get(0); + assertThat(action.getName()).isEqualTo("action1"); + + // Test mismatch + when(context.getPath()).thenReturn("/bad"); + result = matcher.match(context, 0); + TypedExtensionConfig noMatchAction = result.actions.get(0); + assertThat(noMatchAction.getName()).isEqualTo("no-match"); + } + + @Test + public void celMatcher_throwsIfReturnsString() { + try { + io.grpc.xds.internal.matcher.CelMatcher.compile("'should be bool'"); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must evaluate to boolean"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void celStringExtractor_throwsIfReturnsBool() { + try { + io.grpc.xds.internal.matcher.CelStringExtractor.compile("true"); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must evaluate to string"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void celMatcher_headers() { + CelMatcher celMatcher = createCelMatcher("request.headers['x-test'] == 'value'"); + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + when(context.getPath()).thenReturn("/"); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-test", Metadata.ASCII_STRING_MARSHALLER), "value"); + when(context.getMetadata()).thenReturn(headers); + when(context.getId()).thenReturn("123"); + + MatchResult result = matcher.match(context, 0); + TypedExtensionConfig action = result.actions.get(0); + assertThat(action.getName()).isEqualTo("matched"); + } + + @Test + public void matcherList_keepMatching() { + // Matcher 1: matches path '/multi', action 'action1', keep_matching = true + // Matcher 2: matches path '/multi', action 'action2', keep_matching = false + Matcher.MatcherList.Predicate predicate1 = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("/multi"))) + .build(); + Matcher.MatcherList.FieldMatcher matcher1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(predicate1) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher matcher2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(predicate1) // Same predicate + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(matcher1) + .addMatchers(matcher2)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + // Mock header "path" + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/multi"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); + assertThat(result.actions.get(0).getName()).isEqualTo("action1"); + assertThat(result.actions.get(1).getName()).isEqualTo("action2"); + } + + @Test + public void onNoMatchShouldNotExecuteWhenKeepMatchingTrueAndMatchFound() { + Matcher.MatcherList.FieldMatcher matcher1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("/test")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched")) + .setKeepMatching(true)) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(matcher1)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("should-not-run"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/test"); + when(context.getMetadata()).thenReturn(metadata); + MatchResult result = matcher.match(context, 0); + + boolean bugExists = result.actions.stream() + .anyMatch(a -> a.getName().equals("should-not-run")); + assertThat(bugExists).isFalse(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void actionValidation_acceptsSupportedType() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance())))) + .build(); + // Type URL of HttpAttributesCelMatchInput + String supportedType = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + + // Should not throw + UnifiedMatcher.fromProto(proto, (type) -> type.equals(supportedType)); + } + + @Test + public void actionValidation_rejectsUnsupportedType() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance())))) + .build(); + + try { + UnifiedMatcher.fromProto(proto, (type) -> false); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unsupported action type"); + assertThat(e).hasMessageThat().contains( + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"); + } + } + + @Test + public void recursionLimit_validation_should_fail_at_parse_time() { + Matcher current = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("leaf"))) + .build(); + + for (int i = 0; i < 20; i++) { + Matcher wrapper = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setCustomMatch(TypedExtensionConfig.newBuilder().setName("dummy")))) + .setOnMatch(Matcher.OnMatch.newBuilder().setMatcher(current)))) + .build(); + current = wrapper; + } + + try { + UnifiedMatcher.fromProto(current); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for depth > 16"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + private static Matcher.MatcherList.Predicate createHeaderMatchPredicate( + String header, String value) { + return Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(header).build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact(value))) + .build(); + } + + @Test + public void andMatcher_allTrue_matches() { + Matcher.MatcherList.Predicate p1 = createHeaderMatchPredicate("h1", "v1"); + Matcher.MatcherList.Predicate p2 = createHeaderMatchPredicate("h2", "v2"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(p1).addPredicate(p2))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v2"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void andMatcher_oneFalse_fails() { + Matcher.MatcherList.Predicate p1 = createHeaderMatchPredicate("h1", "v1"); + Matcher.MatcherList.Predicate p2 = createHeaderMatchPredicate("h2", "v2"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(p1).addPredicate(p2))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + // h2 is missing + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); // matched by onNoMatch + assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + } + + @Test + public void orMatcher_oneTrue_matches() { + Matcher.MatcherList.Predicate p1 = createHeaderMatchPredicate("h1", "v1"); + Matcher.MatcherList.Predicate p2 = createHeaderMatchPredicate("h2", "v2"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setOrMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(p1).addPredicate(p2))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v2"); + // h1 is missing + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void notMatcher_invert() { + Matcher.MatcherList.Predicate p1 = createHeaderMatchPredicate("h1", "v1"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setNotMatcher(p1)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + // h1 is missing, so inner p1 is false. NOT(false) -> True. + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void matcherTree_keepMatching_aggregate() { + // MatcherTree: Input = 'path'. + // Map: '/prefix' -> Action 'A1', KeepMatching=True + // OnNoMatch -> Action 'A2' + + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true) + .build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/prefix/something"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // Correct behavior per gRFC A106: onNoMatch is ONLY for when no match is found. + // Since we found "A1", onNoMatch ("A2") should NOT be executed. + // keepMatching=true simply means we return with matched=true and let the parent decide. + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + } + + @Test + public void requestUrlPath_available() { + CelMatcher celMatcher = createCelMatcher("request.url_path == '/path/without/query'"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + when(context.getPath()).thenReturn("/path/without/query"); + when(context.getMetadata()).thenReturn(new Metadata()); + when(context.getId()).thenReturn("123"); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void matchInput_headerName_invalidLength() { + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("") + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + + // Empty invalid + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("range [1, 16384)"); + } + + // Too long invalid + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 16384; i++) { + sb.append("a"); + } + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput longProto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(sb.toString()) + .build(); + TypedExtensionConfig longConfig = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(longProto)) + .build(); + + try { + UnifiedMatcher.resolveInput(longConfig); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("range [1, 16384)"); + } + } + + @Test + public void matchInput_headerName_invalidChars_throws() { + // Uppercase not allowed in HTTP/2 validation by Metadata.Key + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("UpperCase") + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for invalid header name"); + } catch (IllegalArgumentException e) { + // Expected + } + } + + @Test + public void invalidInputCombination_stringMatcherWithCelInput_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("any")))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with StringMatcher"); + } + } + + @Test + public void invalidInputCombination_matcherTreeWithCelInput_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("key", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build()))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + } + + @Test + public void matchInput_headerName_binary() { + String headerName = "test-bin"; + byte[] binaryValue = new byte[] {1, 2, 3}; + String expectedBase64 = com.google.common.io.BaseEncoding.base64().encode(binaryValue); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), binaryValue); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadata); + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(headerName) + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + + MatchInput input = UnifiedMatcher.resolveInput(config); + + assertThat(input.apply(context)).isEqualTo(expectedBase64); + } + + @Test + public void matchInput_headerName_te_returnsNull() { + String headerName = "te"; + Metadata metadata = new Metadata(); + // "te" is technically ASCII. + metadata.put( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER), "trailers"); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadata); + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(headerName) + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + + MatchInput input = UnifiedMatcher.resolveInput(config); + + assertThat(input.apply(context)).isNull(); + } + + @Test + public void matcherTree_customMatch_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setName("custom"))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("does not support custom_match"); + } + } + + @Test + public void matcherTree_noMap_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build())))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("must have either exact_match_map or prefix_match_map"); + } + } + + @Test + public void matcherTree_exactMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("foo", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_foo")).build()) + .putMap("bar", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_bar")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-key", Metadata.ASCII_STRING_MARSHALLER), "foo"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched_foo"); + } + + @Test + public void matcherTree_prefixMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/api", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("api")).build()) + .putMap("/api/v1", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("apiv1")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/api/v1/users"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // Longest prefix wins + assertThat(result.actions.get(0).getName()).isEqualTo("apiv1"); + } + + @Test + public void requestHeaders_equalityCheck_failsSafely() { + // request.headers == {'a':'1','b':'2','c':'3'} requires entrySet(), + // which throws UnsupportedOperationException + // We want to ensure this is caught and treated as a mismatch or error, not a crash. + // HeadersWrapper has 3 pseudo headers by default, so size is 3. + // We match size to force entrySet check. + CelMatcher celMatcher = createCelMatcher( + "request.headers == {'a':'1','b':'2','c':'3'}"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + // Should NOT throw exception + MatchResult result = matcher.match(context, 0); + + // Predicate should evaluate to false (due to error or mismatch), + // so it falls through to onNoMatch. onNoMatch has an action, so matched=true + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + } + + @Test + public void noOpMatcher_delegatesToOnNoMatch() { + // Matcher with no list and no tree -> NoOpMatcher + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + // Verify it is indeed NoOpMatcher + assertThat(matcher.getClass().getSimpleName()).isEqualTo("NoOpMatcher"); + + MatchContext context = mock(MatchContext.class); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("fallback"); + } + + @Test + public void matcherRunner_checkMatch_returnsActions() { + Matcher validProto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v1")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("runner-action"))))) + .build(); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + when(context.getMetadata()).thenReturn(metadata); + java.util.List results = + io.grpc.xds.internal.matcher.MatcherRunner.checkMatch(validProto, context); + + assertThat(results).isNotNull(); + assertThat(results).hasSize(1); + assertThat(results.get(0).getName()).isEqualTo("runner-action"); + } + + @Test + public void matcherRunner_checkMatch_returnsNullOnNoMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v1")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("runner-action"))))) + .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); // Empty headers + java.util.List results = + io.grpc.xds.internal.matcher.MatcherRunner.checkMatch(proto, context); + + assertThat(results).isNull(); + } + + @Test + public void matcherList_firstMatchWins_evenIfNestedNoMatch() { + Matcher.MatcherList.Predicate predicate = createHeaderMatchPredicate("path", "/common"); + Matcher.MatcherList.FieldMatcher matcher1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(predicate) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder())) + .build(); + Matcher.MatcherList.FieldMatcher matcher2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(predicate) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("should-not-run"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(matcher1) + .addMatchers(matcher2)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match-global"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/common"); + when(context.getMetadata()).thenReturn(metadata); + MatchResult result = matcher.match(context, 0); + + if (result.matched) { + for (TypedExtensionConfig action : result.actions) { + assertThat(action.getName()).isNotEqualTo("should-not-run"); + } + } + } + + @Test + public void matcherTree_exactMatch_shouldNotFallBackToOnNoMatch_ifKeyFound() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("foo", Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder()) // Empty matcher = No Match + .build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("x-key", Metadata.ASCII_STRING_MARSHALLER), "foo"); + when(context.getMetadata()).thenReturn(metadata); + MatchResult result = matcher.match(context, 0); + + if (result.matched) { + for (TypedExtensionConfig action : result.actions) { + assertThat(action.getName()).isNotEqualTo("fallback"); + } + } + } + + @Test + public void stringMatcher_contains_ignoreCase() { + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-test").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setContains("bar") + .setIgnoreCase(true)) + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("x-test", Metadata.ASCII_STRING_MARSHALLER), "FooBaR"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + } + + @Test + public void matcherTree_emptyMap_throws() { + // Empty exact match map + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder())) // Empty map + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exact_match_map must contain at least one entry"); + } + + // Empty prefix match map + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder())) // Empty map + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("prefix_match_map must contain at least one entry"); + } + } + + @Test + public void stringMatcher_emptyPatterns_throws() { + // Empty Prefix + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setPrefix("")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for empty prefix"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("prefix (match_pattern) must be non-empty"); + } + + // Empty Suffix + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSuffix("")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for empty suffix"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("suffix (match_pattern) must be non-empty"); + } + + // Empty Contains + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setContains("")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for empty contains"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("contains (match_pattern) must be non-empty"); + } + + // Empty Regex + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSafeRegex(com.github.xds.type.matcher.v3.RegexMatcher.newBuilder() + .setRegex(""))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for empty regex"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("regex (match_pattern) must be non-empty"); + } + } + + @Test + public void celMatcher_wrongInput_throws() { + // Attempt to use CelMatcher with HeaderMatchInput (invalid) + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput inputProto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("test").build(); + com.github.xds.type.matcher.v3.CelMatcher celMatcherProto = createCelMatcher("true"); + + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(inputProto))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcherProto)) + .setName("cel_matcher")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException for incompatible input"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("CelMatcher can only be used with HttpAttributesCelMatchInput"); + } + } + + @Test + public void celMatcher_withoutCelExprChecked_throws() { + com.github.xds.type.matcher.v3.CelMatcher celMatcherParsed = + com.github.xds.type.matcher.v3.CelMatcher.newBuilder() + .setExprMatch(com.github.xds.type.v3.CelExpression.newBuilder() + .setCelExprParsed(dev.cel.expr.ParsedExpr.getDefaultInstance())) + .build(); + + try { + UnifiedMatcher.fromProto(wrapInMatcher(celMatcherParsed)); + org.junit.Assert.fail( + "Should have thrown IllegalArgumentException for missing cel_expr_checked"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("CelMatcher must have cel_expr_checked"); + } + } + + @Test + public void celMatcher_withCelExprString_throws() { + // Create a dummy ParsedExpr using the correct type for xds.type.v3.CelExpression + dev.cel.expr.ParsedExpr parsedExpr = + dev.cel.expr.ParsedExpr.getDefaultInstance(); + com.github.xds.type.matcher.v3.CelMatcher celMatcherParsed = + com.github.xds.type.matcher.v3.CelMatcher.newBuilder() + .setExprMatch(com.github.xds.type.v3.CelExpression.newBuilder() + .setCelExprParsed(parsedExpr) + .build()) + .build(); + Matcher proto = wrapInMatcher(celMatcherParsed); + + try { + UnifiedMatcher.fromProto(proto); + org.junit.Assert.fail( + "Should have thrown IllegalArgumentException for using cel_expr_parsed"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("CelMatcher must have cel_expr_checked"); + } + } + + private Matcher wrapInMatcher(com.github.xds.type.matcher.v3.CelMatcher celMatcher) { + return Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher)) + .setName("cel_matcher")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build(); + } + + @Test + public void matcherList_keepMatching_verification() { + // M1: matches, action A1, keep matching + // M2: matches, action A2, stop matching + // M3: matches, action A3 (should be ignored) + Matcher.MatcherList.FieldMatcher m1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("h").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("val")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher m2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("h").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("val")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2")) + .setKeepMatching(false)) + .build(); + Matcher.MatcherList.FieldMatcher m3 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("h").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("val")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A3"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(m1) + .addMatchers(m2) + .addMatchers(m3)) + .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("h", "val")); + io.grpc.xds.internal.matcher.UnifiedMatcher matcher = + io.grpc.xds.internal.matcher.UnifiedMatcher.fromProto(proto, (t) -> true); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + assertThat(result.actions.get(1).getName()).isEqualTo("A2"); + } + + @Test + public void matcherTree_keepMatching_longestPrefixFirst() { + // Prefix /abc: A1, keep=true + // Prefix /ab: A2, keep=true + // Prefix /a: A3, keep=FALSE + + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/abc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true).build()) + .putMap("/ab", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2")) + .setKeepMatching(true).build()) + .putMap("/a", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A3")) + .setKeepMatching(false).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(map)) + .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("path", "/abc")); + io.grpc.xds.internal.matcher.UnifiedMatcher matcher = + io.grpc.xds.internal.matcher.UnifiedMatcher.fromProto(proto, (t) -> true); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + // Implementation sorts longest to shortest: /abc, /ab, /a + // 1. /abc matches -> A1. keep=true. + // 2. /ab matches -> A2. keep=true. + // 3. /a matches -> A3. keep=false -> STOP. + assertThat(result.actions).hasSize(3); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + assertThat(result.actions.get(1).getName()).isEqualTo("A2"); + assertThat(result.actions.get(2).getName()).isEqualTo("A3"); + } + + private Metadata metadataWith(String key, String value) { + Metadata m = new Metadata(); + m.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + return m; + } + + // Below are the 4 Unified Matcher: Evaluation Examples from gRFC A106: https://github.com/grpc/proposal/pull/520 + @Test + public void matcherList_example1_simpleLinearMatch() { + // Matcher 1: x-user-segment == "premium" -> "route_to_premium_cluster" + // Matcher 2: x-user-segment prefix "standard-" -> "route_to_standard_cluster" + // On No Match: -> "route_to_default_cluster" + + Matcher.MatcherList.FieldMatcher matcher1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-user-segment").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("premium")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("route_to_premium_cluster"))) + .build(); + Matcher.MatcherList.FieldMatcher matcher2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-user-segment").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setPrefix("standard-")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("route_to_standard_cluster"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(matcher1) + .addMatchers(matcher2)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("route_to_default_cluster"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + + // Scenario 1: Matches second matcher (standard user) + MatchContext context1 = mock(MatchContext.class); + when(context1.getMetadata()).thenReturn(metadataWith("x-user-segment", "standard-user-1")); + + MatchResult result1 = matcher.match(context1, 0); + assertThat(result1.matched).isTrue(); + assertThat(result1.actions).hasSize(1); + assertThat(result1.actions.get(0).getName()).isEqualTo("route_to_standard_cluster"); + + // Scenario 2: Matches first matcher (premium user) + MatchContext context2 = mock(MatchContext.class); + when(context2.getMetadata()).thenReturn(metadataWith("x-user-segment", "premium")); + + MatchResult result2 = matcher.match(context2, 0); + assertThat(result2.matched).isTrue(); + assertThat(result2.actions).hasSize(1); + assertThat(result2.actions.get(0).getName()).isEqualTo("route_to_premium_cluster"); + + // Scenario 3: Matches neither (fallback to default) - "Request Input 2" from user + MatchContext context3 = mock(MatchContext.class); + when(context3.getMetadata()).thenReturn(metadataWith("x-user-segment", "guest")); + + MatchResult result3 = matcher.match(context3, 0); + assertThat(result3.matched).isTrue(); + // onNoMatch logic returns matched=true when it executes successfully + assertThat(result3.actions).hasSize(1); + assertThat(result3.actions.get(0).getName()).isEqualTo("route_to_default_cluster"); + } + + @Test + public void matcherList_example2_keepMatching() { + TypedExtensionConfig celInput = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance())) + .build(); + Matcher.MatcherList.FieldMatcher m1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("true"))).setName("match1")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action_1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher m2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("false"))).setName("match2")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action_2"))) + .build(); + Matcher.MatcherList.FieldMatcher m3 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("true"))).setName("match3")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action_3"))) + .build(); + Matcher.MatcherList.FieldMatcher m4 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("false"))).setName("match4")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action_4"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(m1) + .addMatchers(m2) + .addMatchers(m3) + .addMatchers(m4)) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + MatchResult result = matcher.match(mock(MatchContext.class), 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); + assertThat(result.actions.get(0).getName()).isEqualTo("action_1"); + assertThat(result.actions.get(1).getName()).isEqualTo("action_3"); + } + + @Test + public void matcherList_example3_nestedMatcher() { + TypedExtensionConfig celInput = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance())) + .build(); + + // Inner Matcher 1: False -> inner_matcher_1 + // Inner Matcher 2: True -> inner_matcher_2 + Matcher innerProto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("false")))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("inner_matcher_1")))) + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("true")))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("inner_matcher_2"))))) + .build(); + // Outer Matcher: True -> Nested Matcher + Matcher outerProto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(celInput) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("true")))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setMatcher(innerProto)))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(outerProto, (t) -> true); + MatchResult result = matcher.match(mock(MatchContext.class), 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("inner_matcher_2"); + } + + @Test + public void matcherTree_example4_prefixMap() { + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("grpc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("shorter_prefix")).build()) + .putMap("grpc.channelz", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("longer_prefix")).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-user-segment").build()))) + .setPrefixMatchMap(map)) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn( + metadataWith("x-user-segment", "grpc.channelz.v1.Channelz/GetTopChannels")); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("longer_prefix"); + } +} From 01c850974dc77ff0d597d190069d50d430f20383 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Tue, 3 Feb 2026 16:55:35 +0530 Subject: [PATCH 02/23] add some unit tests to increase coverage --- .../internal/matcher/UnifiedMatcherTest.java | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 1fe4b5871e3..4ca597d41f6 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -133,6 +133,42 @@ public void celMatcher_throwsIfReturnsString() { } } + @Test + public void celMatcher_evaluationError_returnsFalse() { + CelMatcher celMatcher = createCelMatcher("int(request.path) == 0"); + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + when(context.getPath()).thenReturn("not-an-int"); + // Ensure metadata access (if any by environment) checks out + when(context.getMetadata()).thenReturn(new Metadata()); + when(context.getId()).thenReturn("1"); + + MatchResult result = matcher.match(context, 0); + // Should return false for match, so it falls through to no-match + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + } + @Test public void celStringExtractor_throwsIfReturnsBool() { try { @@ -852,6 +888,123 @@ public void matcherRunner_checkMatch_returnsNullOnNoMatch() { assertThat(results).isNull(); } + @Test + public void predicate_missingType_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.getDefaultInstance(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Predicate must have one of"); + } + } + + @Test + public void celMatcher_missingExprMatch_throws() { + com.github.xds.type.matcher.v3.CelMatcher celProto = + com.github.xds.type.matcher.v3.CelMatcher.getDefaultInstance(); + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack(celProto)))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("CelMatcher must have expr_match"); + } + } + + @Test + public void singlePredicate_unsupportedCustomMatcher_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(com.google.protobuf.Empty.getDefaultInstance())))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unsupported custom_match matcher"); + } + } + + @Test + public void singlePredicate_missingInput_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("foo"))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("SinglePredicate must have input"); + } + } + + @Test + public void singlePredicate_missingMatcher_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance())))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "SinglePredicate must have either value_match or custom_match"); + } + } + + @Test + public void orMatcher_tooFewPredicates_throws() { + Matcher.MatcherList.Predicate.PredicateList protoList = + Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setName("i")) + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("v")))) + .build(); + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setOrMatcher(protoList) + .build(); + try { + PredicateEvaluator.fromProto(proto); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); + } + } + + @Test + public void andMatcher_tooFewPredicates_throws() { + Matcher.MatcherList.Predicate.PredicateList proto = + Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setName("i")) + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("v")))) + .build(); + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(proto).build()); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); + } + } + @Test public void matcherList_firstMatchWins_evenIfNestedNoMatch() { Matcher.MatcherList.Predicate predicate = createHeaderMatchPredicate("path", "/common"); From 0e9f36275b2331aaef749fe3b5562d06136e96d5 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 13:28:09 +0530 Subject: [PATCH 03/23] add some more unit tests --- .../xds/internal/matcher/MatcherTreeTest.java | 415 ++++++++++++++++++ .../internal/matcher/UnifiedMatcherTest.java | 210 +-------- 2 files changed, 416 insertions(+), 209 deletions(-) create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java new file mode 100644 index 00000000000..08476870a19 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java @@ -0,0 +1,415 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import com.google.protobuf.Any; +import io.grpc.Metadata; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class MatcherTreeTest { + + @Test + public void matcherTree_missingInput_throws() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder().build(); + try { + new MatcherTree(proto, null, s -> true); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("MatcherTree must have input"); + } + } + + @Test + public void matcherTree_unsupportedCelInput_throws() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .build(); + try { + new MatcherTree(proto, null, s -> true); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + } + + @Test + public void matcherTree_emptyMaps_throws() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.getDefaultInstance()) + .build(); + try { + new MatcherTree(proto, null, s -> true); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("MatcherTree exact_match_map must contain at least one entry"); + } + } + + @Test + public void matcherTree_maxRecursionDepth_returnsNoMatch() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build())) + .build(); + + MatcherTree tree = new MatcherTree(proto, null, s -> true); + MatchContext context = mock(MatchContext.class); + MatchResult result = tree.match(context, 17); + assertThat(result.matched).isFalse(); + } + + @Test + public void matcherTree_nonStringInput_fallsBack() { + + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build())) + .build(); + + MatcherTree tree = new MatcherTree(proto, + Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback")).build(), + s -> true); + + MatchContext context = mock(MatchContext.class); + + when(context.getMetadata()).thenReturn(new io.grpc.Metadata()); // No headers + + MatchResult result = tree.match(context, 0); + assertThat(result.matched).isTrue(); // onNoMatch matched + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("fallback"); + } + + @Test + public void matcherTree_noExactMatch_fallsBackToOnNoMatch() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build())) + .build(); + + Matcher.OnMatch onNoMatch = Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback")) + .build(); + MatcherTree tree = new MatcherTree(proto, onNoMatch, s -> true); + + MatchContext context = mock(MatchContext.class); + io.grpc.Metadata metadata = new io.grpc.Metadata(); + metadata.put(io.grpc.Metadata.Key.of("foo", io.grpc.Metadata.ASCII_STRING_MARSHALLER), "other"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = tree.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("fallback"); + } + + @Test + public void matcherTree_prefixFoundButNestedFailed_returnNoMatch_notOnNoMatch() { + Matcher.MatcherTree nestedProto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("bar").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("baz", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("n/a")).build())) + .build(); + + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder().setMatcherTree(nestedProto)) + .build())) + .build(); + + Matcher.OnMatch onNoMatch = Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("should-not-be-called")) + .build(); + + MatcherTree tree = new MatcherTree(proto, onNoMatch, s -> true); + + MatchContext context = mock(MatchContext.class); + io.grpc.Metadata metadata = new io.grpc.Metadata(); + metadata.put(io.grpc.Metadata.Key.of("path", io.grpc.Metadata.ASCII_STRING_MARSHALLER), + "/prefix/foo"); + metadata.put(io.grpc.Metadata.Key.of("bar", io.grpc.Metadata.ASCII_STRING_MARSHALLER), "wrong"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = tree.match(context, 0); + + assertThat(result.matched).isFalse(); + // Verify it isn't the onNoMatch action + if (!result.actions.isEmpty()) { + assertThat(result.actions.get(0).getName()).isNotEqualTo("should-not-be-called"); + } + } + + @Test + public void matcherTree_noMap_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build())))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("must have either exact_match_map or prefix_match_map"); + } + } + + @Test + public void matcherTree_customMatch_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setName("custom"))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("does not support custom_match"); + } + } + + @Test + public void matcherTree_exactMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("foo", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_foo")).build()) + .putMap("bar", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_bar")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-key", Metadata.ASCII_STRING_MARSHALLER), "foo"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("matched_foo"); + } + + @Test + public void matcherTree_prefixMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/api", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("api")).build()) + .putMap("/api/v1", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("apiv1")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/api/v1/users"); + when(context.getMetadata()).thenReturn(headers); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // Longest prefix wins + assertThat(result.actions.get(0).getName()).isEqualTo("apiv1"); + } + + @Test + public void matcherTree_keepMatching_aggregate() { + // MatcherTree: Input = 'path'. + // Map: '/prefix' -> Action 'A1', KeepMatching=True + // OnNoMatch -> Action 'A2' + + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true) + .build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/prefix/something"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // Correct behavior per gRFC A106: onNoMatch is ONLY for when no match is found. + // Since we found "A1", onNoMatch ("A2") should NOT be executed. + // keepMatching=true simply means we return with matched=true and let the parent decide. + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + } + + @Test + public void matcherTree_keepMatching_longestPrefixFirst() { + // Prefix /abc: A1, keep=true + // Prefix /ab: A2, keep=true + // Prefix /a: A3, keep=FALSE + + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/abc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true).build()) + .putMap("/ab", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2")) + .setKeepMatching(true).build()) + .putMap("/a", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A3")) + .setKeepMatching(false).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(map)) + .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("path", "/abc")); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + // Implementation sorts longest to shortest: /abc, /ab, /a + // 1. /abc matches -> A1. keep=true. + // 2. /ab matches -> A2. keep=true. + // 3. /a matches -> A3. keep=false -> STOP. + assertThat(result.actions).hasSize(3); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + assertThat(result.actions.get(1).getName()).isEqualTo("A2"); + assertThat(result.actions.get(2).getName()).isEqualTo("A3"); + } + + @Test + public void matcherTree_example4_prefixMap() { + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("grpc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("shorter_prefix")).build()) + .putMap("grpc.channelz", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("longer_prefix")).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-user-segment").build()))) + .setPrefixMatchMap(map)) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn( + metadataWith("x-user-segment", "grpc.channelz.v1.Channelz/GetTopChannels")); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("longer_prefix"); + } + + @Test + public void invalidInputCombination_matcherTreeWithCelInput_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("key", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build()))) + .build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + } + + private Metadata metadataWith(String key, String value) { + Metadata m = new Metadata(); + m.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + return m; + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 4ca597d41f6..540c2e25bef 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -488,41 +488,7 @@ public void notMatcher_invert() { assertThat(result.actions.get(0).getName()).isEqualTo("matched"); } - @Test - public void matcherTree_keepMatching_aggregate() { - // MatcherTree: Input = 'path'. - // Map: '/prefix' -> Action 'A1', KeepMatching=True - // OnNoMatch -> Action 'A2' - - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("path").build()))) - .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("/prefix", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A1")) - .setKeepMatching(true) - .build()))) - .setOnNoMatch(Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) - .build(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - - MatchContext context = mock(MatchContext.class); - Metadata metadata = new Metadata(); - metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/prefix/something"); - when(context.getMetadata()).thenReturn(metadata); - MatchResult result = matcher.match(context, 0); - assertThat(result.matched).isTrue(); - // Correct behavior per gRFC A106: onNoMatch is ONLY for when no match is found. - // Since we found "A1", onNoMatch ("A2") should NOT be executed. - // keepMatching=true simply means we return with matched=true and let the parent decide. - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("A1"); - } @Test public void requestUrlPath_available() { @@ -635,25 +601,7 @@ public void invalidInputCombination_stringMatcherWithCelInput_throws() { } } - @Test - public void invalidInputCombination_matcherTreeWithCelInput_throws() { - try { - UnifiedMatcher.fromProto(Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput - .getDefaultInstance()))) - .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("key", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("action")).build()))) - .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat() - .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); - } - } + @Test public void matchInput_headerName_binary() { @@ -699,96 +647,9 @@ public void matchInput_headerName_te_returnsNull() { assertThat(input.apply(context)).isNull(); } - @Test - public void matcherTree_customMatch_throws() { - try { - UnifiedMatcher.fromProto(Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("key").build()))) - .setCustomMatch(TypedExtensionConfig.newBuilder().setName("custom"))) - .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("does not support custom_match"); - } - } - @Test - public void matcherTree_noMap_throws() { - try { - UnifiedMatcher.fromProto(Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("key").build())))) - .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat() - .contains("must have either exact_match_map or prefix_match_map"); - } - } - @Test - public void matcherTree_exactMatch() { - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("x-key").build()))) - .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("foo", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("matched_foo")).build()) - .putMap("bar", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("matched_bar")).build()))) - .setOnNoMatch(Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) - .build(); - - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - Metadata headers = new Metadata(); - headers.put(Metadata.Key.of("x-key", Metadata.ASCII_STRING_MARSHALLER), "foo"); - when(context.getMetadata()).thenReturn(headers); - - MatchResult result = matcher.match(context, 0); - assertThat(result.matched).isTrue(); - assertThat(result.actions.get(0).getName()).isEqualTo("matched_foo"); - } - - @Test - public void matcherTree_prefixMatch() { - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("path").build()))) - .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("/api", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("api")).build()) - .putMap("/api/v1", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("apiv1")).build()))) - .setOnNoMatch(Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) - .build(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - Metadata headers = new Metadata(); - headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/api/v1/users"); - when(context.getMetadata()).thenReturn(headers); - - MatchResult result = matcher.match(context, 0); - assertThat(result.matched).isTrue(); - // Longest prefix wins - assertThat(result.actions.get(0).getName()).isEqualTo("apiv1"); - } @Test public void requestHeaders_equalityCheck_failsSafely() { @@ -1378,48 +1239,7 @@ public void matcherList_keepMatching_verification() { assertThat(result.actions.get(1).getName()).isEqualTo("A2"); } - @Test - public void matcherTree_keepMatching_longestPrefixFirst() { - // Prefix /abc: A1, keep=true - // Prefix /ab: A2, keep=true - // Prefix /a: A3, keep=FALSE - Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("/abc", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A1")) - .setKeepMatching(true).build()) - .putMap("/ab", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A2")) - .setKeepMatching(true).build()) - .putMap("/a", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("A3")) - .setKeepMatching(false).build()) - .build(); - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("path").build()))) - .setPrefixMatchMap(map)) - .build(); - - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("path", "/abc")); - io.grpc.xds.internal.matcher.UnifiedMatcher matcher = - io.grpc.xds.internal.matcher.UnifiedMatcher.fromProto(proto, (t) -> true); - MatchResult result = matcher.match(context, 0); - - assertThat(result.matched).isTrue(); - // Implementation sorts longest to shortest: /abc, /ab, /a - // 1. /abc matches -> A1. keep=true. - // 2. /ab matches -> A2. keep=true. - // 3. /a matches -> A3. keep=false -> STOP. - assertThat(result.actions).hasSize(3); - assertThat(result.actions.get(0).getName()).isEqualTo("A1"); - assertThat(result.actions.get(1).getName()).isEqualTo("A2"); - assertThat(result.actions.get(2).getName()).isEqualTo("A3"); - } private Metadata metadataWith(String key, String value) { Metadata m = new Metadata(); @@ -1604,32 +1424,4 @@ public void matcherList_example3_nestedMatcher() { assertThat(result.actions).hasSize(1); assertThat(result.actions.get(0).getName()).isEqualTo("inner_matcher_2"); } - - @Test - public void matcherTree_example4_prefixMap() { - Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() - .putMap("grpc", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("shorter_prefix")).build()) - .putMap("grpc.channelz", Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("longer_prefix")).build()) - .build(); - Matcher proto = Matcher.newBuilder() - .setMatcherTree(Matcher.MatcherTree.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("x-user-segment").build()))) - .setPrefixMatchMap(map)) - .build(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); - - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn( - metadataWith("x-user-segment", "grpc.channelz.v1.Channelz/GetTopChannels")); - MatchResult result = matcher.match(context, 0); - - assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("longer_prefix"); - } } From 6be787a9fa44393f6fe25a4737ba4b6b34d4bbb1 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 14:36:11 +0530 Subject: [PATCH 04/23] add some more unit tests --- .../internal/matcher/UnifiedMatcherTest.java | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 540c2e25bef..fe9a8b061a5 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1424,4 +1424,162 @@ public void matcherList_example3_nestedMatcher() { assertThat(result.actions).hasSize(1); assertThat(result.actions.get(0).getName()).isEqualTo("inner_matcher_2"); } + + @Test + public void resolveInput_malformedProto_throws() { + // Create a config with correct typeUrl but corrupted/invalid bytes + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput") + .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid-proto-data")) + .build()) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid input config"); + } + } + + @Test + public void matchInput_headerName_invalidCharacters_throws() { + // A lowercase header name that is still invalid for gRPC Metadata keys + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("invalid$header") + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid header name"); + } + } + + @Test + public void matchInput_headerName_binary_missing() { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("test-bin") // Binary suffix + .build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + + assertThat(input.apply(context)).isNull(); + } + + @Test + public void noOpMatcher_noOnNoMatch_returnsNoMatch() { + // An empty Matcher proto defaults to a NoOpMatcher with no onNoMatch action + Matcher proto = Matcher.getDefaultInstance(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchResult result = matcher.match(mock(MatchContext.class), 0); + assertThat(result.matched).isFalse(); + } + + @Test + public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { + Matcher proto = Matcher.getDefaultInstance(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + // Manually trigger the runtime recursion check in NoOpMatcher + MatchResult result = matcher.match(mock(MatchContext.class), 17); + assertThat(result.matched).isFalse(); + } + + @Test + public void singlePredicate_celInputWithStringMatcher_throws() { + // Tests the explicit check that blocks CEL input for StringMatchers + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("foo")) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "HttpAttributesCelMatchInput cannot be used with StringMatcher"); + } + } + + @Test + public void singlePredicate_headerInputWithCelMatcher_throws() { + // Tests the inverse check: CelMatcher must use HttpAttributesCelMatchInput + com.github.xds.type.matcher.v3.CelMatcher celMatcher = createCelMatcher("true"); + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(celMatcher))) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "CelMatcher can only be used with HttpAttributesCelMatchInput"); + } + } + + @Test + public void singlePredicate_noMatcher_throws() { + // Triggers: "SinglePredicate must have either value_match or custom_match" + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(com.google.protobuf.Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must have either value_match or custom_match"); + } + } + + @Test + public void compoundMatchers_tooFewPredicates_throws() { + // Coverage for OrMatcher and AndMatcher minimum predicate requirements + Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); + Matcher.MatcherList.Predicate.PredicateList list = + Matcher.MatcherList.Predicate.PredicateList.newBuilder().addPredicate(p).build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setOrMatcher(list).build()); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); + } + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(list).build()); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); + } + } } From 9267ee4b2c65f4b717e4d09f8986a236cc20c0f6 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 15:52:20 +0530 Subject: [PATCH 05/23] fix/add tests --- .../internal/matcher/UnifiedMatcherTest.java | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index fe9a8b061a5..b1ea0e3cd9a 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1582,4 +1582,133 @@ public void compoundMatchers_tooFewPredicates_throws() { assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); } } + + @Test + public void singlePredicate_invalidCelMatcherProto_throws() { + // Triggers "Invalid CelMatcher config" + // Create a CEL matcher config with invalid bytes to trigger InvalidProtocolBufferException + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.newBuilder() + .setTypeUrl("type.googleapis.com/xds.type.matcher.v3.CelMatcher") + .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid")) + .build()) + .build(); + + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(config) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); + } + } + + @Test + public void singlePredicate_celEvalError_returnsFalse() { + // Triggers "return false" in "catch (CelEvaluationException e)" + // Expression: [][0] -> Index out of bounds, runtime error + // Type check passes (list access). + // We need a CheckedExpr. + + // We rely on a runtime failure (division by zero) to trigger CelEvaluationException. + + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("1/0 == 0")))) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + // Eval should return false (caught exception) + assertThat(evaluator.evaluate(mock(MatchContext.class))).isFalse(); + } + + @Test + public void stringMatcher_emptySuffix_throws() { + // Triggers "StringMatcher suffix ... must be non-empty" + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setSuffix("")) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "StringMatcher suffix (match_pattern) must be non-empty"); + } + } + + @Test + public void stringMatcher_unknownPattern_throws() { + // Triggers "Unknown StringMatcher match pattern" + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.getDefaultInstance()) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unknown StringMatcher match pattern"); + } + } + + + @Test + public void singlePredicate_stringMatcher_safeRegex_matches() { + // Verifies valid safe_regex config + com.github.xds.type.matcher.v3.RegexMatcher regexMatcher = + com.github.xds.type.matcher.v3.RegexMatcher.newBuilder() + .setRegex("f.*o") + .build(); + + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSafeRegex(regexMatcher)) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + // host header not present -> null -> false + assertThat(evaluator.evaluate(context)).isFalse(); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("host", Metadata.ASCII_STRING_MARSHALLER), "foo"); + when(context.getMetadata()).thenReturn(headers); + assertThat(evaluator.evaluate(context)).isTrue(); + } } From 5b423ed48fdb5c50b0bf399251735bad0823c0a8 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 15:55:30 +0530 Subject: [PATCH 06/23] remove not required tests --- .../internal/matcher/UnifiedMatcherTest.java | 164 ------------------ 1 file changed, 164 deletions(-) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index b1ea0e3cd9a..25b3aba5563 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1425,164 +1425,6 @@ public void matcherList_example3_nestedMatcher() { assertThat(result.actions.get(0).getName()).isEqualTo("inner_matcher_2"); } - @Test - public void resolveInput_malformedProto_throws() { - // Create a config with correct typeUrl but corrupted/invalid bytes - TypedExtensionConfig config = TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.newBuilder() - .setTypeUrl("type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput") - .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid-proto-data")) - .build()) - .build(); - try { - UnifiedMatcher.resolveInput(config); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("Invalid input config"); - } - } - - @Test - public void matchInput_headerName_invalidCharacters_throws() { - // A lowercase header name that is still invalid for gRPC Metadata keys - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("invalid$header") - .build(); - TypedExtensionConfig config = TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack(proto)) - .build(); - try { - UnifiedMatcher.resolveInput(config); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("Invalid header name"); - } - } - - @Test - public void matchInput_headerName_binary_missing() { - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(new Metadata()); - - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("test-bin") // Binary suffix - .build(); - MatchInput input = UnifiedMatcher.resolveInput( - TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); - - assertThat(input.apply(context)).isNull(); - } - - @Test - public void noOpMatcher_noOnNoMatch_returnsNoMatch() { - // An empty Matcher proto defaults to a NoOpMatcher with no onNoMatch action - Matcher proto = Matcher.getDefaultInstance(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - - MatchResult result = matcher.match(mock(MatchContext.class), 0); - assertThat(result.matched).isFalse(); - } - - @Test - public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { - Matcher proto = Matcher.getDefaultInstance(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - - // Manually trigger the runtime recursion check in NoOpMatcher - MatchResult result = matcher.match(mock(MatchContext.class), 17); - assertThat(result.matched).isFalse(); - } - - @Test - public void singlePredicate_celInputWithStringMatcher_throws() { - // Tests the explicit check that blocks CEL input for StringMatchers - Matcher.MatcherList.Predicate.SinglePredicate predicate = - Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("foo")) - .build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains( - "HttpAttributesCelMatchInput cannot be used with StringMatcher"); - } - } - - @Test - public void singlePredicate_headerInputWithCelMatcher_throws() { - // Tests the inverse check: CelMatcher must use HttpAttributesCelMatchInput - com.github.xds.type.matcher.v3.CelMatcher celMatcher = createCelMatcher("true"); - Matcher.MatcherList.Predicate.SinglePredicate predicate = - Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("host").build()))) - .setCustomMatch(TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack(celMatcher))) - .build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains( - "CelMatcher can only be used with HttpAttributesCelMatchInput"); - } - } - - @Test - public void singlePredicate_noMatcher_throws() { - // Triggers: "SinglePredicate must have either value_match or custom_match" - Matcher.MatcherList.Predicate.SinglePredicate predicate = - Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() - .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(com.google.protobuf.Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) - .build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - org.junit.Assert.fail("Should have thrown"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("must have either value_match or custom_match"); - } - } - - @Test - public void compoundMatchers_tooFewPredicates_throws() { - // Coverage for OrMatcher and AndMatcher minimum predicate requirements - Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); - Matcher.MatcherList.Predicate.PredicateList list = - Matcher.MatcherList.Predicate.PredicateList.newBuilder().addPredicate(p).build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setOrMatcher(list).build()); - org.junit.Assert.fail(); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); - } - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(list).build()); - org.junit.Assert.fail(); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); - } - } - @Test public void singlePredicate_invalidCelMatcherProto_throws() { // Triggers "Invalid CelMatcher config" @@ -1613,13 +1455,7 @@ public void singlePredicate_invalidCelMatcherProto_throws() { @Test public void singlePredicate_celEvalError_returnsFalse() { - // Triggers "return false" in "catch (CelEvaluationException e)" - // Expression: [][0] -> Index out of bounds, runtime error - // Type check passes (list access). - // We need a CheckedExpr. - // We rely on a runtime failure (division by zero) to trigger CelEvaluationException. - Matcher.MatcherList.Predicate.SinglePredicate predicate = Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() From 2f53a3042f7b5c6f7e4c384a4a77e03474963d03 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 16:25:16 +0530 Subject: [PATCH 07/23] add tests --- .../internal/matcher/UnifiedMatcherTest.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 25b3aba5563..6c7787f6109 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1547,4 +1547,119 @@ public void singlePredicate_stringMatcher_safeRegex_matches() { when(context.getMetadata()).thenReturn(headers); assertThat(evaluator.evaluate(context)).isTrue(); } + + + + @Test + public void compoundMatchers_tooFewPredicates_throws() { + // Tests OrMatcher/AndMatcher minimum size (2 predicates) + Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); + Matcher.MatcherList.Predicate.PredicateList list = + Matcher.MatcherList.Predicate.PredicateList.newBuilder().addPredicate(p).build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setOrMatcher(list).build()); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); + } + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(list).build()); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); + } + } + + @Test + public void notMatcher_invertsResult() { + // Tests NotMatcher coverage + Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setNotMatcher(p).build()); + + // h:v matches -> Not(True) -> False + assertThat(eval.evaluate(mockContextWith("h", "v"))).isFalse(); + // h:wrong doesn't match -> Not(False) -> True + assertThat(eval.evaluate(mockContextWith("h", "wrong"))).isTrue(); + } + + private MatchContext mockContextWith(String key, String value) { + MatchContext context = mock(MatchContext.class); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + when(context.getMetadata()).thenReturn(headers); + return context; + } + + + @Test + public void singlePredicate_stringMatcher_suffix_matches() { + // Verifies valid suffix config + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSuffix("bar")) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + // "foobar" ends with "bar" -> true + assertThat(evaluator.evaluate(mockContextWith("host", "foobar"))).isTrue(); + // "foobaz" does not end with "bar" -> false + assertThat(evaluator.evaluate(mockContextWith("host", "foobaz"))).isFalse(); + } + + @Test + public void singlePredicate_stringMatcher_prefix_matches() { + // Verifies valid prefix config + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setPrefix("foo")) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + // "foobar" starts with "foo" -> true + assertThat(evaluator.evaluate(mockContextWith("host", "foobar"))).isTrue(); + // "barfoo" does not start with "foo" -> false + assertThat(evaluator.evaluate(mockContextWith("host", "barfoo"))).isFalse(); + } + + @Test + public void singlePredicate_stringMatcher_contains_matches() { + // Verifies valid contains config + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setContains("oba")) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + // "foobar" contains "oba" -> true + assertThat(evaluator.evaluate(mockContextWith("host", "foobar"))).isTrue(); + // "fobar" does not contain "oba" -> false + assertThat(evaluator.evaluate(mockContextWith("host", "none"))).isFalse(); + } } + From 5c433ce32a4cc877b29977edc1652d604d18132c Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 22:05:20 +0530 Subject: [PATCH 08/23] add tests --- .../grpc/xds/internal/matcher/CelMatcher.java | 6 +- .../xds/internal/matcher/MatcherList.java | 3 - .../xds/internal/matcher/MatcherRunner.java | 5 +- .../xds/internal/matcher/UnifiedMatcher.java | 5 +- .../internal/matcher/UnifiedMatcherTest.java | 73 ------------------- 5 files changed, 4 insertions(+), 88 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java index e400d4e7c50..14c42a49de3 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java @@ -23,14 +23,10 @@ import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelRuntime; - - /** * Executes compiled CEL expressions. */ public final class CelMatcher { - - private final CelRuntime.Program program; private CelMatcher(CelRuntime.Program program) { @@ -76,7 +72,7 @@ public boolean match(Object input) throws CelEvaluationException { throw new CelEvaluationException( "Unsupported input type for CEL evaluation: " + input.getClass().getName()); } - // Validated to be boolean during compile check ideally, or we cast safely + if (result instanceof Boolean) { return (Boolean) result; } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java index a3992370775..4f10840b69d 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java @@ -51,8 +51,6 @@ public MatchResult match(MatchContext context, int depth) { List accumulated = new ArrayList<>(); boolean matchedAtLeastOnce = false; - - for (FieldMatcher matcher : matchers) { if (matcher.matches(context)) { MatchResult result = matcher.onMatch.evaluate(context, depth); @@ -79,7 +77,6 @@ public MatchResult match(MatchContext context, int depth) { } } } - if (matchedAtLeastOnce) { return MatchResult.create(accumulated); } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java index 60569655d35..07c4128ba27 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 The gRPC Authors + * Copyright 2026 The gRPC Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,7 +39,6 @@ public static java.util.List checkM } public interface MatchContext { - // Basic context usually involves Metadata (headers) and potentially other attributes. Metadata getMetadata(); @javax.annotation.Nullable @@ -52,7 +51,7 @@ public interface MatchContext { String getMethod(); @javax.annotation.Nullable - String getId(); // x-request-id + String getId(); } } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java index 6ec4800e4df..cfeb64954bd 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -31,13 +31,12 @@ */ public abstract class UnifiedMatcher { - // Supported Extension Type URLs + // Supported Extension Type URLs per gRFC A106 private static final String TYPE_URL_HTTP_HEADER_INPUT = "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; - static final int MAX_RECURSION_DEPTH = 16; @Nullable @@ -64,8 +63,6 @@ public Object apply(MatchContext context) { throw new IllegalArgumentException("Unsupported input type: " + typeUrl); } - - private static final class HeaderMatchInput implements MatchInput { private final String headerName; diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 6c7787f6109..bede00b37f8 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1548,57 +1548,8 @@ public void singlePredicate_stringMatcher_safeRegex_matches() { assertThat(evaluator.evaluate(context)).isTrue(); } - - - @Test - public void compoundMatchers_tooFewPredicates_throws() { - // Tests OrMatcher/AndMatcher minimum size (2 predicates) - Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); - Matcher.MatcherList.Predicate.PredicateList list = - Matcher.MatcherList.Predicate.PredicateList.newBuilder().addPredicate(p).build(); - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setOrMatcher(list).build()); - org.junit.Assert.fail(); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); - } - - try { - PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(list).build()); - org.junit.Assert.fail(); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); - } - } - - @Test - public void notMatcher_invertsResult() { - // Tests NotMatcher coverage - Matcher.MatcherList.Predicate p = createHeaderMatchPredicate("h", "v"); - PredicateEvaluator eval = PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setNotMatcher(p).build()); - - // h:v matches -> Not(True) -> False - assertThat(eval.evaluate(mockContextWith("h", "v"))).isFalse(); - // h:wrong doesn't match -> Not(False) -> True - assertThat(eval.evaluate(mockContextWith("h", "wrong"))).isTrue(); - } - - private MatchContext mockContextWith(String key, String value) { - MatchContext context = mock(MatchContext.class); - Metadata headers = new Metadata(); - headers.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); - when(context.getMetadata()).thenReturn(headers); - return context; - } - - @Test public void singlePredicate_stringMatcher_suffix_matches() { - // Verifies valid suffix config Matcher.MatcherList.Predicate.SinglePredicate predicate = Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() @@ -1620,7 +1571,6 @@ public void singlePredicate_stringMatcher_suffix_matches() { @Test public void singlePredicate_stringMatcher_prefix_matches() { - // Verifies valid prefix config Matcher.MatcherList.Predicate.SinglePredicate predicate = Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() @@ -1639,27 +1589,4 @@ public void singlePredicate_stringMatcher_prefix_matches() { // "barfoo" does not start with "foo" -> false assertThat(evaluator.evaluate(mockContextWith("host", "barfoo"))).isFalse(); } - - @Test - public void singlePredicate_stringMatcher_contains_matches() { - // Verifies valid contains config - Matcher.MatcherList.Predicate.SinglePredicate predicate = - Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() - .setInput(TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() - .setHeaderName("host").build()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() - .setContains("oba")) - .build(); - - PredicateEvaluator evaluator = PredicateEvaluator.fromProto( - Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - - // "foobar" contains "oba" -> true - assertThat(evaluator.evaluate(mockContextWith("host", "foobar"))).isTrue(); - // "fobar" does not contain "oba" -> false - assertThat(evaluator.evaluate(mockContextWith("host", "none"))).isFalse(); - } } - From 568bec13033a859d1b8e9b9996a92a66c8d10cda Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Feb 2026 22:25:20 +0530 Subject: [PATCH 09/23] add tests --- .../xds/internal/matcher/UnifiedMatcher.java | 1 - .../internal/matcher/UnifiedMatcherTest.java | 127 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java index cfeb64954bd..30674a73718 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -36,7 +36,6 @@ public abstract class UnifiedMatcher { "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; - static final int MAX_RECURSION_DEPTH = 16; @Nullable diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index bede00b37f8..4b45cd4a508 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1589,4 +1589,131 @@ public void singlePredicate_stringMatcher_prefix_matches() { // "barfoo" does not start with "foo" -> false assertThat(evaluator.evaluate(mockContextWith("host", "barfoo"))).isFalse(); } + + private MatchContext mockContextWith(String key, String value) { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith(key, value)); + return context; + } + + @Test + public void resolveInput_malformedProto_throws() { + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput") + .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid-bytes")) + .build()) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid input config"); + } + } + + @Test + public void matchInput_headerName_invalidCharacters_throws() { + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("invalid$header") + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)) + .build(); + try { + UnifiedMatcher.resolveInput(config); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid header name"); + } + } + + @Test + public void matchInput_headerName_binary_aggregation() { + String headerName = "test-bin"; + byte[] v1 = new byte[] {1, 2, 3}; + byte[] v2 = new byte[] {4, 5, 6}; + // Expected: comma-separated base64 values + String expected = com.google.common.io.BaseEncoding.base64().encode(v1) + "," + + com.google.common.io.BaseEncoding.base64().encode(v2); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), v1); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), v2); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadata); + + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(headerName).build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + + assertThat(input.apply(context)).isEqualTo(expected); + } + + @Test + public void matchInput_headerName_binary_missing() { + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(new Metadata()); + + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("missing-bin").build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + + assertThat(input.apply(context)).isNull(); + } + + @Test + public void checkRecursionDepth_nestedInTree_throws() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 17; i++) { + current = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("key", Matcher.OnMatch.newBuilder().setMatcher(current).build()))) + .build(); + } + try { + UnifiedMatcher.fromProto(current); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + @Test + public void checkRecursionDepth_nestedInOnNoMatch_throws() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 17; i++) { + current = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder().setMatcher(current)) + .build(); + } + try { + UnifiedMatcher.fromProto(current); + org.junit.Assert.fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + @Test + public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { + Matcher proto = Matcher.getDefaultInstance(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + // Manually calling with depth > 16 + MatchResult result = matcher.match(mock(MatchContext.class), 17); + assertThat(result.matched).isFalse(); + } } From 719b90c248cc8356ec8b56a0dca904cce7d24bb0 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Thu, 5 Feb 2026 11:52:10 +0530 Subject: [PATCH 10/23] add some tests --- .../xds/internal/matcher/MatcherTree.java | 4 -- .../xds/internal/matcher/MatcherTreeTest.java | 27 ++++++++++ .../internal/matcher/UnifiedMatcherTest.java | 50 +++++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java index 95822aa7ad1..d791e5ab40b 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java @@ -94,10 +94,6 @@ public MatchResult match(MatchContext context, int depth) { return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); } String value = (String) valueObj; - if (value == null) { - return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); - } - if (exactMatchMap != null) { OnMatch match = exactMatchMap.get(value); if (match != null) { diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java index 08476870a19..d2645c107ab 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java @@ -407,6 +407,33 @@ public void invalidInputCombination_matcherTreeWithCelInput_throws() { } } + @Test + public void matcherTree_prefixMap_noMatch_shouldFallbackToOnNoMatch() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/a", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("actionA").build()) + .build())) + .build(); + Matcher.OnMatch onNoMatch = Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("actionB")).build(); + MatcherTree tree = new MatcherTree(proto, onNoMatch, s -> true); + + MatchContext context = mock(MatchContext.class); + io.grpc.Metadata metadata = new io.grpc.Metadata(); + metadata.put( + io.grpc.Metadata.Key.of("path", io.grpc.Metadata.ASCII_STRING_MARSHALLER), "/b"); + when(context.getMetadata()).thenReturn(metadata); + + MatchResult result = tree.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("actionB"); + } + private Metadata metadataWith(String key, String value) { Metadata m = new Metadata(); m.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 4b45cd4a508..b49c8f766b7 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -1716,4 +1716,54 @@ public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { MatchResult result = matcher.match(mock(MatchContext.class), 17); assertThat(result.matched).isFalse(); } + + @Test + public void onMatch_empty_throws() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder()) // Neither .setMatcher() nor .setAction() called + .build(); + + try { + UnifiedMatcher.fromProto(proto); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OnMatch must have either matcher or action"); + } + } + + @Test + public void matcherList_empty_throws() { + // We create a MatcherList with no FieldMatchers added. + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder()) + .build(); + + try { + UnifiedMatcher.fromProto(proto); + org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("MatcherList must contain at least one FieldMatcher"); + } + } + + @Test + public void matcherList_maxRecursionDepth_returnsNoMatch() { + // We construct a valid MatcherList but call it with a depth value that exceeds the limit. + Matcher.MatcherList.FieldMatcher matcher = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(matcher)) + .build(); + + UnifiedMatcher matcherList = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + + // Manually pass depth 17 to trigger the 'depth > MAX_RECURSION_DEPTH' check + MatchResult result = matcherList.match(context, 17); + assertThat(result.matched).isFalse(); + } + } From 6da8f6076892ca55cd091ead0434df1dbead2daa Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Mon, 16 Feb 2026 10:37:48 +0530 Subject: [PATCH 11/23] Address comments --- .../java/io/grpc/xds/internal/matcher/CelMatcher.java | 11 ----------- .../grpc/xds/internal/matcher/UnifiedMatcherTest.java | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java index 14c42a49de3..868daebe9c5 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java @@ -16,7 +16,6 @@ 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; @@ -47,16 +46,6 @@ public static CelMatcher compile(CelAbstractSyntaxTree 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. */ diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index b49c8f766b7..1528c232493 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -124,7 +124,7 @@ public void celMatcher_match() { @Test public void celMatcher_throwsIfReturnsString() { try { - io.grpc.xds.internal.matcher.CelMatcher.compile("'should be bool'"); + io.grpc.xds.internal.matcher.CelEnvironmentTest.compile("'should be bool'"); org.junit.Assert.fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("must evaluate to boolean"); From 4d6379d03e7c804f09970ee1f4241d4c40f0f811 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 18 Feb 2026 12:55:24 +0530 Subject: [PATCH 12/23] remove dependency from dev.cel:cel --- interop-testing/build.gradle | 4 +++- .../java/io/grpc/xds/internal/matcher/CelMatcher.java | 6 ++++-- .../grpc/xds/internal/matcher/PredicateEvaluator.java | 5 +---- .../grpc/xds/internal/matcher/UnifiedMatcherTest.java | 10 +++++----- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/interop-testing/build.gradle b/interop-testing/build.gradle index 5160759460c..8d7d2d356b6 100644 --- a/interop-testing/build.gradle +++ b/interop-testing/build.gradle @@ -27,6 +27,7 @@ dependencies { libraries.google.auth.oauth2Http, libraries.opentelemetry.sdk.extension.autoconfigure, libraries.guava.jre // Fix checkUpperBoundDeps using -android + compileOnly libraries.cel.compiler api project(':grpc-api'), project(':grpc-stub'), project(':grpc-protobuf'), @@ -50,7 +51,8 @@ dependencies { project(':grpc-inprocess'), project(':grpc-core'), libraries.mockito.core, - libraries.okhttp + libraries.okhttp, + libraries.cel.compiler signature (libraries.signature.java) { artifact { diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java index 868daebe9c5..fea2ee8957c 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java @@ -17,7 +17,6 @@ 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; @@ -34,9 +33,12 @@ private CelMatcher(CelRuntime.Program program) { /** * Compiles the AST into a CelMatcher. + * Throws an Exception if validation or evaluation fails during compilation setup. */ public static CelMatcher compile(CelAbstractSyntaxTree ast) - throws CelValidationException, CelEvaluationException { + throws Exception { + // CelEvaluationException -> inside cel-runtime -> Allowed in production signatures + // CelValidationException -> inside cel-compiler -> Forbidden in production signatures if (ast.getResultType() != SimpleType.BOOL) { throw new IllegalArgumentException( "CEL expression must evaluate to boolean, got: " + ast.getResultType()); 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 index ba5e3b1c424..30551eaa50a 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java @@ -18,8 +18,6 @@ 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; @@ -89,8 +87,7 @@ private static final class SinglePredicateEvaluator extends PredicateEvaluator { } else { throw new IllegalArgumentException("CelMatcher must have expr_match"); } - } catch (InvalidProtocolBufferException | CelValidationException - | CelEvaluationException e) { + } catch (Exception e) { throw new IllegalArgumentException("Invalid CelMatcher config", e); } } else { diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 1528c232493..f0d787e3030 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -124,7 +124,7 @@ public void celMatcher_match() { @Test public void celMatcher_throwsIfReturnsString() { try { - io.grpc.xds.internal.matcher.CelEnvironmentTest.compile("'should be bool'"); + io.grpc.xds.internal.matcher.CelMatcherTestHelper.compile("'should be bool'"); org.junit.Assert.fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("must evaluate to boolean"); @@ -172,7 +172,7 @@ public void celMatcher_evaluationError_returnsFalse() { @Test public void celStringExtractor_throwsIfReturnsBool() { try { - io.grpc.xds.internal.matcher.CelStringExtractor.compile("true"); + io.grpc.xds.internal.matcher.CelMatcherTestHelper.compileStringExtractor("true"); org.junit.Assert.fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("must evaluate to string"); @@ -774,7 +774,7 @@ public void celMatcher_missingExprMatch_throws() { PredicateEvaluator.fromProto(proto); org.junit.Assert.fail("Should have thrown"); } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().contains("CelMatcher must have expr_match"); + assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); } } @@ -1133,7 +1133,7 @@ public void celMatcher_withoutCelExprChecked_throws() { 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"); + assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); } } @@ -1155,7 +1155,7 @@ public void celMatcher_withCelExprString_throws() { 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"); + assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); } } From a5f70b1465751bc7ac9c1c6b2a2670198830549e Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 25 Feb 2026 09:19:52 +0530 Subject: [PATCH 13/23] Refactor StringMatcher parsing logic --- .../io/grpc/xds/internal/MatcherParser.java | 33 +++++++++++++++- .../internal/matcher/PredicateEvaluator.java | 38 +------------------ 2 files changed, 33 insertions(+), 38 deletions(-) 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 6e4ad415174..ae4a3a8a3e7 100644 --- a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java +++ b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java @@ -97,7 +97,6 @@ public static Matchers.StringMatcher parseStringMatcher( "Unknown StringMatcher match pattern: " + proto.getMatchPatternCase()); } } - /** Translates envoy proto FractionalPercent to internal FractionMatcher. */ public static Matchers.FractionMatcher parseFractionMatcher( io.envoyproxy.envoy.type.v3.FractionalPercent proto) { @@ -118,4 +117,36 @@ public static Matchers.FractionMatcher parseFractionMatcher( } return Matchers.FractionMatcher.create(proto.getNumerator(), denominator); } + + /** Translate StringMatcher xDS proto to internal StringMatcher. */ + public static Matchers.StringMatcher parseStringMatcher( + com.github.xds.type.matcher.v3.StringMatcher proto) { + switch (proto.getMatchPatternCase()) { + case EXACT: + return Matchers.StringMatcher.forExact(proto.getExact(), proto.getIgnoreCase()); + case PREFIX: + return Matchers.StringMatcher.forPrefix( + checkNonEmpty(proto.getPrefix(), "prefix"), proto.getIgnoreCase()); + case SUFFIX: + return Matchers.StringMatcher.forSuffix( + checkNonEmpty(proto.getSuffix(), "suffix"), proto.getIgnoreCase()); + case CONTAINS: + return Matchers.StringMatcher.forContains( + checkNonEmpty(proto.getContains(), "contains"), proto.getIgnoreCase()); + case SAFE_REGEX: + String regex = checkNonEmpty(proto.getSafeRegex().getRegex(), "regex"); + return Matchers.StringMatcher.forSafeRegEx(Pattern.compile(regex)); + default: + throw new IllegalArgumentException( + "Unknown StringMatcher match pattern: " + proto.getMatchPatternCase()); + } + } + + private static String checkNonEmpty(String value, String name) { + if (value.isEmpty()) { + throw new IllegalArgumentException("StringMatcher " + name + + " (match_pattern) must be non-empty"); + } + return value; + } } 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 index 30551eaa50a..0434c0187fa 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java @@ -125,43 +125,7 @@ private static final class SinglePredicateEvaluator extends PredicateEvaluator { 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"); + return io.grpc.xds.internal.MatcherParser.parseStringMatcher(proto); } } From 91de9cd0de7584141f74810b69d04e60a38bd46a Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Fri, 27 Feb 2026 18:07:12 +0530 Subject: [PATCH 14/23] address comments and create registries --- .../xds/internal/matcher/CelStateMatcher.java | 77 + .../internal/matcher/HeaderMatchInput.java | 107 + .../matcher/HttpAttributesCelMatchInput.java | 51 + .../grpc/xds/internal/matcher/MatchInput.java | 12 +- .../internal/matcher/MatchInputProvider.java | 34 + .../internal/matcher/MatchInputRegistry.java | 50 + .../io/grpc/xds/internal/matcher/Matcher.java | 34 + .../xds/internal/matcher/MatcherProvider.java | 34 + .../xds/internal/matcher/MatcherRegistry.java | 49 + .../xds/internal/matcher/MatcherTree.java | 2 +- .../internal/matcher/PredicateEvaluator.java | 88 +- .../xds/internal/matcher/UnifiedMatcher.java | 89 +- .../internal/matcher/CelStateMatcherTest.java | 448 ++++ .../internal/matcher/UnifiedMatcherTest.java | 1926 ++++------------- .../matcher/UnifiedMatcherValidationTest.java | 456 ++++ 15 files changed, 1761 insertions(+), 1696 deletions(-) create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputProvider.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/Matcher.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatcherProvider.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java new file mode 100644 index 00000000000..2b7d8429e90 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java @@ -0,0 +1,77 @@ +/* + * 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 dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.runtime.CelEvaluationException; + +/** + * Matcher for CEL expressions handling xDS CEL Matcher extension. + */ +final class CelStateMatcher implements Matcher { + private final CelMatcher compiledEndpoint; + + CelStateMatcher(CelMatcher compiledEndpoint) { + this.compiledEndpoint = compiledEndpoint; + } + + @Override + public boolean match(Object value) { + try { + return compiledEndpoint.match(value); + } catch (CelEvaluationException e) { + return false; + } + } + + @Override + public Class inputType() { + return GrpcCelEnvironment.class; + } + + static final class Provider implements MatcherProvider { + @Override + public Matcher getMatcher(TypedExtensionConfig config) { + try { + com.github.xds.type.matcher.v3.CelMatcher celProto = config.getTypedConfig() + .unpack(com.github.xds.type.matcher.v3.CelMatcher.class); + if (!celProto.hasExprMatch()) { + throw new IllegalArgumentException("CelMatcher must have expr_match"); + } + com.github.xds.type.v3.CelExpression expr = celProto.getExprMatch(); + if (!expr.hasCelExprChecked()) { + throw new IllegalArgumentException("CelMatcher must have cel_expr_checked"); + } + CelAbstractSyntaxTree ast = + CelProtoAbstractSyntaxTree.fromCheckedExpr( + expr.getCelExprChecked()).getAst(); + CelMatcher compiled = CelMatcher.compile(ast); + + return new CelStateMatcher(compiled); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid CelMatcher config", e); + } + } + + @Override + public String typeUrl() { + return "type.googleapis.com/xds.type.matcher.v3.CelMatcher"; + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java new file mode 100644 index 00000000000..d9645000304 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java @@ -0,0 +1,107 @@ +/* + * 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.google.protobuf.InvalidProtocolBufferException; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; +import io.grpc.Metadata; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; + +/** + * MatchInput for extracting HTTP headers. + */ +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 Object 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); + } + + @Override + public Class outputType() { + return String.class; + } + + static final class Provider implements MatchInputProvider { + @Override + public MatchInput getInput(TypedExtensionConfig config) { + try { + HttpRequestHeaderMatchInput proto = config.getTypedConfig() + .unpack(HttpRequestHeaderMatchInput.class); + return new HeaderMatchInput(proto.getHeaderName()); + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException( + "Invalid input config: " + config.getTypedConfig().getTypeUrl(), e); + } + } + + @Override + public String typeUrl() { + return "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java new file mode 100644 index 00000000000..b8d3e9466d4 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java @@ -0,0 +1,51 @@ +/* + * 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 io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; + +/** + * MatchInput for extracting CEL environment from HTTP attributes. + */ +final class HttpAttributesCelMatchInput implements MatchInput { + static final HttpAttributesCelMatchInput INSTANCE = new HttpAttributesCelMatchInput(); + + private HttpAttributesCelMatchInput() {} + + @Override + public Object apply(MatchContext context) { + return new GrpcCelEnvironment(context); + } + + @Override + public Class outputType() { + return GrpcCelEnvironment.class; + } + + static final class Provider implements MatchInputProvider { + @Override + public MatchInput getInput(TypedExtensionConfig config) { + return INSTANCE; + } + + @Override + public String typeUrl() { + return "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + } + } +} 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 index 0c0037974c0..ea5c7b4bacb 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java @@ -16,17 +16,25 @@ package io.grpc.xds.internal.matcher; +import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; import javax.annotation.Nullable; /** * Interface for extracting values from a match context (e.g. HTTP headers). */ -public interface MatchInput { +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); + Object apply(MatchContext context); + + /** + * Returns the type of value extracted by this input. + */ + default Class outputType() { + return Object.class; + } } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputProvider.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputProvider.java new file mode 100644 index 00000000000..35c3b0f4d59 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputProvider.java @@ -0,0 +1,34 @@ +/* + * 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; + +/** + * Provider interface for creating {@link MatchInput} instances. + */ +public interface MatchInputProvider { + /** + * Returns the corresponding {@link MatchInput} for the given config. + */ + MatchInput getInput(TypedExtensionConfig config); + + /** + * Returns the type URL supported by this provider. + */ + String typeUrl(); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java new file mode 100644 index 00000000000..8ca4c34bf8e --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java @@ -0,0 +1,50 @@ +/* + * 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 java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.Nullable; + +/** + * Registry for {@link MatchInputProvider}s. + */ +public final class MatchInputRegistry { + private static final MatchInputRegistry DEFAULT_INSTANCE = new MatchInputRegistry(); + + private final Map providers = new ConcurrentHashMap<>(); + + public static MatchInputRegistry getDefaultRegistry() { + return DEFAULT_INSTANCE; + } + + @VisibleForTesting + public MatchInputRegistry() { + register(new HeaderMatchInput.Provider()); + register(new HttpAttributesCelMatchInput.Provider()); + } + + public void register(MatchInputProvider provider) { + providers.put(provider.typeUrl(), provider); + } + + @Nullable + public MatchInputProvider getProvider(String typeUrl) { + return providers.get(typeUrl); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/Matcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/Matcher.java new file mode 100644 index 00000000000..8e314c49d91 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/Matcher.java @@ -0,0 +1,34 @@ +/* + * 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; + +/** + * Interface that defines a matcher that can match a given value. + */ +public interface Matcher { + /** + * Returns true if the value matches the matcher. + */ + boolean match(Object value); + + /** + * Returns the type of value accepted by this matcher. + */ + default Class inputType() { + return Object.class; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherProvider.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherProvider.java new file mode 100644 index 00000000000..1b32b263d87 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherProvider.java @@ -0,0 +1,34 @@ +/* + * 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; + +/** + * Provider interface for creating {@link Matcher} instances. + */ +public interface MatcherProvider { + /** + * Returns the corresponding {@link Matcher} for the given config. + */ + Matcher getMatcher(TypedExtensionConfig config); + + /** + * Returns the type URL supported by this provider. + */ + String typeUrl(); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java new file mode 100644 index 00000000000..2dd63fc971e --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.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 com.google.common.annotations.VisibleForTesting; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.Nullable; + +/** + * Registry for {@link MatcherProvider}. + */ +public final class MatcherRegistry { + private static final MatcherRegistry DEFAULT_INSTANCE = new MatcherRegistry(); + + private final Map matcherProviders = new ConcurrentHashMap<>(); + + public static MatcherRegistry getDefaultRegistry() { + return DEFAULT_INSTANCE; + } + + @VisibleForTesting + public MatcherRegistry() { + register(new CelStateMatcher.Provider()); + } + + public void register(MatcherProvider provider) { + matcherProviders.put(provider.typeUrl(), provider); + } + + @Nullable + public MatcherProvider getMatcherProvider(String typeUrl) { + return matcherProviders.get(typeUrl); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java index d791e5ab40b..4165160e90f 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java @@ -25,7 +25,7 @@ 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; + private final MatchInput input; @Nullable private final Map exactMatchMap; @Nullable private final Map prefixMatchMap; @Nullable private final OnMatch onNoMatch; 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 index 0434c0187fa..329f128b18f 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java @@ -18,19 +18,12 @@ import com.github.xds.core.v3.TypedExtensionConfig; import com.github.xds.type.matcher.v3.Matcher.MatcherList.Predicate; -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) { @@ -48,9 +41,8 @@ static PredicateEvaluator fromProto(Predicate proto) { } private static final class SinglePredicateEvaluator extends PredicateEvaluator { - private final MatchInput input; - @Nullable private final Matchers.StringMatcher stringMatcher; - @Nullable private final CelMatcher celMatcher; + private final MatchInput input; + private final Matcher matcher; SinglePredicateEvaluator(Predicate.SinglePredicate proto) { if (!proto.hasInput()) { @@ -59,68 +51,44 @@ private static final class SinglePredicateEvaluator extends PredicateEvaluator { 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"); + Matchers.StringMatcher stringMatcher = fromStringMatcherProto(proto.getValueMatch()); + this.matcher = new Matcher() { + @Override + public boolean match(Object value) { + if (value instanceof String) { + return stringMatcher.matches((String) value); } - } catch (Exception e) { - throw new IllegalArgumentException("Invalid CelMatcher config", e); + return false; } - } else { + + @Override + public Class inputType() { + return String.class; + } + }; + } else if (proto.hasCustomMatch()) { + TypedExtensionConfig customConfig = proto.getCustomMatch(); + MatcherProvider provider = MatcherRegistry.getDefaultRegistry() + .getMatcherProvider(customConfig.getTypedConfig().getTypeUrl()); + if (provider == null) { 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"); - } + this.matcher = provider.getMatcher(customConfig); } else { throw new IllegalArgumentException( "SinglePredicate must have either value_match or custom_match"); } + + if (!input.outputType().isAssignableFrom(matcher.inputType()) + && !matcher.inputType().isAssignableFrom(input.outputType())) { + throw new IllegalArgumentException("Type mismatch: input " + input.outputType().getName() + + " not compatible with matcher " + matcher.inputType().getName()); + } } @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; + return matcher.match(input.apply(context)); } private static Matchers.StringMatcher fromStringMatcherProto( diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java index 30674a73718..534a52e2074 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -16,13 +16,8 @@ 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; @@ -31,90 +26,18 @@ */ 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) { + 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); + MatchInputProvider provider = MatchInputRegistry.getDefaultRegistry().getProvider(typeUrl); + if (provider == null) { + throw new IllegalArgumentException("Unsupported input type: " + typeUrl); } + return provider.getInput(config); } /** diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java new file mode 100644 index 00000000000..e713f88d338 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java @@ -0,0 +1,448 @@ +/* + * 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.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 CelStateMatcherTest { + + private static CelCompiler COMPILER; + + @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 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"); + } + } + + @Test + public void celMatcher_match() { + CelMatcher celMatcher = createCelMatcher("request.path == '/good'"); + 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"); + + 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.CelMatcherTestHelper.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"); + when(context.getMetadata()).thenReturn(new Metadata()); + when(context.getId()).thenReturn("1"); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + } + + @Test + public void celStringExtractor_throwsIfReturnsBool() { + try { + io.grpc.xds.internal.matcher.CelMatcherTestHelper.compileStringExtractor("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 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 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()); + MatchResult result = matcher.match(context, 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + } + + @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("Invalid CelMatcher config"); + } + } + + @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("Type mismatch"); + } + } + + @Test + public void celMatcher_wrongInput_throws() { + 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("Type mismatch"); + } + } + + @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("Invalid CelMatcher config"); + } + } + + @Test + public void celMatcher_withCelExprString_throws() { + 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("Invalid CelMatcher config"); + } + } + + 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 singlePredicate_invalidCelMatcherProto_throws() { + 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()); + + assertThat(evaluator.evaluate(mock(MatchContext.class))).isFalse(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index f0d787e3030..e0045a49193 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -21,20 +21,10 @@ 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; @@ -42,1510 +32,284 @@ @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)) + public void matcherList_firstMatchWins_evenIfNestedNoMatch() { + // matcher1: matches -> nested "no-match" (matched=false) + // matcher2: matches -> action "action2" + // Expect: matcher1 returns matched=false, so we proceed to matcher2, which returns action2. + + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder())) // nested matcher that doesn't match .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) + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) .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"))))) + .addMatchers(fm1) + .addMatchers(fm2)) .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"); + when(context.getMetadata()).thenReturn(metadataWith("h", "v")); - // Test mismatch - when(context.getPath()).thenReturn("/bad"); - result = matcher.match(context, 0); - TypedExtensionConfig noMatchAction = result.actions.get(0); - assertThat(noMatchAction.getName()).isEqualTo("no-match"); + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isFalse(); } @Test - public void celMatcher_throwsIfReturnsString() { - try { - io.grpc.xds.internal.matcher.CelMatcherTestHelper.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); - } - } + public void matcherTree_exactMatch_shouldNotFallBackToOnNoMatch_ifKeyFound() { + Matcher nestedNoMatch = Matcher.newBuilder() + .build(); - @Test - public void celMatcher_evaluationError_returnsFalse() { - CelMatcher celMatcher = createCelMatcher("int(request.path) == 0"); - Matcher.MatcherList.Predicate.SinglePredicate predicate = - Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.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"))))) + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("found", Matcher.OnMatch.newBuilder().setMatcher(nestedNoMatch).build()))) .setOnNoMatch(Matcher.OnMatch.newBuilder() - .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .setAction(TypedExtensionConfig.newBuilder().setName("tree-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"); + when(context.getMetadata()).thenReturn(metadataWith("key", "found")); 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.CelMatcherTestHelper.compileStringExtractor("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); - } + assertThat(result.matched).isFalse(); + assertThat(result.actions).isEmpty(); } @Test - public void celMatcher_headers() { - CelMatcher celMatcher = createCelMatcher("request.headers['x-test'] == 'value'"); + public void stringMatcher_contains_ignoreCase() { 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"))) + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setContains("WoRlD") + .setIgnoreCase(true)) .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); + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); 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); + when(context.getMetadata()).thenReturn(metadataWith("key", "hello world")); - 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)); + assertThat(evaluator.evaluate(context)).isTrue(); } @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(); + public void andMatcher_allTrue_matches() { + Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "v"); + Matcher.MatcherList.Predicate h2 = createHeaderMatchPredicate("h", "v"); - 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; - } + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder() + .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(h1).addPredicate(h2)).build()); - 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"); + when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + assertThat(eval.evaluate(context)).isTrue(); } @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); - + Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "v"); + Matcher.MatcherList.Predicate h2 = createHeaderMatchPredicate("h", "x"); // fail + + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder() + .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(h1).addPredicate(h2)).build()); + 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"); + when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + assertThat(eval.evaluate(context)).isFalse(); } @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); - + Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "x"); // fail + Matcher.MatcherList.Predicate h2 = createHeaderMatchPredicate("h", "v"); // match + + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder() + .setOrMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(h1).addPredicate(h2)).build()); + 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"); + when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + assertThat(eval.evaluate(context)).isTrue(); } @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); - + Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "v"); + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder() + .setNotMatcher(h1).build()); + 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); + when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + assertThat(eval.evaluate(context)).isFalse(); - MatchResult result = matcher.match(context, 0); - assertThat(result.matched).isTrue(); - assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + when(context.getMetadata()).thenReturn(metadataWith("h", "x")); + assertThat(eval.evaluate(context)).isTrue(); } - - @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); + public void matchInput_headerName_binary() { + String headerName = "test-bin"; + byte[] bytes = new byte[] {1, 2, 3}; + String expected = com.google.common.io.BaseEncoding.base64().encode(bytes); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), bytes); 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)"); - } + when(context.getMetadata()).thenReturn(metadata); - // 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(); + .setHeaderName(headerName).build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.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"); - } + assertThat(input.apply(context)).isEqualTo(expected); } - - @Test - public void matchInput_headerName_binary() { + public void matchInput_headerName_binary_aggregation() { String headerName = "test-bin"; - byte[] binaryValue = new byte[] {1, 2, 3}; - String expectedBase64 = com.google.common.io.BaseEncoding.base64().encode(binaryValue); + byte[] v1 = new byte[] {1, 2, 3}; + byte[] v2 = new byte[] {4, 5, 6}; + 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), binaryValue); + 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(); - TypedExtensionConfig config = TypedExtensionConfig.newBuilder() - .setTypedConfig(Any.pack(proto)) - .build(); - - MatchInput input = UnifiedMatcher.resolveInput(config); - - assertThat(input.apply(context)).isEqualTo(expectedBase64); + .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_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("Invalid CelMatcher config"); - } - } - - @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("Invalid CelMatcher config"); - } - } - - @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("Invalid CelMatcher config"); - } - } - - 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"); + 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 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(); + 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); - 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(); + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("te").build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)).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"); - } + assertThat(input.apply(context)).isNull(); } @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")))) + public void noOpMatcher_delegatesToOnNoMatch() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match-action"))) .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(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(mock(MatchContext.class), 0); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("no-match-action"); } @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("")) + public void matcherRunner_checkMatch_returnsActions() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))) .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"); - } + java.util.List actions = + MatcherRunner.checkMatch(proto, mock(MatchContext.class)); + assertThat(actions).hasSize(1); + assertThat(actions.get(0).getName()).isEqualTo("action"); } @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()) + public void matcherRunner_checkMatch_returnsNullOnNoMatch() { + Matcher proto = Matcher.newBuilder() .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"); - } + java.util.List actions = + MatcherRunner.checkMatch(proto, mock(MatchContext.class)); + assertThat(actions).isNull(); } - @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()))) + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() - .setSafeRegex(regexMatcher)) + .setSafeRegex(com.github.xds.type.matcher.v3.RegexMatcher.newBuilder() + .setRegex("v.*"))) // v followed by anything .build(); - - PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + PredicateEvaluator eval = 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(); + when(context.getMetadata()).thenReturn(metadataWith("k", "val")); + assertThat(eval.evaluate(context)).isTrue(); + + when(context.getMetadata()).thenReturn(metadataWith("k", "xal")); + assertThat(eval.evaluate(context)).isFalse(); } @Test @@ -1553,20 +317,21 @@ 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()))) + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() - .setSuffix("bar")) + .setSuffix("tail")) .build(); - - PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + PredicateEvaluator eval = 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(); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("k", "detail")); + assertThat(eval.evaluate(context)).isTrue(); + + when(context.getMetadata()).thenReturn(metadataWith("k", "detai")); + assertThat(eval.evaluate(context)).isFalse(); } @Test @@ -1574,176 +339,176 @@ 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()))) + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() - .setPrefix("foo")) + .setPrefix("pre")) .build(); - - PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + PredicateEvaluator eval = 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; + when(context.getMetadata()).thenReturn(metadataWith("k", "prefix")); + assertThat(eval.evaluate(context)).isTrue(); } @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()) + public void matcherList_keepMatching() { + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1")) + .setKeepMatching(true)) .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") + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h2", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) .build(); - TypedExtensionConfig config = TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack(proto)) + + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(fm1) + .addMatchers(fm2)) .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); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v"); + metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v"); 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(); + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); // Both actions + assertThat(result.actions.get(0).getName()).isEqualTo("action1"); + assertThat(result.actions.get(1).getName()).isEqualTo("action2"); } @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"); - } - } + public void onNoMatchShouldNotExecuteWhenKeepMatchingTrueAndMatchFound() { + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1")) + .setKeepMatching(true)) + .build(); - @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"); - } + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(fm1)) + .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(metadataWith("h1", "v")); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("action1"); } @Test - public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { - Matcher proto = Matcher.getDefaultInstance(); - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + public void matcherList_example1_simpleLinearMatch() { + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v")) // No match + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1"))) + .build(); + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h2", "v")) // Match + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1).addMatchers(fm2)) + .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("h2", "v")); - // Manually calling with depth > 16 - MatchResult result = matcher.match(mock(MatchContext.class), 17); - assertThat(result.matched).isFalse(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("action2"); } @Test - public void onMatch_empty_throws() { + public void matcherList_example2_keepMatching() { + // M1 matches, keep_matching=true -> action1 + // M2 matches -> action2 + // Result: [action1, action2] + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) + .build(); Matcher proto = Matcher.newBuilder() - .setOnNoMatch(Matcher.OnMatch.newBuilder()) // Neither .setMatcher() nor .setAction() called + .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1).addMatchers(fm2)) .build(); + + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("h", "v")); - 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"); - } + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); } @Test - public void matcherList_empty_throws() { - // We create a MatcherList with no FieldMatchers added. + public void matcherList_example3_nestedMatcher() { + // M1 matches -> nested M2 + // M2 matches -> action2 + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h2", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))))))) + .build(); + Matcher proto = Matcher.newBuilder() - .setMatcherList(Matcher.MatcherList.newBuilder()) + .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1)) .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"); - } + MatchContext context = mock(MatchContext.class); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v"); + metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v"); + when(context.getMetadata()).thenReturn(metadata); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("action2"); + } + + @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 @@ -1766,4 +531,65 @@ public void matcherList_maxRecursionDepth_returnsNoMatch() { assertThat(result.matched).isFalse(); } + @Test + public void matcherList_keepMatching_verification() { + // Verifying gRFC logic: + // If a matcher sets keep_matching=true, we add its action and continue. + // If subsequent matchers also match, we add their actions too. + + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("a1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("a2")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher fm3 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("a3"))) // stops here + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(fm1).addMatchers(fm2).addMatchers(fm3)) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = mock(MatchContext.class); + when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + 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"); + } + + // Helpers + + private static Matcher.MatcherList.Predicate createHeaderMatchPredicate( + String name, 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(name).build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact(value))) + .build(); + } + + private static Metadata metadataWith(String key, String value) { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + return metadata; + } } diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java new file mode 100644 index 00000000000..eeb0599fa88 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java @@ -0,0 +1,456 @@ +/* + * 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 com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import com.google.protobuf.Any; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class UnifiedMatcherValidationTest { + + @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(); + String supportedType = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + + 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"); + } + } + + @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"); + } + } + + @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 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 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 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 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 onMatch_empty_throws() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder()) + .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() { + 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 stringMatcher_emptySuffix_throws() { + 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() { + 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"); + } + } +} From 23a00d6cbd3937686bac371dc39ecc8b22012e6a Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Mon, 2 Mar 2026 16:35:11 +0530 Subject: [PATCH 15/23] address comments --- interop-testing/build.gradle | 3 +- .../xds/internal/matcher/CelStateMatcher.java | 17 +- .../internal/matcher/HeaderMatchInput.java | 4 +- .../matcher/HttpAttributesCelMatchInput.java | 4 +- .../grpc/xds/internal/matcher/MatchInput.java | 4 +- .../xds/internal/matcher/MatchResult.java | 38 ++- .../xds/internal/matcher/MatcherList.java | 46 ++-- .../xds/internal/matcher/MatcherRunner.java | 116 ++++++-- .../xds/internal/matcher/MatcherTree.java | 91 +++++-- .../internal/matcher/PredicateEvaluator.java | 34 ++- .../xds/internal/matcher/UnifiedMatcher.java | 8 +- .../internal/matcher/CelStateMatcherTest.java | 150 ++++++----- .../xds/internal/matcher/MatcherTreeTest.java | 164 ++++++------ .../internal/matcher/UnifiedMatcherTest.java | 247 ++++++++++-------- 14 files changed, 579 insertions(+), 347 deletions(-) diff --git a/interop-testing/build.gradle b/interop-testing/build.gradle index 8d7d2d356b6..665e4b990e6 100644 --- a/interop-testing/build.gradle +++ b/interop-testing/build.gradle @@ -51,8 +51,7 @@ dependencies { project(':grpc-inprocess'), project(':grpc-core'), libraries.mockito.core, - libraries.okhttp, - libraries.cel.compiler + libraries.okhttp signature (libraries.signature.java) { artifact { diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java index 2b7d8429e90..3a7de8d86a8 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java @@ -17,6 +17,8 @@ package io.grpc.xds.internal.matcher; import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.CelMatcher; // Proto +import com.github.xds.type.v3.CelExpression; import dev.cel.common.CelAbstractSyntaxTree; import dev.cel.common.CelProtoAbstractSyntaxTree; import dev.cel.runtime.CelEvaluationException; @@ -25,9 +27,9 @@ * Matcher for CEL expressions handling xDS CEL Matcher extension. */ final class CelStateMatcher implements Matcher { - private final CelMatcher compiledEndpoint; + private final io.grpc.xds.internal.matcher.CelMatcher compiledEndpoint; - CelStateMatcher(CelMatcher compiledEndpoint) { + CelStateMatcher(io.grpc.xds.internal.matcher.CelMatcher compiledEndpoint) { this.compiledEndpoint = compiledEndpoint; } @@ -47,21 +49,22 @@ public Class inputType() { static final class Provider implements MatcherProvider { @Override - public Matcher getMatcher(TypedExtensionConfig config) { + public CelStateMatcher getMatcher(TypedExtensionConfig config) { try { - com.github.xds.type.matcher.v3.CelMatcher celProto = config.getTypedConfig() - .unpack(com.github.xds.type.matcher.v3.CelMatcher.class); + CelMatcher celProto = config.getTypedConfig() + .unpack(CelMatcher.class); if (!celProto.hasExprMatch()) { throw new IllegalArgumentException("CelMatcher must have expr_match"); } - com.github.xds.type.v3.CelExpression expr = celProto.getExprMatch(); + CelExpression expr = celProto.getExprMatch(); if (!expr.hasCelExprChecked()) { throw new IllegalArgumentException("CelMatcher must have cel_expr_checked"); } CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr( expr.getCelExprChecked()).getAst(); - CelMatcher compiled = CelMatcher.compile(ast); + io.grpc.xds.internal.matcher.CelMatcher compiled = + io.grpc.xds.internal.matcher.CelMatcher.compile(ast); return new CelStateMatcher(compiled); } catch (Exception e) { diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java index d9645000304..eb48120f04d 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java @@ -51,7 +51,7 @@ final class HeaderMatchInput implements MatchInput { } @Override - public Object apply(MatchContext context) { + public String apply(MatchContext context) { if ("te".equals(headerName)) { return null; } @@ -88,7 +88,7 @@ public Class outputType() { static final class Provider implements MatchInputProvider { @Override - public MatchInput getInput(TypedExtensionConfig config) { + public HeaderMatchInput getInput(TypedExtensionConfig config) { try { HttpRequestHeaderMatchInput proto = config.getTypedConfig() .unpack(HttpRequestHeaderMatchInput.class); diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java index b8d3e9466d4..2010bcabcf0 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java @@ -28,7 +28,7 @@ final class HttpAttributesCelMatchInput implements MatchInput { private HttpAttributesCelMatchInput() {} @Override - public Object apply(MatchContext context) { + public GrpcCelEnvironment apply(MatchContext context) { return new GrpcCelEnvironment(context); } @@ -39,7 +39,7 @@ public Class outputType() { static final class Provider implements MatchInputProvider { @Override - public MatchInput getInput(TypedExtensionConfig config) { + public HttpAttributesCelMatchInput getInput(TypedExtensionConfig config) { return INSTANCE; } 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 index ea5c7b4bacb..8ecad39c0d6 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java @@ -34,7 +34,5 @@ public interface MatchInput { /** * Returns the type of value extracted by this input. */ - default Class outputType() { - return Object.class; - } + Class outputType(); } 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 index 43b0f115f35..5f42b3f87c6 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java @@ -22,28 +22,52 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import javax.annotation.Nullable; /** * Result of a matching operation. */ public final class MatchResult { - public final List actions; + @Nullable + public final TypedExtensionConfig action; + public final List keepMatchingActions; public final boolean matched; - private MatchResult(List actions, boolean matched) { - this.actions = checkNotNull(actions, "actions"); + private MatchResult( + @Nullable TypedExtensionConfig action, + List keepMatchingActions, + boolean matched) { + this.action = action; + this.keepMatchingActions = + Collections.unmodifiableList( + new ArrayList<>(checkNotNull(keepMatchingActions, "keepMatchingActions"))); this.matched = matched; } + /** + * Creates a result indicating a successful match with a terminal action. + */ + public static MatchResult create( + @Nullable TypedExtensionConfig action, + List keepMatchingActions) { + return new MatchResult(action, keepMatchingActions, true); + } + + /** + * Creates a result indicating a match with a terminal action and no accumulated actions. + */ public static MatchResult create(TypedExtensionConfig action) { - return new MatchResult(Collections.singletonList(action), true); + return new MatchResult(action, Collections.emptyList(), true); } - public static MatchResult create(List actions) { - return new MatchResult(new ArrayList<>(actions), true); + /** + * Creates a result indicating no terminal match, but potentially with accumulated actions. + */ + public static MatchResult noMatch(List keepMatchingActions) { + return new MatchResult(null, keepMatchingActions, false); } public static MatchResult noMatch() { - return new MatchResult(Collections.emptyList(), false); + return new MatchResult(null, 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 index 4f10840b69d..c7c4672cd72 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java @@ -21,14 +21,16 @@ import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; import java.util.ArrayList; import java.util.List; +import java.util.function.Predicate; import javax.annotation.Nullable; final class MatcherList extends UnifiedMatcher { private final List matchers; - @Nullable private final OnMatch onNoMatch; + @Nullable + private final OnMatch onNoMatch; MatcherList(Matcher.MatcherList proto, @Nullable Matcher.OnMatch onNoMatchProto, - java.util.function.Predicate actionValidator) { + Predicate actionValidator) { if (proto.getMatchersCount() == 0) { throw new IllegalArgumentException("MatcherList must contain at least one FieldMatcher"); } @@ -50,37 +52,35 @@ public MatchResult match(MatchContext context, int depth) { } List accumulated = new ArrayList<>(); - boolean matchedAtLeastOnce = false; for (FieldMatcher matcher : matchers) { if (matcher.matches(context)) { MatchResult result = matcher.onMatch.evaluate(context, depth); - if (result.matched) { - accumulated.addAll(result.actions); - matchedAtLeastOnce = true; - } + accumulated.addAll(result.keepMatchingActions); - if (!matcher.onMatch.keepMatching) { - if (!matchedAtLeastOnce) { - return MatchResult.noMatch(); + if (result.matched) { + if (matcher.onMatch.keepMatching) { + if (result.action != null) { + accumulated.add(result.action); + } + } else { + return MatchResult.create(result.action, accumulated); + } + } else { + if (!matcher.onMatch.keepMatching) { + return MatchResult.noMatch(accumulated); } - break; } } } - if (!matchedAtLeastOnce) { - if (onNoMatch != null) { - MatchResult noMatchResult = onNoMatch.evaluate(context, depth); - if (noMatchResult.matched) { - accumulated.addAll(noMatchResult.actions); - matchedAtLeastOnce = true; - } + if (onNoMatch != null) { + MatchResult noMatchResult = onNoMatch.evaluate(context, depth); + accumulated.addAll(noMatchResult.keepMatchingActions); + if (noMatchResult.matched) { + return MatchResult.create(noMatchResult.action, accumulated); } } - if (matchedAtLeastOnce) { - return MatchResult.create(accumulated); - } - return MatchResult.noMatch(); + return MatchResult.noMatch(accumulated); } private static final class FieldMatcher { @@ -88,7 +88,7 @@ private static final class FieldMatcher { private final OnMatch onMatch; FieldMatcher(Matcher.MatcherList.FieldMatcher proto, - java.util.function.Predicate actionValidator) { + Predicate actionValidator) { this.predicate = PredicateEvaluator.fromProto(proto.getPredicate()); this.onMatch = new OnMatch(proto.getOnMatch(), actionValidator); } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java index 07c4128ba27..398a5a2386f 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java @@ -16,7 +16,13 @@ package io.grpc.xds.internal.matcher; +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import com.google.common.base.Preconditions; import io.grpc.Metadata; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; /** * Executes a UnifiedMatcher against a request. @@ -27,31 +33,111 @@ private MatcherRunner() {} /** * runs the matcher. */ - @javax.annotation.Nullable - public static java.util.List checkMatch( - com.github.xds.type.matcher.v3.Matcher proto, MatchContext context) { + @Nullable + public static List checkMatch( + Matcher proto, MatchContext context) { UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); MatchResult result = matcher.match(context, 0); - if (!result.matched || result.actions.isEmpty()) { + if (!result.matched) { return null; } - return result.actions; + + List allActions = + new ArrayList<>(result.keepMatchingActions); + if (result.action != null) { + allActions.add(result.action); + } + + if (allActions.isEmpty()) { + return null; + } + return allActions; } - public interface MatchContext { - Metadata getMetadata(); + public static final class MatchContext { + private final Metadata metadata; + @Nullable + private final String path; + @Nullable + private final String host; + @Nullable + private final String method; + @Nullable + private final String id; + + public MatchContext(Metadata metadata, @Nullable String path, + @Nullable String host, @Nullable String method, + @Nullable String id) { + this.metadata = Preconditions.checkNotNull(metadata, "metadata"); + this.path = path; + this.host = host; + this.method = method; + this.id = id; + } + + public Metadata getMetadata() { + return metadata; + } - @javax.annotation.Nullable - String getPath(); + @Nullable + public String getPath() { + return path; + } - @javax.annotation.Nullable - String getHost(); + @Nullable + public String getHost() { + return host; + } - @javax.annotation.Nullable - String getMethod(); + @Nullable + public String getMethod() { + return method; + } - @javax.annotation.Nullable - String getId(); + @Nullable + public String getId() { + return id; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static final class Builder { + private Metadata metadata = new Metadata(); + private String path; + private String host; + private String method; + private String id; + + public Builder setMetadata(Metadata metadata) { + this.metadata = metadata; + return this; + } + public Builder setPath(String path) { + this.path = path; + return this; + } + + public Builder setHost(String host) { + this.host = host; + return this; + } + + public Builder setMethod(String method) { + this.method = method; + return this; + } + + public Builder setId(String id) { + this.id = id; + return this; + } + + public MatchContext build() { + return new MatchContext(metadata, path, host, method, id); + } + } } } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java index 4165160e90f..33e111482bf 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java @@ -16,22 +16,31 @@ 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.Collections; +import java.util.Comparator; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.function.Predicate; 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; + @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) { + Predicate actionValidator) { if (!proto.hasInput()) { throw new IllegalArgumentException("MatcherTree must have input"); } @@ -97,11 +106,38 @@ public MatchResult match(MatchContext context, int depth) { if (exactMatchMap != null) { OnMatch match = exactMatchMap.get(value); if (match != null) { - return match.evaluate(context, depth); + MatchResult result = match.evaluate(context, depth); + + List accumulated = + new ArrayList<>(result.keepMatchingActions); + + if (result.matched && !match.keepMatching) { + return MatchResult.create(result.action, accumulated); + } + + if (result.matched) { // && keepMatching=true + if (result.action != null) { + accumulated.add(result.action); + } + } else { + if (!match.keepMatching) { + return MatchResult.noMatch(accumulated); + } + } + + // If keepMatching=true, OR (matched=true and keepMatching=true), then continue to onNoMatch + if (onNoMatch != null) { + MatchResult noMatchResult = onNoMatch.evaluate(context, depth); + accumulated.addAll(noMatchResult.keepMatchingActions); + if (noMatchResult.matched) { + return MatchResult.create(noMatchResult.action, accumulated); + } + } + return MatchResult.noMatch(accumulated); } return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); } else if (prefixMatchMap != null) { - java.util.List matchingPrefixes = new java.util.ArrayList<>(); + List matchingPrefixes = new ArrayList<>(); for (String prefix : prefixMatchMap.keySet()) { if (value.startsWith(prefix)) { matchingPrefixes.add(prefix); @@ -113,38 +149,47 @@ public MatchResult match(MatchContext context, int depth) { } // Sort by length descending (longest first) - java.util.Collections.sort(matchingPrefixes, new java.util.Comparator() { + Collections.sort(matchingPrefixes, new 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<>(); - + List accumulatedActions = + new 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); + accumulatedActions.addAll(result.keepMatchingActions); + + if (result.matched && !onMatch.keepMatching) { + return MatchResult.create(result.action, accumulatedActions); + } + + if (result.matched) { // AND keepMatching=true + if (result.action != null) { + accumulatedActions.add(result.action); + } + } else { if (!onMatch.keepMatching) { - return MatchResult.create(accumulatedActions); + return MatchResult.noMatch(accumulatedActions); } } + + // If keepMatching=true, we continue regardless of inner match result. } - if (matchedAtLeastOnce) { - return MatchResult.create(accumulatedActions); + // If we fall through, we either found no matches or all matches had keepMatching=true. + if (onNoMatch != null) { + MatchResult noMatchResult = onNoMatch.evaluate(context, depth); + accumulatedActions.addAll(noMatchResult.keepMatchingActions); + if (noMatchResult.matched) { + return MatchResult.create(noMatchResult.action, 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 MatchResult.noMatch(accumulatedActions); } return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); 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 index 329f128b18f..cbfa5b3bb7a 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java @@ -18,6 +18,8 @@ import com.github.xds.core.v3.TypedExtensionConfig; import com.github.xds.type.matcher.v3.Matcher.MatcherList.Predicate; +import com.github.xds.type.matcher.v3.StringMatcher; +import io.grpc.xds.internal.MatcherParser; import io.grpc.xds.internal.Matchers; import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; import java.util.ArrayList; @@ -55,10 +57,15 @@ private static final class SinglePredicateEvaluator extends PredicateEvaluator { this.matcher = new Matcher() { @Override public boolean match(Object value) { - if (value instanceof String) { - return stringMatcher.matches((String) value); + if (value == null) { + return false; } - return false; + if (!(value instanceof String)) { + throw new IllegalArgumentException( + "StringMatcher expected a String input, but received: " + + value.getClass().getName()); + } + return stringMatcher.matches((String) value); } @Override @@ -80,20 +87,20 @@ public Class inputType() { "SinglePredicate must have either value_match or custom_match"); } - if (!input.outputType().isAssignableFrom(matcher.inputType()) - && !matcher.inputType().isAssignableFrom(input.outputType())) { - throw new IllegalArgumentException("Type mismatch: input " + input.outputType().getName() + if (!input.outputType().equals(matcher.inputType())) { + throw new IllegalArgumentException("Type mismatch: input " + input.outputType().getName() + " not compatible with matcher " + matcher.inputType().getName()); } } - @Override boolean evaluate(MatchContext context) { + @Override + boolean evaluate(MatchContext context) { return matcher.match(input.apply(context)); } private static Matchers.StringMatcher fromStringMatcherProto( - com.github.xds.type.matcher.v3.StringMatcher proto) { - return io.grpc.xds.internal.MatcherParser.parseStringMatcher(proto); + StringMatcher proto) { + return MatcherParser.parseStringMatcher(proto); } } @@ -110,7 +117,8 @@ private static final class OrMatcherEvaluator extends PredicateEvaluator { } } - @Override boolean evaluate(MatchContext context) { + @Override + boolean evaluate(MatchContext context) { for (PredicateEvaluator e : evaluators) { if (e.evaluate(context)) { return true; @@ -133,7 +141,8 @@ private static final class AndMatcherEvaluator extends PredicateEvaluator { } } - @Override boolean evaluate(MatchContext context) { + @Override + boolean evaluate(MatchContext context) { for (PredicateEvaluator e : evaluators) { if (!e.evaluate(context)) { return false; @@ -150,7 +159,8 @@ private static final class NotMatcherEvaluator extends PredicateEvaluator { this.evaluator = PredicateEvaluator.fromProto(proto); } - @Override boolean evaluate(MatchContext context) { + @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 index 534a52e2074..bda2240dea4 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -19,6 +19,7 @@ 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.function.Predicate; import javax.annotation.Nullable; /** @@ -47,7 +48,7 @@ static MatchInput resolveInput(TypedExtensionConfig config) { * @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) { + Predicate actionValidator) { checkRecursionDepth(proto, 0); Matcher.OnMatch onNoMatch = proto.hasOnNoMatch() ? proto.getOnNoMatch() : null; if (proto.hasMatcherList()) { @@ -98,10 +99,11 @@ private static void checkRecursionDepth(Matcher proto, int currentDepth) { } private static final class NoOpMatcher extends UnifiedMatcher { - @Nullable private final OnMatch onNoMatch; + @Nullable + private final OnMatch onNoMatch; NoOpMatcher(@Nullable Matcher.OnMatch onNoMatchProto, - java.util.function.Predicate actionValidator) { + Predicate actionValidator) { if (onNoMatchProto != null) { this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); } else { diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java index e713f88d338..8436e5e3c03 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java @@ -17,20 +17,24 @@ 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 static org.junit.Assert.fail; import com.github.xds.core.v3.TypedExtensionConfig; import com.github.xds.type.matcher.v3.CelMatcher; +import com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput; import com.github.xds.type.matcher.v3.Matcher; +import com.github.xds.type.matcher.v3.StringMatcher; import com.github.xds.type.v3.CelExpression; import com.github.xds.type.v3.CelExtractString; import com.google.protobuf.Any; +import com.google.protobuf.ByteString; 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 dev.cel.expr.ParsedExpr; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; import io.grpc.Metadata; import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; import org.junit.BeforeClass; @@ -72,7 +76,7 @@ public void verifyCelExtractStringInputNotSupported() { .build(); try { UnifiedMatcher.resolveInput(config); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("Unsupported input type"); } @@ -85,7 +89,7 @@ public void celMatcher_match() { Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + HttpAttributesCelMatchInput .getDefaultInstance()))) .setCustomMatch(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack(celMatcher))) @@ -101,27 +105,31 @@ public void celMatcher_match() { .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"); + MatchContext context = MatchContext.newBuilder() + .setPath("/good") + .setMetadata(new Metadata()) + .setId("123") + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - TypedExtensionConfig action = result.actions.get(0); + TypedExtensionConfig action = result.action; assertThat(action.getName()).isEqualTo("action1"); - when(context.getPath()).thenReturn("/bad"); + context = MatchContext.newBuilder() + .setPath("/bad") + .setMetadata(new Metadata()) + .setId("123") + .build(); result = matcher.match(context, 0); - TypedExtensionConfig noMatchAction = result.actions.get(0); + TypedExtensionConfig noMatchAction = result.action; assertThat(noMatchAction.getName()).isEqualTo("no-match"); } @Test public void celMatcher_throwsIfReturnsString() { try { - io.grpc.xds.internal.matcher.CelMatcherTestHelper.compile("'should be bool'"); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + CelMatcherTestHelper.compile("'should be bool'"); + fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("must evaluate to boolean"); } catch (Exception e) { @@ -136,7 +144,7 @@ public void celMatcher_evaluationError_returnsFalse() { Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + HttpAttributesCelMatchInput .getDefaultInstance()))) .setCustomMatch(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack(celMatcher))) @@ -153,21 +161,22 @@ public void celMatcher_evaluationError_returnsFalse() { .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - when(context.getPath()).thenReturn("not-an-int"); - when(context.getMetadata()).thenReturn(new Metadata()); - when(context.getId()).thenReturn("1"); + MatchContext context = MatchContext.newBuilder() + .setPath("not-an-int") + .setMetadata(new Metadata()) + .setId("1") + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + assertThat(result.action.getName()).isEqualTo("no-match"); } @Test public void celStringExtractor_throwsIfReturnsBool() { try { - io.grpc.xds.internal.matcher.CelMatcherTestHelper.compileStringExtractor("true"); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + CelMatcherTestHelper.compileStringExtractor("true"); + fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("must evaluate to string"); } catch (Exception e) { @@ -182,7 +191,7 @@ public void celMatcher_headers() { Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + HttpAttributesCelMatchInput .getDefaultInstance()))) .setCustomMatch(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack(celMatcher))) @@ -199,15 +208,16 @@ public void celMatcher_headers() { .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"); + MatchContext context = MatchContext.newBuilder() + .setPath("/") + .setMetadata(headers) + .setId("123") + .build(); MatchResult result = matcher.match(context, 0); - TypedExtensionConfig action = result.actions.get(0); + TypedExtensionConfig action = result.action; assertThat(action.getName()).isEqualTo("matched"); } @@ -221,7 +231,7 @@ public void requestUrlPath_available() { .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + HttpAttributesCelMatchInput .getDefaultInstance()))) .setCustomMatch(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack(celMatcher))))) @@ -232,14 +242,15 @@ public void requestUrlPath_available() { .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"); + MatchContext context = MatchContext.newBuilder() + .setPath("/path/without/query") + .setMetadata(new Metadata()) + .setId("123") + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions.get(0).getName()).isEqualTo("matched"); + assertThat(result.action.getName()).isEqualTo("matched"); } @Test @@ -258,7 +269,7 @@ public void requestHeaders_equalityCheck_failsSafely() { .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + HttpAttributesCelMatchInput .getDefaultInstance()))) .setCustomMatch(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack(celMatcher))))) @@ -269,28 +280,29 @@ public void requestHeaders_equalityCheck_failsSafely() { .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(new Metadata()); + MatchContext context = MatchContext.newBuilder() + .setMetadata(new Metadata()) + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("no-match"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("no-match"); } @Test public void celMatcher_missingExprMatch_throws() { - com.github.xds.type.matcher.v3.CelMatcher celProto = - com.github.xds.type.matcher.v3.CelMatcher.getDefaultInstance(); + CelMatcher celProto = + 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()))) + HttpAttributesCelMatchInput.getDefaultInstance()))) .setCustomMatch(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack(celProto)))) .build(); try { PredicateEvaluator.fromProto(proto); - org.junit.Assert.fail("Should have thrown"); + fail("Should have thrown"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); } @@ -306,12 +318,12 @@ public void invalidInputCombination_stringMatcherWithCelInput_throws() { .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + HttpAttributesCelMatchInput .getDefaultInstance()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setValueMatch(StringMatcher.newBuilder() .setExact("any")))))) .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat() .contains("Type mismatch"); @@ -320,10 +332,10 @@ public void invalidInputCombination_stringMatcherWithCelInput_throws() { @Test public void celMatcher_wrongInput_throws() { - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput inputProto = - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput inputProto = + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("test").build(); - com.github.xds.type.matcher.v3.CelMatcher celMatcherProto = createCelMatcher("true"); + CelMatcher celMatcherProto = createCelMatcher("true"); try { UnifiedMatcher.fromProto(Matcher.newBuilder() @@ -339,7 +351,7 @@ public void celMatcher_wrongInput_throws() { .setOnMatch(Matcher.OnMatch.newBuilder() .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException for incompatible input"); + fail("Should have thrown IllegalArgumentException for incompatible input"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat() .contains("Type mismatch"); @@ -348,15 +360,15 @@ public void celMatcher_wrongInput_throws() { @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())) + CelMatcher celMatcherParsed = + CelMatcher.newBuilder() + .setExprMatch(CelExpression.newBuilder() + .setCelExprParsed(ParsedExpr.getDefaultInstance())) .build(); try { UnifiedMatcher.fromProto(wrapInMatcher(celMatcherParsed)); - org.junit.Assert.fail( + fail( "Should have thrown IllegalArgumentException for missing cel_expr_checked"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); @@ -365,11 +377,11 @@ public void celMatcher_withoutCelExprChecked_throws() { @Test public void celMatcher_withCelExprString_throws() { - 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() + ParsedExpr parsedExpr = + ParsedExpr.getDefaultInstance(); + CelMatcher celMatcherParsed = + CelMatcher.newBuilder() + .setExprMatch(CelExpression.newBuilder() .setCelExprParsed(parsedExpr) .build()) .build(); @@ -377,14 +389,14 @@ public void celMatcher_withCelExprString_throws() { try { UnifiedMatcher.fromProto(proto); - org.junit.Assert.fail( + fail( "Should have thrown IllegalArgumentException for using cel_expr_parsed"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); } } - private Matcher wrapInMatcher(com.github.xds.type.matcher.v3.CelMatcher celMatcher) { + private Matcher wrapInMatcher(CelMatcher celMatcher) { return Matcher.newBuilder() .setMatcherList(Matcher.MatcherList.newBuilder() .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() @@ -392,7 +404,7 @@ private Matcher wrapInMatcher(com.github.xds.type.matcher.v3.CelMatcher celMatch .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + HttpAttributesCelMatchInput .getDefaultInstance()))) .setCustomMatch(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack(celMatcher)) @@ -405,9 +417,9 @@ private Matcher wrapInMatcher(com.github.xds.type.matcher.v3.CelMatcher celMatch @Test public void singlePredicate_invalidCelMatcherProto_throws() { TypedExtensionConfig config = TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.newBuilder() + .setTypedConfig(Any.newBuilder() .setTypeUrl("type.googleapis.com/xds.type.matcher.v3.CelMatcher") - .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid")) + .setValue(ByteString.copyFromUtf8("invalid")) .build()) .build(); @@ -415,14 +427,14 @@ public void singlePredicate_invalidCelMatcherProto_throws() { Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + HttpAttributesCelMatchInput.getDefaultInstance()))) .setCustomMatch(config) .build(); try { PredicateEvaluator.fromProto( Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); } @@ -435,7 +447,7 @@ public void singlePredicate_celEvalError_returnsFalse() { Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + HttpAttributesCelMatchInput.getDefaultInstance()))) .setCustomMatch(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack(createCelMatcher("1/0 == 0")))) .build(); @@ -443,6 +455,6 @@ public void singlePredicate_celEvalError_returnsFalse() { PredicateEvaluator evaluator = PredicateEvaluator.fromProto( Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - assertThat(evaluator.evaluate(mock(MatchContext.class))).isFalse(); + assertThat(evaluator.evaluate(MatcherRunner.MatchContext.newBuilder().build())).isFalse(); } } diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java index d2645c107ab..d9cfe32f94e 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java @@ -17,14 +17,15 @@ 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.HttpAttributesCelMatchInput; import com.github.xds.type.matcher.v3.Matcher; import com.google.protobuf.Any; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; import io.grpc.Metadata; import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -37,7 +38,7 @@ 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"); + Assert.fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("MatcherTree must have input"); } @@ -47,12 +48,12 @@ public void matcherTree_missingInput_throws() { public void matcherTree_unsupportedCelInput_throws() { Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( - Any.pack(com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + Any.pack(HttpAttributesCelMatchInput .getDefaultInstance()))) .build(); try { new MatcherTree(proto, null, s -> true); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + Assert.fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat() .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); @@ -63,13 +64,13 @@ public void matcherTree_unsupportedCelInput_throws() { 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() + Any.pack(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"); + Assert.fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat() .contains("MatcherTree exact_match_map must contain at least one entry"); @@ -80,7 +81,7 @@ public void matcherTree_emptyMaps_throws() { 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() + Any.pack(HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("path").build()))) .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("val", Matcher.OnMatch.newBuilder() @@ -88,7 +89,7 @@ public void matcherTree_maxRecursionDepth_returnsNoMatch() { .build(); MatcherTree tree = new MatcherTree(proto, null, s -> true); - MatchContext context = mock(MatchContext.class); + MatchContext context = MatchContext.newBuilder().build(); MatchResult result = tree.match(context, 17); assertThat(result.matched).isFalse(); } @@ -98,7 +99,7 @@ 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() + Any.pack(HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("foo").build()))) .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("val", Matcher.OnMatch.newBuilder() @@ -110,21 +111,22 @@ public void matcherTree_nonStringInput_fallsBack() { .setAction(TypedExtensionConfig.newBuilder().setName("fallback")).build(), s -> true); - MatchContext context = mock(MatchContext.class); - - when(context.getMetadata()).thenReturn(new io.grpc.Metadata()); // No headers + MatchContext context = MatchContext.newBuilder() + .setMetadata(new Metadata()) + .build(); 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"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("fallback"); + assertThat(result.keepMatchingActions).isEmpty(); } @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() + Any.pack(HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("foo").build()))) .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("val", Matcher.OnMatch.newBuilder() @@ -136,22 +138,24 @@ public void matcherTree_noExactMatch_fallsBackToOnNoMatch() { .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); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "other"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); MatchResult result = tree.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("fallback"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("fallback"); + assertThat(result.keepMatchingActions).isEmpty(); } @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() + Any.pack(HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("bar").build()))) .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("baz", Matcher.OnMatch.newBuilder() @@ -160,7 +164,7 @@ public void matcherTree_prefixFoundButNestedFailed_returnNoMatch_notOnNoMatch() Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( - Any.pack(io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + Any.pack(HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("path").build()))) .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("/prefix", Matcher.OnMatch.newBuilder() @@ -174,19 +178,20 @@ public void matcherTree_prefixFoundButNestedFailed_returnNoMatch_notOnNoMatch() 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), + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", 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); + metadata.put(Metadata.Key.of("bar", Metadata.ASCII_STRING_MARSHALLER), "wrong"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); 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"); + assertThat(result.action).isNull(); + if (!result.keepMatchingActions.isEmpty()) { + assertThat(result.keepMatchingActions.get(0).getName()).isNotEqualTo("should-not-be-called"); } } @@ -197,10 +202,10 @@ public void matcherTree_noMap_throws() { .setMatcherTree(Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("key").build())))) .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + Assert.fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat() .contains("must have either exact_match_map or prefix_match_map"); @@ -214,11 +219,11 @@ public void matcherTree_customMatch_throws() { .setMatcherTree(Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("key").build()))) .setCustomMatch(TypedExtensionConfig.newBuilder().setName("custom"))) .build()); - org.junit.Assert.fail("Should have thrown IllegalArgumentException"); + Assert.fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().contains("does not support custom_match"); } @@ -230,7 +235,7 @@ public void matcherTree_exactMatch() { .setMatcherTree(Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("x-key").build()))) .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("foo", Matcher.OnMatch.newBuilder() @@ -242,14 +247,15 @@ public void matcherTree_exactMatch() { .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); + MatchContext context = MatchContext.newBuilder() + .setMetadata(headers) + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions.get(0).getName()).isEqualTo("matched_foo"); + assertThat(result.action.getName()).isEqualTo("matched_foo"); } @Test @@ -258,7 +264,7 @@ public void matcherTree_prefixMatch() { .setMatcherTree(Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("path").build()))) .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("/api", Matcher.OnMatch.newBuilder() @@ -270,15 +276,16 @@ public void matcherTree_prefixMatch() { .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); + MatchContext context = MatchContext.newBuilder() + .setMetadata(headers) + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); // Longest prefix wins - assertThat(result.actions.get(0).getName()).isEqualTo("apiv1"); + assertThat(result.action.getName()).isEqualTo("apiv1"); } @Test @@ -291,7 +298,7 @@ public void matcherTree_keepMatching_aggregate() { .setMatcherTree(Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("path").build()))) .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("/prefix", Matcher.OnMatch.newBuilder() @@ -303,18 +310,23 @@ public void matcherTree_keepMatching_aggregate() { .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); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); 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"); + // If we only find keepMatching actions, and onNoMatch matches, we get onNoMatch action. + + result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("A2"); + assertThat(result.keepMatchingActions).hasSize(1); + assertThat(result.keepMatchingActions.get(0).getName()).isEqualTo("A1"); } @Test @@ -338,13 +350,14 @@ public void matcherTree_keepMatching_longestPrefixFirst() { .setMatcherTree(Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("path").build()))) .setPrefixMatchMap(map)) .build(); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("path", "/abc")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("path", "/abc")) + .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); MatchResult result = matcher.match(context, 0); @@ -353,10 +366,12 @@ public void matcherTree_keepMatching_longestPrefixFirst() { // 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"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("A3"); + + assertThat(result.keepMatchingActions).hasSize(2); + assertThat(result.keepMatchingActions.get(0).getName()).isEqualTo("A1"); + assertThat(result.keepMatchingActions.get(1).getName()).isEqualTo("A2"); } @Test @@ -371,20 +386,21 @@ public void matcherTree_example4_prefixMap() { .setMatcherTree(Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + 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")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("x-user-segment", "grpc.channelz.v1.Channelz/GetTopChannels")) + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("longer_prefix"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("longer_prefix"); + assertThat(result.keepMatchingActions).isEmpty(); } @Test @@ -394,13 +410,13 @@ public void invalidInputCombination_matcherTreeWithCelInput_throws() { .setMatcherTree(Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + 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"); + Assert.fail("Should have thrown IllegalArgumentException"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat() .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); @@ -411,7 +427,7 @@ public void invalidInputCombination_matcherTreeWithCelInput_throws() { 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() + Any.pack(HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("path").build()))) .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("/a", Matcher.OnMatch.newBuilder() @@ -422,16 +438,18 @@ public void matcherTree_prefixMap_noMatch_shouldFallbackToOnNoMatch() { .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 metadata = new Metadata(); metadata.put( - io.grpc.Metadata.Key.of("path", io.grpc.Metadata.ASCII_STRING_MARSHALLER), "/b"); - when(context.getMetadata()).thenReturn(metadata); + Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/b"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); MatchResult result = tree.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("actionB"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("actionB"); + assertThat(result.keepMatchingActions).isEmpty(); } private Metadata metadataWith(String key, String value) { diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index e0045a49193..983b21c086b 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -17,14 +17,17 @@ 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.github.xds.type.matcher.v3.RegexMatcher; +import com.github.xds.type.matcher.v3.StringMatcher; +import com.google.common.io.BaseEncoding; import com.google.protobuf.Any; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; import io.grpc.Metadata; import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -43,13 +46,11 @@ public void matcherList_firstMatchWins_evenIfNestedNoMatch() { .setOnMatch(Matcher.OnMatch.newBuilder() .setMatcher(Matcher.newBuilder())) // nested matcher that doesn't match .build(); - Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() .setPredicate(createHeaderMatchPredicate("h", "v")) .setOnMatch(Matcher.OnMatch.newBuilder() .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) .build(); - Matcher proto = Matcher.newBuilder() .setMatcherList(Matcher.MatcherList.newBuilder() .addMatchers(fm1) @@ -59,8 +60,9 @@ public void matcherList_firstMatchWins_evenIfNestedNoMatch() { .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isFalse(); @@ -75,7 +77,7 @@ public void matcherTree_exactMatch_shouldNotFallBackToOnNoMatch_ifKeyFound() { .setMatcherTree(Matcher.MatcherTree.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("key").build()))) .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() .putMap("found", Matcher.OnMatch.newBuilder().setMatcher(nestedNoMatch).build()))) @@ -84,12 +86,14 @@ public void matcherTree_exactMatch_shouldNotFallBackToOnNoMatch_ifKeyFound() { .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("key", "found")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("key", "found")) + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isFalse(); - assertThat(result.actions).isEmpty(); + assertThat(result.action).isNull(); + assertThat(result.keepMatchingActions).isEmpty(); } @Test @@ -98,9 +102,9 @@ public void stringMatcher_contains_ignoreCase() { Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("key").build()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setValueMatch(StringMatcher.newBuilder() .setContains("WoRlD") .setIgnoreCase(true)) .build(); @@ -108,8 +112,9 @@ public void stringMatcher_contains_ignoreCase() { PredicateEvaluator evaluator = PredicateEvaluator.fromProto( Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("key", "hello world")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("key", "hello world")) + .build(); assertThat(evaluator.evaluate(context)).isTrue(); } @@ -124,23 +129,25 @@ public void andMatcher_allTrue_matches() { .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() .addPredicate(h1).addPredicate(h2)).build()); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); assertThat(eval.evaluate(context)).isTrue(); } @Test public void andMatcher_oneFalse_fails() { Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "v"); - Matcher.MatcherList.Predicate h2 = createHeaderMatchPredicate("h", "x"); // fail + Matcher.MatcherList.Predicate h2 = createHeaderMatchPredicate("h", "x"); PredicateEvaluator eval = PredicateEvaluator.fromProto( Matcher.MatcherList.Predicate.newBuilder() .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() .addPredicate(h1).addPredicate(h2)).build()); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); assertThat(eval.evaluate(context)).isFalse(); } @@ -154,8 +161,9 @@ public void orMatcher_oneTrue_matches() { .setOrMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() .addPredicate(h1).addPredicate(h2)).build()); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); assertThat(eval.evaluate(context)).isTrue(); } @@ -166,11 +174,14 @@ public void notMatcher_invert() { Matcher.MatcherList.Predicate.newBuilder() .setNotMatcher(h1).build()); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); assertThat(eval.evaluate(context)).isFalse(); - when(context.getMetadata()).thenReturn(metadataWith("h", "x")); + context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "x")) + .build(); assertThat(eval.evaluate(context)).isTrue(); } @@ -178,19 +189,20 @@ public void notMatcher_invert() { public void matchInput_headerName_binary() { String headerName = "test-bin"; byte[] bytes = new byte[] {1, 2, 3}; - String expected = com.google.common.io.BaseEncoding.base64().encode(bytes); + String expected = BaseEncoding.base64().encode(bytes); Metadata metadata = new Metadata(); metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), bytes); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadata); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput proto = + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName(headerName).build(); MatchInput input = UnifiedMatcher.resolveInput( TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + .setTypedConfig(Any.pack(proto)).build()); assertThat(input.apply(context)).isEqualTo(expected); } @@ -200,36 +212,38 @@ public void matchInput_headerName_binary_aggregation() { String headerName = "test-bin"; byte[] v1 = new byte[] {1, 2, 3}; byte[] v2 = new byte[] {4, 5, 6}; - String expected = com.google.common.io.BaseEncoding.base64().encode(v1) + "," - + com.google.common.io.BaseEncoding.base64().encode(v2); + String expected = BaseEncoding.base64().encode(v1) + "," + + 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); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput proto = + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName(headerName).build(); MatchInput input = UnifiedMatcher.resolveInput( TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + .setTypedConfig(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()); + MatchContext context = MatchContext.newBuilder() + .setMetadata(new Metadata()) + .build(); - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput proto = + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("missing-bin").build(); MatchInput input = UnifiedMatcher.resolveInput( TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + .setTypedConfig(Any.pack(proto)).build()); assertThat(input.apply(context)).isNull(); } @@ -241,15 +255,16 @@ public void matchInput_headerName_te_returnsNull() { // "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); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput proto = + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("te").build(); MatchInput input = UnifiedMatcher.resolveInput( TypedExtensionConfig.newBuilder() - .setTypedConfig(com.google.protobuf.Any.pack(proto)).build()); + .setTypedConfig(Any.pack(proto)).build()); assertThat(input.apply(context)).isNull(); } @@ -261,11 +276,12 @@ public void noOpMatcher_delegatesToOnNoMatch() { .setAction(TypedExtensionConfig.newBuilder().setName("no-match-action"))) .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchResult result = matcher.match(mock(MatchContext.class), 0); + MatchResult result = matcher.match(MatchContext.newBuilder().build(), 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("no-match-action"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("no-match-action"); + assertThat(result.keepMatchingActions).isEmpty(); } @Test @@ -274,8 +290,8 @@ public void matcherRunner_checkMatch_returnsActions() { .setOnNoMatch(Matcher.OnMatch.newBuilder() .setAction(TypedExtensionConfig.newBuilder().setName("action"))) .build(); - java.util.List actions = - MatcherRunner.checkMatch(proto, mock(MatchContext.class)); + List actions = + MatcherRunner.checkMatch(proto, MatchContext.newBuilder().build()); assertThat(actions).hasSize(1); assertThat(actions.get(0).getName()).isEqualTo("action"); } @@ -284,8 +300,8 @@ public void matcherRunner_checkMatch_returnsActions() { public void matcherRunner_checkMatch_returnsNullOnNoMatch() { Matcher proto = Matcher.newBuilder() .build(); - java.util.List actions = - MatcherRunner.checkMatch(proto, mock(MatchContext.class)); + List actions = + MatcherRunner.checkMatch(proto, MatchContext.newBuilder().build()); assertThat(actions).isNull(); } @@ -295,20 +311,23 @@ public void singlePredicate_stringMatcher_safeRegex_matches() { Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("k").build()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() - .setSafeRegex(com.github.xds.type.matcher.v3.RegexMatcher.newBuilder() + .setValueMatch(StringMatcher.newBuilder() + .setSafeRegex(RegexMatcher.newBuilder() .setRegex("v.*"))) // v followed by anything .build(); PredicateEvaluator eval = PredicateEvaluator.fromProto( Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("k", "val")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "val")) + .build(); assertThat(eval.evaluate(context)).isTrue(); - when(context.getMetadata()).thenReturn(metadataWith("k", "xal")); + context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "xal")) + .build(); assertThat(eval.evaluate(context)).isFalse(); } @@ -318,19 +337,22 @@ public void singlePredicate_stringMatcher_suffix_matches() { Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("k").build()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setValueMatch(StringMatcher.newBuilder() .setSuffix("tail")) .build(); PredicateEvaluator eval = PredicateEvaluator.fromProto( Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("k", "detail")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "detail")) + .build(); assertThat(eval.evaluate(context)).isTrue(); - when(context.getMetadata()).thenReturn(metadataWith("k", "detai")); + context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "detai")) + .build(); assertThat(eval.evaluate(context)).isFalse(); } @@ -340,16 +362,17 @@ public void singlePredicate_stringMatcher_prefix_matches() { Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() .setInput(TypedExtensionConfig.newBuilder() .setTypedConfig(Any.pack( - io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName("k").build()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setValueMatch(StringMatcher.newBuilder() .setPrefix("pre")) .build(); PredicateEvaluator eval = PredicateEvaluator.fromProto( Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("k", "prefix")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "prefix")) + .build(); assertThat(eval.evaluate(context)).isTrue(); } @@ -375,21 +398,23 @@ public void matcherList_keepMatching() { .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), "v"); metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v"); - when(context.getMetadata()).thenReturn(metadata); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(2); // Both actions - assertThat(result.actions.get(0).getName()).isEqualTo("action1"); - assertThat(result.actions.get(1).getName()).isEqualTo("action2"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("action2"); + assertThat(result.keepMatchingActions).hasSize(1); + assertThat(result.keepMatchingActions.get(0).getName()).isEqualTo("action1"); } @Test - public void onNoMatchShouldNotExecuteWhenKeepMatchingTrueAndMatchFound() { + public void matcherList_keepMatching_fallsThroughToOnNoMatch() { Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() .setPredicate(createHeaderMatchPredicate("h1", "v")) .setOnMatch(Matcher.OnMatch.newBuilder() @@ -405,13 +430,17 @@ public void onNoMatchShouldNotExecuteWhenKeepMatchingTrueAndMatchFound() { .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("h1", "v")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h1", "v")) + .build(); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("action1"); + // onNoMatch IS executed because m1 had keepMatching=true and we reached end of list + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("no-match"); + assertThat(result.keepMatchingActions).hasSize(1); + assertThat(result.keepMatchingActions.get(0).getName()).isEqualTo("action1"); } @Test @@ -430,14 +459,16 @@ public void matcherList_example1_simpleLinearMatch() { .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1).addMatchers(fm2)) .build(); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("h2", "v")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h2", "v")) + .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("action2"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("action2"); + assertThat(result.keepMatchingActions).isEmpty(); } @Test @@ -460,13 +491,17 @@ public void matcherList_example2_keepMatching() { .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1).addMatchers(fm2)) .build(); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("h", "v")); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(2); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("action2"); + assertThat(result.keepMatchingActions).hasSize(1); + assertThat(result.keepMatchingActions.get(0).getName()).isEqualTo("action1"); } @Test @@ -488,17 +523,19 @@ public void matcherList_example3_nestedMatcher() { .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1)) .build(); - MatchContext context = mock(MatchContext.class); Metadata metadata = new Metadata(); metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v"); metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v"); - when(context.getMetadata()).thenReturn(metadata); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - assertThat(result.actions).hasSize(1); - assertThat(result.actions.get(0).getName()).isEqualTo("action2"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("action2"); + assertThat(result.keepMatchingActions).isEmpty(); } @Test @@ -507,7 +544,7 @@ public void noOpMatcher_runtimeRecursionLimit_returnsNoMatch() { UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); // Manually calling with depth > 16 - MatchResult result = matcher.match(mock(MatchContext.class), 17); + MatchResult result = matcher.match(MatchContext.newBuilder().build(), 17); assertThat(result.matched).isFalse(); } @@ -524,10 +561,7 @@ public void matcherList_maxRecursionDepth_returnsNoMatch() { .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); + MatchResult result = matcherList.match(MatchContext.newBuilder().build(), 17); assertThat(result.matched).isFalse(); } @@ -561,28 +595,29 @@ public void matcherList_keepMatching_verification() { .build(); UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); - MatchContext context = mock(MatchContext.class); - when(context.getMetadata()).thenReturn(metadataWith("h", "v")); - + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); + MatchResult result = matcher.match(context, 0); assertThat(result.matched).isTrue(); - 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"); + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("a3"); + + assertThat(result.keepMatchingActions).hasSize(2); + assertThat(result.keepMatchingActions.get(0).getName()).isEqualTo("a1"); + assertThat(result.keepMatchingActions.get(1).getName()).isEqualTo("a2"); } - // Helpers - private static Matcher.MatcherList.Predicate createHeaderMatchPredicate( String name, 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() + HttpRequestHeaderMatchInput.newBuilder() .setHeaderName(name).build()))) - .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setValueMatch(StringMatcher.newBuilder() .setExact(value))) .build(); } From f3042e2909741a937465ead93d1c7231a8eacf95 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Mar 2026 16:15:05 +0530 Subject: [PATCH 16/23] add unit tests for exactMatchMap case --- .../xds/internal/matcher/MatcherTreeTest.java | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java index d9cfe32f94e..02928b908cb 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java @@ -452,6 +452,113 @@ public void matcherTree_prefixMap_noMatch_shouldFallbackToOnNoMatch() { assertThat(result.keepMatchingActions).isEmpty(); } + @Test + public void matcherTree_exactMatch_keepMatching_aggregate() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", 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); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("key", Metadata.ASCII_STRING_MARSHALLER), "val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // exact match matched (A1), keepMatching=true -> continue to onNoMatch (A2) + // onNoMatch matched -> A2 is the terminal action + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("A2"); + assertThat(result.keepMatchingActions).hasSize(1); + assertThat(result.keepMatchingActions.get(0).getName()).isEqualTo("A1"); + } + + @Test + public void matcherTree_exactMatch_keepMatching_nestedMatcher_fails() { + // Exact match "val" -> Nested Matcher (always fails), keepMatching=true + // Because keepMatching=true, we should fallback to onNoMatch + + Matcher.MatcherTree nestedProto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("bar").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("foo", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("n/a")).build())) + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder().setMatcherTree(nestedProto)) + .setKeepMatching(true).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("key", Metadata.ASCII_STRING_MARSHALLER), "val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isTrue(); + // nested matcher failed, but allowed to continue because keepMatching=true + assertThat(result.action).isNotNull(); + assertThat(result.action.getName()).isEqualTo("A2"); + assertThat(result.keepMatchingActions).isEmpty(); + } + + @Test + public void matcherTree_prefixMatch_keepMatching_aggregate_noOnNoMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true).build()))) + // No onNoMatch configured + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + Metadata metadata = new Metadata(); + // "/prefix/val" matches "/prefix" -> A1, keepMatching=true + metadata.put(Metadata.Key.of("key", Metadata.ASCII_STRING_MARSHALLER), "/prefix/val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = matcher.match(context, 0); + assertThat(result.matched).isFalse(); + assertThat(result.action).isNull(); + + assertThat(result.keepMatchingActions).hasSize(1); + assertThat(result.keepMatchingActions.get(0).getName()).isEqualTo("A1"); + } + private Metadata metadataWith(String key, String value) { Metadata m = new Metadata(); m.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); From 2f6810195c59cfb3acbe66ff6fc5e1ac374e659c Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 4 Mar 2026 16:22:29 +0530 Subject: [PATCH 17/23] add unit tests --- .../matcher/UnifiedMatcherValidationTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java index eeb0599fa88..b2c92a7d5fb 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java @@ -453,4 +453,36 @@ public void stringMatcher_unknownPattern_throws() { assertThat(e).hasMessageThat().contains("Unknown StringMatcher match pattern"); } } + + @Test + public void headerName_empty_throws() { + IllegalArgumentException e = org.junit.Assert.assertThrows(IllegalArgumentException.class, () -> + new HeaderMatchInput("")); + assertThat(e).hasMessageThat().contains("Header name length must be in range [1, 16384)"); + } + + @Test + public void headerName_tooLong_throws() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 16384; i++) { + sb.append("a"); + } + String longHeader = sb.toString(); + IllegalArgumentException e = org.junit.Assert.assertThrows(IllegalArgumentException.class, () -> + new HeaderMatchInput(longHeader)); + assertThat(e).hasMessageThat().contains("Header name length must be in range [1, 16384)"); + } + + @Test + public void headerName_uppercase_throws() { + IllegalArgumentException e = org.junit.Assert.assertThrows(IllegalArgumentException.class, () -> + new HeaderMatchInput("X-Custom-Header")); + assertThat(e).hasMessageThat().contains("Header name must be lowercase"); + } + + @Test + public void headerName_valid() { + HeaderMatchInput input = new HeaderMatchInput("x-custom-header"); + assertThat(input).isNotNull(); + } } From 051823008503fe18cca9246138386d8747aba9a9 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Mon, 30 Mar 2026 21:39:37 +0530 Subject: [PATCH 18/23] Address Ashesh's comments --- .../grpc/xds/internal/matcher/CelMatcher.java | 14 +- .../xds/internal/matcher/CelStateMatcher.java | 15 +- .../internal/matcher/HeaderMatchInput.java | 8 +- .../matcher/HttpAttributesCelMatchInput.java | 5 +- .../grpc/xds/internal/matcher/MatchInput.java | 1 - .../internal/matcher/MatchInputRegistry.java | 31 ++- .../xds/internal/matcher/MatcherList.java | 1 - .../xds/internal/matcher/MatcherRegistry.java | 29 ++- .../xds/internal/matcher/MatcherRunner.java | 92 +------- .../xds/internal/matcher/MatcherTree.java | 202 +++++++++++------- .../io/grpc/xds/internal/matcher/OnMatch.java | 4 +- .../internal/matcher/PredicateEvaluator.java | 47 ++-- .../xds/internal/matcher/UnifiedMatcher.java | 1 - .../internal/matcher/CelStateMatcherTest.java | 3 +- .../xds/internal/matcher/MatcherTreeTest.java | 1 - .../internal/matcher/UnifiedMatcherTest.java | 7 +- 16 files changed, 220 insertions(+), 241 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java index fea2ee8957c..c11397b1916 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java @@ -20,6 +20,8 @@ import dev.cel.common.types.SimpleType; import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelVariableResolver; +import java.util.Map; /** * Executes compiled CEL expressions. @@ -33,10 +35,10 @@ private CelMatcher(CelRuntime.Program program) { /** * Compiles the AST into a CelMatcher. - * Throws an Exception if validation or evaluation fails during compilation setup. + * Throws an Exception if evaluation fails during compilation setup. */ public static CelMatcher compile(CelAbstractSyntaxTree ast) - throws Exception { + throws CelEvaluationException { // CelEvaluationException -> inside cel-runtime -> Allowed in production signatures // CelValidationException -> inside cel-compiler -> Forbidden in production signatures if (ast.getResultType() != SimpleType.BOOL) { @@ -53,11 +55,11 @@ public static CelMatcher compile(CelAbstractSyntaxTree ast) */ 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) { + if (input instanceof CelVariableResolver) { + result = program.eval((CelVariableResolver) input); + } else if (input instanceof Map) { @SuppressWarnings("unchecked") - java.util.Map mapInput = (java.util.Map) input; + Map mapInput = (Map) input; result = program.eval(mapInput); } else { throw new CelEvaluationException( diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java index 3a7de8d86a8..110ab751da7 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java @@ -17,7 +17,6 @@ package io.grpc.xds.internal.matcher; import com.github.xds.core.v3.TypedExtensionConfig; -import com.github.xds.type.matcher.v3.CelMatcher; // Proto import com.github.xds.type.v3.CelExpression; import dev.cel.common.CelAbstractSyntaxTree; import dev.cel.common.CelProtoAbstractSyntaxTree; @@ -27,9 +26,10 @@ * Matcher for CEL expressions handling xDS CEL Matcher extension. */ final class CelStateMatcher implements Matcher { - private final io.grpc.xds.internal.matcher.CelMatcher compiledEndpoint; + private final CelMatcher compiledEndpoint; + static final String TYPE_URL = "type.googleapis.com/xds.type.matcher.v3.CelMatcher"; - CelStateMatcher(io.grpc.xds.internal.matcher.CelMatcher compiledEndpoint) { + CelStateMatcher(CelMatcher compiledEndpoint) { this.compiledEndpoint = compiledEndpoint; } @@ -51,8 +51,8 @@ static final class Provider implements MatcherProvider { @Override public CelStateMatcher getMatcher(TypedExtensionConfig config) { try { - CelMatcher celProto = config.getTypedConfig() - .unpack(CelMatcher.class); + com.github.xds.type.matcher.v3.CelMatcher celProto = config.getTypedConfig() + .unpack(com.github.xds.type.matcher.v3.CelMatcher.class); if (!celProto.hasExprMatch()) { throw new IllegalArgumentException("CelMatcher must have expr_match"); } @@ -63,8 +63,7 @@ public CelStateMatcher getMatcher(TypedExtensionConfig config) { CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr( expr.getCelExprChecked()).getAst(); - io.grpc.xds.internal.matcher.CelMatcher compiled = - io.grpc.xds.internal.matcher.CelMatcher.compile(ast); + CelMatcher compiled = CelMatcher.compile(ast); return new CelStateMatcher(compiled); } catch (Exception e) { @@ -74,7 +73,7 @@ public CelStateMatcher getMatcher(TypedExtensionConfig config) { @Override public String typeUrl() { - return "type.googleapis.com/xds.type.matcher.v3.CelMatcher"; + return TYPE_URL; } } } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java index eb48120f04d..42d913deb2d 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java @@ -22,13 +22,15 @@ 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 java.util.Locale; /** * MatchInput for extracting HTTP headers. */ final class HeaderMatchInput implements MatchInput { private final String headerName; + static final String TYPE_URL = + "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; HeaderMatchInput(String headerName) { this.headerName = checkNotNull(headerName, "headerName"); @@ -36,7 +38,7 @@ final class HeaderMatchInput implements MatchInput { throw new IllegalArgumentException( "Header name length must be in range [1, 16384): " + headerName.length()); } - if (!headerName.equals(headerName.toLowerCase(java.util.Locale.ROOT))) { + if (!headerName.equals(headerName.toLowerCase(Locale.ROOT))) { throw new IllegalArgumentException("Header name must be lowercase: " + headerName); } try { @@ -101,7 +103,7 @@ public HeaderMatchInput getInput(TypedExtensionConfig config) { @Override public String typeUrl() { - return "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; + return TYPE_URL; } } } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java index 2010bcabcf0..e497590b547 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java @@ -17,13 +17,14 @@ package io.grpc.xds.internal.matcher; import com.github.xds.core.v3.TypedExtensionConfig; -import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; /** * MatchInput for extracting CEL environment from HTTP attributes. */ final class HttpAttributesCelMatchInput implements MatchInput { static final HttpAttributesCelMatchInput INSTANCE = new HttpAttributesCelMatchInput(); + static final String TYPE_URL = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; private HttpAttributesCelMatchInput() {} @@ -45,7 +46,7 @@ public HttpAttributesCelMatchInput getInput(TypedExtensionConfig config) { @Override public String typeUrl() { - return "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + return TYPE_URL; } } } 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 index 8ecad39c0d6..ea464fbe34a 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java @@ -16,7 +16,6 @@ package io.grpc.xds.internal.matcher; -import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; import javax.annotation.Nullable; /** diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java index 8ca4c34bf8e..e1c6e8127e9 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java @@ -17,30 +17,41 @@ package io.grpc.xds.internal.matcher; import com.google.common.annotations.VisibleForTesting; +import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import javax.annotation.Nullable; /** * Registry for {@link MatchInputProvider}s. */ public final class MatchInputRegistry { - private static final MatchInputRegistry DEFAULT_INSTANCE = new MatchInputRegistry(); + private static MatchInputRegistry instance; - private final Map providers = new ConcurrentHashMap<>(); + private final Map providers = new HashMap<>(); - public static MatchInputRegistry getDefaultRegistry() { - return DEFAULT_INSTANCE; + private MatchInputRegistry() {} + + public static synchronized MatchInputRegistry getDefaultRegistry() { + if (instance == null) { + instance = newRegistry().register( + new HeaderMatchInput.Provider(), + new HttpAttributesCelMatchInput.Provider() + ); + } + return instance; } @VisibleForTesting - public MatchInputRegistry() { - register(new HeaderMatchInput.Provider()); - register(new HttpAttributesCelMatchInput.Provider()); + public static MatchInputRegistry newRegistry() { + return new MatchInputRegistry(); } - public void register(MatchInputProvider provider) { - providers.put(provider.typeUrl(), provider); + @VisibleForTesting + public MatchInputRegistry register(MatchInputProvider... inputProviders) { + for (MatchInputProvider provider : inputProviders) { + providers.put(provider.typeUrl(), provider); + } + return this; } @Nullable diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java index c7c4672cd72..ed39f4bb96f 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java @@ -18,7 +18,6 @@ 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 java.util.function.Predicate; diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java index 2dd63fc971e..96a432e1cbe 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java @@ -17,29 +17,40 @@ package io.grpc.xds.internal.matcher; import com.google.common.annotations.VisibleForTesting; +import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import javax.annotation.Nullable; /** * Registry for {@link MatcherProvider}. */ public final class MatcherRegistry { - private static final MatcherRegistry DEFAULT_INSTANCE = new MatcherRegistry(); + private static MatcherRegistry instance; - private final Map matcherProviders = new ConcurrentHashMap<>(); + private final Map matcherProviders = new HashMap<>(); - public static MatcherRegistry getDefaultRegistry() { - return DEFAULT_INSTANCE; + private MatcherRegistry() { + } + + public static synchronized MatcherRegistry getDefaultRegistry() { + if (instance == null) { + instance = newRegistry().register( + new CelStateMatcher.Provider()); + } + return instance; } @VisibleForTesting - public MatcherRegistry() { - register(new CelStateMatcher.Provider()); + public static MatcherRegistry newRegistry() { + return new MatcherRegistry(); } - public void register(MatcherProvider provider) { - matcherProviders.put(provider.typeUrl(), provider); + @VisibleForTesting + public MatcherRegistry register(MatcherProvider... providers) { + for (MatcherProvider provider : providers) { + matcherProviders.put(provider.typeUrl(), provider); + } + return this; } @Nullable diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java index 398a5a2386f..29aefe7d78b 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java @@ -17,9 +17,6 @@ package io.grpc.xds.internal.matcher; import com.github.xds.core.v3.TypedExtensionConfig; -import com.github.xds.type.matcher.v3.Matcher; -import com.google.common.base.Preconditions; -import io.grpc.Metadata; import java.util.ArrayList; import java.util.List; import javax.annotation.Nullable; @@ -35,8 +32,7 @@ private MatcherRunner() {} */ @Nullable public static List checkMatch( - Matcher proto, MatchContext context) { - UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + UnifiedMatcher matcher, MatchContext context) { MatchResult result = matcher.match(context, 0); if (!result.matched) { return null; @@ -54,90 +50,4 @@ public static List checkMatch( return allActions; } - public static final class MatchContext { - private final Metadata metadata; - @Nullable - private final String path; - @Nullable - private final String host; - @Nullable - private final String method; - @Nullable - private final String id; - - public MatchContext(Metadata metadata, @Nullable String path, - @Nullable String host, @Nullable String method, - @Nullable String id) { - this.metadata = Preconditions.checkNotNull(metadata, "metadata"); - this.path = path; - this.host = host; - this.method = method; - this.id = id; - } - - public Metadata getMetadata() { - return metadata; - } - - @Nullable - public String getPath() { - return path; - } - - @Nullable - public String getHost() { - return host; - } - - @Nullable - public String getMethod() { - return method; - } - - @Nullable - public String getId() { - return id; - } - - public static Builder newBuilder() { - return new Builder(); - } - - public static final class Builder { - private Metadata metadata = new Metadata(); - private String path; - private String host; - private String method; - private String id; - - public Builder setMetadata(Metadata metadata) { - this.metadata = metadata; - return this; - } - - public Builder setPath(String path) { - this.path = path; - return this; - } - - public Builder setHost(String host) { - this.host = host; - return this; - } - - public Builder setMethod(String method) { - this.method = method; - return this; - } - - public Builder setId(String id) { - this.id = id; - return this; - } - - public MatchContext build() { - return new MatchContext(metadata, path, host, method, id); - } - } - } } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java index 33e111482bf..af118fcf632 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java @@ -18,10 +18,8 @@ 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.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -35,7 +33,7 @@ final class MatcherTree extends UnifiedMatcher { @Nullable private final Map exactMatchMap; @Nullable - private final Map prefixMatchMap; + private final PrefixTrie prefixTrie; @Nullable private final OnMatch onNoMatch; @@ -67,21 +65,23 @@ final class MatcherTree extends UnifiedMatcher { this.exactMatchMap.put(entry.getKey(), new OnMatch(entry.getValue(), actionValidator)); } - this.prefixMatchMap = null; + this.prefixTrie = 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<>(); + this.prefixTrie = new PrefixTrie(); for (Map.Entry entry : matchMap.getMapMap().entrySet()) { - this.prefixMatchMap.put(entry.getKey(), + this.prefixTrie.insert(entry.getKey(), new OnMatch(entry.getValue(), actionValidator)); } this.exactMatchMap = null; } else { + this.exactMatchMap = null; + this.prefixTrie = null; throw new IllegalArgumentException( "MatcherTree must have either exact_match_map or prefix_match_map"); } @@ -104,94 +104,134 @@ public MatchResult match(MatchContext context, int depth) { } String value = (String) valueObj; if (exactMatchMap != null) { - OnMatch match = exactMatchMap.get(value); - if (match != null) { - MatchResult result = match.evaluate(context, depth); - - List accumulated = - new ArrayList<>(result.keepMatchingActions); - - if (result.matched && !match.keepMatching) { - return MatchResult.create(result.action, accumulated); - } - - if (result.matched) { // && keepMatching=true - if (result.action != null) { - accumulated.add(result.action); - } - } else { - if (!match.keepMatching) { - return MatchResult.noMatch(accumulated); - } + return matchExact(value, context, depth); + } else if (prefixTrie != null) { + return matchPrefix(value, context, depth); + } + + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } + + private MatchResult matchExact(String value, MatchContext context, int depth) { + OnMatch match = exactMatchMap.get(value); + if (match != null) { + MatchResult result = match.evaluate(context, depth); + + List accumulated = new ArrayList<>(result.keepMatchingActions); + + if (result.matched && !match.keepMatching) { + return MatchResult.create(result.action, accumulated); + } + + if (result.matched) { // && keepMatching=true + if (result.action != null) { + accumulated.add(result.action); } - - // If keepMatching=true, OR (matched=true and keepMatching=true), then continue to onNoMatch - if (onNoMatch != null) { - MatchResult noMatchResult = onNoMatch.evaluate(context, depth); - accumulated.addAll(noMatchResult.keepMatchingActions); - if (noMatchResult.matched) { - return MatchResult.create(noMatchResult.action, accumulated); - } + } else { + if (!match.keepMatching) { + return MatchResult.noMatch(accumulated); } - return MatchResult.noMatch(accumulated); } - return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); - } else if (prefixMatchMap != null) { - List matchingPrefixes = new ArrayList<>(); - for (String prefix : prefixMatchMap.keySet()) { - if (value.startsWith(prefix)) { - matchingPrefixes.add(prefix); + + // If keepMatching=true, OR (matched=true and keepMatching=true), then continue + // to onNoMatch + if (onNoMatch != null) { + MatchResult noMatchResult = onNoMatch.evaluate(context, depth); + accumulated.addAll(noMatchResult.keepMatchingActions); + if (noMatchResult.matched) { + return MatchResult.create(noMatchResult.action, accumulated); } } - - if (matchingPrefixes.isEmpty()) { - return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + return MatchResult.noMatch(accumulated); + } + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } + + private MatchResult matchPrefix(String value, MatchContext context, int depth) { + List matchingPrefixes = prefixTrie.matchPrefixes(value); + + if (matchingPrefixes.isEmpty()) { + return onNoMatch != null ? onNoMatch.evaluate(context, depth) : MatchResult.noMatch(); + } + + List accumulatedActions = new ArrayList<>(); + + for (OnMatch onMatch : matchingPrefixes) { + MatchResult result = onMatch.evaluate(context, depth); + accumulatedActions.addAll(result.keepMatchingActions); + + if (result.matched && !onMatch.keepMatching) { + return MatchResult.create(result.action, accumulatedActions); } - // Sort by length descending (longest first) - Collections.sort(matchingPrefixes, new Comparator() { - @Override - public int compare(String s1, String s2) { - return Integer.compare(s2.length(), s1.length()); + if (result.matched) { // AND keepMatching=true + if (result.action != null) { + accumulatedActions.add(result.action); } - }); - - List accumulatedActions = - new ArrayList<>(); - - for (String prefix : matchingPrefixes) { - OnMatch onMatch = prefixMatchMap.get(prefix); - MatchResult result = onMatch.evaluate(context, depth); - accumulatedActions.addAll(result.keepMatchingActions); - - if (result.matched && !onMatch.keepMatching) { - return MatchResult.create(result.action, accumulatedActions); + } else { + if (!onMatch.keepMatching) { + return MatchResult.noMatch(accumulatedActions); } - - if (result.matched) { // AND keepMatching=true - if (result.action != null) { - accumulatedActions.add(result.action); - } - } else { - if (!onMatch.keepMatching) { - return MatchResult.noMatch(accumulatedActions); - } + } + + // If keepMatching=true, we continue regardless of inner match result. + } + + // If we fall through, we either found no matches or all matches had + // keepMatching=true. + if (onNoMatch != null) { + MatchResult noMatchResult = onNoMatch.evaluate(context, depth); + accumulatedActions.addAll(noMatchResult.keepMatchingActions); + if (noMatchResult.matched) { + return MatchResult.create(noMatchResult.action, accumulatedActions); + } + } + return MatchResult.noMatch(accumulatedActions); + } + + private static final class PrefixTrie { + private final TrieNode root = new TrieNode(); + + void insert(String prefix, OnMatch onMatch) { + TrieNode current = root; + for (int i = 0; i < prefix.length(); i++) { + char c = prefix.charAt(i); + TrieNode child = current.children.get(c); + if (child == null) { + child = new TrieNode(); + current.children.put(c, child); } - - // If keepMatching=true, we continue regardless of inner match result. + current = child; } - - // If we fall through, we either found no matches or all matches had keepMatching=true. - if (onNoMatch != null) { - MatchResult noMatchResult = onNoMatch.evaluate(context, depth); - accumulatedActions.addAll(noMatchResult.keepMatchingActions); - if (noMatchResult.matched) { - return MatchResult.create(noMatchResult.action, accumulatedActions); + current.onMatch = onMatch; + } + + List matchPrefixes(String value) { + List matchingPrefixes = new ArrayList<>(); + TrieNode current = root; + if (current.onMatch != null) { + matchingPrefixes.add(current.onMatch); + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + current = current.children.get(c); + if (current == null) { + break; + } + if (current.onMatch != null) { + matchingPrefixes.add(current.onMatch); } } - return MatchResult.noMatch(accumulatedActions); + + // Evaluate longest matching prefix first + Collections.reverse(matchingPrefixes); + return matchingPrefixes; + } + + private static final class TrieNode { + final Map children = new HashMap<>(); + @Nullable + OnMatch onMatch; } - - 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 index 03e2eac00b2..11102c8da9b 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java @@ -18,7 +18,7 @@ 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.function.Predicate; import javax.annotation.Nullable; /** @@ -29,7 +29,7 @@ final class OnMatch { @Nullable private final TypedExtensionConfig action; final boolean keepMatching; - OnMatch(Matcher.OnMatch proto, java.util.function.Predicate actionValidator) { + OnMatch(Matcher.OnMatch proto, Predicate actionValidator) { this.keepMatching = proto.getKeepMatching(); if (proto.hasMatcher()) { this.nestedMatcher = UnifiedMatcher.fromProto(proto.getMatcher(), actionValidator); 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 index cbfa5b3bb7a..89880192866 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java @@ -21,7 +21,6 @@ import com.github.xds.type.matcher.v3.StringMatcher; import io.grpc.xds.internal.MatcherParser; import io.grpc.xds.internal.Matchers; -import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; import java.util.ArrayList; import java.util.List; @@ -54,25 +53,7 @@ private static final class SinglePredicateEvaluator extends PredicateEvaluator { if (proto.hasValueMatch()) { Matchers.StringMatcher stringMatcher = fromStringMatcherProto(proto.getValueMatch()); - this.matcher = new Matcher() { - @Override - public boolean match(Object value) { - if (value == null) { - return false; - } - if (!(value instanceof String)) { - throw new IllegalArgumentException( - "StringMatcher expected a String input, but received: " - + value.getClass().getName()); - } - return stringMatcher.matches((String) value); - } - - @Override - public Class inputType() { - return String.class; - } - }; + this.matcher = new StringMatcherAdapter(stringMatcher); } else if (proto.hasCustomMatch()) { TypedExtensionConfig customConfig = proto.getCustomMatch(); MatcherProvider provider = MatcherRegistry.getDefaultRegistry() @@ -102,6 +83,32 @@ private static Matchers.StringMatcher fromStringMatcherProto( StringMatcher proto) { return MatcherParser.parseStringMatcher(proto); } + + private static final class StringMatcherAdapter implements Matcher { + private final Matchers.StringMatcher stringMatcher; + + StringMatcherAdapter(Matchers.StringMatcher stringMatcher) { + this.stringMatcher = stringMatcher; + } + + @Override + public boolean match(Object value) { + if (value == null) { + return false; + } + if (!(value instanceof String)) { + throw new IllegalArgumentException( + "StringMatcher expected a String input, but received: " + + value.getClass().getName()); + } + return stringMatcher.matches((String) value); + } + + @Override + public Class inputType() { + return String.class; + } + } } private static final class OrMatcherEvaluator extends PredicateEvaluator { diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java index bda2240dea4..ad99e027ea1 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -18,7 +18,6 @@ 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.function.Predicate; import javax.annotation.Nullable; diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java index 8436e5e3c03..d9500825e27 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java @@ -36,7 +36,6 @@ import dev.cel.expr.ParsedExpr; import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; import io.grpc.Metadata; -import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; @@ -455,6 +454,6 @@ public void singlePredicate_celEvalError_returnsFalse() { PredicateEvaluator evaluator = PredicateEvaluator.fromProto( Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); - assertThat(evaluator.evaluate(MatcherRunner.MatchContext.newBuilder().build())).isFalse(); + assertThat(evaluator.evaluate(MatchContext.newBuilder().build())).isFalse(); } } diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java index 02928b908cb..2d2580c550a 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java @@ -24,7 +24,6 @@ import com.google.protobuf.Any; import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; import io.grpc.Metadata; -import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java index 983b21c086b..91b631fbd6f 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -26,7 +26,6 @@ import com.google.protobuf.Any; import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; import io.grpc.Metadata; -import io.grpc.xds.internal.matcher.MatcherRunner.MatchContext; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; @@ -290,8 +289,9 @@ public void matcherRunner_checkMatch_returnsActions() { .setOnNoMatch(Matcher.OnMatch.newBuilder() .setAction(TypedExtensionConfig.newBuilder().setName("action"))) .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); List actions = - MatcherRunner.checkMatch(proto, MatchContext.newBuilder().build()); + MatcherRunner.checkMatch(matcher, MatchContext.newBuilder().build()); assertThat(actions).hasSize(1); assertThat(actions.get(0).getName()).isEqualTo("action"); } @@ -300,8 +300,9 @@ public void matcherRunner_checkMatch_returnsActions() { public void matcherRunner_checkMatch_returnsNullOnNoMatch() { Matcher proto = Matcher.newBuilder() .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); List actions = - MatcherRunner.checkMatch(proto, MatchContext.newBuilder().build()); + MatcherRunner.checkMatch(matcher, MatchContext.newBuilder().build()); assertThat(actions).isNull(); } From 3f7fc86568a3bdf0ff8ec7c397a2bd8c2fe2d5f1 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Mon, 30 Mar 2026 22:37:05 +0530 Subject: [PATCH 19/23] use switch in PredicateEvaluator --- .../internal/matcher/PredicateEvaluator.java | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) 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 index 89880192866..2e441812e16 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java @@ -28,17 +28,20 @@ abstract class PredicateEvaluator { 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()); + switch (proto.getMatchTypeCase()) { + case SINGLE_PREDICATE: + return new SinglePredicateEvaluator(proto.getSinglePredicate()); + case OR_MATCHER: + return new OrMatcherEvaluator(proto.getOrMatcher()); + case AND_MATCHER: + return new AndMatcherEvaluator(proto.getAndMatcher()); + case NOT_MATCHER: + return new NotMatcherEvaluator(proto.getNotMatcher()); + case MATCHTYPE_NOT_SET: + default: + throw new IllegalArgumentException( + "Predicate must have one of: single_predicate, or_matcher, and_matcher, not_matcher"); } - throw new IllegalArgumentException( - "Predicate must have one of: single_predicate, or_matcher, and_matcher, not_matcher"); } private static final class SinglePredicateEvaluator extends PredicateEvaluator { From 7a6ca94eb47a44ae1e26c0a29077a99421b935c3 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Mon, 20 Apr 2026 14:55:05 +0530 Subject: [PATCH 20/23] Address comments on CelStringExtractor --- .../main/java/io/grpc/xds/internal/matcher/CelMatcher.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java index c11397b1916..5a9a90fa95e 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java @@ -21,7 +21,6 @@ import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelRuntime; import dev.cel.runtime.CelVariableResolver; -import java.util.Map; /** * Executes compiled CEL expressions. @@ -45,7 +44,7 @@ public static CelMatcher compile(CelAbstractSyntaxTree ast) throw new IllegalArgumentException( "CEL expression must evaluate to boolean, got: " + ast.getResultType()); } - CelCommon.checkAllowedVariables(ast); + CelCommon.checkAllowedReferences(ast); CelRuntime.Program program = CelCommon.RUNTIME.createProgram(ast); return new CelMatcher(program); } @@ -57,10 +56,6 @@ public boolean match(Object input) throws CelEvaluationException { Object result; if (input instanceof CelVariableResolver) { result = program.eval((CelVariableResolver) input); - } else if (input instanceof Map) { - @SuppressWarnings("unchecked") - Map mapInput = (Map) input; - result = program.eval(mapInput); } else { throw new CelEvaluationException( "Unsupported input type for CEL evaluation: " + input.getClass().getName()); From 5836446fad8a70db301677b13f58c15e846dbcca Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Mon, 20 Apr 2026 15:11:10 +0530 Subject: [PATCH 21/23] all overloaded methods should be placed together --- .../io/grpc/xds/internal/MatcherParser.java | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) 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 ae4a3a8a3e7..9d79a8a9c91 100644 --- a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java +++ b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java @@ -97,27 +97,7 @@ public static Matchers.StringMatcher parseStringMatcher( "Unknown StringMatcher match pattern: " + proto.getMatchPatternCase()); } } - /** Translates envoy proto FractionalPercent to internal FractionMatcher. */ - public static Matchers.FractionMatcher parseFractionMatcher( - io.envoyproxy.envoy.type.v3.FractionalPercent proto) { - int denominator; - switch (proto.getDenominator()) { - case HUNDRED: - denominator = 100; - break; - case TEN_THOUSAND: - denominator = 10_000; - break; - case MILLION: - denominator = 1_000_000; - break; - case UNRECOGNIZED: - default: - throw new IllegalArgumentException("Unknown denominator type: " + proto.getDenominator()); - } - return Matchers.FractionMatcher.create(proto.getNumerator(), denominator); - } - + /** Translate StringMatcher xDS proto to internal StringMatcher. */ public static Matchers.StringMatcher parseStringMatcher( com.github.xds.type.matcher.v3.StringMatcher proto) { @@ -149,4 +129,25 @@ private static String checkNonEmpty(String value, String name) { } return value; } + + /** Translates envoy proto FractionalPercent to internal FractionMatcher. */ + public static Matchers.FractionMatcher parseFractionMatcher( + io.envoyproxy.envoy.type.v3.FractionalPercent proto) { + int denominator; + switch (proto.getDenominator()) { + case HUNDRED: + denominator = 100; + break; + case TEN_THOUSAND: + denominator = 10_000; + break; + case MILLION: + denominator = 1_000_000; + break; + case UNRECOGNIZED: + default: + throw new IllegalArgumentException("Unknown denominator type: " + proto.getDenominator()); + } + return Matchers.FractionMatcher.create(proto.getNumerator(), denominator); + } } From f312c9c59ee20d4f267fb81c15b203d6f2b18933 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Mon, 8 Jun 2026 14:29:09 +0530 Subject: [PATCH 22/23] Restore MatchContext and CelMatcherTestHelper additions for Unified Matcher --- .../xds/internal/matcher/MatchContext.java | 34 +++++++++++++++---- .../matcher/CelMatcherTestHelper.java | 6 ++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java index a1f79bf5658..bc379b0cbb5 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java @@ -18,36 +18,52 @@ import com.google.common.base.Preconditions; import io.grpc.Metadata; +import javax.annotation.Nullable; public final class MatchContext { private final Metadata metadata; + @Nullable private final String path; + @Nullable private final String host; + @Nullable private final String method; + @Nullable + private final String id; - public MatchContext(Metadata metadata, String path, - String host, String method) { + public MatchContext(Metadata metadata, @Nullable String path, + @Nullable String host, @Nullable String method, + @Nullable String id) { this.metadata = Preconditions.checkNotNull(metadata, "metadata"); - this.path = Preconditions.checkNotNull(path, "path"); - this.host = Preconditions.checkNotNull(host, "host"); - this.method = Preconditions.checkNotNull(method, "method"); + this.path = path; + this.host = host; + this.method = method; + this.id = id; } public Metadata getMetadata() { return metadata; } + @Nullable public String getPath() { return path; } + @Nullable public String getHost() { return host; } + @Nullable public String getMethod() { return method; } + + @Nullable + public String getId() { + return id; + } public static Builder newBuilder() { return new Builder(); @@ -58,6 +74,7 @@ public static final class Builder { private String path; private String host; private String method; + private String id; public Builder setMetadata(Metadata metadata) { this.metadata = metadata; @@ -79,8 +96,13 @@ public Builder setMethod(String method) { return this; } + public Builder setId(String id) { + this.id = id; + return this; + } + public MatchContext build() { - return new MatchContext(metadata, path, host, method); + return new MatchContext(metadata, path, host, method, id); } } } diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelMatcherTestHelper.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelMatcherTestHelper.java index 0e5ef84f7d2..cb5e8a54017 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelMatcherTestHelper.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelMatcherTestHelper.java @@ -54,6 +54,12 @@ public static CelAbstractSyntaxTree compileAst(String expression) return COMPILER.compile(expression).getAst(); } + public static CelMatcher compile(String expression) + throws dev.cel.common.CelException { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + return CelMatcher.compile(ast); + } + public static CelStringExtractor compileStringExtractor(String expression) throws dev.cel.common.CelException { CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); From 0af87518b32a4e6b00be8a8347ad11c39d05122d Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Tue, 9 Jun 2026 06:35:14 +0530 Subject: [PATCH 23/23] Remove cel.compiler dependency from interop-testing to fix CI --- interop-testing/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/interop-testing/build.gradle b/interop-testing/build.gradle index 665e4b990e6..5160759460c 100644 --- a/interop-testing/build.gradle +++ b/interop-testing/build.gradle @@ -27,7 +27,6 @@ dependencies { libraries.google.auth.oauth2Http, libraries.opentelemetry.sdk.extension.autoconfigure, libraries.guava.jre // Fix checkUpperBoundDeps using -android - compileOnly libraries.cel.compiler api project(':grpc-api'), project(':grpc-stub'), project(':grpc-protobuf'),