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/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/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..3d0530e210b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java @@ -0,0 +1,97 @@ +/* + * 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 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) { + return context.getAttribute(requestField); + } + + private static final class LazyRequestMap extends AbstractMap { + private final GrpcCelEnvironment resolver; + + LazyRequestMap(GrpcCelEnvironment resolver) { + this.resolver = resolver; + } + + @Override + public Object get(Object key) { + if (key instanceof String) { + return resolver.getRequestField((String) key); + } + return null; + } + + @Override + public boolean containsKey(Object key) { + 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() { + ImmutableSet.Builder builder = ImmutableSet.builder(); + builder.addAll(AttributeProviderRegistry.getDefaultRegistry().getRegisteredNames()); + builder.addAll(resolver.context.getRawAttributes().keySet()); + return builder.build(); + } + + @Override + public int size() { + return keySet().size(); + } + + @Override + public boolean isEmpty() { + return keySet().isEmpty(); + } + + @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..a8efe6b6480 --- /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 (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); + } + } + + @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..bb17677cdf3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java @@ -0,0 +1,79 @@ +/* + * 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 java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +public final class MatchContext { + private final Metadata metadata; + private final Map attributes; + + private MatchContext(Metadata metadata, Map attributes) { + this.metadata = Preconditions.checkNotNull(metadata, "metadata"); + this.attributes = Collections.unmodifiableMap(new HashMap<>(attributes)); + } + + public Metadata getMetadata() { + return metadata; + } + + @Nullable + public Object getAttribute(String name) { + AttributeProvider provider = AttributeProviderRegistry.getDefaultRegistry().get(name); + if (provider != null) { + return provider.get(this); + } + return attributes.get(name); + } + + @Nullable + public Object getRawAttribute(String name) { + return attributes.get(name); + } + + public Map getRawAttributes() { + return attributes; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static final class Builder { + private Metadata metadata = new Metadata(); + private final Map attributes = new HashMap<>(); + + public Builder setMetadata(Metadata metadata) { + this.metadata = Preconditions.checkNotNull(metadata, "metadata"); + return this; + } + + public Builder setAttribute(String name, Object value) { + attributes.put(name, value); + return this; + } + + public MatchContext build() { + return new MatchContext(metadata, attributes); + } + } +} 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..8a563f41ad4 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -0,0 +1,468 @@ +/* + * 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() + .setAttribute("path", "/path") + .setAttribute("method", "POST") + .setAttribute("host", "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() + .setAttribute("path", "/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() + .setAttribute("host", "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() + .setAttribute("host", "host") + .setAttribute("id", "id") + .setAttribute("method", "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(""); + } + + @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"); + } +} 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"); + } + } +}