From 85ab10fa85b9b5266d322aa6bfe46d6a9cab49ae Mon Sep 17 00:00:00 2001 From: clmatt Date: Tue, 9 Jun 2026 14:16:05 +0000 Subject: [PATCH] fix(interpreter): propagate unknown/error operands through == and != EvalEq and EvalNe evaluated their operands and called lhs.equal(rhs) directly, without first checking whether either operand was unknown or error. Because UnknownT.equal(...) returns BoolT.False, any "==" against an unknown collapsed to a concrete false (and "!=" to a concrete true) during partial evaluation, instead of propagating the unknown. The sibling EvalBinary (used by <, <=, >, >=) already guards with isUnknownOrError, and cel-go guards its equality evaluators the same way. Add the same guard to EvalEq and EvalNe so an undecidable equality is deferred rather than resolved. This fixes residual-AST / partial evaluation: a policy of the form `... && claims.email == "x"` evaluated with `email` unknown now yields a residual of the deferred comparison instead of collapsing the whole expression to false. ResidualAst_Complex is updated to assert the corrected (unknown-propagating) outcome, and a focused regression test, InterpreterTest.equalityWithUnknownOperandStaysUnknown, is added. --- .../cel/interpreter/Interpretable.java | 14 ++++++++++ .../java/org/projectnessie/cel/CELTest.java | 5 ++-- .../cel/interpreter/InterpreterTest.java | 26 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java b/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java index 7b367707..703f6770 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java @@ -396,6 +396,13 @@ final class EvalEq extends AbstractEvalLhsRhs implements InterpretableCall { public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { Val lVal = lhs.eval(ctx); Val rVal = rhs.eval(ctx); + // Early return if any argument to the function is unknown or error. + if (isUnknownOrError(lVal)) { + return lVal; + } + if (isUnknownOrError(rVal)) { + return rVal; + } return lVal.equal(rVal); } @@ -439,6 +446,13 @@ final class EvalNe extends AbstractEvalLhsRhs implements InterpretableCall { public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { Val lVal = lhs.eval(ctx); Val rVal = rhs.eval(ctx); + // Early return if any argument to the function is unknown or error. + if (isUnknownOrError(lVal)) { + return lVal; + } + if (isUnknownOrError(rVal)) { + return rVal; + } Val eqVal = lVal.equal(rVal); switch (eqVal.type().typeEnum()) { case Err: diff --git a/core/src/test/java/org/projectnessie/cel/CELTest.java b/core/src/test/java/org/projectnessie/cel/CELTest.java index 97cd7d22..a34644b1 100644 --- a/core/src/test/java/org/projectnessie/cel/CELTest.java +++ b/core/src/test/java/org/projectnessie/cel/CELTest.java @@ -49,7 +49,6 @@ import static org.projectnessie.cel.ProgramOption.functions; import static org.projectnessie.cel.ProgramOption.globals; import static org.projectnessie.cel.Util.mapOf; -import static org.projectnessie.cel.common.types.BoolT.False; import static org.projectnessie.cel.common.types.BoolT.True; import static org.projectnessie.cel.common.types.Err.isError; import static org.projectnessie.cel.common.types.Err.newErr; @@ -657,10 +656,10 @@ void ResidualAst_Complex() { assertThat(astIss.hasIssues()).isFalse(); Program prg = e.program(astIss.getAst(), evalOptions(OptTrackState, OptPartialEval)); EvalResult outDet = prg.eval(unkVars); - assertThat(outDet.getVal()).isSameAs(False); + assertThat(outDet.getVal()).isInstanceOf(UnknownT.class); Ast residual = e.residualAst(astIss.getAst(), outDet.getEvalDetails()); String expr = astToString(residual); - assertThat(expr).isEqualTo("false"); + assertThat(expr).isEqualTo("request.auth.claims.email == \"wiley@acme.co\""); } @Test diff --git a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java index ce58f0ad..125fbd95 100644 --- a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java +++ b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java @@ -1505,6 +1505,32 @@ void missingIdentInSelect() { assertThat(result).isInstanceOf(Err.class); } + @Test + void equalityWithUnknownOperandStaysUnknown() { + assertThat(evalWithUnknownStringX("x == 'foo'")).isInstanceOf(UnknownT.class); + assertThat(evalWithUnknownStringX("x != 'foo'")).isInstanceOf(UnknownT.class); + assertThat(evalWithUnknownStringX("false || x == 'foo'")).isInstanceOf(UnknownT.class); + assertThat(evalWithUnknownStringX("false && x == 'foo'")).isSameAs(False); + } + + private static Val evalWithUnknownStringX(String expr) { + Source src = newTextSource(expr); + ParseResult parsed = Parser.parseAllMacros(src); + assertThat(parsed.hasErrors()).withFailMessage(parsed.getErrors()::toDisplayString).isFalse(); + + Container cont = testContainer("test"); + TypeRegistry reg = newRegistry(); + CheckerEnv env = newStandardCheckerEnv(cont, reg); + env.add(Decls.newVar("x", Decls.String)); + CheckResult checkResult = Checker.Check(parsed, src, env); + + AttributeFactory attrs = newPartialAttributeFactory(cont, reg, reg); + Interpreter interp = newStandardInterpreter(cont, reg, reg, attrs); + Interpretable i = interp.newInterpretable(checkResult.getCheckedExpr()); + Activation vars = newPartialActivation(mapOf(), newAttributePattern("x")); + return i.eval(vars); + } + static class ConvTestCase { final String in; Val out;