diff --git a/optimizer/src/main/java/dev/cel/optimizer/AstMutator.java b/optimizer/src/main/java/dev/cel/optimizer/AstMutator.java index 59f842e29..0f428f75a 100644 --- a/optimizer/src/main/java/dev/cel/optimizer/AstMutator.java +++ b/optimizer/src/main/java/dev/cel/optimizer/AstMutator.java @@ -664,8 +664,8 @@ private CelMutableSource mangleIdentsInMacroSource( return newSource; } - private static CelMutableSource combine( - CelMutableSource celSource1, CelMutableSource celSource2) { + /** Combines two {@link CelMutableSource} instances into a single new instance. */ + public static CelMutableSource combine(CelMutableSource celSource1, CelMutableSource celSource2) { return CelMutableSource.newInstance() .setDescription( Strings.isNullOrEmpty(celSource1.getDescription()) @@ -677,6 +677,7 @@ private static CelMutableSource combine( .addAllMacroCalls(celSource2.getMacroCalls()); } + /** * Stabilizes the incoming AST by ensuring that all of expr IDs are consistently renumbered * (monotonically increased) from the starting seed ID. If the AST contains any macro calls, its diff --git a/policy/src/main/java/dev/cel/policy/BUILD.bazel b/policy/src/main/java/dev/cel/policy/BUILD.bazel index e0d6af461..79c7a1f05 100644 --- a/policy/src/main/java/dev/cel/policy/BUILD.bazel +++ b/policy/src/main/java/dev/cel/policy/BUILD.bazel @@ -179,6 +179,7 @@ java_library( name = "compiled_rule", srcs = ["CelCompiledRule.java"], deps = [ + ":policy", "//:auto_value", "//bundle:cel", "//common:cel_ast", @@ -246,6 +247,7 @@ java_library( srcs = ["RuleComposer.java"], deps = [ ":compiled_rule", + ":policy", "//bundle:cel", "//common:cel_ast", "//common:compiler_common", @@ -256,11 +258,13 @@ java_library( "//common/ast:mutable_expr", "//common/formats:value_string", "//common/navigation:mutable_navigation", + "//common/types", "//common/types:cel_types", "//common/types:type_providers", "//extensions:optional_library", "//optimizer:ast_optimizer", "//optimizer:mutable_ast", "@maven//:com_google_guava_guava", + "@maven//:org_jspecify_jspecify", ], ) diff --git a/policy/src/main/java/dev/cel/policy/CelCompiledRule.java b/policy/src/main/java/dev/cel/policy/CelCompiledRule.java index 36f1685fc..80ca3ed03 100644 --- a/policy/src/main/java/dev/cel/policy/CelCompiledRule.java +++ b/policy/src/main/java/dev/cel/policy/CelCompiledRule.java @@ -23,6 +23,7 @@ import dev.cel.common.ast.CelConstant; import dev.cel.common.ast.CelExpr; import dev.cel.common.formats.ValueString; +import dev.cel.policy.CelPolicy.EvaluationSemantic; import java.util.Optional; /** @@ -43,11 +44,20 @@ public abstract class CelCompiledRule { public abstract Cel cel(); + public abstract EvaluationSemantic semantic(); + /** * HasOptionalOutput returns whether the rule returns a concrete or optional value. The rule may * return an optional value if all match expressions under the rule are conditional. */ public boolean hasOptionalOutput() { + // AGGREGATE rules always return a concrete list (falling back to an empty list rather than + // optional.none()), meaning they are never optional structurally. This also prevents dead + // code evasion inside parent FIRST_MATCH rules. + if (semantic() == EvaluationSemantic.AGGREGATE) { + return false; + } + boolean isOptionalOutput = false; for (CelCompiledMatch match : matches()) { if (match.result().kind().equals(CelCompiledMatch.Result.Kind.RULE) @@ -154,7 +164,8 @@ static CelCompiledRule create( Optional ruleId, ImmutableList variables, ImmutableList matches, - Cel cel) { - return new AutoValue_CelCompiledRule(sourceId, ruleId, variables, matches, cel); + Cel cel, + CelPolicy.EvaluationSemantic semantic) { + return new AutoValue_CelCompiledRule(sourceId, ruleId, variables, matches, cel, semantic); } } diff --git a/policy/src/main/java/dev/cel/policy/CelPolicy.java b/policy/src/main/java/dev/cel/policy/CelPolicy.java index 19f6631d0..bbcedbc04 100644 --- a/policy/src/main/java/dev/cel/policy/CelPolicy.java +++ b/policy/src/main/java/dev/cel/policy/CelPolicy.java @@ -39,6 +39,12 @@ @AutoValue public abstract class CelPolicy { + /** Evaluation semantic for a rule. */ + public enum EvaluationSemantic { + FIRST_MATCH, + AGGREGATE + } + public abstract ValueString name(); public abstract Optional description(); @@ -143,12 +149,15 @@ public abstract static class Rule { public abstract ImmutableSet matches(); + public abstract EvaluationSemantic semantic(); + /** Builder for {@link Rule}. */ public static Builder newBuilder(long id) { return new AutoValue_CelPolicy_Rule.Builder() .setId(id) .setVariables(ImmutableSet.of()) - .setMatches(ImmutableSet.of()); + .setMatches(ImmutableSet.of()) + .setSemantic(EvaluationSemantic.FIRST_MATCH); } /** Creates a new builder to construct a {@link Rule} instance. */ @@ -195,6 +204,8 @@ public Builder addMatches(Iterable matches) { abstract Rule.Builder setMatches(ImmutableSet matches); + public abstract Rule.Builder setSemantic(EvaluationSemantic semantic); + public abstract Rule build(); } } diff --git a/policy/src/main/java/dev/cel/policy/CelPolicyCompilerImpl.java b/policy/src/main/java/dev/cel/policy/CelPolicyCompilerImpl.java index 7841b9827..d7f74c9ce 100644 --- a/policy/src/main/java/dev/cel/policy/CelPolicyCompilerImpl.java +++ b/policy/src/main/java/dev/cel/policy/CelPolicyCompilerImpl.java @@ -44,6 +44,7 @@ import dev.cel.policy.CelCompiledRule.CelCompiledMatch.Result; import dev.cel.policy.CelCompiledRule.CelCompiledMatch.Result.Kind; import dev.cel.policy.CelCompiledRule.CelCompiledVariable; +import dev.cel.policy.CelPolicy.EvaluationSemantic; import dev.cel.policy.CelPolicy.Import; import dev.cel.policy.CelPolicy.Match; import dev.cel.policy.CelPolicy.Variable; @@ -240,7 +241,12 @@ private CelCompiledRule compileRuleImpl( CelCompiledRule compiledRule = CelCompiledRule.create( - rule.id(), rule.ruleId(), variableBuilder.build(), matchBuilder.build(), ruleCel); + rule.id(), + rule.ruleId(), + variableBuilder.build(), + matchBuilder.build(), + ruleCel, + rule.semantic()); // Validate that all branches in the policy are reachable checkUnreachableCode(compiledRule, compilerContext); @@ -256,7 +262,16 @@ private void checkUnreachableCode(CelCompiledRule compiledRule, CompilerContext CelCompiledMatch compiledMatch = compiledMatches.get(i); boolean isTriviallyTrue = compiledMatch.isConditionTriviallyTrue(); - if (isTriviallyTrue && !ruleHasOptional && i != matchCount - 1) { + // Flag literally false conditions as dead code regardless of semantic + if (isConditionLiterallyFalse(compiledMatch.condition())) { + compilerContext.addIssue( + compiledMatch.sourceId(), CelIssue.formatError(1, 0, "Condition is always false")); + } + + if (compiledRule.semantic() == EvaluationSemantic.FIRST_MATCH + && isTriviallyTrue + && !ruleHasOptional + && i != matchCount - 1) { if (compiledMatch.result().kind().equals(Kind.OUTPUT)) { compilerContext.addIssue( compiledMatch.sourceId(), @@ -270,6 +285,12 @@ private void checkUnreachableCode(CelCompiledRule compiledRule, CompilerContext } } + private static boolean isConditionLiterallyFalse(CelAbstractSyntaxTree condition) { + CelExpr celExpr = condition.getExpr(); + return celExpr.constantOrDefault().getKind().equals(CelConstant.Kind.BOOLEAN_VALUE) + && !celExpr.constant().booleanValue(); + } + private static CelAbstractSyntaxTree newErrorAst() { return CelAbstractSyntaxTree.newParsedAst( CelExpr.ofConstant(0, CelConstant.ofValue("*error*")), CelSource.newBuilder().build()); diff --git a/policy/src/main/java/dev/cel/policy/CelPolicyYamlParser.java b/policy/src/main/java/dev/cel/policy/CelPolicyYamlParser.java index 18b406af0..f57c68d2b 100644 --- a/policy/src/main/java/dev/cel/policy/CelPolicyYamlParser.java +++ b/policy/src/main/java/dev/cel/policy/CelPolicyYamlParser.java @@ -27,6 +27,7 @@ import dev.cel.common.formats.YamlHelper.YamlNodeType; import dev.cel.common.formats.YamlParserContextImpl; import dev.cel.common.internal.CelCodePointArray; +import dev.cel.policy.CelPolicy.EvaluationSemantic; import dev.cel.policy.CelPolicy.Import; import dev.cel.policy.CelPolicy.Match; import dev.cel.policy.CelPolicy.Match.Result; @@ -223,6 +224,15 @@ public CelPolicy.Rule parseRule( case "match": ruleBuilder.addMatches(parseMatches(ctx, policyBuilder, value)); break; + case "semantics": + long semanticId = ctx.collectMetadata(value); + if (!assertYamlType(ctx, semanticId, value, YamlNodeType.STRING, YamlNodeType.TEXT)) { + break; + } + String semanticStr = ((ScalarNode) value).getValue(); + ruleBuilder.setSemantic(parseSemantic(ctx, semanticId, semanticStr)); + break; + default: tagVisitor.visitRuleTag(ctx, tagId, fieldName, value, policyBuilder, ruleBuilder); break; @@ -409,6 +419,21 @@ private Variable parseVariableObject( return builder.build(); } + private static EvaluationSemantic parseSemantic( + PolicyParserContext ctx, long semanticId, String semanticStr) { + switch (semanticStr) { + case "first_match": + return EvaluationSemantic.FIRST_MATCH; + case "aggregate": + return EvaluationSemantic.AGGREGATE; + default: + ctx.reportError(semanticId, "Invalid semantics: " + semanticStr); + // Just return FIRST_MATCH as a sentinel value. The end result is a compilation error + // anyway. + return EvaluationSemantic.FIRST_MATCH; + } + } + private ParserImpl( TagVisitor tagVisitor, boolean enableSimpleVariables, diff --git a/policy/src/main/java/dev/cel/policy/RuleComposer.java b/policy/src/main/java/dev/cel/policy/RuleComposer.java index 73d31a4ee..7f42447e2 100644 --- a/policy/src/main/java/dev/cel/policy/RuleComposer.java +++ b/policy/src/main/java/dev/cel/policy/RuleComposer.java @@ -30,11 +30,14 @@ import dev.cel.common.ast.CelConstant; import dev.cel.common.ast.CelExpr.ExprKind.Kind; import dev.cel.common.ast.CelMutableExpr; +import dev.cel.common.ast.CelMutableExpr.CelMutableList; import dev.cel.common.formats.ValueString; import dev.cel.common.navigation.CelNavigableMutableAst; import dev.cel.common.navigation.CelNavigableMutableExpr; import dev.cel.common.types.CelType; import dev.cel.common.types.CelTypes; +import dev.cel.common.types.ListType; +import dev.cel.common.types.OptionalType; import dev.cel.extensions.CelOptionalLibrary.Function; import dev.cel.optimizer.AstMutator; import dev.cel.optimizer.CelAstOptimizer; @@ -45,6 +48,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import org.jspecify.annotations.Nullable; /** Package-private class for composing various rules into a single expression using optimizer. */ final class RuleComposer implements CelAstOptimizer { @@ -54,11 +58,11 @@ final class RuleComposer implements CelAstOptimizer { @Override public OptimizationResult optimize(CelAbstractSyntaxTree ast, Cel cel) { - Step result = optimizeRule(cel, compiledRule); + Step result = optimizeRule(cel, compiledRule, /* asList= */ false); return OptimizationResult.create(result.expr.toParsedAst()); } - private Step optimizeRule(Cel cel, CelCompiledRule compiledRule) { + private Step optimizeRule(Cel cel, CelCompiledRule compiledRule, boolean asList) { cel = cel.toCelBuilder() .addVarDeclarations( @@ -67,11 +71,18 @@ private Step optimizeRule(Cel cel, CelCompiledRule compiledRule) { .collect(toImmutableList())) .build(); + boolean isAggregate = compiledRule.semantic() == CelPolicy.EvaluationSemantic.AGGREGATE; + boolean returnList = isAggregate || asList; + Step output = null; - // If the rule has an optional output, the last result in the ternary should return - // `optional.none`. This output is implicit and created here to reflect the desired - // last possible output of this type of rule. - if (compiledRule.hasOptionalOutput()) { + if (returnList) { + // If the rule is evaluated as a list (AGGREGATE), the base case is an empty list. + output = Step.newUnconditionalNonOptionalStep(newTrueLiteral(), newList()); + + } else if (compiledRule.hasOptionalOutput()) { + // If the rule has an optional output, the last result in the ternary should return + // `optional.none`. This output is implicit and created here to reflect the desired + // last possible output of this type of rule. output = Step.newUnconditionalOptionalStep( newTrueLiteral(), astMutator.newGlobalCall(Function.OPTIONAL_NONE.getFunction())); @@ -85,59 +96,55 @@ private Step optimizeRule(Cel cel, CelCompiledRule compiledRule) { boolean isTriviallyTrue = match.isConditionTriviallyTrue(); CelMutableAst condAst = CelMutableAst.fromCelAst(conditionAst); - long currentSourceId = lastOutputId; + Step currentStep; + long currentSourceId; + String validationMessage; switch (match.result().kind()) { case OUTPUT: + OutputValue matchOutput = match.result().output(); // If the match has an output, then it is considered a non-optional output since // it is explicitly stated. If the rule itself is optional, then the base case value // of output being optional.none() will convert the non-optional value to an optional // one. - OutputValue matchOutput = match.result().output(); - Step step = + CelMutableAst matchOutputAst = CelMutableAst.fromCelAst(matchOutput.ast()); + currentStep = Step.newNonOptionalStep( - !isTriviallyTrue, condAst, CelMutableAst.fromCelAst(matchOutput.ast())); - currentSourceId = matchOutput.sourceId(); - - output = combine(astMutator, step, output); + !isTriviallyTrue, condAst, returnList ? newList(matchOutputAst) : matchOutputAst); - String outputFailureMessage = - String.format( - "incompatible output types: block has output type %s, but previous outputs have" - + " type %s", - lastOutputType == null ? "" : CelTypes.format(lastOutputType), - CelTypes.format(matchOutput.ast().getResultType())); - lastOutputType = - assertComposedAstIsValid( - cel, output.expr, outputFailureMessage, currentSourceId, lastOutputId) - .getResultType(); + currentSourceId = matchOutput.sourceId(); + validationMessage = + incompatibleOutputTypesMessage( + lastOutputType, + matchOutput.ast().getResultType(), + returnList, + compiledRule.hasOptionalOutput()); break; case RULE: + CelCompiledRule matchNestedRule = match.result().rule(); // If the match has a nested rule, then compute the rule and whether it has // an optional return value. - CelCompiledRule matchNestedRule = match.result().rule(); - Step nestedRule = optimizeRule(cel, matchNestedRule); - Step ruleStep = - new Step( - matchNestedRule.hasOptionalOutput(), !isTriviallyTrue, condAst, nestedRule.expr); + Step nestedRule = optimizeRule(cel, matchNestedRule, returnList); + currentStep = new Step(nestedRule.isOptional, !isTriviallyTrue, condAst, nestedRule.expr); currentSourceId = getFirstOutputSourceId(matchNestedRule); - - output = combine(astMutator, ruleStep, output); - - lastOutputType = - assertComposedAstIsValid( - cel, - output.expr, - String.format( - "failed composing the subrule '%s' due to incompatible output types.", - matchNestedRule.ruleId().map(ValueString::value).orElse("")), - currentSourceId, - lastOutputId) - .getResultType(); + validationMessage = + String.format( + "failed composing the subrule '%s' due to incompatible output types.", + matchNestedRule.ruleId().map(ValueString::value).orElse("")); break; + default: + throw new IllegalStateException("Unknown match kind"); } + output = + isAggregate + ? combineAggregate(astMutator, currentStep, output) + : combine(astMutator, currentStep, output); + lastOutputType = + assertComposedAstIsValid( + cel, output.expr, validationMessage, currentSourceId, lastOutputId) + .getResultType(); lastOutputId = currentSourceId; } @@ -250,6 +257,76 @@ private Step combineWhenCurrentIsNonOptional( } } + private Step combineAggregate(AstMutator astMutator, Step currentStep, Step accumulatedStep) { + CelMutableAst trueCondition = newTrueLiteral(); + // We assume currentStep.expr evaluates to a list due to contextual list generation. + CelMutableAst currentListPart = currentStep.expr; + // Stitch: currentStep.cond ? currentListPart : [] + // If the condition is false, we contribute an empty list to the accumulation, + // effectively dropping the result of this branch if it didn't match. + CelMutableAst conditionalListPart; + if (currentStep.isConditional) { + conditionalListPart = + astMutator.newGlobalCall( + Operator.CONDITIONAL.getFunction(), currentStep.cond, currentListPart, newList()); + + } else { + conditionalListPart = currentListPart; + } + + CelMutableAst concatenated = + astMutator.newGlobalCall( + Operator.ADD.getFunction(), conditionalListPart, accumulatedStep.expr); + + return Step.newUnconditionalNonOptionalStep(trueCondition, concatenated); + } + + /** + * Strips the structural type wrapper injected by the RuleComposer (e.g., optionals for + * FIRST_MATCH, lists for AGGREGATE) so that type mismatch errors display the raw underlying types + * authored by the user. + */ + private static @Nullable CelType unwrapComposerWrapper( + @Nullable CelType type, boolean returnList, boolean hasOptionalOutput) { + if (type == null) { + return null; + } + + if (returnList && type instanceof ListType) { + return ((ListType) type).elemType(); + } + + if (!returnList && hasOptionalOutput && type instanceof OptionalType) { + return type.parameters().get(0); + } + + return type; + } + + private static String incompatibleOutputTypesMessage( + @Nullable CelType lastOutputType, + CelType matchOutputType, + boolean returnList, + boolean hasOptionalOutput) { + CelType unwrappedLastOutputType = + unwrapComposerWrapper(lastOutputType, returnList, hasOptionalOutput); + return String.format( + "incompatible output types: block has output type %s, but previous outputs have" + + " type %s", + unwrappedLastOutputType == null ? "unknown type" : CelTypes.format(unwrappedLastOutputType), + CelTypes.format(matchOutputType)); + } + + private static CelMutableAst newList(CelMutableAst... elements) { + List exprs = new ArrayList<>(); + CelMutableSource combinedSource = CelMutableSource.newInstance(); + for (CelMutableAst element : elements) { + exprs.add(element.expr()); + combinedSource = AstMutator.combine(combinedSource, element.source()); + } + return CelMutableAst.of(CelMutableExpr.ofList(CelMutableList.create(exprs)), combinedSource); + } + private static boolean isOptionalNone(CelMutableAst ast) { CelMutableExpr expr = ast.expr(); return expr.getKind().equals(Kind.CALL) diff --git a/policy/src/test/java/dev/cel/policy/CelPolicyCompilerImplTest.java b/policy/src/test/java/dev/cel/policy/CelPolicyCompilerImplTest.java index b4065b60c..9d23c3e33 100644 --- a/policy/src/test/java/dev/cel/policy/CelPolicyCompilerImplTest.java +++ b/policy/src/test/java/dev/cel/policy/CelPolicyCompilerImplTest.java @@ -32,6 +32,8 @@ import dev.cel.common.CelAbstractSyntaxTree; import dev.cel.common.CelOptions; import dev.cel.common.formats.ValueString; +import dev.cel.common.types.ListType; +import dev.cel.common.types.MapType; import dev.cel.common.types.OptionalType; import dev.cel.common.types.SimpleType; import dev.cel.expr.conformance.proto3.TestAllTypes; @@ -108,6 +110,104 @@ public void compileYamlPolicy_withImportsOnNestedRules() throws Exception { assertThat(ast.getResultType()).isEqualTo(OptionalType.create(SimpleType.BOOL)); } + @Test + public void evalYamlPolicy_aggregate() throws Exception { + String policySource = + "name: \"aggregate_policy\"\n" + + "rule:\n" + + " semantics: aggregate\n" + + " match:\n" + + " - condition: 'true'\n" + + " output: '\"PII\"'\n" + + " - condition: 'true'\n" + + " output: '\"CONFIDENTIAL\"'\n"; + Cel cel = newCel(); + CelPolicy policy = POLICY_PARSER.parse(policySource); + + CelAbstractSyntaxTree ast = + CelPolicyCompilerFactory.newPolicyCompiler(cel).build().compile(policy); + + Object evalResult = cel.createProgram(ast).eval(); + assertThat(evalResult).isEqualTo(ImmutableList.of("PII", "CONFIDENTIAL")); + } + + @Test + public void evaluateYamlPolicy_aggregate_cseApplied() throws Exception { + String policySource = + "name: \"cse_policy\"\n" + + "rule:\n" + + " semantics: aggregate\n" + + " match:\n" + + " - condition: \"size(resource.payload) > 5\"\n" + + " output: '\"CSE1\"'\n" + + " - condition: \"size(resource.payload) > 5\"\n" + + " output: '\"CSE2\"'\n" + + " - condition: 'true'\n" + + " output: '\"ALWAYS\"'\n"; + Cel cel = + newCel() + .toCelBuilder() + .addVar("resource", MapType.create(SimpleType.STRING, ListType.create(SimpleType.INT))) + .build(); + CelPolicy policy = POLICY_PARSER.parse(policySource); + + CelAbstractSyntaxTree ast = + CelPolicyCompilerFactory.newPolicyCompiler(cel).build().compile(policy); + + String unparsed = CelUnparserFactory.newUnparser().unparse(ast); + assertThat(unparsed) + .isEqualTo( + "cel.@block(" + + "[size(resource.payload) > 5], " + + "(@index0 ? [\"CSE1\"] : []) " + + "+ ((@index0 ? [\"CSE2\"] : []) + [\"ALWAYS\"]))"); + + // Evaluate under true condition (size of payload is 6 > 5) + ImmutableMap inputTrue = + ImmutableMap.of("resource", ImmutableMap.of("payload", ImmutableList.of(1, 2, 3, 4, 5, 6))); + Object evalResultTrue = cel.createProgram(ast).eval(inputTrue); + assertThat(evalResultTrue).isEqualTo(ImmutableList.of("CSE1", "CSE2", "ALWAYS")); + // Evaluate under false condition (size of payload is 3 <= 5) + ImmutableMap inputFalse = + ImmutableMap.of("resource", ImmutableMap.of("payload", ImmutableList.of(1, 2, 3))); + Object evalResultFalse = cel.createProgram(ast).eval(inputFalse); + assertThat(evalResultFalse).isEqualTo(ImmutableList.of("ALWAYS")); + } + + @Test + public void compileYamlPolicy_aggregate_macrosPreserved() throws Exception { + String policySource = + "name: aggregate_macros_preserved\n" + + "rule:\n" + + " semantics: aggregate\n" + + " match:\n" + + " - condition: \"cond\"\n" + + " rule:\n" + + " semantics: first_match\n" + + " match:\n" + + " - condition: \"true\"\n" + + " output: \"payload.filter(x, x > 10).exists(y, y % 2 == 0)\"\n" + + " - condition: \"true\"\n" + + " output: \"payload.all(x, x > 0)\"\n"; + Cel cel = + newCel() + .toCelBuilder() + .addVar("cond", SimpleType.BOOL) + .addVar("payload", ListType.create(SimpleType.INT)) + .build(); + + CelPolicy policy = POLICY_PARSER.parse(policySource); + + CelAbstractSyntaxTree ast = + CelPolicyCompilerFactory.newPolicyCompiler(cel).build().compile(policy); + + String unparsed = CelUnparserFactory.newUnparser().unparse(ast); + assertThat(unparsed) + .isEqualTo( + "(cond ? [payload.filter(x, x > 10, x).exists(y, y % 2 == 0)] : []) " + + "+ ([payload.all(x, x > 0)] + [])"); + } + @Test public void compileYamlPolicy_containsCompilationError_throws( @TestParameter TestErrorYamlPolicy testCase) throws Exception { @@ -366,7 +466,8 @@ public void compose_ruleWithNoOutputs_throws() throws Exception { Optional.of(ValueString.of(2L, "empty_rule")), ImmutableList.of(), ImmutableList.of(), - cel); + cel, + CelPolicy.EvaluationSemantic.FIRST_MATCH); RuleComposer composer = RuleComposer.newInstance(emptyRule, "variables.", 1000); CelAbstractSyntaxTree ast = cel.compile("true").getAst(); @@ -512,7 +613,9 @@ private enum TestErrorYamlPolicy { COMPILE_ERRORS("compile_errors"), COMPOSE_ERRORS_CONFLICTING_OUTPUT("compose_errors_conflicting_output"), COMPOSE_ERRORS_CONFLICTING_SUBRULE("compose_errors_conflicting_subrule"), - ERRORS_UNREACHABLE("errors_unreachable"); + ERRORS_UNREACHABLE("errors_unreachable"), + AGGREGATE_ERRORS("aggregate_errors"), + AGGREGATE_LIST_ERRORS("aggregate_list_errors"); private final String name; private final String policyFilePath; diff --git a/policy/src/test/java/dev/cel/policy/CelPolicyYamlParserTest.java b/policy/src/test/java/dev/cel/policy/CelPolicyYamlParserTest.java index 2a2c47a98..933ecd741 100644 --- a/policy/src/test/java/dev/cel/policy/CelPolicyYamlParserTest.java +++ b/policy/src/test/java/dev/cel/policy/CelPolicyYamlParserTest.java @@ -217,6 +217,21 @@ private enum PolicyParseErrorTestCase { + " [tag:yaml.org,2002:str !txt]\n" + " | illegal: yaml-type\n" + " | ..^"), + ILLEGAL_YAML_TYPE_ON_SEMANTIC_VALUE( + "name: test\n" + "rule:\n" + " semantics: 123\n" + " match:\n" + " - output: 'true'\n", + "ERROR: :3:14: Got yaml node type tag:yaml.org,2002:int, wanted type(s)" + + " [tag:yaml.org,2002:str !txt]\n" + + " | semantics: 123\n" + + " | .............^"), + INVALID_SEMANTIC_VALUE( + "name: test\n" + + "rule:\n" + + " semantics: 'invalid_semantic'\n" + + " match:\n" + + " - output: 'true'\n", + "ERROR: :3:15: Invalid semantics: invalid_semantic\n" + + " | semantics: 'invalid_semantic'\n" + + " | ..............^"), ILLEGAL_YAML_TYPE_ON_RULE_VALUE( "rule: illegal", "ERROR: :1:7: Got yaml node type tag:yaml.org,2002:str, wanted type(s)" diff --git a/testing/src/test/resources/policy/aggregate_errors/config.yaml b/testing/src/test/resources/policy/aggregate_errors/config.yaml new file mode 100644 index 000000000..48f1d35b8 --- /dev/null +++ b/testing/src/test/resources/policy/aggregate_errors/config.yaml @@ -0,0 +1,19 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. + +name: "aggregate_errors" +variables: + - name: cond + type: + type_name: "bool" diff --git a/testing/src/test/resources/policy/aggregate_errors/expected_errors.baseline b/testing/src/test/resources/policy/aggregate_errors/expected_errors.baseline new file mode 100644 index 000000000..3c9b280be --- /dev/null +++ b/testing/src/test/resources/policy/aggregate_errors/expected_errors.baseline @@ -0,0 +1,6 @@ +ERROR: aggregate_errors/policy.yaml:24:22: incompatible output types: block has output type int, but previous outputs have type optional_type(string) + | output: "optional.of('USER_PII')" + | .....................^ +ERROR: aggregate_errors/policy.yaml:26:22: incompatible output types: block has output type int, but previous outputs have type optional_type(string) + | output: "403" + | .....................^ \ No newline at end of file diff --git a/testing/src/test/resources/policy/aggregate_errors/policy.yaml b/testing/src/test/resources/policy/aggregate_errors/policy.yaml new file mode 100644 index 000000000..b29226534 --- /dev/null +++ b/testing/src/test/resources/policy/aggregate_errors/policy.yaml @@ -0,0 +1,26 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. + +name: aggregate_errors +rule: + semantics: aggregate + match: + - condition: "cond" + rule: + semantics: first_match + match: + - condition: "cond" + output: "optional.of('USER_PII')" + - condition: "true" + output: "403" diff --git a/testing/src/test/resources/policy/aggregate_list_errors/config.yaml b/testing/src/test/resources/policy/aggregate_list_errors/config.yaml new file mode 100644 index 000000000..4361531bb --- /dev/null +++ b/testing/src/test/resources/policy/aggregate_list_errors/config.yaml @@ -0,0 +1,19 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. + +name: "aggregate_list_errors" +variables: + - name: cond + type: + type_name: "bool" diff --git a/testing/src/test/resources/policy/aggregate_list_errors/expected_errors.baseline b/testing/src/test/resources/policy/aggregate_list_errors/expected_errors.baseline new file mode 100644 index 000000000..c42f7b8ff --- /dev/null +++ b/testing/src/test/resources/policy/aggregate_list_errors/expected_errors.baseline @@ -0,0 +1,6 @@ +ERROR: aggregate_list_errors/policy.yaml:24:22: incompatible output types: block has output type int, but previous outputs have type list(string) + | output: "['tag1', 'tag2']" + | .....................^ +ERROR: aggregate_list_errors/policy.yaml:26:22: incompatible output types: block has output type int, but previous outputs have type list(string) + | output: "403" + | .....................^ \ No newline at end of file diff --git a/testing/src/test/resources/policy/aggregate_list_errors/policy.yaml b/testing/src/test/resources/policy/aggregate_list_errors/policy.yaml new file mode 100644 index 000000000..2db896af0 --- /dev/null +++ b/testing/src/test/resources/policy/aggregate_list_errors/policy.yaml @@ -0,0 +1,26 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. + +name: aggregate_list_errors +rule: + semantics: aggregate + match: + - condition: "cond" + rule: + semantics: first_match + match: + - condition: "cond" + output: "['tag1', 'tag2']" + - condition: "true" + output: "403" diff --git a/testing/src/test/resources/policy/errors_unreachable/expected_errors.baseline b/testing/src/test/resources/policy/errors_unreachable/expected_errors.baseline index f5f24acbe..f8061f5dd 100644 --- a/testing/src/test/resources/policy/errors_unreachable/expected_errors.baseline +++ b/testing/src/test/resources/policy/errors_unreachable/expected_errors.baseline @@ -1,3 +1,6 @@ +ERROR: errors_unreachable/policy.yaml:38:9: Condition is always false + | - condition: "false" + | ........^ ERROR: errors_unreachable/policy.yaml:36:9: Match creates unreachable outputs | - output: | | ........^