From 4801530df3f4f4fdad60e5fd0972a302b7fbe812 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Wed, 22 Apr 2026 19:20:01 +0530 Subject: [PATCH 1/2] xds: CEL implementation --- MODULE.bazel | 3 + build.gradle | 1 + gradle/libs.versions.toml | 3 + repositories.bzl | 3 + xds/BUILD.bazel | 5 + xds/build.gradle | 11 + .../grpc/xds/internal/matcher/CelCommon.java | 125 +++++ .../internal/matcher/CelStringExtractor.java | 88 ++++ .../internal/matcher/GrpcCelEnvironment.java | 133 ++++++ .../xds/internal/matcher/HeadersWrapper.java | 129 +++++ .../xds/internal/matcher/MatchContext.java | 108 +++++ .../xds/internal/matcher/CelCommonTest.java | 113 +++++ .../internal/matcher/CelEnvironmentTest.java | 443 ++++++++++++++++++ .../matcher/CelMatcherTestHelper.java | 62 +++ .../matcher/CelStringExtractorTest.java | 178 +++++++ 15 files changed, 1405 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/CelCommonTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/CelMatcherTestHelper.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java diff --git a/MODULE.bazel b/MODULE.bazel index 9b10b1e3c36..064f224fbda 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -21,6 +21,9 @@ IO_GRPC_GRPC_JAVA_ARTIFACTS = [ "com.google.re2j:re2j:1.8", "com.google.s2a.proto.v2:s2a-proto:0.1.3", "com.google.truth:truth:1.4.5", + "dev.cel:runtime:0.12.0", + "dev.cel:protobuf:0.12.0", + "dev.cel:common:0.12.0", "com.squareup.okhttp:okhttp:2.7.5", "com.squareup.okio:okio:2.10.0", # 3.0+ needs swapping to -jvm; need work to avoid flag-day "io.netty:netty-buffer:4.1.132.Final", diff --git a/build.gradle b/build.gradle index e65261b0cc4..2fba44452d6 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,7 @@ subprojects { ignoreGradleMetadataRedirection() } } + maven { url 'https://central.sonatype.com/repository/maven-snapshots/' } } tasks.withType(JavaCompile).configureEach { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 705026a3fe3..22ea7fac76d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,9 @@ checkstyle = "com.puppycrawl.tools:checkstyle:10.26.1" # checkstyle 10.0+ requires Java 11+ # See https://checkstyle.sourceforge.io/releasenotes_old_8-35_10-26.html#Release_10.0 # checkForUpdates: checkstylejava8:9.+ +cel-runtime = "dev.cel:runtime:0.12.0" +cel-protobuf = "dev.cel:protobuf:0.12.0" +cel-compiler = "dev.cel:compiler:0.12.0" checkstylejava8 = "com.puppycrawl.tools:checkstyle:9.3" commons-math3 = "org.apache.commons:commons-math3:3.6.1" conscrypt = "org.conscrypt:conscrypt-openjdk-uber:2.5.2" diff --git a/repositories.bzl b/repositories.bzl index 0a09cece070..526bf2cb508 100644 --- a/repositories.bzl +++ b/repositories.bzl @@ -25,6 +25,9 @@ IO_GRPC_GRPC_JAVA_ARTIFACTS = [ "com.google.re2j:re2j:1.8", "com.google.s2a.proto.v2:s2a-proto:0.1.3", "com.google.truth:truth:1.4.5", + "dev.cel:runtime:0.12.0", + "dev.cel:protobuf:0.12.0", + "dev.cel:common:0.12.0", "com.squareup.okhttp:okhttp:2.7.5", "com.squareup.okio:okio:2.10.0", # 3.0+ needs swapping to -jvm; need work to avoid flag-day "io.netty:netty-buffer:4.1.132.Final", diff --git a/xds/BUILD.bazel b/xds/BUILD.bazel index 9a650485c6c..e36bd37b228 100644 --- a/xds/BUILD.bazel +++ b/xds/BUILD.bazel @@ -41,6 +41,9 @@ java_library( artifact("com.google.errorprone:error_prone_annotations"), artifact("com.google.guava:guava"), artifact("com.google.re2j:re2j"), + artifact("dev.cel:runtime"), + artifact("dev.cel:protobuf"), + artifact("dev.cel:common"), artifact("io.netty:netty-buffer"), artifact("io.netty:netty-codec"), artifact("io.netty:netty-common"), @@ -97,6 +100,8 @@ JAR_JAR_RULES = [ "rule com.google.api.expr.** io.grpc.xds.shaded.com.google.api.expr.@1", "rule com.google.security.** io.grpc.xds.shaded.com.google.security.@1", "rule dev.cel.expr.** io.grpc.xds.shaded.dev.cel.expr.@1", + "rule dev.cel.** io.grpc.xds.shaded.dev.cel.@1", + "rule cel.** io.grpc.xds.shaded.cel.@1", "rule envoy.annotations.** io.grpc.xds.shaded.envoy.annotations.@1", "rule io.envoyproxy.** io.grpc.xds.shaded.io.envoyproxy.@1", "rule udpa.annotations.** io.grpc.xds.shaded.udpa.annotations.@1", diff --git a/xds/build.gradle b/xds/build.gradle index 8394fe12f6b..8036f8691ec 100644 --- a/xds/build.gradle +++ b/xds/build.gradle @@ -56,11 +56,18 @@ dependencies { libraries.re2j, libraries.auto.value.annotations, libraries.protobuf.java.util + implementation(libraries.cel.runtime) { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } + implementation(libraries.cel.protobuf) { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } def nettyDependency = implementation project(':grpc-netty') testImplementation project(':grpc-api') testImplementation project(':grpc-rls') testImplementation project(':grpc-inprocess') + testImplementation libraries.cel.compiler testImplementation testFixtures(project(':grpc-core')), testFixtures(project(':grpc-api')), testFixtures(project(':grpc-util')) @@ -175,6 +182,7 @@ tasks.named("javadoc").configure { exclude 'io/grpc/xds/XdsNameResolverProvider.java' exclude 'io/grpc/xds/internal/**' exclude 'io/grpc/xds/Internal*' + exclude 'dev/cel/**' } def prefixName = 'io.grpc.xds' @@ -182,6 +190,7 @@ tasks.named("shadowJar").configure { archiveClassifier = null dependencies { include(project(':grpc-xds')) + include(dependency('dev.cel:.*')) } // Relocated packages commonly need exclusions in jacocoTestReport and javadoc // Keep in sync with BUILD.bazel's JAR_JAR_RULES @@ -198,6 +207,8 @@ tasks.named("shadowJar").configure { // TODO: missing java_package option in .proto relocate 'udpa.annotations', "${prefixName}.shaded.udpa.annotations" relocate 'xds.annotations', "${prefixName}.shaded.xds.annotations" + relocate 'dev.cel', "${prefixName}.shaded.dev.cel" + relocate 'cel', "${prefixName}.shaded.cel" exclude "**/*.proto" } diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java new file mode 100644 index 00000000000..31f1d3b5bd5 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java @@ -0,0 +1,125 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.google.common.collect.ImmutableSet; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOptions; +import dev.cel.common.ast.CelReference; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelRuntimeFactory; +import dev.cel.runtime.CelStandardFunctions; +import dev.cel.runtime.CelStandardFunctions.StandardFunction; +import dev.cel.runtime.standard.AddOperator.AddOverload; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Shared utilities for CEL-based matchers and extractors. + */ +final class CelCommon { + private static final CelOptions CEL_OPTIONS = CelOptions.newBuilder() + .enableComprehension(false) + .maxRegexProgramSize(100) + .build(); + private static final String REQUEST_VARIABLE = "request"; + private static final CelStandardFunctions FUNCTIONS = + CelStandardFunctions.newBuilder() + .filterFunctions((func, over) -> { + if (func == StandardFunction.STRING) { + return false; + } + if (func == StandardFunction.ADD) { + return !over.equals(AddOverload.ADD_STRING) + && !over.equals(AddOverload.ADD_LIST); + } + return true; + }) + .build(); + + + + private static final ImmutableSet ALLOWED_EXACT_OVERLOAD_IDS = ImmutableSet.of( + "equals", "not_equals", "logical_and", "logical_or", "logical_not"); + + /** + * Regular expression pattern to validate internal CEL overload IDs. + * + *

+ * Standard CEL operators and conversion functions often have empty names in the + * AST and are identified solely by their overload IDs (e.g., {@code equals} for + * {@code ==}, {@code divide_int64} for {@code /}). + * + *

+ * This pattern matches allowed overload IDs by their prefixes (e.g., + * {@code divide}, {@code size}), optionally followed by numeric types + * (e.g., {@code int64}) and type-specific suffixes (e.g., {@code _string}, + * {@code _int64}). + */ + private static final Pattern ALLOWED_OVERLOAD_ID_PREFIX_PATTERN = Pattern.compile( + "^(size|matches|contains|startsWith|endsWith|starts_with|ends_with|" + + "timestamp|duration|in|index|has|int|uint|double|string|bytes|bool|" + + "less|less_equals|greater|greater_equals|" + + "add|subtract|multiply|divide|modulo|negate)" + + "[0-9]*(_.*)?$"); + + static final CelRuntime RUNTIME = CelRuntimeFactory.standardCelRuntimeBuilder() + .setStandardEnvironmentEnabled(false) + .setStandardFunctions(FUNCTIONS) + .setOptions(CEL_OPTIONS) + .build(); + + private CelCommon() {} + + /** + * Validates that the AST only references the allowed variable ("request") + * and supported functions as defined in gRFC A106. + */ + static void checkAllowedReferences(CelAbstractSyntaxTree ast) { + for (Map.Entry entry : ast.getReferenceMap().entrySet()) { + CelReference ref = entry.getValue(); + + // Check for variables (where overloadIds is empty) + if (!ref.value().isPresent() && ref.overloadIds().isEmpty()) { + if (!REQUEST_VARIABLE.equals(ref.name())) { + throw new IllegalArgumentException( + "CEL expression references unknown variable: " + ref.name()); + } + } else if (!ref.overloadIds().isEmpty()) { + String name = ref.name(); + if (name.isEmpty()) { + boolean allowed = false; + for (String id : ref.overloadIds()) { + if (ALLOWED_EXACT_OVERLOAD_IDS.contains(id) + || ALLOWED_OVERLOAD_ID_PREFIX_PATTERN.matcher(id).matches()) { + allowed = true; + break; + } + } + if (!allowed) { + throw new IllegalArgumentException( + "CEL expression references unknown function with overload IDs: " + + ref.overloadIds()); + } + } else { + throw new IllegalArgumentException( + "CEL expression references unsupported named function: " + name); + } + } + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java new file mode 100644 index 00000000000..04d686ee47c --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.types.SimpleType; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelVariableResolver; +import javax.annotation.Nullable; + +/** + * Executes compiled CEL expressions that extract a string. + */ +public final class CelStringExtractor { + private final CelRuntime.Program program; + @Nullable + private final String defaultValue; + + private CelStringExtractor(CelRuntime.Program program, @Nullable String defaultValue) { + this.program = program; + this.defaultValue = defaultValue; + } + + /** + * Compiles the AST into a CelStringExtractor with an optional default value. + * Throws an Exception if evaluation fails during compilation setup. + */ + public static CelStringExtractor compile(CelAbstractSyntaxTree ast, @Nullable String defaultValue) + throws CelEvaluationException { + if (ast.getResultType() != SimpleType.STRING && ast.getResultType() != SimpleType.DYN) { + throw new IllegalArgumentException( + "CEL expression must evaluate to string, got: " + ast.getResultType()); + } + CelCommon.checkAllowedReferences(ast); + CelRuntime.Program program = CelCommon.RUNTIME.createProgram(ast); + return new CelStringExtractor(program, defaultValue); + } + + /** + * Compiles the AST into a CelStringExtractor with no default value. + * Throws an Exception if evaluation fails during compilation setup. + */ + public static CelStringExtractor compile(CelAbstractSyntaxTree ast) + throws CelEvaluationException { + return compile(ast, null); + } + + /** + * Evaluates the CEL expression and returns the string result. + * Returns the default value if the result is not a string or if evaluation + * fails. + */ + public String extract(Object input) throws CelEvaluationException { + if (input instanceof CelVariableResolver) { + try { + Object result = program.eval((CelVariableResolver) input); + + if (result instanceof String) { + return (String) result; + } + } catch (CelEvaluationException e) { + if (defaultValue == null) { + throw e; + } + } + } else if (defaultValue == null) { + throw new CelEvaluationException( + "Unsupported input type for CEL evaluation: " + + (input == null ? "null" : input.getClass().getName())); + } + return defaultValue; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java new file mode 100644 index 00000000000..71a0893eb3c --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java @@ -0,0 +1,133 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.google.common.collect.ImmutableSet; +import dev.cel.runtime.CelVariableResolver; +import io.grpc.Metadata; +import java.util.AbstractMap; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Nullable; + +/** + * CEL Environment for gRPC xDS matching. + */ +final class GrpcCelEnvironment implements CelVariableResolver { + private final MatchContext context; + + GrpcCelEnvironment(MatchContext context) { + this.context = context; + } + + @Override + public Optional find(String name) { + if (name.equals("request")) { + return Optional.of(new LazyRequestMap(this)); + } + return Optional.empty(); + } + + @Nullable + private Object getRequestField(String requestField) { + switch (requestField) { + case "headers": return new HeadersWrapper(context); + case "host": return orEmpty(context.getHost()); + case "id": return orEmpty(context.getId()); + case "method": return or(context.getMethod(), "POST"); + case "path": + case "url_path": + return orEmpty(context.getPath()); + case "query": return ""; + case "scheme": return ""; + case "protocol": return ""; + case "time": return null; + case "referer": return getHeader("referer"); + case "useragent": return getHeader("user-agent"); + + default: + return null; + } + } + + private String getHeader(String key) { + Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll( + Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)); + if (values == null) { + return ""; + } + return String.join(",", values); + } + + private static String orEmpty(@Nullable String s) { + return s == null ? "" : s; + } + + private static String or(@Nullable String s, String def) { + return s == null ? def : s; + } + + private static final class LazyRequestMap extends AbstractMap { + private static final Set KNOWN_KEYS = ImmutableSet.of( + "headers", "host", "id", "method", "path", "url_path", "query", "scheme", "protocol", + "referer", "useragent", "time"); + private final GrpcCelEnvironment resolver; + + LazyRequestMap(GrpcCelEnvironment resolver) { + this.resolver = resolver; + } + + @Override + public Object get(Object key) { + if (key instanceof String) { + Object val = resolver.getRequestField((String) key); + if (val == null) { + return null; + } + return val; + } + return null; + } + + @Override + public boolean containsKey(Object key) { + boolean contains = key instanceof String && KNOWN_KEYS.contains(key); + return contains; + } + + @Override + public Set keySet() { + return KNOWN_KEYS; + } + + @Override + public int size() { + return KNOWN_KEYS.size(); + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public Set> entrySet() { + throw new UnsupportedOperationException("LazyRequestMap does not support entrySet"); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java new file mode 100644 index 00000000000..7b7db6d7e63 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.google.common.collect.ImmutableSet; +import com.google.common.io.BaseEncoding; +import com.google.errorprone.annotations.DoNotCall; +import io.grpc.Metadata; +import java.util.AbstractMap; +import java.util.Set; +import javax.annotation.Nullable; + +/** + * A Map view over Metadata and MatchContext for CEL attribute resolution. + * Supports efficient lookup of headers and pseudo-headers without unnecessary copying. + */ +final class HeadersWrapper extends AbstractMap { + private static final ImmutableSet PSEUDO_HEADERS = + ImmutableSet.of(":method", ":authority", ":path"); + private final MatchContext context; + + HeadersWrapper(MatchContext context) { + this.context = context; + } + + @Override + @Nullable + public String get(Object key) { + if (!(key instanceof String)) { + return null; + } + String headerName = ((String) key).toLowerCase(java.util.Locale.ROOT); + if ("te".equals(headerName)) { + return null; + } + switch (headerName) { + case ":method": return context.getMethod(); + case ":authority": return context.getHost(); + case "host": return context.getHost(); + case ":path": return context.getPath(); + default: return getHeader(headerName); + } + } + + @Nullable + private String getHeader(String headerName) { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Iterable values = context.getMetadata().getAll( + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER)); + if (values == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (byte[] value : values) { + if (!first) { + sb.append(","); + } + first = false; + sb.append(BaseEncoding.base64().encode(value)); + } + return sb.toString(); + } + Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER)); + if (values == null) { + return null; + } + return String.join(",", values); + } + + @Override + public boolean containsKey(Object key) { + if (!(key instanceof String)) { + return false; + } + String headerName = ((String) key).toLowerCase(java.util.Locale.ROOT); + if ("te".equals(headerName)) { + return false; + } + if (PSEUDO_HEADERS.contains(headerName)) { + return true; + } + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + return context.getMetadata().containsKey( + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER)); + } + return context.getMetadata().containsKey( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER)); + } + + @Override + public Set keySet() { + return ImmutableSet.builder() + .addAll(context.getMetadata().keys()) + .addAll(PSEUDO_HEADERS) + .build(); + } + + @Override + public int size() { + // Metadata.keys() returns a Set of unique keys, so we can just add the sizes. + // Note: This counts the number of unique header names, which is consistent with + // keySet().size(). + return context.getMetadata().keys().size() + PSEUDO_HEADERS.size(); + } + + @Override + @DoNotCall("Always throws UnsupportedOperationException") + public Set> entrySet() { + throw new UnsupportedOperationException( + "Should not be called to prevent resolving all header values."); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java new file mode 100644 index 00000000000..bc379b0cbb5 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java @@ -0,0 +1,108 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.google.common.base.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, @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/test/java/io/grpc/xds/internal/matcher/CelCommonTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelCommonTest.java new file mode 100644 index 00000000000..25f6556f212 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelCommonTest.java @@ -0,0 +1,113 @@ +/* + * 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 org.junit.Assert.fail; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelCommonTest { + private static CelCompiler COMPILER; + + @BeforeClass + public static void setupCompiler() { + COMPILER = CelCompilerFactory.standardCelCompilerBuilder() + .addVar("request", SimpleType.DYN) + .addVar("unknown_var", SimpleType.STRING) + .build(); + } + + private void assertAllowed(String expression) throws Exception { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + CelCommon.checkAllowedReferences(ast); + } + + private void assertDisallowed(String expression) throws Exception { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + try { + CelCommon.checkAllowedReferences(ast); + fail("Should have thrown IllegalArgumentException for expression: " + expression); + } catch (IllegalArgumentException e) { + // Expected + } + } + + @Test + public void checkAllowedReferences_variables() throws Exception { + assertAllowed("request == 'foo'"); + assertDisallowed("unknown_var == 'foo'"); + } + + @Test + public void checkAllowedReferences_operators() throws Exception { + assertAllowed("request == 'foo'"); + assertAllowed("request != 'foo'"); + assertAllowed("1 < 2"); + assertAllowed("1 <= 2"); + assertAllowed("1 > 2"); + assertAllowed("1 >= 2"); + assertAllowed("1 + 2 == 3"); + assertAllowed("1 - 2 == -1"); + assertAllowed("1 * 2 == 2"); + assertAllowed("1 / 2 == 0"); + assertAllowed("1 % 2 == 1"); + assertAllowed("true && false == false"); + assertAllowed("true || false == true"); + assertAllowed("!true == false"); + } + + @Test + public void checkAllowedReferences_indexing() throws Exception { + assertAllowed("request['key'] == 'val'"); + } + + @Test + public void checkAllowedReferences_functions() throws Exception { + assertAllowed("size('foo') == 3"); + assertAllowed("'foo'.matches('.*')"); + assertAllowed("'foo'.contains('o')"); + assertAllowed("'foo'.startsWith('f')"); + assertAllowed("'foo'.endsWith('o')"); + assertAllowed("int(1) == 1"); + assertAllowed("uint(1) == 1u"); + assertAllowed("double(1) == 1.0"); + assertAllowed("string(1) == '1'"); + } + + @Test + public void checkAllowedReferences_additionalFunctions() throws Exception { + assertAllowed("timestamp('2026-04-20T00:00:00Z') != timestamp('2026-04-21T00:00:00Z')"); + assertAllowed("duration('1s') != duration('2s')"); + assertAllowed("'foo' in ['foo', 'bar']"); + assertAllowed("bytes('foo') == b'foo'"); + assertAllowed("bool('true') == true"); + } + + @Test + public void checkAllowedReferences_negation() throws Exception { + assertAllowed("-(1) == -1"); + assertAllowed("-(1.0) == -1.0"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java new file mode 100644 index 00000000000..261c0bba0dc --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -0,0 +1,443 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.google.common.io.BaseEncoding; +import dev.cel.common.CelValidationException; +import io.grpc.Metadata; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelEnvironmentTest { + + @Test + public void headersWrapper_resolvesPseudoHeaders() { + MatchContext context = MatchContext.newBuilder() + .setPath("/path") + .setMethod("POST") + .setHost("example.com") + .build(); + + Map headers = new HeadersWrapper(context); + + assertThat(headers.get(":path")).isEqualTo("/path"); + assertThat(headers.get(":method")).isEqualTo("POST"); + assertThat(headers.get(":authority")).isEqualTo("example.com"); + } + + @Test + public void headersWrapper_resolvesStandardHeaders() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("custom-key", Metadata.ASCII_STRING_MARSHALLER), "custom-val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + Map headers = new HeadersWrapper(context); + + assertThat(headers.get("custom-key")).isEqualTo("custom-val"); + assertThat(headers.containsKey("custom-key")).isTrue(); + } + + @Test + @SuppressWarnings("DoNotCall") + public void headersWrapper_entrySet_unsupported() { + MatchContext context = MatchContext.newBuilder().build(); + Map headers = new HeadersWrapper(context); + + try { + headers.entrySet(); + fail("Should throw UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + assertThat(e).hasMessageThat().contains("Should not be called"); + } + } + + @Test + public void celEnvironment_resolvesRequestField() { + MatchContext context = MatchContext.newBuilder() + .setPath("/foo") + .build(); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isInstanceOf(Map.class); + + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("path")).isEqualTo("/foo"); + } + + @Test + public void headers_caseInsensitive() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("User-Agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-java"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + Map headers = new HeadersWrapper(context); + + // CEL lookup with different casing + assertThat(headers.get("user-agent")).isEqualTo("grpc-java"); + assertThat(headers.get("USER-AGENT")).isEqualTo("grpc-java"); + assertThat(headers.containsKey("User-Agent")).isTrue(); + assertThat(headers.containsKey("user-agent")).isTrue(); + } + + @Test + public void headers_ignoreTe() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("te", Metadata.ASCII_STRING_MARSHALLER), "trailers"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + Map headers = new HeadersWrapper(context); + + // "te" should be hidden + assertThat(headers.get("te")).isNull(); + assertThat(headers.containsKey("te")).isFalse(); + // Case insensitive check for "TE" logic too + assertThat(headers.get("TE")).isNull(); + assertThat(headers.containsKey("TE")).isFalse(); + } + + @Test + public void headers_hostAliasing() { + MatchContext context = MatchContext.newBuilder() + .setHost("example.com") + .build(); + Map headers = new HeadersWrapper(context); + + assertThat(headers.get("host")).isEqualTo("example.com"); + assertThat(headers.get("HOST")).isEqualTo("example.com"); + assertThat(headers.get(":authority")).isEqualTo("example.com"); + } + + @Test + public void headers_binaryHeader() { + Metadata metadata = new Metadata(); + byte[] bytes = new byte[] { 0, 1, 2, 3 }; + metadata.put(Metadata.Key.of("test-bin", Metadata.BINARY_BYTE_MARSHALLER), bytes); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + Map headers = new HeadersWrapper(context); + + // Expect Base64 encoded string + String expected = BaseEncoding.base64().encode(bytes); + assertThat(headers.get("test-bin")).isEqualTo(expected); + assertThat(headers.containsKey("test-bin")).isTrue(); + } + + @Test + public void celEnvironment_disabledFeatures_throwsValidationException() { + // String concatenation + try { + CelMatcherTestHelper.compileAst("'a' + 'b'"); + Assert.fail("String concatenation should be disabled"); + } catch (CelValidationException e) { + assertThat(e).hasMessageThat().contains("found no matching overload for '_+_'"); + } + + // List concatenation + try { + CelMatcherTestHelper.compileAst("[1] + [2]"); + Assert.fail("List concatenation should be disabled"); + } catch (CelValidationException e) { + assertThat(e).hasMessageThat().contains("found no matching overload for '_+_'"); + } + + // String conversion + try { + CelMatcherTestHelper.compileAst("string(1)"); + Assert.fail("String conversion should be disabled"); + } catch (CelValidationException e) { + assertThat(e).hasMessageThat().contains("undeclared reference to 'string'"); + } + + // Comprehensions + try { + CelMatcherTestHelper.compileAst("[1, 2, 3].all(x, x > 0)"); + Assert.fail("Comprehensions should be disabled"); + } catch (CelValidationException e) { + assertThat(e).hasMessageThat().contains("undeclared reference to 'all'"); + } + } + + @Test + public void celEnvironment_method_fallback() { + MatchContext context = MatchContext.newBuilder().build(); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("method")).isEqualTo("POST"); + } + + @Test + public void celEnvironment_resolvesLazyRequestMap() { + MatchContext context = MatchContext.newBuilder().build(); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isInstanceOf(Map.class); + + Map map = (Map) result.get(); + assertThat(map.containsKey("path")).isTrue(); + assertThat(map.size()).isAtLeast(1); + + try { + map.entrySet(); + fail("Should throw UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected + } + } + + @Test + public void celEnvironment_timeField_supportedButNull() { + MatchContext context = MatchContext.newBuilder().build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.containsKey("time")).isTrue(); + assertThat(requestMap.get("time")).isNull(); + } + + + @Test + public void headersWrapper_size() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("k1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + metadata.put(Metadata.Key.of("k2", Metadata.ASCII_STRING_MARSHALLER), "v2"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + HeadersWrapper headers = new HeadersWrapper(context); + + // 2 custom headers + 3 pseudo headers (:method, :authority, :path) = 5 + assertThat(headers.size()).isEqualTo(5); + } + + @Test + public void celEnvironment_accessAllFields() { + MatchContext context = MatchContext.newBuilder() + .setHost("host") + .setId("id") + .setMethod("GET") + .build(); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + + assertThat(requestMap.get("host")).isEqualTo("host"); + assertThat(requestMap.get("id")).isEqualTo("id"); + assertThat(requestMap.get("method")).isEqualTo("GET"); + assertThat(requestMap.get("scheme")).isEqualTo(""); + assertThat(requestMap.get("protocol")).isEqualTo(""); + assertThat(requestMap.get("query")).isEqualTo(""); + assertThat(requestMap.get("headers")).isInstanceOf(HeadersWrapper.class); + } + + @Test + public void celEnvironment_find_unknownField() { + MatchContext context = MatchContext.newBuilder().build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("unknown")).isNull(); + + assertThat(env.find("other").isPresent()).isFalse(); + } + + @Test + public void lazyRequestMap_unknownKey_returnsNull() { + MatchContext context = MatchContext.newBuilder().build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + Map map = (Map) env.find("request").get(); + + assertThat(map.get("unknown")).isNull(); + assertThat(map.get(new Object())).isNull(); + assertThat(map.containsKey(new Object())).isFalse(); + } + + @Test + public void headersWrapper_get_nonStringKey_returnsNull() { + MatchContext context = MatchContext.newBuilder().build(); + Map headers = new HeadersWrapper(context); + assertThat(headers.get(new Object())).isNull(); + } + + @Test + public void headersWrapper_getHeader_binary_multipleValues() { + Metadata metadata = new Metadata(); + byte[] val1 = new byte[] { 1, 2, 3 }; + byte[] val2 = new byte[] { 4, 5, 6 }; + Metadata.Key key = Metadata.Key.of("bin-header-bin", Metadata.BINARY_BYTE_MARSHALLER); + metadata.put(key, val1); + metadata.put(key, val2); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + Map headers = new HeadersWrapper(context); + String expected = com.google.common.io.BaseEncoding.base64().encode(val1) + "," + + com.google.common.io.BaseEncoding.base64().encode(val2); + assertThat(headers.get("bin-header-bin")).isEqualTo(expected); + } + + @Test + public void headersWrapper_containsKey_nonStringKey_returnsFalse() { + MatchContext context = MatchContext.newBuilder().build(); + Map headers = new HeadersWrapper(context); + assertThat(headers.containsKey(new Object())).isFalse(); + } + + @Test + public void headersWrapper_containsKey_pseudoHeader_returnsTrue() { + MatchContext context = MatchContext.newBuilder().build(); + Map headers = new HeadersWrapper(context); + assertThat(headers.containsKey(":method")).isTrue(); + assertThat(headers.containsKey(":path")).isTrue(); + assertThat(headers.containsKey(":authority")).isTrue(); + } + + @Test + public void headersWrapper_keySet_containsExpectedKeys() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("custom-key", Metadata.ASCII_STRING_MARSHALLER), "val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + Map headers = new HeadersWrapper(context); + Set keys = headers.keySet(); + + assertThat(keys).containsAtLeast("custom-key", ":method", ":path", ":authority"); + } + + @Test + public void headersWrapper_getHeader_missingBinaryHeader_returnsNull() { + MatchContext context = MatchContext.newBuilder().build(); + Map headers = new HeadersWrapper(context); + assertThat(headers.get("missing-bin")).isNull(); + } + + @Test + public void celEnvironment_resolvesRefererAndUserAgent() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("referer", Metadata.ASCII_STRING_MARSHALLER), "http://example.com"); + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-test"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("referer")).isEqualTo("http://example.com"); + assertThat(requestMap.get("useragent")).isEqualTo("grpc-test"); + } + + @Test + public void celEnvironment_joinsMultipleHeaderValues() { + Metadata metadata = new Metadata(); + Metadata.Key key = Metadata.Key.of("referer", Metadata.ASCII_STRING_MARSHALLER); + metadata.put(key, "v1"); + metadata.put(key, "v2"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("referer")).isEqualTo("v1,v2"); + } + + @Test + public void celEnvironment_find_invalidFormat() { + MatchContext context = MatchContext.newBuilder().build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + assertThat(env.find("other.path").isPresent()).isFalse(); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("a")).isNull(); + } + + @Test + public void lazyRequestMap_additionalMethods() { + MatchContext context = MatchContext.newBuilder().build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + Map map = (Map) env.find("request").get(); + + assertThat(map.isEmpty()).isFalse(); + assertThat(map.get("time")).isNull(); + } + + @Test + public void celEnvironment_missingHeader_returnsEmptyString() { + MatchContext context = MatchContext.newBuilder().build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("referer")).isEqualTo(""); + } + + + +} 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 new file mode 100644 index 00000000000..0e5ef84f7d2 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelMatcherTestHelper.java @@ -0,0 +1,62 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOptions; +import dev.cel.common.CelValidationException; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; + +public final class CelMatcherTestHelper { + private static final dev.cel.checker.CelStandardDeclarations DECLARATIONS = + dev.cel.checker.CelStandardDeclarations.newBuilder() + .filterFunctions((func, over) -> { + if (func == dev.cel.checker.CelStandardDeclarations.StandardFunction.STRING) { + return false; + } + if (func == dev.cel.checker.CelStandardDeclarations.StandardFunction.ADD) { + String id = over.celOverloadDecl().overloadId(); + return !id.equals("add_string") && !id.equals("add_list"); + } + return true; + }) + .build(); + + private static final CelCompiler COMPILER = CelCompilerFactory.standardCelCompilerBuilder() + .setStandardEnvironmentEnabled(false) + .setStandardDeclarations(DECLARATIONS) + .addVar("request", SimpleType.DYN) + .setOptions(CelOptions.newBuilder() + .enableComprehension(false) + .build()) + .build(); + + private CelMatcherTestHelper() {} + + public static CelAbstractSyntaxTree compileAst(String expression) + throws CelValidationException { + return COMPILER.compile(expression).getAst(); + } + + public static CelStringExtractor compileStringExtractor(String expression) + throws dev.cel.common.CelException { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + return CelStringExtractor.compile(ast); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java new file mode 100644 index 00000000000..0a546cfaddc --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java @@ -0,0 +1,178 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelVariableResolver; +import java.util.Collections; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelStringExtractorTest { + + @Test + public void extract_simpleString() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("'foo'"); + CelVariableResolver resolver = name -> Optional.empty(); + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("foo"); + } + + @Test + public void extract_resolvesVariable() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("request['key']"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of(Collections.singletonMap("key", "value")); + } + return Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("value"); + } + + @Test + public void extract_nonStringResult_returnsNull() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("request"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of(123); + } + return Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isNull(); + } + + @Test + public void extract_evaluationError_throws() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("request.bad"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of("foo"); + } + return Optional.empty(); + }; + + try { + extractor.extract(resolver); + fail("Should throw CelEvaluationException"); + } catch (CelEvaluationException e) { + // Expected + } + } + + @Test + public void extract_nonStringResult_returnsDefaultValue() throws Exception { + CelAbstractSyntaxTree ast = CelMatcherTestHelper.compileAst("request"); + CelStringExtractor extractor = CelStringExtractor.compile(ast, "default_val"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of(123); + } + return java.util.Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void extract_evaluationError_returnsDefaultValue() throws Exception { + CelAbstractSyntaxTree ast = CelMatcherTestHelper.compileAst("request.bad"); + CelStringExtractor extractor = CelStringExtractor.compile(ast, "default_val"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of("foo"); + } + return java.util.Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void extract_withCelVariableResolver_resolvesVariable() throws Exception { + CelAbstractSyntaxTree ast = CelMatcherTestHelper.compileAst("request['key']"); + CelStringExtractor extractor = CelStringExtractor.compile(ast, "default_val"); + + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of(Collections.singletonMap("key", "value")); + } + return java.util.Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("value"); + } + + @Test + public void extract_withCelVariableResolver_evalError_returnsDefaultValue() throws Exception { + CelAbstractSyntaxTree ast = CelMatcherTestHelper.compileAst("request.bad"); + CelStringExtractor extractor = CelStringExtractor.compile(ast, "default_val"); + + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of("foo"); + } + return java.util.Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void compile_invalidSyntax_throws() { + try { + CelMatcherTestHelper.compileStringExtractor("invalid syntax ???"); + fail("Should throw CelValidationException"); + } catch (Exception e) { + // Expected + } + } + + @Test + public void extract_withCelVariableResolver() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("'val'"); + CelVariableResolver resolver = name -> Optional.empty(); + + assertThat(extractor.extract(resolver)).isEqualTo("val"); + } + + @Test + public void extract_unsupportedInputType_throws() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("'foo'"); + try { + extractor.extract("not-a-map"); + fail("Should have thrown CelEvaluationException"); + } catch (CelEvaluationException e) { + assertThat(e).hasMessageThat().contains("Unsupported input type"); + } + } +} From 8b8cbb0a0ca770745dc7dad0b1dc333e83882da5 Mon Sep 17 00:00:00 2001 From: MV Shiva Prasad Date: Thu, 23 Apr 2026 15:31:47 +0530 Subject: [PATCH 2/2] use registry provider for attributes --- .../internal/matcher/AttributeProvider.java | 32 +++ .../matcher/AttributeProviderRegistry.java | 240 ++++++++++++++++++ .../internal/matcher/GrpcCelEnvironment.java | 64 +---- .../xds/internal/matcher/HeadersWrapper.java | 8 +- .../xds/internal/matcher/MatchContext.java | 77 ++---- .../internal/matcher/CelEnvironmentTest.java | 43 +++- 6 files changed, 348 insertions(+), 116 deletions(-) create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/AttributeProvider.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/matcher/AttributeProviderRegistry.java diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/AttributeProvider.java b/xds/src/main/java/io/grpc/xds/internal/matcher/AttributeProvider.java new file mode 100644 index 00000000000..668e6be7e23 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/AttributeProvider.java @@ -0,0 +1,32 @@ +/* + * 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 for resolving attributes from {@link MatchContext}. + */ +public interface AttributeProvider { + /** + * Returns the names of the attributes this provider handles. + */ + Iterable names(); + + /** + * Resolves the attribute value from the context. + */ + Object get(MatchContext context); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/AttributeProviderRegistry.java b/xds/src/main/java/io/grpc/xds/internal/matcher/AttributeProviderRegistry.java new file mode 100644 index 00000000000..eade5921dc4 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/AttributeProviderRegistry.java @@ -0,0 +1,240 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.matcher; + +import com.google.common.collect.ImmutableSet; +import io.grpc.Metadata; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.Nullable; + +/** + * Registry for {@link AttributeProvider}s. + */ +public final class AttributeProviderRegistry { + private static final AttributeProviderRegistry DEFAULT_INSTANCE = new AttributeProviderRegistry(); + + private final Map providers = new ConcurrentHashMap<>(); + + private AttributeProviderRegistry() { + register(new HeadersProvider()); + register(new HostProvider()); + register(new IdProvider()); + register(new MethodProvider()); + register(new PathProvider()); + register(new QueryProvider()); + register(new SchemeProvider()); + register(new ProtocolProvider()); + register(new TimeProvider()); + register(new RefererProvider()); + register(new UserAgentProvider()); + } + + public static AttributeProviderRegistry getDefaultRegistry() { + return DEFAULT_INSTANCE; + } + + public void register(AttributeProvider provider) { + for (String name : provider.names()) { + providers.put(name, provider); + } + } + + @Nullable + public AttributeProvider get(String name) { + return providers.get(name); + } + + public Set getRegisteredNames() { + return Collections.unmodifiableSet(providers.keySet()); + } + + private static String orEmpty(@Nullable Object s) { + return s == null ? "" : s.toString(); + } + + private static String or(@Nullable Object s, String def) { + return s == null ? def : s.toString(); + } + + private static String getHeader(MatchContext context, String key) { + Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll( + Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)); + if (values == null) { + return ""; + } + return String.join(",", values); + } + + private static final class HeadersProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("headers"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return new HeadersWrapper(context); + } + } + + private static final class HostProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("host"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return orEmpty(context.getRawAttribute("host")); + } + } + + private static final class IdProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("id"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return orEmpty(context.getRawAttribute("id")); + } + } + + private static final class MethodProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("method"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return or(context.getRawAttribute("method"), "POST"); + } + } + + private static final class PathProvider implements AttributeProvider { + private static final Set NAMES = ImmutableSet.of("path", "url_path"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return orEmpty(context.getRawAttribute("path")); + } + } + + private static final class QueryProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("query"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return ""; + } + } + + private static final class SchemeProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("scheme"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return ""; + } + } + + private static final class ProtocolProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("protocol"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return ""; + } + } + + private static final class TimeProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("time"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + @Nullable + public Object get(MatchContext context) { + return null; + } + } + + private static final class RefererProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("referer"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return getHeader(context, "referer"); + } + } + + private static final class UserAgentProvider implements AttributeProvider { + private static final Set NAMES = Collections.singleton("useragent"); + + @Override + public Iterable names() { + return NAMES; + } + + @Override + public Object get(MatchContext context) { + return getHeader(context, "user-agent"); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java index 71a0893eb3c..3d0530e210b 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java @@ -18,7 +18,6 @@ import com.google.common.collect.ImmutableSet; import dev.cel.runtime.CelVariableResolver; -import io.grpc.Metadata; import java.util.AbstractMap; import java.util.Optional; import java.util.Set; @@ -44,48 +43,10 @@ public Optional find(String name) { @Nullable private Object getRequestField(String requestField) { - switch (requestField) { - case "headers": return new HeadersWrapper(context); - case "host": return orEmpty(context.getHost()); - case "id": return orEmpty(context.getId()); - case "method": return or(context.getMethod(), "POST"); - case "path": - case "url_path": - return orEmpty(context.getPath()); - case "query": return ""; - case "scheme": return ""; - case "protocol": return ""; - case "time": return null; - case "referer": return getHeader("referer"); - case "useragent": return getHeader("user-agent"); - - default: - return null; - } - } - - private String getHeader(String key) { - Metadata metadata = context.getMetadata(); - Iterable values = metadata.getAll( - Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)); - if (values == null) { - return ""; - } - return String.join(",", values); - } - - private static String orEmpty(@Nullable String s) { - return s == null ? "" : s; - } - - private static String or(@Nullable String s, String def) { - return s == null ? def : s; + return context.getAttribute(requestField); } private static final class LazyRequestMap extends AbstractMap { - private static final Set KNOWN_KEYS = ImmutableSet.of( - "headers", "host", "id", "method", "path", "url_path", "query", "scheme", "protocol", - "referer", "useragent", "time"); private final GrpcCelEnvironment resolver; LazyRequestMap(GrpcCelEnvironment resolver) { @@ -95,34 +56,37 @@ private static final class LazyRequestMap extends AbstractMap { @Override public Object get(Object key) { if (key instanceof String) { - Object val = resolver.getRequestField((String) key); - if (val == null) { - return null; - } - return val; + return resolver.getRequestField((String) key); } return null; } @Override public boolean containsKey(Object key) { - boolean contains = key instanceof String && KNOWN_KEYS.contains(key); - return contains; + if (!(key instanceof String)) { + return false; + } + String name = (String) key; + return AttributeProviderRegistry.getDefaultRegistry().get(name) != null + || resolver.context.getRawAttribute(name) != null; } @Override public Set keySet() { - return KNOWN_KEYS; + ImmutableSet.Builder builder = ImmutableSet.builder(); + builder.addAll(AttributeProviderRegistry.getDefaultRegistry().getRegisteredNames()); + builder.addAll(resolver.context.getRawAttributes().keySet()); + return builder.build(); } @Override public int size() { - return KNOWN_KEYS.size(); + return keySet().size(); } @Override public boolean isEmpty() { - return false; + return keySet().isEmpty(); } @Override diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java index 7b7db6d7e63..a8efe6b6480 100644 --- a/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java @@ -48,10 +48,10 @@ public String get(Object key) { return null; } switch (headerName) { - case ":method": return context.getMethod(); - case ":authority": return context.getHost(); - case "host": return context.getHost(); - case ":path": return context.getPath(); + case ":method": return (String) context.getAttribute("method"); + case ":authority": return (String) context.getAttribute("host"); + case "host": return (String) context.getAttribute("host"); + case ":path": return (String) context.getAttribute("path"); default: return getHeader(headerName); } } 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 bc379b0cbb5..bb17677cdf3 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,51 +18,40 @@ import com.google.common.base.Preconditions; import io.grpc.Metadata; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; 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; + private final Map attributes; - public MatchContext(Metadata metadata, @Nullable String path, - @Nullable String host, @Nullable String method, - @Nullable String id) { + private MatchContext(Metadata metadata, Map attributes) { this.metadata = Preconditions.checkNotNull(metadata, "metadata"); - this.path = path; - this.host = host; - this.method = method; - this.id = id; + this.attributes = Collections.unmodifiableMap(new HashMap<>(attributes)); } public Metadata getMetadata() { return metadata; } - - @Nullable - public String getPath() { - return path; - } - + @Nullable - public String getHost() { - return host; + public Object getAttribute(String name) { + AttributeProvider provider = AttributeProviderRegistry.getDefaultRegistry().get(name); + if (provider != null) { + return provider.get(this); + } + return attributes.get(name); } - + @Nullable - public String getMethod() { - return method; + public Object getRawAttribute(String name) { + return attributes.get(name); } - - @Nullable - public String getId() { - return id; + + public Map getRawAttributes() { + return attributes; } public static Builder newBuilder() { @@ -71,38 +60,20 @@ public static Builder newBuilder() { public static final class Builder { private Metadata metadata = new Metadata(); - private String path; - private String host; - private String method; - private String id; + private final Map attributes = new HashMap<>(); 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; + this.metadata = Preconditions.checkNotNull(metadata, "metadata"); return this; } - public Builder setId(String id) { - this.id = id; + public Builder setAttribute(String name, Object value) { + attributes.put(name, value); return this; } public MatchContext build() { - return new MatchContext(metadata, path, host, method, id); + return new MatchContext(metadata, attributes); } } } diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java index 261c0bba0dc..8a563f41ad4 100644 --- a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -36,9 +36,9 @@ public final class CelEnvironmentTest { @Test public void headersWrapper_resolvesPseudoHeaders() { MatchContext context = MatchContext.newBuilder() - .setPath("/path") - .setMethod("POST") - .setHost("example.com") + .setAttribute("path", "/path") + .setAttribute("method", "POST") + .setAttribute("host", "example.com") .build(); Map headers = new HeadersWrapper(context); @@ -79,7 +79,7 @@ public void headersWrapper_entrySet_unsupported() { @Test public void celEnvironment_resolvesRequestField() { MatchContext context = MatchContext.newBuilder() - .setPath("/foo") + .setAttribute("path", "/foo") .build(); GrpcCelEnvironment env = new GrpcCelEnvironment(context); @@ -131,7 +131,7 @@ public void headers_ignoreTe() { @Test public void headers_hostAliasing() { MatchContext context = MatchContext.newBuilder() - .setHost("example.com") + .setAttribute("host", "example.com") .build(); Map headers = new HeadersWrapper(context); @@ -259,9 +259,9 @@ public void headersWrapper_size() { @Test public void celEnvironment_accessAllFields() { MatchContext context = MatchContext.newBuilder() - .setHost("host") - .setId("id") - .setMethod("GET") + .setAttribute("host", "host") + .setAttribute("id", "id") + .setAttribute("method", "GET") .build(); GrpcCelEnvironment env = new GrpcCelEnvironment(context); @@ -438,6 +438,31 @@ public void celEnvironment_missingHeader_returnsEmptyString() { assertThat(requestMap.get("referer")).isEqualTo(""); } + @Test + public void celEnvironment_resolvesCustomAttribute() { + AttributeProvider customProvider = new AttributeProvider() { + @Override + public Iterable names() { + return java.util.Collections.singletonList("custom_attr"); + } + + @Override + public Object get(MatchContext context) { + return "custom_value"; + } + }; + + AttributeProviderRegistry.getDefaultRegistry().register(customProvider); + + MatchContext context = MatchContext.newBuilder().build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); - + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("custom_attr")).isEqualTo("custom_value"); + assertThat(requestMap.containsKey("custom_attr")).isTrue(); + assertThat(requestMap.keySet()).contains("custom_attr"); + } }