diff --git a/attributes.go b/attributes.go index 2335334..3cb3599 100644 --- a/attributes.go +++ b/attributes.go @@ -43,7 +43,10 @@ import ( // it with `!=`, testing it with has(), or a map or list holding it, included, and so does reading // the kind's attributes as a whole, by `in`, size(), a macro or comparing them. A name a declared // kind doesn't declare, which a condition reads only through dyn(), fails so too. An undeclared -// kind's are read as passed, and CEL compares values of different types as unequal. +// kind's are read as passed, and CEL compares values of different types as unequal. A map passed +// there may be keyed by a double that isn't finite, as no declared one may: comparing it with a map +// of as many entries fails, but `in` it is false for any key it doesn't hold, as CEL finds none +// equal to NaN. type Attributes struct { Subject, Resource, Request map[string]AttributeType } diff --git a/condition.go b/condition.go index 2d8c103..4c11a0d 100644 --- a/condition.go +++ b/condition.go @@ -16,6 +16,7 @@ import ( "cel.dev/cel-go/cel" "cel.dev/cel-go/common/ast" "cel.dev/cel-go/common/cost" + "cel.dev/cel-go/common/functions" "cel.dev/cel-go/common/operators" "cel.dev/cel-go/common/overloads" "cel.dev/cel-go/common/types" @@ -35,12 +36,14 @@ import ( // such as `request.time < timestamp("2027-01-01T00:00:00Z") && resource.attributes.env != "prod"`. // A grant with one gives its role only where the expression is true. An expression that fails, as // one reading an attribute the host didn't pass does, or comparing a double that isn't finite, as -// dividing by zero makes, is false. A label value and a power to release are decided on the tenant -// with no attributes, whatever a check passes (Holding), and their conditions can't read the -// tenant's: any read of resource.attributes fails, a test of an attribute's presence, in, size(), a -// macro and a comparison included, so a grant's gives nothing and a deny's applies. The zero -// Condition is none, and one with a title or description has an expression. A pattern it matches is -// a string literal, of bounded size; it nests at most 32 deep; it builds no message, as +// dividing by zero makes, converting one to a string, keying a map by one or comparing a map keyed +// by one with another of as many entries, is false, though `in` a map the host passes keyed by one +// is false for a key it doesn't hold (Attributes). A label value and a power to release are decided +// on the tenant with no attributes, whatever a check passes (Holding), and their conditions can't +// read the tenant's: any read of resource.attributes fails, a test of an attribute's presence, in, +// size(), a macro and a comparison included, so a grant's gives nothing and a deny's applies. The +// zero Condition is none, and one with a title or description has an expression. A pattern it +// matches is a string literal, of bounded size; it nests at most 32 deep; it builds no message, as // `google.protobuf.Struct{}`; and a comparison, or a search of a list, that would read more than a // condition may spend, or reading a time in a zone whose name is longer than 64 bytes, fails before // it runs. @@ -410,9 +413,11 @@ func (c *conditions) build(expr string) (program, error) { // have, as a test plans a loop as another version of CEL may. func plan(env *cel.Env, checked *cel.Ast, patterns map[string]uint64, limit uint64, before ...cel.ProgramOption) (cel.Program, error) { s, k := newStepping(checked.NativeRep(), limit), newKeying(env, checked.NativeRep(), limit) + str := &stringing{env: env} prg, err := env.Program(checked, slices.Concat(before, []cel.ProgramOption{ cel.CostLimit(limit), cel.CostTracking(costs{patterns}), cel.OptimizeRegex(matching(patterns)), - cel.CustomDecorator(bounded), cel.CustomDecoratorV2(s.decorate), cel.CustomDecoratorV2(k.decorate), + cel.CustomDecorator(bounded), cel.CustomDecorator(str.decorate), cel.CustomDecoratorV2(s.decorate), + cel.CustomDecoratorV2(k.decorate), })...) if err != nil { return nil, err @@ -947,6 +952,58 @@ func inZone(call interpreter.InterpretableCall) interpreter.InterpretableCall { }) } +// stringing converts a value to a string as CEL does, failing for a double that isn't finite, as +// int() and uint() of one do: "NaN" compares as any string does, so a deny's +// `string(failed / total) == "0.5"` would lift where both are 0.0. It holds the environment's own +// overloads of string(), each by its id and, by the function's name, the one choosing among them by +// a value's type, and runs the one a program would: CEL picks one by the type it checked, which a +// value it checks as of another's type, as `(dyn([true]) + [""])[0]`, needn't have. It reads them +// once a program converts a value to a string: reading them copies every function the environment +// declares, which takes about a tenth as long as planning a short condition. +type stringing struct { + env *cel.Env + overloads map[string]*functions.Overload +} + +func (s *stringing) decorate(i interpreter.Interpretable) (interpreter.Interpretable, error) { + call, ok := i.(interpreter.InterpretableCall) + if !ok || call.Function() != overloads.TypeConvertString { + return i, nil + } + if s.overloads == nil { + s.overloads = map[string]*functions.Overload{} + if fn, ok := s.env.Functions()[overloads.TypeConvertString]; ok { + bindings, err := fn.Bindings() + if err != nil { + return nil, err + } + for _, o := range bindings { + s.overloads[o.Operator] = o + } + } + } + impl := s.overloads[call.OverloadID()] + if impl == nil { + impl = s.overloads[call.Function()] + } + // Another version of CEL may bind it otherwise, which this can't run as CEL would. + if impl == nil || impl.Unary == nil && impl.Function == nil { + return nil, errors.New("a conversion to a string is planned where this package can't check it") + } + return interpreter.NewCall(call.ID(), call.Function(), call.OverloadID(), call.Args(), func(args ...ref.Val) ref.Val { + if len(args) != 1 { + return types.NoSuchOverloadErr() + } + if nonFinite(args[0]) { + return types.NewErr("rbac: a double that isn't finite can't be converted to a string") + } + if impl.Unary != nil { + return impl.Unary(args[0]) + } + return impl.Function(args...) + }), nil +} + // timePart reports whether function reads a part of a time, in a zone where it is given one. func timePart(function string) bool { switch function { @@ -1079,7 +1136,8 @@ func (*counting) Parent() interpreter.Activation { return nil } // literal of it does, by its length: the map hashes it whole, which CEL charges as 1 however long // it is, so a loop looking up a long string the host passes would take time growing with its // length, charged only for the steps. Each costs a tenth of its bytes more, as reading it does -// (textSize), counted beside the steps (counting). +// (textSize), counted beside the steps (counting). A double that isn't finite fails as a key +// (finiteKey). type keying struct { // keys are the ids of the keys checked computes, and planned what CEL planned each as last, // which is what it reads: it plans an index whose key isn't an attribute as an attribute under @@ -1141,6 +1199,10 @@ func (k *keying) decorate(i interpreter.InterpretableV2) (interpreter.Interpreta planned := i switch i := i.(type) { case interpreter.InterpretableConst: + // Another version of CEL may fold a key, as `0.0 / 0.0`, which finiteKey wouldn't see. + if nonFinite(i.Value()) { + return nil, errors.New("a key is a double that isn't finite") + } case interpreter.InterpretableAttribute: planned = &keyedAttribute{InterpretableAttribute: i, k: k} case interpreter.InterpretableCall: @@ -1198,7 +1260,7 @@ func (a *keyedAttribute) Exec(frame *interpreter.ExecutionFrame) ref.Val { } key := a.Adapter().NativeToValue(v) a.k.charge(frame, a, key) - return key + return finiteKey(key) } func (a *keyedAttribute) Eval(vars interpreter.Activation) ref.Val { @@ -1232,9 +1294,34 @@ func (a *keyedAttribute) qualifier(vars interpreter.Activation) (interpreter.Qua return nil, err } a.k.charge(vars, a, v) + // An index never runs the key, so finiteKey doesn't see it: a map the host passes would find + // an infinite key, where `in` refuses one. CEL indexes by a double only for these, and + // converting any other again would parse a JSON attribute a second time. + switch d := v.(type) { + case float32, float64, types.Double: + if nonFinite(a.Adapter().NativeToValue(d)) { + return nil, errNonFiniteKey + } + } return a.k.fac.NewQualifier(nil, attr.ID(), v, attr.IsOptional()) } +var errNonFiniteKey = errors.New("rbac: a double that isn't finite can't be a key") + +// finiteKey is key, failing for a double that isn't finite, as comparing one does (nonFinite): CEL +// finds no key equal to NaN, so a map keyed by one would equal no map and hold no value, and a +// deny's `{failed / total: 1} == {0.5: 1}` would lift where both are 0.0. A condition computes +// every key it can make one of, since none is written. A map the host passes can be keyed by one: +// comparing it with a map of as many entries fails (equal), as does indexing it by one +// (qualifier), but a key not found `in` it is false, as finding whether it holds one that isn't +// finite would read every key, uncharged. +func finiteKey(key ref.Val) ref.Val { + if nonFinite(key) { + return types.WrapErr(errNonFiniteKey) + } + return key +} + // keyedCall is a key CEL plans as a call, charged once it has run. type keyedCall struct { interpreter.InterpretableCall @@ -1244,7 +1331,7 @@ type keyedCall struct { func (c *keyedCall) Exec(frame *interpreter.ExecutionFrame) ref.Val { v := c.InterpretableCall.Exec(frame) c.k.charge(frame, c, v) - return v + return finiteKey(v) } func (c *keyedCall) Eval(vars interpreter.Activation) ref.Val { @@ -1290,6 +1377,16 @@ func incomparable() ref.Val { return types.NewErr("rbac: a double that isn't finite can't be compared") } +// nonFiniteKey reports whether m holds a key that is a double that isn't finite. +func nonFiniteKey(m traits.Mapper) bool { + for it := m.Iterator(); it.HasNext() == types.True; { + if nonFinite(it.Next()) { + return true + } + } + return false +} + // equal is x == y, failing where comparing values a map or list holds fails, as reading a // declared attribute passed as another type does: CEL's own comparison of maps and lists counts // such a value as equal, so that a map holding one would equal any map with its keys. It finds the @@ -1320,11 +1417,24 @@ func equal(x, y ref.Val, read uint64) ref.Val { return eq != types.False } switch xs := x.(type) { + case *types.Optional: + // A host can pass one, which CEL compares by its own comparison of what it holds. + ys, ok := y.(*types.Optional) + if !ok || !xs.HasValue() || !ys.HasValue() { + return types.Equal(x, y) + } + return equal(xs.GetValue(), ys.GetValue(), read) case traits.Mapper: ys, ok := y.(traits.Mapper) if !ok || xs.Size() != ys.Size() { return types.Equal(x, y) } + // A map the host passes can be keyed by one, which finiteKey doesn't see, and no key is found + // equal to NaN, so it would equal no map. Comparing is charged for each key of either, as + // they hold as many. + if nonFiniteKey(xs) || nonFiniteKey(ys) { + return incomparable() + } for it := xs.Iterator(); it.HasNext() == types.True; { k := it.Next() if valueSize(k, read) > read { @@ -1395,6 +1505,11 @@ func smaller(x, y ref.Val, limit uint64) uint64 { func valueSize(v ref.Val, limit uint64) uint64 { n := textSize(v) switch v := v.(type) { + case *types.Optional: + // One a host passes, which CEL compares by what it holds, whole. + if v.HasValue() && n <= limit { + n += valueSize(v.GetValue(), limit-n) + } case traits.Mapper: // Each entry is at least a key and a value. if least := n + 2*count(v); least > limit { diff --git a/condition_test.go b/condition_test.go index 6b16c1c..e047efe 100644 --- a/condition_test.go +++ b/condition_test.go @@ -22,6 +22,7 @@ import ( "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" "github.com/parallelworks/rbac" ) @@ -104,7 +105,9 @@ func TestADeclaredDoubleThatIsntFiniteFailsItsConditions(t *testing.T) { } // A condition can make a double that isn't finite of finite ones, dividing by zero or converting -// "NaN", which CEL orders and equates as false with anything: so comparing one fails too. +// "NaN", which CEL orders and equates as false with anything: so comparing one fails too, and so +// do keying a map by one, which CEL finds no key equal to, whatever the map holds, and converting +// one to a string, which compares as any string does. func TestADoubleAConditionMakesThatIsntFiniteFailsWhatComparesIt(t *testing.T) { h := setup(t, func(c *rbac.Config) { c.Catalog = append(c.Catalog[:len(c.Catalog):len(c.Catalog)], guardRole) @@ -114,8 +117,17 @@ func TestADoubleAConditionMakesThatIsntFiniteFailsWhatComparesIt(t *testing.T) { }) ctx := t.Context() const ratio = `request.attributes.failed / request.attributes.total` - grants := []string{ratio + ` < 0.5`, `!(` + ratio + ` >= 0.5)`, ratio + ` != 1.0`} - denies := []string{ratio + ` >= 0.5`, ratio + ` == 1.0`, `double(request.attributes.score) >= 0.5`} + keyed := `{` + ratio + `: 1}` + grants := []string{ + ratio + ` < 0.5`, `!(` + ratio + ` >= 0.5)`, ratio + ` != 1.0`, + keyed + ` != {0.5: 1}`, `[` + keyed + `] != [{0.5: 1}]`, `!(0.5 in ` + keyed + `)`, + `[` + ratio + `].all(r, {r: 1} != {0.5: 1})`, `string(` + ratio + `) != "0.5"`, + } + denies := []string{ + ratio + ` >= 0.5`, ratio + ` == 1.0`, `double(request.attributes.score) >= 0.5`, + keyed + ` == {0.5: 1}`, `{0.5: 1} == ` + keyed, `0.5 in {double(request.attributes.score): 1}`, + `[` + ratio + `].exists(r, {r: 1} == {0.5: 1})`, `string(double(request.attributes.score)) == "0.5"`, + } seeded := []rbac.Grant{grant(cluster.Scope(), "guard", rbac.User, "guardian")} for i, expr := range grants { g := grant(cluster.Scope(), "cluster.login", rbac.User, fmt.Sprint("u", i)) @@ -141,7 +153,7 @@ func TestADoubleAConditionMakesThatIsntFiniteFailsWhatComparesIt(t *testing.T) { return err == nil } finite := map[string]any{"failed": 0.0, "total": 1.0, "score": "0.1"} - for i := range 3 { + for i := range grants { if !is(fmt.Sprint("u", i), finite) || !is(fmt.Sprint("v", i), finite) { t.Fatalf("finite doubles: %s, %s", grants[i], denies[i]) } @@ -151,7 +163,7 @@ func TestADoubleAConditionMakesThatIsntFiniteFailsWhatComparesIt(t *testing.T) { "infinity": {"failed": 1.0, "total": 0.0, "score": "inf"}, "-infinity": {"failed": -1.0, "total": 0.0, "score": "-inf"}, } { - for i := range 3 { + for i := range grants { if is(fmt.Sprint("u", i), request) { t.Errorf("a grant's condition %s held for %s", grants[i], name) } @@ -162,6 +174,58 @@ func TestADoubleAConditionMakesThatIsntFiniteFailsWhatComparesIt(t *testing.T) { } } +// A map the host passes can be keyed by a double that isn't finite, a key no condition computes: +// comparing one keyed by NaN decided it equal to no map, and looking up its infinite key by one a +// condition reads found it, where `in` fails. +func TestAMapTheHostPassesKeyedByADoubleThatIsntFiniteFailsWhatComparesOrIndexesIt(t *testing.T) { + attributes := map[string]any{ + "nan": map[float64]any{math.NaN(): 1}, "inf": map[float64]any{math.Inf(1): 1}, "y": math.Inf(1), + "finite": map[float64]any{0.5: 1}, "half": 0.5, + } + for _, expr := range []string{ + `request.attributes.nan != {0.5: 1}`, `{0.5: 1} != request.attributes.nan`, `[request.attributes.nan] != [{0.5: 1}]`, + `request.attributes.inf == request.attributes.inf`, `request.attributes.inf[request.attributes.y] == 1`, + `[request.attributes.y].all(r, request.attributes.inf[r] == 1)`, + } { + if _, evaluated, _, err := rbac.EvaluateCondition(t, expr, attributes); err != nil || evaluated { + t.Errorf("%s: evaluated %t, %v", expr, evaluated, err) + } + } + for _, expr := range []string{`request.attributes.finite == {0.5: 1}`, `request.attributes.finite[request.attributes.half] == 1`} { + if held, _, _, err := rbac.EvaluateCondition(t, expr, attributes); err != nil || !held { + t.Errorf("%s: held %t, %v", expr, held, err) + } + } +} + +// A host can pass an optional value, which CEL compares by its own comparison of what it holds: +// one holding NaN compared as unequal to any, and one holding a long list was read whole, charged +// as a single value. +func TestAnOptionalTheHostPassesComparesAsWhatItHolds(t *testing.T) { + attributes := map[string]any{ + "nan": types.OptionalOf(types.Double(math.NaN())), "half": types.OptionalOf(types.Double(0.5)), + "long": types.OptionalOf(types.DefaultTypeAdapter.NativeToValue(make([]string, 100000))), + "keyed": types.OptionalOf(types.DefaultTypeAdapter.NativeToValue(map[string]any{strings.Repeat("k", 100): 1})), + "none": types.OptionalNone, + } + for _, expr := range []string{ + `request.attributes.nan != request.attributes.half`, `[request.attributes.nan] != [request.attributes.half]`, + `request.attributes.long == request.attributes.long`, + } { + if _, evaluated, _, err := rbac.EvaluateCondition(t, expr, attributes); err != nil || evaluated { + t.Errorf("%s: evaluated %t, %v", expr, evaluated, err) + } + } + for _, expr := range []string{ + `request.attributes.half == request.attributes.half`, `request.attributes.keyed == request.attributes.keyed`, + `request.attributes.none != request.attributes.half`, + } { + if held, _, _, err := rbac.EvaluateCondition(t, expr, attributes); err != nil || !held { + t.Errorf("%s: held %t, %v", expr, held, err) + } + } +} + func TestADeclaredAttributePassedAsAnotherTypeFailsAComparisonOfWhatHoldsIt(t *testing.T) { h := setup(t, func(c *rbac.Config) { c.Catalog = append(c.Catalog[:len(c.Catalog):len(c.Catalog)], guardRole) @@ -1132,6 +1196,88 @@ func TestComparingMapsDecidesAsCELDoes(t *testing.T) { } } +// Converting a finite value to a string, comparing optional values the host passes and comparing +// maps keyed by numbers of different types decide as CEL does. A conversion ran CEL's own for the +// Go type of the value, not the overload CEL picks by the type it checked, which a value CEL +// checks as of another's type needn't have: converting `(dyn([true]) + [""])[0]` made "true" where +// CEL's conversion fails, and a grant's `!=` held. +func TestFiniteValuesDecideAsCELDoes(t *testing.T) { + env, err := cel.NewEnv(cel.Variable("request", cel.MapType(cel.StringType, cel.DynType))) + if err != nil { + t.Fatal(err) + } + celEval := func(expr string, attributes map[string]any) (ref.Val, error) { + checked, iss := env.Compile(expr) + if iss.Err() != nil { + t.Fatal(iss.Err()) + } + prg, err := env.Program(checked) + if err != nil { + t.Fatal(err) + } + out, _, err := prg.Eval(map[string]any{"request": map[string]any{"attributes": attributes}}) + return out, err + } + decides := func(expr string, attributes map[string]any) { + t.Helper() + out, err := celEval(expr, attributes) + held, evaluated, _, rerr := rbac.EvaluateCondition(t, expr, attributes) + if rerr != nil { + t.Fatalf("%s: %v", expr, rerr) + } + if evaluated != (err == nil) || evaluated && held != out.Value() { + t.Errorf("%s: held %t, evaluated %t; CEL decides %v, %v", expr, held, evaluated, out, err) + } + } + strs := map[string]any{ + "string": "a", "bytes": []byte("a"), "invalid UTF-8": []byte{0xff}, "bool": true, "int": -1, "uint": uint(1), + "double": 0.5, "negative zero": math.Copysign(0, -1), "large double": 1e300, + "timestamp": time.Date(2026, 1, 2, 3, 4, 5, 6, time.UTC), "duration": 90 * time.Minute, "null": nil, + "type": types.IntType, "optional": types.OptionalOf(types.String("a")), "list": []any{"a"}, "map": map[string]any{"a": "a"}, + "string wrapper": wrapperspb.String("a"), "double wrapper": wrapperspb.Double(0.5), "bytes wrapper": wrapperspb.Bytes([]byte("a")), + "JSON string": structpb.NewStringValue("a"), "JSON number": structpb.NewNumberValue(0.5), "JSON number text": json.Number("1.5"), + } + for name, v := range strs { + for _, of := range []string{ + "%s", "dyn(%s)", `(dyn([%s]) + [""])[0]`, `(dyn([%s]) + [b""])[0]`, `(dyn([%s]) + [true])[0]`, `(dyn([%s]) + [1])[0]`, + `(dyn([%s]) + [1u])[0]`, `(dyn([%s]) + [1.0])[0]`, `(dyn([%s]) + [duration("1s")])[0]`, `(dyn([%s]) + [timestamp(0)])[0]`, + } { + conversion := "string(" + fmt.Sprintf(of, "request.attributes.v") + ")" + attributes := map[string]any{"v": v, "want": ""} + if s, err := celEval(conversion, attributes); err == nil { + attributes["want"] = s.Value() + } + for _, op := range []string{" == ", " != "} { + decides(conversion+op+"request.attributes.want", attributes) + } + } + if t.Failed() { + t.Fatalf("converting a %s", name) + } + } + values := map[string]any{ + "none": types.OptionalNone, "1": types.OptionalOf(types.Int(1)), "1u": types.OptionalOf(types.Uint(1)), + "1.0": types.OptionalOf(types.Double(1)), "2": types.OptionalOf(types.Int(2)), "null": types.OptionalOf(types.NullValue), + "[1]": types.OptionalOf(types.NewDynamicList(types.DefaultTypeAdapter, []any{1})), + "[1u]": types.OptionalOf(types.NewDynamicList(types.DefaultTypeAdapter, []any{uint(1)})), + "of 1": types.OptionalOf(types.OptionalOf(types.Int(1))), "of 1u": types.OptionalOf(types.OptionalOf(types.Uint(1))), + "bare 1": 1, "bare null": nil, + "{1}": map[int64]any{1: "a"}, "{1u}": map[uint64]any{1: "a"}, "{1.0}": map[float64]any{1: "a"}, + "{1, 2u}": map[any]any{int64(1): "a", uint64(2): "a"}, "{1.0, 2.0}": map[float64]any{1: "a", 2: "a"}, + } + for x := range values { + for y := range values { + for _, of := range []string{"%s == %s", "%s in [%s]"} { + decides(fmt.Sprintf(of, "request.attributes.x", "request.attributes.y"), map[string]any{"x": values[x], "y": values[y]}) + } + } + for _, literal := range []string{`{1: "a"}`, `{1u: "a"}`, `{1.0: "a"}`, `{1: "a", 2u: "a"}`, `{1.0: "a", 2.0: "a"}`} { + decides("request.attributes.x == "+literal, map[string]any{"x": values[x]}) + decides(literal+" == request.attributes.x", map[string]any{"x": values[x]}) + } + } +} + // iterated counts the elements a loop over a list takes. type iterated struct { traits.Lister @@ -1211,6 +1357,47 @@ func TestAKeyPlannedOtherwiseIsNoCondition(t *testing.T) { } } +// A key folded into a double that isn't finite, as another version of CEL may fold one, isn't a +// condition: no key it computes is left to fail. +func TestAKeyFoldedIntoADoubleThatIsntFiniteIsNoCondition(t *testing.T) { + fold := func(i interpreter.InterpretableV2) interpreter.InterpretableV2 { + return interpreter.NewConstValue(i.ID(), i.Eval(interpreter.EmptyActivation())) + } + for expr, isCondition := range map[string]bool{ + `{0.0 / 0.0: 1} != {0.5: 1}`: false, `{1.0 / 0.0: 1} != {0.5: 1}`: false, `{1.0 / 2.0: 1} == {0.5: 1}`: true, + } { + if err := rbac.PlanKeysOtherwise(t, expr, fold); (err == nil) != isCondition { + t.Errorf("%s with its key folded: %v", expr, err) + } + } +} + +// A conversion to a string where string() has no binding a program can find, as another version of +// CEL may bind one only once it runs, isn't a condition: it would convert a double that isn't finite +// unchecked. +func TestAConversionToAStringBoundOtherwiseIsNoCondition(t *testing.T) { + declared := func(opts ...cel.OverloadOpt) *cel.Env { + env, err := cel.NewCustomEnv(cel.Variable("x", cel.DoubleType), + cel.Function("string", cel.Overload("double_to_string", []*cel.Type{cel.DoubleType}, cel.StringType, opts...))) + if err != nil { + t.Fatal(err) + } + return env + } + converts := cel.UnaryBinding(func(v ref.Val) ref.Val { return v.ConvertToType(types.StringType) }) + for how, c := range map[string]struct { + env *cel.Env + isCondition bool + }{ + "unbound": {declared(), false}, "bound once it runs": {declared(cel.LateFunctionBinding()), false}, + "bound": {declared(converts), true}, + } { + if err := rbac.PlanIn(t, c.env, `string(x)`); (err == nil) != c.isCondition { + t.Errorf("string() %s: %v", how, err) + } + } +} + // Loading a time zone copies its name for each place it looks for it, which zoneCost doesn't count // however long the name is, so a condition reading a time in a zone the request names, in a loop, // took time growing with its length, charged as a short one. A zone is at most 64 bytes, longer diff --git a/export_test.go b/export_test.go index febe224..44bedcc 100644 --- a/export_test.go +++ b/export_test.go @@ -325,6 +325,15 @@ func PlanKeysOtherwise(t *testing.T, expr string, replan func(interpreter.Interp return err } +// PlanIn is why expr can't be planned as a condition in env, which may bind its functions as +// another version of CEL does, or nil. +func PlanIn(t *testing.T, env *cel.Env, expr string) error { + t.Helper() + checked, patterns := compiled(t, &conditions{env: env}, expr) + _, err := plan(env, checked, patterns, maxCost) + return err +} + // probing plans each uint literal as a probe, which evaluates and is charged as the literal, but // which CEL's cost tracking reads as a call of an argument none of the values it holds is of, so // it reads them all.